Files
codegraph/__tests__/pascal-nested-routines.test.ts
T
Colby MchenryandClaude Opus 5.5 df1cd1e185 fix(pascal): Delphi units with re-published properties and similar syntax parse whole (#2816)
SynEdit's main unit parsed into one ERROR node on main. A re-published
property, `property OwnsObjects;` at line 303, is one of several Delphi
constructs tree-sitter-pascal has no rule for. Each breaks the parse
locally, and enough of them cost a whole unit: routines after a break
lost their calls or had them credited to the file, their locals showed
up as unit variables, and class members after an unnamed record went
missing.

The Pascal pre-parse now fixes each construct in place, offsets and
newlines kept (signatures still read the source as written):
- a re-published property (`property Cursor default crIBeam;`) is blanked;
- a conditional right after a routine's header that holds no whole body
  (`{$IFDEF HASINLINE}inline;{$ENDIF}`) has its directives blanked, and
  so does one nested at the start of a conditional that holds no block;
- branches that open the same `begin` keep only the first one, and an
  assembler version that opens a body beside a Pascal one is blanked;
- a re-raise is blanked, or written as `;` right before an `end`;
- an unnamed record type (`fIn: record … end`) is written as `string`
  with its fields blanked, so the declaration keeps its name;
- a label right before `end` is blanked;
- alternative statements after a case label, or after then/else/do
  without a `;`, read as one block: the directives become begin, `;`
  and end, so every branch keeps its calls.

The last fix also undoes a regression the header fix caused alone:
in HeidiSQL's SynRegExpr.pas a case statement full of `{$IFDEF}`
alternatives broke the unit once main's accidental resync points were
gone (659 references lost). With all fixes it has 1,712 against
main's 1,681.

Also fixed on the way: a routine nested in another credited the
constants its parent's body reads to itself, when it was the last one
before the body (the value-ref scan pulled in the next sibling, which
is the outer routine's body).

Validation: pascal-delphi-syntax (17 tests, 13 fail on main) and a new
pascal-nested-routines case (fails on main); 14 mutations, one per rule
and three guards, each fail a test. Two pascal-system-calls fixtures
broke the parse on purpose with constructs this now reads; they use an
old-style `begin … end.` initialization and an anonymous record holding
a method instead, which still break it, and still fail when the guard
they cover is removed. Parse census, main -> fix, files
without a unit node: SynEdit 4 -> 1, HeidiSQL 6 -> 1, DelphiMVCFramework
15 -> 1, mORMot2 34 -> 6, DWScript 2100 -> 2086 (its test scripts have
no unit header). Indexed A/B, edges removed/added: SynEdit 1889/2150,
HeidiSQL 2571/3305, DelphiMVCFramework 4915/6227, mORMot2 15298/27195,
DWScript 3758/4570; leveldb and SDWebImage identical. No file loses
more call sites than it gains outside DelphiMVCFramework, where the
six that do lose only wrong edges. Index time within noise (HeidiSQL
+3% wall, +8% CPU for the code now read); the pre-parse itself takes
223 ms over mORMot2's 25 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:42:08 +00:00

402 lines
10 KiB
TypeScript

/**
* A Pascal routine may declare routines of its own between its header and
* its `begin`. Their bodies were never walked, so whatever they called was
* missing from the graph, and a call to one from the routine that declares
* it went by name to any routine elsewhere that shares it. Now a nested
* routine is a function qualified under the routine it is nested in
* (`THost::Fire::Inner`), its calls are its own, and its name means it
* wherever it is in scope: in the routine that declares it, in the routines
* nested after it there, and in its own body. Its locals and parameters are
* in scope for its body and for the routines nested inside it.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
beforeAll(async () => {
await initGrammars();
await loadGrammarsForLanguages(['pascal']);
});
async function withIndex(files: Record<string, string>, check: (cg: CodeGraph) => void): Promise<void> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-pascal-nested-'));
try {
for (const [name, code] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), code);
const cg = await CodeGraph.init(dir, { index: true });
try {
check(cg);
} finally {
cg.close();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
function routine(cg: CodeGraph, qualifiedName: string) {
const node = cg.getNodesByKind('method').concat(cg.getNodesByKind('function')).find((n) => n.qualifiedName === qualifiedName);
expect(node, qualifiedName).toBeDefined();
return node!;
}
/** What a routine calls, as `file:Target`, sorted. */
function callsFrom(cg: CodeGraph, qualifiedName: string): string[] {
return [...new Set(cg.getOutgoingEdges(routine(cg, qualifiedName).id)
.filter((e) => e.kind === 'calls')
.map((e) => cg.getNode(e.target)!)
.map((n) => `${path.basename(n.filePath)}:${n.qualifiedName}`))].sort();
}
// The routines elsewhere a nested routine's name could be taken for.
const LIB = `unit Lib;
interface
procedure Inner(N: Integer);
procedure Helper(N: Integer);
procedure Log(const S: string);
implementation
procedure Inner(N: Integer); begin end;
procedure Helper(N: Integer); begin end;
procedure Log(const S: string); begin end;
end.
`;
function host(implementation: string): string {
return `unit Host;
interface
uses Lib;
type
THost = class
public
procedure Read;
procedure Fire;
function Count(O: TObject): Integer;
end;
TOther = class
public
function Count(O: TObject): Integer;
end;
implementation
function THost.Count(O: TObject): Integer; begin Result := 0; end;
procedure THost.Read; begin end;
function TOther.Count(O: TObject): Integer; begin Result := 1; end;
${implementation}
end.
`;
}
describe('Pascal nested routines', () => {
it('indexes a nested routine as a function of its own, called by the routine that declares it', async () => {
const main = host(`procedure THost.Fire;
var
Count: Integer;
procedure Inner(N: Integer);
var
K: Integer;
begin
K := N + Count;
Helper(K);
end;
begin
Inner(1);
end;`);
await withIndex({ 'Lib.pas': LIB, 'Host.pas': main }, (cg) => {
const inner = routine(cg, 'THost::Fire::Inner');
expect(inner.kind).toBe('function');
const contains = cg.getOutgoingEdges(routine(cg, 'THost::Fire').id).filter((e) => e.kind === 'contains');
expect(contains.map((e) => e.target)).toContain(inner.id);
// Its call is its own, and the routine's call to `Inner` is to it, not to Lib's.
expect(callsFrom(cg, 'THost::Fire::Inner')).toEqual(['Lib.pas:Helper']);
expect(callsFrom(cg, 'THost::Fire')).toEqual(['Host.pas:THost::Fire::Inner']);
});
});
it('follows calls through nesting levels, siblings above and the routine itself', async () => {
const main = host(`procedure THost.Fire;
procedure First;
begin
Log('first');
end;
procedure Again(N: Integer);
begin
if N > 0 then
Again(N - 1);
First;
end;
procedure Level1;
procedure Level2;
begin
Again(2);
Level1;
end;
begin
Level2;
end;
begin
Level1;
end;`);
await withIndex({ 'Lib.pas': LIB, 'Host.pas': main }, (cg) => {
expect(callsFrom(cg, 'THost::Fire')).toEqual(['Host.pas:THost::Fire::Level1']);
expect(callsFrom(cg, 'THost::Fire::Level1')).toEqual(['Host.pas:THost::Fire::Level1::Level2']);
// A deeper routine reaches a routine nested further out, and the one it is in.
expect(callsFrom(cg, 'THost::Fire::Level1::Level2')).toEqual([
'Host.pas:THost::Fire::Again',
'Host.pas:THost::Fire::Level1',
]);
// A routine calls itself, and an earlier sibling, with or without parentheses.
expect(callsFrom(cg, 'THost::Fire::Again')).toEqual(['Host.pas:THost::Fire::Again', 'Host.pas:THost::Fire::First']);
expect(callsFrom(cg, 'THost::Fire::First')).toEqual(['Lib.pas:Log']);
});
});
it('keeps a nested routine to the routine it is nested in', async () => {
const main = host(`procedure THost.Fire;
procedure Outer;
procedure Hidden(N: Integer);
begin
end;
begin
Hidden(1);
end;
procedure Peer;
begin
Hidden(2);
Helper(3);
end;
begin
Outer;
Peer;
end;
procedure Tidy;
procedure Helper(N: Integer);
begin
end;
begin
Helper(4);
end;`);
await withIndex({ 'Lib.pas': LIB, 'Host.pas': main }, (cg) => {
expect(callsFrom(cg, 'THost::Fire::Outer')).toEqual(['Host.pas:THost::Fire::Outer::Hidden']);
// Neither another routine's nested `Hidden` nor `Tidy`'s nested `Helper` is in scope here.
expect(callsFrom(cg, 'THost::Fire::Peer')).toEqual(['Lib.pas:Helper']);
expect(callsFrom(cg, 'Tidy')).toEqual(['Host.pas:Tidy::Helper']);
});
});
it('keeps a later sibling out of scope, and a nested routine out of another unit', async () => {
const main = host(`procedure THost.Fire;
procedure Early;
begin
Inner(1);
end;
procedure Inner(N: Integer);
begin
end;
begin
Early;
Inner(2);
end;`);
const other = `unit Other;
interface
uses Lib, Host;
procedure Run;
implementation
procedure Run;
begin
Inner(3);
end;
end.
`;
await withIndex({ 'Lib.pas': LIB, 'Host.pas': main, 'Other.pas': other }, (cg) => {
// Declared after it, the sibling isn't one `Early` can call: Lib's `Inner` is.
expect(callsFrom(cg, 'THost::Fire::Early')).toEqual(['Lib.pas:Inner']);
expect(callsFrom(cg, 'THost::Fire')).toEqual(['Host.pas:THost::Fire::Early', 'Host.pas:THost::Fire::Inner']);
expect(callsFrom(cg, 'Run')).toEqual(['Lib.pas:Inner']);
});
});
it('gives a nested routine the class and the locals of the routines around it', async () => {
const main = host(`procedure THost.Fire;
var
Before: TObject;
procedure Inner;
begin
Read;
Before.Free;
Helper(Count(Before));
end;
var
Log: Integer;
procedure Second(Helper: Integer);
begin
Log := Helper + 1;
end;
begin
Inner;
Second(1);
end;`);
await withIndex({ 'Lib.pas': LIB, 'Host.pas': main }, (cg) => {
// The class's `Read` and `Count` are in scope there, not `TOther`'s
// `Count`, and `Before` is the routine's local.
expect(callsFrom(cg, 'THost::Fire::Inner')).toEqual(['Host.pas:THost::Count', 'Host.pas:THost::Read', 'Lib.pas:Helper']);
// A parameter and a local of the routine around it hide Lib's routines.
expect(callsFrom(cg, 'THost::Fire::Second')).toEqual([]);
expect(callsFrom(cg, 'THost::Fire')).toEqual(['Host.pas:THost::Fire::Inner', 'Host.pas:THost::Fire::Second']);
});
});
it('takes a routine a broken parse nests, at the start of its line, for one of the unit', async () => {
// The missing `;` after `procedure First` leaves `Second` inside it in the tree.
const script = `procedure First
begin
end;
function Second : Integer
var
i : Integer;
begin
i := 1;
Result := Helper(i);
end;
type
TThing = class
method Run : String;
method Stop;
end;
method TThing.Run : String
begin
Result := '';
end;
method TThing.Stop
const
c = 2;
begin
end;
`;
const lib = `unit Lib;
interface
function Helper(N: Integer): Integer;
implementation
function Helper(N: Integer): Integer; begin Result := N; end;
end.
`;
await withIndex({ 'Lib.pas': lib, 'Script.pas': script }, (cg) => {
const names = cg.getNodesByKind('function').filter((n) => n.filePath === 'Script.pas').map((n) => n.qualifiedName);
expect(names).toContain('Second');
expect(names).not.toContain('First::Second');
expect(callsFrom(cg, 'Second')).toEqual(['Lib.pas:Helper']);
});
});
it('takes a nested function\'s own name for its result, and a nested name like a System routine\'s for the nested one', async () => {
const main = host(`procedure THost.Fire;
function Calc: Integer;
begin
Calc := 5;
end;
procedure Delete(Index: Integer);
begin
end;
begin
if Calc > 0 then
Delete(1);
end;`);
await withIndex({ 'Lib.pas': LIB, 'Host.pas': main }, (cg) => {
expect(callsFrom(cg, 'THost::Fire::Calc')).toEqual([]);
expect(callsFrom(cg, 'THost::Fire')).toEqual(['Host.pas:THost::Fire::Calc', 'Host.pas:THost::Fire::Delete']);
});
});
it('gives a nested routine the constants it reads, not those its routine\'s body reads after it', async () => {
const code = `unit Values;
interface
const
MAX_DEPTH = 10;
OP_EXACTLY = 3;
OP_ANY = 4;
function Parse(N: Integer): Integer;
implementation
function Parse(N: Integer): Integer;
function Unquote(C: Integer): Integer;
begin
Result := C + OP_ANY;
end;
begin
if N > MAX_DEPTH then
Result := OP_EXACTLY
else
Result := Unquote(N);
end;
end.
`;
await withIndex({ 'Values.pas': code }, (cg) => {
const reads = cg.getOutgoingEdges(routine(cg, 'Parse::Unquote').id)
.filter((e) => e.kind === 'references')
.map((e) => cg.getNode(e.target)!.name);
expect(reads).toEqual(['OP_ANY']);
});
});
});