blob: 75220366a313335f5c2e808e3b11d9f205d1bbbe [file] [log] [blame]
// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import '../ast.dart';
import '../charcode.dart';
import '../document.dart';
import '../inline_parser.dart';
import '../util.dart';
import 'delimiter_syntax.dart';
/// Matches links like `[blah][label]` and `[blah](url)`.
class LinkSyntax extends DelimiterSyntax {
static final _entirelyWhitespacePattern = RegExp(r'^\s*$');
final Resolver linkResolver;
LinkSyntax({
Resolver? linkResolver,
String pattern = r'\[',
int startCharacter = $lbracket,
}) : linkResolver = (linkResolver ?? ((String _, [String? __]) => null)),
super(pattern, startCharacter: startCharacter);
@override
Node? close(
InlineParser parser,
covariant SimpleDelimiter opener,
Delimiter? closer, {
String? tag,
required List<Node> Function() getChildren,
}) {
final text = parser.source.substring(opener.endPos, parser.pos);
// The current character is the `]` that closed the link text. Examine the
// next character, to determine what type of link we might have (a '('
// means a possible inline link; otherwise a possible reference link).
if (parser.pos + 1 >= parser.source.length) {
// The `]` is at the end of the document, but this may still be a valid
// shortcut reference link.
return _tryCreateReferenceLink(parser, text, getChildren: getChildren);
}
// Peek at the next character; don't advance, so as to avoid later stepping
// backward.
final char = parser.charAt(parser.pos + 1);
if (char == $lparen) {
// Maybe an inline link, like `[text](destination)`.
parser.advanceBy(1);
final leftParenIndex = parser.pos;
final inlineLink = _parseInlineLink(parser);
if (inlineLink != null) {
return _tryCreateInlineLink(
parser,
inlineLink,
getChildren: getChildren,
);
}
// At this point, we've matched `[...](`, but that `(` did not pan out to
// be an inline link. We must now check if `[...]` is simply a shortcut
// reference link.
// Reset the parser position.
parser.pos = leftParenIndex;
parser.advanceBy(-1);
return _tryCreateReferenceLink(parser, text, getChildren: getChildren);
}
if (char == $lbracket) {
parser.advanceBy(1);
// At this point, we've matched `[...][`. Maybe a *full* reference link,
// like `[foo][bar]` or a *collapsed* reference link, like `[foo][]`.
if (parser.pos + 1 < parser.source.length &&
parser.charAt(parser.pos + 1) == $rbracket) {
// That opening `[` is not actually part of the link. Maybe a
// *shortcut* reference link (followed by a `[`).
parser.advanceBy(1);
return _tryCreateReferenceLink(parser, text, getChildren: getChildren);
}
final label = _parseReferenceLinkLabel(parser);
if (label != null) {
return _tryCreateReferenceLink(parser, label, getChildren: getChildren);
}
return null;
}
// The link text (inside `[...]`) was not followed with a opening `(` nor
// an opening `[`. Perhaps just a simple shortcut reference link (`[...]`).
return _tryCreateReferenceLink(parser, text, getChildren: getChildren);
}
/// Resolve a possible reference link.
///
/// Uses [linkReferences], [linkResolver], and [createNode] to try to
/// resolve [label] into a [Node]. If [label] is defined in
/// [linkReferences] or can be resolved by [linkResolver], returns a [Node]
/// that links to the resolved URL.
///
/// Otherwise, returns `null`.
///
/// [label] does not need to be normalized.
Node? _resolveReferenceLink(
String label,
Map<String, LinkReference> linkReferences, {
required List<Node> Function() getChildren,
}) {
final linkReference = linkReferences[normalizeLinkLabel(label)];
if (linkReference != null) {
return createNode(
linkReference.destination,
linkReference.title,
getChildren: getChildren,
);
} else {
// This link has no reference definition. But we allow users of the
// library to specify a custom resolver function ([linkResolver]) that
// may choose to handle this. Otherwise, it's just treated as plain
// text.
// Normally, label text does not get parsed as inline Markdown. However,
// for the benefit of the link resolver, we need to at least escape
// brackets, so that, e.g. a link resolver can receive `[\[\]]` as `[]`.
final resolved = linkResolver(label
.replaceAll(r'\\', r'\')
.replaceAll(r'\[', '[')
.replaceAll(r'\]', ']'));
if (resolved != null) {
getChildren();
}
return resolved;
}
}
/// Create the node represented by a Markdown link.
Node createNode(
String destination,
String? title, {
required List<Node> Function() getChildren,
}) {
final children = getChildren();
final element = Element('a', children);
element.attributes['href'] = escapeAttribute(destination);
if (title != null && title.isNotEmpty) {
element.attributes['title'] = escapeAttribute(title);
}
return element;
}
/// Tries to create a reference link node.
///
/// Returns the link if it was successfully created, `null` otherwise.
Node? _tryCreateReferenceLink(
InlineParser parser,
String label, {
required List<Node> Function() getChildren,
}) {
return _resolveReferenceLink(
label,
parser.document.linkReferences,
getChildren: getChildren,
);
}
// Tries to create an inline link node.
//
/// Returns the link if it was successfully created, `null` otherwise.
Node _tryCreateInlineLink(
InlineParser parser,
InlineLink link, {
required List<Node> Function() getChildren,
}) {
return createNode(link.destination, link.title, getChildren: getChildren);
}
/// Parse a reference link label at the current position.
///
/// Specifically, [parser.pos] is expected to be pointing at the `[` which
/// opens the link label.
///
/// Returns the label if it could be parsed, or `null` if not.
String? _parseReferenceLinkLabel(InlineParser parser) {
// Walk past the opening `[`.
parser.advanceBy(1);
if (parser.isDone) return null;
final buffer = StringBuffer();
while (true) {
final char = parser.charAt(parser.pos);
if (char == $backslash) {
parser.advanceBy(1);
final next = parser.charAt(parser.pos);
if (next != $backslash && next != $rbracket) {
buffer.writeCharCode(char);
}
buffer.writeCharCode(next);
} else if (char == $lbracket) {
return null;
} else if (char == $rbracket) {
break;
} else {
buffer.writeCharCode(char);
}
parser.advanceBy(1);
if (parser.isDone) return null;
// TODO(srawlins): only check 999 characters, for performance reasons?
}
final label = buffer.toString();
// A link label must contain at least one non-whitespace character.
if (_entirelyWhitespacePattern.hasMatch(label)) return null;
return label;
}
/// Parse an inline [InlineLink] at the current position.
///
/// At this point, we have parsed a link's (or image's) opening `[`, and then
/// a matching closing `]`, and [parser.pos] is pointing at an opening `(`.
/// This method will then attempt to parse a link destination wrapped in `<>`,
/// such as `(<http://url>)`, or a bare link destination, such as
/// `(http://url)`, or a link destination with a title, such as
/// `(http://url "title")`.
///
/// Returns the [InlineLink] if one was parsed, or `null` if not.
InlineLink? _parseInlineLink(InlineParser parser) {
// Start walking to the character just after the opening `(`.
parser.advanceBy(1);
_moveThroughWhitespace(parser);
if (parser.isDone) return null; // EOF. Not a link.
if (parser.charAt(parser.pos) == $lt) {
// Maybe a `<...>`-enclosed link destination.
return _parseInlineBracketedLink(parser);
} else {
return _parseInlineBareDestinationLink(parser);
}
}
/// Parse an inline link with a bracketed destination (a destination wrapped
/// in `<...>`). The current position of the parser must be the first
/// character of the destination.
///
/// Returns the link if it was successfully created, `null` otherwise.
InlineLink? _parseInlineBracketedLink(InlineParser parser) {
parser.advanceBy(1);
final buffer = StringBuffer();
while (true) {
final char = parser.charAt(parser.pos);
if (char == $backslash) {
parser.advanceBy(1);
final next = parser.charAt(parser.pos);
// TODO: Follow the backslash spec better here.
// http://spec.commonmark.org/0.29/#backslash-escapes
if (next != $backslash && next != $gt) {
buffer.writeCharCode(char);
}
buffer.writeCharCode(next);
} else if (char == $lf || char == $cr || char == $ff) {
// Not a link (no line breaks allowed within `<...>`).
return null;
} else if (char == $space) {
buffer.write('%20');
} else if (char == $gt) {
break;
} else {
buffer.writeCharCode(char);
}
parser.advanceBy(1);
if (parser.isDone) return null;
}
final destination = buffer.toString();
parser.advanceBy(1);
final char = parser.charAt(parser.pos);
if (char == $space || char == $lf || char == $cr || char == $ff) {
final title = _parseTitle(parser);
if (title == null &&
(parser.isDone || parser.charAt(parser.pos) != $rparen)) {
// This looked like an inline link, until we found this $space
// followed by mystery characters; no longer a link.
return null;
}
return InlineLink(destination, title: title);
} else if (char == $rparen) {
return InlineLink(destination);
} else {
// We parsed something like `[foo](<url>X`. Not a link.
return null;
}
}
/// Parse an inline link with a "bare" destination (a destination _not_
/// wrapped in `<...>`). The current position of the parser must be the first
/// character of the destination.
///
/// Returns the link if it was successfully created, `null` otherwise.
InlineLink? _parseInlineBareDestinationLink(InlineParser parser) {
// According to
// [CommonMark](http://spec.commonmark.org/0.28/#link-destination):
//
// > A link destination consists of [...] a nonempty sequence of
// > characters [...], and includes parentheses only if (a) they are
// > backslash-escaped or (b) they are part of a balanced pair of
// > unescaped parentheses.
//
// We need to count the open parens. We start with 1 for the paren that
// opened the destination.
var parenCount = 1;
final buffer = StringBuffer();
while (true) {
final char = parser.charAt(parser.pos);
switch (char) {
case $backslash:
parser.advanceBy(1);
if (parser.isDone) return null; // EOF. Not a link.
final next = parser.charAt(parser.pos);
// Parentheses may be escaped.
//
// http://spec.commonmark.org/0.28/#example-467
if (next != $backslash && next != $lparen && next != $rparen) {
buffer.writeCharCode(char);
}
buffer.writeCharCode(next);
break;
case $space:
case $lf:
case $cr:
case $ff:
final destination = buffer.toString();
final title = _parseTitle(parser);
if (title == null &&
(parser.isDone || parser.charAt(parser.pos) != $rparen)) {
// This looked like an inline link, until we found this $space
// followed by mystery characters; no longer a link.
return null;
}
// [_parseTitle] made sure the title was follwed by a closing `)`
// (but it's up to the code here to examine the balance of
// parentheses).
parenCount--;
if (parenCount == 0) {
return InlineLink(destination, title: title);
}
break;
case $lparen:
parenCount++;
buffer.writeCharCode(char);
break;
case $rparen:
parenCount--;
if (parenCount == 0) {
final destination = buffer.toString();
return InlineLink(destination);
}
buffer.writeCharCode(char);
break;
default:
buffer.writeCharCode(char);
}
parser.advanceBy(1);
if (parser.isDone) return null; // EOF. Not a link.
}
}
// Walk the parser forward through any whitespace.
void _moveThroughWhitespace(InlineParser parser) {
while (!parser.isDone) {
final char = parser.charAt(parser.pos);
if (char != $space &&
char != $tab &&
char != $lf &&
char != $vt &&
char != $cr &&
char != $ff) {
return;
}
parser.advanceBy(1);
}
}
/// Parses a link title in [parser] at it's current position. The parser's
/// current position should be a whitespace character that followed a link
/// destination.
///
/// Returns the title if it was successfully parsed, `null` otherwise.
String? _parseTitle(InlineParser parser) {
_moveThroughWhitespace(parser);
if (parser.isDone) return null;
// The whitespace should be followed by a title delimiter.
final delimiter = parser.charAt(parser.pos);
if (delimiter != $apostrophe &&
delimiter != $quote &&
delimiter != $lparen) {
return null;
}
final closeDelimiter = delimiter == $lparen ? $rparen : delimiter;
parser.advanceBy(1);
// Now we look for an un-escaped closing delimiter.
final buffer = StringBuffer();
while (true) {
final char = parser.charAt(parser.pos);
if (char == $backslash) {
parser.advanceBy(1);
final next = parser.charAt(parser.pos);
if (next != $backslash && next != closeDelimiter) {
buffer.writeCharCode(char);
}
buffer.writeCharCode(next);
} else if (char == closeDelimiter) {
break;
} else {
buffer.writeCharCode(char);
}
parser.advanceBy(1);
if (parser.isDone) return null;
}
final title = buffer.toString();
// Advance past the closing delimiter.
parser.advanceBy(1);
if (parser.isDone) return null;
_moveThroughWhitespace(parser);
if (parser.isDone) return null;
if (parser.charAt(parser.pos) != $rparen) return null;
return title;
}
}
class InlineLink {
final String destination;
final String? title;
InlineLink(this.destination, {this.title});
}