| // Copyright (c) 2023, 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 'charcode.dart'; |
| import 'text_parser.dart'; |
| import 'util.dart'; |
| |
| class LinkParser extends TextParser { |
| /// If there is a valid link formed. |
| bool get valid => _valid; |
| bool _valid = false; |
| |
| /// Link label. |
| String? get label => _label; |
| String? _label; |
| |
| /// Link destination. |
| String? get destination => _destination; |
| String? _destination; |
| |
| /// Link title. |
| String? get title => _title; |
| String? _title; |
| |
| LinkParser(super.source); |
| |
| /// How many lines of the [source] have been consumed by link reference |
| /// definition. |
| int get unconsumedLines => _unconsumedLines; |
| int _unconsumedLines = 0; |
| |
| /// Parses [source] to a link reference definition. |
| void parseDefinition() { |
| if (!parseLabel() || isDone || charAt() != $colon) { |
| return; |
| } |
| |
| // Advance to the next character after the colon. |
| advance(); |
| if (!_parseDestination()) { |
| return; |
| } |
| |
| var precedingWhitespaces = moveThroughWhitespace(); |
| if (isDone) { |
| _valid = true; |
| return; |
| } |
| |
| final multiline = charAt() == $lf; |
| precedingWhitespaces += moveThroughWhitespace(multiLine: true); |
| |
| // The title must be preceded by whitespaces. |
| if (precedingWhitespaces == 0 || isDone) { |
| _valid = isDone; |
| return; |
| } |
| |
| final hasValidTitle = _parseTitle(); |
| // For example: `[foo]: <bar> "baz` is a invalid definition, but this one is |
| // valid: |
| // ``` |
| // [foo]: <bar> |
| // "baz |
| // ``` |
| if (!hasValidTitle && !multiline) { |
| return; |
| } |
| |
| if (hasValidTitle) { |
| moveThroughWhitespace(); |
| if (!isDone && charAt() != $lf) { |
| // It is not a valid definition if the title is followed by |
| // non-whitespace characters, for example: `[foo]: <bar> "baz" hello`. |
| // See https://spec.commonmark.org/0.30/#example-209. |
| if (!multiline) { |
| return; |
| } |
| // But it is a valid link reference definition if this definition is |
| // multiline, see https://spec.commonmark.org/0.30/#example-210. |
| _title = null; |
| } |
| } |
| |
| final linesUnconsumed = source.substring(pos).split('\n'); |
| if (linesUnconsumed.isNotEmpty && linesUnconsumed.first.isBlank) { |
| linesUnconsumed.removeAt(0); |
| } |
| _unconsumedLines = linesUnconsumed.length; |
| |
| _valid = true; |
| } |
| |
| /// Parses the link label, returns `true` if there is a valid link label. |
| bool parseLabel() { |
| moveThroughWhitespace(multiLine: true); |
| |
| if (length - pos < 2) { |
| return false; |
| } |
| |
| if (charAt() != $lbracket) { |
| return false; |
| } |
| |
| // Advance past the opening `[`. |
| advance(); |
| final start = pos; |
| |
| // A link label can have at most 999 characters inside the square brackets. |
| // See https://spec.commonmark.org/0.30/#link-label. |
| var maxLoop = 999; |
| while (true) { |
| if (maxLoop-- < 0) { |
| return false; |
| } |
| final char = charAt(pos); |
| if (char == $backslash) { |
| advance(); |
| } else if (char == $lbracket) { |
| return false; |
| } else if (char == $rbracket) { |
| break; |
| } |
| advance(); |
| if (isDone) { |
| return false; |
| } |
| } |
| |
| final text = substring(start, pos); |
| if (text.isBlank) { |
| return false; |
| } |
| |
| // Advance past the closing `]`. |
| advance(); |
| _label = text; |
| return true; |
| } |
| |
| /// Parses the link destination, returns `true` there is a valid link |
| /// destination. |
| bool _parseDestination() { |
| moveThroughWhitespace(multiLine: true); |
| if (isDone) { |
| return false; |
| } |
| |
| final isValidDestination = charAt() == $lt |
| ? _parseBracketedDestination() |
| : _parseBareDestination(); |
| |
| return isValidDestination; |
| } |
| |
| /// Parses bracketed destinations (destinations wrapped in `<...>`). The |
| /// current position of the parser must be the first character of the |
| /// destination. |
| /// |
| /// Returns `true` if there is a valid link destination. |
| bool _parseBracketedDestination() { |
| // Walk past the opening `<`. |
| advance(); |
| |
| final start = pos; |
| while (true) { |
| final char = charAt(); |
| if (char == $backslash) { |
| advance(); |
| } else if (char == $lf || char == $cr || char == $ff) { |
| return false; |
| } else if (char == $gt) { |
| break; |
| } |
| advance(); |
| if (isDone) { |
| return false; |
| } |
| } |
| |
| _destination = substring(start, pos); |
| |
| // Advance past the closing `>`. |
| advance(); |
| return true; |
| } |
| |
| /// Parse "bare" destinations (destinations _not_ wrapped in `<...>`). The |
| /// current position of the parser must be the first character of the |
| /// destination. |
| /// |
| /// Returns `true` if there is a valid link destination. |
| bool _parseBareDestination() { |
| var parenCount = 0; |
| final start = pos; |
| |
| while (true) { |
| final char = charAt(); |
| if (char == $backslash) { |
| advance(); |
| } else if (char == $space || char == $lf || char == $cr || char == $ff) { |
| break; |
| } else if (char == $lparen) { |
| parenCount++; |
| } else if (char == $rparen) { |
| parenCount--; |
| if (parenCount == 0) { |
| advance(); |
| break; |
| } |
| } |
| advance(); |
| |
| // There is no ending delimiter, so `isDone` also means it is at the end |
| // of a link destination. |
| if (isDone) { |
| break; |
| } |
| } |
| |
| _destination = substring(start, pos); |
| return true; |
| } |
| |
| /// Parses the **optional** link title, returns `true` if there is a valid |
| /// link title. |
| bool _parseTitle() { |
| // See: https://spec.commonmark.org/0.30/#link-title |
| // The whitespace should be followed by a title delimiter. |
| final delimiter = charAt(); |
| if (delimiter != $apostrophe && |
| delimiter != $quote && |
| delimiter != $lparen) { |
| return false; |
| } |
| |
| final closeDelimiter = delimiter == $lparen ? $rparen : delimiter; |
| advance(); |
| if (isDone) { |
| return false; |
| } |
| final start = pos; |
| |
| // Looking for an un-escaped closing delimiter. |
| while (true) { |
| final char = charAt(); |
| if (char == $backslash) { |
| advance(); |
| } else if (char == closeDelimiter) { |
| break; |
| } |
| advance(); |
| if (isDone) { |
| return false; |
| } |
| } |
| |
| if (isDone) { |
| return false; |
| } |
| |
| _title = substring(start, pos); |
| |
| // Advance past the closing delimiter. |
| advance(); |
| return true; |
| } |
| } |