diff --git a/lib/core/theme/parser/querya_theme_manifest.dart b/lib/core/theme/parser/querya_theme_manifest.dart new file mode 100644 index 00000000..becf6bc0 --- /dev/null +++ b/lib/core/theme/parser/querya_theme_manifest.dart @@ -0,0 +1,198 @@ +import 'dart:convert'; + +import 'jsonc_preprocessor.dart'; +import 'vscode_theme_manifest.dart'; + +const queryaThemeSchemaV1 = 'querya.theme.v1'; + +enum QueryaThemeType { + dark, + light, +} + +/// Parsed Querya custom theme manifest (`querya.theme.v1`). +class QueryaThemeManifest { + const QueryaThemeManifest({ + required this.schema, + required this.id, + required this.name, + required this.type, + required this.shadcnColors, + required this.editorColors, + this.tokenColors = const [], + this.description, + this.author, + this.version, + }); + + final String schema; + final String id; + final String name; + final QueryaThemeType type; + final Map shadcnColors; + final Map editorColors; + final List tokenColors; + final String? description; + final String? author; + final String? version; + + bool get isDark => type == QueryaThemeType.dark; + bool get isLight => type == QueryaThemeType.light; + + factory QueryaThemeManifest.fromJsonString(String source) { + final cleaned = stripJsonc(source); + final dynamic decoded; + try { + decoded = jsonDecode(cleaned); + } on FormatException catch (e) { + throw QueryaThemeManifestParseException( + 'Invalid JSON after JSONC strip: ${e.message}', + ); + } + if (decoded is! Map) { + throw const QueryaThemeManifestParseException( + 'Theme root must be a JSON object', + ); + } + return QueryaThemeManifest.fromJson(decoded); + } + + factory QueryaThemeManifest.fromJson(Map json) { + final schema = _requiredString(json, 'schema'); + if (schema != queryaThemeSchemaV1) { + throw QueryaThemeManifestParseException( + 'Unsupported schema "$schema"; expected "$queryaThemeSchemaV1"', + ); + } + + final id = _requiredString(json, 'id'); + final name = _requiredString(json, 'name'); + final type = _parseType(_requiredString(json, 'type')); + final shadcnColors = _parseColorMap(json['shadcn_colors'], 'shadcn_colors'); + final editorColors = _parseColorMap(json['editor_colors'], 'editor_colors'); + + 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 QueryaThemeManifest( + schema: schema, + id: id, + name: name, + type: type, + shadcnColors: shadcnColors, + editorColors: editorColors, + tokenColors: List.unmodifiable(rules), + description: _optionalString(json['description']), + author: _optionalString(json['author']), + version: _optionalString(json['version']), + ); + } + + static String _requiredString(Map json, String key) { + if (!json.containsKey(key)) { + throw QueryaThemeManifestParseException('Missing required field "$key"'); + } + final value = json[key]; + if (value is! String || value.trim().isEmpty) { + throw QueryaThemeManifestParseException('Invalid or empty "$key"'); + } + return value.trim(); + } + + static String? _optionalString(Object? value) { + if (value is! String) return null; + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } + + static QueryaThemeType _parseType(String raw) { + switch (raw.toLowerCase()) { + case 'dark': + return QueryaThemeType.dark; + case 'light': + return QueryaThemeType.light; + default: + throw QueryaThemeManifestParseException('Invalid type "$raw"; expected dark or light'); + } + } + + static Map _parseColorMap(Object? raw, String fieldName) { + if (raw == null) { + throw QueryaThemeManifestParseException('Missing required field "$fieldName"'); + } + if (raw is! Map) { + throw QueryaThemeManifestParseException('"$fieldName" must be a JSON object'); + } + + final colors = {}; + for (final entry in raw.entries) { + final key = entry.key?.toString(); + final value = entry.value?.toString(); + if (key != null && key.isNotEmpty && value != null && value.isNotEmpty) { + colors[key] = value; + } + } + return Map.unmodifiable(colors); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is QueryaThemeManifest && + schema == other.schema && + id == other.id && + name == other.name && + type == other.type && + _mapEquals(shadcnColors, other.shadcnColors) && + _mapEquals(editorColors, other.editorColors) && + _listEquals(tokenColors, other.tokenColors) && + description == other.description && + author == other.author && + version == other.version; + + @override + int get hashCode => Object.hash( + schema, + id, + name, + type, + Object.hashAll(shadcnColors.entries), + Object.hashAll(editorColors.entries), + Object.hashAll(tokenColors), + description, + author, + version, + ); + + static bool _mapEquals(Map a, Map b) { + if (a.length != b.length) return false; + for (final entry in a.entries) { + if (b[entry.key] != entry.value) return false; + } + return true; + } + + static bool _listEquals(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} + +class QueryaThemeManifestParseException implements Exception { + const QueryaThemeManifestParseException(this.message); + final String message; + + @override + String toString() => 'QueryaThemeManifestParseException: $message'; +} diff --git a/test/core/theme/parser/querya_theme_manifest_test.dart b/test/core/theme/parser/querya_theme_manifest_test.dart new file mode 100644 index 00000000..832b303b --- /dev/null +++ b/test/core/theme/parser/querya_theme_manifest_test.dart @@ -0,0 +1,200 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; + +void main() { + group('QueryaThemeManifest', () { + test('parses full dark fixture', () { + final raw = + File('test/fixtures/themes/querya_custom_dark.json').readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + + expect(manifest.schema, queryaThemeSchemaV1); + expect(manifest.id, 'fixture-custom-dark'); + expect(manifest.name, 'Fixture Custom Dark'); + expect(manifest.type, QueryaThemeType.dark); + expect(manifest.isDark, isTrue); + expect(manifest.shadcnColors['primary'], '#38BDF8'); + expect(manifest.editorColors['background'], '#0F1117'); + expect(manifest.tokenColors.length, 3); + expect(manifest.description, isNotNull); + expect(manifest.author, 'QueryaHub'); + expect(manifest.version, '1.0.0'); + }); + + test('parses full light fixture', () { + final raw = + File('test/fixtures/themes/querya_custom_light.json').readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + + expect(manifest.type, QueryaThemeType.light); + expect(manifest.isLight, isTrue); + expect(manifest.shadcnColors['background'], '#F8FAFC'); + expect(manifest.editorColors['foreground'], '#1E293B'); + expect(manifest.tokenColors.length, 3); + expect( + manifest.tokenColors.last.scopes, + ['string'], + ); + }); + + test('parses minimal fixture with sparse colors', () { + final raw = + File('test/fixtures/themes/querya_custom_minimal.json').readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + + expect(manifest.id, 'fixture-custom-minimal'); + expect(manifest.shadcnColors, {'primary': '#FF00AA'}); + expect(manifest.editorColors, {'background': '#010203'}); + expect(manifest.tokenColors, isEmpty); + expect(manifest.description, isNull); + }); + + test('parses JSONC fixture with comments and trailing commas', () { + final raw = + File('test/fixtures/themes/querya_custom_jsonc.jsonc').readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + + expect(manifest.id, 'fixture-custom-jsonc'); + expect(manifest.type, QueryaThemeType.light); + expect(manifest.shadcnColors['primary'], '#0EA5E9'); + expect(manifest.editorColors['foreground'], '#111827'); + expect(manifest.tokenColors.single.foreground, '#6B7280'); + }); + + test('accepts empty shadcn_colors and editor_colors objects', () { + const src = ''' +{ + "schema": "querya.theme.v1", + "id": "empty-maps", + "name": "Empty Maps", + "type": "dark", + "shadcn_colors": {}, + "editor_colors": {} +} +'''; + final manifest = QueryaThemeManifest.fromJsonString(src); + + expect(manifest.shadcnColors, isEmpty); + expect(manifest.editorColors, isEmpty); + }); + + test('returns unmodifiable color maps', () { + final raw = + File('test/fixtures/themes/querya_custom_minimal.json').readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + + expect( + () => manifest.shadcnColors['new'] = '#000000', + throwsA(isA()), + ); + expect( + () => manifest.editorColors['new'] = '#000000', + throwsA(isA()), + ); + }); + + test('ignores unknown root fields', () { + const src = ''' +{ + "schema": "querya.theme.v1", + "id": "with-unknown", + "name": "Unknown Fields", + "type": "dark", + "shadcn_colors": {}, + "editor_colors": {}, + "futureField": true +} +'''; + final manifest = QueryaThemeManifest.fromJsonString(src); + expect(manifest.id, 'with-unknown'); + }); + + test('throws when id is missing', () { + final raw = File('test/fixtures/themes/querya_custom_invalid_missing_id.json') + .readAsStringSync(); + + expect( + () => QueryaThemeManifest.fromJsonString(raw), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('id'), + ), + ), + ); + }); + + test('throws on invalid type', () { + const src = ''' +{ + "schema": "querya.theme.v1", + "id": "bad-type", + "name": "Bad Type", + "type": "neon", + "shadcn_colors": {}, + "editor_colors": {} +} +'''; + expect( + () => QueryaThemeManifest.fromJsonString(src), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Invalid type'), + ), + ), + ); + }); + + test('throws on unsupported schema', () { + const src = ''' +{ + "schema": "querya.theme.v2", + "id": "future", + "name": "Future", + "type": "dark", + "shadcn_colors": {}, + "editor_colors": {} +} +'''; + expect( + () => QueryaThemeManifest.fromJsonString(src), + throwsA(isA()), + ); + }); + + test('throws on invalid JSON', () { + expect( + () => QueryaThemeManifest.fromJsonString('{ not json }'), + throwsA(isA()), + ); + }); + + test('reuses TokenColorRule parsing from VS Code themes', () { + const src = ''' +{ + "schema": "querya.theme.v1", + "id": "tokens", + "name": "Tokens", + "type": "dark", + "shadcn_colors": {}, + "editor_colors": {}, + "tokenColors": [ + { + "scope": ["keyword", "storage.type"], + "settings": { "foreground": "#569CD6", "fontStyle": "italic" } + } + ] +} +'''; + final manifest = QueryaThemeManifest.fromJsonString(src); + expect(manifest.tokenColors.single, isA()); + expect(manifest.tokenColors.single.scopes, ['keyword', 'storage.type']); + }); + }); +} diff --git a/test/fixtures/themes/querya_custom_dark.json b/test/fixtures/themes/querya_custom_dark.json new file mode 100644 index 00000000..ce08d673 --- /dev/null +++ b/test/fixtures/themes/querya_custom_dark.json @@ -0,0 +1,81 @@ +{ + "schema": "querya.theme.v1", + "id": "fixture-custom-dark", + "name": "Fixture Custom Dark", + "type": "dark", + "description": "Full dark custom theme fixture for parser tests.", + "author": "QueryaHub", + "version": "1.0.0", + "shadcn_colors": { + "background": "#101014", + "foreground": "#E2E8F0", + "card": "#18181F", + "cardForeground": "#E2E8F0", + "popover": "#18181F", + "popoverForeground": "#E2E8F0", + "primary": "#38BDF8", + "primaryForeground": "#020617", + "secondary": "#1F2937", + "secondaryForeground": "#E2E8F0", + "muted": "#1F2937", + "mutedForeground": "#94A3B8", + "accent": "#6366F1", + "accentForeground": "#F8FAFC", + "destructive": "#F87171", + "destructiveForeground": "#F8FAFC", + "border": "#334155", + "input": "#334155", + "ring": "#38BDF8", + "chart1": "#38BDF8", + "chart2": "#6366F1", + "chart3": "#F472B6", + "chart4": "#A78BFA", + "chart5": "#34D399" + }, + "editor_colors": { + "background": "#0F1117", + "foreground": "#E2E8F0", + "lineHighlight": "#1A1D27", + "selection": "#264F78", + "lineNumber": "#64748B", + "bracketMatch": "#38BDF833", + "widgetBorder": "#38BDF866", + "comment": "#6A9955", + "keyword": "#569CD6", + "string": "#CE9178", + "number": "#B5CEA8", + "operator": "#D4D4D4", + "function": "#DCDCAA", + "type": "#4EC9B0", + "canvas": "#09090B", + "surface": "#111827", + "sidebarBackground": "#0B0F19", + "editorBackground": "#0F1117", + "mutedForeground": "#94A3B8", + "accent": "#38BDF8", + "onAccent": "#020617", + "borderSubtle": "#334155", + "destructive": "#F87171", + "success": "#34D399", + "warning": "#FBBF24", + "gitModified": "#FBBF24", + "gitUntracked": "#34D399" + }, + "tokenColors": [ + { + "name": "Comments", + "scope": ["comment", "comment.line"], + "settings": { "foreground": "#6A9955", "fontStyle": "italic" } + }, + { + "name": "Keywords", + "scope": ["keyword", "keyword.control"], + "settings": { "foreground": "#569CD6", "fontStyle": "bold" } + }, + { + "name": "Strings", + "scope": ["string"], + "settings": { "foreground": "#CE9178" } + } + ] +} diff --git a/test/fixtures/themes/querya_custom_invalid_color.json b/test/fixtures/themes/querya_custom_invalid_color.json new file mode 100644 index 00000000..d48c6bfa --- /dev/null +++ b/test/fixtures/themes/querya_custom_invalid_color.json @@ -0,0 +1,14 @@ +{ + "schema": "querya.theme.v1", + "id": "fixture-invalid-color", + "name": "Fixture Invalid Color", + "type": "dark", + "shadcn_colors": { + "primary": "not-a-color", + "background": "#101014" + }, + "editor_colors": { + "background": "#0F1117", + "selection": "ZZZZZZ" + } +} diff --git a/test/fixtures/themes/querya_custom_invalid_missing_id.json b/test/fixtures/themes/querya_custom_invalid_missing_id.json new file mode 100644 index 00000000..ab4eb120 --- /dev/null +++ b/test/fixtures/themes/querya_custom_invalid_missing_id.json @@ -0,0 +1,7 @@ +{ + "schema": "querya.theme.v1", + "name": "Missing Id Fixture", + "type": "dark", + "shadcn_colors": {}, + "editor_colors": {} +} diff --git a/test/fixtures/themes/querya_custom_jsonc.jsonc b/test/fixtures/themes/querya_custom_jsonc.jsonc new file mode 100644 index 00000000..d708efc6 --- /dev/null +++ b/test/fixtures/themes/querya_custom_jsonc.jsonc @@ -0,0 +1,22 @@ +{ + // JSONC fixture: comments and trailing commas are stripped before parse. + "schema": "querya.theme.v1", + "id": "fixture-custom-jsonc", + "name": "Fixture Custom JSONC", + "type": "light", + "shadcn_colors": { + "background": "#FAFAFA", + "primary": "#0EA5E9", + }, + "editor_colors": { + "background": "#FFFFFF", + "foreground": "#111827", + }, + "tokenColors": [ + { + "name": "Comments", + "scope": ["comment"], + "settings": { "foreground": "#6B7280" }, + }, + ], +} diff --git a/test/fixtures/themes/querya_custom_light.json b/test/fixtures/themes/querya_custom_light.json new file mode 100644 index 00000000..974da5de --- /dev/null +++ b/test/fixtures/themes/querya_custom_light.json @@ -0,0 +1,81 @@ +{ + "schema": "querya.theme.v1", + "id": "fixture-custom-light", + "name": "Fixture Custom Light", + "type": "light", + "description": "Full light custom theme fixture for parser tests.", + "author": "QueryaHub", + "version": "1.0.0", + "shadcn_colors": { + "background": "#F8FAFC", + "foreground": "#0F172A", + "card": "#FFFFFF", + "cardForeground": "#0F172A", + "popover": "#FFFFFF", + "popoverForeground": "#0F172A", + "primary": "#0284C7", + "primaryForeground": "#F8FAFC", + "secondary": "#E2E8F0", + "secondaryForeground": "#0F172A", + "muted": "#E2E8F0", + "mutedForeground": "#64748B", + "accent": "#CBD5E1", + "accentForeground": "#0F172A", + "destructive": "#DC2626", + "destructiveForeground": "#F8FAFC", + "border": "#CBD5E1", + "input": "#CBD5E1", + "ring": "#0284C7", + "chart1": "#0284C7", + "chart2": "#0891B2", + "chart3": "#EA580C", + "chart4": "#7C3AED", + "chart5": "#DB2777" + }, + "editor_colors": { + "background": "#FFFFFF", + "foreground": "#1E293B", + "lineHighlight": "#F1F5F9", + "selection": "#ADD6FF", + "lineNumber": "#64748B", + "bracketMatch": "#0284C733", + "widgetBorder": "#0284C766", + "comment": "#008000", + "keyword": "#0000FF", + "string": "#A31515", + "number": "#098658", + "operator": "#1E293B", + "function": "#795E26", + "type": "#267F99", + "canvas": "#F8FAFC", + "surface": "#FFFFFF", + "sidebarBackground": "#F1F5F9", + "editorBackground": "#FFFFFF", + "mutedForeground": "#64748B", + "accent": "#0284C7", + "onAccent": "#F8FAFC", + "borderSubtle": "#CBD5E1", + "destructive": "#DC2626", + "success": "#16A34A", + "warning": "#D97706", + "gitModified": "#D97706", + "gitUntracked": "#16A34A" + }, + "tokenColors": [ + { + "name": "Comments", + "scope": ["comment", "comment.line"], + "settings": { "foreground": "#008000", "fontStyle": "italic" } + }, + { + "name": "Keywords", + "scope": ["keyword", "keyword.control"], + "settings": { "foreground": "#0000FF", "fontStyle": "bold" } + }, + { + "name": "Strings", + "scope": ["string"], + "settings": { "foreground": "#A31515" } + } + ] +} diff --git a/test/fixtures/themes/querya_custom_minimal.json b/test/fixtures/themes/querya_custom_minimal.json new file mode 100644 index 00000000..1bd15fec --- /dev/null +++ b/test/fixtures/themes/querya_custom_minimal.json @@ -0,0 +1,12 @@ +{ + "schema": "querya.theme.v1", + "id": "fixture-custom-minimal", + "name": "Fixture Custom Minimal", + "type": "dark", + "shadcn_colors": { + "primary": "#FF00AA" + }, + "editor_colors": { + "background": "#010203" + } +}