Files
codegraph/__tests__/pascal-delphi-syntax.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

522 lines
12 KiB
TypeScript

/**
* Delphi syntax the Pascal grammar has no rule for, which broke whole units
* of SynEdit, mORMot2, DWScript and HeidiSQL: a property re-published
* without its type (`property OwnsObjects;`, SynEdit.pas from line 303), a
* conditional right after a routine's header that holds no whole body, two
* branches that open the same `begin`, a re-raise, a field or variable of an
* anonymous record type, a label right before `end`, and alternative
* statements where the grammar takes one (`OP_X: {$IFDEF U} … {$ELSE} …
* {$ENDIF}`). The extractor's preParse blanks each, or writes a word the
* grammar knows in its place, offsets kept, so the unit parses whole and the
* routines after it keep their calls.
*/
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 { extractFromSource } from '../src/extraction';
import { getParser, initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
import { preParsePascal } from '../src/extraction/languages/pascal-preparse';
beforeAll(async () => {
await initGrammars();
await loadGrammarsForLanguages(['pascal']);
});
/** Whether the unit parses whole: a `unit` node and no error anywhere. */
function parsesWhole(code: string): boolean {
const tree = getParser('pascal')!.parse(preParsePascal(code))!;
try {
return !tree.rootNode.hasError && tree.rootNode.namedChildren.some((c) => c.type === 'unit');
} finally {
tree.delete();
}
}
/** Every routine's calls, as `Caller->Callee`, and every node's qualified name, from the unit's extraction. */
function extract(code: string, file: string): { calls: string[]; names: string[] } {
const result = extractFromSource(file, code);
const byId = new Map(result.nodes.map((n) => [n.id, n]));
return {
calls: result.unresolvedReferences
.filter((r) => r.referenceKind === 'calls')
.map((r) => `${byId.get(r.fromNodeId)?.name}->${r.referenceName}`),
names: result.nodes.map((n) => n.qualifiedName),
};
}
const OVERRIDES = `unit Marks;
interface
type
TMarkList = class(TObjectList)
protected
function GetItem(Index: Integer): TObject;
property OwnsObjects;
public
constructor Create;
property Items[Index: Integer]: TObject read GetItem; default;
end;
TEditor = class(TCustomControl)
published
property Align;
property Cursor default crIBeam;
class property Tag;
property Lines: TStrings read FLines;
procedure Paint;
end;
implementation
procedure TEditor.Paint;
begin
Refresh(1);
end;
end.
`;
const HEADERS = `unit Headers;
interface
procedure KeyUp;
function Size: Integer;
procedure Generate;
implementation
procedure KeyUp;
{$IFDEF LINUX}
var
Code: Byte;
{$ENDIF}
begin
Translate(1);
end;
function Size: Integer; {$IFDEF HASINLINE}inline;{$ENDIF}
begin
Result := Measure(1);
end;
procedure Generate;
{$IFDEF LLVM}
{$IFDEF LLVM_EXECUTE}
var
Engine: Pointer;
{$ENDIF}
{$ENDIF}
begin
Emit(1);
end;
end.
`;
const BEGINS = `unit Begins;
interface
procedure Click;
procedure After;
implementation
procedure Click;
var
I: Integer;
{$IFDEF FOLDING}
Index: Integer;
begin
Fold(1);
if I > 0 then
begin
Exit;
end;
{$ELSE}
begin
{$ENDIF}
Paint(1);
end;
procedure After;
begin
Finish(1);
end;
end.
`;
const ASSEMBLER = `unit Assembler;
interface
function Log2Of(const V: Double): Double;
function DivMod10(var Dividend: Cardinal): Cardinal;
procedure Fill(P: Pointer);
procedure After;
implementation
procedure Fill(P: Pointer);
{$IFDEF FPC}nostackframe; assembler; asm {$ELSE} asm .noframe {$ENDIF}
mov eax, 1
end;
function Log2Of(const V: Double): Double;
{$IFDEF WIN32_ASM}
asm
fld1
fld V
fyl2x
{$ELSE}
begin
Result := Log2(V);
{$ENDIF}
end;
function DivMod10(var Dividend: Cardinal): Cardinal;
{$IFNDEF WIN32_ASM}
var
Divided: Cardinal;
begin
Divided := Dividend div 10;
Result := Remainder(Dividend, Divided);
{$ELSE}
const
C10: Cardinal = 10;
asm
mov ecx, eax
div C10
{$ENDIF}
end;
procedure After;
begin
Finish(1);
end;
end.
`;
const RERAISES = `unit Reraises;
interface
procedure Load;
procedure Parse;
implementation
procedure Load;
begin
try
Read(1);
except
Log(1);
raise;
end
end;
procedure Parse;
begin
try
Scan(1);
except
on E: EConvertError do
Recover(1);
else raise
end;
end;
end.
`;
const RECORDS = `unit Records;
interface
type
TClient = class
private
fIn: record
Headers: Pointer;
URL, Method: string;
end;
fRows: array of packed record
Id: Integer;
end;
fCount: Integer;
public
procedure Send;
end;
TCaches = array[1..4] of record
Size: Integer;
end;
TState = record
Seed: Integer;
Data: record
case Integer of
0: (Lc: Pointer)
end
end;
var
PosixInject: record
GetSmbios: function(Index: Integer): string;
OnIdle: procedure of object;
end;
Ready: Boolean;
const
Origin: record X, Y: Integer; end = (X: 0; Y: 0);
implementation
procedure TClient.Send;
begin
Post(fIn.URL);
end;
end.
`;
const LABELS = `unit Labels;
interface
function Find(P: PChar): Integer;
procedure After;
implementation
function Find(P: PChar): Integer;
label
fnd, quit;
begin
Result := 0;
if P = nil then
goto quit;
if Match(P) then
goto fnd;
goto quit;
fnd:
Result := 1;
quit:
end;
procedure After;
begin
Finish(1);
end;
end.
`;
const ALTERNATIVES = `unit Alternatives;
interface
function Repeated(Op: Integer): Integer;
function Pick(Lookup: Pointer): Integer;
implementation
function Repeated(Op: Integer): Integer;
begin
Result := 0;
case Op of
1:
{$IFDEF UNICODE}
begin
CountWide(Result);
end;
{$ELSE}
while Result < 10 do
CountAnsi(Result);
{$ENDIF}
2:
Result := 2;
end;
end;
function Pick(Lookup: Pointer): Integer;
begin
if Lookup = nil then
{$IFDEF ASMX86}
Result := PosAsm(0)
{$ELSE}
Result := PosPas(0)
{$ENDIF}
else
Result := PosLookup(Lookup);
end;
end.
`;
describe('Delphi syntax the Pascal grammar has no rule for', () => {
it.each([
['re-published properties', OVERRIDES],
['conditionals after routine headers', HEADERS],
['branches that open the same begin', BEGINS],
['an assembler and a Pascal version of a body', ASSEMBLER],
['re-raises', RERAISES],
['anonymous records', RECORDS],
['a label that ends a block', LABELS],
['alternative statements where one goes', ALTERNATIVES],
])('parses a unit whole past %s', (_name, code) => {
expect(parsesWhole(code)).toBe(true);
});
it('keeps a class whole past its re-published properties', () => {
const { calls, names } = extract(OVERRIDES, 'Marks.pas');
expect(names).toEqual(expect.arrayContaining([
'TMarkList::GetItem', 'TMarkList::Create', 'TMarkList::Items', 'TEditor::Lines', 'TEditor::Paint',
]));
// An override declares nothing new.
expect(names).not.toContain('TMarkList::OwnsObjects');
expect(names).not.toContain('TEditor::Align');
expect(calls).toEqual(['Paint->Refresh']);
});
it('gives each routine its calls past a conditional after its header', () => {
expect(extract(HEADERS, 'Headers.pas').calls.sort()).toEqual(['Generate->Emit', 'KeyUp->Translate', 'Size->Measure']);
expect(extract(BEGINS, 'Begins.pas').calls.sort()).toEqual(['After->Finish', 'Click->Fold', 'Click->Paint']);
expect(extract(ASSEMBLER, 'Assembler.pas').calls.sort()).toEqual(['After->Finish', 'DivMod10->Remainder', 'Log2Of->Log2']);
});
it('gives each routine its calls past a re-raise and a label that ends a block', () => {
expect(extract(RERAISES, 'Reraises.pas').calls.sort()).toEqual(['Load->Log', 'Load->Read', 'Parse->Recover', 'Parse->Scan']);
expect(extract(LABELS, 'Labels.pas').calls.sort()).toEqual(['After->Finish', 'Find->Match']);
});
it('keeps a declaration of an anonymous record type, and every declaration after it', () => {
const { calls, names } = extract(RECORDS, 'Records.pas');
expect(names).toEqual(expect.arrayContaining([
'TClient::fIn', 'TClient::fRows', 'TClient::fCount', 'TClient::Send', 'TCaches', 'TState::Seed', 'TState::Data',
'PosixInject', 'Ready', 'Origin',
]));
// The record's own fields are no members of the class around it.
expect(names).not.toContain('TClient::URL');
expect(names).not.toContain('GetSmbios');
expect(calls).toEqual(['Send->Post']);
});
it('reads alternative statements where one goes as one block, keeping the calls of each', () => {
expect(extract(ALTERNATIVES, 'Alternatives.pas').calls.sort()).toEqual([
'Pick->PosAsm', 'Pick->PosLookup', 'Pick->PosPas', 'Repeated->CountAnsi', 'Repeated->CountWide',
]);
});
it('keeps every offset and line, CRLF included', () => {
for (const code of [OVERRIDES, HEADERS, BEGINS, ASSEMBLER, RERAISES, RECORDS, LABELS, ALTERNATIVES]) {
const crlf = code.replace(/\n/g, '\r\n');
const out = preParsePascal(crlf);
expect(out.length).toBe(crlf.length);
const breaks = (s: string) => [...s.matchAll(/\r\n/g)].map((m) => m.index);
expect(breaks(out)).toEqual(breaks(crlf));
}
});
it('writes the words it needs where the grammar reads them', () => {
const blank = (s: string) => s.replace(/[^\r\n]/g, ' ');
expect(preParsePascal(' 1: {$IFDEF U} A(1); {$ELSE} B(2); {$ENDIF}\n'))
.toBe(' 1: begin A(1); ; B(2); end; \n');
expect(preParsePascal(' Data: record X: Integer; end;\n')).toBe(` Data: string${blank(' X: Integer; end')};\n`);
expect(preParsePascal(' Data: packed record X: Integer; end end;\n')).toBe(` Data: string${blank(' record X: Integer; ')}; end;\n`);
expect(preParsePascal(' else raise\n end;\n')).toBe(' else ; \n end;\n');
});
it('leaves alone what the grammar reads as it is', () => {
const code = `unit Keep;
interface
type
TList<T: record> = class
property Items[Index: Integer]: T read GetItem; default;
property Count: Integer read FCount;
Value: {$IFDEF CPU64} Int64 {$ELSE} Integer {$ENDIF};
end;
TRec = record
X: Integer;
end;
function Load: Integer;
implementation
function Load: Integer;
{$IFDEF PUREPASCAL}
var
I: Integer;
begin
Result := Read(I);
end;
{$ELSE}
asm
mov eax, 1
end;
{$ENDIF}
procedure Run;
begin
with List do
{$IFDEF CPU64}
if A = B then
{$ELSE}
if C = D then
{$ENDIF}
Exit;
if Ready then {$IFDEF X} Go(1); {$ELSE} Stay(1); {$ENDIF}
raise EAbort.Create('stop');
WriteLn('raise; property X; quit:');
end;
end.
`;
expect(preParsePascal(code)).toBe(code);
});
it('links a routine past a conditional after its header to what it calls', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-pascal-delphi-syntax-'));
try {
fs.writeFileSync(path.join(dir, 'Lib.pas'), `unit Lib;
interface
procedure Translate(N: Integer);
procedure Emit(N: Integer);
implementation
procedure Translate(N: Integer); begin end;
procedure Emit(N: Integer); begin end;
end.
`);
fs.writeFileSync(path.join(dir, 'Headers.pas'), HEADERS.replace('implementation\n', 'implementation\n\nuses Lib;\n'));
const cg = await CodeGraph.init(dir, { index: true });
try {
const callers = (name: string) => {
const target = cg.getNodesByKind('function').find((n) => n.qualifiedName === name && n.filePath === 'Lib.pas');
return target ? cg.getIncomingEdges(target.id).filter((e) => e.kind === 'calls').map((e) => cg.getNode(e.source)?.name) : [];
};
expect(callers('Translate')).toEqual(['KeyUp']);
expect(callers('Emit')).toEqual(['Generate']);
} finally {
cg.close();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});