diff --git a/.claude/skills/agent-eval/corpus.json b/.claude/skills/agent-eval/corpus.json index 9d1606a75..8cea298dc 100644 --- a/.claude/skills/agent-eval/corpus.json +++ b/.claude/skills/agent-eval/corpus.json @@ -247,6 +247,29 @@ "question": "How do shadcn-svelte components compose and apply their styling?" } ], + "Slint": [ + { + "name": "slint-nodejs-template", + "repo": "https://github.com/slint-ui/slint-nodejs-template", + "size": "Small", + "files": "~6", + "question": "How does the JavaScript entry point load the Slint UI and update the MainWindow state?" + }, + { + "name": "SurrealismUI", + "repo": "https://github.com/Surrealism-All/SurrealismUI", + "size": "Medium", + "files": "~3000", + "question": "How does SurrealismUI define and compose a Button component from shared style and theme primitives?" + }, + { + "name": "slint", + "repo": "https://github.com/slint-ui/slint", + "size": "Large", + "files": "~4300", + "question": "How does the Slint example gallery UI compose component definitions and callback handlers from .slint files into the viewer runtime?" + } + ], "Lua": [ { "name": "lualine.nvim", @@ -424,4 +447,4 @@ "question": "When a ggplot object is printed, how does the plot actually get built and drawn \u2014 trace the path from print/plot to where geoms render. Name the key functions in order." } ] -} \ No newline at end of file +} diff --git a/CHANGELOG.md b/CHANGELOG.md index abc341c09..129bce8c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- CodeGraph now indexes Slint (`.slint`) UI files, including components, globals, interfaces, structs, enums, properties, callbacks, functions, imports, and call edges. (#648, #916) ## [1.1.6] - 2026-06-30 diff --git a/README.md b/README.md index 7b6c33932..eefad41c4 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,7 @@ The reliable, universal payoff is **surgical context and speed**: CodeGraph coll | **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 | | **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes | | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config | -| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Svelte, Vue, Astro, Liquid, Pascal/Delphi | +| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Slint, Svelte, Vue, Astro, Liquid, Pascal/Delphi | | **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks | | **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules | | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only | @@ -714,6 +714,7 @@ is written): | Lua | `.lua` | Full support (functions, methods with receivers, local variables, `require` imports, call edges) | | R | `.R` `.r` | Full support (functions in every assignment form, S4/R5/R6 classes with methods, `library`/`require` imports, `source()` file references, call edges) | | Luau | `.luau` | Full support (everything in Lua, plus `type`/`export type` aliases, typed signatures, and Roblox instance-path `require`) | +| Slint | `.slint` | Full support (components, globals, interfaces, structs, enums, properties, callbacks, functions, imports, and call edges) | ## Measured cross-file coverage diff --git a/__tests__/slint-extraction.test.ts b/__tests__/slint-extraction.test.ts new file mode 100644 index 000000000..4ac37e24e --- /dev/null +++ b/__tests__/slint-extraction.test.ts @@ -0,0 +1,169 @@ +/** + * Slint extraction tests. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { extractFromSource } from '../src/extraction'; +import { + detectLanguage, + getSupportedLanguages, + initGrammars, + isLanguageSupported, + isSourceFile, + loadGrammarsForLanguages, +} from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['slint']); +}); + +describe('Slint Extraction', () => { + describe('Language detection', () => { + it('should detect Slint files', () => { + expect(detectLanguage('ui/main.slint')).toBe('slint'); + expect(isSourceFile('ui/main.slint')).toBe(true); + }); + + it('should report Slint as supported', () => { + expect(isLanguageSupported('slint')).toBe(true); + expect(getSupportedLanguages()).toContain('slint'); + }); + }); + + it('extracts components, globals, interfaces, structs, enums, members, and imports', () => { + const code = ` +import { Button, VerticalBox } from "std-widgets.slint"; + +export struct Person { + name: string, + age: int, +} + +export enum Mode { + Light, + Dark, +} + +global AppState { + in-out property counter: 0; + callback increment(int); + + public function bump(delta: int) -> int { + counter += delta; + return counter; + } +} + +export interface Greeter { + callback greet(string) -> string; + function reset(); +} + +export component MainWindow inherits Window implements Greeter { + in property title: "Demo"; + out property doubled: AppState.counter * 2; + callback accepted(string); + + function handle-click(name: string) { + AppState.bump(1); + accepted(name); + } + + VerticalBox { + Button { + text: title; + clicked => { + root.handle-click("world"); + } + } + } +} +`; + const result = extractFromSource('ui/main.slint', code); + const byName = new Map(result.nodes.map((n) => [`${n.kind}:${n.name}`, n])); + + expect(byName.get('import:std-widgets.slint')?.signature).toContain('Button'); + expect(byName.get('struct:Person')?.isExported).toBe(true); + expect(byName.get('field:name')?.qualifiedName).toBe('Person::name'); + expect(byName.get('enum:Mode')?.isExported).toBe(true); + expect(byName.get('enum_member:Light')?.qualifiedName).toBe('Mode::Light'); + expect(byName.get('class:AppState')?.language).toBe('slint'); + expect(byName.get('property:counter')?.qualifiedName).toBe('AppState::counter'); + expect(byName.get('method:bump')?.signature).toBe('(delta: int) -> int'); + expect(byName.get('interface:Greeter')?.isExported).toBe(true); + expect(byName.get('method:greet')?.qualifiedName).toBe('Greeter::greet'); + expect(byName.get('component:MainWindow')?.isExported).toBe(true); + expect(byName.get('property:title')?.qualifiedName).toBe('MainWindow::title'); + expect(byName.get('method:handle-click')?.qualifiedName).toBe('MainWindow::handle-click'); + }); + + it('records Slint inheritance, implemented interfaces, child components, and calls', () => { + const code = ` +interface Greeter { + callback greet(string); +} + +export component MainWindow inherits Window implements Greeter { + callback accepted(string); + + function handle-click(name: string) { + accepted(name); + } + + Button { + clicked => { + root.handle-click("world"); + } + } +} +`; + const result = extractFromSource('ui/main.slint', code); + const main = result.nodes.find((n) => n.kind === 'component' && n.name === 'MainWindow'); + const handler = result.nodes.find((n) => n.kind === 'method' && n.name === 'handle-click'); + expect(main).toBeDefined(); + expect(handler).toBeDefined(); + + expect(result.unresolvedReferences).toEqual( + expect.arrayContaining([ + expect.objectContaining({ fromNodeId: main?.id, referenceKind: 'extends', referenceName: 'Window' }), + expect.objectContaining({ fromNodeId: main?.id, referenceKind: 'implements', referenceName: 'Greeter' }), + expect.objectContaining({ fromNodeId: main?.id, referenceKind: 'references', referenceName: 'Button' }), + expect.objectContaining({ fromNodeId: main?.id, referenceKind: 'calls', referenceName: 'handle-click' }), + expect.objectContaining({ fromNodeId: handler?.id, referenceKind: 'calls', referenceName: 'accepted' }), + ]) + ); + }); + + it('records Slint re-export barrels as import dependencies', () => { + const code = ` +export { AboutPage } from "about_page.slint"; +export { TableViewPage, TableViewPageAdapter } from "table_view_page.slint"; +`; + const result = extractFromSource('ui/pages/pages.slint', code); + const file = result.nodes.find((n) => n.kind === 'file' && n.name === 'pages.slint'); + expect(file).toBeDefined(); + + expect(result.nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'import', + name: 'about_page.slint', + signature: 'export { AboutPage } from "about_page.slint";', + }), + expect.objectContaining({ + kind: 'import', + name: 'table_view_page.slint', + signature: 'export { TableViewPage, TableViewPageAdapter } from "table_view_page.slint";', + }), + ]) + ); + expect(result.unresolvedReferences).toEqual( + expect.arrayContaining([ + expect.objectContaining({ fromNodeId: file?.id, referenceKind: 'imports', referenceName: 'AboutPage' }), + expect.objectContaining({ fromNodeId: file?.id, referenceKind: 'imports', referenceName: 'TableViewPage' }), + expect.objectContaining({ fromNodeId: file?.id, referenceKind: 'imports', referenceName: 'TableViewPageAdapter' }), + ]) + ); + }); +}); diff --git a/site/src/content/docs/reference/languages.md b/site/src/content/docs/reference/languages.md index 0c5587773..23a7927a3 100644 --- a/site/src/content/docs/reference/languages.md +++ b/site/src/content/docs/reference/languages.md @@ -31,3 +31,4 @@ Language support is automatic from the file extension — there's nothing to con | Lua | `.lua` | Full support (functions, methods, locals, `require` imports, call edges) | | R | `.R`, `.r` | Full support (functions, S4/R5/R6 classes with methods, `library`/`require` imports, `source()` file references, call edges) | | Luau | `.luau` | Full support (Lua, plus typed signatures, `type` aliases, Roblox `require`) | +| Slint | `.slint` | Full support (components, globals, interfaces, structs, enums, properties, callbacks, functions, imports, call edges) | diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 1b15996c0..000a4df6e 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -39,6 +39,7 @@ const WASM_GRAMMAR_FILES: Record = { r: 'tree-sitter-r.wasm', luau: 'tree-sitter-luau.wasm', objc: 'tree-sitter-objc.wasm', + slint: 'tree-sitter-slint.wasm', }; /** @@ -108,6 +109,7 @@ export const EXTENSION_MAP: Record = { '.luau': 'luau', '.m': 'objc', '.mm': 'objc', + '.slint': 'slint', // XML: file-level tracking; the MyBatis extractor matches `` // shape and emits SQL-statement nodes (other XML returns empty). '.xml': 'xml', @@ -220,8 +222,9 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise> = { typescript: typescriptExtractor, @@ -51,4 +52,5 @@ export const EXTRACTORS: Partial> = { r: rExtractor, luau: luauExtractor, objc: objcExtractor, + slint: slintExtractor, }; diff --git a/src/extraction/languages/slint.ts b/src/extraction/languages/slint.ts new file mode 100644 index 000000000..ed71ba7c0 --- /dev/null +++ b/src/extraction/languages/slint.ts @@ -0,0 +1,215 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getChildByField, getNodeText } from '../tree-sitter-helpers'; +import type { LanguageExtractor } from '../tree-sitter-types'; + +function slintSignature(node: SyntaxNode, source: string): string | undefined { + const args: string[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && node.fieldNameForNamedChild(i) === 'arguments') { + args.push(getNodeText(child, source)); + } + } + const returnType = getChildByField(node, 'return_type'); + const params = `(${args.join(', ')})`; + return returnType ? `${params} -> ${getNodeText(returnType, source)}` : params; +} + +function slintVisibility(node: SyntaxNode): 'public' | 'private' | 'protected' | undefined { + const visibility = getChildByField(node, 'visibility'); + if (!visibility) { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'function_visibility') { + const text = child.text; + if (text === 'public' || text === 'private' || text === 'protected') return text; + } + } + return undefined; + } + + const text = visibility.text.replace('_', '-'); + if (text === 'private') return 'private'; + return 'public'; +} + +function slintIsExported(node: SyntaxNode): boolean { + let current = node.parent; + while (current) { + if (current.type === 'exported_definition') return true; + if (current.type === 'rust_attr') { + current = current.parent; + continue; + } + current = current.parent; + } + return false; +} + +function slintImportModule(node: SyntaxNode, source: string): string | null { + const stringNode = node.namedChildren.find((c) => c.type === 'string_value'); + if (!stringNode) return null; + return getNodeText(stringNode, source).replace(/^"|"$/g, ''); +} + +function slintReExportInfo(node: SyntaxNode, source: string): { moduleName: string; names: SyntaxNode[] } | null { + const moduleName = slintImportModule(node, source); + if (!moduleName) return null; + const names = node.namedChildren + .filter((c) => c.type === 'export_type') + .map((c) => getChildByField(c, 'local_name') ?? c.namedChildren.find((n) => n.type === 'user_type_identifier')) + .filter((c): c is SyntaxNode => !!c); + return names.length > 0 ? { moduleName, names } : null; +} + +export const slintExtractor: LanguageExtractor = { + functionTypes: ['function_definition', 'function_declaration'], + classTypes: ['global_definition'], + methodTypes: ['function_definition', 'function_declaration', 'callback'], + interfaceTypes: ['interface_definition'], + structTypes: ['struct_definition'], + enumTypes: ['enum_definition'], + enumMemberTypes: ['user_type_identifier'], + typeAliasTypes: [], + importTypes: ['import_statement'], + callTypes: ['function_call'], + variableTypes: [], + propertyTypes: ['property', 'binding_alias'], + fieldTypes: ['struct_field_definition'], + nameField: 'name', + bodyField: 'body', + paramsField: 'arguments', + returnField: 'return_type', + visitNode: (node, ctx) => { + if (node.type === 'export_statement') { + const reExport = slintReExportInfo(node, ctx.source); + if (!reExport) return false; + + const importNode = ctx.createNode('import', reExport.moduleName, node, { + signature: getNodeText(node, ctx.source).trim(), + }); + const fromNodeId = ctx.nodeStack[ctx.nodeStack.length - 1] ?? importNode?.id; + if (!fromNodeId) return true; + + for (const nameNode of reExport.names) { + ctx.addUnresolvedReference({ + fromNodeId, + referenceName: getNodeText(nameNode, ctx.source), + referenceKind: 'imports', + line: nameNode.startPosition.row + 1, + column: nameNode.startPosition.column, + }); + } + return true; + } + + if (node.type === 'component_definition') { + const nameNode = getChildByField(node, 'name'); + if (!nameNode) return false; + + const component = ctx.createNode('component', getNodeText(nameNode, ctx.source), node, { + isExported: slintIsExported(node), + }); + if (!component) return true; + + for (const modifier of node.namedChildren.filter((c) => c.type === 'component_modifier')) { + const base = getChildByField(modifier, 'base_type'); + if (base) { + ctx.addUnresolvedReference({ + fromNodeId: component.id, + referenceName: getNodeText(base, ctx.source), + referenceKind: 'extends', + line: base.startPosition.row + 1, + column: base.startPosition.column, + }); + } + const implementsClause = modifier.namedChildren.find((c) => c.type === 'implements_clause'); + if (implementsClause) { + for (const iface of implementsClause.namedChildren.filter((c) => c.type === 'user_type_identifier')) { + ctx.addUnresolvedReference({ + fromNodeId: component.id, + referenceName: getNodeText(iface, ctx.source), + referenceKind: 'implements', + line: iface.startPosition.row + 1, + column: iface.startPosition.column, + }); + } + } + } + + ctx.pushScope(component.id); + const body = getChildByField(node, 'body') ?? node.namedChildren.find((c) => c.type === 'block'); + if (body) { + for (let i = 0; i < body.namedChildCount; i++) { + const child = body.namedChild(i); + if (child) ctx.visitNode(child); + } + } + ctx.popScope(); + return true; + } + + if (node.type === 'component') { + const typeNode = getChildByField(node, 'type'); + const fromNodeId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (typeNode && fromNodeId) { + ctx.addUnresolvedReference({ + fromNodeId, + referenceName: getNodeText(typeNode, ctx.source), + referenceKind: 'references', + line: typeNode.startPosition.row + 1, + column: typeNode.startPosition.column, + }); + } + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.type !== 'user_type_identifier') ctx.visitNode(child); + } + return true; + } + + return false; + }, + resolveName: (node, source) => { + if (node.type === 'callback' || node.type === 'property' || node.type === 'binding_alias') { + const name = getChildByField(node, 'name'); + return name ? getNodeText(name, source) : undefined; + } + return undefined; + }, + resolveBody: (node, bodyField) => { + const standard = getChildByField(node, bodyField); + if (standard) return standard; + if (node.type === 'global_definition') { + return node.namedChildren.find((c) => c.type === 'global_block') ?? null; + } + if (node.type === 'interface_definition') { + return node.namedChildren.find((c) => c.type === 'interface_block') ?? null; + } + if (node.type === 'struct_definition') { + return node.namedChildren.find((c) => c.type === 'struct_block') ?? null; + } + if (node.type === 'enum_definition') { + return node.namedChildren.find((c) => c.type === 'enum_block') ?? null; + } + if (node.type === 'function_definition') { + return node.namedChildren.find((c) => c.type === 'imperative_block') ?? null; + } + return null; + }, + getSignature: slintSignature, + getVisibility: slintVisibility, + isExported: slintIsExported, + extractPropertyName: (node, source) => { + const name = getChildByField(node, 'name'); + return name ? getNodeText(name, source) : null; + }, + extractImport: (node, source) => { + const moduleName = slintImportModule(node, source); + if (!moduleName) return null; + return { + moduleName, + signature: getNodeText(node, source).trim(), + }; + }, +}; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 36e43cd82..8175ed8cc 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1344,7 +1344,8 @@ export class TreeSitterExtractor { parentNode.kind === 'interface' || parentNode.kind === 'trait' || parentNode.kind === 'enum' || - parentNode.kind === 'module' + parentNode.kind === 'module' || + (parentNode.kind === 'component' && parentNode.language === 'slint') ); } @@ -1714,7 +1715,8 @@ export class TreeSitterExtractor { // Skip forward declarations and type references (no body = not a definition) // — EXCEPT C# positional records (`record struct M(decimal Amount);`), // complete definitions with no body block. (#831) - const body = getChildByField(node, this.extractor.bodyField); + const body = this.extractor.resolveBody?.(node, this.extractor.bodyField) + ?? getChildByField(node, this.extractor.bodyField); if (!body && node.type !== 'record_declaration') return; const name = extractName(node, this.source, this.extractor); diff --git a/src/extraction/wasm/tree-sitter-slint.wasm b/src/extraction/wasm/tree-sitter-slint.wasm new file mode 100644 index 000000000..bc2305f48 Binary files /dev/null and b/src/extraction/wasm/tree-sitter-slint.wasm differ diff --git a/src/types.ts b/src/types.ts index a3122bf9a..3a1afbbb8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -91,6 +91,7 @@ export const LANGUAGES = [ 'luau', 'objc', 'r', + 'slint', 'yaml', 'twig', 'xml',