diff --git a/lib/core/theme/parser/jsonc_preprocessor.dart b/lib/core/theme/parser/jsonc_preprocessor.dart new file mode 100644 index 00000000..8497172b --- /dev/null +++ b/lib/core/theme/parser/jsonc_preprocessor.dart @@ -0,0 +1,90 @@ +/// Strips JSONC (comments, trailing commas) to valid JSON for [dart:convert]. +String stripJsonc(String input) { + final out = StringBuffer(); + var i = 0; + final len = input.length; + + while (i < len) { + final ch = input[i]; + final next = i + 1 < len ? input[i + 1] : ''; + + if (ch == '"') { + out.write(_copyStringLiteral(input, i)); + i = _skipStringLiteral(input, i); + continue; + } + + if (ch == '/' && next == '/') { + i += 2; + while (i < len && input[i] != '\n') { + i++; + } + continue; + } + + if (ch == '/' && next == '*') { + i += 2; + while (i < len) { + if (input[i] == '*' && i + 1 < len && input[i + 1] == '/') { + i += 2; + break; + } + i++; + } + continue; + } + + if (ch == ',') { + var j = i + 1; + while (j < len && _isWhitespace(input[j])) { + j++; + } + if (j < len && (input[j] == '}' || input[j] == ']')) { + i++; + continue; + } + } + + out.write(ch); + i++; + } + + return out.toString(); +} + +bool _isWhitespace(String c) => c == ' ' || c == '\t' || c == '\n' || c == '\r'; + +String _copyStringLiteral(String s, int start) { + final buf = StringBuffer(); + var i = start; + buf.write(s[i]); + i++; + while (i < s.length) { + final ch = s[i]; + buf.write(ch); + if (ch == '\\' && i + 1 < s.length) { + i++; + buf.write(s[i]); + } else if (ch == '"') { + i++; + break; + } + i++; + } + return buf.toString(); +} + +int _skipStringLiteral(String s, int start) { + var i = start + 1; + while (i < s.length) { + if (s[i] == '\\') { + i += 2; + continue; + } + if (s[i] == '"') { + return i + 1; + } + i++; + } + return s.length; +} diff --git a/lib/core/theme/parser/vscode_theme_manifest.dart b/lib/core/theme/parser/vscode_theme_manifest.dart new file mode 100644 index 00000000..b6e43008 --- /dev/null +++ b/lib/core/theme/parser/vscode_theme_manifest.dart @@ -0,0 +1,120 @@ +import 'dart:convert'; + +import 'jsonc_preprocessor.dart'; + +/// Parsed VS Code theme manifest (subset used by Querya). +class VsCodeThemeManifest { + const VsCodeThemeManifest({ + this.name, + this.type, + this.colors = const {}, + this.tokenColors = const [], + }); + + final String? name; + + /// `dark` or `light` when present. + final String? type; + + final Map colors; + + final List tokenColors; + + bool get isDark => type?.toLowerCase() == 'dark'; + bool get isLight => type?.toLowerCase() == 'light'; + + factory VsCodeThemeManifest.fromJsonString(String source) { + final cleaned = stripJsonc(source); + final dynamic decoded; + try { + decoded = jsonDecode(cleaned); + } on FormatException catch (e) { + throw VsCodeThemeParseException('Invalid JSON after JSONC strip: ${e.message}'); + } + if (decoded is! Map) { + throw VsCodeThemeParseException('Theme root must be a JSON object'); + } + return VsCodeThemeManifest.fromJson(decoded); + } + + factory VsCodeThemeManifest.fromJson(Map json) { + final colorsRaw = json['colors']; + final colors = {}; + if (colorsRaw is Map) { + for (final e in colorsRaw.entries) { + final k = e.key?.toString(); + final v = e.value?.toString(); + if (k != null && k.isNotEmpty && v != null && v.isNotEmpty) { + colors[k] = v; + } + } + } + + final tokenColorsRaw = json['tokenColors']; + final rules = []; + if (tokenColorsRaw is List) { + for (final item in tokenColorsRaw) { + if (item is Map) { + final rule = TokenColorRule.tryParse(item); + if (rule != null) rules.add(rule); + } + } + } + + return VsCodeThemeManifest( + name: json['name']?.toString(), + type: json['type']?.toString(), + colors: colors, + tokenColors: rules, + ); + } +} + +/// One `tokenColors` entry from a VS Code theme file. +class TokenColorRule { + const TokenColorRule({ + required this.scopes, + this.foreground, + this.background, + this.fontStyle, + }); + + final List scopes; + final String? foreground; + final String? background; + final String? fontStyle; + + static TokenColorRule? tryParse(Map json) { + final scopeRaw = json['scope']; + final scopes = []; + if (scopeRaw is String && scopeRaw.isNotEmpty) { + scopes.add(scopeRaw); + } else if (scopeRaw is List) { + for (final s in scopeRaw) { + final t = s?.toString(); + if (t != null && t.isNotEmpty) scopes.add(t); + } + } + if (scopes.isEmpty) return null; + + final settings = json['settings']; + if (settings is! Map) { + return TokenColorRule(scopes: scopes); + } + + return TokenColorRule( + scopes: scopes, + foreground: settings['foreground']?.toString(), + background: settings['background']?.toString(), + fontStyle: settings['fontStyle']?.toString(), + ); + } +} + +class VsCodeThemeParseException implements Exception { + VsCodeThemeParseException(this.message); + final String message; + + @override + String toString() => 'VsCodeThemeParseException: $message'; +} diff --git a/test/core/theme/parser/jsonc_preprocessor_test.dart b/test/core/theme/parser/jsonc_preprocessor_test.dart new file mode 100644 index 00000000..1759bfde --- /dev/null +++ b/test/core/theme/parser/jsonc_preprocessor_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/jsonc_preprocessor.dart'; + +void main() { + group('stripJsonc', () { + test('removes line comments outside strings', () { + const input = ''' +{ + // sidebar + "a": 1 +} +'''; + final out = stripJsonc(input); + expect(out.contains('//'), isFalse); + expect(out.contains('"a"'), isTrue); + }); + + test('preserves // inside string', () { + const input = '{"x": "http://example.com"}'; + expect(stripJsonc(input), contains('http://')); + }); + + test('removes block comments', () { + const input = '{ /* block */ "k": 2 }'; + final out = stripJsonc(input); + expect(out.contains('/*'), isFalse); + expect(out.contains('"k"'), isTrue); + }); + + test('removes trailing comma', () { + const input = '{"a": 1,}'; + expect(stripJsonc(input), '{"a": 1}'); + }); + }); +} diff --git a/test/core/theme/parser/vscode_theme_manifest_test.dart b/test/core/theme/parser/vscode_theme_manifest_test.dart new file mode 100644 index 00000000..141b8820 --- /dev/null +++ b/test/core/theme/parser/vscode_theme_manifest_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; + +void main() { + group('VsCodeThemeManifest', () { + test('parses minimal dark theme with JSONC', () { + const src = ''' +{ + // theme + "name": "Test Dark", + "type": "dark", + "colors": { + "editor.background": "#1e1e1e", + "sideBar.background": "#252526", + }, + "tokenColors": [ + { + "scope": "comment", + "settings": { "foreground": "#6A9955" } + }, + { + "scope": ["keyword", "storage.type"], + "settings": { "foreground": "#569CD6", "fontStyle": "italic" } + }, + ], +} +'''; + final m = VsCodeThemeManifest.fromJsonString(src); + expect(m.name, 'Test Dark'); + expect(m.isDark, isTrue); + expect(m.colors['editor.background'], '#1e1e1e'); + expect(m.tokenColors.length, 2); + expect(m.tokenColors.first.scopes, ['comment']); + expect(m.tokenColors.first.foreground, '#6A9955'); + expect(m.tokenColors[1].scopes, ['keyword', 'storage.type']); + }); + + test('throws on invalid JSON', () { + expect( + () => VsCodeThemeManifest.fromJsonString('{ not json }'), + throwsA(isA()), + ); + }); + }); +}