Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 132 additions & 5 deletions lib/core/editor/syntax_highlight_service.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart';
Expand All @@ -9,6 +11,126 @@ import 'highlighter_theme_from_querya.dart';
import 'querya_code_language.dart';
import 'querya_highlight_controller.dart';

/// A TextMate grammar [json] that Dart's RegExp can run: a regex that starts
/// with the inline flag `(?i)` (Oniguruma) has no Dart form that works.
/// `(?i)` itself is rejected ("Invalid group"), and `(?i:a|b)` applies the
/// flag to the last alternative only (checked on Dart 3.13), so each ASCII
/// letter becomes a class instead: `select` -> `[sS][eE][lL]...`.
@visibleForTesting
String normalizeGrammarJson(String json) {
Object? fix(Object? node) {
if (node is List) return [for (final item in node) fix(item)];
if (node is Map) {
return {
for (final entry in node.entries)
entry.key: switch (entry.key) {
'match' || 'begin' || 'end' || 'while'
when entry.value is String =>
_withoutInlineIgnoreCase(entry.value as String),
_ => fix(entry.value),
},
};
}
return node;
}

return jsonEncode(fix(jsonDecode(json)));
}

/// [pattern] without a leading `(?i)`, with every ASCII letter outside
/// escapes and group syntax matching both cases. Other patterns are returned
/// as they are.
@visibleForTesting
String withoutInlineIgnoreCase(String pattern) =>
_withoutInlineIgnoreCase(pattern);

String _withoutInlineIgnoreCase(String pattern) {
if (!pattern.startsWith('(?i)')) return pattern;
final src = pattern.substring(4);
final out = StringBuffer();
bool isLetter(String c) =>
(c.compareTo('a') >= 0 && c.compareTo('z') <= 0) ||
(c.compareTo('A') >= 0 && c.compareTo('Z') <= 0);
String swap(String c) =>
c == c.toLowerCase() ? c.toUpperCase() : c.toLowerCase();

var inClass = false;
var i = 0;
while (i < src.length) {
final c = src[i];
if (c == r'\') {
// An escape (`\b`, `\s`, `\.`): copied as it is.
out.write(c);
if (i + 1 < src.length) out.write(src[i + 1]);
i += 2;
continue;
}
if (inClass) {
if (c == ']') {
inClass = false;
out.write(c);
} else if (isLetter(c) &&
i + 2 < src.length &&
src[i + 1] == '-' &&
isLetter(src[i + 2])) {
// A range such as a-z also gets its other-case twin.
out
..write('$c-${src[i + 2]}')
..write('${swap(c)}-${swap(src[i + 2])}');
i += 3;
continue;
} else if (isLetter(c)) {
out
..write(c)
..write(swap(c));
} else {
out.write(c);
}
i++;
continue;
}
if (c == '[') {
inClass = true;
out.write(c);
i++;
if (i < src.length && src[i] == '^') {
out.write('^');
i++;
}
// A `]` right after `[` / `[^` is a literal member.
if (i < src.length && src[i] == ']') {
out.write(']');
i++;
}
continue;
}
if (c == '(' && i + 1 < src.length && src[i + 1] == '?') {
// Group syntax: `(?:`, `(?=`, `(?!`, `(?<=`, `(?<!`, `(?<name>`.
var j = i + 2;
if (j < src.length && src[j] == '<' && j + 1 < src.length) {
if (src[j + 1] == '=' || src[j + 1] == '!') {
j += 2;
} else {
j = src.indexOf('>', j) + 1;
if (j == 0) j = src.length;
}
} else if (j < src.length) {
j += 1;
}
out.write(src.substring(i, j));
i = j;
continue;
}
if (isLetter(c)) {
out.write('[$c${swap(c)}]');
} else {
out.write(c);
}
i++;
}
return out.toString();
}

/// Global syntax highlighter setup for [QueryaCodeEditor].
abstract final class SyntaxHighlightService {
static bool _initialized = false;
Expand All @@ -17,13 +139,18 @@ abstract final class SyntaxHighlightService {

static Future<void> ensureInitialized() async {
if (_initialized) return;
await Highlighter.initialize(['sql', 'json']);
_sqlGrammarJson = await rootBundle.loadString(
// Not `Highlighter.initialize`: it compiles the grammars as shipped, and 17
// of the 27 rules of the SQL one start with `(?i)`, which Dart's RegExp
// rejects ("Invalid group"), so those rules were dropped and no SQL
// keyword was ever coloured (#1814). The grammars are fixed first.
_sqlGrammarJson = normalizeGrammarJson(await rootBundle.loadString(
'packages/syntax_highlight/grammars/sql.json',
);
_jsonGrammarJson = await rootBundle.loadString(
));
_jsonGrammarJson = normalizeGrammarJson(await rootBundle.loadString(
'packages/syntax_highlight/grammars/json.json',
);
));
Highlighter.addLanguage('sql', _sqlGrammarJson!);
Highlighter.addLanguage('json', _jsonGrammarJson!);
_initialized = true;
}

Expand Down
97 changes: 97 additions & 0 deletions test/core/editor/sql_keyword_highlighting_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_test/flutter_test.dart';
import 'package:querya_desktop/core/editor/querya_code_language.dart';
import 'package:querya_desktop/core/editor/syntax_highlight_service.dart';
import 'package:querya_desktop/core/theme/querya_theme.dart';

void main() {
group('withoutInlineIgnoreCase', () {
RegExp compile(String p) => RegExp(withoutInlineIgnoreCase(p), multiLine: true);

test('leaves a pattern without the flag alone', () {
expect(withoutInlineIgnoreCase(r'\b\d+\b'), r'\b\d+\b');
});

test('every alternative matches in any case (Dart applies (?i:a|b) to the '
'last alternative only)', () {
final re = compile(r'(?i)\b(select|from|where|order\s+by)\b');
for (final text in ['select', 'SELECT', 'Select', 'FROM', 'from',
'WHERE', 'ORDER BY', 'order by']) {
expect(re.hasMatch(text), isTrue, reason: text);
}
expect(re.hasMatch('selection'), isFalse);
});

test('escapes, groups and classes keep their meaning', () {
final re = compile(r'(?i)(?<!\.)\b(?:p(i|o)_[-a-z0-9_]+)\b');
expect(re.hasMatch('PI_Value'), isTrue);
expect(re.hasMatch('po_x-1'), isTrue);
expect(re.hasMatch('.PI_value'), isFalse);
expect(compile(r'(?i)[^a-z]x').hasMatch('1X'), isTrue);
expect(compile(r'(?i)[^a-z]x').hasMatch('aX'), isFalse);
});
});

test('every rule of the shipped SQL grammar compiles after normalisation',
() async {
TestWidgetsFlutterBinding.ensureInitialized();
final raw = await rootBundle.loadString(
'packages/syntax_highlight/grammars/sql.json',
);
final fixed = normalizeGrammarJson(raw);
final rules = <String>[];
void walk(Object? o) {
if (o is Map) {
for (final e in o.entries) {
if (['match', 'begin', 'end', 'while'].contains(e.key) &&
e.value is String) {
rules.add(e.value as String);
} else {
walk(e.value);
}
}
} else if (o is List) {
o.forEach(walk);
}
}

walk(jsonDecode(fixed));
expect(rules, isNotEmpty);
for (final rule in rules) {
expect(() => RegExp(rule, multiLine: true), returnsNormally,
reason: rule);
}
});

test('SQL keywords get the keyword colour, in any case', () async {
TestWidgetsFlutterBinding.ensureInitialized();
await SyntaxHighlightService.ensureInitialized();
final editor = QueryaTheme.darkDefault.editor;
final highlighter = SyntaxHighlightService.createHighlighter(
language: QueryaCodeLanguage.sql,
editorTheme: editor,
brightness: Brightness.dark,
);

final colors = <String, Color?>{};
void walk(InlineSpan s) {
if (s is TextSpan) {
final text = s.text;
if (text != null && text.trim().isNotEmpty) {
colors[text.trim()] = s.style?.color;
}
s.children?.forEach(walk);
}
}

walk(highlighter.highlight(
'SELECT a FROM t WHERE b = 1 ORDER BY a;\nselect a from t'));

for (final word in ['SELECT', 'FROM', 'WHERE', 'ORDER BY', 'select', 'from']) {
expect(colors[word], editor.keyword, reason: word);
}
});
}
Loading