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>
This commit is contained in:
Colby Mchenry
2026-10-22 22:42:08 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ab92c90a54
commit df1cd1e185
6 changed files with 1361 additions and 50 deletions
+787 -36
View File
@@ -1,8 +1,8 @@
/**
* Offset-preserving source fixes the Pascal grammar needs before it parses a
* Delphi unit (the extractor's `preParse`). Each one blanks syntax the grammar
* has no rule for with spaces, keeping every newline, so node positions and
* text stay where they are.
* has no rule for with spaces, or writes a word it knows in its place, keeping
* every newline, so node positions and text stay where they are.
*
* - An attribute on a routine parameter, before or after its modifier:
* `const [ref] AValue: Integer`, `[ref] const Source: TVarData`,
@@ -17,6 +17,46 @@
* call in its body was read. Blanking `var` and the type leaves the loop
* the grammar knows. The resolver still finds the type: it reads a
* receiver's declaration from the source as written.
* - A property override, which names an inherited property without its type
* to publish it or change a specifier: `property OwnsObjects;`, `property
* Cursor default crIBeam;`. The grammar's property rule needs the type, so
* the class fell apart at the first override and every member declared
* after it was lost. An override declares nothing new to index.
* - A conditional right after a routine's header that doesn't hold whole
* bodies: `procedure KeyUp(…); {$IFDEF LINUX} var Code: Byte; {$ENDIF}`,
* `function Get: Integer; {$IFDEF HASINLINE}inline;{$ENDIF}`. The grammar
* reads a conditional there as alternative bodies only, so any other one
* broke the whole unit. Blanking its directives leaves its branches as
* plain code, which is how the grammar reads a conditional anywhere else.
* - A branch holding only a `begin` that another branch of its conditional
* opens too: `{$IFDEF FOLDING} Index: Integer; begin … {$ELSE} begin
* {$ENDIF}`. Read together, the branches open one block more than the code
* closes, so every routine after it was lost. The other branch's `begin`
* stays. An assembler version that opens the body beside a Pascal one,
* `{$IFDEF WIN32_ASM} asm … {$ELSE} begin … {$ENDIF} end;`, is blanked
* whole, so the Pascal version is the body.
* - A field, variable or typed constant of an anonymous record type:
* `AlgData: record case … end`, `Stack: array[1..32] of record First, Last:
* LongInt; end;`. The grammar knows a record only as a named type, so the
* record's `end` closed the class around it and the members declared after
* it were lost. The `record` (or the `packed` before it) is written over by
* `string` and the fields are blanked, so the declaration keeps its name:
* the fields belong to no type the graph could index them under.
* - A re-raise, `raise` with no exception (`except Log; raise; end`). The
* grammar's `raise` takes an exception, so it read the next word (often
* the `end`) as one and the unit broke. The `raise` is blanked, or written
* over by `;` right before an `end` (`else raise end`), where the grammar
* needs a statement to end the list.
* - A label that ends its block, right before `end`, `until`, `except` or
* `finally` (`quit: end;`). The grammar takes a label only before a
* statement, so it read the `end` as a name and the routine never closed,
* taking the routines after it along. The graph reads no labels.
* - Alternative statements where the grammar takes one: `OP_X: {$IFDEF U}
* begin … end; {$ELSE} while … do Inc(N); {$ENDIF}` after a case label, or
* `then {$IFDEF X} A := 1 {$ELSE} A := 2 {$ENDIF} else …`. Read together,
* the branches are two statements, so the case arm broke and took the
* routines after it along. The conditional's directives become `begin`, `;`
* and `end`, which makes its branches one block and keeps the code of each.
*/
const MODIFIERS: ReadonlySet<string> = new Set(['const', 'var', 'out', 'constref']);
@@ -27,13 +67,54 @@ const MAX_ATTRIBUTE_LENGTH = 2000;
const MAX_LOOP_TYPE_LENGTH = 500;
/** Words that end a for-in variable's type without an `in`: not the for-in form. */
const NOT_A_FOR_IN: ReadonlySet<string> = new Set(['do', 'to', 'downto', 'begin', 'end']);
/** Whether a file can hold a `for var … in` loop at all (the scan below decides). */
const FOR_VAR = /\bfor\s+var\b/i;
/**
* Whether a file can hold anything to fix at all (the scan below decides): a directive, an attribute, a `for var`
* loop, a property override, an anonymous record, a re-raise or a label (only a `goto` needs one).
*/
const MAY_NEED_FIXES = /\{\$|\[|\bfor\s+var\b|\bproperty\b|\brecord\b|\braise\b|\bgoto\b/i;
/** A property override longer than this, up to its `;`, is not one. */
const MAX_OVERRIDE_LENGTH = 500;
/** A routine header's rest after its parameters, or a hint after it (`message WM_SIZE;`), longer than this is not one. */
const MAX_HEADER_PART_LENGTH = 500;
/** An anonymous record longer than this, `record` to `end`, is left alone. */
const MAX_RECORD_LENGTH = 20000;
/** The hints a routine's header can carry after its `;`: the grammar's procedure attributes. */
const ROUTINE_HINTS: ReadonlySet<string> = new Set([
'static', 'virtual', 'dynamic', 'abstract', 'override', 'overload', 'reintroduce', 'inline',
'stdcall', 'cdecl', 'pascal', 'register', 'safecall', 'assembler', 'noreturn', 'local', 'far',
'near', 'default', 'nodefault', 'deprecated', 'experimental', 'message', 'platform',
'unimplemented', 'cppdecl', 'cvar', 'mwpascal', 'nostackframe', 'interrupt', 'iocheck',
'hardfloat', 'softfloat', 'ms_abi_default', 'ms_abi_cdecl', 'saveregisters', 'sysv_abi_default',
'sysv_abi_cdecl', 'vectorcall', 'varargs', 'winapi', 'public', 'export', 'alias', 'dispid',
]);
/** Words that end a block's statements, so a label right before one labels no statement, and a `raise` re-raises. */
const LABEL_ENDS: ReadonlySet<string> = new Set(['end', 'until', 'except', 'finally']);
/** Words that open a block an `end` closes, for telling whether a conditional branch leaves one open. */
const BLOCK_OPENERS: ReadonlySet<string> = new Set(['begin', 'asm', 'try', 'case', 'record']);
/** Words an anonymous record's fields can't hold: a `record` before them is something else. */
const NOT_IN_RECORD_FIELDS: ReadonlySet<string> = new Set([
'begin', 'class', 'object', 'interface', 'implementation', 'procedure', 'function', 'constructor',
'destructor', 'property', 'type', 'var', 'const', 'private', 'protected', 'public', 'published', 'strict',
]);
/** Words that start a statement, for telling a conditional's branch holds statements rather than a type. */
const STATEMENT_KEYWORDS: ReadonlySet<string> = new Set([
'begin', 'if', 'while', 'for', 'repeat', 'case', 'try', 'with', 'raise', 'inherited', 'goto', 'asm',
]);
/** Words a type can start with, so a name before `(` that is one of them starts no call. */
const TYPE_KEYWORDS: ReadonlySet<string> = new Set([
'procedure', 'function', 'reference', 'class', 'record', 'object', 'interface', 'array', 'set', 'file',
'packed', 'string', 'type', 'const', 'var', 'out',
]);
/** Words a statement can't end with: a branch ending in one holds a statement's head or a part of an expression. */
const STATEMENT_CONTINUES: ReadonlySet<string> = new Set([
'then', 'do', 'else', 'of', 'and', 'or', 'xor', 'not', 'div', 'mod', 'shl', 'shr', 'in', 'is', 'as', 'to',
'downto', 'until', 'goto', 'on',
]);
const CH_TAB = 9, CH_LF = 10, CH_CR = 13, CH_SPACE = 32, CH_AMP = 38, CH_QUOTE = 39;
const CH_LPAREN = 40, CH_RPAREN = 41, CH_STAR = 42, CH_DOT = 46, CH_SLASH = 47;
const CH_COLON = 58, CH_SEMI = 59, CH_LT = 60, CH_EQ = 61, CH_GT = 62;
const CH_LBRACKET = 91, CH_RBRACKET = 93, CH_LBRACE = 123;
const CH_TAB = 9, CH_LF = 10, CH_CR = 13, CH_SPACE = 32, CH_DOLLAR = 36, CH_AMP = 38, CH_QUOTE = 39;
const CH_LPAREN = 40, CH_RPAREN = 41, CH_STAR = 42, CH_COMMA = 44, CH_DOT = 46, CH_SLASH = 47;
const CH_COLON = 58, CH_SEMI = 59, CH_LT = 60, CH_EQ = 61, CH_GT = 62, CH_AT = 64;
const CH_LBRACKET = 91, CH_RBRACKET = 93, CH_LBRACE = 123, CH_RBRACE = 125;
/** Identifier characters, `&` (Delphi's keyword escape) and any non-ASCII letter. */
function isIdentStart(c: number): boolean {
@@ -42,6 +123,9 @@ function isIdentStart(c: number): boolean {
function isIdentPart(c: number): boolean {
return (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 95 || (c >= 48 && c <= 57) || c >= 128;
}
function isSpace(c: number): boolean {
return c === CH_SPACE || c === CH_TAB || c === CH_LF || c === CH_CR;
}
function wordEnd(s: string, i: number): number {
while (i < s.length && isIdentPart(s.charCodeAt(i))) i++;
@@ -85,21 +169,72 @@ function commentOrStringEnd(s: string, i: number): number {
return -1;
}
/** Whether a comment starts at `i`. */
function isCommentStart(s: string, i: number): boolean {
const c = s.charCodeAt(i);
return c === CH_LBRACE || (c === CH_LPAREN && s.charCodeAt(i + 1) === CH_STAR) || (c === CH_SLASH && s.charCodeAt(i + 1) === CH_SLASH);
}
/** The index of the first character from `i` that is neither whitespace nor in a comment. */
function skipTrivia(s: string, i: number): number {
while (i < s.length) {
const c = s.charCodeAt(i);
if (c === CH_SPACE || c === CH_TAB || c === CH_LF || c === CH_CR) {
i++;
} else if (c === CH_LBRACE || (c === CH_LPAREN && s.charCodeAt(i + 1) === CH_STAR) || (c === CH_SLASH && s.charCodeAt(i + 1) === CH_SLASH)) {
i = commentOrStringEnd(s, i);
} else {
break;
}
if (isSpace(s.charCodeAt(i))) i++;
else if (isCommentStart(s, i)) i = commentOrStringEnd(s, i);
else break;
}
return i;
}
/** The index after the last character before `i` that is neither whitespace nor in a `{…}` or `(*…*)` comment. */
function tokenEndBefore(s: string, i: number): number {
for (;;) {
while (i > 0 && isSpace(s.charCodeAt(i - 1))) i--;
if (i > 0 && s.charCodeAt(i - 1) === CH_RBRACE) {
const open = s.lastIndexOf('{', i - 2);
if (open < 0) return i;
i = open;
} else if (i > 1 && s.charCodeAt(i - 1) === CH_RPAREN && s.charCodeAt(i - 2) === CH_STAR) {
const open = s.lastIndexOf('(*', i - 3);
if (open < 0) return i;
i = open;
} else {
return i;
}
}
}
/** The start of the identifier that ends at `i`, or -1 when none does. */
function wordStartBefore(s: string, i: number): number {
let k = i;
while (k > 0 && isIdentPart(s.charCodeAt(k - 1))) k--;
if (k === i) return -1;
if (k > 0 && s.charCodeAt(k - 1) === CH_AMP) k--;
return isIdentStart(s.charCodeAt(k)) ? k : -1;
}
/** The index after the first `;` from `i` outside parentheses and brackets, or -1 when none comes within `max` characters. */
function semicolonEnd(s: string, i: number, max: number): number {
const limit = Math.min(s.length, i + max);
let depth = 0;
while (i < limit) {
const k = commentOrStringEnd(s, i);
if (k >= 0) {
i = k;
continue;
}
const c = s.charCodeAt(i);
if (c === CH_LPAREN || c === CH_LBRACKET) {
depth++;
} else if (c === CH_RPAREN || c === CH_RBRACKET) {
depth--;
} else if (c === CH_SEMI && depth <= 0) {
return i + 1;
}
i++;
}
return -1;
}
/**
* The index after the `]` closing the attribute group whose `[` is at `i`,
* or -1 when the group doesn't close the way an attribute does: nested
@@ -268,25 +403,587 @@ function scanForInVariable(s: string, forEnd: number, ranges: number[]): number
return i;
}
/** `s` with every `[from, to)` range in `ranges` blanked to spaces, newlines kept. */
function blankRanges(s: string, ranges: number[]): string {
if (ranges.length === 0) return s;
/**
* After a `property` keyword running from `start` to `propertyEnd`,
* recognize a property override, which names an inherited property without
* its type to publish it or change a specifier — `property OwnsObjects;`,
* `property Cursor default crIBeam;`, `property Color stored IsColorStored;`
* — and push its range, keyword (or the `class` before it) to `;`, onto
* `ranges`. A name followed by `:` or by an index list (`property
* Items[Index: Integer]: T`) declares a property the grammar knows. Returns
* the index to resume scanning from.
*/
function scanPropertyOverride(s: string, start: number, propertyEnd: number, ranges: number[]): number {
const nameStart = skipTrivia(s, propertyEnd);
if (!isIdentStart(s.charCodeAt(nameStart))) return propertyEnd;
const i = skipTrivia(s, wordEnd(s, nameStart + 1));
const next = s.charCodeAt(i);
if (next === CH_COLON || next === CH_LBRACKET) return propertyEnd;
const end = semicolonEnd(s, i, MAX_OVERRIDE_LENGTH);
if (end < 0) return propertyEnd;
const classStart = classBefore(s, start);
ranges.push(classStart >= 0 ? classStart : start, end);
return end;
}
/**
* A conditional compiler directive group, from its `{$IF…}`, `{$IFDEF…}`,
* `{$IFNDEF…}` or `{$IFOPT…}` to its `{$ENDIF}` or `{$IFEND}`. `bounds`
* holds the `[start, end)` offsets of the group's own directives in order,
* its `{$ELSE}`s and `{$ELSEIF…}`s between, so branch `b` runs from
* `bounds[2b + 1]` to `bounds[2b + 2]`; `nested[b]` says whether another
* group sits in that branch.
*/
interface ConditionalGroup {
bounds: number[];
nested: boolean[];
}
const COND_NONE = 0, COND_OPEN = 1, COND_ELSE = 2, COND_CLOSE = 3;
/** What the `{$…}` directive at `i` does to a conditional: opens one, starts its next branch, closes it, or none of these. */
function conditionalKind(s: string, i: number): number {
let j = i + 2;
while (j < s.length) {
const c = s.charCodeAt(j) | 32;
if (c < 97 || c > 122) break;
j++;
}
switch (s.slice(i + 2, j).toLowerCase()) {
case 'if': case 'ifdef': case 'ifndef': case 'ifopt': return COND_OPEN;
case 'else': case 'elseif': return COND_ELSE;
case 'endif': case 'ifend': return COND_CLOSE;
default: return COND_NONE;
}
}
/**
* Every closed conditional group in `s`, innermost first. Directives in
* strings and comments don't count, nor do `(*$…*)` ones, which the grammar
* reads as comments.
*/
function conditionalGroups(s: string): ConditionalGroup[] {
const groups: ConditionalGroup[] = [];
const open: ConditionalGroup[] = [];
const n = s.length;
let i = 0;
while (i < n) {
const c = s.charCodeAt(i);
const k = c === CH_LBRACE || c === CH_QUOTE || c === CH_LPAREN || c === CH_SLASH ? commentOrStringEnd(s, i) : -1;
if (k < 0) {
i++;
continue;
}
if (c === CH_LBRACE && s.charCodeAt(i + 1) === CH_DOLLAR) {
const kind = conditionalKind(s, i);
const top = open[open.length - 1];
if (kind === COND_OPEN) {
if (top) top.nested[top.nested.length - 1] = true;
open.push({ bounds: [i, k], nested: [false] });
} else if (kind === COND_ELSE && top) {
top.bounds.push(i, k);
top.nested.push(false);
} else if (kind === COND_CLOSE && top) {
top.bounds.push(i, k);
groups.push(open.pop()!);
}
}
i = k;
}
return groups;
}
/**
* Whether the code from `from` to `to` is a routine's locals and body, the
* one conditional the grammar reads right after a routine's header: it ends
* with the `end;` of a block that no routine declared in it owns. Code that
* closes a block it didn't open (a local class type) counts as a body too,
* so it stays the way the grammar reads it.
*/
function holdsBody(s: string, from: number, to: number): boolean {
const blocks: string[] = [];
let pending = 0; // routines declared at the top whose body hasn't come yet
let own = false; // whether the last top-level block is no declared routine's
let last = '';
let beforeLast = '';
let i = from;
while (i < to) {
const k = commentOrStringEnd(s, i);
if (k >= 0) {
if (s.charCodeAt(i) === CH_QUOTE) {
beforeLast = last;
last = "'";
}
i = k;
continue;
}
const c = s.charCodeAt(i);
if (isIdentStart(c)) {
const e = wordEnd(s, i + 1);
const word = e - i <= 11 ? s.slice(i, e).toLowerCase() : 'word';
if (blocks.length === 0) {
if (ROUTINE_KEYWORDS.has(word) && (last === '' || last === ';')) pending++;
else if ((word === 'forward' || word === 'external') && pending > 0) pending--;
}
if (word === 'end') {
if (blocks.pop() === undefined) return true;
} else if (word === 'begin' || word === 'asm') {
if (blocks.length === 0) {
own = pending === 0;
if (!own) pending--;
}
blocks.push(word);
} else if (word === 'try' || word === 'record' || (word === 'case' && blocks[blocks.length - 1] !== 'record')) {
blocks.push(word);
}
beforeLast = last;
last = word;
i = e;
continue;
}
if (!isSpace(c)) {
beforeLast = last;
last = s[i]!;
}
i++;
}
return blocks.length === 0 && own && beforeLast === 'end' && last === ';';
}
/** Whether the code from `from` to `to` holds a `begin` or an `asm`: a body, or a part of one. */
function holdsBlock(s: string, from: number, to: number): boolean {
let i = from;
while (i < to) {
const k = commentOrStringEnd(s, i);
if (k >= 0) {
i = k;
continue;
}
if (isIdentStart(s.charCodeAt(i))) {
const e = wordEnd(s, i + 1);
if (e - i === 3 || e - i === 5) {
const word = s.slice(i, e).toLowerCase();
if (word === 'begin' || word === 'asm') return true;
}
i = e;
continue;
}
i++;
}
return false;
}
/** The index of the first character from `i` that is neither whitespace nor in a comment, stopping at a conditional group's opening directive. */
function skipTriviaToGroup(s: string, i: number, groupAt: ReadonlyMap<number, ConditionalGroup>): number {
while (i < s.length) {
if (isSpace(s.charCodeAt(i))) i++;
else if (isCommentStart(s, i) && !groupAt.has(i)) i = commentOrStringEnd(s, i);
else break;
}
return i;
}
/**
* From `i`, the index after a routine header's `;`, blank the directives of
* each conditional that follows the header, past the hints it carries
* (`overload;`, `inline;`), unless every branch of the conditional holds a
* whole body: the one conditional the grammar reads there. When the
* conditional holds no block at all (only locals or hints), the scan goes on
* in its first branch, now plain code, so a conditional nested at its start
* comes right after the header too.
*/
function blankConditionalsAfterHeader(s: string, i: number, groupAt: ReadonlyMap<number, ConditionalGroup>, ranges: number[]): void {
for (;;) {
i = skipTriviaToGroup(s, i, groupAt);
const group = groupAt.get(i);
if (group) {
const { bounds } = group;
let bodies = true;
for (let b = 1; b < bounds.length - 1 && bodies; b += 2) bodies = holdsBody(s, bounds[b]!, bounds[b + 1]!);
if (bodies) return; // the grammar reads these
ranges.push(...bounds);
i = holdsBlock(s, bounds[1]!, bounds[bounds.length - 2]!) ? bounds[bounds.length - 1]! : bounds[1]!;
continue;
}
if (!ROUTINE_HINTS.has(lowerWordAt(s, i))) return;
i = semicolonEnd(s, i, MAX_HEADER_PART_LENGTH);
if (i < 0) return;
}
}
const OPEN_BEGIN = 0, OPEN_ASM = 1, OPEN_OTHER = 2;
/**
* The blocks the code from `from` to `to` leaves open, in order, each as its
* opener's offset and kind (OPEN_BEGIN, OPEN_ASM, or OPEN_OTHER for `try`,
* `case` and `record`), or null when it closes a block it didn't open.
*/
function openBlocks(s: string, from: number, to: number): Array<[number, number]> | null {
const open: Array<[number, number]> = [];
let i = from;
while (i < to) {
const k = commentOrStringEnd(s, i);
if (k >= 0) {
i = k;
continue;
}
if (isIdentStart(s.charCodeAt(i))) {
const e = wordEnd(s, i + 1);
if (e - i >= 3 && e - i <= 6) {
const word = s.slice(i, e).toLowerCase();
if (word === 'end') {
if (open.pop() === undefined) return null;
} else if (BLOCK_OPENERS.has(word)) {
open.push([i, word === 'begin' ? OPEN_BEGIN : word === 'asm' ? OPEN_ASM : OPEN_OTHER]);
}
}
i = e;
continue;
}
i++;
}
return open;
}
/**
* In every conditional (holding no conditional of its own) whose branches
* each leave blocks open for the code after it to close. When they all leave
* the same number of `begin`s open, the `begin`s each branch after the first
* leaves open are blanked: read together, they would open that many blocks
* more than the code closes, and a branch's statements can sit in the first
* branch's block, as the graph doesn't read nesting. When each opens one
* body, some with `asm` and some with `begin` (an assembler and a Pascal
* version of a routine sharing one `end`), each assembler branch is blanked
* whole, so the Pascal one is the body; the graph reads nothing in assembler.
*/
function blankDuplicateBegins(s: string, groups: readonly ConditionalGroup[], ranges: number[]): void {
for (const { bounds, nested } of groups) {
if (nested.length < 2 || nested.includes(true)) continue;
const open: Array<Array<[number, number]>> = [];
for (let b = 0; b < nested.length; b++) {
const blocks = openBlocks(s, bounds[2 * b + 1]!, bounds[2 * b + 2]!);
if (!blocks || blocks.length === 0) break;
open.push(blocks);
}
if (open.length < nested.length) continue;
const kinds = (kind: number) => open.filter((o) => o.every(([, k]) => k === kind)).length;
if (kinds(OPEN_BEGIN) === open.length && open.every((o) => o.length === open[0]!.length)) {
for (let b = 1; b < open.length; b++) for (const [at] of open[b]!) ranges.push(at, at + 5);
} else if (open.every((o) => o.length === 1) && kinds(OPEN_ASM) > 0 && kinds(OPEN_BEGIN) > 0 && kinds(OPEN_BEGIN) + kinds(OPEN_ASM) === open.length) {
let kept = false;
for (let b = 0; b < open.length; b++) {
const [at, kind] = open[b]![0]!;
if (kind === OPEN_ASM) ranges.push(bounds[2 * b + 1]!, bounds[2 * b + 2]!);
else if (kept) ranges.push(at, at + 5);
else kept = true;
}
}
}
}
const SHAPE_NONE = -1, SHAPE_STATEMENTS = 0, SHAPE_STATEMENTS_SEMI = 1, SHAPE_EMPTY = 2;
const START_NONE = 0, START_STATEMENT = 1, START_CALL = 2, START_NAME = 3;
/**
* Whether the code from `from` to `to`, a conditional's branch, holds whole statements: its blocks and brackets
* close, every `if` has its `then` and every `while`, `for`, `with` and `on` its `do`, and it ends where a
* statement can. It must start with a statement's keyword, assign at its top level or call a routine (`Foo(…)`,
* `A.B(…)`); with `bare`, a lone name counts too (`Exit`, `List.Clear;`), which after a case label could just as
* well be a type. Returns SHAPE_STATEMENTS (SHAPE_STATEMENTS_SEMI when it ends with `;`), SHAPE_EMPTY for no code,
* or SHAPE_NONE.
*/
function statementsShape(s: string, from: number, to: number, bare: boolean): number {
const blocks: boolean[] = []; // an open block each: true for a `repeat`, which `until` closes
let depth = 0;
let heads = 0; // `if`, `while`, `for`, `with` and `on` still waiting for their `then` or `do`
let tokens = 0;
let start = START_NONE;
let name = 0; // reading the first dotted name: 1 after a part of it, 2 after its `.`, 0 when done
let last = ''; // the last token: a word lowercased ('word' when long), '0' for a number, else its character
let i = from;
while (i < to) {
const k = commentOrStringEnd(s, i);
if (k >= 0) {
if (s.charCodeAt(i) === CH_QUOTE) {
tokens++;
name = 0;
last = "'";
}
i = k;
continue;
}
const c = s.charCodeAt(i);
if (isSpace(c)) {
i++;
continue;
}
tokens++;
if (isIdentStart(c)) {
const e = wordEnd(s, i + 1);
const word = e - i <= 9 ? s.slice(i, e).toLowerCase() : 'word';
if (word === 'begin' || word === 'try' || word === 'case' || word === 'asm') blocks.push(false);
else if (word === 'repeat') blocks.push(true);
else if (word === 'end' && blocks.pop() !== false) return SHAPE_NONE;
else if (word === 'until' && blocks.pop() !== true) return SHAPE_NONE;
else if (word === 'if' || word === 'while' || word === 'for' || word === 'with' || word === 'on') heads++;
else if (word === 'then' || word === 'do') heads--;
if (tokens === 1) {
if (STATEMENT_KEYWORDS.has(word)) start = START_STATEMENT;
else if (!TYPE_KEYWORDS.has(word) && !STATEMENT_CONTINUES.has(word) && word !== 'end') name = 1;
} else {
name = name === 2 ? 1 : 0;
}
last = word;
i = e;
continue;
}
if (c >= 48 && c <= 57) {
name = 0;
last = '0';
i = wordEnd(s, i + 1);
continue;
}
if (c === CH_COLON && s.charCodeAt(i + 1) === CH_EQ) {
if (depth === 0 && start !== START_CALL) start = START_STATEMENT;
name = 0;
last = ':=';
i += 2;
continue;
}
if (c === CH_LPAREN || c === CH_LBRACKET) {
if (c === CH_LPAREN && name === 1 && start === START_NONE) start = START_CALL;
depth++;
} else if ((c === CH_RPAREN || c === CH_RBRACKET) && --depth < 0) {
return SHAPE_NONE;
} else if (c === CH_SEMI && name === 1 && start === START_NONE) {
start = START_NAME;
}
name = c === CH_DOT && name === 1 ? 2 : 0;
last = s[i]!;
i++;
}
if (tokens === 0) return SHAPE_EMPTY;
if (depth !== 0 || blocks.length > 0 || heads !== 0) return SHAPE_NONE;
if (name === 1 && start === START_NONE) start = START_NAME;
if (start === START_NONE || (start === START_NAME && !bare)) return SHAPE_NONE;
const ends = last === ';' || last === ')' || last === ']' || last === '^' || last === "'" || last === '0'
|| (isIdentStart(last.charCodeAt(0)) && !STATEMENT_CONTINUES.has(last));
if (!ends) return SHAPE_NONE;
return last === ';' ? SHAPE_STATEMENTS_SEMI : SHAPE_STATEMENTS;
}
/** Whether a blank range in `ranges` overlaps `[from, to)`. */
function overlapsRange(ranges: readonly number[], from: number, to: number): boolean {
for (let r = 0; r < ranges.length; r += 2) if (ranges[r]! < to && ranges[r + 1]! > from) return true;
return false;
}
/** Whether `[at, at + length)` holds no line break, so a word written there keeps every line where it is. */
function onOneLine(s: string, at: number, length: number): boolean {
for (let i = at; i < at + length; i++) {
const c = s.charCodeAt(i);
if (c === CH_LF || c === CH_CR) return false;
}
return true;
}
/**
* Read as one statement each conditional whose branches are alternative statements where the grammar takes just
* one: after a case label, `then`, `else` or `do` (`label` maps each such group's opening directive to whether a
* case label's `:` comes before it). Its opening directive becomes `begin`, each `{$ELSE}` a `;`, and its closing
* directive `end`, or `end;` when its branches end with `;`. Read together, the branches of one after a case label
* always broke the arm (`OP_X: begin … end; while … do Inc(N);`). After the others they break only when a branch
* before the last has no `;` (`then A {$ELSE} B {$ENDIF} else C`), since `then A; B;` parses as it is. A group
* holding another conditional is left alone, as is one whose branches end in a statement's head (`if … then`) for a
* body after them.
*/
function wrapAlternativeStatements(
s: string,
groups: readonly ConditionalGroup[],
label: ReadonlyMap<number, boolean>,
ranges: readonly number[],
words: Array<[number, number, string]>,
): void {
for (const { bounds, nested } of groups) {
const afterLabel = label.get(bounds[0]!);
if (afterLabel === undefined || nested.length < 2 || nested.includes(true)) continue;
let filled = 0;
let semi = false;
let breaks = afterLabel;
let ok = true;
for (let b = 0; b < nested.length && ok; b++) {
const shape = statementsShape(s, bounds[2 * b + 1]!, bounds[2 * b + 2]!, !afterLabel);
if (shape === SHAPE_EMPTY) continue;
if (shape === SHAPE_NONE) ok = false;
if (filled > 0 && !semi) breaks = true;
filled++;
semi = shape === SHAPE_STATEMENTS_SEMI;
}
if (!ok || filled < 2 || !breaks || !onOneLine(s, bounds[0]!, 5)) continue;
let clear = true;
for (let d = 0; d < bounds.length && clear; d += 2) clear = !overlapsRange(ranges, bounds[d]!, bounds[d + 1]!);
if (!clear) continue;
words.push([bounds[0]!, bounds[1]!, 'begin']);
for (let d = 2; d < bounds.length - 2; d += 2) words.push([bounds[d]!, bounds[d + 1]!, ';']);
words.push([bounds[bounds.length - 2]!, bounds[bounds.length - 1]!, semi ? 'end;' : 'end']);
}
}
/**
* The kind of statement position the token from `at` to `end` makes for what follows it: 1 for a case label's
* `:`, 0 for `then`, `else` or `do`, -1 for anything else.
*/
function statementPosition(s: string, at: number, end: number): number {
const length = end - at;
if (length === 1) return s.charCodeAt(at) === CH_COLON ? 1 : -1;
if (length < 2 || length > 4) return -1;
const word = s.slice(at, end).toLowerCase();
return word === 'then' || word === 'else' || word === 'do' ? 0 : -1;
}
/**
* The start of the declaration that the `record` keyword at `i` gives an
* anonymous type to: the first of the `Names` in `Names: [packed] record` or
* `Names: [packed] array[…] of [packed] record`, or the `TName` in `TName =
* array[…] of record`. -1 for a named record type (`TName = record`) and
* anything else.
*/
function anonymousRecordDeclStart(s: string, i: number): number {
let j = tokenEndBefore(s, i);
let w = wordStartBefore(s, j);
if (w >= 0 && lowerWordAt(s, w) === 'packed') {
j = tokenEndBefore(s, w);
w = wordStartBefore(s, j);
}
let array = false;
if (w >= 0 && lowerWordAt(s, w) === 'of') {
j = tokenEndBefore(s, w);
if (s.charCodeAt(j - 1) === CH_RBRACKET) {
const open = s.lastIndexOf('[', j - 1);
if (open < 0) return -1;
j = tokenEndBefore(s, open);
}
w = wordStartBefore(s, j);
if (w < 0 || lowerWordAt(s, w) !== 'array') return -1;
j = tokenEndBefore(s, w);
w = wordStartBefore(s, j);
if (w >= 0 && lowerWordAt(s, w) === 'packed') j = tokenEndBefore(s, w);
array = true;
}
const c = s.charCodeAt(j - 1);
if (c !== CH_COLON && !(c === CH_EQ && array)) return -1;
let start = wordStartBefore(s, tokenEndBefore(s, j - 1));
if (start < 0 || c === CH_EQ) return start;
for (;;) {
const k = tokenEndBefore(s, start);
if (s.charCodeAt(k - 1) !== CH_COMMA) return start;
const name = wordStartBefore(s, tokenEndBefore(s, k - 1));
if (name < 0) return -1;
start = name;
}
}
/**
* The index after the `end` closing a record whose fields start at `i`, or -1 when they hold what a record's fields
* can't. A word like `procedure` or `object` is part of a field's type after `:`, `of` or `to` (`OnIdle: procedure
* of object`, `Get: reference to function: Integer`, `Args: array of const`), and words in brackets are a
* routine type's parameters.
*/
function recordEnd(s: string, i: number): number {
const limit = Math.min(s.length, i + MAX_RECORD_LENGTH);
let depth = 1;
let brackets = 0;
let prev = ''; // the token before: a word lowercased, or its character
while (i < limit) {
const k = commentOrStringEnd(s, i);
if (k >= 0) {
if (s.charCodeAt(i) === CH_QUOTE) prev = "'";
i = k;
continue;
}
const c = s.charCodeAt(i);
if (isIdentStart(c)) {
const e = wordEnd(s, i + 1);
const word = s.slice(i, e).toLowerCase();
if (brackets === 0) {
if (word === 'end') {
if (--depth === 0) return e;
} else if (word === 'record') {
depth++;
} else if (NOT_IN_RECORD_FIELDS.has(word) && prev !== ':' && prev !== 'of' && prev !== 'to') {
return -1;
}
}
prev = word;
i = e;
continue;
}
if (c === CH_LPAREN || c === CH_LBRACKET) brackets++;
else if (c === CH_RPAREN || c === CH_RBRACKET) brackets--;
else if (c === CH_EQ && brackets === 0) return -1;
if (!isSpace(c)) prev = s[i]!;
i++;
}
return -1;
}
/**
* At a `record` keyword from `start` to `end`, recognize the declaration of a field, variable or typed constant of
* an anonymous record type, followed by its `;`, a typed constant's `=`, or the `)` or `end` after a last field. The
* `record` (or the `packed` before it) is written over by `string`, a type the grammar knows, and the fields after
* it are blanked through its `end`, so the declaration keeps its name and a typed constant its value. Returns the
* index to resume from.
*/
function scanAnonymousRecord(s: string, start: number, end: number, ranges: number[], words: Array<[number, number, string]>): number {
if (anonymousRecordDeclStart(s, start) < 0) return end;
const first = lowerWordAt(s, skipTrivia(s, end));
if (first === '' || NOT_IN_RECORD_FIELDS.has(first)) return end; // `<T: record>`, `R: record;`
const close = recordEnd(s, end);
if (close < 0) return end;
const next = skipTrivia(s, close);
const c = s.charCodeAt(next);
const last = lowerWordAt(s, next) === 'end'; // a last field, whose `;` Pascal lets go but the grammar needs
if (c !== CH_SEMI && c !== CH_EQ && c !== CH_RPAREN && !last) return end;
const packed = wordStartBefore(s, tokenEndBefore(s, start));
const at = packed >= 0 && lowerWordAt(s, packed) === 'packed' ? packed : start;
words.push([at, at + 6, 'string']);
if (last) {
ranges.push(at + 6, close - 3);
words.push([close - 3, close, ';']);
} else {
ranges.push(at + 6, close);
}
return close;
}
/**
* `s` with every `[from, to)` range in `ranges` blanked to spaces, newlines kept, and every `[from, to, word]` in
* `words` (which overlap neither each other nor a range) written over by its word, padded with spaces. Ranges may
* come in any order and overlap.
*/
function applyEdits(s: string, ranges: readonly number[], words: ReadonlyArray<readonly [number, number, string]>): string {
if (ranges.length === 0 && words.length === 0) return s;
const edits: Array<readonly [number, number, string]> = words.slice();
for (let r = 0; r < ranges.length; r += 2) edits.push([ranges[r]!, ranges[r + 1]!, '']);
edits.sort((a, b) => a[0] - b[0]);
let out = '';
let last = 0;
for (let r = 0; r < ranges.length; r += 2) {
const from = ranges[r]!;
const to = ranges[r + 1]!;
out += s.slice(last, from) + s.slice(from, to).replace(/[^\r\n]/g, ' ');
for (const [start, to, word] of edits) {
const from = Math.max(start, last);
if (to <= from) continue;
const written = from === start && word.length <= to - from ? word : '';
out += s.slice(last, from) + written + s.slice(from + written.length, to).replace(/[^\r\n]/g, ' ');
last = to;
}
return out + s.slice(last);
}
/** Whether the word before index `i`, past whitespace, is `class`. */
function followsClass(s: string, i: number): boolean {
let j = i - 1;
while (j >= 0 && (s.charCodeAt(j) === CH_SPACE || s.charCodeAt(j) === CH_TAB || s.charCodeAt(j) === CH_LF || s.charCodeAt(j) === CH_CR)) j--;
return j >= 4 && s.slice(j - 4, j + 1).toLowerCase() === 'class' && (j < 5 || !isIdentPart(s.charCodeAt(j - 5)));
/** The start of the `class` word before index `i`, past whitespace, or -1. */
function classBefore(s: string, i: number): number {
let j = i;
while (j > 0 && isSpace(s.charCodeAt(j - 1))) j--;
const w = j - 5;
return w >= 0 && s.slice(w, j).toLowerCase() === 'class' && (w === 0 || !isIdentPart(s.charCodeAt(w - 1))) ? w : -1;
}
/**
@@ -294,13 +991,30 @@ function followsClass(s: string, i: number): boolean {
* the source unchanged when there is nothing to blank.
*/
export function preParsePascal(source: string): string {
if (source.indexOf('[') < 0 && !FOR_VAR.test(source)) return source;
if (!MAY_NEED_FIXES.test(source)) return source;
const directives = source.indexOf('{$') >= 0;
const ranges: number[] = [];
const words: Array<[number, number, string]> = [];
const groups = directives ? conditionalGroups(source) : [];
const groupAt = new Map<number, ConditionalGroup>();
for (const group of groups) groupAt.set(group.bounds[0]!, group);
// Each conditional's opening directive that comes where one statement goes: whether a case label is before it.
const label = new Map<number, boolean>();
// The last token read, start and end, for that; -1 right after a declaration the scan read past.
let lastAt = -1;
let lastEnd = -1;
const n = source.length;
let i = 0;
while (i < n) {
const k = commentOrStringEnd(source, i);
if (k >= 0) {
if (source.charCodeAt(i) === CH_QUOTE) {
lastAt = i;
lastEnd = k;
} else if (lastAt >= 0 && source.charCodeAt(i + 1) === CH_DOLLAR && groupAt.has(i)) {
const position = statementPosition(source, lastAt, lastEnd);
if (position >= 0) label.set(i, position === 1);
}
i = k;
continue;
}
@@ -308,25 +1022,62 @@ export function preParsePascal(source: string): string {
if (isIdentStart(c)) {
const e = wordEnd(source, i + 1);
const len = e - i;
const word = len >= 8 && len <= 11 ? source.slice(i, e).toLowerCase() : '';
const word = len >= 6 && len <= 11 ? source.slice(i, e).toLowerCase() : '';
// `operator` is no reserved word in Delphi (a call `Operator([1, 2])` is
// code), so it starts a header only after `class`.
if (ROUTINE_KEYWORDS.has(word) && (word !== 'operator' || followsClass(source, i))) {
if (ROUTINE_KEYWORDS.has(word) && (word !== 'operator' || classBefore(source, i) >= 0)) {
const open = skipTrivia(source, routineNameEnd(source, e));
if (source.charCodeAt(open) === CH_LPAREN) {
i = scanParameterList(source, open, ranges);
continue;
const next = source.charCodeAt(open) === CH_LPAREN ? scanParameterList(source, open, ranges) : e;
if (groupAt.size > 0) {
const headerEnd = semicolonEnd(source, next, MAX_HEADER_PART_LENGTH);
if (headerEnd >= 0) blankConditionalsAfterHeader(source, headerEnd, groupAt, ranges);
}
i = next;
lastAt = -1;
continue;
} else if (word === 'property') {
i = scanPropertyOverride(source, i, e, ranges);
lastAt = -1;
continue;
} else if (word === 'record') {
i = scanAnonymousRecord(source, i, e, ranges, words);
lastAt = -1;
continue;
} else if (len === 3 && source.slice(i, e).toLowerCase() === 'for') {
i = scanForInVariable(source, e, ranges);
lastAt = -1;
continue;
} else if (len === 5 && source.slice(i, e).toLowerCase() === 'raise') {
// A re-raise: blanked, or an empty statement where one must end a list (`else raise end`).
const next = skipTrivia(source, e);
const after = lowerWordAt(source, next);
if (source.charCodeAt(next) === CH_SEMI || after === 'else') ranges.push(i, e);
else if (LABEL_ENDS.has(after)) words.push([i, e, ';']);
} else {
let colon = e;
while (source.charCodeAt(colon) === CH_SPACE || source.charCodeAt(colon) === CH_TAB) colon++;
// A label ending its block (`quit: end;`), not an assembler one (`@z:`).
if (source.charCodeAt(colon) === CH_COLON && source.charCodeAt(colon + 1) !== CH_EQ && source.charCodeAt(i - 1) !== CH_AT
&& LABEL_ENDS.has(lowerWordAt(source, skipTrivia(source, colon + 1)))) ranges.push(i, colon + 1);
}
lastAt = i;
lastEnd = e;
i = e;
} else if (c >= 48 && c <= 57) {
lastAt = i;
i = wordEnd(source, i + 1); // a number
} else {
lastEnd = i;
} else if (isSpace(c)) {
i++;
} else {
lastAt = i;
i += c === CH_COLON && source.charCodeAt(i + 1) === CH_EQ ? 2 : 1;
lastEnd = i;
}
}
return blankRanges(source, ranges);
if (groups.length > 0) {
blankDuplicateBegins(source, groups, ranges);
if (label.size > 0) wrapAlternativeStatements(source, groups, label, ranges, words);
}
return applyEdits(source, ranges, words);
}
+4 -2
View File
@@ -1309,8 +1309,10 @@ export class TreeSitterExtractor {
// live in the sibling. Pull it in. (A body as a next sibling of the scope
// node is unique to Dart/Pascal among the value-ref languages — every other
// grammar nests the body inside the function node — so this is inert
// elsewhere.)
const sib = scope.node.nextNamedSibling;
// elsewhere.) A Pascal routine nested in another is stored as its whole
// `defProc`, body included, and the `block` after it is the outer
// routine's body: its reads are not the nested routine's.
const sib = scope.node.type === 'defProc' ? null : scope.node.nextNamedSibling;
if (sib && (sib.type === 'function_body' || sib.type === 'block')) stack.push(sib);
let visited = 0;
while (stack.length > 0 && visited < TreeSitterExtractor.MAX_VALUE_REF_NODES) {