diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 0a5c46b..7b248d4 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -1810,6 +1810,708 @@ import 'package:flutter/material.dart'; }); }); +// ============================================================================= +// Pascal / Delphi Extraction +// ============================================================================= + +describe('Pascal / Delphi Extraction', () => { + describe('Language detection', () => { + it('should detect Pascal files', () => { + expect(detectLanguage('UAuth.pas')).toBe('pascal'); + expect(detectLanguage('App.dpr')).toBe('pascal'); + expect(detectLanguage('Package.dpk')).toBe('pascal'); + expect(detectLanguage('App.lpr')).toBe('pascal'); + expect(detectLanguage('MainForm.dfm')).toBe('pascal'); + expect(detectLanguage('MainForm.fmx')).toBe('pascal'); + }); + + it('should report Pascal as supported', () => { + expect(isLanguageSupported('pascal')).toBe(true); + expect(getSupportedLanguages()).toContain('pascal'); + }); + }); + + describe('Unit extraction', () => { + it('should extract unit as module', () => { + const code = `unit MyUnit;\ninterface\nimplementation\nend.`; + const result = extractFromSource('MyUnit.pas', code); + + const moduleNode = result.nodes.find((n) => n.kind === 'module'); + expect(moduleNode).toBeDefined(); + expect(moduleNode?.name).toBe('MyUnit'); + expect(moduleNode?.language).toBe('pascal'); + }); + + it('should extract program as module', () => { + const code = `program MyApp;\nbegin\nend.`; + const result = extractFromSource('MyApp.dpr', code); + + const moduleNode = result.nodes.find((n) => n.kind === 'module'); + expect(moduleNode).toBeDefined(); + expect(moduleNode?.name).toBe('MyApp'); + }); + }); + + describe('Uses clause (imports)', () => { + it('should extract uses as individual imports', () => { + const code = `unit Test;\ninterface\nuses\n System.SysUtils,\n System.Classes;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const imports = result.nodes.filter((n) => n.kind === 'import'); + expect(imports.length).toBe(2); + expect(imports.map((n) => n.name)).toContain('System.SysUtils'); + expect(imports.map((n) => n.name)).toContain('System.Classes'); + }); + + it('should create unresolved references for imports', () => { + const code = `unit Test;\ninterface\nuses\n UAuth;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const importRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'imports' + ); + expect(importRef).toBeDefined(); + expect(importRef?.referenceName).toBe('UAuth'); + }); + }); + + describe('Class extraction', () => { + it('should extract class declarations', () => { + const code = `unit Test;\ninterface\ntype\n TMyClass = class\n public\n procedure DoSomething;\n end;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const classNode = result.nodes.find((n) => n.kind === 'class'); + expect(classNode).toBeDefined(); + expect(classNode?.name).toBe('TMyClass'); + }); + + it('should extract class with inheritance', () => { + const code = `unit Test;\ninterface\ntype\n TChild = class(TParent)\n end;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const extendsRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'extends' + ); + expect(extendsRef).toBeDefined(); + expect(extendsRef?.referenceName).toBe('TParent'); + }); + + it('should extract class with interface implementation', () => { + const code = `unit Test;\ninterface\ntype\n TService = class(TInterfacedObject, ILogger)\n end;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const extendsRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'extends' + ); + const implementsRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'implements' + ); + expect(extendsRef?.referenceName).toBe('TInterfacedObject'); + expect(implementsRef?.referenceName).toBe('ILogger'); + }); + }); + + describe('Record extraction', () => { + it('should extract records as class nodes', () => { + const code = `unit Test;\ninterface\ntype\n TPoint = record\n X: Double;\n Y: Double;\n end;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const classNode = result.nodes.find((n) => n.kind === 'class'); + expect(classNode).toBeDefined(); + expect(classNode?.name).toBe('TPoint'); + + const fields = result.nodes.filter((n) => n.kind === 'field'); + expect(fields.length).toBe(2); + expect(fields.map((f) => f.name)).toContain('X'); + expect(fields.map((f) => f.name)).toContain('Y'); + }); + }); + + describe('Interface extraction', () => { + it('should extract interface declarations', () => { + const code = `unit Test;\ninterface\ntype\n ILogger = interface\n procedure Log(const AMsg: string);\n end;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const ifaceNode = result.nodes.find((n) => n.kind === 'interface'); + expect(ifaceNode).toBeDefined(); + expect(ifaceNode?.name).toBe('ILogger'); + }); + }); + + describe('Method extraction', () => { + it('should extract methods with visibility', () => { + const code = `unit Test;\ninterface\ntype\n TMyClass = class\n private\n FValue: Integer;\n public\n constructor Create;\n function GetValue: Integer;\n end;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const methods = result.nodes.filter((n) => n.kind === 'method'); + expect(methods.length).toBe(2); + + const createMethod = methods.find((m) => m.name === 'Create'); + expect(createMethod?.visibility).toBe('public'); + + const getValue = methods.find((m) => m.name === 'GetValue'); + expect(getValue?.visibility).toBe('public'); + + const fields = result.nodes.filter((n) => n.kind === 'field'); + const fValue = fields.find((f) => f.name === 'FValue'); + expect(fValue?.visibility).toBe('private'); + }); + + it('should detect static methods (class methods)', () => { + const code = `unit Test;\ninterface\ntype\n THelper = class\n public\n class function Create: THelper; static;\n end;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const methods = result.nodes.filter((n) => n.kind === 'method'); + const staticMethod = methods.find((m) => m.name === 'Create'); + expect(staticMethod?.isStatic).toBe(true); + }); + }); + + describe('Enum extraction', () => { + it('should extract enums with members', () => { + const code = `unit Test;\ninterface\ntype\n TColor = (clRed, clGreen, clBlue);\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const enumNode = result.nodes.find((n) => n.kind === 'enum'); + expect(enumNode).toBeDefined(); + expect(enumNode?.name).toBe('TColor'); + + const members = result.nodes.filter((n) => n.kind === 'enum_member'); + expect(members.length).toBe(3); + expect(members.map((m) => m.name)).toEqual(['clRed', 'clGreen', 'clBlue']); + }); + }); + + describe('Property extraction', () => { + it('should extract properties', () => { + const code = `unit Test;\ninterface\ntype\n TObj = class\n public\n property Name: string read FName write FName;\n end;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const propNode = result.nodes.find((n) => n.kind === 'property'); + expect(propNode).toBeDefined(); + expect(propNode?.name).toBe('Name'); + expect(propNode?.visibility).toBe('public'); + }); + }); + + describe('Constant extraction', () => { + it('should extract constants', () => { + const code = `unit Test;\ninterface\nconst\n MAX_RETRIES = 3;\n APP_NAME = 'MyApp';\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const constants = result.nodes.filter((n) => n.kind === 'constant'); + expect(constants.length).toBe(2); + expect(constants.map((c) => c.name)).toContain('MAX_RETRIES'); + expect(constants.map((c) => c.name)).toContain('APP_NAME'); + }); + }); + + describe('Type alias extraction', () => { + it('should extract type aliases', () => { + const code = `unit Test;\ninterface\ntype\n TUserName = string;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const aliasNode = result.nodes.find((n) => n.kind === 'type_alias'); + expect(aliasNode).toBeDefined(); + expect(aliasNode?.name).toBe('TUserName'); + }); + }); + + describe('Call extraction', () => { + it('should extract calls from implementation bodies', () => { + const code = `unit Test;\ninterface\ntype\n TObj = class\n public\n procedure DoWork;\n end;\nimplementation\nprocedure TObj.DoWork;\nbegin\n WriteLn('hello');\nend;\nend.`; + const result = extractFromSource('Test.pas', code); + + const callRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'calls' + ); + expect(callRef).toBeDefined(); + expect(callRef?.referenceName).toBe('WriteLn'); + }); + }); + + describe('Containment edges', () => { + it('should create contains edges for class members', () => { + const code = `unit Test;\ninterface\ntype\n TObj = class\n public\n procedure Foo;\n end;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const classNode = result.nodes.find((n) => n.kind === 'class'); + const methodNode = result.nodes.find((n) => n.kind === 'method'); + expect(classNode).toBeDefined(); + expect(methodNode).toBeDefined(); + + const containsEdge = result.edges.find( + (e) => e.source === classNode?.id && e.target === methodNode?.id && e.kind === 'contains' + ); + expect(containsEdge).toBeDefined(); + }); + }); + + describe('Full fixture: UAuth.pas', () => { + const code = `unit UAuth; + +interface + +uses + System.SysUtils, + System.Classes; + +type + ITokenValidator = interface + ['{11111111-1111-1111-1111-111111111111}'] + function Validate(const AToken: string): Boolean; + end; + + TAuthService = class(TInterfacedObject, ITokenValidator) + private + FToken: string; + FLoginCount: Integer; + procedure IncLoginCount; + protected + function GetToken: string; + public + constructor Create; + destructor Destroy; override; + function Validate(const AToken: string): Boolean; + function Login(const AUser, APass: string): string; + property Token: string read GetToken; + property LoginCount: Integer read FLoginCount; + end; + +implementation + +constructor TAuthService.Create; +begin + inherited Create; + FToken := ''; + FLoginCount := 0; +end; + +destructor TAuthService.Destroy; +begin + FToken := ''; + inherited Destroy; +end; + +procedure TAuthService.IncLoginCount; +begin + Inc(FLoginCount); +end; + +function TAuthService.GetToken: string; +begin + Result := FToken; +end; + +function TAuthService.Validate(const AToken: string): Boolean; +begin + Result := AToken <> ''; +end; + +function TAuthService.Login(const AUser, APass: string): string; +begin + IncLoginCount; + if Validate(AUser + ':' + APass) then + begin + FToken := AUser; + Result := 'ok'; + end + else + Result := ''; +end; + +end.`; + + it('should extract all expected nodes', () => { + const result = extractFromSource('UAuth.pas', code); + + expect(result.errors).toHaveLength(0); + + // Module + const moduleNode = result.nodes.find((n) => n.kind === 'module'); + expect(moduleNode?.name).toBe('UAuth'); + + // Imports + const imports = result.nodes.filter((n) => n.kind === 'import'); + expect(imports.length).toBe(2); + + // Interface + const ifaceNode = result.nodes.find((n) => n.kind === 'interface'); + expect(ifaceNode?.name).toBe('ITokenValidator'); + + // Class + const classNode = result.nodes.find((n) => n.kind === 'class'); + expect(classNode?.name).toBe('TAuthService'); + + // Methods + const methods = result.nodes.filter((n) => n.kind === 'method'); + expect(methods.length).toBeGreaterThanOrEqual(6); + expect(methods.map((m) => m.name)).toContain('Create'); + expect(methods.map((m) => m.name)).toContain('Destroy'); + expect(methods.map((m) => m.name)).toContain('Login'); + + // Fields + const fields = result.nodes.filter((n) => n.kind === 'field'); + expect(fields.length).toBe(2); + expect(fields.every((f) => f.visibility === 'private')).toBe(true); + + // Properties + const props = result.nodes.filter((n) => n.kind === 'property'); + expect(props.length).toBe(2); + expect(props.map((p) => p.name)).toContain('Token'); + expect(props.map((p) => p.name)).toContain('LoginCount'); + }); + + it('should extract inheritance and interface implementation', () => { + const result = extractFromSource('UAuth.pas', code); + + const extendsRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'extends' + ); + expect(extendsRef?.referenceName).toBe('TInterfacedObject'); + + const implementsRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'implements' + ); + expect(implementsRef?.referenceName).toBe('ITokenValidator'); + }); + + it('should extract calls from implementation', () => { + const result = extractFromSource('UAuth.pas', code); + + const callRefs = result.unresolvedReferences.filter( + (r) => r.referenceKind === 'calls' + ); + expect(callRefs.map((r) => r.referenceName)).toContain('Inc'); + expect(callRefs.map((r) => r.referenceName)).toContain('Validate'); + }); + }); + + describe('Full fixture: UTypes.pas', () => { + const code = `unit UTypes; + +interface + +uses + System.SysUtils; + +const + C_MAX_RETRIES = 3; + C_DEFAULT_NAME = 'Guest'; + +type + TUserRole = (urAdmin, urEditor, urViewer); + + TPoint2D = record + X: Double; + Y: Double; + end; + + TUserName = string; + + TUserInfo = class + public + type + TAddress = record + Street: string; + City: string; + Zip: string; + end; + private + FName: TUserName; + FRole: TUserRole; + FAddress: TAddress; + public + constructor Create(const AName: TUserName; ARole: TUserRole); + function GetDisplayName: string; + class function CreateAdmin(const AName: TUserName): TUserInfo; static; + property Name: TUserName read FName write FName; + property Role: TUserRole read FRole; + property Address: TAddress read FAddress write FAddress; + end; + +implementation + +constructor TUserInfo.Create(const AName: TUserName; ARole: TUserRole); +begin + FName := AName; + FRole := ARole; +end; + +function TUserInfo.GetDisplayName: string; +begin + if FRole = urAdmin then + Result := '[Admin] ' + FName + else + Result := FName; +end; + +class function TUserInfo.CreateAdmin(const AName: TUserName): TUserInfo; +begin + Result := TUserInfo.Create(AName, urAdmin); +end; + +end.`; + + it('should extract enums with members', () => { + const result = extractFromSource('UTypes.pas', code); + + const enumNode = result.nodes.find((n) => n.kind === 'enum'); + expect(enumNode?.name).toBe('TUserRole'); + + const members = result.nodes.filter((n) => n.kind === 'enum_member'); + expect(members.length).toBe(3); + expect(members.map((m) => m.name)).toEqual(['urAdmin', 'urEditor', 'urViewer']); + }); + + it('should extract constants', () => { + const result = extractFromSource('UTypes.pas', code); + + const constants = result.nodes.filter((n) => n.kind === 'constant'); + expect(constants.length).toBe(2); + expect(constants.map((c) => c.name)).toContain('C_MAX_RETRIES'); + expect(constants.map((c) => c.name)).toContain('C_DEFAULT_NAME'); + }); + + it('should extract type aliases', () => { + const result = extractFromSource('UTypes.pas', code); + + const aliases = result.nodes.filter((n) => n.kind === 'type_alias'); + expect(aliases.map((a) => a.name)).toContain('TUserName'); + }); + + it('should extract records as classes with fields', () => { + const result = extractFromSource('UTypes.pas', code); + + const classes = result.nodes.filter((n) => n.kind === 'class'); + expect(classes.map((c) => c.name)).toContain('TPoint2D'); + + // TPoint2D fields + const fields = result.nodes.filter((n) => n.kind === 'field'); + expect(fields.map((f) => f.name)).toContain('X'); + expect(fields.map((f) => f.name)).toContain('Y'); + }); + + it('should extract static class methods', () => { + const result = extractFromSource('UTypes.pas', code); + + const methods = result.nodes.filter((n) => n.kind === 'method'); + const staticMethod = methods.find((m) => m.name === 'CreateAdmin'); + expect(staticMethod).toBeDefined(); + expect(staticMethod?.isStatic).toBe(true); + }); + + it('should extract nested types', () => { + const result = extractFromSource('UTypes.pas', code); + + const classes = result.nodes.filter((n) => n.kind === 'class'); + expect(classes.map((c) => c.name)).toContain('TAddress'); + }); + }); +}); + +// ============================================================================= +// DFM/FMX Extraction +// ============================================================================= + +describe('DFM/FMX Extraction', () => { + it('should extract components from DFM', () => { + const code = `object Form1: TForm1 + Left = 0 + Top = 0 + Caption = 'My Form' + object Button1: TButton + Left = 10 + Top = 10 + Caption = 'Click Me' + end +end`; + const result = extractFromSource('Form1.dfm', code); + + const components = result.nodes.filter((n) => n.kind === 'component'); + expect(components.length).toBe(2); + expect(components.map((c) => c.name)).toContain('Form1'); + expect(components.map((c) => c.name)).toContain('Button1'); + + const button = components.find((c) => c.name === 'Button1'); + expect(button?.signature).toBe('TButton'); + }); + + it('should extract nested component hierarchy', () => { + const code = `object Form1: TForm1 + object Panel1: TPanel + object Label1: TLabel + Caption = 'Hello' + end + end +end`; + const result = extractFromSource('Form1.dfm', code); + + const components = result.nodes.filter((n) => n.kind === 'component'); + expect(components.length).toBe(3); + + // Check nesting: Panel1 contains Label1 + const panel = components.find((c) => c.name === 'Panel1'); + const label = components.find((c) => c.name === 'Label1'); + const containsEdge = result.edges.find( + (e) => e.source === panel?.id && e.target === label?.id && e.kind === 'contains' + ); + expect(containsEdge).toBeDefined(); + }); + + it('should extract event handler references', () => { + const code = `object Form1: TForm1 + OnCreate = FormCreate + OnDestroy = FormDestroy + object Button1: TButton + OnClick = Button1Click + end +end`; + const result = extractFromSource('Form1.dfm', code); + + const refs = result.unresolvedReferences; + expect(refs.length).toBe(3); + expect(refs.map((r) => r.referenceName)).toContain('FormCreate'); + expect(refs.map((r) => r.referenceName)).toContain('FormDestroy'); + expect(refs.map((r) => r.referenceName)).toContain('Button1Click'); + expect(refs.every((r) => r.referenceKind === 'references')).toBe(true); + }); + + it('should handle multi-line properties', () => { + const code = `object Form1: TForm1 + SQL.Strings = ( + 'SELECT * FROM users' + 'WHERE active = 1') + object Button1: TButton + OnClick = Button1Click + end +end`; + const result = extractFromSource('Form1.dfm', code); + + const components = result.nodes.filter((n) => n.kind === 'component'); + expect(components.length).toBe(2); + + const refs = result.unresolvedReferences; + expect(refs.length).toBe(1); + expect(refs[0]?.referenceName).toBe('Button1Click'); + }); + + it('should handle inherited keyword', () => { + const code = `inherited Form1: TForm1 + Caption = 'Inherited Form' + object Button1: TButton + OnClick = Button1Click + end +end`; + const result = extractFromSource('Form1.dfm', code); + + const components = result.nodes.filter((n) => n.kind === 'component'); + expect(components.length).toBe(2); + expect(components.map((c) => c.name)).toContain('Form1'); + }); + + it('should handle item collection properties', () => { + const code = `object Form1: TForm1 + object StatusBar1: TStatusBar + Panels = < + item + Width = 200 + end + item + Width = 200 + end> + end +end`; + const result = extractFromSource('Form1.dfm', code); + + const components = result.nodes.filter((n) => n.kind === 'component'); + expect(components.length).toBe(2); + }); + + describe('Full fixture: MainForm.dfm', () => { + const code = `object frmMain: TfrmMain + Left = 0 + Top = 0 + Caption = 'CodeGraph DFM Fixture' + ClientHeight = 480 + ClientWidth = 640 + OnCreate = FormCreate + OnDestroy = FormDestroy + object pnlTop: TPanel + Left = 0 + Top = 0 + Width = 640 + Height = 50 + object lblTitle: TLabel + Left = 16 + Top = 16 + Caption = 'Authentication Service' + end + object btnLogin: TButton + Left = 540 + Top = 12 + OnClick = btnLoginClick + end + end + object pnlContent: TPanel + Left = 0 + Top = 50 + object edtUsername: TEdit + Left = 16 + Top = 16 + OnChange = edtUsernameChange + end + object edtPassword: TEdit + Left = 16 + Top = 48 + OnKeyPress = edtPasswordKeyPress + end + object mmoLog: TMemo + Left = 16 + Top = 88 + end + end + object pnlStatus: TStatusBar + Left = 0 + Top = 440 + Panels = < + item + Width = 200 + end + item + Width = 200 + end> + end +end`; + + it('should extract all components', () => { + const result = extractFromSource('MainForm.dfm', code); + + const components = result.nodes.filter((n) => n.kind === 'component'); + expect(components.length).toBe(9); + expect(components.map((c) => c.name)).toEqual( + expect.arrayContaining([ + 'frmMain', 'pnlTop', 'lblTitle', 'btnLogin', + 'pnlContent', 'edtUsername', 'edtPassword', 'mmoLog', 'pnlStatus', + ]) + ); + }); + + it('should extract all event handlers', () => { + const result = extractFromSource('MainForm.dfm', code); + + const refs = result.unresolvedReferences; + expect(refs.length).toBe(5); + expect(refs.map((r) => r.referenceName)).toEqual( + expect.arrayContaining([ + 'FormCreate', 'FormDestroy', 'btnLoginClick', + 'edtUsernameChange', 'edtPasswordKeyPress', + ]) + ); + }); + }); +}); + describe('Full Indexing', () => { let tempDir: string; diff --git a/package-lock.json b/package-lock.json index f7f4e76..d09146f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "commander": "^14.0.2", "figlet": "^1.8.0", "picomatch": "^4.0.3", - "tree-sitter": "0.22.4" + "tree-sitter": "0.22.4", + "tree-sitter-pascal": "github:Isopod/tree-sitter-pascal" }, "bin": { "codegraph": "dist/bin/codegraph.js" @@ -877,7 +878,6 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" @@ -909,7 +909,6 @@ "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^18 || ^20 || >= 21" } @@ -2342,6 +2341,7 @@ "integrity": "sha512-usbHZP9/oxNsUY65MQUsduGRqDHQOou1cagUSwjhoSYAmSahjQDAVsh9s+SlZkn8X8+O1FULRGwHu7AFP3kjzg==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" @@ -2582,6 +2582,23 @@ } } }, + "node_modules/tree-sitter-pascal": { + "version": "0.10.2", + "resolved": "git+ssh://git@github.com/Isopod/tree-sitter-pascal.git#042119eca2e18a60e56317fb06ee3ba5c32cb447", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.8.4" + }, + "peerDependencies": { + "tree-sitter": "^0.22.0" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, "node_modules/tree-sitter-php": { "version": "0.23.11", "resolved": "https://registry.npmjs.org/tree-sitter-php/-/tree-sitter-php-0.23.11.tgz", @@ -2818,6 +2835,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/package.json b/package.json index 6bd64ee..5d35abc 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "commander": "^14.0.2", "figlet": "^1.8.0", "picomatch": "^4.0.3", - "tree-sitter": "0.22.4" + "tree-sitter": "0.22.4", + "tree-sitter-pascal": "github:Isopod/tree-sitter-pascal" }, "devDependencies": { "@types/better-sqlite3": "^7.6.0", diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 5ef676b..7157be2 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -80,6 +80,10 @@ const grammarLoaders: Record = { // eslint-disable-next-line @typescript-eslint/no-require-imports return require('@sengac/tree-sitter-dart'); }, + pascal: () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require('tree-sitter-pascal'); + }, // Note: tree-sitter-liquid has ABI compatibility issues with tree-sitter 0.22+ // Liquid extraction is handled separately via regex in tree-sitter.ts }; @@ -116,6 +120,12 @@ export const EXTENSION_MAP: Record = { '.dart': 'dart', '.liquid': 'liquid', '.svelte': 'svelte', + '.pas': 'pascal', + '.dpr': 'pascal', + '.dpk': 'pascal', + '.lpr': 'pascal', + '.dfm': 'pascal', + '.fmx': 'pascal', }; /** @@ -245,6 +255,7 @@ export function getLanguageDisplayName(language: Language): string { dart: 'Dart', svelte: 'Svelte', liquid: 'Liquid', + pascal: 'Pascal / Delphi', unknown: 'Unknown', }; return names[language] || language; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index b19b7a7..9cf94b8 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -755,6 +755,64 @@ const EXTRACTORS: Partial> = { return false; }, }, + pascal: { + functionTypes: ['declProc'], + classTypes: ['declClass'], + methodTypes: ['declProc'], + interfaceTypes: ['declIntf'], + structTypes: [], + enumTypes: ['declEnum'], + typeAliasTypes: ['declType'], + importTypes: ['declUses'], + callTypes: ['exprCall'], + variableTypes: ['declField', 'declConst'], + nameField: 'name', + bodyField: 'body', + paramsField: 'args', + returnField: 'type', + getSignature: (node, source) => { + const args = getChildByField(node, 'args'); + const returnType = node.namedChildren.find( + (c: SyntaxNode) => c.type === 'typeref' + ); + if (!args && !returnType) return undefined; + let sig = ''; + if (args) sig = getNodeText(args, source); + if (returnType) { + sig += ': ' + getNodeText(returnType, source); + } + return sig || undefined; + }, + getVisibility: (node) => { + let current = node.parent; + while (current) { + if (current.type === 'declSection') { + for (let i = 0; i < current.childCount; i++) { + const child = current.child(i); + if (child?.type === 'kPublic' || child?.type === 'kPublished') + return 'public'; + if (child?.type === 'kPrivate') return 'private'; + if (child?.type === 'kProtected') return 'protected'; + } + } + current = current.parent; + } + return undefined; + }, + isExported: (_node, _source) => { + // In Pascal, symbols declared in the interface section are exported + return false; + }, + isStatic: (node) => { + for (let i = 0; i < node.childCount; i++) { + if (node.child(i)?.type === 'kClass') return true; + } + return false; + }, + isConst: (node) => { + return node.type === 'declConst'; + }, + }, }; // TSX and JSX use the same extractors as their base languages @@ -924,6 +982,12 @@ export class TreeSitterExtractor { const nodeType = node.type; let skipChildren = false; + // Pascal-specific AST handling + if (this.language === 'pascal') { + skipChildren = this.visitPascalNode(node); + if (skipChildren) return; + } + // Check for function declarations // For Python/Ruby, function_definition inside a class should be treated as method if (this.extractor.functionTypes.includes(nodeType)) { @@ -1979,6 +2043,379 @@ export class TreeSitterExtractor { } } } + + /** + * Handle Pascal-specific AST structures. + * Returns true if the node was fully handled and children should be skipped. + */ + private visitPascalNode(node: SyntaxNode): boolean { + const nodeType = node.type; + + // Unit/Program/Library → module node + if (nodeType === 'unit' || nodeType === 'program' || nodeType === 'library') { + const moduleNameNode = node.namedChildren.find( + (c: SyntaxNode) => c.type === 'moduleName' + ); + if (moduleNameNode) { + const name = getNodeText(moduleNameNode, this.source); + this.createNode('module', name, node); + } + // Continue visiting children (interface/implementation sections) + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) this.visitNode(child); + } + return true; + } + + // declType wraps declClass/declIntf/declEnum/type-alias + // The name lives on declType, the inner node determines the kind + if (nodeType === 'declType') { + this.extractPascalDeclType(node); + return true; + } + + // declUses → import nodes for each unit name + if (nodeType === 'declUses') { + this.extractPascalUses(node); + return true; + } + + // declConsts → container; visit children for individual declConst + if (nodeType === 'declConsts') { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'declConst') { + this.extractPascalConst(child); + } + } + return true; + } + + // declConst at top level (outside declConsts) + if (nodeType === 'declConst') { + this.extractPascalConst(node); + return true; + } + + // declTypes → container for type declarations + if (nodeType === 'declTypes') { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) this.visitNode(child); + } + return true; + } + + // declVars → container for variable declarations + if (nodeType === 'declVars') { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'declVar') { + const nameNode = getChildByField(child, 'name'); + if (nameNode) { + const name = getNodeText(nameNode, this.source); + this.createNode('variable', name, child); + } + } + } + return true; + } + + // defProc in implementation section → extract calls but don't create duplicate nodes + if (nodeType === 'defProc') { + this.extractPascalDefProc(node); + return true; + } + + // declProp → property node + if (nodeType === 'declProp') { + const nameNode = getChildByField(node, 'name'); + if (nameNode) { + const name = getNodeText(nameNode, this.source); + const visibility = this.extractor!.getVisibility?.(node); + this.createNode('property', name, node, { visibility }); + } + return true; + } + + // declField → field node + if (nodeType === 'declField') { + const nameNode = getChildByField(node, 'name'); + if (nameNode) { + const name = getNodeText(nameNode, this.source); + const visibility = this.extractor!.getVisibility?.(node); + this.createNode('field', name, node, { visibility }); + } + return true; + } + + // declSection → visit children (propagates visibility via getVisibility) + if (nodeType === 'declSection') { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) this.visitNode(child); + } + return true; + } + + // exprCall → extract function call reference + if (nodeType === 'exprCall') { + this.extractPascalCall(node); + return true; + } + + // interface/implementation sections → visit children + if (nodeType === 'interface' || nodeType === 'implementation') { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) this.visitNode(child); + } + return true; + } + + // block (begin..end) → visit for calls + if (nodeType === 'block') { + this.visitPascalBlock(node); + return true; + } + + return false; + } + + /** + * Extract a Pascal declType node (class, interface, enum, or type alias) + */ + private extractPascalDeclType(node: SyntaxNode): void { + const nameNode = getChildByField(node, 'name'); + if (!nameNode) return; + const name = getNodeText(nameNode, this.source); + + // Find the inner type declaration + const declClass = node.namedChildren.find( + (c: SyntaxNode) => c.type === 'declClass' + ); + const declIntf = node.namedChildren.find( + (c: SyntaxNode) => c.type === 'declIntf' + ); + const typeChild = node.namedChildren.find( + (c: SyntaxNode) => c.type === 'type' + ); + + if (declClass) { + const classNode = this.createNode('class', name, node); + // Extract inheritance from typeref children of declClass + this.extractPascalInheritance(declClass, classNode.id); + // Visit class body + this.nodeStack.push(classNode.id); + for (let i = 0; i < declClass.namedChildCount; i++) { + const child = declClass.namedChild(i); + if (child) this.visitNode(child); + } + this.nodeStack.pop(); + } else if (declIntf) { + const ifaceNode = this.createNode('interface', name, node); + // Visit interface members + this.nodeStack.push(ifaceNode.id); + for (let i = 0; i < declIntf.namedChildCount; i++) { + const child = declIntf.namedChild(i); + if (child) this.visitNode(child); + } + this.nodeStack.pop(); + } else if (typeChild) { + // Check if it contains a declEnum + const declEnum = typeChild.namedChildren.find( + (c: SyntaxNode) => c.type === 'declEnum' + ); + if (declEnum) { + const enumNode = this.createNode('enum', name, node); + // Extract enum members + this.nodeStack.push(enumNode.id); + for (let i = 0; i < declEnum.namedChildCount; i++) { + const child = declEnum.namedChild(i); + if (child?.type === 'declEnumValue') { + const memberName = getChildByField(child, 'name'); + if (memberName) { + this.createNode('enum_member', getNodeText(memberName, this.source), child); + } + } + } + this.nodeStack.pop(); + } else { + // Simple type alias: type TFoo = string / type TFoo = Integer + this.createNode('type_alias', name, node); + } + } else { + // Fallback: could be a forward declaration or simple alias + this.createNode('type_alias', name, node); + } + } + + /** + * Extract Pascal uses clause into individual import nodes + */ + private extractPascalUses(node: SyntaxNode): void { + const importText = getNodeText(node, this.source).trim(); + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'moduleName') { + const unitName = getNodeText(child, this.source); + this.createNode('import', unitName, child, { + signature: importText, + }); + // Create unresolved reference for resolution + if (this.nodeStack.length > 0) { + const parentId = this.nodeStack[this.nodeStack.length - 1]; + if (parentId) { + this.unresolvedReferences.push({ + fromNodeId: parentId, + referenceName: unitName, + referenceKind: 'imports', + line: child.startPosition.row + 1, + column: child.startPosition.column, + }); + } + } + } + } + } + + /** + * Extract a Pascal constant declaration + */ + private extractPascalConst(node: SyntaxNode): void { + const nameNode = getChildByField(node, 'name'); + if (!nameNode) return; + const name = getNodeText(nameNode, this.source); + const defaultValue = node.namedChildren.find( + (c: SyntaxNode) => c.type === 'defaultValue' + ); + const sig = defaultValue ? getNodeText(defaultValue, this.source) : undefined; + this.createNode('constant', name, node, { signature: sig }); + } + + /** + * Extract Pascal inheritance (extends/implements) from declClass typeref children + */ + private extractPascalInheritance(declClass: SyntaxNode, classId: string): void { + const typerefs = declClass.namedChildren.filter( + (c: SyntaxNode) => c.type === 'typeref' + ); + for (let i = 0; i < typerefs.length; i++) { + const ref = typerefs[i]!; + const name = getNodeText(ref, this.source); + this.unresolvedReferences.push({ + fromNodeId: classId, + referenceName: name, + referenceKind: i === 0 ? 'extends' : 'implements', + line: ref.startPosition.row + 1, + column: ref.startPosition.column, + }); + } + } + + /** + * Extract calls and resolve method context from a Pascal defProc (implementation body). + * Does not create a new node — the declaration was already captured from the interface section. + */ + private extractPascalDefProc(node: SyntaxNode): void { + // Find the matching declaration node by name to use as call parent + const declProc = node.namedChildren.find( + (c: SyntaxNode) => c.type === 'declProc' + ); + if (!declProc) return; + + const nameNode = getChildByField(declProc, 'name'); + if (!nameNode) return; + const fullName = getNodeText(nameNode, this.source); + // fullName is like "TAuthService.Create" — we want just the method name part + const shortName = fullName.includes('.') ? fullName.split('.').pop()! : fullName; + + // Find matching node from earlier extraction + const existingNode = this.nodes.find( + (n) => n.name === shortName && (n.kind === 'method' || n.kind === 'function') + ); + + const parentId = existingNode?.id || this.nodeStack[this.nodeStack.length - 1]; + if (!parentId) return; + + // Visit the block for calls + const block = node.namedChildren.find( + (c: SyntaxNode) => c.type === 'block' + ); + if (block) { + this.nodeStack.push(parentId); + this.visitPascalBlock(block); + this.nodeStack.pop(); + } + } + + /** + * Extract function calls from a Pascal expression + */ + private extractPascalCall(node: SyntaxNode): void { + if (this.nodeStack.length === 0) return; + const callerId = this.nodeStack[this.nodeStack.length - 1]; + if (!callerId) return; + + // Get the callee name — first child is typically the identifier or exprDot + const firstChild = node.namedChild(0); + if (!firstChild) return; + + let calleeName = ''; + if (firstChild.type === 'exprDot') { + // Qualified call: Obj.Method(...) + const identifiers = firstChild.namedChildren.filter( + (c: SyntaxNode) => c.type === 'identifier' + ); + if (identifiers.length > 0) { + calleeName = identifiers.map((id: SyntaxNode) => getNodeText(id, this.source)).join('.'); + } + } else if (firstChild.type === 'identifier') { + calleeName = getNodeText(firstChild, this.source); + } + + if (calleeName) { + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: calleeName, + referenceKind: 'calls', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + + // Also visit arguments for nested calls + const args = node.namedChildren.find( + (c: SyntaxNode) => c.type === 'exprArgs' + ); + if (args) { + this.visitPascalBlock(args); + } + } + + /** + * Recursively visit a Pascal block/statement tree for call expressions + */ + private visitPascalBlock(node: SyntaxNode): void { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + if (child.type === 'exprCall') { + this.extractPascalCall(child); + } else if (child.type === 'exprDot') { + // Check if exprDot contains an exprCall + for (let j = 0; j < child.namedChildCount; j++) { + const grandchild = child.namedChild(j); + if (grandchild?.type === 'exprCall') { + this.extractPascalCall(grandchild); + } + } + } else { + this.visitPascalBlock(child); + } + } + } } /** @@ -2523,6 +2960,163 @@ export class SvelteExtractor { } } +/** + * Custom extractor for Delphi DFM/FMX form files. + * + * DFM/FMX files describe the visual component hierarchy and event handler + * bindings. They use a simple text format (object/end blocks) that we parse + * with regex — no tree-sitter grammar exists for this format. + * + * Extracted information: + * - Components as NodeKind `component` + * - Nesting as EdgeKind `contains` + * - Event handlers (OnClick = MethodName) as UnresolvedReference → EdgeKind `references` + */ +export class DfmExtractor { + private filePath: string; + private source: string; + private nodes: Node[] = []; + private edges: Edge[] = []; + private unresolvedReferences: UnresolvedReference[] = []; + private errors: ExtractionError[] = []; + + constructor(filePath: string, source: string) { + this.filePath = filePath; + this.source = source; + } + + /** + * Extract components and event handler references from DFM/FMX source + */ + extract(): ExtractionResult { + const startTime = Date.now(); + + try { + const fileNode = this.createFileNode(); + this.parseComponents(fileNode.id); + } catch (error) { + captureException(error, { operation: 'dfm-extraction', filePath: this.filePath }); + this.errors.push({ + message: `DFM extraction error: ${error instanceof Error ? error.message : String(error)}`, + severity: 'error', + }); + } + + return { + nodes: this.nodes, + edges: this.edges, + unresolvedReferences: this.unresolvedReferences, + errors: this.errors, + durationMs: Date.now() - startTime, + }; + } + + /** Create a file node for the DFM form file */ + private createFileNode(): Node { + const lines = this.source.split('\n'); + const id = generateNodeId(this.filePath, 'file', this.filePath, 1); + + const fileNode: Node = { + id, + kind: 'file', + name: this.filePath.split('/').pop() || this.filePath, + qualifiedName: this.filePath, + filePath: this.filePath, + language: 'pascal', + startLine: 1, + endLine: lines.length, + startColumn: 0, + endColumn: lines[lines.length - 1]?.length || 0, + updatedAt: Date.now(), + }; + + this.nodes.push(fileNode); + return fileNode; + } + + /** Parse object/end blocks and extract components + event handlers */ + private parseComponents(fileNodeId: string): void { + const lines = this.source.split('\n'); + const stack: string[] = [fileNodeId]; + + const objectPattern = /^\s*(object|inherited|inline)\s+(\w+)\s*:\s*(\w+)/; + const eventPattern = /^\s*(On\w+)\s*=\s*(\w+)\s*$/; + const endPattern = /^\s*end\s*$/; + const multiLineStart = /=\s*\(\s*$/; + const multiLineItemStart = /=\s*<\s*$/; + let inMultiLine = false; + let multiLineEndChar = ')'; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const lineNum = i + 1; + + // Skip multi-line properties + if (inMultiLine) { + if (line.trimEnd().endsWith(multiLineEndChar)) inMultiLine = false; + continue; + } + if (multiLineStart.test(line)) { + inMultiLine = true; + multiLineEndChar = ')'; + continue; + } + if (multiLineItemStart.test(line)) { + inMultiLine = true; + multiLineEndChar = '>'; + continue; + } + + // Component declaration + const objMatch = line.match(objectPattern); + if (objMatch) { + const [, , name, typeName] = objMatch; + const nodeId = generateNodeId(this.filePath, 'component', name!, lineNum); + this.nodes.push({ + id: nodeId, + kind: 'component', + name: name!, + qualifiedName: `${this.filePath}#${name}`, + filePath: this.filePath, + language: 'pascal', + startLine: lineNum, + endLine: lineNum, + startColumn: 0, + endColumn: line.length, + signature: typeName, + updatedAt: Date.now(), + }); + this.edges.push({ + source: stack[stack.length - 1]!, + target: nodeId, + kind: 'contains', + }); + stack.push(nodeId); + continue; + } + + // Event handler + const eventMatch = line.match(eventPattern); + if (eventMatch) { + const [, , methodName] = eventMatch; + this.unresolvedReferences.push({ + fromNodeId: stack[stack.length - 1]!, + referenceName: methodName!, + referenceKind: 'references', + line: lineNum, + column: 0, + }); + continue; + } + + // Block end + if (endPattern.test(line)) { + if (stack.length > 1) stack.pop(); + } + } + } +} + /** * Extract nodes and edges from source code */ @@ -2545,6 +3139,15 @@ export function extractFromSource( return extractor.extract(); } + // Use custom extractor for DFM/FMX form files + if ( + detectedLanguage === 'pascal' && + (filePath.endsWith('.dfm') || filePath.endsWith('.fmx')) + ) { + const extractor = new DfmExtractor(filePath, source); + return extractor.extract(); + } + const extractor = new TreeSitterExtractor(filePath, source, detectedLanguage); return extractor.extract(); } diff --git a/src/types.ts b/src/types.ts index 0c17ed7..df5001f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -74,6 +74,7 @@ export type Language = | 'dart' | 'svelte' | 'liquid' + | 'pascal' | 'unknown'; // ============================================================================= @@ -519,6 +520,13 @@ export const DEFAULT_CONFIG: CodeGraphConfig = { '**/*.svelte', // Liquid (Shopify themes) '**/*.liquid', + // Pascal / Delphi + '**/*.pas', + '**/*.dpr', + '**/*.dpk', + '**/*.lpr', + '**/*.dfm', + '**/*.fmx', ], exclude: [ // Version control