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
198 changes: 198 additions & 0 deletions lib/core/theme/parser/querya_theme_manifest.dart
Original file line number Diff line number Diff line change
@@ -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<String, String> shadcnColors;
final Map<String, String> editorColors;
final List<TokenColorRule> 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<String, dynamic>) {
throw const QueryaThemeManifestParseException(
'Theme root must be a JSON object',
);
}
return QueryaThemeManifest.fromJson(decoded);
}

factory QueryaThemeManifest.fromJson(Map<String, dynamic> 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 = <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 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<String, dynamic> 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<String, String> _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 = <String, String>{};
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<String, String> a, Map<String, String> 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<TokenColorRule> a, List<TokenColorRule> 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';
}
200 changes: 200 additions & 0 deletions test/core/theme/parser/querya_theme_manifest_test.dart
Original file line number Diff line number Diff line change
@@ -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<UnsupportedError>()),
);
expect(
() => manifest.editorColors['new'] = '#000000',
throwsA(isA<UnsupportedError>()),
);
});

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<QueryaThemeManifestParseException>().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<QueryaThemeManifestParseException>().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<QueryaThemeManifestParseException>()),
);
});

test('throws on invalid JSON', () {
expect(
() => QueryaThemeManifest.fromJsonString('{ not json }'),
throwsA(isA<QueryaThemeManifestParseException>()),
);
});

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<TokenColorRule>());
expect(manifest.tokenColors.single.scopes, ['keyword', 'storage.type']);
});
});
}
Loading
Loading