Repository: akvelon/flutter-code-editor
Pull request #57 (2022-09-05 11:39:26)

Add HighlightFoldableBlockParser, add tests for Java
Addresses #44 


Patch:
diff --git a/lib/flutter_code_editor.dart b/lib/flutter_code_editor.dart
index cd8cab2..b95d035 100644
--- a/lib/flutter_code_editor.dart
+++ b/lib/flutter_code_editor.dart
@@ -16,6 +16,11 @@ export 'src/code_modifiers/tab_code_modifier.dart';
 export 'src/code_theme/code_theme.dart';
 export 'src/code_theme/code_theme_data.dart';
 
+export 'src/folding/foldable_block.dart';
+export 'src/folding/foldable_block_type.dart';
+export 'src/folding/invalid_foldable_block.dart';
+export 'src/folding/parsers/highlight.dart';
+
 export 'src/line_numbers/line_number_controller.dart';
 export 'src/line_numbers/line_number_style.dart';
 
diff --git a/lib/src/folding/foldable_block.dart b/lib/src/folding/foldable_block.dart
new file mode 100644
index 0000000..60bd2cf
--- /dev/null
+++ b/lib/src/folding/foldable_block.dart
@@ -0,0 +1,28 @@
+import 'package:equatable/equatable.dart';
+
+import 'foldable_block_type.dart';
+
+class FoldableBlock with EquatableMixin {
+  final int startLine;
+  final int endLine;
+  final FoldableBlockType type;
+
+  const FoldableBlock({
+    required this.startLine,
+    required this.endLine,
+    required this.type,
+  });
+
+  @override
+  List<Object?> get props => [
+        startLine,
+        endLine,
+        type,
+      ];
+}
+
+extension FoldableBlockList on List<FoldableBlock> {
+  void sortByStartLine() {
+    sort((a, b) => a.startLine - b.startLine);
+  }
+}
diff --git a/lib/src/folding/foldable_block_type.dart b/lib/src/folding/foldable_block_type.dart
new file mode 100644
index 0000000..c962985
--- /dev/null
+++ b/lib/src/folding/foldable_block_type.dart
@@ -0,0 +1,14 @@
+enum FoldableBlockType {
+  /// { }
+  braces,
+
+  /// [ ]
+  brackets,
+
+  /// ( )
+  parentheses,
+
+  multilineComment,
+  singleLineComment,
+  imports,
+}
diff --git a/lib/src/folding/invalid_foldable_block.dart b/lib/src/folding/invalid_foldable_block.dart
new file mode 100644
index 0000000..cb26894
--- /dev/null
+++ b/lib/src/folding/invalid_foldable_block.dart
@@ -0,0 +1,34 @@
+import 'package:equatable/equatable.dart';
+
+import 'foldable_block.dart';
+import 'foldable_block_type.dart';
+
+/// Anything that failed to be a [FoldableBlock] due to missing
+/// the opposite pair character.
+class InvalidFoldableBlock with EquatableMixin {
+  final int? startLine;
+  final int? endLine;
+  final FoldableBlockType type;
+
+  const InvalidFoldableBlock({
+    required this.type,
+    this.startLine,
+    this.endLine,
+  });
+
+  @override
+  List<Object?> get props => [
+        startLine,
+        endLine,
+        type,
+      ];
+}
+
+extension InvalidFoldableBlockList on List<InvalidFoldableBlock> {
+  void sortByStartOrEndLine() {
+    sort(
+      (a, b) =>
+          (a.startLine ?? a.endLine ?? 0) - (b.startLine ?? b.endLine ?? 0),
+    );
+  }
+}
diff --git a/lib/src/folding/parsers/abstract.dart b/lib/src/folding/parsers/abstract.dart
new file mode 100644
index 0000000..0898941
--- /dev/null
+++ b/lib/src/folding/parsers/abstract.dart
@@ -0,0 +1,244 @@
+import 'package:collection/collection.dart';
+import 'package:meta/meta.dart';
+
+import '../foldable_block.dart';
+import '../foldable_block_type.dart';
+import '../invalid_foldable_block.dart';
+import 'line_semantics.dart';
+
+/// A base class for parsers that go through some representation
+/// of code to parse blocks from it.
+///
+/// This object accumulates data in its fields as the parsing progresses.
+class AbstractFoldableBlockParser {
+  /// Valid blocks by the end of the parsing.
+  final blocks = <FoldableBlock>[];
+
+  /// Invalid blocks by the end of the parsing.
+  /// These can be used for error highlighting.
+  final invalidBlocks = <InvalidFoldableBlock>[];
+
+  /// The stack of blocks open at any moment as the parsing progresses.
+  final _startedBlocks = <_StartedFoldableBlock>[];
+
+  /// A semantics for each line.
+  /// Used when grouping sequential imports and single line comments.
+  final _linesWithSemantics = <_LineWithSemantics>[];
+
+  /// Records that a block has started at [line].
+  @protected
+  void startBlock(int line, FoldableBlockType type) {
+    _startedBlocks.add(
+      _StartedFoldableBlock(line: line, type: type),
+    );
+  }
+
+  /// Checks if the block has started and it is its turn to terminate at [line].
+  /// Creates a [FoldableBlock] if alright and [InvalidFoldableBlock] otherwise.
+  @protected
+  void endBlock(int line, FoldableBlockType type) {
+    final started = _startedBlocks.lastOrNull;
+
+    if (started == null || started.type != type) {
+      invalidBlocks.add(
+        InvalidFoldableBlock(
+          endLine: line,
+          type: type,
+        ),
+      );
+      return;
+    }
+
+    if (line != started.line) {
+      blocks.add(
+        FoldableBlock(
+          startLine: started.line,
+          endLine: line,
+          type: type,
+        ),
+      );
+    }
+
+    _startedBlocks.removeLast();
+  }
+
+  /// Records the semantic for the given line.
+  /// Must be called in ascending order for lines.
+  @protected
+  void submitLine(int index, LineSemantics semantics) {
+    _linesWithSemantics.add(
+      _LineWithSemantics(
+        index: index,
+        semantics: semantics,
+      ),
+    );
+  }
+
+  /// Terminates a sequence of import lines if one was started above.
+  ///
+  /// An import block is a block that:
+  /// - Starts with import or possibleImport line.
+  /// - Ends with import or possibleImport line.
+  /// - Has at least one import line.
+  /// - Only can have import, possibleImport, blank, singleLineComment lines.
+  ///
+  /// [lineIndex] is the current line that does not have import statements.
+  /// So the latest possible import line is [lineIndex] - 1.
+  @protected
+  void endImportSequenceIfAny(int lineIndex) {
+    // We can have many possibleImport lines that add to the block range,
+    // but at least one true import line is required for the block.
+    bool importFound = false;
+    int? first; // Min line index of the block.
+    int? last; // Max line index of the block.
+
+    // Called if the block upper bound is found. Adds if it is a valid block.
+    void addIfFound() {
+      if (first == null || !importFound || last == first) {
+        return;
+      }
+
+      blocks.add(
+        FoldableBlock(
+          startLine: first,
+          endLine: last!,
+          type: FoldableBlockType.imports,
+        ),
+      );
+    }
+
+    int i = lineIndex;
+
+    for (final line in _linesWithSemantics.reversed) {
+      if (line.index != --i) {
+        // A gap in submitted lines. This happens with uninterpreted blocks.
+        // They should break the block.
+        addIfFound();
+        return;
+      }
+
+      if (line.semantics == LineSemantics.import) {
+        importFound = true;
+      }
+
+      switch (line.semantics) {
+        case LineSemantics.import:
+        case LineSemantics.possibleImport:
+          first = line.index;
+          last ??= line.index;
+          break;
+
+        case LineSemantics.singleLineCommentAndImportTerminator:
+          addIfFound();
+          return;
+
+        case LineSemantics.blank:
+        case LineSemantics.singleLineComment:
+        case LineSemantics.singleLineCommentTerminator:
+          break; // no op
+      }
+    }
+
+    addIfFound();
+  }
+
+  /// Terminates a single-line comment sequence if one was started above.
+  ///
+  /// A single-line comment block is a block that:
+  /// - Starts and ends with lines with [LineSemantics.singleLineComment].
+  /// - Can only contain [LineSemantics.singleLineComment]
+  ///   and [LineSemantics.blank] inside.
+  ///
+  /// [lineIndex] is the current line that is not
+  /// [LineSemantics.singleLineComment].
+  /// So the latest possible block line is [lineIndex] - 1.
+  @protected
+  void endCommentSequenceIfAny(int lineIndex) {
+    int? first; // Min line index of the block.
+    int? last; // Max line index of the block.
+
+    // Called if the block upper bound is found. Adds if it is a valid block.
+    void addIfFound() {
+      if (first == null || last == first) {
+        return;
+      }
+
+      blocks.add(
+        FoldableBlock(
+          startLine: first,
+          endLine: last!,
+          type: FoldableBlockType.singleLineComment,
+        ),
+      );
+    }
+
+    int i = lineIndex;
+
+    for (final line in _linesWithSemantics.reversed) {
+      if (line.index != --i) {
+        // A gap in submitted lines. This happens with uninterpreted blocks.
+        // They should break the block.
+        addIfFound();
+        return;
+      }
+
+      switch (line.semantics) {
+        case LineSemantics.singleLineComment:
+          first = line.index;
+          last ??= line.index;
+          break;
+
+        case LineSemantics.blank:
+          break;
+
+        default: // ignore: no_default_cases
+          addIfFound();
+          return;
+      }
+    }
+
+    addIfFound();
+  }
+
+  /// Terminates all open blocks.
+  /// Called when the parsing is otherwise finished.
+  @protected
+  void finalize() {
+    final lastLinePlusOne = (_linesWithSemantics.lastOrNull?.index ?? 0) + 1;
+    endCommentSequenceIfAny(lastLinePlusOne);
+    endImportSequenceIfAny(lastLinePlusOne);
+
+    for (final started in _startedBlocks) {
+      invalidBlocks.add(
+        InvalidFoldableBlock(
+          startLine: started.line,
+          type: started.type,
+        ),
+      );
+    }
+    _startedBlocks.clear();
+
+    blocks.sortByStartLine();
+    invalidBlocks.sortByStartOrEndLine();
+  }
+}
+
+class _StartedFoldableBlock {
+  final int line;
+  final FoldableBlockType type;
+
+  const _StartedFoldableBlock({
+    required this.line,
+    required this.type,
+  });
+}
+
+class _LineWithSemantics {
+  final int index;
+  final LineSemantics semantics;
+
+  const _LineWithSemantics({
+    required this.index,
+    required this.semantics,
+  });
+}
diff --git a/lib/src/folding/parsers/highlight.dart b/lib/src/folding/parsers/highlight.dart
new file mode 100644
index 0000000..89e705d
--- /dev/null
+++ b/lib/src/folding/parsers/highlight.dart
@@ -0,0 +1,224 @@
+import 'package:charcode/ascii.dart';
+import 'package:collection/collection.dart';
+import 'package:highlight/highlight_core.dart';
+
+import '../../highlight/keyword_semantics.dart';
+import '../../highlight/node.dart';
+import '../../highlight/node_classes.dart';
+import '../foldable_block_type.dart';
+import 'abstract.dart';
+import 'line_semantics.dart';
+
+/// A parser for foldable blocks from highlight's [Result].
+class HighlightFoldableBlockParser extends AbstractFoldableBlockParser {
+  int _lineIndex = 0;
+
+  /// If in the current line we found any keyword with import semantics.
+  bool _foundImport = false;
+
+  /// If in the current line we found any keyword that may or may not
+  /// have import semantics depending on the context.
+  bool _foundPossibleImport = false;
+
+  /// If in the current line we found anything that terminates
+  /// an import sequence.
+  bool _foundImportTerminator = false;
+
+  /// If in the current line we found a single-line comment.
+  bool _foundSingleLineComment = false;
+
+  /// If in the current line we found a non-whitespace character that is
+  /// not a comment.
+  bool _foundNonWhitespace = false;
+
+  void parse(Result highlighted) {
+    if (highlighted.nodes != null) {
+      _processNodes(highlighted.nodes!);
+    }
+
+    _submitLine(); // In case the last one did not end with '\n'.
+    finalize();
+  }
+
+  void _processNodes(List<Node> nodes) {
+    nodes.forEach(_processNode);
+  }
+
+  void _processNode(Node node) {
+    switch (node.className) {
+      case NodeClasses.comment:
+        _processComment(node);
+        break;
+
+      case NodeClasses.keyword:
+        _processKeyword(node);
+        break;
+
+      case NodeClasses.string:
+        _processString(node);
+        break;
+
+      default:
+        _processDefault(node);
+    }
+  }
+
+  void _processComment(Node node) {
+    final newlineCount = node.getNewlineCount();
+
+    if (newlineCount == 0) {
+      _foundSingleLineComment = true;
+      return;
+    }
+
+    _foundImportTerminator = true;
+    _submitLine();
+
+    startBlock(_lineIndex, FoldableBlockType.multilineComment);
+
+    _lineIndex += newlineCount;
+    endBlock(_lineIndex, FoldableBlockType.multilineComment);
+  }
+
+  void _processKeyword(Node node) {
+    final child = node.children?.firstOrNull;
+    if (child == null) {
+      return;
+    }
+
+    final semantics = node.keywordSemantics;
+
+    switch (semantics) {
+      case KeywordSemantics.import:
+        _foundImport = true;
+        break;
+      case KeywordSemantics.possibleImport:
+        _foundPossibleImport = true;
+        break;
+      case null:
+        _foundImportTerminator = true;
+        break;
+    }
+  }
+
+  void _processString(Node node) {
+    final newlineCount = node.getNewlineCount();
+
+    _foundNonWhitespace = true;
+    if (newlineCount > 0) {
+      _foundImportTerminator = true;
+      _submitLine();
+      _clearLineFlags();
+    }
+
+    _lineIndex += newlineCount;
+  }
+
+  void _processDefault(Node node) {
+    _processDefaultValue(node);
+
+    if (node.children != null) {
+      _processNodes(node.children!);
+    }
+  }
+
+  /// Except: comment, keyword, string
+  void _processDefaultValue(Node node) {
+    final value = node.value;
+    if (value == null) {
+      return;
+    }
+
+    for (final code in value.runes) {
+      switch (code) {
+        case $space:
+        case $tab:
+        case $cr:
+        case $lf:
+          break;
+        default:
+          _foundNonWhitespace = true;
+      }
+
+      switch (code) {
+        case $lf: // Newline
+          _submitLine();
+          _clearLineFlags();
+          _lineIndex++;
+          break;
+
+        case $openParenthesis: // (
+          startBlock(_lineIndex, FoldableBlockType.parentheses);
+          break;
+
+        case $closeParenthesis: // )
+          endBlock(_lineIndex, FoldableBlockType.parentheses);
+          break;
+
+        case $openBracket: // [
+          startBlock(_lineIndex, FoldableBlockType.brackets);
+          break;
+
+        case $closeBracket: // ]
+          endBlock(_lineIndex, FoldableBlockType.brackets);
+          break;
+
+        case $openBrace: // {
+          startBlock(_lineIndex, FoldableBlockType.braces);
+          break;
+
+        case $closeBrace: // }
+          endBlock(_lineIndex, FoldableBlockType.braces);
+          break;
+      }
+    }
+  }
+
+  void _submitLine() {
+    if (_foundImport) {
+      _endCommentSequence();
+      _submitLineSemantics(LineSemantics.import);
+      return;
+    }
+
+    if (_foundImportTerminator) {
+      _endCommentSequence();
+      endImportSequenceIfAny(_lineIndex);
+      _submitLineSemantics(LineSemantics.singleLineCommentAndImportTerminator);
+      return;
+    }
+
+    if (_foundPossibleImport) {
+      _endCommentSequence();
+      _submitLineSemantics(LineSemantics.possibleImport);
+      return;
+    }
+
+    if (_foundSingleLineComment) {
+      _submitLineSemantics(LineSemantics.singleLineComment);
+      return;
+    }
+
+    if (_foundNonWhitespace) {
+      _endCommentSequence();
+      _submitLineSemantics(LineSemantics.singleLineCommentTerminator);
+      return;
+    }
+
+    _submitLineSemantics(LineSemantics.blank);
+  }
+
+  void _clearLineFlags() {
+    _foundImport = false;
+    _foundPossibleImport = false;
+    _foundImportTerminator = false;
+    _foundSingleLineComment = false;
+    _foundNonWhitespace = false;
+  }
+
+  void _endCommentSequence() => endCommentSequenceIfAny(_lineIndex);
+
+  void _submitLineSemantics(LineSemantics semantics) {
+    submitLine(_lineIndex, semantics);
+  }
+}
diff --git a/lib/src/folding/parsers/line_semantics.dart b/lib/src/folding/parsers/line_semantics.dart
new file mode 100644
index 0000000..368436a
--- /dev/null
+++ b/lib/src/folding/parsers/line_semantics.dart
@@ -0,0 +1,34 @@
+import '../../highlight/keyword_semantics.dart';
+
+/// The semantics of a line from the perspective of a sequence
+/// of similar lines that can form a foldable block.
+enum LineSemantics {
+  /// A blank line or line with only whitespace characters.
+  blank,
+
+  /// A line with only a single-line comment and possibly
+  /// whitespace characters before it.
+  singleLineComment,
+
+  /// A line containing any keyword with [KeywordSemantics.import]
+  /// and possibly other entities that do not terminate an import block.
+  import,
+
+  /// A line containing any keyword with [KeywordSemantics.possibleImport]
+  /// but not [KeywordSemantics.import], and possibly other entities
+  /// that do not terminate an import block.
+  possibleImport,
+
+  /// Content that can terminate a single-line comment sequence
+  /// and an import sequence.
+  /// These are:
+  /// - Keywords without [KeywordSemantics.import] or
+  ///   [KeywordSemantics.possibleImport].
+  /// - Multiline comments.
+  singleLineCommentAndImportTerminator,
+
+  /// Content that can terminate a single-line comment sequence
+  /// but cannot terminate an import sequence.
+  /// These are any non-whitespace characters.
+  singleLineCommentTerminator,
+}
diff --git a/lib/src/highlight/keyword_semantics.dart b/lib/src/highlight/keyword_semantics.dart
new file mode 100644
index 0000000..6bf5f67
--- /dev/null
+++ b/lib/src/highlight/keyword_semantics.dart
@@ -0,0 +1,10 @@
+enum KeywordSemantics {
+  /// A keyword that can only be used in an import statement.
+  import,
+
+  /// A keyword that can be used in both import statement and a non-import
+  /// statement.
+  /// Ex.: 'as' in Dart can be used both in `import 'dart:ui' as ui;`
+  /// and in `final n = value as int;`
+  possibleImport,
+}
diff --git a/lib/src/highlight/node.dart b/lib/src/highlight/node.dart
new file mode 100644
index 0000000..2f4f31e
--- /dev/null
+++ b/lib/src/highlight/node.dart
@@ -0,0 +1,54 @@
+import 'package:collection/collection.dart';
+import 'package:highlight/highlight_core.dart';
+
+import 'keyword_semantics.dart';
+import 'node_classes.dart';
+
+extension MyNode on Node {
+  int getValueNewlineCount() => '\n'.allMatches(value ?? '').length;
+
+  /// The number of newline characters in this node's [value] and its
+  /// descendants.
+  int getNewlineCount() {
+    int result = '\n'.allMatches(value ?? '').length;
+
+    for (final child in children ?? const <Node>[]) {
+      result += child.getNewlineCount();
+    }
+
+    return result;
+  }
+
+  /// The number of characters in this node's [value] ad its descendants.
+  int getCharacterCount() {
+    int result = value?.length ?? 0;
+
+    for (final child in children ?? const <Node>[]) {
+      result += child.getCharacterCount();
+    }
+
+    return result;
+  }
+
+  /// The [KeywordSemantics] for nodes with
+  /// [className] == [NodeClasses.keyword], null for other nodes.
+  ///
+  /// There is no language information here, so this only works for
+  /// selected languages. This is currently valid for:
+  /// - Java
+  ///
+  /// It may give false positive or negative for other languages.
+  KeywordSemantics? get keywordSemantics {
+    if (className != NodeClasses.keyword) {
+      return null;
+    }
+
+    switch (children?.firstOrNull?.value) {
+      case 'import':
+      case 'package':
+        return KeywordSemantics.import;
+    }
+
+    return null;
+  }
+}
diff --git a/lib/src/highlight/node_classes.dart b/lib/src/highlight/node_classes.dart
new file mode 100644
index 0000000..5809270
--- /dev/null
+++ b/lib/src/highlight/node_classes.dart
@@ -0,0 +1,5 @@
+abstract class NodeClasses {
+  static const comment = 'comment';
+  static const keyword = 'keyword';
+  static const string = 'string';
+}
diff --git a/lib/src/highlight/result.dart b/lib/src/highlight/result.dart
new file mode 100644
index 0000000..b47bc23
--- /dev/null
+++ b/lib/src/highlight/result.dart
@@ -0,0 +1,43 @@
+import 'package:highlight/highlight_core.dart';
+
+import 'node.dart';
+
+extension MyResult on Result {
+  void forEachNode(
+    void Function(Node node, int lineIndex, int characterIndex) callback,
+  ) {
+    int lineIndex = 0;
+    int characterIndex = 0;
+
+    void walk(List<Node> nodes) {
+      for (final node in nodes) {
+        callback(node, lineIndex, characterIndex);
+
+        lineIndex += node.getValueNewlineCount();
+        characterIndex += node.value?.length ?? 0;
+
+        if (node.children != null) {
+          walk(node.children!);
+        }
+      }
+    }
+
+    if (nodes != null) {
+      walk(nodes!);
+    }
+  }
+
+  void forEachTopLevelNode(
+    void Function(Node node, int lineIndex, int characterIndex) callback,
+  ) {
+    int lineIndex = 0;
+    int characterIndex = 0;
+
+    for (final node in nodes ?? const <Node>[]) {
+      callback(node, lineIndex, characterIndex);
+
+      lineIndex += node.getNewlineCount();
+      characterIndex += node.getCharacterCount();
+    }
+  }
+}


Test patch:
diff --git a/test/src/folding/parsers/highlight_parser_test.dart b/test/src/folding/parsers/highlight_parser_test.dart
new file mode 100644
index 0000000..c11092e
--- /dev/null
+++ b/test/src/folding/parsers/highlight_parser_test.dart
@@ -0,0 +1,224 @@
+import 'package:flutter_code_editor/flutter_code_editor.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:highlight/highlight.dart';
+import 'package:highlight/languages/java.dart';
+
+void main() {
+  group('HighlightFoldableBlockParser', () {
+    test('parses', () {
+      final _examples = [
+        //
+        _Example(
+          'Java. Empty string',
+          code: '',
+          mode: java,
+          expected: const [],
+          expectedInvalid: const [],
+        ),
+
+        _Example(
+          'Java. Multiline pair-character blocks',
+          code: '''
+public class MyClass {
+  public static void main(
+    String[
+    ] args) {
+    }
+}
+''',
+          mode: java,
+          expected: const [
+            _FB(startLine: 0, endLine: 5, type: _T.braces),
+            _FB(startLine: 1, endLine: 3, type: _T.parentheses),
+            _FB(startLine: 2, endLine: 3, type: _T.brackets),
+            _FB(startLine: 3, endLine: 4, type: _T.braces),
+          ],
+          expectedInvalid: const [],
+        ),
+
+        _Example(
+          'Java. Single-line pair-character blocks are ignored',
+          code: '''
+public class MyClass {
+  public static void main(String[] args) {}
+}
+''',
+          mode: java,
+          expected: const [
+            _FB(startLine: 0, endLine: 2, type: _T.braces),
+          ],
+          expectedInvalid: const [],
+        ),
+
+        _Example(
+          'Java. Blocks with missing pair characters',
+          code: '''
+public class MyClass }
+  public static void main)String][ args( }
+    String][ str;
+  {
+{
+''',
+          mode: java,
+          expected: const [],
+          expectedInvalid: const [
+            InvalidFoldableBlock(endLine: 0, type: _T.braces),
+            InvalidFoldableBlock(endLine: 1, type: _T.parentheses),
+            InvalidFoldableBlock(endLine: 1, type: _T.brackets),
+            InvalidFoldableBlock(endLine: 1, type: _T.braces),
+            InvalidFoldableBlock(startLine: 1, type: _T.brackets),
+            InvalidFoldableBlock(startLine: 1, type: _T.parentheses),
+            InvalidFoldableBlock(endLine: 2, type: _T.brackets),
+            InvalidFoldableBlock(startLine: 2, type: _T.brackets),
+            InvalidFoldableBlock(startLine: 3, type: _T.braces),
+            InvalidFoldableBlock(startLine: 4, type: _T.braces),
+          ],
+        ),
+
+        _Example(
+          'Java. Pair characters in literals are ignored',
+          code: '''
+String a = "{[(";
+String b = ")]}";
+''',
+          mode: java,
+          expected: const [],
+          expectedInvalid: const [],
+        ),
+
+        _Example(
+          'Java. Pair characters in comments are ignored',
+          code: '''
+public class MyClass {
+  /// {[(                  1
+  /*                       2
+    )]}                    3
+  */                       4
+  /// {                    5
+}
+''',
+          mode: java,
+          expected: const [
+            _FB(startLine: 0, endLine: 6, type: _T.braces),
+            _FB(startLine: 2, endLine: 4, type: _T.multilineComment),
+          ],
+          expectedInvalid: const [],
+        ),
+
+        _Example(
+          'Java. Single line comments in a row',
+          code: '''
+// License                                            0
+// License                                            1
+public class MyClass {
+
+  /// Method                                          4
+
+  /// Method                                          6
+
+  void method() {} // Not the only thing in the line  8
+  // Single                                           9
+  void method2 // Not the only thing in the line      10
+  // Single                                           11
+  () {}
+}
+// Single                                             14
+// Single                                             15
+''',
+          mode: java,
+          expected: const [
+            _FB(startLine: 0, endLine: 1, type: _T.singleLineComment),
+            _FB(startLine: 2, endLine: 13, type: _T.braces),
+            _FB(startLine: 4, endLine: 6, type: _T.singleLineComment),
+            _FB(startLine: 14, endLine: 15, type: _T.singleLineComment),
+          ],
+          expectedInvalid: const [],
+        ),
+
+        _Example(
+          'Java. Imports at beginning',
+          code: '''
+package com.akvelon.temp;
+
+import java.util.Arrays;
+
+/* */
+/* */
+import java.util.Date;
+
+/*
+*/
+import java.lang.Math;
+
+public class MyClass {}
+''',
+          mode: java,
+          expected: const [
+            _FB(startLine: 0, endLine: 6, type: _T.imports),
+            _FB(startLine: 4, endLine: 5, type: _T.singleLineComment),
+            _FB(startLine: 8, endLine: 9, type: _T.multilineComment),
+          ],
+          expectedInvalid: const [],
+        ),
+
+        _Example(
+          'Java. Imports at mid and the end, no newline in the end',
+          code: '''
+//
+import java.util.Arrays;
+import java.util.Date;
+public class MyClass {}
+import java.lang.Math;
+import java.lang.Exception;''',
+          mode: java,
+          expected: const [
+            _FB(startLine: 1, endLine: 2, type: _T.imports),
+            _FB(startLine: 4, endLine: 5, type: _T.imports),
+          ],
+          expectedInvalid: const [],
+        ),
+      ];
+
+      for (final example in _examples) {
+        highlight.registerLanguage('language', example.mode);
+        final highlighted = highlight.parse(example.code, language: 'language');
+        final parser = HighlightFoldableBlockParser();
+
+        parser.parse(highlighted);
+
+        expect(
+          parser.blocks,
+          example.expected,
+          reason: '${example.name}, valid blocks',
+        );
+        expect(
+          parser.invalidBlocks,
+          example.expectedInvalid,
+          reason: '${example.name}, invalid blocks',
+        );
+      }
+    });
+  });
+}
+
+class _Example {
+  final String name;
+  final String code;
+  final Mode mode;
+  final List<FoldableBlock> expected;
+  final List<InvalidFoldableBlock> expectedInvalid;
+
+  const _Example(
+    this.name, {
+    required this.code,
+    required this.mode,
+    required this.expected,
+    required this.expectedInvalid,
+  });
+}
+
+/// Shorter alias for [FoldableBlock] to avoid line breaks.
+typedef _FB = FoldableBlock;
+
+/// Shorter alias for [FoldableBlockType] to avoid line breaks.
+typedef _T = FoldableBlockType;