From 3e54d9a6edf46af91fb911e51132ef8ccb32da89 Mon Sep 17 00:00:00 2001 From: Junye Ji Date: Thu, 28 May 2026 22:05:31 -0700 Subject: [PATCH] Add Lean language support --- README.md | 14 +- __tests__/lean-extraction.test.ts | 408 +++++++++ __tests__/resolution.test.ts | 83 ++ .../docs/getting-started/configuration.md | 2 +- site/src/content/docs/guides/indexing.md | 2 +- site/src/content/docs/reference/languages.md | 3 + site/src/content/docs/troubleshooting.md | 2 +- src/extraction/grammars.ts | 7 +- src/extraction/index.ts | 2 + src/extraction/lean-extractor.ts | 848 ++++++++++++++++++ src/extraction/tree-sitter.ts | 6 + src/extraction/wasm/tree-sitter-lean.wasm | Bin 0 -> 3646288 bytes src/resolution/import-resolver.ts | 37 + src/resolution/index.ts | 6 +- src/resolution/lean-lsp-resolver.ts | 356 ++++++++ src/resolution/name-matcher.ts | 56 +- src/resolution/types.ts | 2 +- src/types.ts | 1 + 18 files changed, 1821 insertions(+), 14 deletions(-) create mode 100644 __tests__/lean-extraction.test.ts create mode 100644 src/extraction/lean-extractor.ts create mode 100755 src/extraction/wasm/tree-sitter-lean.wasm create mode 100644 src/resolution/lean-lsp-resolver.ts diff --git a/README.md b/README.md index 08f2838ce..f583fb290 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ The gains scale with codebase size: on large repos the agent answers from the in | **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, Dart, Lua, Luau, Svelte, Liquid, Pascal/Delphi | +| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Dart, Lean, Lua, Luau, Svelte, Liquid, Pascal/Delphi | | **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 14 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 | @@ -465,7 +465,7 @@ nothing to wire up per language. What it skips out of the box: - **Dependency, build, and cache directories** — `node_modules`, `vendor`, - `dist`, `build`, `target`, `.venv`, `Pods`, `.next`, and the like across every + `dist`, `build`, `target`, `.lake`, `.venv`, `Pods`, `.next`, and the like across every [supported stack](#supported-languages) — so the graph is your code, not third-party noise. This holds even with no `.gitignore`. - **Anything in your `.gitignore`** — honored in git repos via git, and in @@ -526,6 +526,7 @@ is written): | Kotlin | `.kt`, `.kts` | Full support | | Scala | `.scala`, `.sc` | Full support (classes, traits, methods, type aliases, Scala 3 enums) | | Dart | `.dart` | Full support | +| Lean | `.lean` | Static extraction with optional Lean/Lake LSP definition resolution | | Svelte | `.svelte` | Full support (script extraction, Svelte 5 runes, SvelteKit routes) | | Vue | `.vue` | Full support (script + script-setup extraction, Nuxt page/API/middleware routes) | | Liquid | `.liquid` | Full support | @@ -533,6 +534,13 @@ is written): | Lua | `.lua` | Full support (functions, methods with receivers, local variables, `require` imports, call edges) | | Luau | `.luau` | Full support (everything in Lua, plus `type`/`export type` aliases, typed signatures, and Roblox instance-path `require`) | +Lean files index without requiring a local Lean toolchain. When available, +CodeGraph tries `lake env lean --server` (or `lean --server`) for better +definition edges. Set `CODEGRAPH_LEAN_SEMANTICS=off` to force static-only +indexing, or override the command with `CODEGRAPH_LEAN_LSP_COMMAND`; +`CODEGRAPH_LEAN_LSP_TIMEOUT_MS` and `CODEGRAPH_LEAN_LSP_REF_LIMIT` bound the +best-effort LSP pass. + ## Troubleshooting **"CodeGraph not initialized"** — Run `codegraph init` in your project directory first. @@ -546,7 +554,7 @@ is written): **MCP server not connecting** — Ensure the project is initialized/indexed, verify the path in your MCP config, and check that `codegraph serve --mcp` works from the command line. -**Missing symbols** — The MCP server auto-syncs on save (wait a couple seconds). Run `codegraph sync` manually if needed. Check that the file's language is supported and isn't inside a `.gitignore`d or default-excluded directory (e.g. `node_modules`, `dist`). +**Missing symbols** — The MCP server auto-syncs on save (wait a couple seconds). Run `codegraph sync` manually if needed. Check that the file's language is supported and isn't inside a `.gitignore`d or default-excluded directory (e.g. `node_modules`, `dist`, `.lake`). ## Star History diff --git a/__tests__/lean-extraction.test.ts b/__tests__/lean-extraction.test.ts new file mode 100644 index 000000000..1e922c712 --- /dev/null +++ b/__tests__/lean-extraction.test.ts @@ -0,0 +1,408 @@ +import { describe, expect, it, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { DatabaseConnection } from '../src/db'; +import { extractFromSource, scanDirectory } from '../src/extraction'; +import { + detectLanguage, + getLanguageDisplayName, + getSupportedLanguages, + initGrammars, + isGrammarLoaded, + isLanguageSupported, + loadGrammarsForLanguages, +} from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['lean']); +}); + +const LEAN_ENV_KEYS = [ + 'CODEGRAPH_LEAN_SEMANTICS', + 'CODEGRAPH_LEAN_LSP_COMMAND', + 'CODEGRAPH_LEAN_LSP_TIMEOUT_MS', + 'CODEGRAPH_LEAN_LSP_REF_LIMIT', + 'FAKE_LEAN_TARGET_URI', + 'FAKE_LEAN_TARGET_LINE', + 'FAKE_LEAN_TARGET_COLUMN', + 'FAKE_LEAN_SKIP_DEFINITION', +] as const; + +function snapshotLeanEnv(): Partial> { + const snapshot: Partial> = {}; + for (const key of LEAN_ENV_KEYS) { + if (process.env[key] !== undefined) snapshot[key] = process.env[key]; + } + return snapshot; +} + +function restoreLeanEnv(snapshot: Partial>): void { + for (const key of LEAN_ENV_KEYS) { + if (snapshot[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = snapshot[key]; + } + } +} + +function writeFakeLeanLsp(): string { + const serverPath = path.join(os.tmpdir(), `codegraph-fake-lean-lsp-${process.pid}-${Date.now()}.cjs`); + fs.writeFileSync(serverPath, String.raw` +const { pathToFileURL } = require('url'); + +let buffer = Buffer.alloc(0); + +function send(message) { + const body = Buffer.from(JSON.stringify(message), 'utf8'); + process.stdout.write(Buffer.from('Content-Length: ' + body.length + '\r\n\r\n', 'ascii')); + process.stdout.write(body); +} + +function handle(message) { + if (!message || !message.id) return; + if (message.method === 'initialize') { + send({ jsonrpc: '2.0', id: message.id, result: { capabilities: { definitionProvider: true } } }); + return; + } + if (message.method === 'textDocument/definition') { + if (process.env.FAKE_LEAN_SKIP_DEFINITION === '1') return; + const uri = process.env.FAKE_LEAN_TARGET_URI || pathToFileURL('/tmp/outside.lean').href; + const line = Number.parseInt(process.env.FAKE_LEAN_TARGET_LINE || '0', 10); + const character = Number.parseInt(process.env.FAKE_LEAN_TARGET_COLUMN || '0', 10); + send({ + jsonrpc: '2.0', + id: message.id, + result: { + uri, + range: { + start: { line, character }, + end: { line, character: character + 6 }, + }, + }, + }); + } +} + +process.stdin.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (true) { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd < 0) return; + const header = buffer.slice(0, headerEnd).toString('ascii'); + const match = header.match(/Content-Length:\s*(\d+)/i); + if (!match) { + buffer = buffer.slice(headerEnd + 4); + continue; + } + const length = Number.parseInt(match[1], 10); + const start = headerEnd + 4; + const end = start + length; + if (buffer.length < end) return; + const body = buffer.slice(start, end).toString('utf8'); + buffer = buffer.slice(end); + try { + handle(JSON.parse(body)); + } catch { + // Ignore malformed input. + } + } +}); +`, 'utf-8'); + return serverPath; +} + +describe('Lean support', () => { + it('detects and reports Lean as a grammar-backed supported language', () => { + expect(detectLanguage('Example.lean')).toBe('lean'); + expect(isLanguageSupported('lean')).toBe(true); + expect(isGrammarLoaded('lean')).toBe(true); + expect(getSupportedLanguages()).toContain('lean'); + expect(getLanguageDisplayName('lean')).toBe('Lean'); + }); + + it('extracts namespaces, imports, declarations, fields, constructors, docs, visibility, and references', () => { + const code = ` +import Foo.Bar + +namespace Demo + +/-- doubles a number -/ +def twice (n : Nat) : Nat := Nat.succ n + +private lemma hidden : twice 0 = 1 := by + rfl + +structure Point where + /-- x coordinate -/ + x : Nat + y : Nat := Nat.zero + +class Size (α : Type) where + size : α → Nat + +inductive Color where + | red + | blue : Color + +opaque mystery : Nat +axiom trusted : Nat +constant answer : Nat +abbrev Alias := Nat + +end Demo +`; + + const result = extractFromSource('Demo.lean', code); + expect(result.errors).toHaveLength(0); + + expect(result.nodes.find((n) => n.kind === 'file' && n.language === 'lean')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'import' && n.name === 'Foo.Bar')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'namespace' && n.qualifiedName === 'Demo')).toBeDefined(); + + const twice = result.nodes.find((n) => n.kind === 'function' && n.qualifiedName === 'Demo.twice'); + expect(twice?.docstring).toBe('doubles a number'); + expect(twice?.isExported).toBe(true); + + const hidden = result.nodes.find((n) => n.name === 'hidden'); + expect(hidden).toMatchObject({ kind: 'function', visibility: 'private', isExported: false }); + + expect(result.nodes.find((n) => n.kind === 'struct' && n.qualifiedName === 'Demo.Point')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'class' && n.qualifiedName === 'Demo.Size')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'enum' && n.qualifiedName === 'Demo.Color')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'field' && n.qualifiedName === 'Demo.Point.x')).toMatchObject({ + docstring: 'x coordinate', + }); + expect(result.nodes.find((n) => n.kind === 'field' && n.qualifiedName === 'Demo.Size.size')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'enum_member' && n.qualifiedName === 'Demo.Color.red')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'enum_member' && n.qualifiedName === 'Demo.Color.blue')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'type_alias' && n.qualifiedName === 'Demo.Alias')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'constant' && n.qualifiedName === 'Demo.trusted')).toBeDefined(); + + expect(result.unresolvedReferences).toEqual(expect.arrayContaining([ + expect.objectContaining({ referenceKind: 'imports', referenceName: 'Foo.Bar' }), + expect.objectContaining({ referenceKind: 'calls', referenceName: 'Nat.succ' }), + expect.objectContaining({ referenceKind: 'calls', referenceName: 'twice' }), + expect.objectContaining({ referenceKind: 'references', referenceName: 'Nat' }), + ])); + expect(result.unresolvedReferences.some((r) => r.fromNodeId === twice?.id && r.referenceName === 'twice')).toBe(false); + }); + + it('extracts Lean open/export/section/attributes/extends/anonymous instances and candidates', () => { + const code = ` +import Foo.Bar +open Helpers +export Helpers (helper) + +namespace Demo +section Tools + +@[simp, inline] def use (n : Nat) : Nat := helper n + +structure Point extends ToString where + x : Nat + +class Sized (α : Type) extends Inhabited α where + size : α → Nat + +instance : Inhabited Point where + default := { x := 0 } + +theorem t : use 0 = 0 := by + simp [use] + +end Tools +end Demo +`; + + const result = extractFromSource('Demo.lean', code); + expect(result.errors).toHaveLength(0); + + expect(result.nodes.find((n) => n.kind === 'import' && n.name === 'open Helpers')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'export' && n.name === 'Helpers (helper)')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'module' && n.name === 'Tools')).toBeDefined(); + + const use = result.nodes.find((n) => n.kind === 'function' && n.qualifiedName === 'Demo.use'); + expect(use?.decorators).toEqual(['simp', 'inline']); + + expect(result.nodes.find((n) => n.kind === 'constant' && n.name.startsWith('instInhabitedPoint@'))).toBeDefined(); + expect(result.unresolvedReferences).toEqual(expect.arrayContaining([ + expect.objectContaining({ + referenceKind: 'extends', + referenceName: 'ToString', + candidates: expect.arrayContaining(['Demo.ToString', 'Helpers.ToString']), + }), + expect.objectContaining({ + referenceKind: 'extends', + referenceName: 'Inhabited', + candidates: expect.arrayContaining(['Demo.Inhabited', 'Helpers.Inhabited']), + }), + expect.objectContaining({ + referenceKind: 'calls', + referenceName: 'helper', + candidates: expect.arrayContaining(['Demo.helper', 'Helpers.helper', 'Foo.Bar.helper']), + }), + expect.objectContaining({ + referenceKind: 'references', + referenceName: 'use', + candidates: expect.arrayContaining(['Demo.use']), + }), + ])); + }); + + it('resolves local Lean module imports and qualified calls end-to-end', async () => { + const env = snapshotLeanEnv(); + process.env.CODEGRAPH_LEAN_SEMANTICS = 'off'; + const tempProject = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-lean-e2e-')); + let cg: CodeGraph | null = null; + try { + fs.mkdirSync(path.join(tempProject, 'Foo'), { recursive: true }); + fs.writeFileSync( + path.join(tempProject, 'Foo', 'Bar.lean'), + `namespace Foo\n\ndef bar (n : Nat) : Nat := n\n\nend Foo\n` + ); + fs.writeFileSync( + path.join(tempProject, 'Main.lean'), + `import Foo.Bar\n\nnamespace Main\n\ndef main : Nat := Foo.bar 1\n\nend Main\n` + ); + + cg = await CodeGraph.init(tempProject, { index: true }); + const db = DatabaseConnection.open(path.join(tempProject, '.codegraph', 'codegraph.db')); + const rows = db.getDb().prepare(` + select src.kind as srcKind, src.file_path as srcPath, dst.kind as dstKind, + dst.file_path as dstPath, dst.qualified_name as dstQName, e.kind as edgeKind + from edges e + join nodes src on e.source = src.id + join nodes dst on e.target = dst.id + where src.file_path = 'Main.lean' + `).all() as Array<{ + srcKind: string; + srcPath: string; + dstKind: string; + dstPath: string; + dstQName: string; + edgeKind: string; + }>; + db.close(); + + expect(rows.some((r) => + r.srcKind === 'file' && + r.edgeKind === 'imports' && + r.dstKind === 'file' && + r.dstPath === 'Foo/Bar.lean' + )).toBe(true); + expect(rows.some((r) => + r.edgeKind === 'calls' && + r.dstKind === 'function' && + r.dstQName === 'Foo.bar' + )).toBe(true); + } finally { + if (cg) cg.destroy(); + fs.rmSync(tempProject, { recursive: true, force: true }); + restoreLeanEnv(env); + } + }); + + it('uses fake Lean LSP definition results when semantic resolution is available', async () => { + const env = snapshotLeanEnv(); + const tempProject = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-lean-lsp-')); + const fakeServer = writeFakeLeanLsp(); + let cg: CodeGraph | null = null; + try { + fs.writeFileSync(path.join(tempProject, 'Lib.lean'), `namespace Lib\n\ndef target : Nat := 1\n\nend Lib\n`); + fs.writeFileSync(path.join(tempProject, 'Main.lean'), `import Lib\n\nnamespace Main\n\ndef caller : Nat := alias 0\n\nend Main\n`); + + process.env.CODEGRAPH_LEAN_SEMANTICS = 'auto'; + process.env.CODEGRAPH_LEAN_LSP_COMMAND = `${process.execPath} ${fakeServer}`; + process.env.CODEGRAPH_LEAN_LSP_TIMEOUT_MS = '1000'; + process.env.CODEGRAPH_LEAN_LSP_REF_LIMIT = '10'; + process.env.FAKE_LEAN_TARGET_URI = new URL(`file://${path.join(tempProject, 'Lib.lean')}`).href; + process.env.FAKE_LEAN_TARGET_LINE = '2'; + process.env.FAKE_LEAN_TARGET_COLUMN = '4'; + + cg = await CodeGraph.init(tempProject, { index: true }); + const caller = cg.getNodesByKind('function').find((n) => n.name === 'caller'); + expect(caller).toBeDefined(); + const edge = cg.getOutgoingEdges(caller!.id).find((e) => e.kind === 'calls'); + expect(edge?.metadata?.resolvedBy).toBe('lean-lsp'); + expect(cg.getNode(edge!.target)?.qualifiedName).toBe('Lib.target'); + } finally { + if (cg) cg.destroy(); + fs.rmSync(tempProject, { recursive: true, force: true }); + fs.rmSync(fakeServer, { force: true }); + restoreLeanEnv(env); + } + }); + + it('falls back when Lean LSP returns a project-external location, times out, or is disabled', async () => { + const run = async (configure: (project: string, fakeServer: string) => void) => { + const env = snapshotLeanEnv(); + const tempProject = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-lean-lsp-fallback-')); + const fakeServer = writeFakeLeanLsp(); + let cg: CodeGraph | null = null; + try { + fs.writeFileSync(path.join(tempProject, 'Lib.lean'), `namespace Lib\n\ndef target : Nat := 1\n\nend Lib\n`); + fs.writeFileSync(path.join(tempProject, 'Main.lean'), `import Lib\n\nnamespace Main\n\ndef caller : Nat := alias 0\n\nend Main\n`); + configure(tempProject, fakeServer); + + cg = await CodeGraph.init(tempProject, { index: true }); + const caller = cg.getNodesByKind('function').find((n) => n.name === 'caller'); + expect(caller).toBeDefined(); + expect(cg.getOutgoingEdges(caller!.id).filter((e) => e.kind === 'calls')).toHaveLength(0); + } finally { + if (cg) cg.destroy(); + fs.rmSync(tempProject, { recursive: true, force: true }); + fs.rmSync(fakeServer, { force: true }); + restoreLeanEnv(env); + } + }; + + await run((_project, fakeServer) => { + process.env.CODEGRAPH_LEAN_SEMANTICS = 'auto'; + process.env.CODEGRAPH_LEAN_LSP_COMMAND = `${process.execPath} ${fakeServer}`; + process.env.CODEGRAPH_LEAN_LSP_TIMEOUT_MS = '1000'; + process.env.FAKE_LEAN_TARGET_URI = new URL('file:///tmp/outside.lean').href; + }); + + await run((project, fakeServer) => { + process.env.CODEGRAPH_LEAN_SEMANTICS = 'auto'; + process.env.CODEGRAPH_LEAN_LSP_COMMAND = `${process.execPath} ${fakeServer}`; + process.env.CODEGRAPH_LEAN_LSP_TIMEOUT_MS = '50'; + process.env.FAKE_LEAN_SKIP_DEFINITION = '1'; + process.env.FAKE_LEAN_TARGET_URI = new URL(`file://${path.join(project, 'Lib.lean')}`).href; + }); + + await run((project, fakeServer) => { + process.env.CODEGRAPH_LEAN_SEMANTICS = 'off'; + process.env.CODEGRAPH_LEAN_LSP_COMMAND = `${process.execPath} ${fakeServer}`; + process.env.FAKE_LEAN_TARGET_URI = new URL(`file://${path.join(project, 'Lib.lean')}`).href; + process.env.FAKE_LEAN_TARGET_LINE = '2'; + process.env.FAKE_LEAN_TARGET_COLUMN = '4'; + }); + }); + + it('skips Lake dependency packages by default unless explicitly unignored', () => { + const tempProject = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-lean-ignore-')); + try { + fs.mkdirSync(path.join(tempProject, '.lake', 'packages', 'Dep'), { recursive: true }); + fs.writeFileSync(path.join(tempProject, 'Main.lean'), 'def main := 1\n'); + fs.writeFileSync(path.join(tempProject, '.lake', 'packages', 'Dep', 'Lib.lean'), 'def dep := 1\n'); + + let files = scanDirectory(tempProject); + expect(files).toContain('Main.lean'); + expect(files).not.toContain('.lake/packages/Dep/Lib.lean'); + + fs.writeFileSync( + path.join(tempProject, '.gitignore'), + '!.lake/\n!.lake/packages/\n!.lake/packages/**\n' + ); + files = scanDirectory(tempProject); + expect(files).toContain('.lake/packages/Dep/Lib.lean'); + } finally { + fs.rmSync(tempProject, { recursive: true, force: true }); + } + }); +}); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 03b8ea6ab..6109c7662 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -268,6 +268,55 @@ describe('Resolution Module', () => { expect(result).not.toBeNull(); expect(result?.targetNodeId).toBe('method:user.ts:User.save:15'); }); + + it('should prefer extractor-provided qualified candidates', () => { + const preferred: Node = { + id: 'func:src/Demo/Helpers.lean:helper:3', + kind: 'function', + name: 'helper', + qualifiedName: 'Demo.Helpers.helper', + filePath: 'src/Demo/Helpers.lean', + language: 'lean', + startLine: 3, + endLine: 3, + startColumn: 0, + endColumn: 30, + updatedAt: Date.now(), + }; + const unrelated: Node = { + ...preferred, + id: 'func:src/Other.lean:helper:3', + qualifiedName: 'Other.helper', + filePath: 'src/Other.lean', + }; + + const context: ResolutionContext = { + getNodesInFile: () => [], + getNodesByName: (name) => name === 'helper' ? [unrelated, preferred] : [], + getNodesByQualifiedName: (qualifiedName) => qualifiedName === 'Demo.Helpers.helper' ? [preferred] : [], + getNodesByKind: () => [], + fileExists: () => true, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => ['src/Demo/Helpers.lean', 'src/Other.lean'], + getNodesByLowerName: () => [], + getImportMappings: () => [], + }; + + const result = matchReference({ + fromNodeId: 'func:src/Main.lean:caller:10', + referenceName: 'helper', + referenceKind: 'calls', + line: 10, + column: 8, + filePath: 'src/Main.lean', + language: 'lean', + candidates: ['Demo.Helpers.helper', 'Other.helper'], + }, context); + + expect(result?.targetNodeId).toBe(preferred.id); + expect(result?.resolvedBy).toBe('qualified-name'); + }); }); describe('Import Resolver', () => { @@ -352,6 +401,40 @@ from ..services import auth_service expect(mappings.some((m) => m.localName === 'helper')).toBe(true); expect(mappings.some((m) => m.localName === 'User')).toBe(true); }); + + it('should resolve Lean module imports by exact path or one unique suffix match', () => { + const makeContext = (files: string[], exists: (p: string) => boolean): ResolutionContext => ({ + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: exists, + readFile: () => null, + getProjectRoot: () => '', + getAllFiles: () => files, + }); + + expect(resolveImportPath( + 'Foo.Bar', + 'Main.lean', + 'lean', + makeContext(['Foo/Bar.lean'], (p) => p === 'Foo/Bar.lean') + )).toBe('Foo/Bar.lean'); + + expect(resolveImportPath( + 'Foo.Bar', + 'src/Main.lean', + 'lean', + makeContext(['src/Foo/Bar.lean'], () => false) + )).toBe('src/Foo/Bar.lean'); + + expect(resolveImportPath( + 'Foo.Bar', + 'src/Main.lean', + 'lean', + makeContext(['src/Foo/Bar.lean', 'vendor/Foo/Bar.lean'], () => false) + )).toBeNull(); + }); }); describe('JVM FQN Import Resolution', () => { diff --git a/site/src/content/docs/getting-started/configuration.md b/site/src/content/docs/getting-started/configuration.md index bba4ddb92..01bea5630 100644 --- a/site/src/content/docs/getting-started/configuration.md +++ b/site/src/content/docs/getting-started/configuration.md @@ -7,7 +7,7 @@ There isn't any — CodeGraph is **zero-config**, with **no config file** to wri ## What it skips out of the box -- **Dependency, build, and cache directories** — `node_modules`, `vendor`, `dist`, `build`, `target`, `.venv`, `Pods`, `.next`, and the like across every [supported stack](/codegraph/reference/languages/) — so the graph is your code, not third-party noise. This holds even with no `.gitignore`. +- **Dependency, build, and cache directories** — `node_modules`, `vendor`, `dist`, `build`, `target`, `.lake`, `.venv`, `Pods`, `.next`, and the like across every [supported stack](/codegraph/reference/languages/) — so the graph is your code, not third-party noise. This holds even with no `.gitignore`. - **Anything in your `.gitignore`** — honored in git repos via git, and in non-git projects by reading `.gitignore` directly (root and nested). - **Files larger than 1 MB** — generated bundles, minified JS, vendored blobs. diff --git a/site/src/content/docs/guides/indexing.md b/site/src/content/docs/guides/indexing.md index 7c0fd1ddc..d9437f309 100644 --- a/site/src/content/docs/guides/indexing.md +++ b/site/src/content/docs/guides/indexing.md @@ -98,4 +98,4 @@ Reports node/edge/file counts, the active SQLite backend, and the journal mode. ## What gets indexed -Every file whose extension maps to a [supported language](/codegraph/reference/languages/), minus dependency/build directories excluded by default (`node_modules`, `vendor`, `dist`, …), anything your `.gitignore` excludes, and files over 1 MB. See [Configuration](/codegraph/getting-started/configuration/). +Every file whose extension maps to a [supported language](/codegraph/reference/languages/), minus dependency/build directories excluded by default (`node_modules`, `vendor`, `dist`, `.lake`, …), anything your `.gitignore` excludes, and files over 1 MB. See [Configuration](/codegraph/getting-started/configuration/). diff --git a/site/src/content/docs/reference/languages.md b/site/src/content/docs/reference/languages.md index 184b579ac..602e8946b 100644 --- a/site/src/content/docs/reference/languages.md +++ b/site/src/content/docs/reference/languages.md @@ -22,9 +22,12 @@ Language support is automatic from the file extension — there's nothing to con | Kotlin | `.kt`, `.kts` | Full support | | Scala | `.scala`, `.sc` | Full support (classes, traits, methods, type aliases, Scala 3 enums) | | Dart | `.dart` | Full support | +| Lean | `.lean` | Static extraction with optional Lean/Lake LSP definition resolution | | Svelte | `.svelte` | Full support (script extraction, Svelte 5 runes, SvelteKit routes) | | Vue | `.vue` | Full support (script + script-setup, Nuxt page/API/middleware routes) | | Liquid | `.liquid` | Full support | | Pascal / Delphi | `.pas`, `.dpr`, `.dpk`, `.lpr` | Full support (classes, records, interfaces, enums, DFM/FMX forms) | | Lua | `.lua` | Full support (functions, methods, locals, `require` imports, call edges) | | Luau | `.luau` | Full support (Lua, plus typed signatures, `type` aliases, Roblox `require`) | + +Lean files are indexed without requiring Lean locally. If `lake` or `lean` is available, CodeGraph runs a best-effort LSP definition pass for unresolved Lean references. Set `CODEGRAPH_LEAN_SEMANTICS=off` to force static-only indexing, or override the command with `CODEGRAPH_LEAN_LSP_COMMAND`. `CODEGRAPH_LEAN_LSP_TIMEOUT_MS` and `CODEGRAPH_LEAN_LSP_REF_LIMIT` cap the optional pass. diff --git a/site/src/content/docs/troubleshooting.md b/site/src/content/docs/troubleshooting.md index 2a4de29ef..33f9c27cd 100644 --- a/site/src/content/docs/troubleshooting.md +++ b/site/src/content/docs/troubleshooting.md @@ -24,4 +24,4 @@ Ensure the project is initialized/indexed, verify the path in your MCP config, a ## Missing symbols -The MCP server auto-syncs on save (wait a couple of seconds). Run `codegraph sync` manually if needed. Check that the file's language is [supported](/codegraph/reference/languages/) and isn't excluded by `.gitignore`. +The MCP server auto-syncs on save (wait a couple of seconds). Run `codegraph sync` manually if needed. Check that the file's language is [supported](/codegraph/reference/languages/) and isn't excluded by `.gitignore` or a default-excluded directory such as `node_modules`, `dist`, or `.lake`. diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index c9a2bcb37..6c11f95ee 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -14,7 +14,7 @@ export type GrammarLanguage = Exclude = { typescript: 'tree-sitter-typescript.wasm', @@ -38,6 +38,7 @@ const WASM_GRAMMAR_FILES: Record = { lua: 'tree-sitter-lua.wasm', luau: 'tree-sitter-luau.wasm', objc: 'tree-sitter-objc.wasm', + lean: 'tree-sitter-lean.wasm', }; /** @@ -95,6 +96,7 @@ export const EXTENSION_MAP: Record = { '.luau': 'luau', '.m': 'objc', '.mm': 'objc', + '.lean': 'lean', // XML: file-level tracking; the MyBatis extractor matches `` // shape and emits SQL-statement nodes (other XML returns empty). '.xml': 'xml', @@ -179,7 +181,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise = new Set([ '.ipynb_checkpoints', '.eggs', // Rust / JVM (Maven, Gradle, Scala) 'target', '.gradle', + // Lean / Lake + '.lake', // .NET 'obj', // Vendored deps (Go, PHP/Composer, Ruby/Bundler) diff --git a/src/extraction/lean-extractor.ts b/src/extraction/lean-extractor.ts new file mode 100644 index 000000000..e697ba4c5 --- /dev/null +++ b/src/extraction/lean-extractor.ts @@ -0,0 +1,848 @@ +import * as path from 'path'; +import { Node as SyntaxNode, Tree } from 'web-tree-sitter'; +import { + Edge, + ExtractionError, + ExtractionResult, + Node, + NodeKind, + UnresolvedReference, +} from '../types'; +import { getParser } from './grammars'; +import { generateNodeId, getChildByField, getNodeText } from './tree-sitter-helpers'; + +type ScopeFrame = + | { type: 'namespace'; nodeId: string; qualifiedName: string; opens: string[] } + | { type: 'section'; nodeId: string | null; name: string; opens: string[] }; + +const DECLARATION_TYPES = new Set([ + 'def', + 'theorem', + 'abbrev', + 'instance', + 'axiom', + 'opaque', + 'constant', + 'structure', + 'inductive', +]); + +const DECL_NAME_NODE_TYPES = new Set([ + 'def', + 'theorem', + 'abbrev', + 'instance', + 'axiom', + 'opaque', + 'constant', + 'structure', + 'inductive', + 'field', + 'ctor', + 'ctor_alt', + 'struct_field', + 'namespace', + 'import', + 'open', + 'export', +]); + +const BINDER_NODE_TYPES = new Set([ + 'binders', + 'explicit_binder', + 'implicit_binder', + 'strict_implicit_binder', + 'inst_implicit_binder', + 'tuple_binder', + 'anon_ctor_binder', + 'binder_predicate', +]); + +/** + * Lean 4 extractor backed by tree-sitter-lean. + * + * The Lean grammar deliberately represents `namespace` / `section` / `end` + * as flat top-level commands. This extractor reconstructs namespace scope + * while using the AST for declarations, fields, constructors, and references. + */ +export class LeanExtractor { + private filePath: string; + private source: string; + private tree: Tree | null = null; + private nodes: Node[] = []; + private edges: Edge[] = []; + private unresolvedReferences: UnresolvedReference[] = []; + private errors: ExtractionError[] = []; + private scopeStack: ScopeFrame[] = []; + private moduleOpenNamespaces: string[] = []; + private importedModules: string[] = []; + + constructor(filePath: string, source: string) { + this.filePath = filePath; + this.source = source; + } + + extract(): ExtractionResult { + const startTime = Date.now(); + + const parser = getParser('lean'); + if (!parser) { + return { + nodes: [], + edges: [], + unresolvedReferences: [], + errors: [ + { + message: 'Failed to get parser for language: lean', + filePath: this.filePath, + severity: 'error', + code: 'parser_error', + }, + ], + durationMs: Date.now() - startTime, + }; + } + + try { + this.tree = parser.parse(this.source) ?? null; + if (!this.tree) throw new Error('Parser returned null tree'); + + const fileNode = this.createFileNode(); + this.visitModule(fileNode.id, this.tree.rootNode); + this.closeOpenNamespaces(fileNode.endLine); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (msg.includes('memory access out of bounds') || msg.includes('out of memory')) { + throw error; + } + this.errors.push({ + message: `Lean extraction error: ${msg}`, + filePath: this.filePath, + severity: 'error', + code: 'parse_error', + }); + } finally { + if (this.tree) { + this.tree.delete(); + this.tree = null; + } + } + + return { + nodes: this.nodes, + edges: this.edges, + unresolvedReferences: this.unresolvedReferences, + errors: this.errors, + durationMs: Date.now() - startTime, + }; + } + + private createFileNode(): Node { + const lines = this.source.split('\n'); + const fileNode: Node = { + id: `file:${this.filePath}`, + kind: 'file', + name: path.basename(this.filePath), + qualifiedName: this.filePath, + filePath: this.filePath, + language: 'lean', + startLine: 1, + endLine: lines.length, + startColumn: 0, + endColumn: lines[lines.length - 1]?.length ?? 0, + isExported: false, + updatedAt: Date.now(), + }; + this.nodes.push(fileNode); + return fileNode; + } + + private visitModule(fileNodeId: string, root: SyntaxNode): void { + for (let i = 0; i < root.namedChildCount; i++) { + const child = root.namedChild(i); + if (!child) continue; + + if (child.type === 'import') { + this.extractImport(fileNodeId, child); + } else if (child.type === 'open') { + this.extractOpen(fileNodeId, child); + } else if (child.type === 'export') { + this.extractExport(fileNodeId, child); + } else if (child.type === 'namespace') { + this.enterNamespace(fileNodeId, child); + } else if (child.type === 'section') { + this.enterSection(fileNodeId, child); + } else if (child.type === 'end') { + this.exitScope(child); + } else if (child.type === 'declaration') { + this.extractDeclaration(fileNodeId, child); + } + } + } + + private extractImport(fileNodeId: string, node: SyntaxNode): void { + const nameNode = getChildByField(node, 'name'); + if (!nameNode) return; + const moduleName = getNodeText(nameNode, this.source); + this.importedModules.push(moduleName); + const importNode = this.createNode(fileNodeId, 'import', moduleName, node, { + qualifiedName: `${this.filePath}::import:${moduleName}`, + signature: getNodeText(node, this.source).trim(), + isExported: false, + }); + if (!importNode) return; + + this.unresolvedReferences.push({ + fromNodeId: fileNodeId, + referenceName: moduleName, + referenceKind: 'imports', + line: node.startPosition.row + 1, + column: node.startPosition.column, + filePath: this.filePath, + language: 'lean', + }); + } + + private extractOpen(fileNodeId: string, node: SyntaxNode): void { + const openedNamespaces: string[] = []; + const openedScopes: string[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child || child.type !== 'identifier') continue; + const field = node.fieldNameForNamedChild(i); + const text = getNodeText(child, this.source); + if (field === 'namespace') { + openedNamespaces.push(text); + } else if (field === 'scoped') { + openedScopes.push(text); + } + } + + if (openedNamespaces.length === 0 && openedScopes.length === 0) return; + + const name = [...openedNamespaces, ...openedScopes.map((scope) => `scoped ${scope}`)].join(', '); + const parentId = this.currentContainerNodeId() ?? fileNodeId; + const openNode = this.createNode(parentId, 'import', `open ${name}`, node, { + qualifiedName: `${this.filePath}::open:${node.startPosition.row + 1}:${name}`, + signature: getNodeText(node, this.source).trim(), + isExported: false, + }); + + if (!openNode) return; + for (const namespaceName of openedNamespaces) { + this.unresolvedReferences.push({ + fromNodeId: openNode.id, + referenceName: namespaceName, + referenceKind: 'references', + line: node.startPosition.row + 1, + column: node.startPosition.column, + filePath: this.filePath, + language: 'lean', + candidates: this.candidatesFor(namespaceName), + }); + } + this.addOpenNamespaces(openedNamespaces); + } + + private extractExport(fileNodeId: string, node: SyntaxNode): void { + const namespaceNode = this.namedChildrenWithField(node, 'namespace')[0]; + if (!namespaceNode) return; + + const namespaceName = getNodeText(namespaceNode, this.source); + const exportedNames = this.namedChildrenWithField(node, 'only') + .map((child) => getNodeText(child, this.source)); + const name = exportedNames.length > 0 + ? `${namespaceName} (${exportedNames.join(', ')})` + : namespaceName; + const parentId = this.currentContainerNodeId() ?? fileNodeId; + const exportNode = this.createNode(parentId, 'export', name, node, { + qualifiedName: `${this.filePath}::export:${node.startPosition.row + 1}:${name}`, + signature: getNodeText(node, this.source).trim(), + isExported: true, + }); + if (!exportNode) return; + + const targets = exportedNames.length > 0 + ? exportedNames.map((exportedName) => `${namespaceName}.${exportedName}`) + : [namespaceName]; + for (const target of targets) { + this.unresolvedReferences.push({ + fromNodeId: exportNode.id, + referenceName: target, + referenceKind: 'exports', + line: node.startPosition.row + 1, + column: node.startPosition.column, + filePath: this.filePath, + language: 'lean', + candidates: this.candidatesFor(target), + }); + } + } + + private enterNamespace(fileNodeId: string, node: SyntaxNode): void { + const nameNode = getChildByField(node, 'name'); + if (!nameNode) return; + + const rawName = getNodeText(nameNode, this.source); + const parentNamespace = this.currentNamespaceQualifiedName(); + const qualifiedName = parentNamespace ? `${parentNamespace}.${rawName}` : rawName; + const parentId = this.currentContainerNodeId() ?? fileNodeId; + const namespaceNode = this.createNode(parentId, 'namespace', rawName, node, { + qualifiedName, + signature: getNodeText(node, this.source).trim(), + isExported: true, + }); + if (!namespaceNode) return; + + this.scopeStack.push({ + type: 'namespace', + nodeId: namespaceNode.id, + qualifiedName, + opens: [], + }); + } + + private enterSection(fileNodeId: string, node: SyntaxNode): void { + const nameNode = getChildByField(node, 'name'); + const sectionName = nameNode + ? getNodeText(nameNode, this.source) + : `section@${node.startPosition.row + 1}`; + const parentId = this.currentContainerNodeId() ?? fileNodeId; + const sectionNode = this.createNode(parentId, 'module', sectionName, node, { + qualifiedName: `${this.filePath}::section:${sectionName}:${node.startPosition.row + 1}`, + signature: getNodeText(node, this.source).trim(), + isExported: false, + }); + this.scopeStack.push({ + type: 'section', + nodeId: sectionNode?.id ?? null, + name: sectionName, + opens: [], + }); + } + + private exitScope(node: SyntaxNode): void { + const frame = this.scopeStack.pop(); + if (!frame) return; + + if (frame.nodeId) { + const scopeNode = this.nodes.find((n) => n.id === frame.nodeId); + if (scopeNode) { + scopeNode.endLine = node.endPosition.row + 1; + scopeNode.endColumn = node.endPosition.column; + } + } + } + + private closeOpenNamespaces(endLine: number): void { + for (const frame of this.scopeStack) { + if (!frame.nodeId) continue; + const node = this.nodes.find((n) => n.id === frame.nodeId); + if (node) node.endLine = Math.max(node.endLine, endLine); + } + this.scopeStack = []; + } + + private extractDeclaration(fileNodeId: string, declaration: SyntaxNode): void { + const decl = this.findDeclarationPayload(declaration); + if (!decl) return; + + const kind = this.declarationKind(decl); + if (!kind) return; + + const nameNode = getChildByField(decl, 'name'); + const rawName = nameNode + ? getNodeText(nameNode, this.source) + : decl.type === 'instance' + ? this.anonymousInstanceName(decl) + : null; + if (!rawName) return; + + const simpleName = this.simpleName(rawName); + const parentNamespace = this.currentNamespaceQualifiedName(); + const qualifiedName = parentNamespace ? `${parentNamespace}.${rawName}` : rawName; + const parentId = this.currentContainerNodeId() ?? fileNodeId; + const visibility = this.visibilityForDeclaration(declaration); + + const declNode = this.createNode(parentId, kind, simpleName, decl, { + qualifiedName, + signature: getNodeText(decl, this.source).trim().slice(0, 300), + docstring: this.getLeanDocstring(declaration), + visibility, + isExported: visibility !== 'private', + decorators: this.extractAttributes(declaration), + }); + if (!declNode) return; + + this.extractInheritanceReferences(decl, declNode.id); + + if (decl.type === 'structure') { + this.extractStructureMembers(declNode, decl); + } else if (decl.type === 'inductive') { + this.extractInductiveConstructors(declNode, decl); + } + + this.extractReferencesFromDeclaration(decl, declNode.id, rawName); + } + + private findDeclarationPayload(declaration: SyntaxNode): SyntaxNode | null { + for (let i = 0; i < declaration.namedChildCount; i++) { + const child = declaration.namedChild(i); + if (child && DECLARATION_TYPES.has(child.type)) return child; + } + return null; + } + + private declarationKind(node: SyntaxNode): NodeKind | null { + switch (node.type) { + case 'def': + case 'theorem': + case 'opaque': + return 'function'; + case 'abbrev': + return 'type_alias'; + case 'axiom': + case 'constant': + case 'instance': + return 'constant'; + case 'structure': + return this.leadingKeyword(node) === 'class' ? 'class' : 'struct'; + case 'inductive': + return 'enum'; + default: + return null; + } + } + + private extractStructureMembers(parent: Node, node: SyntaxNode): void { + this.walkDescendants(node, (child) => { + if (child.type === 'ctor') { + this.createMember(parent, 'enum_member', child); + return false; + } + if (child.type === 'field') { + this.createMember(parent, 'field', child); + return false; + } + return true; + }); + } + + private extractInductiveConstructors(parent: Node, node: SyntaxNode): void { + this.walkDescendants(node, (child) => { + if (child.type !== 'ctor_alt') return true; + this.createMember(parent, 'enum_member', child); + return false; + }); + } + + private createMember(parent: Node, kind: NodeKind, node: SyntaxNode): Node | null { + const nameNode = getChildByField(node, 'name'); + if (!nameNode) return null; + const rawName = getNodeText(nameNode, this.source); + const simpleName = this.simpleName(rawName); + return this.createNode(parent.id, kind, simpleName, node, { + qualifiedName: `${parent.qualifiedName}.${rawName}`, + signature: getNodeText(node, this.source).trim().slice(0, 300), + docstring: this.getLeanDocstring(node), + visibility: this.visibilityForDeclaration(node), + isExported: this.visibilityForDeclaration(node) !== 'private', + }); + } + + private extractReferencesFromDeclaration(declaration: SyntaxNode, fromNodeId: string, declarationName: string): void { + const locals = new Set([declarationName, this.simpleName(declarationName)]); + this.collectLocalNames(declaration, locals); + this.collectDeclaredNames(declaration, locals); + + const emitted = new Set(); + + this.walkDescendants(declaration, (node) => { + if (node.type !== 'app') return true; + if (this.isInsideNodeType(node, 'attributes') || this.isInInheritancePosition(node)) return true; + const fn = getChildByField(node, 'fn'); + if (!fn) return true; + const referenceName = this.identifierLikeText(fn); + if (!referenceName || locals.has(referenceName) || locals.has(this.simpleName(referenceName))) { + return true; + } + this.addReference(fromNodeId, referenceName, 'calls', fn, emitted); + return true; + }); + + this.walkDescendants(declaration, (node) => { + if (node.type !== 'identifier') return true; + const name = getNodeText(node, this.source); + if (!name || name === '_' || locals.has(name) || locals.has(this.simpleName(name))) { + return true; + } + if ( + this.isIdentifierNamePosition(node) || + this.isBinderName(node) || + this.isAppFunction(node) || + this.isInsideNodeType(node, 'attributes') || + this.isInInheritancePosition(node) + ) { + return true; + } + this.addReference(fromNodeId, name, 'references', node, emitted); + return true; + }); + } + + private addReference( + fromNodeId: string, + referenceName: string, + referenceKind: 'calls' | 'references', + node: SyntaxNode, + emitted: Set + ): void { + const key = `${referenceKind}:${referenceName}`; + if (emitted.has(key)) return; + emitted.add(key); + this.unresolvedReferences.push({ + fromNodeId, + referenceName, + referenceKind, + line: node.startPosition.row + 1, + column: node.startPosition.column, + filePath: this.filePath, + language: 'lean', + candidates: this.candidatesFor(referenceName), + }); + } + + private extractInheritanceReferences(decl: SyntaxNode, fromNodeId: string): void { + if (decl.type !== 'structure') return; + + const emitted = new Set(); + for (let i = 0; i < decl.namedChildCount; i++) { + const child = decl.namedChild(i); + if (!child) continue; + if (!this.isInheritanceChild(child, decl)) continue; + + const referenceName = this.identifierLikeText(child); + if (!referenceName || emitted.has(referenceName)) continue; + emitted.add(referenceName); + this.unresolvedReferences.push({ + fromNodeId, + referenceName, + referenceKind: 'extends', + line: child.startPosition.row + 1, + column: child.startPosition.column, + filePath: this.filePath, + language: 'lean', + candidates: this.candidatesFor(referenceName), + }); + } + } + + private collectLocalNames(node: SyntaxNode, out: Set): void { + this.walkDescendants(node, (child) => { + if (!BINDER_NODE_TYPES.has(child.type)) return true; + + for (let i = 0; i < child.namedChildCount; i++) { + const named = child.namedChild(i); + if (!named) continue; + const field = child.fieldNameForNamedChild(i); + if (field === 'name' || (child.type === 'binders' && named.type === 'identifier')) { + const text = getNodeText(named, this.source); + out.add(text); + out.add(this.simpleName(text)); + } + } + + return true; + }); + } + + private collectDeclaredNames(node: SyntaxNode, out: Set): void { + this.walkDescendants(node, (child) => { + if (!DECL_NAME_NODE_TYPES.has(child.type)) return true; + const nameNode = getChildByField(child, 'name'); + if (nameNode) { + const text = getNodeText(nameNode, this.source); + out.add(text); + out.add(this.simpleName(text)); + } + return true; + }); + } + + private createNode( + parentId: string, + kind: NodeKind, + name: string, + syntaxNode: SyntaxNode, + extra: Partial = {} + ): Node | null { + if (!name) return null; + const qualifiedName = extra.qualifiedName ?? name; + const node: Node = { + id: generateNodeId(this.filePath, kind, qualifiedName, syntaxNode.startPosition.row + 1), + kind, + name, + qualifiedName, + filePath: this.filePath, + language: 'lean', + startLine: syntaxNode.startPosition.row + 1, + endLine: syntaxNode.endPosition.row + 1, + startColumn: syntaxNode.startPosition.column, + endColumn: syntaxNode.endPosition.column, + updatedAt: Date.now(), + ...extra, + }; + + this.nodes.push(node); + this.edges.push({ + source: parentId, + target: node.id, + kind: 'contains', + }); + return node; + } + + private currentNamespaceQualifiedName(): string | null { + for (let i = this.scopeStack.length - 1; i >= 0; i--) { + const frame = this.scopeStack[i]!; + if (frame.type === 'namespace') return frame.qualifiedName; + } + return null; + } + + private currentContainerNodeId(): string | null { + for (let i = this.scopeStack.length - 1; i >= 0; i--) { + const frame = this.scopeStack[i]!; + if (frame.nodeId) return frame.nodeId; + } + return null; + } + + private visibilityForDeclaration(node: SyntaxNode): 'public' | 'private' | 'protected' | undefined { + const text = getNodeText(node, this.source); + if (/\b(?:private|local)\b/.test(text)) return 'private'; + if (/\bprotected\b/.test(text)) return 'protected'; + return 'public'; + } + + private leadingKeyword(node: SyntaxNode): string { + return getNodeText(node, this.source).trimStart().split(/\s+/, 1)[0] ?? node.type; + } + + private simpleName(name: string): string { + const parts = name.split('.').filter(Boolean); + return parts[parts.length - 1] ?? name; + } + + private anonymousInstanceName(node: SyntaxNode): string { + const typeNode = this.namedChildrenWithField(node, 'type')[0] ?? getChildByField(node, 'type'); + const typeParts = typeNode + ? getNodeText(typeNode, this.source) + .split(/[^A-Za-z0-9_]+/) + .filter(Boolean) + : []; + return `inst${typeParts.join('') || 'anonymous'}@${node.startPosition.row + 1}`; + } + + private identifierLikeText(node: SyntaxNode): string | null { + if (node.type === 'identifier') return getNodeText(node, this.source); + if (node.type === 'app') { + const fn = getChildByField(node, 'fn'); + return fn ? this.identifierLikeText(fn) : null; + } + const name = getChildByField(node, 'name') ?? getChildByField(node, 'field'); + return name && name.type === 'identifier' ? getNodeText(name, this.source) : null; + } + + private extractAttributes(node: SyntaxNode): string[] | undefined { + const attributes = node.namedChildren.find((child) => child.type === 'attributes'); + if (!attributes) return undefined; + + const names: string[] = []; + for (let i = 0; i < attributes.namedChildCount; i++) { + const child = attributes.namedChild(i); + if (!child || child.type !== 'identifier') continue; + if (attributes.fieldNameForNamedChild(i) !== 'name') continue; + names.push(getNodeText(child, this.source)); + } + return names.length > 0 ? names : undefined; + } + + private isIdentifierNamePosition(node: SyntaxNode): boolean { + const parent = node.parent; + if (!parent || !DECL_NAME_NODE_TYPES.has(parent.type)) return false; + return this.fieldNameInParent(node) === 'name'; + } + + private isBinderName(node: SyntaxNode): boolean { + const parent = node.parent; + if (!parent) return false; + return BINDER_NODE_TYPES.has(parent.type) && ( + this.fieldNameInParent(node) === 'name' || + (parent.type === 'binders' && this.fieldNameInParent(node) === null) + ); + } + + private isAppFunction(node: SyntaxNode): boolean { + return node.parent?.type === 'app' && this.fieldNameInParent(node) === 'fn'; + } + + private isInsideNodeType(node: SyntaxNode, type: string): boolean { + let current: SyntaxNode | null = node.parent; + while (current) { + if (current.type === type) return true; + current = current.parent; + } + return false; + } + + private isInInheritancePosition(node: SyntaxNode): boolean { + let current: SyntaxNode | null = node; + while (current?.parent) { + const parent: SyntaxNode = current.parent; + if (parent.type === 'structure') { + return this.isInheritanceChild(current, parent); + } + current = parent; + } + return false; + } + + private isInheritanceChild(child: SyntaxNode, parent: SyntaxNode): boolean { + if (parent.type !== 'structure') return false; + if (child.type !== 'identifier' && child.type !== 'app') return false; + if (this.fieldNameInParent(child) === 'name') return false; + + for (let i = 0; i < parent.namedChildCount; i++) { + const candidate = parent.namedChild(i); + if (!candidate) continue; + if (candidate.id === child.id) return true; + if ( + candidate.type === 'field' || + candidate.type === 'struct_field' || + candidate.type === 'where_struct' + ) { + return false; + } + } + return false; + } + + private fieldNameInParent(node: SyntaxNode): string | null { + const parent = node.parent; + if (!parent) return null; + for (let i = 0; i < parent.namedChildCount; i++) { + if (parent.namedChild(i)?.id === node.id) { + return parent.fieldNameForNamedChild(i); + } + } + return null; + } + + private namedChildrenWithField(node: SyntaxNode, fieldName: string): SyntaxNode[] { + const children: SyntaxNode[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && node.fieldNameForNamedChild(i) === fieldName) { + children.push(child); + } + } + return children; + } + + private addOpenNamespaces(namespaces: string[]): void { + if (namespaces.length === 0) return; + const frame = this.scopeStack[this.scopeStack.length - 1]; + const target = frame ? frame.opens : this.moduleOpenNamespaces; + for (const namespaceName of namespaces) { + if (!target.includes(namespaceName)) target.push(namespaceName); + } + } + + private currentOpenNamespaces(): string[] { + const namespaces: string[] = [...this.moduleOpenNamespaces]; + for (const frame of this.scopeStack) { + namespaces.push(...frame.opens); + } + return namespaces; + } + + private currentNamespacePrefixes(): string[] { + const qualifiedName = this.currentNamespaceQualifiedName(); + if (!qualifiedName) return []; + const parts = qualifiedName.split('.').filter(Boolean); + const prefixes: string[] = []; + for (let i = parts.length; i >= 1; i--) { + prefixes.push(parts.slice(0, i).join('.')); + } + return prefixes; + } + + private candidatesFor(referenceName: string): string[] | undefined { + const cleanName = referenceName.replace(/^_root_\./, ''); + if (!cleanName || cleanName === '_') return undefined; + + const candidates: string[] = []; + const add = (candidate: string) => { + if (candidate && !candidates.includes(candidate)) candidates.push(candidate); + }; + + if (cleanName.includes('.')) { + add(cleanName); + for (const namespaceName of this.currentNamespacePrefixes()) { + add(`${namespaceName}.${cleanName}`); + } + for (const namespaceName of this.currentOpenNamespaces()) { + add(`${namespaceName}.${cleanName}`); + } + return candidates.length > 0 ? candidates : undefined; + } + + for (const namespaceName of this.currentNamespacePrefixes()) { + add(`${namespaceName}.${cleanName}`); + } + for (const namespaceName of this.currentOpenNamespaces()) { + add(`${namespaceName}.${cleanName}`); + } + for (const moduleName of this.importedModules) { + const parts = moduleName.split('.').filter(Boolean); + for (let i = parts.length; i >= 1; i--) { + add(`${parts.slice(0, i).join('.')}.${cleanName}`); + } + } + add(cleanName); + + return candidates.length > 0 ? candidates : undefined; + } + + private getLeanDocstring(node: SyntaxNode): string | undefined { + const comments: string[] = []; + let sibling = node.previousNamedSibling; + while (sibling && (sibling.type === 'doc_comment' || sibling.type === 'module_doc_comment')) { + comments.unshift(getNodeText(sibling, this.source)); + sibling = sibling.previousNamedSibling; + } + if (comments.length === 0) return undefined; + const cleaned = comments + .map((comment) => comment + .replace(/^\/-!/, '') + .replace(/^\/--?/, '') + .replace(/-\/$/, '') + .replace(/^--!? ?/gm, '') + .replace(/^\s*\* ?/gm, '') + .trim()) + .filter(Boolean) + .join('\n') + .trim(); + return cleaned || undefined; + } + + private walkDescendants(node: SyntaxNode, visit: (node: SyntaxNode) => boolean): void { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + const shouldDescend = visit(child); + if (shouldDescend) this.walkDescendants(child, visit); + } + } +} diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index f576839fa..466d888a9 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -24,6 +24,7 @@ import { SvelteExtractor } from './svelte-extractor'; import { DfmExtractor } from './dfm-extractor'; import { VueExtractor } from './vue-extractor'; import { MyBatisExtractor } from './mybatis-extractor'; +import { LeanExtractor } from './lean-extractor'; import { getAllFrameworkResolvers, getApplicableFrameworks, @@ -3067,6 +3068,11 @@ export function extractFromSource( // Use custom extractor for Liquid const extractor = new LiquidExtractor(filePath, source); result = extractor.extract(); + } else if (detectedLanguage === 'lean') { + // Lean namespaces are flat sibling commands in the grammar, so a custom + // extractor reconstructs namespace scope while still using tree-sitter. + const extractor = new LeanExtractor(filePath, source); + result = extractor.extract(); } else if (detectedLanguage === 'xml') { // Custom extractor for MyBatis mapper XML. Non-mapper XML returns just a // file node so the watcher tracks it without emitting symbols. diff --git a/src/extraction/wasm/tree-sitter-lean.wasm b/src/extraction/wasm/tree-sitter-lean.wasm new file mode 100755 index 0000000000000000000000000000000000000000..3647d04c6177690fab09fc7816297b8658d5363f GIT binary patch literal 3646288 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