diff --git a/README.md b/README.md index 48323f6fd..9edc00ac4 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ With the index available, the agent answers from one to four `codegraph_explore` ## Built for speed — the Rust kernel -CodeGraph's parsing engine is a **native Rust kernel**: 20 languages — TypeScript, JavaScript, Java, Python, Go, C, C++, Rust, C#, Ruby, PHP, Swift, Kotlin, Scala, Dart, R, Lua, Luau (Metal and CUDA ride the C++ path) — parse in compiled code with one boundary crossing per file. Every language shipped only after its graphs proved **byte-for-byte identical** to the reference engine on real repositories, from small libraries up to the Linux kernel; platforms without a prebuilt binary and files with syntax errors fall back per-file automatically, same graph either way. +CodeGraph's parsing engine is a **native Rust kernel**: 20 languages — TypeScript, JavaScript, Java, Python, Go, C, C++, Rust, C#, Ruby, PHP, Swift, Kotlin, Scala, Dart, R, Lua, Luau (Metal and CUDA ride the C++ path) — parse in compiled code with one boundary crossing per file. Additional languages such as AL are supported via WASM grammars with the same extraction pipeline. Every language shipped only after its graphs proved **byte-for-byte identical** to the reference engine on real repositories, from small libraries up to the Linux kernel; platforms without a prebuilt binary and files with syntax errors fall back per-file automatically, same graph either way. **And it scales itself to the machine it's on.** Worker pools, parallel resolution, and analysis caches are sized from what the system actually has — real core counts (container/cgroup-aware, so a VPS that grants 2 cores gets sized for 2, not the host's 64), honestly-measured available RAM on macOS and Linux, and the measured cost of *your* project's resolution work: @@ -278,7 +278,7 @@ CodeGraph's parsing engine is a **native Rust kernel**: 20 languages — TypeScr | **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, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi | +| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi, AL (Business Central) | | **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 | diff --git a/__tests__/al-extraction.test.ts b/__tests__/al-extraction.test.ts new file mode 100644 index 000000000..c199cc70c --- /dev/null +++ b/__tests__/al-extraction.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { extractFromSource } from '../src/extraction'; +import { loadGrammarsForLanguages } from '../src/extraction/grammars'; + +beforeAll(async () => { + await loadGrammarsForLanguages(['al']); +}); + +describe('AL Extraction', () => { + it('extracts Codeunit and procedure correctly', () => { + const source = ` +codeunit 50100 "My Test Codeunit" +{ + Access = Public; + Subtype = Normal; + + trigger OnRun() + begin + Message('Hello World'); + end; + + procedure MyFunction(VarParam: Record "Sales Line") + var + LocalVar: Integer; + begin + LocalVar := 1; + CalculateRounding(LocalVar); + end; +} +`; + + const result = extractFromSource('test.al', source); + + expect(result.nodes.some(n => n.kind === 'class' && n.name === '"My Test Codeunit"')).toBe(true); + expect(result.nodes.some(n => n.kind === 'method' && n.name === 'OnRun')).toBe(true); + expect(result.nodes.some(n => n.kind === 'method' && n.name === 'MyFunction')).toBe(true); + + // Check if the reference to CalculateRounding is picked up + const refs = result.unresolvedReferences; + const callRef = refs.find(r => r.referenceName === 'CalculateRounding' && r.referenceKind === 'calls'); + expect(callRef).toBeDefined(); + }); + + it('names enums and interfaces and extracts interface procedures', () => { + const source = ` +enum 50100 "Color" +{ + value(0; Red) { } + value(1; "Dark Blue") { } +} + +interface "Color Provider" +{ + procedure GetColor(): Enum "Color"; +} +`; + + const result = extractFromSource('types.al', source); + + expect(result.nodes.some(n => n.kind === 'enum' && n.name === '"Color"')).toBe(true); + expect(result.nodes.some(n => n.kind === 'enum_member' && n.name === 'Red')).toBe(true); + expect(result.nodes.some(n => n.kind === 'enum_member' && n.name === '"Dark Blue"')).toBe(true); + expect(result.nodes.some(n => n.kind === 'interface' && n.name === '"Color Provider"')).toBe(true); + expect(result.nodes.some(n => n.kind === 'method' && n.name === 'GetColor')).toBe(true); + }); + + it('extracts table fields and nests field triggers beneath them', () => { + const source = ` +table 50101 Customer +{ + fields + { + field(1; Name; Text[100]) + { + trigger OnValidate() + begin + ValidateName(); + end; + } + } +} +`; + + const result = extractFromSource('customer.al', source); + const field = result.nodes.find(n => n.kind === 'field' && n.name === 'Name'); + const trigger = result.nodes.find(n => n.kind === 'method' && n.name === 'OnValidate'); + + expect(field?.qualifiedName).toBe('Customer::Name'); + expect(trigger?.qualifiedName).toBe('Customer::Name::OnValidate'); + expect(result.edges).toContainEqual({ source: field?.id, target: trigger?.id, kind: 'contains' }); + expect(result.unresolvedReferences).toEqual(expect.arrayContaining([ + expect.objectContaining({ + fromNodeId: trigger?.id, + referenceName: 'ValidateName', + referenceKind: 'calls', + }), + ])); + }); + + it('extracts namespaces, using directives, and extension base objects', () => { + const source = ` +namespace Contoso.Extensions; +using Microsoft.Sales.Customer; + +tableextension 50102 CustomerExtension extends Customer +{ +} + +permissionsetextension 50103 PermissionExtension extends BasePermissionSet +{ +} + +profileextension ProfileExtension extends BaseProfile +{ +} +`; + + const result = extractFromSource('customer-extension.al', source); + const namespace = result.nodes.find(n => n.kind === 'namespace'); + const extension = result.nodes.find(n => n.kind === 'class' && n.name === 'CustomerExtension'); + const permissionExtension = result.nodes.find( + n => n.kind === 'class' && n.name === 'PermissionExtension', + ); + const profileExtension = result.nodes.find( + n => n.kind === 'class' && n.name === 'ProfileExtension', + ); + const using = result.nodes.find(n => n.kind === 'import'); + + expect(namespace?.name).toBe('Contoso.Extensions'); + expect(extension?.qualifiedName).toBe('Contoso.Extensions::CustomerExtension'); + expect(permissionExtension?.qualifiedName).toBe('Contoso.Extensions::PermissionExtension'); + expect(profileExtension?.qualifiedName).toBe('Contoso.Extensions::ProfileExtension'); + expect(using?.name).toBe('Microsoft.Sales.Customer'); + expect(result.unresolvedReferences).toEqual(expect.arrayContaining([ + expect.objectContaining({ + fromNodeId: extension?.id, + referenceName: 'Customer', + referenceKind: 'extends', + }), + expect.objectContaining({ + referenceName: 'Microsoft.Sales.Customer', + referenceKind: 'imports', + }), + expect.objectContaining({ + fromNodeId: permissionExtension?.id, + referenceName: 'BasePermissionSet', + referenceKind: 'extends', + }), + expect.objectContaining({ + fromNodeId: profileExtension?.id, + referenceName: 'BaseProfile', + referenceKind: 'extends', + }), + ])); + }); +}); diff --git a/__tests__/al-resolution.test.ts b/__tests__/al-resolution.test.ts new file mode 100644 index 000000000..8e683aaeb --- /dev/null +++ b/__tests__/al-resolution.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; +import { ReferenceResolver } from '../src/resolution'; +import { matchReference } from '../src/resolution/name-matcher'; +import type { QueryBuilder } from '../src/db/queries'; +import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types'; +import type { Node } from '../src/types'; + +function contextFor(nodes: Node[]): ResolutionContext { + return { + getNodesInFile: filePath => nodes.filter(node => node.filePath === filePath), + getNodesByName: name => nodes.filter(node => node.name === name), + getNodesByQualifiedName: qualifiedName => nodes.filter(node => node.qualifiedName === qualifiedName), + getNodesByKind: kind => nodes.filter(node => node.kind === kind), + fileExists: () => true, + readFile: () => null, + getProjectRoot: () => '/project', + getAllFiles: () => [...new Set(nodes.map(node => node.filePath))], + getNodesByLowerName: lowerName => nodes.filter( + node => node.name.toLowerCase() === lowerName.toLowerCase(), + ), + getImportMappings: () => [], + }; +} + +function call(referenceName: string): UnresolvedRef { + return { + fromNodeId: 'caller', + referenceName, + referenceKind: 'calls', + line: 10, + column: 4, + filePath: 'caller.al', + language: 'al', + }; +} + +function symbol( + id: string, + kind: Node['kind'], + name: string, + qualifiedName: string, + filePath: string, +): Node { + return { + id, + kind, + name, + qualifiedName, + filePath, + language: 'al', + startLine: 1, + endLine: 2, + startColumn: 0, + endColumn: 0, + updatedAt: 0, + }; +} + +function resolverFor(nodes: Node[]): ReferenceResolver { + const queries = { + getAllFilePaths: () => [...new Set(nodes.map(node => node.filePath))], + getAllNodeNames: () => [...new Set(nodes.map(node => node.name))], + getNodesByFile: (filePath: string) => nodes.filter(node => node.filePath === filePath), + getNodesByName: (name: string) => nodes.filter(node => node.name === name), + getNodesByLowerName: (name: string) => nodes.filter( + node => node.name.toLowerCase() === name.toLowerCase(), + ), + getNodesByQualifiedNameExact: (qualifiedName: string) => nodes.filter( + node => node.qualifiedName === qualifiedName, + ), + getNodesByKind: (kind: Node['kind']) => nodes.filter(node => node.kind === kind), + iterateNodesByKind: function* (kind: Node['kind']) { + yield* nodes.filter(node => node.kind === kind); + }, + getNodeById: (id: string) => nodes.find(node => node.id === id) ?? null, + } as unknown as QueryBuilder; + return new ReferenceResolver('/project', queries); +} + +describe('AL resolution', () => { + it('resolves member calls case-insensitively', () => { + const target = symbol('target', 'method', 'DoWork', 'Target::DoWork', 'target.al'); + + expect(matchReference(call('service.dowork'), contextFor([target]))?.targetNodeId).toBe(target.id); + }); + + it('resolves case-insensitive AL calls through the existing production prefilter', () => { + const target = symbol('target', 'method', 'DoWork', 'Target::DoWork', 'target.al'); + const result = resolverFor([target]).resolveAll([ + call('dowork'), + call('service.dowork'), + ]); + + expect(result.resolved.map(ref => ref.targetNodeId)).toEqual([target.id, target.id]); + }); + + it('resolves quoted member calls case-insensitively', () => { + const target = symbol('target', 'method', '"Do Work"', 'Service::"Do Work"', 'target.al'); + + const result = resolverFor([target]).resolveAll([call('service."do work"')]); + expect(result.resolved[0]?.targetNodeId).toBe(target.id); + }); + + it('resolves Unicode member calls using AL identifier rules', () => { + const target = symbol('target', 'method', 'Oa\u0301k', 'Service::Oa\u0301k', 'target.al'); + + expect(matchReference(call('service.oa\u0301k'), contextFor([target]))?.targetNodeId).toBe(target.id); + }); + + it('does not treat dots inside quoted AL identifiers as member separators', () => { + const target = symbol('target', 'method', '"Do.Work"', 'Service::"Do.Work"', 'target.al'); + + expect(matchReference(call('service."do.work"'), contextFor([target]))?.targetNodeId).toBe(target.id); + expect(resolverFor([target]).resolveAll([call('"do.work"')]).resolved[0]?.targetNodeId).toBe(target.id); + }); + + it('does not guess when a case-insensitive member name is ambiguous', () => { + const first = symbol('first', 'method', 'DoWork', 'First::DoWork', 'first.al'); + const second = symbol('second', 'method', 'DOWORK', 'Second::DOWORK', 'second.al'); + + expect(matchReference(call('service.dowork'), contextFor([first, second]))).toBeNull(); + }); + + it('uses AL using directives to disambiguate object references', () => { + const currentNamespace = symbol( + 'caller-ns', + 'namespace', + 'Contoso.Extension', + 'Contoso.Extension', + 'caller.al', + ); + const wrong = symbol( + 'wrong', + 'class', + 'Customer', + 'Nearby.Unrelated::Customer', + 'nearby/customer.al', + ); + const target = symbol( + 'target', + 'class', + 'Customer', + 'Microsoft.Sales::Customer', + 'base/customer.al', + ); + const using = symbol( + 'using-sales', + 'import', + 'Microsoft.Sales', + 'Microsoft.Sales', + 'caller.al', + ); + const ref: UnresolvedRef = { + ...call('customer'), + referenceKind: 'extends', + }; + + const result = resolverFor([currentNamespace, using, wrong, target]).resolveAll([ref]); + expect(result.resolved[0]?.targetNodeId).toBe(target.id); + }); +}); diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index ad0ba2374..a11dd086c 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -150,6 +150,11 @@ describe('Language Detection', () => { expect(isSourceFile('default.nix')).toBe(true); }); + it('should detect AL files', () => { + expect(detectLanguage('src/CustomerCard.al')).toBe('al'); + expect(isSourceFile('src/CustomerCard.al')).toBe(true); + }); + it('should detect a .h whose only C++ signal is an export-macro class as cpp', () => { // Lean Unreal-Engine style header: the class is annotated with an export // macro and carries no explicit `public:`/`virtual`/`namespace`/`template`, diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 84647c3e4..0057f2880 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -50,12 +50,14 @@ const WASM_GRAMMAR_FILES: Record = { terraform: 'tree-sitter-terraform.wasm', arkts: 'tree-sitter-arkts.wasm', nix: 'tree-sitter-nix.wasm', + al: 'tree-sitter-al.wasm', }; /** * File extension to Language mapping */ export const EXTENSION_MAP: Record = { + '.al': 'al', '.ts': 'typescript', '.tsx': 'tsx', // ESM/CJS TypeScript module extensions — parsed as TS (no JSX). (#366) @@ -290,7 +292,7 @@ export async function initGrammars(): Promise { */ const VENDORED_WASM_LANGS: ReadonlySet = new Set([ 'pascal', 'scala', 'lua', 'luau', 'csharp', 'r', 'cfml', 'cfscript', 'cfquery', - 'cobol', 'vbnet', 'erlang', 'terraform', 'arkts', 'nix', + 'cobol', 'vbnet', 'erlang', 'terraform', 'arkts', 'nix', 'al', 'typescript', 'tsx', 'javascript', 'jsx', 'java', 'python', 'go', // R7a (C/C++ kernel port prep): tree-sitter-c v0.24.2 (b780e47) + // tree-sitter-cpp v0.23.4 (f41e1a0), parser.c/scanner.c sha-matched against @@ -693,6 +695,7 @@ export function getLanguageDisplayName(language: Language): string { vbnet: 'Visual Basic .NET', erlang: 'Erlang', terraform: 'Terraform', + al: 'AL (Business Central)', arkts: 'ArkTS', unknown: 'Unknown', }; diff --git a/src/extraction/languages/al.ts b/src/extraction/languages/al.ts new file mode 100644 index 000000000..5f144638c --- /dev/null +++ b/src/extraction/languages/al.ts @@ -0,0 +1,151 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import type { ExtractorContext, LanguageExtractor } from '../tree-sitter-types'; + +const AL_EXTENSION_TYPES = new Set([ + 'tableextension_declaration', + 'pageextension_declaration', + 'enumextension_declaration', + 'reportextension_declaration', + 'permissionsetextension_declaration', + 'profileextension_declaration', +]); + +const AL_CLASS_TYPES = [ + 'codeunit_declaration', 'table_declaration', 'page_declaration', + 'report_declaration', 'xmlport_declaration', 'query_declaration', + ...AL_EXTENSION_TYPES, +]; + +const AL_OBJECT_NAME_TYPES = new Set([ + ...AL_CLASS_TYPES, + 'enum_declaration', + 'interface_declaration', +]); + +function fieldText(node: SyntaxNode, field: string): string | undefined { + return node.childForFieldName(field)?.text.trim() || undefined; +} + +/** + * Extract an AL field and its field-scoped triggers as real nested symbols. + * The generic variable path deliberately skips declarations inside classes, + * while the generic field path does not walk the field body; AL needs both. + */ +function extractField(node: SyntaxNode, ctx: ExtractorContext): boolean { + const name = fieldText(node, 'name'); + if (!name) return false; + + const type = fieldText(node, 'type'); + const fieldNode = ctx.createNode('field', name, node, { + signature: type ? `${name}: ${type}` : name, + }); + if (!fieldNode) return true; + + const body = node.childForFieldName('body'); + if (!body) return true; + + ctx.pushScope(fieldNode.id); + try { + for (const child of body.namedChildren) { + if (child.type !== 'trigger_declaration' && child.type !== 'trigger') { + ctx.visitNode(child); + continue; + } + + const triggerName = fieldText(child, 'name'); + if (!triggerName) { + ctx.visitNode(child); + continue; + } + const triggerNode = ctx.createNode('method', triggerName, child); + const triggerBody = child.childForFieldName('body'); + if (!triggerNode || !triggerBody) continue; + ctx.pushScope(triggerNode.id); + try { + ctx.visitFunctionBody(triggerBody, triggerNode.id); + } finally { + ctx.popScope(); + } + } + } finally { + ctx.popScope(); + } + return true; +} + +/** Extract an AL extension object and connect it to the object it extends. */ +function extractExtension(node: SyntaxNode, ctx: ExtractorContext): boolean { + const name = fieldText(node, 'object_name'); + if (!name) return false; + + const extensionNode = ctx.createNode('class', name, node); + if (!extensionNode) return true; + + const baseObject = node.childForFieldName('base_object'); + if (baseObject) { + ctx.addUnresolvedReference({ + fromNodeId: extensionNode.id, + referenceName: baseObject.text, + referenceKind: 'extends', + line: baseObject.startPosition.row + 1, + column: baseObject.startPosition.column, + }); + } + + const body = node.childForFieldName('body'); + if (!body) return true; + ctx.pushScope(extensionNode.id); + try { + for (const child of body.namedChildren) ctx.visitNode(child); + } finally { + ctx.popScope(); + } + return true; +} + +export const alExtractor: LanguageExtractor = { + functionTypes: [], + classTypes: AL_CLASS_TYPES, + methodTypes: [ + 'procedure', + 'procedure_declaration', + 'interface_procedure', + 'trigger', + 'trigger_declaration', + 'event_declaration', + ], + interfaceTypes: ['interface_declaration'], + structTypes: [], + enumTypes: ['enum_declaration'], + typeAliasTypes: [], + importTypes: ['using_statement'], + callTypes: ['call_expression', 'call_statement'], + variableTypes: ['variable_declaration'], + methodsAreTopLevel: false, + nameField: 'name', + resolveName: (node) => + AL_OBJECT_NAME_TYPES.has(node.type) + ? fieldText(node, 'object_name') + : undefined, + visitNode: (node, ctx) => { + if (AL_EXTENSION_TYPES.has(node.type)) return extractExtension(node, ctx); + if (node.type === 'field_declaration') return extractField(node, ctx); + // Keep AL's value_name field handling local for both enums and their + // class-shaped enumextension declarations. + if (node.type === 'enum_value_declaration') { + const name = fieldText(node, 'value_name'); + if (!name) return false; + ctx.createNode('enum_member', name, node); + return true; + } + return false; + }, + packageTypes: ['namespace_declaration'], + extractPackage: (node) => fieldText(node, 'name') ?? null, + extractImport: (node) => { + const namespace = fieldText(node, 'namespace'); + return namespace ? { moduleName: namespace, signature: node.text.trim() } : null; + }, + bodyField: 'body', + paramsField: 'parameters', +}; diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 6b760b01d..7a883432e 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -36,6 +36,7 @@ import { solidityExtractor } from './solidity'; import { terraformExtractor } from './terraform'; import { arktsExtractor } from './arkts'; import { nixExtractor } from './nix'; +import { alExtractor } from './al'; export const EXTRACTORS: Partial> = { typescript: typescriptExtractor, @@ -69,4 +70,5 @@ export const EXTRACTORS: Partial> = { terraform: terraformExtractor, arkts: arktsExtractor, nix: nixExtractor, + al: alExtractor, }; diff --git a/src/extraction/wasm/tree-sitter-al.wasm b/src/extraction/wasm/tree-sitter-al.wasm new file mode 100644 index 000000000..68f5a5d30 Binary files /dev/null and b/src/extraction/wasm/tree-sitter-al.wasm differ diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 7b4bccc18..ffac86120 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -761,37 +761,44 @@ export class ReferenceResolver { /** * Check if a reference name has any possible match in the codebase. * Uses the pre-built knownNames set to skip expensive resolution - * for names that definitely don't exist as symbols. + * for names that definitely don't exist as symbols. Case-insensitive + * languages also consult the cached lowercase-name index. */ - private hasAnyPossibleMatch(name: string): boolean { - if (!this.knownNames) return true; // no pre-filter available + private hasAnyPossibleMatch(name: string, caseInsensitive = false): boolean { + const knownNames = this.knownNames; + if (!knownNames) return true; // no pre-filter available + + const hasKnownName = (candidate: string): boolean => + knownNames.has(candidate) || + (caseInsensitive && + this.context.getNodesByLowerName(candidate.toLowerCase()).length > 0); // Direct name match - if (this.knownNames.has(name)) return true; + if (hasKnownName(name)) return true; // For qualified names like "obj.method" or "Class::method", check the parts const dotIdx = name.indexOf('.'); if (dotIdx > 0) { const receiver = name.substring(0, dotIdx); const member = name.substring(dotIdx + 1); - if (this.knownNames.has(receiver) || this.knownNames.has(member)) return true; + if (hasKnownName(receiver) || hasKnownName(member)) return true; // Also check capitalized receiver (instance-method resolution) const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1); - if (this.knownNames.has(capitalized)) return true; + if (hasKnownName(capitalized)) return true; // JVM FQN: `com.example.foo.Bar` — the only useful segment is the // last one (`Bar`); the earlier check finds `example.foo.Bar` which // never matches a node name. const lastDot = name.lastIndexOf('.'); if (lastDot > dotIdx) { const tail = name.substring(lastDot + 1); - if (tail && this.knownNames.has(tail)) return true; + if (tail && hasKnownName(tail)) return true; } } const colonIdx = name.indexOf('::'); if (colonIdx > 0) { const receiver = name.substring(0, colonIdx); const member = name.substring(colonIdx + 2); - if (this.knownNames.has(receiver) || this.knownNames.has(member)) return true; + if (hasKnownName(receiver) || hasKnownName(member)) return true; // Multi-segment path `a::b::c` (a Rust/C++ module call like // `database::profiles::find`) — the only segment that names a symbol is // the last (`c`); `member` above is `b::c`, which never matches a node @@ -800,7 +807,7 @@ export class ReferenceResolver { const lastColon = name.lastIndexOf('::'); if (lastColon > colonIdx) { const tail = name.substring(lastColon + 2); - if (tail && this.knownNames.has(tail)) return true; + if (tail && hasKnownName(tail)) return true; } } @@ -814,9 +821,9 @@ export class ReferenceResolver { if (sepIdx > 0) { const receiver = name.substring(0, sepIdx); const member = name.substring(sepIdx + 1); - if (this.knownNames.has(member) || this.knownNames.has(receiver)) return true; + if (hasKnownName(member) || hasKnownName(receiver)) return true; const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1); - if (this.knownNames.has(capitalized)) return true; + if (hasKnownName(capitalized)) return true; } } @@ -824,7 +831,7 @@ export class ReferenceResolver { const slashIdx = name.lastIndexOf('/'); if (slashIdx > 0) { const fileName = name.substring(slashIdx + 1); - if (this.knownNames.has(fileName)) return true; + if (hasKnownName(fileName)) return true; } return false; @@ -896,7 +903,7 @@ export class ReferenceResolver { const tPre = this.profileStages ? process.hrtime.bigint() : 0n; const preFilterPass = isNixPathImportRef(ref) || - this.hasAnyPossibleMatch(existenceName) || + this.hasAnyPossibleMatch(existenceName, ref.language === 'al') || this.matchesAnyImport(ref) || this.frameworks.some((f) => f.claimsReference?.(ref.referenceName)); if (this.profileStages) this.stageAdd('preFilter', ref, preFilterPass, tPre); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index c74d8f272..05a99edd6 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1722,6 +1722,9 @@ function inferPhpAssignedPropertyType( return null; } +const AL_DOTTED_MEMBER_RE = + /^(.+)\.("(?:[^"\n]|"")*"|[_\p{L}\p{Nl}][_\p{L}\p{N}\p{Mn}\p{Mc}\p{Pc}\p{Cf}]*)$/u; + /** * Try to resolve by method name on a class/object */ @@ -1745,11 +1748,15 @@ export function matchMethodCall( // operator form requires at least one non-word char after `operator`, and // every downstream strategy compares the method part by exact string // equality, so a stray match can't invent an edge. - const dotMatch = + const genericDotMatch = ref.referenceName.match(/^([\w.]+)\.(\w+:?(?:\w+:)*)$/) ?? (ref.language === 'cpp' ? ref.referenceName.match(/^([\w.]+)\.(operator[^\w\s.]+)$/) : null); + const dotMatch = + ref.language === 'al' + ? ref.referenceName.match(AL_DOTTED_MEMBER_RE) + : genericDotMatch; const colonMatch = ref.referenceName.match(/^(\w+)::(\w+)$/); // Lua/Luau method calls use a single colon (`lg:log`); R uses `$` (`lg$log`). // Recognize these receiver/method separators so local-variable receiver-type @@ -2480,6 +2487,125 @@ export function dumpNameMatcherProfile(label: string): void { } } +const AL_OBJECT_KINDS = new Set(['class', 'enum', 'interface']); + +function matchAlReference( + ref: UnresolvedRef, + context: ResolutionContext, +): ResolvedRef | null | undefined { + if (ref.language !== 'al') return undefined; + + const dottedMember = ref.referenceName.match(AL_DOTTED_MEMBER_RE); + if ( + !dottedMember && + (ref.referenceKind === 'extends' || + ref.referenceKind === 'implements' || + ref.referenceKind === 'references' || + ref.referenceKind === 'instantiates') + ) { + const lowerName = ref.referenceName.toLowerCase(); + const candidates = context + .getNodesByLowerName(lowerName) + .filter( + (candidate) => + candidate.language === 'al' && + AL_OBJECT_KINDS.has(candidate.kind) && + candidate.name.toLowerCase() === lowerName, + ); + + if (candidates.length > 0) { + const namespaceOf = (candidate: Node): string | null => { + const suffix = `::${candidate.name}`; + return candidate.qualifiedName.endsWith(suffix) + ? candidate.qualifiedName.slice(0, -suffix.length) + : null; + }; + const resolved = ( + candidate: Node, + resolvedBy: ResolvedRef['resolvedBy'], + ): ResolvedRef => ({ + original: ref, + targetNodeId: candidate.id, + confidence: 0.95, + resolvedBy, + }); + + const nodesInFile = context.getNodesInFile(ref.filePath); + const currentNamespace = nodesInFile + .find((candidate) => candidate.language === 'al' && candidate.kind === 'namespace') + ?.name.toLowerCase(); + const currentMatches = candidates.filter((candidate) => { + const namespace = namespaceOf(candidate)?.toLowerCase() ?? null; + return currentNamespace ? namespace === currentNamespace : namespace === null; + }); + if (currentMatches.length === 1) return resolved(currentMatches[0]!, 'qualified-name'); + if (currentMatches.length > 1) return null; + + const importedNamespaces = new Set( + nodesInFile + .filter((candidate) => candidate.language === 'al' && candidate.kind === 'import') + .map((candidate) => candidate.name.toLowerCase()), + ); + const importedMatches = candidates.filter((candidate) => { + const namespace = namespaceOf(candidate)?.toLowerCase(); + return namespace != null && importedNamespaces.has(namespace); + }); + if (importedMatches.length === 1) return resolved(importedMatches[0]!, 'import'); + if (importedMatches.length > 1) return null; + } + } + + if (ref.referenceKind === 'calls' && dottedMember) { + const methodName = dottedMember[2]!; + const lowerMethodName = methodName.toLowerCase(); + const methodView = (candidate: Node, requestedName = methodName): Node => + candidate.language === 'al' && + candidate.kind === 'method' && + candidate.name.toLowerCase() === lowerMethodName + ? { ...candidate, name: requestedName } + : candidate; + const alContext: ResolutionContext = { + ...context, + getNodesInFile: (filePath: string) => + context.getNodesInFile(filePath).map((candidate) => methodView(candidate)), + getNodesByName: (name: string) => + context + .getNodesByLowerName(name.toLowerCase()) + .filter( + (candidate) => + candidate.language === 'al' && + candidate.name.toLowerCase() === name.toLowerCase(), + ) + .map((candidate) => methodView(candidate, name)), + }; + return matchMethodCall(ref, alContext); + } + + if (!dottedMember) { + const lowerName = ref.referenceName.toLowerCase(); + const candidates = context + .getNodesByLowerName(lowerName) + .filter( + (candidate) => + candidate.language === 'al' && + candidate.kind !== 'import' && + candidate.name.toLowerCase() === lowerName, + ); + const chosen = preferCallSiteFile(candidates, ref.filePath); + if (chosen.length === 1) { + return { + original: ref, + targetNodeId: chosen[0]!.id, + confidence: 0.9, + resolvedBy: 'exact-match', + }; + } + if (chosen.length > 1) return null; + } + + return undefined; +} + export function matchReference( ref: UnresolvedRef, context: ResolutionContext @@ -2491,6 +2617,9 @@ export function matchReference( return matchFunctionRef(ref, context); } + const alMatch = matchAlReference(ref, context); + if (alMatch !== undefined) return alMatch; + // ArkTS chained UI attributes — emitted with a leading dot (`.titleStyle`, // `.width`) by the extractor — resolve ONLY to decorator-marked attribute // helpers: `@Extend`/`@Styles`/`@AnimatableExtend` functions (and global diff --git a/src/types.ts b/src/types.ts index 186f57adc..13a86f08c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -117,6 +117,7 @@ export const LANGUAGES = [ 'vbnet', 'erlang', 'terraform', + 'al', 'unknown', ] as const;