Browse Source

Pascal plugin: Outline, completion and hover from local symbols

Eric Streit 3 days ago
parent
commit
cdbd47d8aa

+ 477 - 0
extensions/pascal-language/src/pascalSymbols.ts

@@ -0,0 +1,477 @@
+/** 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;
+}
+
+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. */
+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('');
+}
+
+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;
+  while ((m = re.exec(masked)) !== null) {
+    const before = masked.slice(0, m.index).split('\n');
+    const line = before.length - 1;
+    const ch = before[before.length - 1].length;
+    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
+    });
+  }
+  return toks;
+}
+
+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. */
+  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', 'PROCEDURE', 'FUNCTION'].includes(t.upper)) {
+        this.skipSection();
+        continue;
+      }
+      if (t.upper === 'END') return;
+      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 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' || u === 'IMPLEMENTATION' || u === 'INTERFACE' || u === 'FINALIZATION' || u === 'INITIALIZATION') return;
+    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();
+        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: [] });
+        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();
+        // 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 === '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;
+}

+ 126 - 1
extensions/pascal-language/src/server.ts

@@ -9,6 +9,10 @@ import * as os from 'os';
 import { promises as fs } from 'fs';
 import { spawn } from 'child_process';
 import { parseListing } from './pascalListing';
+import { parsePascal, visibleAt, PasSymbol } from './pascalSymbols';
+import {
+  CompletionItem, CompletionItemKind, DocumentSymbol, SymbolKind, Hover
+} from 'vscode-languageserver/node';
 
 const connection = createConnection(ProposedFeatures.all);
 const documents = new TextDocuments(TextDocument);
@@ -23,7 +27,10 @@ connection.onInitialize((params: InitializeParams): InitializeResult => {
   pascalValidators = options.pascalValidators || {};
   return {
     capabilities: {
-      textDocumentSync: TextDocumentSyncKind.Full
+      textDocumentSync: TextDocumentSyncKind.Full,
+      completionProvider: { triggerCharacters: ['.'] },
+      hoverProvider: true,
+      documentSymbolProvider: true
     }
   };
 });
@@ -97,5 +104,123 @@ function uriToFilePath(uri: string): string {
   return uri;
 }
 
+// ---- Local intelligence (Outline, completion, hover) ----
+
+const PASCAL_KEYWORDS = [
+  'AND', 'ARRAY', 'BEGIN', 'CASE', 'CONST', 'DIV', 'DO', 'DOWNTO',
+  'ELSE', 'END', 'FILE', 'FOR', 'FUNCTION', 'GOTO', 'IF', 'IN',
+  'LABEL', 'MOD', 'NIL', 'NOT', 'OF', 'OR', 'PACKED', 'PROCEDURE',
+  'PROGRAM', 'RECORD', 'REPEAT', 'SET', 'THEN', 'TO', 'TYPE', 'UNIT',
+  'UNTIL', 'USES', 'VAR', 'WHILE', 'WITH', 'FORWARD'
+];
+
+const PASCAL_BUILTINS = [
+  'READ', 'READLN', 'WRITE', 'WRITELN', 'EOF', 'EOLN', 'ODD', 'ORD',
+  'CHR', 'ABS', 'SQR', 'TRUNC', 'NEW', 'SIN', 'COS', 'EXP', 'LN'
+];
+
+function symbolKind(kind: PasSymbol['kind']): SymbolKind {
+  switch (kind) {
+    case 'module': return SymbolKind.Module;
+    case 'procedure':
+    case 'function': return SymbolKind.Function;
+    case 'constant': return SymbolKind.Constant;
+    case 'type': return SymbolKind.Class;
+    case 'variable':
+    case 'parameter':
+    case 'field': return SymbolKind.Variable;
+  }
+}
+
+function toDocumentSymbol(s: PasSymbol): DocumentSymbol {
+  const endLine = s.scopeEndLine ?? s.endLine;
+  const endCh = s.scopeEndCh ?? s.endCh;
+  return {
+    name: s.name,
+    kind: symbolKind(s.kind),
+    range: {
+      start: { line: s.line, character: s.ch },
+      end: { line: endLine, character: endCh }
+    },
+    selectionRange: {
+      start: { line: s.line, character: s.ch },
+      end: { line: s.endLine, character: s.endCh }
+    },
+    detail: s.detail,
+    children: s.children.map(toDocumentSymbol)
+  };
+}
+
+connection.onDocumentSymbol(params => {
+  const document = documents.get(params.textDocument.uri);
+  if (!document) return [];
+  try {
+    return parsePascal(document.getText()).map(toDocumentSymbol);
+  } catch {
+    return [];
+  }
+});
+
+connection.onCompletion(params => {
+  const document = documents.get(params.textDocument.uri);
+  if (!document) return [];
+  let symbols: PasSymbol[] = [];
+  try {
+    symbols = parsePascal(document.getText());
+  } catch {
+    symbols = [];
+  }
+  const vis = visibleAt(symbols, params.position.line, params.position.character);
+  const items: CompletionItem[] = vis.map(s => ({
+    label: s.name,
+    kind: s.kind === 'procedure' || s.kind === 'function'
+      ? CompletionItemKind.Function
+      : s.kind === 'constant' ? CompletionItemKind.Constant
+      : s.kind === 'type' ? CompletionItemKind.Class
+      : CompletionItemKind.Variable,
+    detail: s.detail
+  } as CompletionItem));
+  for (const kw of PASCAL_KEYWORDS) {
+    items.push({ label: kw, kind: CompletionItemKind.Keyword, detail: 'Pascal keyword' } as CompletionItem);
+  }
+  for (const b of PASCAL_BUILTINS) {
+    items.push({ label: b, kind: CompletionItemKind.Function, detail: 'Pascal predefined routine' } as CompletionItem);
+  }
+  return items;
+});
+
+connection.onHover(params => {
+  const document = documents.get(params.textDocument.uri);
+  if (!document) return null;
+  let symbols: PasSymbol[] = [];
+  try {
+    symbols = parsePascal(document.getText());
+  } catch {
+    return null;
+  }
+  const offset = document.offsetAt(params.position);
+  const text = document.getText();
+  let start = offset;
+  while (start > 0 && /[A-Za-z0-9_]/.test(text[start - 1] ?? '')) start--;
+  let end = offset;
+  while (end < text.length && /[A-Za-z0-9_]/.test(text[end] ?? '')) end++;
+  const word = text.slice(start, end);
+  if (!word) return null;
+  const upper = word.toUpperCase();
+  const hit = visibleAt(symbols, params.position.line, params.position.character)
+    .find(s => s.name.toUpperCase() === upper);
+  const show = hit ?? (PASCAL_BUILTINS.includes(upper)
+    ? { name: upper, kind: 'procedure' as const, detail: 'Pascal predefined routine' }
+    : null);
+  if (!show) return null;
+  const hover: Hover = {
+    contents: {
+      kind: 'markdown',
+      value: ['```pascal', show.detail || show.name, '```', `*${show.kind}*`].join('\n')
+    }
+  };
+  return hover;
+});
+
 documents.listen(connection);
 connection.listen();