/** Local Pascal symbol model for Outline, completion and hover. * * Conservative by design: only declaration shapes recognised with full * confidence are emitted (misses are acceptable, wrong entries are not). * Bodies are skipped with depth counting, so statement-level noise can * never leak into the symbol table. Scopes: unit/program globals plus one * routine level (params and locals); deeper nesting folds into its owner. */ export interface PasSymbol { name: string; kind: 'procedure' | 'function' | 'constant' | 'type' | 'variable' | 'parameter' | 'field' | 'module'; detail: string; line: number; ch: number; endLine: number; endCh: number; children: PasSymbol[]; /** Last line covered by this scope (routines/modules), if known. */ scopeEndLine?: number; scopeEndCh?: number; } export interface Tok { text: string; upper: string; line: number; ch: number; endLine: number; endCh: number; } const WS = /\s/; /** Strip comments and string literals (length-preserving) so the * declaration scan never sees keywords inside them. Exported for the * resolver's occurrence scan, which needs identical masking. */ export function mask(text: string): string { const out = text.split(''); const n = text.length; let i = 0; const blank = (a: number, b: number) => { for (let k = a; k < b; k++) if (out[k] !== '\n') out[k] = ' '; }; while (i < n) { const c = text[i]; const nx = i + 1 < n ? text[i + 1] : ''; if (c === '(' && nx === '*') { let depth = 1; let j = i + 2; while (j < n && depth > 0) { if (text[j] === '(' && text[j + 1] === '*') { depth++; j += 2; } else if (text[j] === '*' && text[j + 1] === ')') { depth--; j += 2; } else j++; } blank(i, j); i = j; } else if (c === '{') { let j = i + 1; while (j < n && text[j] !== '}') j++; blank(i, Math.min(j + 1, n)); i = Math.min(j + 1, n); } else if (c === "'") { let j = i + 1; while (j < n) { if (text[j] === "'") { if (text[j + 1] === "'") j += 2; else { j++; break; } } else j++; } blank(i, j); i = j; } else { i++; } } return out.join(''); } export function lex(masked: string): Tok[] { const toks: Tok[] = []; const re = /[A-Za-z_][A-Za-z0-9_]*|\d+(?:\.\d+)?(?:[Ee][+-]?\d+)?|:=|\.\.|<=|>=|<>|[^\sA-Za-z0-9_]/g; let m: RegExpExecArray | null; // Incremental line tracking: the previous version re-sliced from the // file start per token (O(n^2)), which stalls on ~800KB sources. let line = 0; let prevEnd = 0; while ((m = re.exec(masked)) !== null) { for (let k = prevEnd; k < m.index; k++) { if (masked[k] === '\n') line++; } const ch = m.index - (masked.lastIndexOf('\n', m.index - 1) + 1); const mid = m[0].split('\n'); toks.push({ text: m[0], upper: m[0].toUpperCase(), line, ch, endLine: line + mid.length - 1, endCh: mid.length > 1 ? mid[mid.length - 1].length : ch + m[0].length }); line += mid.length - 1; prevEnd = m.index + m[0].length; } return toks; } export const isIdent = (t: Tok) => /^[A-Za-z_]/.test(t.text); class Parser { pos = 0; constructor(readonly toks: Tok[]) {} peek(off = 0): Tok | null { return this.pos + off < this.toks.length ? this.toks[this.pos + off] : null; } at(word: string): boolean { const t = this.peek(); return !!t && t.upper === word; } next(): Tok { return this.toks[this.pos++]; } expect(word: string): boolean { if (this.at(word)) { this.next(); return true; } return false; } /** Skip a parenthesis/bracket group, returns true when balanced. */ skipGroup(open: string, close: string): boolean { if (!this.at(open)) return false; let depth = 0; while (this.pos < this.toks.length) { const t = this.next(); if (t.upper === open) depth++; else if (t.upper === close) { depth--; if (depth === 0) return true; } } return false; } /** Skip a routine body: declarations then an optional compound. * Only section words skipSection() accepts are delegated to it; * anything else (notably a nested PROCEDURE/FUNCTION, which the * caller parses itself) ends the skip so the loop always progresses. */ skipBody(name: string): void { for (;;) { const t = this.peek(); if (!t) return; if (t.upper === 'BEGIN') { this.skipCompound(); // trailing name after END (Oberon families) if (this.at('END')) { this.next(); if (this.peek()?.upper !== '.' && /^[A-Za-z_]/.test(this.peek()?.text ?? '')) this.next(); } return; } if (['CONST', 'TYPE', 'VAR', 'LABEL'].includes(t.upper)) { this.skipSection(); continue; } return; } } skipCompound(): void { if (!this.at('BEGIN')) return; this.next(); let depth = 1; while (this.pos < this.toks.length && depth > 0) { const t = this.next(); if (t.upper === 'BEGIN' || t.upper === 'CASE' || t.upper === 'RECORD') depth++; else if (t.upper === 'END') depth--; } } /** Skip one declaration section (const/type/var/label). */ skipSection(): void { const kw = this.peek()?.upper; if (kw !== 'CONST' && kw !== 'TYPE' && kw !== 'VAR' && kw !== 'LABEL') return; this.next(); // items separated by ';' until a section starter, routine, BEGIN or END for (;;) { const t = this.peek(); if (!t) return; if (['CONST', 'TYPE', 'VAR', 'LABEL', 'PROCEDURE', 'FUNCTION', 'BEGIN', 'END', 'IMPLEMENTATION', 'INTERFACE', 'FINALIZATION', 'INITIALIZATION'].includes(t.upper)) return; this.skipDeclItem(); if (this.at(';')) this.next(); else return; } } /** Skip one `name [, ...] ...` declaration item up to `;` at depth 0. */ skipDeclItem(): void { let depth = 0; while (this.pos < this.toks.length) { const t = this.peek()!; if (t.upper === '(' || t.upper === '[') depth++; else if (t.upper === ')' || t.upper === ']') depth--; else if (t.upper === 'RECORD') depth++; else if (t.upper === 'END' && depth > 0) depth--; else if (depth === 0 && (t.upper === ';' || ['CONST', 'TYPE', 'VAR', 'LABEL', 'PROCEDURE', 'FUNCTION', 'BEGIN', 'END'].includes(t.upper))) return; this.next(); } } /** Parse an identifier list, returning the name tokens. */ nameList(): Tok[] { const out: Tok[] = []; for (;;) { const t = this.peek(); if (!t || !isIdent(t)) return out; out.push(this.next()); if (this.at(',')) { this.next(); continue; } return out; } } } /** Parse a `RECORD ... END` field list; RECORD already consumed. * * Only plain `name [, ...] : type;` items at depth 1 become fields. * Variant parts (CASE), nested records and anything unrecognised are * skipped without emitting (misses acceptable, wrong entries are not). * The closing END is consumed. */ export function parseRecordFields(p: Parser): PasSymbol[] { const fields: PasSymbol[] = []; let depth = 1; for (;;) { const t = p.peek(); if (!t) return fields; if (t.upper === 'RECORD') { depth++; p.next(); continue; } if (t.upper === 'END') { depth--; p.next(); if (depth <= 0) return fields; continue; } if (depth !== 1) { p.next(); continue; } if (!isIdent(t)) { p.skipDeclItem(); if (p.at(';')) p.next(); else if (p.peek() && p.peek()!.upper !== 'END') p.next(); continue; } if (['CONST', 'TYPE', 'VAR', 'LABEL', 'PROCEDURE', 'FUNCTION', 'BEGIN'].includes(t.upper)) { // Lost sync: skip to the matching END so the caller stays aligned. while (p.pos < p.toks.length && depth > 0) { const k = p.next(); if (k.upper === 'RECORD') depth++; else if (k.upper === 'END') depth--; } return fields; } const names = p.nameList(); if (!p.at(':')) { p.skipDeclItem(); if (p.at(';')) p.next(); continue; } p.next(); const typeToks: string[] = []; let dd = 0; while (p.pos < p.toks.length) { const k = p.peek()!; if (k.upper === '(' || k.upper === '[') dd++; else if (k.upper === ')' || k.upper === ']') dd--; else if (k.upper === 'RECORD') dd++; else if (k.upper === 'END' && dd > 0) dd--; else if (dd === 0 && (k.upper === ';' || k.upper === 'END')) break; else if (dd === 0 && ['CONST', 'TYPE', 'VAR', 'PROCEDURE', 'FUNCTION', 'BEGIN'].includes(k.upper)) break; typeToks.push(k.text); p.next(); } const typeText = typeToks.join(' ').replace(/\s+([,;:()[\]])/g, '$1').replace(/([(\[])\s+/g, '$1'); for (const nm of names) { fields.push({ name: nm.text, kind: 'field', detail: `${nm.text}: ${typeText}`, line: nm.line, ch: nm.ch, endLine: nm.endLine, endCh: nm.endCh, children: [] }); } if (p.at(';')) p.next(); } } /** Parse an enumeration literal list `(A, B, ...)`; `(` already consumed. * Returns the literal names with their tokens. Stops at `)` (consumed) * or on anything unparseable (partial list kept, never throws). */ export function parseEnumLiterals(p: Parser): Tok[] { const out: Tok[] = []; for (;;) { const e = p.peek(); if (!e) return out; if (e.upper === ')') { p.next(); return out; } if (e.upper === ';') return out; if (isIdent(e)) { out.push(p.next()); const nx = p.peek(); if (nx && (nx.upper === '=' || nx.upper === ':=')) { // Explicit ordinal (some dialects): skip value to ',' or ')'. p.next(); let dd = 0; while (p.pos < p.toks.length) { const q = p.peek()!; if (q.upper === '(') dd++; else if (q.upper === ')') { if (dd === 0) break; dd--; } else if (dd === 0 && (q.upper === ',' || q.upper === ';')) break; p.next(); } } if (p.at(',')) { p.next(); continue; } continue; } p.next(); } } /** A `USES` clause unit name with its position (for go-to-unit). */ export interface PasUse { name: string; line: number; ch: number; endCh: number; } /** All `USES a, b, ...;` unit names at paren-depth 0. * * `IN 'file'` segments are skipped (string contents are masked away, * so IN is followed by `,`/`;`/ident — everything up to the next `,` * or `;` is ignored). A USES not followed by an identifier, or one * without a terminating `;`, yields nothing. */ export function parseUses(text: string): PasUse[] { const toks = lex(mask(text)); const out: PasUse[] = []; let depth = 0; for (let k = 0; k < toks.length; k++) { const t = toks[k]; if (t.upper === '(') { depth++; continue; } if (t.upper === ')') { depth--; continue; } if (depth !== 0 || t.upper !== 'USES') continue; const nx = toks[k + 1]; if (!nx || !isIdent(nx)) continue; let j = k + 1; let ok = false; for (;;) { const u = toks[j]; if (!u) break; if (u.upper === ';') { ok = true; break; } if (u.upper === 'BEGIN' || u.upper === 'END' || u.upper === 'IMPLEMENTATION' || u.upper === 'PROCEDURE' || u.upper === 'FUNCTION') break; if (u.upper === 'IN') { j++; while (j < toks.length && toks[j].upper !== ',' && toks[j].upper !== ';') j++; continue; } if (isIdent(u)) { // Must be `name`, `name,`, or `name;` — anything else aborts. const after = toks[j + 1]; if (!after || (after.upper !== ',' && after.upper !== ';' && after.upper !== 'IN')) break; out.push({ name: u.text, line: u.line, ch: u.ch, endCh: u.endCh }); } else if (u.upper !== ',') { break; } j++; } if (ok) k = j; } return out; } /** Parse the declarations of one scope; stops before BEGIN/END/EOF. */ function parseScope(p: Parser, out: PasSymbol[], stopAtBegin: boolean): void { for (;;) { const t = p.peek(); if (!t) return; const u = t.upper; if (u === 'BEGIN') { if (stopAtBegin) return; p.skipBody(''); return; } if (u === 'END') return; if (u === 'INTERFACE' || u === 'IMPLEMENTATION') { p.next(); continue; } if (u === 'INITIALIZATION' || u === 'FINALIZATION') { // Statement section: skip to its END so no statement noise leaks in. p.next(); let depth = 0; while (p.pos < p.toks.length) { const k = p.next(); if (k.upper === 'BEGIN' || k.upper === 'CASE' || k.upper === 'RECORD') depth++; else if (k.upper === 'END') { if (depth === 0) break; depth--; } } continue; } if (u === 'LABEL') { p.skipSection(); continue; } if (u === 'CONST') { p.next(); for (;;) { const n = p.peek(); if (!n || !isIdent(n)) break; if (['TYPE', 'VAR', 'CONST', 'LABEL', 'PROCEDURE', 'FUNCTION', 'BEGIN', 'END'].includes(n.upper)) break; const nm = p.next(); if (!p.at('=')) { p.skipDeclItem(); if (p.at(';')) p.next(); continue; } p.next(); // value: skip to ';' at depth 0 p.skipDeclItem(); out.push({ name: nm.text, kind: 'constant', detail: `CONST ${nm.text}`, line: nm.line, ch: nm.ch, endLine: nm.endLine, endCh: nm.endCh, children: [] }); if (p.at(';')) p.next(); else break; } continue; } if (u === 'TYPE') { p.next(); for (;;) { const n = p.peek(); if (!n || !isIdent(n)) break; if (['TYPE', 'VAR', 'CONST', 'LABEL', 'PROCEDURE', 'FUNCTION', 'BEGIN', 'END'].includes(n.upper)) break; const nm = p.next(); if (!p.at('=')) { p.skipDeclItem(); if (p.at(';')) p.next(); continue; } p.next(); const typeChildren: PasSymbol[] = []; const packedRec = p.at('PACKED') && p.peek(1)?.upper === 'RECORD'; if (packedRec) p.next(); if (p.at('RECORD')) { p.next(); typeChildren.push(...parseRecordFields(p)); } else if (p.at('(')) { p.next(); for (const lit of parseEnumLiterals(p)) { typeChildren.push({ name: lit.text, kind: 'constant', detail: `${nm.text}.${lit.text}`, line: lit.line, ch: lit.ch, endLine: lit.endLine, endCh: lit.endCh, children: [] }); } } else { p.skipDeclItem(); } out.push({ name: nm.text, kind: 'type', detail: `TYPE ${nm.text}`, line: nm.line, ch: nm.ch, endLine: nm.endLine, endCh: nm.endCh, children: typeChildren }); if (p.at(';')) p.next(); else break; } continue; } if (u === 'VAR' || u === 'THREADVAR') { p.next(); for (;;) { const n = p.peek(); if (!n || !isIdent(n)) break; if (['TYPE', 'VAR', 'CONST', 'LABEL', 'PROCEDURE', 'FUNCTION', 'BEGIN', 'END'].includes(n.upper)) break; const names = p.nameList(); if (!p.at(':')) { p.skipDeclItem(); if (p.at(';')) p.next(); continue; } p.next(); // Inline `RECORD ... END` type: fields become the variable's children. const packedInline = p.at('PACKED') && p.peek(1)?.upper === 'RECORD'; if (packedInline) p.next(); if (p.at('RECORD')) { p.next(); const fields = parseRecordFields(p); for (const nm of names) { out.push({ name: nm.text, kind: 'variable', detail: `VAR ${nm.text}: RECORD`, line: nm.line, ch: nm.ch, endLine: nm.endLine, endCh: nm.endCh, children: fields }); } if (p.at(';')) p.next(); else break; continue; } // capture type text up to ';' or '=' (initializer) at depth 0 const typeToks: string[] = []; let depth = 0; while (p.pos < p.toks.length) { const k = p.peek()!; if (k.upper === '(' || k.upper === '[') depth++; else if (k.upper === ')' || k.upper === ']') depth--; else if (k.upper === 'RECORD') depth++; else if (k.upper === 'END' && depth > 0) depth--; else if (depth === 0 && (k.upper === ';' || k.upper === '=')) break; else if (depth === 0 && ['CONST', 'TYPE', 'VAR', 'LABEL', 'PROCEDURE', 'FUNCTION', 'BEGIN', 'END'].includes(k.upper)) break; typeToks.push(k.text); p.next(); } const typeText = typeToks.join(' ').replace(/\s+([,;:()[\]])/g, '$1').replace(/([(\[])\s+/g, '$1'); if (p.at('=')) { p.next(); p.skipDeclItem(); } for (const nm of names) { out.push({ name: nm.text, kind: 'variable', detail: `VAR ${nm.text}: ${typeText}`, line: nm.line, ch: nm.ch, endLine: nm.endLine, endCh: nm.endCh, children: [] }); } if (p.at(';')) p.next(); else break; } continue; } if (u === 'PROCEDURE' || u === 'FUNCTION') { const isFunc = u === 'FUNCTION'; const kw = p.next(); // optional generic prefix / class / receiver: find the routine name. // Name = last ident before the formal list, result type or ';'. // Stops at '(' (formals parsed separately below); '<...>' type // parameter lists are skipped via angle depth. let depth = 0; let adepth = 0; let nameTok: Tok | null = null; let lastIdent: Tok | null = null; while (p.pos < p.toks.length) { const k = p.peek()!; if (k.upper === '<') adepth++; else if (k.upper === '>') adepth--; else if (k.upper === '(' && adepth === 0) break; else if (k.upper === '(') depth++; else if (k.upper === ')') depth--; else if (depth === 0 && adepth === 0 && (k.upper === ';' || k.upper === ':')) break; else if (depth === 0 && adepth === 0 && k.upper === '.') { lastIdent = null; } else if (depth === 0 && adepth === 0 && isIdent(k)) lastIdent = k; if (depth === 0 && adepth === 0 && ['BEGIN', 'END', 'CONST', 'TYPE', 'VAR', 'PROCEDURE', 'FUNCTION'].includes(k.upper)) break; p.next(); } nameTok = lastIdent; // formal params const params: PasSymbol[] = []; if (p.at('(')) { p.next(); let pd = 1; const pnames: Tok[] = []; let ptype: string[] = []; let inType = false; const flush = () => { const tt = ptype.join(' ').replace(/\s+([,;:()[\]])/g, '$1').replace(/([(\[])\s+/g, '$1'); for (const nm of pnames) { params.push({ name: nm.text, kind: 'parameter', detail: `${nm.text}: ${tt}`, line: nm.line, ch: nm.ch, endLine: nm.endLine, endCh: nm.endCh, children: [] }); } pnames.length = 0; ptype = []; inType = false; }; while (p.pos < p.toks.length && pd > 0) { const k = p.next(); if (k.upper === '(') { pd++; if (inType) ptype.push(k.text); continue; } if (k.upper === ')') { pd--; if (pd === 0) { if (pnames.length || ptype.length) flush(); break; } if (inType) ptype.push(k.text); continue; } if (pd !== 1) { if (inType) ptype.push(k.text); continue; } if (k.upper === ';') { flush(); continue; } if (k.upper === ':' && !inType) { inType = true; continue; } if (k.upper === ',') continue; if (k.upper === '=' && inType) { // default value: skip to ';' or ')' at depth 1 let dd = 1; while (p.pos < p.toks.length && dd > 0) { const q = p.next(); if (q.upper === '(') dd++; else if (q.upper === ')') { dd--; if (dd === 0) { pd--; if (pnames.length || ptype.length) flush(); break; } } else if (q.upper === ';' && dd === 1) { flush(); break; } } continue; } if (['VAR', 'OUT', 'CONST'].includes(k.upper) && !inType && pnames.length === 0) continue; if (isIdent(k)) { if (inType) ptype.push(k.text); else pnames.push(k); } else if (inType) ptype.push(k.text); } } // result type let restype = ''; if (p.at(':')) { p.next(); const rt: string[] = []; while (p.pos < p.toks.length) { const k = p.peek()!; if (k.upper === ';') break; rt.push(k.text); p.next(); } restype = rt.join(' ').replace(/\s+([,;:()[\]])/g, '$1').replace(/([(\[])\s+/g, '$1'); } if (!nameTok) { // unrecognised header: skip one item and continue (never emit) p.skipDeclItem(); if (p.at(';')) p.next(); continue; } const sym: PasSymbol = { name: nameTok.text, kind: isFunc ? 'function' : 'procedure', detail: `${isFunc ? 'FUNCTION' : 'PROCEDURE'} ${nameTok.text}${restype ? ': ' + restype : ''}`, line: kw.line, ch: kw.ch, endLine: nameTok.endLine, endCh: nameTok.endCh, children: params }; out.push(sym); if (p.at(';')) p.next(); // directives or body: skip directives (words + ';') then body for (;;) { const k = p.peek(); if (!k) break; if (k.upper === 'INTERFACE' || k.upper === 'IMPLEMENTATION') { p.next(); continue; } if (k.upper === 'BEGIN') { p.skipBody(nameTok.text); break; } if (k.upper === 'FORWARD' || k.upper === 'EXTERNAL') { // bodyless: consume through ';' while (p.pos < p.toks.length && !p.at(';') && !['BEGIN', 'END', 'PROCEDURE', 'FUNCTION', 'CONST', 'TYPE', 'VAR'].includes(p.peek()!.upper)) p.next(); if (p.at(';')) p.next(); break; } if (k.upper === 'END' || k.upper === 'PROCEDURE' || k.upper === 'FUNCTION') break; // nested declarations belong to this routine: parse one level, then body if (['CONST', 'TYPE', 'VAR', 'LABEL'].includes(k.upper)) { parseScope(p, sym.children, true); continue; } // unknown directive word: skip to ';' (covers calling conventions) if (isIdent(k)) { p.next(); if (p.at(';')) { p.next(); continue; } // not a lone directive: treat rest as body p.skipBody(nameTok.text); break; } break; } const tail = p.toks[p.pos - 1]; if (tail) { sym.scopeEndLine = tail.endLine; sym.scopeEndCh = tail.endCh; } continue; } // anything else at scope level: skip one item to stay in sync p.skipDeclItem(); if (p.at(';')) p.next(); else if (p.pos < p.toks.length) p.next(); } } /** Entry: top-level symbols of a Pascal unit/program. */ export function parsePascal(text: string): PasSymbol[] { const toks = lex(mask(text)); const p = new Parser(toks); // skip program/unit/library header up to the first ';' at depth 0 const first = p.peek(); if (first && ['PROGRAM', 'UNIT', 'LIBRARY'].includes(first.upper)) { p.next(); let depth = 0; while (p.pos < p.toks.length) { const t = p.next(); if (t.upper === '(') depth++; else if (t.upper === ')') depth--; else if (t.upper === ';' && depth === 0) break; } } const out: PasSymbol[] = []; parseScope(p, out, false); // module wrapper for outline friendliness when a header was seen if (first && ['PROGRAM', 'UNIT', 'LIBRARY'].includes(first.upper)) { const nm = p.toks.length > 1 ? p.toks[1] : null; if (nm && /^[A-Za-z_]/.test(nm.text)) { const last = p.toks[p.toks.length - 1]; const mod: PasSymbol = { name: nm.text, kind: 'module', detail: `${first.text.toUpperCase()} ${nm.text}`, line: first.line, ch: first.ch, endLine: nm.endLine, endCh: nm.endCh, children: out }; if (last) { mod.scopeEndLine = last.endLine; mod.scopeEndCh = last.endCh; } return [mod]; } } return out; } /** True when (line, ch) lies within the symbol's scope, if known. */ function inScope(s: PasSymbol, line: number, ch: number): boolean { if (s.scopeEndLine === undefined) return true; if (line < s.line) return false; if (line > s.scopeEndLine) return false; if (line === s.scopeEndLine && s.scopeEndCh !== undefined && ch > s.scopeEndCh) return false; return true; } /** Symbols visible at a position: globals plus the innermost enclosing * routine's params and locals. Forgiving by design (completion-only). */ export function visibleAt(symbols: PasSymbol[], line: number, ch: number): PasSymbol[] { const top: PasSymbol[] = []; for (const s of symbols) { if (s.kind === 'module') top.push(...s.children); else top.push(s); } const result: PasSymbol[] = []; for (const s of top) { if (s.kind === 'constant' || s.kind === 'type' || s.kind === 'variable') result.push(s); } // innermost enclosing routine first const routines = top.filter(s => (s.kind === 'procedure' || s.kind === 'function') && inScope(s, line, ch)); routines.sort((a, b) => (b.line - a.line) || ((b.ch ?? 0) - (a.ch ?? 0))); for (const r of routines) { result.push(...r.children); result.push(r); } // top-level routines are callable throughout the unit for (const s of top) { if ((s.kind === 'procedure' || s.kind === 'function') && !result.includes(s)) result.push(s); } return result; }