diff --git a/lib/src/highlighter.dart b/lib/src/highlighter.dart index ccab8c7..f942875 100644 --- a/lib/src/highlighter.dart +++ b/lib/src/highlighter.dart @@ -39,7 +39,13 @@ class Highlighter { required this.language, required this.theme, }) { - _grammar = _cache[language]!; + final grammar = _cache[language]; + if (grammar == null) { + throw UnsupportedError( + 'Language "$language" has not been initialized or added.', + ); + } + _grammar = grammar; } /// Initializes the [Highlighter] with the given list of [languages]. This @@ -118,39 +124,36 @@ class Highlighter { (TextSpan, int) _formatBrackets(String text, int bracketCounter) { var spans = []; - var plainText = ''; - for (var char in text.characters) { - if (_isStartingBracket(char)) { - if (plainText.isNotEmpty) { - spans.add(TextSpan(text: plainText)); - plainText = ''; - } + final plainText = StringBuffer(); + + void flushPlainText() { + if (plainText.isEmpty) return; + spans.add(TextSpan(text: plainText.toString())); + plainText.clear(); + } + for (var i = 0; i < text.length; i += 1) { + final charCode = text.codeUnitAt(i); + final char = text[i]; + if (_isStartingBracket(charCode)) { + flushPlainText(); spans.add(TextSpan( text: char, style: _getBracketStyle(bracketCounter), )); bracketCounter += 1; - plainText = ''; - } else if (_isEndingBracket(char)) { - if (plainText.isNotEmpty) { - spans.add(TextSpan(text: plainText)); - plainText = ''; - } - + } else if (_isEndingBracket(charCode)) { + flushPlainText(); bracketCounter -= 1; spans.add(TextSpan( text: char, style: _getBracketStyle(bracketCounter), )); - plainText = ''; } else { - plainText += char; + plainText.writeCharCode(charCode); } } - if (plainText.isNotEmpty) { - spans.add(TextSpan(text: plainText)); - } + flushPlainText(); if (spans.length == 1) { return (spans[0], bracketCounter); @@ -166,12 +169,12 @@ class Highlighter { return _bracketStyles[bracketCounter % _bracketStyles.length]; } - bool _isStartingBracket(String bracket) { - return bracket == '{' || bracket == '[' || bracket == '('; + bool _isStartingBracket(int charCode) { + return charCode == 0x7B || charCode == 0x5B || charCode == 0x28; } - bool _isEndingBracket(String bracket) { - return bracket == '}' || bracket == ']' || bracket == ')'; + bool _isEndingBracket(int charCode) { + return charCode == 0x7D || charCode == 0x5D || charCode == 0x29; } } @@ -180,6 +183,7 @@ class HighlighterTheme { final TextStyle _wrapper; TextStyle? _fallback; final _scopes = {}; + final _styleCache = {}; HighlighterTheme._({required TextStyle wrapper}) : _wrapper = wrapper; @@ -287,14 +291,18 @@ class HighlighterTheme { var fontStyleSetting = setting['fontStyle']; if (fontStyleSetting is String) { - if (fontStyleSetting == 'italic') { - fontStyle = FontStyle.italic; - } else if (fontStyleSetting == 'bold') { - fontWeight = FontWeight.bold; - } else if (fontStyleSetting == 'underline') { - textDecoration = TextDecoration.underline; - } else { - throw Exception('WARNING unknown style: $fontStyleSetting'); + final styles = fontStyleSetting.trim().split(RegExp(r'\s+')); + for (final style in styles) { + if (style.isEmpty) continue; + if (style == 'italic') { + fontStyle = FontStyle.italic; + } else if (style == 'bold') { + fontWeight = FontWeight.bold; + } else if (style == 'underline') { + textDecoration = TextDecoration.underline; + } else { + throw Exception('WARNING unknown style: $fontStyleSetting'); + } } } @@ -308,28 +316,38 @@ class HighlighterTheme { void _addScope(String scope, TextStyle style) { _scopes[scope] = style; + _styleCache.clear(); } TextStyle? _getStyle(List scope) { for (var s in scope) { - var fallbacks = _fallbacks(s); - for (var f in fallbacks) { - var style = _scopes[f]; - if (style != null) { - return style; - } + final style = _getStyleForScope(s); + if (style != null) { + return style; } } return _fallback; } - List _fallbacks(String scope) { - var fallbacks = []; - var parts = scope.split('.'); - for (var i = 0; i < parts.length; i++) { - var s = parts.sublist(0, i + 1).join('.'); - fallbacks.add(s); + TextStyle? _getStyleForScope(String scope) { + if (_styleCache.containsKey(scope)) { + return _styleCache[scope]; + } + + String candidate = scope; + while (candidate.isNotEmpty) { + final style = _scopes[candidate]; + if (style != null) { + _styleCache[scope] = style; + return style; + } + + final lastSeparator = candidate.lastIndexOf('.'); + if (lastSeparator == -1) break; + candidate = candidate.substring(0, lastSeparator); } - return fallbacks.reversed.toList(); + + _styleCache[scope] = null; + return null; } } diff --git a/lib/src/span_parser.dart b/lib/src/span_parser.dart index 5053de0..aac15a4 100644 --- a/lib/src/span_parser.dart +++ b/lib/src/span_parser.dart @@ -236,6 +236,7 @@ abstract class GrammarMatcher { final matchStartLocation = location; if (captures != null) { final match = scanner.substring(start, end); + var captureSearchOffset = 0; for (int i = 0; i <= lastMatch.groupCount; ++i) { // Skip if we don't have a scope or nested patterns for this capture. if (!captures.containsKey(i.toString())) continue; @@ -243,7 +244,11 @@ abstract class GrammarMatcher { final captureText = lastMatch.group(i); if (captureText == null || captureText.isEmpty) continue; - final startOffset = match.indexOf(captureText); + final startOffset = i == 0 + ? 0 + : match.indexOf(captureText, captureSearchOffset); + if (startOffset < 0) continue; + captureSearchOffset = startOffset + captureText.length; final capture = captures[i.toString()] as Map; final captureStartLocation = matchStartLocation.offset(startOffset); final captureEndLocation = @@ -272,11 +277,11 @@ abstract class GrammarMatcher { /// A simple matcher which matches a single line. class _SimpleMatcher extends GrammarMatcher { - _SimpleMatcher(Map json) + _SimpleMatcher(super.json) : match = RegExp(json['match'] as String, multiLine: true), captures = (json['captures'] as Map?) ?.cast>(), - super._(json); + super._(); static bool isType(Map json) { return json.containsKey('match'); @@ -309,7 +314,7 @@ class _SimpleMatcher extends GrammarMatcher { } class _MultilineMatcher extends GrammarMatcher { - _MultilineMatcher(Map json) + _MultilineMatcher(super.json) : begin = RegExp(json['begin'] as String, multiLine: true), beginCaptures = json['beginCaptures'] as Map?, contentName = json['contentName'] as String?, @@ -325,8 +330,9 @@ class _MultilineMatcher extends GrammarMatcher { ?.cast>() .map((e) => GrammarMatcher.parse(e)) .toList() - .cast(), - super._(json); + .cast() ?? + const [], + super._(); static bool isType(Map json) { return json.containsKey('begin') && @@ -374,7 +380,7 @@ class _MultilineMatcher extends GrammarMatcher { /// be null if this property is provided. final Map? captures; - final List? patterns; + final List patterns; void _scanBegin(Grammar grammar, LineScanner scanner, ScopeStack scopeStack) { final location = scanner.location; @@ -397,21 +403,14 @@ class _MultilineMatcher extends GrammarMatcher { LineScanner scanner, ScopeStack scopeStack, ) { - while (!scanner.isDone) { - if (String.fromCharCode(scanner.peekChar()!) == '\n') { - scanner.readChar(); - break; - } - bool foundMatch = false; - for (final pattern in patterns ?? []) { - if (pattern.scan(grammar, scanner, scopeStack)) { - foundMatch = true; - break; - } - } - if (!foundMatch) { - scanner.readChar(); - } + _scanUntil( + grammar, + scanner, + scopeStack, + () => scanner.peekChar() == 0x0A, + ); + if (!scanner.isDone) { + scanner.readChar(); } } @@ -420,21 +419,41 @@ class _MultilineMatcher extends GrammarMatcher { LineScanner scanner, ScopeStack scopeStack, ) { - while (!scanner.isDone && end != null && !scanner.matches(end!)) { - bool foundMatch = false; - for (final pattern in patterns ?? []) { - if (pattern.scan(grammar, scanner, scopeStack)) { - foundMatch = true; - break; - } - } - if (!foundMatch) { - // Move forward by a character, try again. + if (end == null) return; + _scanUntil( + grammar, + scanner, + scopeStack, + () => scanner.matches(end!), + ); + } + + void _scanUntil( + Grammar grammar, + LineScanner scanner, + ScopeStack scopeStack, + bool Function() shouldStop, + ) { + while (!scanner.isDone && !shouldStop()) { + if (!_scanPatterns(grammar, scanner, scopeStack)) { scanner.readChar(); } } } + bool _scanPatterns( + Grammar grammar, + LineScanner scanner, + ScopeStack scopeStack, + ) { + for (final pattern in patterns) { + if (pattern.scan(grammar, scanner, scopeStack)) { + return true; + } + } + return false; + } + void _scanEnd(Grammar grammar, LineScanner scanner, ScopeStack scopeStack) { final location = scanner.location; if (end != null && !scanner.scan(end!)) { @@ -531,31 +550,32 @@ class _MultilineMatcher extends GrammarMatcher { if (end != null) 'end': end!.pattern, if (endCaptures != null) 'endCaptures': endCaptures, if (whileCond != null) 'while': whileCond!.pattern, - if (patterns != null) - 'patterns': patterns!.map((e) => e.toJson()).toList(), + if (patterns.isNotEmpty) + 'patterns': patterns.map((e) => e.toJson()).toList(), }; } } class _PatternMatcher extends GrammarMatcher { - _PatternMatcher(Map json) + _PatternMatcher(super.json) : patterns = (json['patterns'] as List?) ?.cast>() .map((e) => GrammarMatcher.parse(e)) .toList() - .cast(), - super._(json); + .cast() ?? + const [], + super._(); static bool isType(Map json) { return json.containsKey('patterns'); } - final List? patterns; + final List patterns; @override bool scan(Grammar grammar, LineScanner scanner, ScopeStack scopeStack) { // Try each rule in the include and return after the first successful match. - for (final pattern in patterns!) { + for (final pattern in patterns) { if (pattern.scan(grammar, scanner, scopeStack)) { return true; } @@ -567,8 +587,8 @@ class _PatternMatcher extends GrammarMatcher { Map toJson() { return { if (name != null) 'name': name, - if (patterns != null) - 'patterns': patterns!.map((e) => e.toJson()).toList(), + if (patterns.isNotEmpty) + 'patterns': patterns.map((e) => e.toJson()).toList(), }; } } diff --git a/lib/syntax_highlight.dart b/lib/syntax_highlight.dart index 1a8b86f..3cccbb4 100644 --- a/lib/syntax_highlight.dart +++ b/lib/syntax_highlight.dart @@ -1,4 +1,2 @@ -library syntax_highlight; - export 'src/highlighter.dart'; export 'src/code_editor.dart'; diff --git a/test/syntax_highlight_regression_test.dart b/test/syntax_highlight_regression_test.dart new file mode 100644 index 0000000..e1befd6 --- /dev/null +++ b/test/syntax_highlight_regression_test.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:syntax_highlight/src/highlighter.dart'; +import 'package:syntax_highlight/src/span_parser.dart'; + +void main() { + group('SpanParser regressions', () { + test('null pattern lists are treated as empty pattern lists', () { + final grammar = Grammar.fromJson({ + 'name': 'Null patterns grammar', + 'scopeName': 'source.test', + 'patterns': null, + }); + + expect(SpanParser.parse(grammar, 'plain text'), isEmpty); + }); + + test('capture offsets use the actual capture group location', () { + final grammar = Grammar.fromJson({ + 'name': 'Repeated capture grammar', + 'scopeName': 'source.test', + 'patterns': [ + { + 'match': r'(foo).*?(foo)', + 'captures': { + '1': {'name': 'entity.first'}, + '2': {'name': 'entity.second'}, + }, + }, + ], + }); + + final spans = SpanParser.parse(grammar, 'foo bar foo'); + + expect( + spans, + contains( + isA() + .having((span) => span.start, 'start', 8) + .having((span) => span.end, 'end', 11) + .having((span) => span.scopes, 'scopes', ['entity.second']), + ), + ); + }); + }); + + group('HighlighterTheme regressions', () { + test('most specific matching scope wins during fallback lookup', () { + final theme = HighlighterTheme.fromConfiguration( + ''' + { + "settings": [ + { + "scope": "keyword", + "settings": {"foreground": "#ff0000"} + }, + { + "scope": "keyword.control", + "settings": {"foreground": "#00ff00"} + } + ] + } + ''', + const TextStyle(color: Colors.black), + ); + Highlighter.addLanguage( + 'specific-scope-test', + ''' + { + "name": "Specific scope test", + "scopeName": "source.test", + "patterns": [ + { + "name": "keyword.control.test", + "match": "word" + } + ] + } + ''', + ); + + final span = Highlighter( + language: 'specific-scope-test', + theme: theme, + ).highlight('word'); + final styledSpan = span.children!.single as TextSpan; + + expect(styledSpan.style!.color, const Color(0xFF00ff00)); + }); + + test('fontStyle can combine bold italic and underline flags', () { + final theme = HighlighterTheme.fromConfiguration( + ''' + { + "settings": [ + { + "scope": "keyword.control.test", + "settings": { + "foreground": "#112233", + "fontStyle": "bold italic underline" + } + } + ] + } + ''', + const TextStyle(color: Colors.black), + ); + Highlighter.addLanguage( + 'combined-font-style-test', + ''' + { + "name": "Combined font style test", + "scopeName": "source.test", + "patterns": [ + { + "name": "keyword.control.test", + "match": "word" + } + ] + } + ''', + ); + + final span = Highlighter( + language: 'combined-font-style-test', + theme: theme, + ).highlight('word'); + final styledSpan = span.children!.single as TextSpan; + + expect(styledSpan.style!.color, const Color(0xFF112233)); + expect(styledSpan.style!.fontWeight, FontWeight.bold); + expect(styledSpan.style!.fontStyle, FontStyle.italic); + expect(styledSpan.style!.decoration, TextDecoration.underline); + }); + }); + + group('Highlighter regressions', () { + test('balanced unscoped brackets are highlighted without failure style', () { + final theme = HighlighterTheme.fromConfiguration( + '{"settings": []}', + const TextStyle(color: Colors.black), + ); + Highlighter.addLanguage( + 'bracket-balance-test', + ''' + { + "name": "Bracket balance test", + "scopeName": "source.test", + "patterns": [] + } + ''', + ); + + final span = Highlighter( + language: 'bracket-balance-test', + theme: theme, + ).highlight('({[]})'); + final bracketContainer = span.children!.single as TextSpan; + final bracketSpans = bracketContainer.children!.cast(); + + expect(bracketSpans.map((span) => span.text).join(), '({[]})'); + expect( + bracketSpans.map((span) => span.style?.color), + isNot(contains(const Color(0xFFFF0000))), + ); + }); + + test('unknown languages fail with a deliberate unsupported error', () { + final theme = HighlighterTheme.fromConfiguration( + '{"settings": []}', + const TextStyle(color: Colors.black), + ); + + expect( + () => Highlighter(language: 'missing-language-test', theme: theme), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/syntax_highlight_test.dart b/test/syntax_highlight_test.dart deleted file mode 100644 index 8b13789..0000000 --- a/test/syntax_highlight_test.dart +++ /dev/null @@ -1 +0,0 @@ -