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
90 changes: 90 additions & 0 deletions lib/core/theme/parser/jsonc_preprocessor.dart
Original file line number Diff line number Diff line change
@@ -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;
}
120 changes: 120 additions & 0 deletions lib/core/theme/parser/vscode_theme_manifest.dart
Original file line number Diff line number Diff line change
@@ -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<String, String> colors;

final List<TokenColorRule> 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<String, dynamic>) {
throw VsCodeThemeParseException('Theme root must be a JSON object');
}
return VsCodeThemeManifest.fromJson(decoded);
}

factory VsCodeThemeManifest.fromJson(Map<String, dynamic> json) {
final colorsRaw = json['colors'];
final colors = <String, String>{};
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 = <TokenColorRule>[];
if (tokenColorsRaw is List) {
for (final item in tokenColorsRaw) {
if (item is Map<String, dynamic>) {
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<String> scopes;
final String? foreground;
final String? background;
final String? fontStyle;

static TokenColorRule? tryParse(Map<String, dynamic> json) {
final scopeRaw = json['scope'];
final scopes = <String>[];
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';
}
35 changes: 35 additions & 0 deletions test/core/theme/parser/jsonc_preprocessor_test.dart
Original file line number Diff line number Diff line change
@@ -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}');
});
});
}
45 changes: 45 additions & 0 deletions test/core/theme/parser/vscode_theme_manifest_test.dart
Original file line number Diff line number Diff line change
@@ -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<VsCodeThemeParseException>()),
);
});
});
}
Loading