blob: 534ca3e73bcd2dbc7dcd3707e36d1860292fc4c7 [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 '../block_parser.dart';
import '../charcode.dart';
import '../line.dart';
import '../patterns.dart';
import '../text_parser.dart';
import '../util.dart';
import 'block_syntax.dart';
import 'ordered_list_with_checkbox_syntax.dart';
import 'unordered_list_with_checkbox_syntax.dart';
class ListItem {
const ListItem(
this.lines, {
this.taskListItemState,
});
final List<Line> lines;
final TaskListItemState? taskListItemState;
}
enum TaskListItemState { checked, unchecked }
/// Base class for both ordered and unordered lists.
abstract class ListSyntax extends BlockSyntax {
@override
bool canParse(BlockParser parser) =>
pattern.hasMatch(parser.current.content) &&
!hrPattern.hasMatch(parser.current.content);
@override
bool canEndBlock(BlockParser parser) {
// An empty list cannot interrupt a paragraph. See
// https://spec.commonmark.org/0.30/#example-285.
// Ideally, [BlockSyntax.canEndBlock] should be changed to be a method
// which accepts a [BlockParser], but this would be a breaking change,
// so we're going with this temporarily.
final match = pattern.firstMatch(parser.current.content)!;
// Allow only lists starting with 1 to interrupt paragraphs, if it is an
// ordered list. See https://spec.commonmark.org/0.30/#example-304.
// But there should be an exception for nested ordered lists, for example:
// ```
// 1. one
// 2. two
// 3. three
// 4. four
// 5. five
// ```
if (parser.parentSyntax is! ListSyntax &&
match[1] != null &&
match[1] != '1') {
return false;
}
// An empty list item cannot interrupt a paragraph. See
// https://spec.commonmark.org/0.30/#example-285
return match[2]?.isNotEmpty ?? false;
}
const ListSyntax();
/// A list of patterns that can start a valid block within a list item.
static final blocksInList = [
blockquotePattern,
headerPattern,
hrPattern,
indentPattern,
listPattern,
];
@override
Node parse(BlockParser parser) {
final match = pattern.firstMatch(parser.current.content);
final ordered = match![1] != null;
final taskListParserEnabled = this is UnorderedListWithCheckboxSyntax ||
this is OrderedListWithCheckboxSyntax;
final items = <ListItem>[];
var childLines = <Line>[];
TaskListItemState? taskListItemState;
void endItem() {
if (childLines.isNotEmpty) {
items.add(ListItem(childLines, taskListItemState: taskListItemState));
childLines = <Line>[];
}
}
String parseTaskListItem(String text) {
final pattern = RegExp(r'^ {0,3}\[([ xX])\][ \t]');
if (taskListParserEnabled && pattern.hasMatch(text)) {
return text.replaceFirstMapped(pattern, (match) {
taskListItemState = match[1] == ' '
? TaskListItemState.unchecked
: TaskListItemState.checked;
return '';
});
} else {
taskListItemState = null;
return text;
}
}
late Match? possibleMatch;
bool tryMatch(RegExp pattern) {
possibleMatch = pattern.firstMatch(parser.current.content);
return possibleMatch != null;
}
String? listMarker;
int? indent;
// In case the first number in an ordered list is not 1, use it as the
// "start".
int? startNumber;
int? blankLines;
while (!parser.isDone) {
final currentIndent = parser.current.content.indentation() +
(parser.current.tabRemaining ?? 0);
if (parser.current.isBlankLine) {
childLines.add(parser.current);
if (blankLines != null) {
blankLines++;
}
} else if (indent != null && indent <= currentIndent) {
// A list item can begin with at most one blank line. See:
// https://spec.commonmark.org/0.30/#example-280
if (blankLines != null && blankLines > 1) {
break;
}
final indentedLine = parser.current.content.dedent(indent);
childLines.add(Line(
blankLines == null
? indentedLine.text
: parseTaskListItem(indentedLine.text),
tabRemaining: indentedLine.tabRemaining,
));
} else if (tryMatch(hrPattern)) {
// Horizontal rule takes precedence to a new list item.
break;
} else if (tryMatch(listPattern)) {
blankLines = null;
final match = possibleMatch!;
final textParser = TextParser(parser.current.content);
var precedingWhitespaces = textParser.moveThroughWhitespace();
final markerStart = textParser.pos;
final digits = match[1] ?? '';
if (digits.isNotEmpty) {
startNumber ??= int.parse(digits);
textParser.advanceBy(digits.length);
}
textParser.advance();
// See https://spec.commonmark.org/0.30/#ordered-list-marker
final marker = textParser.substring(
markerStart,
textParser.pos,
);
var isBlank = true;
var contentWhitespances = 0;
var containsTab = false;
int? contentBlockStart;
if (!textParser.isDone) {
containsTab = textParser.charAt() == $tab;
// Skip the first whitespace.
textParser.advance();
contentBlockStart = textParser.pos;
if (!textParser.isDone) {
contentWhitespances = textParser.moveThroughWhitespace();
if (!textParser.isDone) {
isBlank = false;
}
}
}
// Changing the bullet or ordered list delimiter starts a new list.
if (listMarker != null && listMarker.last() != marker.last()) {
break;
}
// End the current list item and start a new one.
endItem();
// Start a new list item, the last item will be ended up outside of the
// `while` loop.
listMarker = marker;
precedingWhitespaces += digits.length + 2;
if (isBlank) {
// See https://spec.commonmark.org/0.30/#example-278.
blankLines = 1;
indent = precedingWhitespaces;
} else if (contentWhitespances >= 4) {
// See https://spec.commonmark.org/0.30/#example-270.
//
// If the list item starts with indented code, we need to _not_ count
// any indentation past the required whitespace character.
indent = precedingWhitespaces;
} else {
indent = precedingWhitespaces + contentWhitespances;
}
taskListItemState = null;
var content = contentBlockStart != null && !isBlank
? parseTaskListItem(textParser.substring(contentBlockStart))
: '';
if (content.isEmpty && containsTab) {
content = content.prependSpace(2);
}
childLines.add(Line(
content,
tabRemaining: containsTab ? 2 : null,
));
} else if (BlockSyntax.isAtBlockEnd(parser)) {
// Done with the list.
break;
} else {
// If the previous item is a blank line, this means we're done with the
// list and are starting a new top-level paragraph.
if (childLines.isNotEmpty && childLines.last.isBlankLine) {
parser.encounteredBlankLine = true;
break;
}
// Anything else is paragraph continuation text.
childLines.add(parser.current);
}
parser.advance();
}
endItem();
final itemNodes = <Element>[];
items.forEach(_removeLeadingEmptyLine);
final anyEmptyLines = _removeTrailingEmptyLines(items);
var anyEmptyLinesBetweenBlocks = false;
var containsTaskList = false;
const taskListClass = 'task-list-item';
for (final item in items) {
Element? checkboxToInsert;
if (item.taskListItemState != null) {
containsTaskList = true;
checkboxToInsert = Element.withTag('input')
..attributes['type'] = 'checkbox';
if (item.taskListItemState == TaskListItemState.checked) {
checkboxToInsert.attributes['checked'] = 'true';
}
}
final itemParser = BlockParser(item.lines, parser.document);
final children = itemParser.parseLines(parentSyntax: this);
final itemElement = checkboxToInsert == null
? Element('li', children)
: (Element('li', _addCheckbox(children, checkboxToInsert))
..attributes['class'] = taskListClass);
itemNodes.add(itemElement);
anyEmptyLinesBetweenBlocks =
anyEmptyLinesBetweenBlocks || itemParser.encounteredBlankLine;
}
// Must strip paragraph tags if the list is "tight".
// https://spec.commonmark.org/0.30/#lists
final listIsTight = !anyEmptyLines && !anyEmptyLinesBetweenBlocks;
if (listIsTight) {
// We must post-process the list items, converting any top-level paragraph
// elements to just text elements.
for (final item in itemNodes) {
final isTaskList = item.attributes['class'] == taskListClass;
final children = item.children;
if (children != null) {
Node? lastNode;
for (var i = 0; i < children.length; i++) {
final child = children[i];
if (child is Element && child.tag == 'p') {
final childContent = child.children!;
if (lastNode is Element && !isTaskList) {
childContent.insert(0, Text('\n'));
}
children
..removeAt(i)
..insertAll(i, childContent);
}
lastNode = child;
}
}
}
}
final listElement = Element(ordered ? 'ol' : 'ul', itemNodes);
if (ordered && startNumber != 1) {
listElement.attributes['start'] = '$startNumber';
}
if (containsTaskList) {
listElement.attributes['class'] = 'contains-task-list';
}
return listElement;
}
List<Node> _addCheckbox(List<Node> children, Element checkbox) {
if (children.isNotEmpty) {
final firstChild = children.first;
if (firstChild is Element && firstChild.tag == 'p') {
firstChild.children!.insert(0, checkbox);
return children;
}
}
return [checkbox, ...children];
}
void _removeLeadingEmptyLine(ListItem item) {
if (item.lines.isNotEmpty && item.lines.first.isBlankLine) {
item.lines.removeAt(0);
}
}
/// Removes any trailing empty lines and notes whether any items are separated
/// by such lines.
bool _removeTrailingEmptyLines(List<ListItem> items) {
var anyEmpty = false;
for (var i = 0; i < items.length; i++) {
if (items[i].lines.length == 1) continue;
while (items[i].lines.isNotEmpty && items[i].lines.last.isBlankLine) {
if (i < items.length - 1) {
anyEmpty = true;
}
items[i].lines.removeLast();
}
}
return anyEmpty;
}
}