From ab2e5e618b69758603f0a1b01bf8e933ed6a4f4f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 17 May 2026 20:21:05 +0200 Subject: [PATCH] tree_sitter test coverage + DI seam, ratchet floor to 95 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `colorForRole` switch-arm tests (every role → token mapping plus the unknown-role fallback). Introduce a DI seam in `TreeSitterService` and `TreeSitterLib` so tests can substitute the FFI surface and asset loaders without dlopen'ing `libtree-sitter.so` — `TreeSitterLib.testing(...)` takes named per-function overrides with safe no-op defaults, and `TreeSitterLib.fromDynamicLibrary(...)` lets the smoke test load the vendored library explicitly. Production paths (`TreeSitterService.shared`, `TreeSitterLib.instance`) are unchanged. Fake-FFI tests walk every branch of `_init`, `_loadGrammar`, `highlight`, and `dispose`. The smoke test catches FFI-signature regressions the fakes can't, by exercising the real native library end-to-end on Linux. Together this takes `tree_sitter_service.dart` from 17% to 96% and crosses the global 95% target — closing out the D-66 line-coverage epic. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 20 +- lib/kernel/src/syntax/tree_sitter_ffi.dart | 62 +++ .../src/syntax/tree_sitter_service.dart | 67 +++- pubspec.yaml | 2 +- .../syntax/tree_sitter_service_fake_test.dart | 377 ++++++++++++++++++ .../src/syntax/tree_sitter_service_test.dart | 98 +++++ .../src/syntax/tree_sitter_smoke_test.dart | 80 ++++ 7 files changed, 691 insertions(+), 15 deletions(-) create mode 100644 test/kernel/src/syntax/tree_sitter_service_fake_test.dart create mode 100644 test/kernel/src/syntax/tree_sitter_smoke_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f0a9af5..f4c63154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,16 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. getters), and `widgets/src/clide_markdown.dart` (h3–h6, tables, strikethrough, default block fallback, record-link tap). Crosses the 93% line-coverage threshold (T-91). +- `kernel/src/syntax/tree_sitter_service.dart` test sweep — every + `colorForRole` switch arm plus fake-FFI coverage of + `_init`/`_loadGrammar`/`highlight`/`dispose` branches, taking the + file from 17% → 96%. Real-library smoke test + (`test/kernel/src/syntax/tree_sitter_smoke_test.dart`) dlopen's the + vendored `native/linux-x64/libtree-sitter.so`, loads the bundled + `dart` grammar end-to-end, and verifies highlight emits sane spans — + catches FFI signature drift the fake-driven tests can't. Skips on + non-Linux and when the vendored library isn't present. Drives total + line coverage across the 95% target (T-91). - Staged `dart doc` CI job — generates and uploads an HTML API reference for the public `lib/` surface. The step wraps `dart doc --validate-links` and grep-fails the build on any warning, @@ -109,8 +119,14 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Changed -- Pre-push line-coverage floor unfrozen and ratcheted to 93% (was held - at 90 on 2026-05-14 by mistake). 95% target restored per D-66. +- Pre-push line-coverage floor ratcheted to 95% — D-66 target hit. +- `TreeSitterService` accepts injectable `TreeSitterLib` and grammar / + query loaders so tests can substitute a fake FFI surface without + dlopen'ing `libtree-sitter.so`. `TreeSitterLib.testing(...)` exposes a + named-parameter constructor with safe no-op defaults for every native + function; `TreeSitterLib.fromDynamicLibrary(...)` lets the smoke test + load the vendored `.so` directly. Production paths + (`TreeSitterService.shared`, `TreeSitterLib.instance`) unchanged. - Tidied test imports — dropped redundant `dart:ui` / `dart:typed_data` / barrel-redundant package imports flagged by `unnecessary_import`. - Terminal panes now render bold attributes with a real bold weight — diff --git a/lib/kernel/src/syntax/tree_sitter_ffi.dart b/lib/kernel/src/syntax/tree_sitter_ffi.dart index f075fb69..aa741f86 100644 --- a/lib/kernel/src/syntax/tree_sitter_ffi.dart +++ b/lib/kernel/src/syntax/tree_sitter_ffi.dart @@ -4,6 +4,7 @@ import 'dart:ffi'; import 'dart:io' show File, Platform; import 'package:ffi/ffi.dart'; +import 'package:flutter/foundation.dart'; // -- Opaque handles ---------------------------------------------------------- @@ -146,6 +147,60 @@ class TreeSitterLib { wasmEngineNew = lib.lookupFunction<_WasmEngineNew, DWasmEngineNew>('wasm_engine_new'), wasmEngineDelete = lib.lookupFunction<_WasmEngineDelete, DWasmEngineDelete>('wasm_engine_delete'); + /// Constructs a [TreeSitterLib] from caller-supplied Dart closures. Used by + /// tests to substitute the FFI surface without dlopen'ing the real library; + /// each unspecified function defaults to a safe no-op (pointers return + /// `nullptr`, ints return `0`, bools return `false`). Tests override the + /// few entries they exercise. + @visibleForTesting + TreeSitterLib.testing({ + DTsParserNew? parserNew, + DTsParserDelete? parserDelete, + DTsParserSetLanguage? parserSetLanguage, + DTsParserSetWasmStore? parserSetWasmStore, + DTsParserParseString? parserParseString, + DTsTreeDelete? treeDelete, + DTsTreeRootNode? treeRootNode, + DTsNodeStartByte? nodeStartByte, + DTsNodeEndByte? nodeEndByte, + DTsQueryNew? queryNew, + DTsQueryDelete? queryDelete, + DTsQueryCaptureCount? queryCaptureCount, + DTsQueryCaptureNameForId? queryCaptureNameForId, + DTsQueryCursorNew? queryCursorNew, + DTsQueryCursorDelete? queryCursorDelete, + DTsQueryCursorExec? queryCursorExec, + DTsQueryCursorNextMatch? queryCursorNextMatch, + DTsWasmStoreNew? wasmStoreNew, + DTsWasmStoreDelete? wasmStoreDelete, + DTsWasmStoreLoadLanguage? wasmStoreLoadLanguage, + DWasmEngineNew? wasmEngineNew, + DWasmEngineDelete? wasmEngineDelete, + }) : parserNew = parserNew ?? (() => nullptr), + parserDelete = parserDelete ?? ((_) {}), + parserSetLanguage = parserSetLanguage ?? ((_, __) => false), + parserSetWasmStore = parserSetWasmStore ?? ((_, __) {}), + parserParseString = parserParseString ?? ((_, __, ___, ____) => nullptr), + treeDelete = treeDelete ?? ((_) {}), + // Leaks a zeroed TSNode allocation — only hit when the test supplies + // a non-null parserParseString without also supplying treeRootNode. + treeRootNode = treeRootNode ?? ((_) => calloc().ref), + nodeStartByte = nodeStartByte ?? ((_) => 0), + nodeEndByte = nodeEndByte ?? ((_) => 0), + queryNew = queryNew ?? ((_, __, ___, ____, _____) => nullptr), + queryDelete = queryDelete ?? ((_) {}), + queryCaptureCount = queryCaptureCount ?? ((_) => 0), + queryCaptureNameForId = queryCaptureNameForId ?? ((_, __, ___) => nullptr), + queryCursorNew = queryCursorNew ?? (() => nullptr), + queryCursorDelete = queryCursorDelete ?? ((_) {}), + queryCursorExec = queryCursorExec ?? ((_, __, ___) {}), + queryCursorNextMatch = queryCursorNextMatch ?? ((_, __) => false), + wasmStoreNew = wasmStoreNew ?? ((_, __) => nullptr), + wasmStoreDelete = wasmStoreDelete ?? ((_) {}), + wasmStoreLoadLanguage = wasmStoreLoadLanguage ?? ((_, __, ___, ____, _____) => nullptr), + wasmEngineNew = wasmEngineNew ?? (() => nullptr), + wasmEngineDelete = wasmEngineDelete ?? ((_) {}); + final DTsParserNew parserNew; final DTsParserDelete parserDelete; final DTsParserSetLanguage parserSetLanguage; @@ -181,6 +236,13 @@ class TreeSitterLib { return true; } + /// Constructs a [TreeSitterLib] from an already-loaded [DynamicLibrary]. + /// Used by the smoke test to dlopen the vendored `libtree-sitter.so` + /// directly without going through the global `init()` / `_instance` + /// dance, so the test stays isolated from the singleton. + @visibleForTesting + static TreeSitterLib fromDynamicLibrary(DynamicLibrary lib) => TreeSitterLib._(lib); + static DynamicLibrary? _openLibrary() { final libName = Platform.isLinux ? 'libtree-sitter.so' diff --git a/lib/kernel/src/syntax/tree_sitter_service.dart b/lib/kernel/src/syntax/tree_sitter_service.dart index d636eb7a..a9a28157 100644 --- a/lib/kernel/src/syntax/tree_sitter_service.dart +++ b/lib/kernel/src/syntax/tree_sitter_service.dart @@ -8,8 +8,17 @@ import 'package:clide/kernel/src/syntax/language_map.dart'; import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart'; import 'package:clide/kernel/src/theme/tokens.dart'; import 'package:ffi/ffi.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart' show rootBundle; +/// Loads grammar WASM bytes for [language] (e.g. "dart" → `dart.wasm`). +/// Throws on missing or unreadable assets. +typedef GrammarBytesLoader = Future Function(String language); + +/// Loads the highlight query (`.scm` source) for [language], or returns +/// null if no query is bundled for it. +typedef GrammarQueryLoader = Future Function(String language); + class SyntaxSpan { const SyntaxSpan({ required this.start, @@ -42,8 +51,37 @@ class _LoadedGrammar { } class TreeSitterService { - static final TreeSitterService shared = TreeSitterService._(); - TreeSitterService._(); + static final TreeSitterService shared = TreeSitterService(); + + /// Production constructor: uses the dlopen'd [TreeSitterLib.instance] and + /// the Flutter [rootBundle]. Tests pass [lib] / [grammarBytes] / + /// [grammarQuery] to substitute a fake FFI surface and in-memory assets. + TreeSitterService({ + TreeSitterLib? lib, + GrammarBytesLoader? grammarBytes, + GrammarQueryLoader? grammarQuery, + }) : _injectedLib = lib, + _grammarBytes = grammarBytes ?? _defaultGrammarBytes, + _grammarQuery = grammarQuery ?? _defaultGrammarQuery; + + final TreeSitterLib? _injectedLib; + final GrammarBytesLoader _grammarBytes; + final GrammarQueryLoader _grammarQuery; + + TreeSitterLib? get _lib => _injectedLib ?? TreeSitterLib.instance; + + static Future _defaultGrammarBytes(String language) async { + final data = await rootBundle.load('assets/grammars/$language.wasm'); + return data.buffer.asUint8List(); + } + + static Future _defaultGrammarQuery(String language) async { + try { + return await rootBundle.loadString('assets/queries/$language.scm'); + } catch (_) { + return null; + } + } final Map _grammars = {}; final Set _unavailable = {}; @@ -58,7 +96,7 @@ class TreeSitterService { if (_initDone) return _parser != null; _initDone = true; - final lib = TreeSitterLib.instance; + final lib = _lib; if (lib == null) return false; final engine = lib.wasmEngineNew(); @@ -92,12 +130,11 @@ class TreeSitterService { return null; } - final lib = TreeSitterLib.instance!; + final lib = _lib!; try { // Load grammar WASM bytes. - final wasmData = await rootBundle.load('assets/grammars/$language.wasm'); - final wasmBytes = wasmData.buffer.asUint8List(); + final wasmBytes = await _grammarBytes(language); // Load into WASM store. final nameNative = language.toNativeUtf8(); @@ -126,10 +163,7 @@ class TreeSitterService { calloc.free(error); // Load highlight query. - String? querySource; - try { - querySource = await rootBundle.loadString('assets/queries/$language.scm'); - } catch (_) {} + final querySource = await _grammarQuery(language); Pointer query = nullptr; List captureNames = []; @@ -200,7 +234,7 @@ class TreeSitterService { return SyntaxResult.empty; } - final lib = TreeSitterLib.instance!; + final lib = _lib!; final parser = _parser!; final cursor = _cursor!; @@ -253,7 +287,7 @@ class TreeSitterService { } void dispose() { - final lib = TreeSitterLib.instance; + final lib = _lib; if (lib == null) return; for (final grammar in _grammars.values) { @@ -273,6 +307,15 @@ class TreeSitterService { _unavailable.clear(); } + /// Resets the service to a pre-init state. Tests use this to re-exercise + /// `_init()` without constructing a new singleton; production code never + /// needs it. + @visibleForTesting + void resetForTests() { + dispose(); + _initDone = false; + } + static Color colorForRole(String role, SurfaceTokens tokens) { return switch (role) { 'keyword' || 'repeat' || 'conditional' || 'include' || 'exception' || 'operator' => tokens.syntaxKeyword, diff --git a/pubspec.yaml b/pubspec.yaml index d97d0ca8..2917e296 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,7 +18,7 @@ repository: https://github.com/postmeridiem/clide # Pre-push line-coverage floor. Ratchets up only — see D-66. # Reading: `awk -F: '/^coverage_floor:/ {gsub(/ /,"",$2); print $2}' pubspec.yaml`. -coverage_floor: 93 +coverage_floor: 95 # Project metadata (was project.yaml, folded in per D-056). # version: above is the single source of truth. The Makefile reads diff --git a/test/kernel/src/syntax/tree_sitter_service_fake_test.dart b/test/kernel/src/syntax/tree_sitter_service_fake_test.dart new file mode 100644 index 00000000..09827452 --- /dev/null +++ b/test/kernel/src/syntax/tree_sitter_service_fake_test.dart @@ -0,0 +1,377 @@ +/// Fake-FFI tests for `TreeSitterService` — exercises every branch of +/// `_init`, `_loadGrammar`, `highlight`, and `dispose` by substituting +/// `TreeSitterLib.testing(...)` and in-memory grammar/query loaders, no +/// real `libtree-sitter.so` required. +library; + +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart'; +import 'package:clide/kernel/src/syntax/tree_sitter_service.dart'; +import 'package:ffi/ffi.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + // Fake addresses used to stand in for opaque handles. Never dereferenced — + // the fake FFI surface just compares pointer identity. + Pointer engineHandle() => Pointer.fromAddress(0x1000); + Pointer storeHandle() => Pointer.fromAddress(0x2000); + Pointer parserHandle() => Pointer.fromAddress(0x3000); + Pointer cursorHandle() => Pointer.fromAddress(0x4000); + Pointer languageHandle() => Pointer.fromAddress(0x5000); + Pointer queryHandle() => Pointer.fromAddress(0x6000); + Pointer treeHandle() => Pointer.fromAddress(0x7000); + + /// Builds a lib that succeeds through `_init` (engine + store + parser + + /// cursor) and lets each test customize what happens afterwards. + TreeSitterLib initOkLib({ + DTsWasmStoreLoadLanguage? wasmStoreLoadLanguage, + DTsQueryNew? queryNew, + DTsQueryCaptureCount? queryCaptureCount, + DTsQueryCaptureNameForId? queryCaptureNameForId, + DTsParserParseString? parserParseString, + DTsTreeRootNode? treeRootNode, + DTsQueryCursorNextMatch? queryCursorNextMatch, + DTsNodeStartByte? nodeStartByte, + DTsNodeEndByte? nodeEndByte, + DTsQueryDelete? queryDelete, + DTsParserDelete? parserDelete, + DTsWasmStoreDelete? wasmStoreDelete, + DTsQueryCursorDelete? queryCursorDelete, + DTsParserSetLanguage? parserSetLanguage, + }) { + return TreeSitterLib.testing( + wasmEngineNew: engineHandle, + wasmEngineDelete: (_) {}, + wasmStoreNew: (_, __) => storeHandle(), + parserNew: parserHandle, + parserSetWasmStore: (_, __) {}, + queryCursorNew: cursorHandle, + wasmStoreLoadLanguage: wasmStoreLoadLanguage, + queryNew: queryNew, + queryCaptureCount: queryCaptureCount, + queryCaptureNameForId: queryCaptureNameForId, + parserParseString: parserParseString, + treeRootNode: treeRootNode, + queryCursorNextMatch: queryCursorNextMatch, + nodeStartByte: nodeStartByte, + nodeEndByte: nodeEndByte, + queryDelete: queryDelete, + parserDelete: parserDelete, + wasmStoreDelete: wasmStoreDelete, + queryCursorDelete: queryCursorDelete, + parserSetLanguage: parserSetLanguage ?? ((_, __) => true), + ); + } + + Future okBytes(String _) async => Uint8List.fromList(const [0, 1, 2, 3]); + Future noQuery(String _) async => null; + Future okQuery(String _) async => '(identifier) @keyword'; + + group('TreeSitterService._init — FFI failure branches', () { + test('wasmEngineNew returning nullptr → hasGrammar(.dart) is false', () async { + final svc = TreeSitterService( + lib: TreeSitterLib.testing(), + grammarBytes: okBytes, + grammarQuery: noQuery, + ); + expect(await svc.hasGrammar('foo.dart'), isFalse); + }); + + test('wasmStoreNew returning nullptr → hasGrammar is false', () async { + final lib = TreeSitterLib.testing( + wasmEngineNew: engineHandle, + wasmEngineDelete: (_) {}, + // wasmStoreNew default → nullptr + ); + final svc = TreeSitterService(lib: lib, grammarBytes: okBytes, grammarQuery: noQuery); + expect(await svc.hasGrammar('foo.dart'), isFalse); + }); + + test('parserNew returning nullptr → hasGrammar is false', () async { + final lib = TreeSitterLib.testing( + wasmEngineNew: engineHandle, + wasmEngineDelete: (_) {}, + wasmStoreNew: (_, __) => storeHandle(), + // parserNew default → nullptr + ); + final svc = TreeSitterService(lib: lib, grammarBytes: okBytes, grammarQuery: noQuery); + expect(await svc.hasGrammar('foo.dart'), isFalse); + }); + + test('_init failure is cached — second call still false without re-trying', () async { + var engineCalls = 0; + final lib = TreeSitterLib.testing( + wasmEngineNew: () { + engineCalls++; + return nullptr; + }, + ); + final svc = TreeSitterService(lib: lib, grammarBytes: okBytes, grammarQuery: noQuery); + expect(await svc.hasGrammar('foo.dart'), isFalse); + expect(await svc.hasGrammar('foo.dart'), isFalse); + // Second hasGrammar hits the _unavailable cache before _init runs again. + expect(engineCalls, 1); + }); + }); + + group('TreeSitterService._loadGrammar — branches', () { + test('grammarBytes throwing is caught → grammar marked unavailable', () async { + final svc = TreeSitterService( + lib: initOkLib(wasmStoreLoadLanguage: (_, __, ___, ____, _____) => languageHandle()), + grammarBytes: (_) async => throw StateError('bundle missing'), + grammarQuery: noQuery, + ); + expect(await svc.hasGrammar('foo.dart'), isFalse); + }); + + test('wasmStoreLoadLanguage returning nullptr → grammar marked unavailable', () async { + final svc = TreeSitterService( + lib: initOkLib(), + grammarBytes: okBytes, + grammarQuery: noQuery, + ); + expect(await svc.hasGrammar('foo.dart'), isFalse); + // Once marked unavailable, languageFor also returns null. + expect(await svc.languageFor('foo.dart'), isNull); + }); + + test('grammarQuery returning null → grammar loads with query=nullptr', () async { + final svc = TreeSitterService( + lib: initOkLib( + wasmStoreLoadLanguage: (_, __, ___, ____, _____) => languageHandle(), + ), + grammarBytes: okBytes, + grammarQuery: noQuery, + ); + // languageFor returns the language name when the grammar loaded — even + // though there's no highlight query. + expect(await svc.languageFor('foo.dart'), 'dart'); + // hasGrammar likewise returns true. + expect(await svc.hasGrammar('foo.dart'), isTrue); + // highlight returns empty because grammar.query is nullptr. + final h = await svc.highlight('foo.dart', 'x'); + expect(h.spans, isEmpty); + }); + + test('grammarQuery loads + queryNew fails → grammar still cached with query=nullptr', () async { + final svc = TreeSitterService( + lib: initOkLib( + wasmStoreLoadLanguage: (_, __, ___, ____, _____) => languageHandle(), + // queryNew default → nullptr; capture reflection block skipped. + ), + grammarBytes: okBytes, + grammarQuery: okQuery, + ); + expect(await svc.languageFor('foo.dart'), 'dart'); + expect((await svc.highlight('foo.dart', 'x')).spans, isEmpty); + }); + + test('grammarQuery loads + queryNew + captures reflected → loadedLanguages reports it', () async { + // Allocate a static capture name buffer that the fake returns for every + // capture id. Leaks for the duration of the test; not freed. + final nameNative = 'keyword'.toNativeUtf8(); + final svc = TreeSitterService( + lib: initOkLib( + wasmStoreLoadLanguage: (_, __, ___, ____, _____) => languageHandle(), + queryNew: (_, __, ___, ____, _____) => queryHandle(), + queryCaptureCount: (_) => 1, + queryCaptureNameForId: (_, __, lenOut) { + lenOut.value = nameNative.length; + return nameNative; + }, + ), + grammarBytes: okBytes, + grammarQuery: okQuery, + ); + expect(await svc.languageFor('foo.dart'), 'dart'); + expect(svc.loadedLanguages, contains('dart')); + }); + + test('a second call for the same language returns the cached grammar', () async { + var byteLoads = 0; + final svc = TreeSitterService( + lib: initOkLib( + wasmStoreLoadLanguage: (_, __, ___, ____, _____) => languageHandle(), + ), + grammarBytes: (lang) async { + byteLoads++; + return Uint8List.fromList(const [0, 1, 2]); + }, + grammarQuery: noQuery, + ); + await svc.languageFor('foo.dart'); + await svc.languageFor('foo.dart'); + expect(byteLoads, 1); + }); + + test('once marked unavailable, repeat calls do not re-attempt the bundle load', () async { + var byteLoads = 0; + final svc = TreeSitterService( + lib: initOkLib(), // wasmStoreLoadLanguage default → nullptr + grammarBytes: (lang) async { + byteLoads++; + return Uint8List.fromList(const [0]); + }, + grammarQuery: noQuery, + ); + expect(await svc.hasGrammar('foo.dart'), isFalse); + expect(await svc.hasGrammar('foo.dart'), isFalse); + expect(byteLoads, 1); + }); + }); + + group('TreeSitterService.highlight — parse + cursor loop', () { + test('parserParseString returning nullptr → empty spans', () async { + final svc = TreeSitterService( + lib: initOkLib( + wasmStoreLoadLanguage: (_, __, ___, ____, _____) => languageHandle(), + queryNew: (_, __, ___, ____, _____) => queryHandle(), + // parserParseString default → nullptr + ), + grammarBytes: okBytes, + grammarQuery: okQuery, + ); + final h = await svc.highlight('foo.dart', 'whatever'); + expect(h.spans, isEmpty); + }); + + test('cursor produces a match with one capture → SyntaxSpan emitted', () async { + // Build a leaky TSNode for the fake to return — start/end byte + // closures decide the span coordinates. + final rootNode = calloc(); + final capNode = calloc(); + final captures = calloc(1); + captures[0].index = 0; + captures[0].node = capNode.ref; + final nameNative = 'keyword'.toNativeUtf8(); + + var matchCalls = 0; + final svc = TreeSitterService( + lib: initOkLib( + wasmStoreLoadLanguage: (_, __, ___, ____, _____) => languageHandle(), + queryNew: (_, __, ___, ____, _____) => queryHandle(), + queryCaptureCount: (_) => 1, + queryCaptureNameForId: (_, __, lenOut) { + lenOut.value = nameNative.length; + return nameNative; + }, + parserParseString: (_, __, ___, ____) => treeHandle(), + treeRootNode: (_) => rootNode.ref, + queryCursorNextMatch: (_, match) { + if (matchCalls > 0) return false; + matchCalls++; + match.ref.captureCount = 1; + match.ref.captures = captures; + return true; + }, + nodeStartByte: (_) => 4, + nodeEndByte: (_) => 11, + ), + grammarBytes: okBytes, + grammarQuery: okQuery, + ); + + final h = await svc.highlight('foo.dart', 'void hello();'); + expect(h.spans, hasLength(1)); + expect(h.spans.single.start, 4); + expect(h.spans.single.end, 11); + expect(h.spans.single.role, 'keyword'); + }); + + test('captures with an out-of-range index are skipped', () async { + final rootNode = calloc(); + final capNode = calloc(); + final captures = calloc(1); + captures[0].index = 99; // way beyond captureNames.length=1 + captures[0].node = capNode.ref; + final nameNative = 'keyword'.toNativeUtf8(); + + var matchCalls = 0; + final svc = TreeSitterService( + lib: initOkLib( + wasmStoreLoadLanguage: (_, __, ___, ____, _____) => languageHandle(), + queryNew: (_, __, ___, ____, _____) => queryHandle(), + queryCaptureCount: (_) => 1, + queryCaptureNameForId: (_, __, lenOut) { + lenOut.value = nameNative.length; + return nameNative; + }, + parserParseString: (_, __, ___, ____) => treeHandle(), + treeRootNode: (_) => rootNode.ref, + queryCursorNextMatch: (_, match) { + if (matchCalls > 0) return false; + matchCalls++; + match.ref.captureCount = 1; + match.ref.captures = captures; + return true; + }, + ), + grammarBytes: okBytes, + grammarQuery: okQuery, + ); + + final h = await svc.highlight('foo.dart', 'void main() {}'); + expect(h.spans, isEmpty); + }); + }); + + group('TreeSitterService.dispose', () { + test('dispose calls queryDelete + parserDelete + wasmStoreDelete + queryCursorDelete', () async { + var queryDeletes = 0; + var parserDeletes = 0; + var storeDeletes = 0; + var cursorDeletes = 0; + final svc = TreeSitterService( + lib: initOkLib( + wasmStoreLoadLanguage: (_, __, ___, ____, _____) => languageHandle(), + queryNew: (_, __, ___, ____, _____) => queryHandle(), + queryDelete: (_) => queryDeletes++, + parserDelete: (_) => parserDeletes++, + wasmStoreDelete: (_) => storeDeletes++, + queryCursorDelete: (_) => cursorDeletes++, + ), + grammarBytes: okBytes, + grammarQuery: okQuery, + ); + + // Load one grammar so dispose has something to walk. + await svc.hasGrammar('foo.dart'); + svc.dispose(); + + expect(queryDeletes, 1); + expect(parserDeletes, 1); + expect(storeDeletes, 1); + expect(cursorDeletes, 1); + }); + + test('dispose is a no-op when no lib was ever injected and TreeSitterLib.instance is null', () { + // No injected lib + no dlopen'd native lib in the test runner. + final svc = TreeSitterService(grammarBytes: okBytes, grammarQuery: noQuery); + svc.dispose(); // must not throw + }); + + test('resetForTests re-arms _init for the next call', () async { + var engineCalls = 0; + final lib = TreeSitterLib.testing( + wasmEngineNew: () { + engineCalls++; + return engineHandle(); + }, + wasmEngineDelete: (_) {}, + wasmStoreNew: (_, __) => storeHandle(), + parserNew: parserHandle, + parserSetWasmStore: (_, __) {}, + queryCursorNew: cursorHandle, + wasmStoreLoadLanguage: (_, __, ___, ____, _____) => languageHandle(), + ); + final svc = TreeSitterService(lib: lib, grammarBytes: okBytes, grammarQuery: noQuery); + await svc.hasGrammar('foo.dart'); + expect(engineCalls, 1); + svc.resetForTests(); + await svc.hasGrammar('bar.dart'); + expect(engineCalls, 2); + }); + }); +} diff --git a/test/kernel/src/syntax/tree_sitter_service_test.dart b/test/kernel/src/syntax/tree_sitter_service_test.dart index c917f864..30743e9d 100644 --- a/test/kernel/src/syntax/tree_sitter_service_test.dart +++ b/test/kernel/src/syntax/tree_sitter_service_test.dart @@ -6,8 +6,11 @@ library; import 'package:clide/kernel/src/syntax/tree_sitter_service.dart'; +import 'package:clide/kernel/src/theme/tokens.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../../helpers/kernel_fixture.dart'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -63,4 +66,99 @@ void main() { expect(s.role, 'keyword'); }); }); + + group('TreeSitterService.colorForRole', () { + late SurfaceTokens tokens; + + setUpAll(() async { + final f = await KernelFixture.create(); + tokens = f.services.theme.current.surface; + await f.dispose(); + }); + + test('every keyword-flavoured role maps to syntaxKeyword', () { + for (final role in ['keyword', 'repeat', 'conditional', 'include', 'exception', 'operator']) { + expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxKeyword, reason: role); + } + }); + + test('type-flavoured roles map to syntaxType', () { + for (final role in ['type', 'type.builtin', 'constructor']) { + expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxType, reason: role); + } + }); + + test('string-flavoured roles map to syntaxString', () { + for (final role in ['string', 'string.special']) { + expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxString, reason: role); + } + }); + + test('number-flavoured roles map to syntaxNumber', () { + for (final role in ['number', 'float', 'boolean']) { + expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxNumber, reason: role); + } + }); + + test('comment maps to syntaxComment', () { + expect(TreeSitterService.colorForRole('comment', tokens), tokens.syntaxComment); + }); + + test('function-flavoured roles map to syntaxMethod', () { + for (final role in ['function', 'function.builtin', 'function.method', 'method']) { + expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxMethod, reason: role); + } + }); + + test('punctuation roles map to syntaxPunct', () { + for (final role in ['punctuation.bracket', 'punctuation.delimiter', 'punctuation.special']) { + expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxPunct, reason: role); + } + }); + + test('variable roles fall back to globalForeground', () { + for (final role in ['variable', 'variable.builtin', 'variable.parameter']) { + expect(TreeSitterService.colorForRole(role, tokens), tokens.globalForeground, reason: role); + } + }); + + test('property / field share syntaxMethod with functions', () { + for (final role in ['property', 'field']) { + expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxMethod, reason: role); + } + }); + + test('constant roles share syntaxNumber with numbers', () { + for (final role in ['constant', 'constant.builtin']) { + expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxNumber, reason: role); + } + }); + + test('tag / attribute share syntaxKeyword', () { + for (final role in ['tag', 'attribute']) { + expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxKeyword, reason: role); + } + }); + + test('namespace / module share syntaxType', () { + for (final role in ['namespace', 'module']) { + expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxType, reason: role); + } + }); + + test('markdown text.* roles distribute across keyword / string / type tokens', () { + expect(TreeSitterService.colorForRole('text.title', tokens), tokens.syntaxKeyword); + for (final role in ['text.literal', 'text.reference', 'text.uri']) { + expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxString, reason: role); + } + for (final role in ['text.emphasis', 'text.strong']) { + expect(TreeSitterService.colorForRole(role, tokens), tokens.syntaxType, reason: role); + } + }); + + test('unknown roles fall through to globalForeground', () { + expect(TreeSitterService.colorForRole('definitely-not-a-real-role', tokens), tokens.globalForeground); + expect(TreeSitterService.colorForRole('', tokens), tokens.globalForeground); + }); + }); } diff --git a/test/kernel/src/syntax/tree_sitter_smoke_test.dart b/test/kernel/src/syntax/tree_sitter_smoke_test.dart new file mode 100644 index 00000000..22fc3e9b --- /dev/null +++ b/test/kernel/src/syntax/tree_sitter_smoke_test.dart @@ -0,0 +1,80 @@ +/// Real-library smoke test for `TreeSitterService`. Dlopen's the vendored +/// `native/linux-x64/libtree-sitter.so`, loads the bundled `dart` grammar +/// from `assets/grammars/dart.wasm` + `assets/queries/dart.scm` straight off +/// the filesystem (no `rootBundle`), and verifies highlight produces sane +/// spans over a tiny Dart program. Catches FFI-signature regressions that +/// the fake-driven branch tests cannot. +/// +/// Skips cleanly on non-Linux hosts and on Linux hosts where the vendored +/// library hasn't been built yet. +library; + +import 'dart:ffi'; +import 'dart:io'; + +import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart'; +import 'package:clide/kernel/src/syntax/tree_sitter_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _libPath = 'native/linux-x64/libtree-sitter.so'; + +void main() { + if (!Platform.isLinux || !File(_libPath).existsSync()) return; + + group('TreeSitterService — native smoke (real libtree-sitter.so + dart grammar)', () { + late TreeSitterService svc; + + setUpAll(() { + final dylib = DynamicLibrary.open(_libPath); + svc = TreeSitterService( + lib: TreeSitterLib.fromDynamicLibrary(dylib), + grammarBytes: (lang) async => File('assets/grammars/$lang.wasm').readAsBytes(), + grammarQuery: (lang) async { + final f = File('assets/queries/$lang.scm'); + return await f.exists() ? f.readAsString() : null; + }, + ); + }); + + // Intentionally do NOT call svc.dispose() at teardown — wasmtime's + // store-delete path collides with the Flutter test runner's process + // finalization (libc `double free` on exit). The OS reclaims everything + // when the runner process exits. + + test('loads the dart grammar end-to-end', () async { + expect(await svc.hasGrammar('main.dart'), isTrue); + expect(await svc.languageFor('main.dart'), 'dart'); + expect(svc.loadedLanguages, contains('dart')); + }); + + test('highlight returns at least one span for a tiny dart program', () async { + const source = 'void main() {\n print("hi");\n}\n'; + final r = await svc.highlight('main.dart', source); + + expect(r.spans, isNotEmpty); + // Every span must be in-range and well-ordered. + for (final s in r.spans) { + expect(s.start, lessThanOrEqualTo(s.end)); + expect(s.end, lessThanOrEqualTo(source.length)); + expect(s.role, isNotEmpty); + } + // The captures the upstream dart.scm emits cover at least one of these + // semantic roles for a `void main()` program — assert intersection so + // the test survives minor query reshufflings. + final roles = r.spans.map((s) => s.role).toSet(); + expect( + roles.intersection({'keyword', 'type', 'function', 'string', 'punctuation.bracket', 'punctuation.delimiter'}), + isNotEmpty, + reason: 'expected at least one familiar dart role, got: $roles', + ); + }); + + test('a second highlight reuses the cached grammar (no re-load)', () async { + // Re-issue a highlight; the grammar cache hit is internal but if the + // service is healthy this just completes without throwing and returns + // a sane result. + final r = await svc.highlight('main.dart', 'class X {}'); + expect(r.spans, isNotEmpty); + }); + }); +}