From c1c54e4538e5dc3dd49aca530c9f9f854286cd42 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Thu, 5 Feb 2026 09:35:05 -0500 Subject: [PATCH 01/46] Enhance parser & lexer; add VSCode packaging Large refactor and feature additions across parser, lexer, analyzer and packaging files. Key parser changes: - Add parser diagnostics collection (File.diagnostics) and addDiagnostic helper. - Detect and warn about ternary (? :) usage and improve recovery for incomplete top-level code and statements. - Support primitive type keywords when parsing types and handle EOF/incomplete constructs more gracefully. - Robust generic parsing with support for nested generics and correct handling/splitting of '>>' tokens. - Support destructor names (~Foo) and operator overload identifiers (operator==, operator[], etc.). Lexer and rules: - More robust lexer: handle multi-character operators (==, !=, <=, >=, &&, ||, ++, etc.), character literals, improved numeric literal handling and preprocessor branch skipping. - Expand punctuation and keyword sets; introduce multiCharOps set used by lexer. - Small token module doc additions. Analyzer / project graph / completions: - Major enhancements to completions: context-aware member completions, resolve variable/function return types, handle 'this'/'super', inheritance traversal and static vs instance completion filtering. - Add heuristics/known-type overrides (e.g., GetGame/g_Game) and richer completion metadata. - Improve caching error handling to return empty stub with diagnostics on parse failures. Packaging / editor config: - Add .vscodeignore, update .gitignore to include .vsix, add files.associations in .vscode/settings.json, and add @vscode/vsce dev dependency to package.json. These changes improve language parsing robustness (especially generics and operators), produce useful diagnostics for unsupported constructs, and significantly enhance completion quality in the LSP. --- .gitignore | 3 +- .vscode/settings.json | 5 +- .vscodeignore | 22 + package.json | 1 + server/src/analysis/ast/parser.ts | 315 ++++- server/src/analysis/lexer/lexer.ts | 214 +++- server/src/analysis/lexer/rules.ts | 68 +- server/src/analysis/lexer/token.ts | 35 + server/src/analysis/project/graph.ts | 1622 ++++++++++++++++++++++++- server/src/lsp/handlers/completion.ts | 38 +- 10 files changed, 2262 insertions(+), 61 deletions(-) create mode 100644 .vscodeignore diff --git a/.gitignore b/.gitignore index df6b75d..7dece82 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ server/out out/ node_modules/ *.tsbuildinfo -package-lock.json \ No newline at end of file +package-lock.json +*.vsix diff --git a/.vscode/settings.json b/.vscode/settings.json index cdd5d2b..7c53757 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,8 @@ { "editor.tabSize": 4, "editor.insertSpaces": true, - "editor.detectIndentation": false + "editor.detectIndentation": false, + "files.associations": { + "*.c": "enscript" + } } \ No newline at end of file diff --git a/.vscodeignore b/.vscodeignore new file mode 100644 index 0000000..a8fe6a7 --- /dev/null +++ b/.vscodeignore @@ -0,0 +1,22 @@ +# Exclude development files from the packaged extension +.git/ +.vscode/ +src/ +server/src/ +test/ +*.ts +*.map +tsconfig*.json +jest.config.js +.gitignore + +# Include compiled output (override .gitignore) +!out/ +!server/out/ + +# Exclude unnecessary node_modules files +node_modules/.bin/ +node_modules/**/test/ +node_modules/**/*.md +node_modules/**/*.ts +node_modules/**/.github/ diff --git a/package.json b/package.json index 24a88a5..c75492a 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "@types/jest": "^29.5.14", "@types/node": "^22.15.18", "@types/vscode": "^1.90.0", + "@vscode/vsce": "^3.7.1", "jest": "^29.7.0", "rimraf": "^5.0.5", "ts-jest": "^29.3.4", diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index 0dfa74f..fac1e25 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -1,12 +1,48 @@ /********************************************************************** * Mini-parser for Enforce/EnScript (DayZ / Arma Reforger flavour) - * – walks tokens once, builds a lightweight AST but captures: + * ================================================================ + * + * Walks tokens once, builds a lightweight AST capturing: * • classes (base, modifiers, fields, methods) * • enums + enumerators * • typedefs * • free functions / globals * • local variables inside method bodies - * – prints all collected symbols to the LSP output channel + * + * RECENT FIXES & IMPROVEMENTS: + * + * 1. NESTED GENERIC >> TOKEN SPLITTING (parseType) + * Problem: In nested generics like map>, the closing + * '>>' was treated as a single token (right-shift operator). + * + * Solution: When parsing generic args and we encounter '>>', we: + * - Consume the '>>' token + * - Return from inner generic parsing + * - Leave a synthetic '>' for the outer generic to consume + * + * This is a classic parsing challenge also faced by C++ compilers! + * + * 2. OPERATOR OVERLOAD PARSING (expectIdentifier) + * Problem: Enforce Script allows operator overloads like: + * bool operator==(MyClass other) + * bool operator<(MyClass other) + * These were rejected as invalid function names. + * + * Solution: Extended expectIdentifier() to recognize 'operator' followed + * by an operator token as a valid composite identifier. + * + * 3. DESTRUCTOR PARSING (expectIdentifier) + * Problem: Destructor names like ~Foo were not parsed correctly. + * + * Solution: Handle '~' followed by identifier as a single name token. + * + * 4. TEMPLATE CLASS DECLARATIONS (parseDecl) + * Problem: Generic class declarations like: + * class Container { ... } + * Were not parsing the generic parameter list correctly. + * + * Solution: Added proper parsing of syntax. + * *********************************************************************/ import { TextDocument } from 'vscode-languageserver-textdocument'; @@ -126,6 +162,7 @@ export interface FunctionDeclNode extends SymbolNodeBase { export interface File { body: SymbolNodeBase[] version: number + diagnostics: Diagnostic[] // Parser-generated diagnostics (e.g., ternary operator warnings) } // parse entry point @@ -134,7 +171,36 @@ export function parse( conn?: Connection // optional – pass from index.ts to auto-log ): File { const toks = lex(doc.getText()); + const text = doc.getText(); let pos = 0; + + // ==================================================================== + // DIAGNOSTICS COLLECTION (PORTED FROM JS) + // ==================================================================== + // Collect parser-generated diagnostics like ternary operator warnings. + // These are returned in the File result for the LSP to report. + // ==================================================================== + const diagnostics: Diagnostic[] = []; + + /** + * Add a diagnostic error or warning + */ + function addDiagnostic(token: Token, message: string, severity: DiagnosticSeverity = DiagnosticSeverity.Error): void { + diagnostics.push({ + range: { + start: doc.positionAt(token.start), + end: doc.positionAt(token.end) + }, + message, + severity, + source: 'enforce-script' + }); + } + + // Flag for handling nested generic '>>' tokens + // When the inner parseType consumes '>>', it sets this flag to tell + // the outer parseType that its closing '>' was already consumed. + let pendingGenericClose = false; /* skip comments / #ifdef lines */ const skipTrivia = () => { @@ -172,11 +238,19 @@ export function parse( ); }; + /* helper: check if a keyword is a primitive type */ + const isPrimitiveType = (value: string): boolean => { + return ['void', 'int', 'float', 'bool', 'string', 'vector', 'typename'].includes(value); + }; + /* read & return one identifier or keyword token */ const readTypeLike = (): Token => { const t = peek(); if (t.kind === TokenKind.Identifier) return next(); + // Allow primitive type keywords (int, float, bool, string, void, vector, typename) + if (t.kind === TokenKind.Keyword && isPrimitiveType(t.value)) + return next(); return throwErr(t, 'type identifier'); }; @@ -208,7 +282,8 @@ export function parse( // ast root const file: File = { body: [], - version: doc.version + version: doc.version, + diagnostics: diagnostics // Include parser diagnostics }; // main loop @@ -252,6 +327,11 @@ export function parse( mods.push(next().value); } + // Handle EOF after modifiers (e.g., empty file or file ending with modifiers only) + if (eof()) { + return []; + } + const t = peek(); // class @@ -368,14 +448,63 @@ export function parse( } as TypedefNode]; } + // Handle statement keywords that can appear at top level in invalid code + // These are not valid top-level declarations, skip to semicolon/brace and recover + const statementKeywords = ['for', 'while', 'if', 'else', 'switch', 'return', 'break', 'continue', 'do', 'foreach']; + if (t.kind === TokenKind.Keyword && statementKeywords.includes(t.value)) { + // Skip past this statement - find matching braces and semicolons + let braceDepth = 0; + let parenDepth = 0; + while (!eof()) { + const tok = next(); + if (tok.value === '(') parenDepth++; + else if (tok.value === ')') parenDepth--; + else if (tok.value === '{') braceDepth++; + else if (tok.value === '}') { + braceDepth--; + if (braceDepth === 0 && parenDepth === 0) break; + } + else if (tok.value === ';' && braceDepth === 0 && parenDepth === 0) break; + } + return []; + } + // function OR variable const baseTypeNode = parseType(doc); + + // Handle incomplete/invalid code gracefully at top level: + // - "g_Game." - dot without identifier (incomplete member access) + // - "GetGame().Something();" - top-level statement (function call expression) + // - "SomeType = value" - assignment without variable name + // - EOF after type + // These are not valid declarations, skip to semicolon and recover + if (eof() || peek().value === '.' || (depth === 0 && peek().value === '(') || peek().value === '=') { + // Skip until we find a semicolon or EOF to recover + while (!eof() && peek().value !== ';') { + next(); + } + if (peek().value === ';') next(); + return []; + } + let nameTok = expectIdentifier(); if (peek().value === '(') { const params = fastParamScan(doc); - /* body? */ + // ==================================================================== + // FUNCTION BODY PARSING WITH TERNARY DETECTION (PORTED FROM JS) + // ==================================================================== + // Enforce Script does NOT support the ternary operator (? :). + // We detect this pattern and generate a diagnostic warning. + // + // Example invalid code: + // int x = (condition) ? 1 : 0; // ERROR: Not supported! + // + // Valid alternative: + // int x; + // if (condition) x = 1; else x = 0; + // ==================================================================== if (peek().value === '{') { next(); let depth = 1; @@ -383,6 +512,30 @@ export function parse( const t = next(); if (t.value === '{') depth++; else if (t.value === '}') depth--; + // Detect ternary operator (condition ? true : false) + // This is invalid in Enforce Script + else if (t.value === '?' && depth > 0) { + // Check if this looks like a ternary (not just a nullable type) + // Ternary is typically: expr ? expr : expr + // Look for the colon that follows + let scanPos = pos; + let scanDepth = 0; + let foundColon = false; + while (scanPos < toks.length && scanDepth >= 0) { + const scanTok = toks[scanPos]; + if (scanTok.value === '(' || scanTok.value === '[' || scanTok.value === '{') scanDepth++; + else if (scanTok.value === ')' || scanTok.value === ']' || scanTok.value === '}') scanDepth--; + else if (scanTok.value === ';') break; + else if (scanTok.value === ':' && scanDepth === 0) { + foundColon = true; + break; + } + scanPos++; + } + if (foundColon) { + addDiagnostic(t, 'Ternary operator (? :) is not supported in Enforce Script. Use if/else statement instead.', DiagnosticSeverity.Error); + } + } } } @@ -422,10 +575,45 @@ export function parse( // value initialization (skip for now) if (peek().value === '=') { next(); + + // Handle EOF after = (incomplete code) + if (eof()) { + break; + } while ((inline && peek().value !== ',' && peek().value !== ')') || (!inline && peek().value !== ';' && peek().value !== ',')) { + + // Handle EOF in the middle of initialization + if (eof()) { + break; + } + const curTok = next(); + + // Detect ternary operator in variable initializers + // Example: int x = condition ? 1 : 0; // ERROR! + if (curTok.value === '?') { + // Look for the colon that follows to confirm it's a ternary + let scanPos = pos; + let scanDepth = 0; + let foundColon = false; + while (scanPos < toks.length && scanDepth >= 0) { + const scanTok = toks[scanPos]; + if (scanTok.value === '(' || scanTok.value === '[' || scanTok.value === '{') scanDepth++; + else if (scanTok.value === ')' || scanTok.value === ']' || scanTok.value === '}') scanDepth--; + else if (scanTok.value === ';' || scanTok.value === ',') break; + else if (scanTok.value === ':' && scanDepth === 0) { + foundColon = true; + break; + } + scanPos++; + } + if (foundColon) { + addDiagnostic(curTok, 'Ternary operator (? :) is not supported in Enforce Script. Use if/else statement instead.', DiagnosticSeverity.Error); + } + } + if (curTok.value === '(' || curTok.value === '[' || curTok.value === '{' || curTok.value === '<') { // skip initializer expression let depth = 1; @@ -439,8 +627,8 @@ export function parse( else if (curTok.value === '-' && peek().kind === TokenKind.Number) { next(); } - else if (curTok.kind !== TokenKind.Keyword && curTok.kind !== TokenKind.Identifier && curTok.kind !== TokenKind.Number && - curTok.kind !== TokenKind.String && curTok.value !== '.' && curTok.value !== '+' && curTok.value !== '|') { + else if (curTok.value !== '?' && curTok.value !== ':' && curTok.kind !== TokenKind.Keyword && curTok.kind !== TokenKind.Identifier && curTok.kind !== TokenKind.Number && + curTok.kind !== TokenKind.String && curTok.value !== '.' && curTok.value !== '+' && curTok.value !== '-' && curTok.value !== '*' && curTok.value !== '/' && curTok.value !== '|' && curTok.value !== '&' && curTok.value !== '%') { throwErr(curTok, "initialization expression"); } } @@ -492,18 +680,73 @@ export function parse( modifiers: mods, }; - // generic: map + // ==================================================================== + // GENERIC/TEMPLATE TYPE PARSING + // ==================================================================== + // Handles Enforce Script generics like: + // - array + // - ref map + // - map> (nested generics) + // + // CRITICAL FIX: Nested Generic >> Token Handling + // ----------------------------------------------- + // Problem: The lexer may treat >> as a single token (right shift). + // But in nested generics like map>, the >> is actually + // two separate > closing brackets. + // + // Solution: When we see '>>' while parsing generics: + // 1. We're inside nested generic, >> means we close THIS level + // 2. The outer parseType() call will handle the remaining '>' + // 3. We DON'T consume the full '>>' - just return and let parent handle it + // + // Example parse of: map> + // 1. parseType sees 'map', then '<' + // 2. Recursively parse 'string' (simple type) + // 3. See ',', continue + // 4. Recursively parse 'array' + // 4a. parseType sees 'array', then '<' + // 4b. Recursively parse 'int' (simple type) + // 4c. See '>>' - this closes array, return + // 5. Parent sees '>' (second half of >>), closes map<...> + // + // This is a classic parsing challenge also faced by C++ compilers! + // ==================================================================== if (peek().value === '<') { next(); node.genericArgs = []; - while (peek().value !== '>' && !eof()) { + // Parse generic arguments, watching for both '>' and '>>' + // Also check pendingGenericClose - if a nested parseType consumed '>>' + // that included our closing '>', we need to stop parsing args + while (!pendingGenericClose && peek().value !== '>' && peek().value !== '>>' && !eof()) { node.genericArgs.push(parseType(doc)); + // After parsing a type arg, check if it consumed our closing bracket + if (pendingGenericClose) break; if (peek().value === ',') next(); } - const endTok = expect('>'); - node.end = doc.positionAt(endTok.end); + // Handle the closing bracket(s) + if (pendingGenericClose) { + // Our nested child already consumed our '>' as part of '>>' + // Just clear the flag and continue + pendingGenericClose = false; + node.end = node.genericArgs[node.genericArgs.length - 1]?.end ?? node.end; + } else if (peek().value === '>>') { + // NESTED GENERIC CASE: '>>' at end of generic args + // This means we have nested generics like map> + // The '>>' closes BOTH levels. We consume it but need to signal + // to our caller that their '>' was already consumed. + // We do this by leaving a special marker - we set a flag. + const tok = next(); // consume '>>' + node.end = doc.positionAt(tok.end); + // Set flag so outer parseType knows its '>' was consumed + pendingGenericClose = true; + } else if (peek().value === '>') { + const endTok = expect('>'); + node.end = doc.positionAt(endTok.end); + } else { + throwErr(peek(), '> or >>'); + } } parseArrayDims(doc, node); @@ -560,12 +803,34 @@ export function parse( return decl[0] as VarDeclNode; } - // support helpers + // ======================================================================== + // IDENTIFIER PARSING (with special cases) + // ======================================================================== + // Handles several Enforce Script-specific identifier patterns: + // + // 1. DESTRUCTOR NAMES: ~ClassName + // Enforce Script uses C++-style destructors. We combine '~' + name + // into a single identifier token. + // + // 2. OPERATOR OVERLOADS: operator==, operator<, etc. + // Enforce Script allows operator overloading. The function name is + // 'operator' followed by the operator symbol(s). + // + // Examples: + // bool operator==(MyClass other) → name = "operator==" + // bool operator<(MyClass other) → name = "operator<" + // int operator[](int index) → name = "operator[]" + // + // These are combined into synthetic identifier tokens so the parser + // treats them as normal function names. + // ======================================================================== function expectIdentifier(): Token { const t = next(); - // Allow destructor names like ~Foo - if (t.kind === TokenKind.Operator && t.value === '~' && peek().kind === TokenKind.Identifier) { + // DESTRUCTOR: ~Foo + // Handle '~' followed by identifier as a single destructor name + // Note: '~' is tokenized as Punctuation (not Operator) + if (t.kind === TokenKind.Punctuation && t.value === '~' && peek().kind === TokenKind.Identifier) { const id = next(); return { kind: TokenKind.Identifier, @@ -575,6 +840,30 @@ export function parse( }; } + // OPERATOR OVERLOAD: operator==, operator<, operator[], etc. + // Handle 'operator' keyword followed by operator symbol(s) + if (t.kind === TokenKind.Identifier && t.value === 'operator') { + const opTok = peek(); + // Accept various operator tokens: ==, !=, <, >, <=, >=, [], etc. + if (opTok.kind === TokenKind.Operator || opTok.kind === TokenKind.Punctuation) { + const op = next(); + let opName = op.value; + + // Handle operator[] - need to consume both '[' and ']' + if (op.value === '[' && peek().value === ']') { + next(); // consume ']' + opName = '[]'; + } + + return { + kind: TokenKind.Identifier, + value: 'operator' + opName, + start: t.start, + end: op.end + }; + } + } + if (t.kind !== TokenKind.Identifier) throwErr(t, 'identifier'); return t; } diff --git a/server/src/analysis/lexer/lexer.ts b/server/src/analysis/lexer/lexer.ts index e513764..8282a85 100644 --- a/server/src/analysis/lexer/lexer.ts +++ b/server/src/analysis/lexer/lexer.ts @@ -1,5 +1,50 @@ +/** + * Lexer Module - Enforce Script Language Server + * ============================================== + * + * Tokenizes Enforce Script source code into a stream of tokens for the parser. + * + * TOKEN FLOW: + * Source Code → [lexer.ts] → Token[] → [parser.ts] → AST + * + * ENFORCE SCRIPT SPECIFICS: + * - C-like syntax but NOT C++ (no templates, different semantics) + * - Uses 'modded class' for runtime class modification (unique to DayZ) + * - Uses 'ref', 'autoptr' for reference counting + * - Uses 'proto', 'native' for engine bindings + * + * KNOWN ISSUES & FIXES: + * + * 1. MULTI-CHARACTER OPERATORS (FIXED) + * Problem: The original lexer didn't handle multi-char operators like + * '==', '!=', '<=', '>=', '&&', '||', '++', '--', '+=', etc. + * These would be tokenized as two separate single-char operators. + * + * Solution: Check for two-character operator sequences BEFORE + * single punctuation/operator handling. + * + * 2. LESS-THAN vs GENERIC BRACKET AMBIGUITY + * Problem: '<' can be either: + * - A comparison operator: if (x < 10) + * - A generic type bracket: array + * + * Solution: The PARSER (not lexer) handles this contextually. + * The lexer emits '<' as punctuation; the parser decides based on + * whether it follows a type identifier. + * + * 3. RIGHT-SHIFT vs NESTED GENERIC CLOSING + * Problem: '>>' can be either: + * - Right shift operator: x >> 2 + * - Two generic closing brackets: map> + * + * Solution: The PARSER handles this by splitting '>>' when inside + * generic type parsing context. See parser.ts parseType(). + * + * @module enscript/server/src/analysis/lexer/lexer + */ + import { Token, TokenKind } from './token'; -import { keywords, punct } from './rules'; +import { keywords, punct, multiCharOps } from './rules'; export function lex(text: string): Token[] { const toks: Token[] = []; @@ -41,18 +86,79 @@ export function lex(text: string): Token[] { continue; } - // pre-processor (#define, #ifdef …) + // pre-processor (#define, #ifdef, #else, #endif, etc.) + // Strategy for #ifdef/#else/#endif: + // - Skip the #ifdef branch entirely (until #else or #endif) + // - Process the #else branch (if present) + // - This ensures we consistently get the "fallback" code path + // - Handles nested #ifdef by tracking depth if (ch === '#') { + const lineStart = i; while (i < text.length && text[i] !== '\n' && text[i] !== '\r') i++; - push(TokenKind.Preproc, text.slice(start, i), start); + const directive = text.slice(lineStart, i).trim(); + + // Check if this is #ifdef or #ifndef + if (directive.match(/^#\s*(ifdef|ifndef)\b/)) { + // Skip the #ifdef branch until we find #else or #endif at same nesting level + let depth = 1; + const ifdefStart = lineStart; + + while (depth > 0 && i < text.length) { + // Find next preprocessor directive + while (i < text.length && text[i] !== '#') { + // Skip strings to avoid matching # inside strings + if (text[i] === '"') { + i++; + while (i < text.length && text[i] !== '"') { + if (text[i] === '\\' && i + 1 < text.length) i++; + i++; + } + if (i < text.length) i++; + } else if (text[i] === '/' && i + 1 < text.length && text[i + 1] === '/') { + // Skip single line comment + while (i < text.length && text[i] !== '\n') i++; + } else if (text[i] === '/' && i + 1 < text.length && text[i + 1] === '*') { + // Skip multi-line comment + i += 2; + while (i + 1 < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++; + i += 2; + } else { + i++; + } + } + + if (i >= text.length) break; + + // Read the directive + const dStart = i; + while (i < text.length && text[i] !== '\n' && text[i] !== '\r') i++; + const d = text.slice(dStart, i).trim(); + + if (d.match(/^#\s*(ifdef|ifndef)\b/)) { + depth++; + } else if (d.match(/^#\s*endif\b/)) { + depth--; + } else if (d.match(/^#\s*else\b/) && depth === 1) { + // Found #else at our level - stop skipping, process #else branch + depth = 0; + } + } + + // Emit the whole skipped block as a single preproc token + push(TokenKind.Preproc, text.slice(lineStart, i), lineStart); + continue; + } + + // For other preprocessor directives, just skip the line + push(TokenKind.Preproc, directive, lineStart); continue; } - // string literal + // string literal "..." if (ch === '"') { i++; while (i < text.length && text[i] !== '"') { - if (text[i] === '\\') i += 2; + if (text[i] === '\\' && i + 1 < text.length) i += 2; else i++; } i++; // consume closing " @@ -60,9 +166,63 @@ export function lex(text: string): Token[] { continue; } - // number literal - if (/\d/.test(ch)) { - while (i < text.length && /[0-9.eE+-]/.test(text[i])) i++; + // ==================================================================== + // CHARACTER LITERAL '...' (PORTED FROM JS) + // ==================================================================== + // Enforce Script supports single-quoted character literals like 'A', '\n' + // These are tokenized as strings for simplicity. + // ==================================================================== + if (ch === "'") { + i++; + while (i < text.length && text[i] !== "'") { + if (text[i] === '\\' && i + 1 < text.length) { + i += 2; // Skip escaped character + } else { + i++; + } + } + i++; // Consume closing ' + push(TokenKind.String, text.slice(start, i), start); + continue; + } + + // ==================================================================== + // NUMBER LITERAL (PORTED FROM JS - More robust handling) + // ==================================================================== + // Supports: + // - Decimal integers: 42, 123 + // - Hex integers: 0x1A, 0xFF + // - Floats: 3.14, .5 + // - Scientific notation: 1e10, 2.5E-3 + // - Float suffix: 1.0f, 2.5F + // ==================================================================== + if (/\d/.test(ch) || (ch === '.' && i + 1 < text.length && /\d/.test(text[i + 1]))) { + // Handle hex: 0x... or 0X... + if (ch === '0' && i + 1 < text.length && (text[i + 1] === 'x' || text[i + 1] === 'X')) { + i += 2; // Skip '0x' + while (i < text.length && /[0-9a-fA-F]/.test(text[i])) { + i++; + } + } else { + // Decimal or float + while (i < text.length && /[0-9.]/.test(text[i])) { + i++; + } + // Handle exponent: e+10, E-5 + if (i < text.length && (text[i] === 'e' || text[i] === 'E')) { + i++; + if (i < text.length && (text[i] === '+' || text[i] === '-')) { + i++; + } + while (i < text.length && /\d/.test(text[i])) { + i++; + } + } + // Handle float suffix: f or F + if (i < text.length && (text[i] === 'f' || text[i] === 'F')) { + i++; + } + } push(TokenKind.Number, text.slice(start, i), start); continue; } @@ -78,13 +238,49 @@ export function lex(text: string): Token[] { continue; } - // punctuation / operator + // ==================================================================== + // MULTI-CHARACTER OPERATORS (CRITICAL FIX!) + // ==================================================================== + // Problem: Without this check, '==' becomes two '=' tokens, '&&' becomes + // two '&' tokens, etc. This breaks all comparison and logical operators. + // + // Solution: Check for two-character operator sequences BEFORE checking + // single punctuation. The order matters! + // + // Examples: + // if (x == 10) → should tokenize '==' not '=' '=' + // if (a && b) → should tokenize '&&' not '&' '&' + // x += 5; → should tokenize '+=' not '+' '=' + // map> → '>>' handled specially by parser + // ==================================================================== + if (i + 1 < text.length) { + const twoChar = ch + text[i + 1]; + if (multiCharOps.has(twoChar)) { + i += 2; + push(TokenKind.Operator, twoChar, start); + continue; + } + } + + // Single-character punctuation (must come AFTER multi-char check!) if (punct.includes(ch)) { i++; push(TokenKind.Punctuation, ch, start); continue; } + // ==================================================================== + // SINGLE-CHARACTER OPERATORS (PORTED FROM JS) + // ==================================================================== + // These are mathematical and logical operators that weren't covered + // by multi-char ops or punctuation. Mark them explicitly as operators. + // ==================================================================== + if ('+-*/%&|!^~<>='.includes(ch)) { + i++; + push(TokenKind.Operator, ch, start); + continue; + } + // unknown char → treat as operator i++; push(TokenKind.Operator, ch, start); diff --git a/server/src/analysis/lexer/rules.ts b/server/src/analysis/lexer/rules.ts index 3a37889..11082e8 100644 --- a/server/src/analysis/lexer/rules.ts +++ b/server/src/analysis/lexer/rules.ts @@ -1,9 +1,65 @@ -// Simplified rules – split on whitespace & punctuation, recognise keywords. +/** + * Rules Module - Enforce Script Lexer Rules + * ========================================== + * + * Defines keywords, punctuation, and operators for Enforce Script tokenization. + * + * ENFORCE SCRIPT vs C++ DIFFERENCES: + * - 'modded' keyword: Modifies existing classes at runtime (unique to DayZ) + * - 'ref', 'autoptr': Reference counting (not C++ smart pointers) + * - 'proto', 'native': Engine binding declarations + * - 'notnull': Null safety annotation + * - 'sealed', 'abstract', 'final': Class modifiers + * - NO 'template' keyword (uses different generic syntax) + * - NO 'virtual' keyword (all methods are virtual by default) + * + * @module enscript/server/src/analysis/lexer/rules + */ + +// Enforce Script keywords - complete set of reserved words export const keywords = new Set([ - 'class', 'enum', 'typedef', 'using', 'extends', 'auto', 'event', - 'modded', 'proto', 'native', 'owned', 'local', - 'ref', 'reference', 'return', 'if', 'else', 'for', 'while', 'break', 'continue', 'out', 'inout', - 'override', 'private', 'protected', 'public', 'static', 'const', 'notnull', 'external', 'volatile', 'autoptr' + // Class/type declaration keywords + 'class', 'enum', 'typedef', 'using', 'extends', + // Modifiers + 'modded', 'proto', 'native', 'owned', 'local', 'auto', 'event', + 'ref', 'reference', 'out', 'inout', + 'override', 'private', 'protected', 'public', 'static', 'const', + 'notnull', 'external', 'volatile', 'autoptr', + // Control flow + 'return', 'if', 'else', 'for', 'foreach', 'while', 'do', 'switch', 'case', 'default', + 'break', 'continue', 'goto', + // Operators/values + 'new', 'delete', 'null', 'true', 'false', 'this', 'super', + // Types (common built-in) + 'void', 'int', 'float', 'bool', 'string', 'vector', 'typename', + // Additional Enforce Script keywords + 'sealed', 'abstract', 'final' ]); -export const punct = '(){}[];:,.<>='; +// Single-character punctuation +export const punct = '(){}[];:,.<>=+-*/%&|!?^~@#'; + +/** + * Multi-character operators + * + * CRITICAL: These must be checked BEFORE single-char operators in the lexer! + * Otherwise '==' becomes two '=' tokens, breaking comparisons. + * + * Includes: + * - Comparison: ==, !=, <=, >= + * - Logical: &&, || + * - Increment/Decrement: ++, -- + * - Compound assignment: +=, -=, *=, /=, %=, &=, |=, ^= + * - Shift: <<, >> + * - Member access: ->, :: + * - Null coalescing: ?? + */ +export const multiCharOps = new Set([ + '==', '!=', '<=', '>=', + '&&', '||', + '++', '--', + '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', + '<<', '>>', + '->', '::', + '??' +]); diff --git a/server/src/analysis/lexer/token.ts b/server/src/analysis/lexer/token.ts index 3debf3e..a0c0c51 100644 --- a/server/src/analysis/lexer/token.ts +++ b/server/src/analysis/lexer/token.ts @@ -1,3 +1,30 @@ +/** + * Token Module - Enforce Script LSP + * ================================== + * + * Defines the token types produced by the lexer. Tokens are the atomic units + * that the parser consumes to build the AST. + * + * TOKEN FLOW: + * Source Code → [lexer.ts] → Token[] → [parser.ts] → AST + * + * @module enscript/server/src/analysis/lexer/token + */ + +/** + * Token kinds for the Enforce Script lexer + * + * Each token produced by the lexer has one of these kinds: + * - Identifier: Variable names, class names, function names + * - Keyword: Reserved words like 'class', 'override', 'modded' + * - Number: Numeric literals (int, float, hex) + * - String: String literals "..." + * - Operator: Single or multi-char operators (+, ==, &&, etc.) + * - Punctuation: Structural chars ({, }, (, ), ;, etc.) + * - Comment: // or /* ... * / comments + * - Preproc: Preprocessor directives (#ifdef, #define, etc.) + * - EOF: End of file marker + */ export enum TokenKind { Identifier, Keyword, @@ -10,6 +37,14 @@ export enum TokenKind { EOF } +/** + * Token interface - represents a single lexical token + * + * @property kind - The type of token (from TokenKind enum) + * @property value - The actual text content of the token + * @property start - Byte offset where the token starts in source + * @property end - Byte offset where the token ends in source + */ export interface Token { kind: TokenKind; value: string; diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 6ecdf1a..1cda12c 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -1,3 +1,41 @@ +/** + * Analyzer (Graph) Module - Enforce Script LSP + * ============================================== + * + * Central code intelligence facade that coordinates parsing, symbol indexing, + * and LSP query handling. Uses singleton pattern for shared state. + * + * KEY RESPONSIBILITIES: + * - Document parsing and caching (ensure()) + * - Symbol resolution at cursor position + * - Workspace-wide symbol search + * - Go-to-definition navigation + * - Hover information + * - Code completions + * - Reference finding + * + * CACHING STRATEGY: + * - Documents are parsed on-demand and cached by URI + version + * - Cache hit returns immediately if version matches + * - Parse errors return empty stubs to allow graceful degradation + * + * IMPROVEMENTS NEEDED (from JS version fixes): + * + * 1. THREE-TIER SYMBOL SEARCH PRIORITY + * Current: Simple .includes() matching + * Needed: Prioritize exact > prefix > contains matches + * + * 2. ENUM MEMBER KIND FILTERING + * Current: All symbols returned regardless of kind filter + * Needed: Respect kinds filter for enum members + * + * 3. NON-VOID RETURN TYPE PREFERENCE + * When multiple symbols have same name, prefer ones with return types + * Why: Breaks method chaining resolution when wrong symbol selected + * + * @module enscript/server/src/analysis/project/graph + */ + import { TextDocument } from 'vscode-languageserver-textdocument'; import { Position, Range, Location, SymbolInformation, SymbolKind, Diagnostic, DiagnosticSeverity } from 'vscode-languageserver'; import { parse, ParseError, ClassDeclNode, File, SymbolNodeBase, FunctionDeclNode, VarDeclNode, TypedefNode, toSymbolKind, EnumDeclNode, EnumMemberDeclNode, TypeNode } from '../ast/parser'; @@ -18,6 +56,17 @@ interface SymbolEntry { scope: 'global' | 'class' | 'function'; } +/** + * Completion result with optional metadata + */ +interface CompletionResult { + name: string; + kind: string; + detail?: string; + insertText?: string; + returnType?: string; +} + /** * Returns the token at a specific offset (e.g. mouse hover or cursor position). * Lexes only a small window around the position for performance. @@ -134,14 +183,14 @@ export class Analyzer { // source: 'parser' // }; // connection.sendDiagnostics({ uri: err.uri, diagnostics: [diagnostic] }); - console.error(String(err.stack)); + // Stack trace removed to reduce noise during indexing } else { // unexpected failure console.error(String(err)); } // 4 · return an empty stub so callers can continue - return { body: [], version: 0 }; + return { body: [], version: 0, diagnostics: [] }; } } @@ -188,50 +237,760 @@ export class Analyzer { return result; } - getCompletions(doc: TextDocument, _pos: Position) { + // ======================================================================== + // COMPLETIONS - Enhanced with parameter type resolution & member access + // ======================================================================== + // This is a major improvement over the basic implementation. + // + // FEATURES: + // 1. CONTEXT DETECTION: Detects if cursor is after a dot (member access) + // 2. PARAMETER TYPE RESOLUTION: Resolves types of function parameters + // Example: void SomeFunc(PlayerBase p) { p. } → shows PlayerBase methods + // 3. LOCAL VARIABLE TYPE RESOLUTION: Resolves types of local variables + // Example: PlayerBase player = GetPlayer(); player. → shows methods + // 4. INHERITANCE CHAIN: Walks up class hierarchy for complete method list + // 5. GLOBAL COMPLETIONS: Shows classes, functions, enums when not after dot + // 6. CLASS CONTEXT: When inside a class, show methods from this class + parents + // 7. FUNCTION RETURN TYPES: GetGame(). → resolves return type of GetGame() + // ======================================================================== + getCompletions(doc: TextDocument, pos: Position): CompletionResult[] { const ast = this.ensure(doc); - return ast.body.filter((n: any) => n.name); + const text = doc.getText(); + const offset = doc.offsetAt(pos); + + // Check if we're after a dot (member completion) + const textBeforeCursor = text.substring(0, offset); + + // Match both variable.method and function().method patterns + // Pattern 1: variable. or variable.prefix + // Pattern 2: function(). or function().prefix + // Pattern 3: function(args). or function(args).prefix + const dotMatch = textBeforeCursor.match(/(\w+)(\([^)]*\))?\s*\.\s*(\w*)$/); + + if (dotMatch) { + // MEMBER COMPLETION MODE + const name = dotMatch[1]; + const hasParens = !!dotMatch[2]; // true if it's a function call like GetGame() + const prefix = dotMatch[3] || ''; + + console.info(`Completion: matched "${name}" hasParens=${hasParens} prefix="${prefix}"`); + + // Handle 'this' keyword + if (name === 'this') { + const containingClass = this.findContainingClass(ast, pos); + if (containingClass) { + return this.getClassMemberCompletions(containingClass.name, prefix); + } + } + + // Handle 'super' keyword + if (name === 'super') { + const containingClass = this.findContainingClass(ast, pos); + if (containingClass?.base?.identifier) { + return this.getClassMemberCompletions(containingClass.base.identifier, prefix); + } + } + + // If it's a function call like GetGame(). → look up the function's return type + if (hasParens) { + const returnType = this.resolveFunctionReturnType(name); + if (returnType) { + return this.getClassMemberCompletions(returnType, prefix); + } + } + + // Try to resolve the variable's type + const varType = this.resolveVariableType(doc, pos, name); + + if (varType) { + // Get methods/fields for this type (including inherited) + return this.getClassMemberCompletions(varType, prefix); + } + + // If name looks like a class name (starts with uppercase), + // it might be a static method call: ClassName.StaticMethod() + // OR an enum access: EnumName.EnumValue + if (name[0] === name[0].toUpperCase()) { + // First check if it's an enum + const enumNode = this.findEnumByName(name); + if (enumNode) { + return this.getEnumMemberCompletions(enumNode, prefix); + } + + // Otherwise check for class static members + const classNode = this.findClassByName(name); + if (classNode) { + return this.getStaticMemberCompletions(classNode, prefix); + } + } + + return []; + } + + // Get the prefix being typed (for filtering) + const prefixMatch = textBeforeCursor.match(/(\w+)$/); + const prefix = prefixMatch ? prefixMatch[1].toLowerCase() : ''; + + // CONTEXT-AWARE COMPLETION MODE + const results: CompletionResult[] = []; + const seen = new Set(); + + // Check if we're inside a class + const containingClass = this.findContainingClass(ast, pos); + + if (containingClass) { + // Add methods/fields from current class hierarchy (including modded) + const classHierarchy = this.getClassHierarchyOrdered(containingClass.name, new Set()); + + for (const classNode of classHierarchy) { + for (const member of classNode.members || []) { + if (!member.name) continue; + if (seen.has(member.name)) continue; + if (prefix && !member.name.toLowerCase().startsWith(prefix)) continue; + + seen.add(member.name); + + if (member.kind === 'FunctionDecl') { + const func = member as FunctionDeclNode; + const params = func.parameters?.map(p => + `${p.type?.identifier || 'auto'} ${p.name}` + ).join(', ') || ''; + + results.push({ + name: func.name, + kind: 'function', + detail: `${func.returnType?.identifier || 'void'} (${classNode.name})`, + insertText: `${func.name}()`, + returnType: func.returnType?.identifier + }); + } else if (member.kind === 'VarDecl') { + const field = member as VarDeclNode; + results.push({ + name: field.name, + kind: 'field', + detail: `${field.type?.identifier || 'auto'} (${classNode.name})` + }); + } + } + } + } + + // Add all top-level symbols from ALL indexed documents + for (const [uri, fileAst] of this.docCache) { + for (const node of fileAst.body) { + if (!node.name) continue; + if (seen.has(node.name)) continue; + if (prefix && !node.name.toLowerCase().startsWith(prefix)) continue; + + seen.add(node.name); + + if (node.kind === 'ClassDecl') { + results.push({ + name: node.name, + kind: 'class', + detail: (node as ClassDeclNode).base?.identifier + ? `extends ${(node as ClassDeclNode).base?.identifier}` + : 'class' + }); + } else if (node.kind === 'FunctionDecl') { + const func = node as FunctionDeclNode; + results.push({ + name: func.name, + kind: 'function', + detail: func.returnType?.identifier || 'void', + insertText: `${func.name}()`, + returnType: func.returnType?.identifier + }); + } else if (node.kind === 'VarDecl') { + const v = node as VarDeclNode; + results.push({ + name: v.name, + kind: 'variable', + detail: v.type?.identifier || 'auto' + }); + } else if (node.kind === 'EnumDecl') { + results.push({ + name: node.name, + kind: 'enum', + detail: 'enum' + }); + } else if (node.kind === 'Typedef') { + results.push({ + name: node.name, + kind: 'typedef', + detail: `typedef ${(node as TypedefNode).oldType?.identifier}` + }); + } + } + } + + return results; + } + + /** + * Resolve the type of a variable at a given position + * Checks: function parameters, local variables, class fields + */ + /** + * Known DayZ global variables that have a more specific type than declared. + * Example: g_Game is declared as "Game" but is actually "CGame" + */ + private static readonly KNOWN_VARIABLE_TYPES: Record = { + 'g_Game': 'CGame', + }; + + private resolveVariableType(doc: TextDocument, pos: Position, varName: string): string | null { + console.info(`resolveVariableType: looking for "${varName}"`); + + // Check for known variable type overrides first + const knownType = Analyzer.KNOWN_VARIABLE_TYPES[varName]; + if (knownType) { + console.info(` Using known variable type for ${varName} -> ${knownType}`); + return knownType; + } + + const ast = this.ensure(doc); + + // Find which function we're inside + const containingFunc = this.findContainingFunction(ast, pos); + + if (containingFunc) { + // Check function parameters first + // Example: void SomeFunc(PlayerBase p) { p. } → type is "PlayerBase" + for (const param of containingFunc.parameters || []) { + if (param.name === varName) { + console.info(` Found param ${varName} -> ${param.type?.identifier}`); + return param.type?.identifier || null; + } + } + + // Check local variables + for (const local of containingFunc.locals || []) { + if (local.name === varName) { + console.info(` Found local ${varName} -> ${local.type?.identifier}`); + return local.type?.identifier || null; + } + } + } + + // Check if we're inside a class - look for fields + inherited fields + const containingClass = this.findContainingClass(ast, pos); + if (containingClass) { + // Check current class and all parent classes (including modded) + const classHierarchy = this.getClassHierarchyOrdered(containingClass.name, new Set()); + for (const classNode of classHierarchy) { + for (const member of classNode.members || []) { + if (member.kind === 'VarDecl' && member.name === varName) { + console.info(` Found field ${varName} in ${classNode.name} -> ${(member as VarDeclNode).type?.identifier}`); + return (member as VarDeclNode).type?.identifier || null; + } + } + } + } + + // Check global variables in ALL indexed files + for (const [uri, fileAst] of this.docCache) { + for (const node of fileAst.body) { + if (node.kind === 'VarDecl' && node.name === varName) { + console.info(` Found global ${varName} -> ${(node as VarDeclNode).type?.identifier}`); + return (node as VarDeclNode).type?.identifier || null; + } + } + } + + // Try scanning the current document for variable declarations + const text = doc.getText(); + + // Pattern: Type varName; or Type varName = + const varDeclMatch = text.match(new RegExp(`(\\w+)\\s+${varName}\\s*[;=]`)); + if (varDeclMatch) { + console.info(` Found via regex ${varName} -> ${varDeclMatch[1]}`); + return varDeclMatch[1]; + } + + // Pattern: (Type varName) or (Type varName,) - function parameters + const paramMatch = text.match(new RegExp(`[,(]\\s*(\\w+)\\s+${varName}\\s*[,)]`)); + if (paramMatch) { + console.info(` Found param via regex ${varName} -> ${paramMatch[1]}`); + return paramMatch[1]; + } + + // Pattern: out Type varName or inout Type varName + const outParamMatch = text.match(new RegExp(`(?:out|inout)\\s+(\\w+)\\s+${varName}\\s*[,)]`)); + if (outParamMatch) { + return outParamMatch[1]; + } + + return null; + } + + /** + * Known DayZ singleton functions that return a more specific type than declared. + * These functions are declared to return base class but actually return derived class. + * Example: GetGame() is declared as returning "Game" but actually returns "CGame" + */ + private static readonly KNOWN_RETURN_TYPES: Record = { + 'GetGame': 'CGame', + 'GetDayZGame': 'DayZGame', + 'g_Game': 'CGame', + }; + + /** + * Resolve the return type of a function by name + * Searches top-level functions and class methods across all indexed files + */ + private resolveFunctionReturnType(funcName: string): string | null { + console.info(`resolveFunctionReturnType: looking for "${funcName}" in ${this.docCache.size} indexed files`); + + // Check for known overrides first (e.g., GetGame() returns CGame, not Game) + const knownType = Analyzer.KNOWN_RETURN_TYPES[funcName]; + if (knownType) { + console.info(` Using known return type for ${funcName} -> ${knownType}`); + return knownType; + } + + // Search all indexed documents for a function with this name + for (const [uri, ast] of this.docCache) { + for (const node of ast.body) { + // Top-level function + if (node.kind === 'FunctionDecl' && node.name === funcName) { + const func = node as FunctionDeclNode; + const returnType = func.returnType?.identifier; + console.info(` Found top-level function ${funcName} -> ${returnType}`); + // Skip void functions + if (returnType && returnType !== 'void') { + return returnType; + } + } + + // Class method (for static calls or when we don't know the class) + if (node.kind === 'ClassDecl') { + for (const member of (node as ClassDeclNode).members || []) { + if (member.kind === 'FunctionDecl' && member.name === funcName) { + const func = member as FunctionDeclNode; + const returnType = func.returnType?.identifier; + console.info(` Found ${funcName} in class ${node.name} -> ${returnType}`); + if (returnType && returnType !== 'void') { + return returnType; + } + } + } + } + } + } + + console.info(` ${funcName} not found in ${this.docCache.size} cached documents`); + return null; + } + + /** + * Find the function containing the given position + */ + private findContainingFunction(ast: File, pos: Position): FunctionDeclNode | null { + for (const node of ast.body) { + if (node.kind === 'FunctionDecl') { + const func = node as FunctionDeclNode; + if (this.positionInRange(pos, func.start, func.end)) { + return func; + } + } + + if (node.kind === 'ClassDecl') { + for (const member of (node as ClassDeclNode).members || []) { + if (member.kind === 'FunctionDecl') { + const func = member as FunctionDeclNode; + if (this.positionInRange(pos, func.start, func.end)) { + return func; + } + } + } + } + } + return null; + } + + /** + * Find the class containing the given position + */ + private findContainingClass(ast: File, pos: Position): ClassDeclNode | null { + for (const node of ast.body) { + if (node.kind === 'ClassDecl') { + const cls = node as ClassDeclNode; + if (this.positionInRange(pos, cls.start, cls.end)) { + return cls; + } + } + } + return null; + } + + private positionInRange(pos: Position, start: Position, end: Position): boolean { + if (pos.line < start.line || pos.line > end.line) return false; + if (pos.line === start.line && pos.character < start.character) return false; + if (pos.line === end.line && pos.character > end.character) return false; + return true; + } + + /** + * Get member completions for a class type (methods + fields) + * Walks the FULL inheritance chain INCLUDING modded classes + */ + private getClassMemberCompletions(className: string, prefix: string): CompletionResult[] { + const results: CompletionResult[] = []; + const seen = new Set(); // Deduplicate by name + + console.info(`getClassMemberCompletions: "${className}" prefix="${prefix}"`); + + // Get the complete class hierarchy including modded classes + const classHierarchy = this.getClassHierarchyOrdered(className, new Set()); + + console.info(` Class hierarchy: [${classHierarchy.map(c => c.name).join(', ')}]`); + + for (const classNode of classHierarchy) { + for (const member of classNode.members || []) { + if (!member.name) continue; + if (seen.has(member.name)) continue; // Skip duplicates + if (prefix && !member.name.toLowerCase().startsWith(prefix.toLowerCase())) continue; + + // Skip static members for instance completions + if (member.modifiers?.includes('static')) continue; + + seen.add(member.name); + + if (member.kind === 'FunctionDecl') { + const func = member as FunctionDeclNode; + const params = func.parameters?.map(p => + `${p.type?.identifier || 'auto'} ${p.name}` + ).join(', ') || ''; + + // Show visibility modifier if present + const visibility = func.modifiers?.find(m => ['private', 'protected'].includes(m)) || ''; + const visPrefix = visibility ? `${visibility} ` : ''; + + results.push({ + name: func.name, + kind: 'function', + detail: `${visPrefix}${func.returnType?.identifier || 'void'} - ${classNode.name}`, + insertText: `${func.name}()`, + returnType: func.returnType?.identifier + }); + } else if (member.kind === 'VarDecl') { + const field = member as VarDeclNode; + const visibility = field.modifiers?.find(m => ['private', 'protected'].includes(m)) || ''; + const visPrefix = visibility ? `${visibility} ` : ''; + + results.push({ + name: field.name, + kind: 'variable', + detail: `${visPrefix}${field.type?.identifier || 'auto'} - ${classNode.name}` + }); + } + } + } + + return results; + } + + /** + * Get static member completions for a class (ClassName.StaticMethod()) + */ + private getStaticMemberCompletions(classNode: ClassDeclNode, prefix: string): CompletionResult[] { + const results: CompletionResult[] = []; + + for (const member of classNode.members || []) { + if (!member.name) continue; + if (!member.modifiers?.includes('static')) continue; + if (prefix && !member.name.toLowerCase().startsWith(prefix.toLowerCase())) continue; + + if (member.kind === 'FunctionDecl') { + const func = member as FunctionDeclNode; + const params = func.parameters?.map(p => + `${p.type?.identifier || 'auto'} ${p.name}` + ).join(', ') || ''; + + results.push({ + name: `${func.name}(${params})`, + kind: 'function', + detail: `${func.returnType?.identifier || 'void'} (static)`, + insertText: `${func.name}()` + }); + } + } + + return results; + } + + /** + * Find a class by name across all cached documents + */ + private findClassByName(className: string): ClassDeclNode | null { + for (const [uri, ast] of this.docCache) { + for (const node of ast.body) { + if (node.kind === 'ClassDecl' && node.name === className) { + return node as ClassDeclNode; + } + } + } + return null; + } + + /** + * Find an enum by name across all indexed files + */ + private findEnumByName(enumName: string): EnumDeclNode | null { + for (const [uri, ast] of this.docCache) { + for (const node of ast.body) { + if (node.kind === 'EnumDecl' && node.name === enumName) { + return node as EnumDeclNode; + } + } + } + return null; + } + + /** + * Get completions for enum members (e.g., MuzzleState. → shows U, L, etc.) + */ + private getEnumMemberCompletions(enumNode: EnumDeclNode, prefix: string): CompletionResult[] { + const results: CompletionResult[] = []; + + for (const member of enumNode.members || []) { + if (!member.name) continue; + if (prefix && !member.name.toLowerCase().startsWith(prefix.toLowerCase())) continue; + + results.push({ + name: member.name, + kind: 'enumMember', + detail: `${enumNode.name}.${member.name}` + }); + } + + return results; + } + + /** + * Get completion detail text for a node + */ + private getCompletionDetail(node: SymbolNodeBase): string { + switch (node.kind) { + case 'ClassDecl': { + const cls = node as ClassDeclNode; + return cls.base ? `extends ${cls.base.identifier}` : 'class'; + } + case 'FunctionDecl': { + const func = node as FunctionDeclNode; + return func.returnType?.identifier || 'void'; + } + case 'VarDecl': { + const v = node as VarDeclNode; + return v.type?.identifier || 'auto'; + } + case 'EnumDecl': + return 'enum'; + default: + return ''; + } } resolveDefinitions(doc: TextDocument, _pos: Position): SymbolNodeBase[] { const offset = doc.offsetAt(_pos); + const text = doc.getText(); - const token = getTokenAtPosition(doc.getText(), offset); + const token = getTokenAtPosition(text, offset); if (!token || token.kind !== TokenKind.Identifier) return []; const name = token.value; console.info(`resolveDefinitions: "${name}"`); + // Check if this is a member access (e.g., player.GetInputType) + // Look backwards from the token start to find a dot + const textBeforeToken = text.substring(0, token.start); + const memberMatch = textBeforeToken.match(/(\w+)\s*\.\s*$/); + + if (memberMatch) { + // MEMBER ACCESS: Resolve the variable type and search only that class hierarchy + const varName = memberMatch[1]; + const varType = this.resolveVariableType(doc, _pos, varName); + + if (varType) { + const classMatches = this.findMemberInClassHierarchy(varType, name); + if (classMatches.length > 0) { + return classMatches; + } + } + + // If varName looks like a class (uppercase), try static member lookup + if (varName[0] === varName[0].toUpperCase()) { + const classMatches = this.findMemberInClassHierarchy(varName, name); + if (classMatches.length > 0) { + return classMatches; + } + } + } + + // Check if we're inside a class - prioritize current class and inheritance + const ast = this.ensure(doc); + const containingClass = this.findContainingClass(ast, _pos); + + if (containingClass) { + // First, look in current class hierarchy + const hierarchyMatches = this.findMemberInClassHierarchy(containingClass.name, name); + if (hierarchyMatches.length > 0) { + return hierarchyMatches; + } + } + + // FALLBACK: Global search - but with proper scoping rules + // - Enum members ONLY if accessed via EnumName.member + // - Class members ONLY if inside that class (already checked above) const matches: SymbolNodeBase[] = []; + // Check if this is an enum member access (e.g., MuzzleState.U) + const enumMemberMatch = textBeforeToken.match(/(\w+)\s*\.\s*$/); + const isEnumAccess = enumMemberMatch && enumMemberMatch[1][0] === enumMemberMatch[1][0].toUpperCase(); + // iterate all loaded documents for (const [uri, ast] of this.docCache) { for (const node of ast.body) { - // top-level match + // top-level match (classes, functions, global variables, enums, typedefs) if (node.name === name) { matches.push(node as SymbolNodeBase); } - // class member match - if (node.kind === 'ClassDecl') { - for (const member of (node as ClassDeclNode).members) { + // Enum member match - ONLY if accessed via EnumName.member + if (isEnumAccess && enumMemberMatch && node.kind === 'EnumDecl' && node.name === enumMemberMatch[1]) { + for (const member of (node as EnumDeclNode).members) { if (member.name === name) { matches.push(member as SymbolNodeBase); } } } + + // Class members are NOT included in global search + // They should only be found via: + // 1. Member access (player.Method) - handled above + // 2. Inside the class (this.Method or just Method) - handled above + // 3. Inheritance chain - handled above + } + } - // enum member match - if (node.kind === 'EnumDecl') { - for (const member of (node as EnumDeclNode).members) { - if (member.name === name) { - matches.push(member as SymbolNodeBase); - } - } + return matches; + } + + /** + * Find a member (method or field) in a class and its full hierarchy + * Includes: parent classes (extends) and modded classes + */ + private findMemberInClassHierarchy(className: string, memberName: string): SymbolNodeBase[] { + const matches: SymbolNodeBase[] = []; + const visited = new Set(); + + // Collect all classes in the hierarchy (inheritance + modded) + // Returns in order: base classes first, then derived, with modded grouped by class + const classesToSearch = this.getClassHierarchyOrdered(className, visited); + + for (const classNode of classesToSearch) { + for (const member of classNode.members || []) { + if (member.name === memberName) { + matches.push(member as SymbolNodeBase); } } } + + return matches; + } + + /** + * Get all classes in a hierarchy in inheritance order: + * 1. Root base class first (e.g., Managed) + * 2. Then each level of inheritance down to the target class + * 3. Modded classes are grouped with their base class + * + * Example for PlayerBase extends ManBase extends Entity: + * Returns: [Entity, modded Entity, ManBase, modded ManBase, PlayerBase, modded PlayerBase] + */ + private getClassHierarchyOrdered(className: string, visited: Set): ClassDeclNode[] { + if (visited.has(className)) return []; + visited.add(className); + + // Find all classes with this name (original + modded versions) + const classNodes = this.findAllClassesByName(className); + if (classNodes.length === 0) return []; + + // Separate original class from modded classes + const originalClass = classNodes.find(c => !c.modifiers?.includes('modded')); + const moddedClasses = classNodes.filter(c => c.modifiers?.includes('modded')); + + // Get the base class name (from original or first modded) + const baseClassName = (originalClass || classNodes[0])?.base?.identifier; + + // Recursively get parent hierarchy FIRST (so base classes come first) + const parentHierarchy: ClassDeclNode[] = baseClassName + ? this.getClassHierarchyOrdered(baseClassName, visited) + : []; + + // Build result: parents first, then this class (original + modded) + const result: ClassDeclNode[] = [...parentHierarchy]; + + // Add original class first, then modded classes + if (originalClass) { + result.push(originalClass); + } + result.push(...moddedClasses); + + return result; + } + /** + * Get all classes in a hierarchy including: + * - The class itself + * - All parent classes (via extends) + * - All modded versions of any class in the hierarchy + * @deprecated Use getClassHierarchyOrdered for ordered results + */ + private getClassHierarchy(className: string, visited: Set): ClassDeclNode[] { + const result: ClassDeclNode[] = []; + + if (visited.has(className)) return result; + visited.add(className); + + // Find all classes with this name (includes modded classes) + const classNodes = this.findAllClassesByName(className); + + for (const classNode of classNodes) { + result.push(classNode); + + // Walk up inheritance chain + if (classNode.base?.identifier) { + const parentClasses = this.getClassHierarchy(classNode.base.identifier, visited); + result.push(...parentClasses); + } + } + + return result; + } + + /** + * Find all classes with a given name (handles modded classes) + * In Enforce Script, multiple 'modded class X' can exist for the same class + */ + private findAllClassesByName(className: string): ClassDeclNode[] { + const matches: ClassDeclNode[] = []; + + for (const [uri, ast] of this.docCache) { + for (const node of ast.body) { + if (node.kind === 'ClassDecl' && node.name === className) { + const classNode = node as ClassDeclNode; + console.info(` findAllClassesByName("${className}"): found in ${uri} (modded=${classNode.modifiers?.includes('modded')}, members=${classNode.members?.length || 0})`); + matches.push(classNode); + } + } + } + + if (matches.length === 0) { + console.info(` findAllClassesByName("${className}"): NO MATCHES FOUND`); + } + return matches; } @@ -256,6 +1015,130 @@ export class Analyzer { return [] as { uri: string; range: Range }[]; } + // ======================================================================== + // WORKSPACE SYMBOL SEARCH - Three-Tier Priority System + // ======================================================================== + // Problem: When searching for "U", we want "U()" to appear before "UFLog", + // "Update", "UnitTest", etc. Simple .includes() returns them in arbitrary order. + // + // Solution: Three-tier priority: + // 1. EXACT MATCHES - Symbol name exactly equals query (highest priority) + // 2. PREFIX MATCHES - Symbol name starts with query + // 3. CONTAINS MATCHES - Symbol name contains query anywhere (lowest priority) + // + // Results are returned in priority order: exact first, then prefix, then contains. + // ======================================================================== + + /** + * Collect symbols with three-tier prioritization + */ + private collectSymbolsPrioritized( + uri: string, + query: string, + members: SymbolNodeBase[], + exactMatches: SymbolInformation[], + prefixMatches: SymbolInformation[], + containsMatches: SymbolInformation[], + containerName?: string, + kinds?: SymbolKind[] + ): void { + const queryLower = query.toLowerCase(); + + for (const node of members) { + const nameLower = node.name.toLowerCase(); + const nodeKind = toSymbolKind(node.kind); + + // Check if this kind is allowed (if filter specified) + const kindMatch = !kinds || kinds.length === 0 || kinds.includes(nodeKind); + + // Determine match type + const isExact = nameLower === queryLower; + const isPrefix = !isExact && nameLower.startsWith(queryLower); + const isContains = !isExact && !isPrefix && nameLower.includes(queryLower); + + if (kindMatch && (isExact || isPrefix || isContains)) { + const symbolInfo: SymbolInformation = { + name: node.name, + kind: nodeKind, + containerName: containerName, + location: { uri, range: { start: node.nameStart, end: node.nameEnd } } + }; + + if (isExact) { + exactMatches.push(symbolInfo); + } else if (isPrefix) { + prefixMatches.push(symbolInfo); + } else { + containsMatches.push(symbolInfo); + } + } + + // Recurse into class members + if (node.kind === "ClassDecl") { + this.collectSymbolsPrioritized( + uri, query, (node as ClassDeclNode).members, + exactMatches, prefixMatches, containsMatches, + node.name, kinds + ); + } + + // Handle enum members - IMPORTANT: respect kinds filter! + // Bug fix: Previously enum members were always returned even when + // searching for functions. Now we check the kinds filter. + if (node.kind === "EnumDecl") { + const enumMemberKindMatch = !kinds || kinds.length === 0 || kinds.includes(SymbolKind.EnumMember); + + if (enumMemberKindMatch) { + for (const enumerator of (node as EnumDeclNode).members) { + const enumNameLower = enumerator.name.toLowerCase(); + const enumExact = enumNameLower === queryLower; + const enumPrefix = !enumExact && enumNameLower.startsWith(queryLower); + const enumContains = !enumExact && !enumPrefix && enumNameLower.includes(queryLower); + + if (enumExact || enumPrefix || enumContains) { + const enumSymbol: SymbolInformation = { + name: enumerator.name, + kind: SymbolKind.EnumMember, + containerName: node.name, + location: { uri, range: { start: enumerator.nameStart, end: enumerator.nameEnd } } + }; + + if (enumExact) { + exactMatches.push(enumSymbol); + } else if (enumPrefix) { + prefixMatches.push(enumSymbol); + } else { + containsMatches.push(enumSymbol); + } + } + } + } + } + } + } + + /** + * Get workspace symbols with optional kind filtering + * Uses three-tier priority: exact > prefix > contains + */ + getWorkspaceSymbols(query: string, kinds?: SymbolKind[]): SymbolInformation[] { + const exactMatches: SymbolInformation[] = []; + const prefixMatches: SymbolInformation[] = []; + const containsMatches: SymbolInformation[] = []; + + for (const [uri, ast] of this.docCache) { + this.collectSymbolsPrioritized( + uri, query, ast.body, + exactMatches, prefixMatches, containsMatches, + undefined, kinds + ); + } + + // Return in priority order: exact first, then prefix, then contains + return [...exactMatches, ...prefixMatches, ...containsMatches]; + } + + // Legacy method for backwards compatibility getInnerWorkspaceSymbols(uri: string, query: string, members: SymbolNodeBase[], containerName?: string): SymbolInformation[] { const res: SymbolInformation[] = []; for (const node of members) { @@ -288,27 +1171,708 @@ export class Analyzer { return res } - getWorkspaceSymbols(query: string): SymbolInformation[] { - const res: SymbolInformation[] = []; - for (const [uri, ast] of this.docCache) { - res.push(...this.getInnerWorkspaceSymbols(uri, query, ast.body, undefined)); + // Minimum number of indexed files before running type checks + // This prevents false positives during initial indexing + private static readonly MIN_INDEX_SIZE_FOR_TYPE_CHECKS = 100; + + runDiagnostics(doc: TextDocument): Diagnostic[] { + const ast = this.ensure(doc); + const diags: Diagnostic[] = []; + + // Include parser-generated diagnostics (e.g., ternary operator errors) + if (ast.diagnostics && ast.diagnostics.length > 0) { + diags.push(...ast.diagnostics); + } + + // Only run type/symbol checks if we have enough indexed files + // This prevents false positives during initial workspace indexing + if (this.docCache.size >= Analyzer.MIN_INDEX_SIZE_FOR_TYPE_CHECKS) { + // Check for unknown types and symbols + this.checkUnknownSymbols(ast, diags); + + // Check for type mismatches in assignments + this.checkTypeMismatches(doc, diags); } - return res; + + // Check for multi-line statements (not supported in Enforce Script) + // This doesn't require indexing - it's purely syntactic + this.checkMultiLineStatements(doc, diags); + + // Check for duplicate variable declarations in same scope + // Enforce Script doesn't allow duplicate variable names even in sibling for loops + this.checkDuplicateVariables(doc, diags); + + return diags; } - runDiagnostics(doc: TextDocument) { + /** + * Check for duplicate variable declarations within the same scope. + * In Enforce Script, you cannot have two for loops with the same loop variable + * at the same scope level, even though they're "separate" blocks. + * + * Example that causes error: + * for (int j = 0; j < 10; j++) { } + * for (int j = 0; j < 10; j++) { } // ERROR: 'j' already declared + */ + private checkDuplicateVariables(doc: TextDocument, diags: Diagnostic[]): void { + const text = doc.getText(); + + // Track variables by scope - use a stack of scopes + // Each scope has a map of variable names to their declaration info + type VarInfo = { line: number; character: number }; + let scopeStack: Map[] = [new Map()]; // Start with global/class scope + + // Pattern to find variable declarations + // Matches: Type varName in various contexts (including for loop init) + const varDeclPattern = /\b(int|float|bool|string|auto|vector|Man|PlayerBase|\w+)\s+(\w+)\s*(?:=|;|,|\)|<)/g; + + // Pattern to detect function declarations + const funcDeclPattern = /\b(void|int|float|bool|string|auto|override\s+\w+|\w+)\s+(\w+)\s*\([^)]*\)\s*\{?\s*$/; + + // Pattern to detect for/foreach/while loops (their vars go to parent scope in Enforce) + const loopPattern = /\b(for|foreach|while)\s*\(/; + + // Pattern to detect class declarations + const classDeclPattern = /\b(?:modded\s+)?class\s+(\w+)/; + + // Track when we enter/exit functions + let inFunction = false; + let functionBraceDepth = 0; + let braceDepth = 0; + + // Track class fields - these need to be visible inside methods + let classFieldScope: Map = new Map(); + let inClass = false; + let classBraceDepth = 0; + + // Process line by line to track scope + const lines = text.split('\n'); + + for (let lineNum = 0; lineNum < lines.length; lineNum++) { + const line = lines[lineNum]; + + // Check if this line starts a class + if (classDeclPattern.test(line.trim()) && !inClass) { + inClass = true; + classBraceDepth = braceDepth; + classFieldScope = new Map(); // Fresh class field scope + } + + // Check if this line starts a new function + if (funcDeclPattern.test(line.trim())) { + // New function - reset to: global scope + class fields + new function scope + scopeStack = [scopeStack[0], classFieldScope, new Map()]; + inFunction = true; + functionBraceDepth = braceDepth; + } + + // Check if this line has a for/foreach/while loop + // In Enforce Script, loop variables are scoped to the PARENT scope, not the loop block + const isLoopLine = loopPattern.test(line); + + // FIRST: Find variable declarations on this line BEFORE processing braces + // This ensures for loop variables (int j in "for (int j = 0...") + // are added to the current scope before we push a new scope for { + let match; + varDeclPattern.lastIndex = 0; + + while ((match = varDeclPattern.exec(line)) !== null) { + const typeName = match[1]; + const varName = match[2]; + + // Skip if it looks like a function call or keyword + if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', 'else', 'foreach', 'void', 'override', 'static', 'private', 'protected', 'const', 'ref', 'autoptr', 'proto', 'native', 'modded', 'sealed', 'event'].includes(typeName)) { + continue; + } + + // Skip common false positives + if (['this', 'super', 'null', 'true', 'false'].includes(varName)) { + continue; + } + + // Check all scopes in the stack for existing declaration + let foundDuplicate = false; + for (const scope of scopeStack) { + const existing = scope.get(varName); + if (existing) { + const startPos = { line: lineNum, character: match.index + match[1].length + 1 }; + const endPos = { line: lineNum, character: match.index + match[1].length + 1 + varName.length }; + + diags.push({ + message: `Variable '${varName}' is already declared at line ${existing.line + 1}. Enforce Script does not allow duplicate variable names in the same scope.`, + range: { start: startPos, end: endPos }, + severity: DiagnosticSeverity.Error + }); + foundDuplicate = true; + break; + } + } + + // Record this declaration in the appropriate scope + if (!foundDuplicate && scopeStack.length > 0) { + // If we're at class level (in a class but not in a function), record as class field + if (inClass && !inFunction && braceDepth === classBraceDepth + 1) { + classFieldScope.set(varName, { + line: lineNum, + character: match.index + }); + } + // Always add to current scope stack + scopeStack[scopeStack.length - 1].set(varName, { + line: lineNum, + character: match.index + }); + } + } + + // THEN: Process braces AFTER variable declarations + // This ensures for loop vars are in parent scope + for (let charIdx = 0; charIdx < line.length; charIdx++) { + const char = line[charIdx]; + if (char === '{') { + braceDepth++; + // Push new scope + scopeStack.push(new Map()); + } else if (char === '}') { + braceDepth--; + // Pop scope + if (scopeStack.length > 1) { + scopeStack.pop(); + } + // Check if we exited a function + if (inFunction && braceDepth <= functionBraceDepth) { + inFunction = false; + } + // Check if we exited a class + if (inClass && braceDepth <= classBraceDepth) { + inClass = false; + classFieldScope = new Map(); + } + } + } + } + } + + /** + * Type compatibility result + */ + private checkTypeCompatibility(declaredType: string, assignedType: string): { + compatible: boolean; + isDowncast: boolean; + isUpcast: boolean; + message?: string; + } { + // Same type is always compatible + if (declaredType === assignedType) { + return { compatible: true, isDowncast: false, isUpcast: false }; + } + + // Normalize types (remove ref, autoptr, etc.) + const normalizeType = (t: string): string => { + return t.replace(/^(ref|autoptr)\s+/, '').trim(); + }; + + const declNorm = normalizeType(declaredType); + const assignNorm = normalizeType(assignedType); + + if (declNorm === assignNorm) { + return { compatible: true, isDowncast: false, isUpcast: false }; + } + + // Primitive type compatibility + const numericTypes = new Set(['int', 'float', 'bool']); + if (numericTypes.has(declNorm) && numericTypes.has(assignNorm)) { + // int/float/bool are compatible with each other (implicit conversion) + return { compatible: true, isDowncast: false, isUpcast: false }; + } + + // string is only compatible with string + if (declNorm === 'string' || assignNorm === 'string') { + if (declNorm !== assignNorm) { + return { + compatible: false, + isDowncast: false, + isUpcast: false, + message: `Cannot convert '${assignNorm}' to 'string'` + }; + } + return { compatible: true, isDowncast: false, isUpcast: false }; + } + + // void is not compatible with anything + if (declNorm === 'void' || assignNorm === 'void') { + return { + compatible: false, + isDowncast: false, + isUpcast: false, + message: `Cannot assign 'void' to a variable` + }; + } + + // auto/typename/Class are wildcards + if (declNorm === 'auto' || assignNorm === 'auto' || + declNorm === 'typename' || assignNorm === 'typename' || + declNorm === 'Class' || assignNorm === 'Class') { + return { compatible: true, isDowncast: false, isUpcast: false }; + } + + // array types - need to check element type compatibility + if (declNorm.startsWith('array<') || assignNorm.startsWith('array<')) { + const bothArrays = declNorm.startsWith('array') && assignNorm.startsWith('array'); + return { compatible: bothArrays, isDowncast: false, isUpcast: false }; + } + + // Check class hierarchy + // UPCAST: Assigning derived (child) to base (parent) - ALWAYS SAFE + // Example: Man m = playerBase; where PlayerBase extends Man + const assignedHierarchy = this.getClassHierarchyOrdered(assignNorm, new Set()); + for (const classNode of assignedHierarchy) { + if (classNode.name === declNorm) { + // declaredType is a parent of assignedType - this is an upcast (safe) + return { compatible: true, isDowncast: false, isUpcast: true }; + } + } + + // DOWNCAST: Assigning base (parent) to derived (child) - REQUIRES CAST + // Example: PlayerBase p = man; where Man is parent of PlayerBase + // Should use: PlayerBase p = PlayerBase.Cast(man); + // Or: Class.CastTo(p, man); + const declaredHierarchy = this.getClassHierarchyOrdered(declNorm, new Set()); + for (const classNode of declaredHierarchy) { + if (classNode.name === assignNorm) { + // assignedType is a parent of declaredType - this is a downcast (risky) + return { + compatible: true, // Technically compiles but risky + isDowncast: true, + isUpcast: false, + message: `Unsafe downcast from '${assignNorm}' to '${declNorm}'. Use '${declNorm}.Cast(value)' or 'Class.CastTo(target, value)' instead.` + }; + } + } + + // Check primitive types BEFORE checking if classes exist + // Primitives won't be found as classes, so we need to handle them first + const primitiveTypes = new Set(['int', 'float', 'bool', 'string', 'void', 'vector']); + const declIsPrimitive = primitiveTypes.has(declNorm); + const assignIsPrimitive = primitiveTypes.has(assignNorm); + + // Primitive vs class (or vice versa) is never compatible + if (declIsPrimitive !== assignIsPrimitive) { + return { + compatible: false, + isDowncast: false, + isUpcast: false, + message: `Cannot assign '${assignNorm}' to '${declNorm}'` + }; + } + + // If both are primitives but different (and not numeric), they're not compatible + // Note: numeric types (int/float/bool) were already handled above + if (declIsPrimitive && assignIsPrimitive && declNorm !== assignNorm) { + return { + compatible: false, + isDowncast: false, + isUpcast: false, + message: `Cannot assign '${assignNorm}' to '${declNorm}'` + }; + } + + // If we can't determine the types (not in cache), assume compatible + const declExists = this.findAllClassesByName(declNorm).length > 0; + const assignExists = this.findAllClassesByName(assignNorm).length > 0; + + if (!declExists || !assignExists) { + return { compatible: true, isDowncast: false, isUpcast: false }; // Unknown types + } + + // No compatibility found - types are unrelated + return { + compatible: false, + isDowncast: false, + isUpcast: false, + message: `Cannot assign '${assignNorm}' to '${declNorm}' - types are not related` + }; + } + + /** + * Check for type mismatches in variable assignments + * Checks both: + * - Declaration with init: Type varName = FunctionCall(); + * - Re-assignment: varName = otherVar; + */ + private checkTypeMismatches(doc: TextDocument, diags: Diagnostic[]): void { + const text = doc.getText(); const ast = this.ensure(doc); - const diags = [] as any[]; - for (const node of (ast.body as any[])) { - if (node.kind === 'Typedef') { + + // Build a map of variable names to their types from the AST + const variableTypes = new Map(); + + // Collect types from all declarations in this file + const collectVarTypes = (nodes: any[], scope?: string) => { + for (const node of nodes) { + if (node.kind === 'VarDecl' && node.name && node.type?.identifier) { + variableTypes.set(node.name, node.type.identifier); + } + if (node.kind === 'FunctionDecl') { + // Collect parameters + for (const param of node.parameters || []) { + if (param.name && param.type?.identifier) { + variableTypes.set(param.name, param.type.identifier); + } + } + // Collect locals + for (const local of node.locals || []) { + if (local.name && local.type?.identifier) { + variableTypes.set(local.name, local.type.identifier); + } + } + } + if (node.kind === 'ClassDecl') { + // Collect class fields + for (const member of node.members || []) { + if (member.kind === 'VarDecl' && member.name && member.type?.identifier) { + variableTypes.set(member.name, member.type.identifier); + } + } + } + } + }; + + collectVarTypes(ast.body); + + // Also scan the raw text for variable declarations to catch any the AST missed + // Pattern: Type varName; or Type varName = ... + const varDeclScanPattern = /\b(\w+)\s+(\w+)\s*(?:=|;)/g; + let scanMatch; + while ((scanMatch = varDeclScanPattern.exec(text)) !== null) { + const typeName = scanMatch[1]; + const varName = scanMatch[2]; + // Skip keywords + if (!['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', 'else', 'void', 'override', 'static', 'private', 'protected', 'const', 'ref', 'autoptr'].includes(typeName)) { + // Only add if not already in map (AST takes precedence) + if (!variableTypes.has(varName)) { + variableTypes.set(varName, typeName); + } + } + } + + // Pattern 1: Type varName = FunctionCall(); + // e.g., int i = GetGame(); + const funcAssignPattern = /\b(\w+)\s+(\w+)\s*=\s*(\w+)\s*\(/g; + + let match; + while ((match = funcAssignPattern.exec(text)) !== null) { + const declaredType = match[1]; + const varName = match[2]; + const funcName = match[3]; + + // Skip if declared type is a keyword that's not a type + if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum'].includes(declaredType)) { + continue; + } + + // Get the return type of the function + const returnType = this.resolveFunctionReturnType(funcName); + + if (returnType) { + this.addTypeMismatchDiagnostic(doc, diags, match.index, match[0].length, declaredType, returnType); + } + } + + // Pattern 2: Type varName = otherVar; + // e.g., int i = p; where p is PlayerBase + const varDeclAssignPattern = /\b(\w+)\s+(\w+)\s*=\s*(\w+)\s*;/g; + + while ((match = varDeclAssignPattern.exec(text)) !== null) { + const declaredType = match[1]; + const varName = match[2]; + const sourceVar = match[3]; + + // Skip if declared type is a keyword + if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', 'else'].includes(declaredType)) { + continue; + } + + // Skip if source looks like a literal (number, true, false, null) + if (/^\d+$/.test(sourceVar) || ['true', 'false', 'null', 'NULL'].includes(sourceVar)) { + continue; + } + + // Look up the type of the source variable + const sourceType = variableTypes.get(sourceVar); + + if (sourceType) { + this.addTypeMismatchDiagnostic(doc, diags, match.index, match[0].length, declaredType, sourceType); + } + } + + // Pattern 3: varName = otherVar; (re-assignment, not declaration) + // Must ensure there's no type before the targetVar + const reassignPattern = /(?:^|[;{})\n])(\s*)(\w+)\s*=\s*(\w+)\s*;/g; + + while ((match = reassignPattern.exec(text)) !== null) { + const leadingWhitespace = match[1]; + const targetVar = match[2]; + const sourceVar = match[3]; + + // Skip keywords + if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'else'].includes(targetVar)) { + continue; + } + + // Skip literals + if (/^\d+$/.test(sourceVar) || ['true', 'false', 'null', 'NULL'].includes(sourceVar)) { + continue; + } + + // Look up types for both variables + const targetType = variableTypes.get(targetVar); + const sourceType = variableTypes.get(sourceVar); + + if (targetType && sourceType) { + // Calculate actual start position (skip the leading delimiter and whitespace) + const actualStart = match.index + 1 + leadingWhitespace.length; + const actualLength = match[0].length - 1 - leadingWhitespace.length; + this.addTypeMismatchDiagnostic(doc, diags, actualStart, actualLength, targetType, sourceType); + } + } + + // Pattern 4: varName = FunctionCall(); (re-assignment with function call) + // e.g., i = GetGame(); where i is declared as int earlier + const reassignFuncPattern = /(?:^|[;{})\n])(\s*)(\w+)\s*=\s*(\w+)\s*\(/g; + + while ((match = reassignFuncPattern.exec(text)) !== null) { + const leadingWhitespace = match[1]; + const targetVar = match[2]; + const funcName = match[3]; + + // Skip keywords + if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'else'].includes(targetVar)) { + continue; + } + + // Look up type of target variable and return type of function + const targetType = variableTypes.get(targetVar); + const returnType = this.resolveFunctionReturnType(funcName); + + if (targetType && returnType) { + // Calculate actual start position (skip the leading delimiter and whitespace) + const actualStart = match.index + 1 + leadingWhitespace.length; + const actualLength = match[0].length - 1 - leadingWhitespace.length; + this.addTypeMismatchDiagnostic(doc, diags, actualStart, actualLength, targetType, returnType); + } + } + } + + /** + * Helper to add a type mismatch diagnostic if needed + */ + private addTypeMismatchDiagnostic( + doc: TextDocument, + diags: Diagnostic[], + matchIndex: number, + matchLength: number, + targetType: string, + sourceType: string + ): void { + const result = this.checkTypeCompatibility(targetType, sourceType); + + const startPos = doc.positionAt(matchIndex); + const endPos = doc.positionAt(matchIndex + matchLength); + + if (!result.compatible) { + // Type error - incompatible types + diags.push({ + message: result.message || `Type mismatch: cannot assign '${sourceType}' to '${targetType}'`, + range: { start: startPos, end: endPos }, + severity: DiagnosticSeverity.Error + }); + } else if (result.isDowncast) { + // Warning - unsafe downcast + diags.push({ + message: result.message || `Unsafe downcast from '${sourceType}' to '${targetType}'. Use '${targetType}.Cast(value)' or 'Class.CastTo(target, value)' instead.`, + range: { start: startPos, end: endPos }, + severity: DiagnosticSeverity.Warning + }); + } + // Upcast is fine - no warning needed + } + + /** + * Check for multi-line statements which are NOT supported in Enforce Script. + * Each statement must be on a single line. + * + * Detects patterns like: + * Print("text" + + * "more text"); // ERROR! + */ + private checkMultiLineStatements(doc: TextDocument, diags: Diagnostic[]): void { + const text = doc.getText(); + const lines = text.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Skip empty lines and comments + if (!line || line.startsWith('//')) continue; + + // Check if line ends with a continuation operator or unclosed construct + // These indicate a multi-line statement which is not allowed + + // Pattern 1: Line ends with binary operator (excluding comment lines) + // e.g., "text" + or a && or b || + const endsWithOperator = /[+\-*\/&|<>=!,]\s*$/.test(line) && !line.startsWith('//'); + + // Pattern 2: Unclosed parenthesis (more ( than )) + const openParens = (line.match(/\(/g) || []).length; + const closeParens = (line.match(/\)/g) || []).length; + const unclosedParens = openParens > closeParens; + + // Pattern 3: Unclosed brackets + const openBrackets = (line.match(/\[/g) || []).length; + const closeBrackets = (line.match(/\]/g) || []).length; + const unclosedBrackets = openBrackets > closeBrackets; + + // Exclude lines that are valid multi-line constructs + // - Function/class declarations with { at end + // - Control flow with { at end + // - Lines ending with { or ; are fine + const endsWithBraceOrSemi = /[{};]\s*$/.test(line); + const isDeclarationStart = /^(class|enum|if|else|for|while|switch|foreach)\b/.test(line); + + // Report error if this looks like a multi-line statement + if ((endsWithOperator || unclosedParens || unclosedBrackets) && + !endsWithBraceOrSemi && + !isDeclarationStart && + !line.endsWith('{') && + i + 1 < lines.length) { + + // Check if next non-empty line continues this statement + let nextLineIdx = i + 1; + while (nextLineIdx < lines.length && !lines[nextLineIdx].trim()) { + nextLineIdx++; + } + + if (nextLineIdx < lines.length) { + const nextLine = lines[nextLineIdx].trim(); + // If next line doesn't start with { and isn't empty, it's a continuation + if (nextLine && !nextLine.startsWith('{') && !nextLine.startsWith('//')) { + diags.push({ + message: 'Multi-line statements are not supported in Enforce Script. Each statement must be on a single line.', + range: { + start: { line: i, character: 0 }, + end: { line: i, character: lines[i].length } + }, + severity: DiagnosticSeverity.Error + }); + } + } + } + } + } + + /** + * Check for unknown/undefined symbols in the AST + * Generates warnings for: + * - Unknown type names in variable declarations + * - Unknown base classes + * - Unknown function return types + */ + private checkUnknownSymbols(ast: File, diags: Diagnostic[]): void { + // Only truly primitive/language types that aren't defined in any file + // Everything else should come from indexed files in P:\scripts + const primitives = new Set([ + 'void', 'int', 'float', 'bool', 'string', 'vector', 'typename', + 'Class', 'auto', 'array', 'set', 'map', 'ref', 'autoptr', + 'proto', 'private', 'protected', 'static', 'const', 'owned', + 'out', 'inout', 'notnull', 'modded', 'sealed', 'event', 'native' + ]); + + // Require a significant index before flagging unknown types + // This helps avoid false positives during initial indexing + // and for types wrapped in #ifdef that we can't see + const MIN_FILES_FOR_UNKNOWN_TYPE_CHECK = 500; + if (this.docCache.size < MIN_FILES_FOR_UNKNOWN_TYPE_CHECK) { + return; // Not enough files indexed to be confident + } + + // Check if a type exists + const typeExists = (typeName: string): boolean => { + if (!typeName) return true; + if (primitives.has(typeName)) return true; + + // Check for class, enum, or typedef with this name + for (const [uri, fileAst] of this.docCache) { + for (const node of fileAst.body) { + if (node.name === typeName) { + return true; + } + } + } + return false; + }; + + // Check a type node for unknown types + const checkType = (type: TypeNode | undefined): void => { + if (!type) return; + + if (!typeExists(type.identifier)) { diags.push({ - message: `Typedef '${node.name}' is never used`, - range: { start: doc.positionAt(node.start), end: doc.positionAt(node.end) }, - severity: 2 + message: `Unknown type '${type.identifier}'`, + range: { start: type.start, end: type.end }, + severity: DiagnosticSeverity.Warning }); } + + // Check generic arguments too + for (const arg of type.genericArgs || []) { + checkType(arg); + } + }; + + // Walk the AST + for (const node of ast.body) { + // Check class declarations + if (node.kind === 'ClassDecl') { + const classNode = node as ClassDeclNode; + + // Check base class exists + if (classNode.base && !typeExists(classNode.base.identifier)) { + diags.push({ + message: `Unknown base class '${classNode.base.identifier}'`, + range: { start: classNode.base.start, end: classNode.base.end }, + severity: DiagnosticSeverity.Warning + }); + } + + // Check class members + for (const member of classNode.members || []) { + if (member.kind === 'VarDecl') { + checkType((member as VarDeclNode).type); + } else if (member.kind === 'FunctionDecl') { + const func = member as FunctionDeclNode; + checkType(func.returnType); + for (const param of func.parameters || []) { + checkType(param.type); + } + } + } + } + + // Check top-level variable declarations + if (node.kind === 'VarDecl') { + checkType((node as VarDeclNode).type); + } + + // Check top-level function declarations + if (node.kind === 'FunctionDecl') { + const func = node as FunctionDeclNode; + checkType(func.returnType); + for (const param of func.parameters || []) { + checkType(param.type); + } + } } - return diags; } private toSymbolKindName(kind: string): SymbolEntry['kind'] { diff --git a/server/src/lsp/handlers/completion.ts b/server/src/lsp/handlers/completion.ts index 9cace4d..e432a55 100644 --- a/server/src/lsp/handlers/completion.ts +++ b/server/src/lsp/handlers/completion.ts @@ -1,9 +1,33 @@ +/** + * Completion Handler - Enforce Script LSP + * ======================================== + * + * Provides intelligent code completions for Enforce Script. + * + * FEATURES: + * 1. MEMBER COMPLETIONS (after dot) + * - Resolves variable type from parameters, locals, fields + * - Walks inheritance chain for complete method list + * - Example: void Func(PlayerBase p) { p. } → shows PlayerBase methods + * + * 2. STATIC MEMBER COMPLETIONS + * - Detects ClassName.Method() pattern + * - Shows only static members + * + * 3. GLOBAL COMPLETIONS + * - Shows classes, functions, enums, variables + * - Used when not after a dot + * + * @module enscript/server/src/lsp/handlers/completion + */ + import { CompletionItemKind, CompletionItem, CompletionParams, Connection, - TextDocuments + TextDocuments, + InsertTextFormat } from 'vscode-languageserver'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { Analyzer } from '../../analysis/project/graph'; @@ -21,7 +45,11 @@ export function registerCompletion( return items.map(i => ({ label: (i as any).name, - kind: convertKind(i.kind) + kind: convertKind((i as any).kind), + detail: (i as any).detail, + insertText: (i as any).insertText || (i as any).name, + // If it's a function with params, place cursor inside parens + insertTextFormat: InsertTextFormat.PlainText })); }); } @@ -34,6 +62,12 @@ function convertKind(kind: string): CompletionItemKind { return CompletionItemKind.Function; case 'variable': return CompletionItemKind.Variable; + case 'field': + return CompletionItemKind.Field; + case 'enum': + return CompletionItemKind.Enum; + case 'typedef': + return CompletionItemKind.TypeParameter; default: return CompletionItemKind.Text; } From f5067c56483975ed237e62433873d3645f79299c Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Fri, 6 Feb 2026 10:24:38 -0500 Subject: [PATCH 02/46] Add workspace check & indexing notification Add a new command and protocol to check all workspace files and refresh diagnostics after indexing. package.json registers the "enscript.checkWorkspace" command. The language server (server/src/index.ts) now sends an "enscript/indexingComplete" notification after indexing and implements an "enscript/checkWorkspace" request that runs diagnostics across all .c files, publishes diagnostics, and returns summary stats. The client (src/extension.ts) listens for the indexing notification to trigger a refresh of open enscript documents and registers the "enscript.checkWorkspace" command to invoke the server request and show results to the user. --- package.json | 4 ++++ server/src/index.ts | 34 ++++++++++++++++++++++++++++++++++ server/src/util/fs.ts | 4 ++++ src/extension.ts | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+) diff --git a/package.json b/package.json index c75492a..a46bdc3 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,10 @@ { "command": "enscript.dumpDiagnostics", "title": "Enscript: Dump Diagnostics" + }, + { + "command": "enscript.checkWorkspace", + "title": "Enscript: Check All Workspace Files" } ], "configuration": { diff --git a/server/src/index.ts b/server/src/index.ts index c4087e2..c252d35 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -73,6 +73,40 @@ connection.onInitialized(async () => { } console.log('Indexing complete.'); + + // Notify client that indexing is complete - trigger refresh of open files + connection.sendNotification('enscript/indexingComplete', { + fileCount: allFiles.length, + workspaceRoot: workspaceRoot + }); +}); + +// Handle request to check all workspace files +connection.onRequest('enscript/checkWorkspace', async () => { + console.log(`Checking all workspace files in ${workspaceRoot}...`); + + const files = await findAllFiles(workspaceRoot, ['.c']); + const allDiagnostics: Array<{ uri: string; diagnostics: any[] }> = []; + + for (const filePath of files) { + const uri = url.pathToFileURL(filePath).toString(); + const text = await readFileUtf8(filePath); + const doc = TextDocument.create(uri, 'enscript', 1, text); + + const diagnostics = Analyzer.instance().runDiagnostics(doc); + if (diagnostics.length > 0) { + allDiagnostics.push({ uri, diagnostics }); + // Publish diagnostics so they show in Problems panel + connection.sendDiagnostics({ uri, diagnostics }); + } + } + + console.log(`Checked ${files.length} files, found issues in ${allDiagnostics.length} files`); + return { + filesChecked: files.length, + filesWithIssues: allDiagnostics.length, + totalIssues: allDiagnostics.reduce((sum, d) => sum + d.diagnostics.length, 0) + }; }); // Wire all feature handlers. diff --git a/server/src/util/fs.ts b/server/src/util/fs.ts index b4a5aa2..d018740 100644 --- a/server/src/util/fs.ts +++ b/server/src/util/fs.ts @@ -11,6 +11,10 @@ export async function findAllFiles(dir: string, extensions: string[], files: str for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { + // Skip node_modules, .git, and other non-script directories + if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === '.svn') { + continue; + } await findAllFiles(fullPath, extensions, files); } else if (extensions.some(ext => entry.name.endsWith(ext))) { files.push(fullPath); diff --git a/src/extension.ts b/src/extension.ts index 02b1ccf..8ad448d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -38,10 +38,45 @@ export async function activate(context: vscode.ExtensionContext) { ); client.start(); context.subscriptions.push(client); + + // Listen for indexing complete notification - refresh diagnostics on open files + client.onNotification('enscript/indexingComplete', (params: { fileCount: number }) => { + vscode.window.showInformationMessage(`Enscript: Indexed ${params.fileCount} files. Refreshing diagnostics...`); + + // Trigger a re-validation of all open enscript documents + for (const doc of vscode.workspace.textDocuments) { + if (doc.languageId === 'enscript') { + // Force a change event by doing a no-op edit + const edit = new vscode.WorkspaceEdit(); + // Insert and immediately remove an empty string to trigger didChangeContent + edit.insert(doc.uri, new vscode.Position(0, 0), ''); + vscode.workspace.applyEdit(edit); + } + } + }); context.subscriptions.push( vscode.commands.registerCommand('enscript.restartServer', () => client?.restart()) ); + + context.subscriptions.push( + vscode.commands.registerCommand('enscript.checkWorkspace', async () => { + vscode.window.showInformationMessage('Enscript: Checking all workspace files...'); + + const response = await client?.sendRequest('enscript/checkWorkspace') as { + filesChecked: number; + filesWithIssues: number; + totalIssues: number + } | undefined; + + if (response) { + vscode.window.showInformationMessage( + `Enscript: Checked ${response.filesChecked} files. ` + + `Found ${response.totalIssues} issues in ${response.filesWithIssues} files.` + ); + } + }) + ); context.subscriptions.push( vscode.commands.registerCommand('enscript.dumpDiagnostics', async () => { From 7805011c37c38ebe0cc5def94a493b1e3c3aacbd Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Fri, 6 Feb 2026 10:37:13 -0500 Subject: [PATCH 03/46] Improve parsing, type inference, and analyzer Add language and analyzer robustness: extend modifier list (sealed, abstract, final); allow primitive type keywords to act as identifiers in parse; accept additional operators in initialization expressions; handle standalone annotations followed by semicolons; remove noisy console.info debug logs. Enhance completion & type resolution: accept type-keyword tokens for completions, detect chained function calls (e.g., GetGame().GetTime()) and resolve return types across chains, add resolveMethodReturnType and resolveChainReturnType helpers, and improve member lookup behavior. Make scanning and diagnostics more reliable: tighten function-declaration regex, strip comments and string literals when scanning, handle block comments, track scoped variables with accurate line ranges (including class fields and inherited fields), scan function bodies for locals, and avoid matching inside comments/strings. Improve reassignment/assignment/func-assignment detection to handle chains and multi-line safety, and compute better highlight ranges for diagnostics. Improve type compatibility checks: use case-insensitive comparisons for primitives, prefer indexed class-hierarchy checks for upcast/downcast detection, provide clearer downcast messages, and fall back to hardened primitive rules. Also whitelist common generic type parameter names and collect generic params from file to reduce false positives. Overall: fewer false positives, better completions for chained calls, improved scoping and type inference, and cleaner logging. --- server/src/analysis/ast/parser.ts | 40 +- server/src/analysis/project/graph.ts | 923 +++++++++++++++++++++++---- 2 files changed, 818 insertions(+), 145 deletions(-) diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index fac1e25..c17c88c 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -71,7 +71,7 @@ export class ParseError extends Error { } // config tables -const modifiers = new Set(['override', 'proto', 'native', 'modded', 'owned', 'ref', 'reference', 'public', 'private', 'protected', 'static', 'const', 'out', 'inout', 'notnull', 'external', 'volatile', 'local', 'autoptr', 'event']); +const modifiers = new Set(['override', 'proto', 'native', 'modded', 'owned', 'ref', 'reference', 'public', 'private', 'protected', 'static', 'const', 'out', 'inout', 'notnull', 'external', 'volatile', 'local', 'autoptr', 'event', 'sealed', 'abstract', 'final']); const isModifier = (t: Token) => t.kind === TokenKind.Keyword && modifiers.has(t.value); @@ -300,16 +300,6 @@ export function parse( file.body.push(...nodes); } - /* pretty-log for debugging */ - console.info( - `parsed ${file.body.length} top-level symbols from ${doc.uri}` - ); - file.body.forEach((n) => - console.info( - ` • ${n.kind} ${'name' in n ? (n as any).name : ''}` - ) - ); - return file; // declaration parser (recursive) @@ -332,6 +322,12 @@ export function parse( return []; } + // Handle standalone annotations with no declaration: [Obsolete("...")]; + if (annotations.length > 0 && peek().value === ';') { + next(); // consume the semicolon + return []; + } + const t = peek(); // class @@ -628,7 +624,7 @@ export function parse( next(); } else if (curTok.value !== '?' && curTok.value !== ':' && curTok.kind !== TokenKind.Keyword && curTok.kind !== TokenKind.Identifier && curTok.kind !== TokenKind.Number && - curTok.kind !== TokenKind.String && curTok.value !== '.' && curTok.value !== '+' && curTok.value !== '-' && curTok.value !== '*' && curTok.value !== '/' && curTok.value !== '|' && curTok.value !== '&' && curTok.value !== '%') { + curTok.kind !== TokenKind.String && curTok.value !== '.' && curTok.value !== '+' && curTok.value !== '-' && curTok.value !== '*' && curTok.value !== '/' && curTok.value !== '|' && curTok.value !== '&' && curTok.value !== '%' && curTok.value !== '~' && curTok.value !== '!' && curTok.value !== '^' && curTok.value !== '<<' && curTok.value !== '>>' && curTok.value !== '==' && curTok.value !== '!=' && curTok.value !== '<=' && curTok.value !== '>=' && curTok.value !== '<' && curTok.value !== '>') { throwErr(curTok, "initialization expression"); } } @@ -842,10 +838,17 @@ export function parse( // OPERATOR OVERLOAD: operator==, operator<, operator[], etc. // Handle 'operator' keyword followed by operator symbol(s) + // NOTE: 'operator' is also used as a regular variable/parameter name + // in DayZ scripts (e.g., `int operator`), so we must only match + // actual operator symbols, not delimiters like ) , ; { } if (t.kind === TokenKind.Identifier && t.value === 'operator') { const opTok = peek(); - // Accept various operator tokens: ==, !=, <, >, <=, >=, [], etc. - if (opTok.kind === TokenKind.Operator || opTok.kind === TokenKind.Punctuation) { + const validOpOverloads = new Set([ + '==', '!=', '<=', '>=', '<<', '>>', + '<', '>', '+', '-', '*', '/', '%', + '&', '|', '^', '~', '!', '[', + ]); + if (validOpOverloads.has(opTok.value)) { const op = next(); let opName = op.value; @@ -864,7 +867,14 @@ export function parse( } } - if (t.kind !== TokenKind.Identifier) throwErr(t, 'identifier'); + if (t.kind !== TokenKind.Identifier) { + // Allow type-keywords as identifiers (e.g., class string, class int) + // These are valid class/variable names in Enforce Script (defined in enconvert.c, enstring.c) + if (t.kind === TokenKind.Keyword && isPrimitiveType(t.value)) { + return { ...t, kind: TokenKind.Identifier }; + } + throwErr(t, 'identifier'); + } return t; } diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 1cda12c..131b83a 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -273,7 +273,6 @@ export class Analyzer { const hasParens = !!dotMatch[2]; // true if it's a function call like GetGame() const prefix = dotMatch[3] || ''; - console.info(`Completion: matched "${name}" hasParens=${hasParens} prefix="${prefix}"`); // Handle 'this' keyword if (name === 'this') { @@ -304,7 +303,9 @@ export class Analyzer { if (varType) { // Get methods/fields for this type (including inherited) - return this.getClassMemberCompletions(varType, prefix); + const members = this.getClassMemberCompletions(varType, prefix); + return members; + } else { } // If name looks like a class name (starts with uppercase), @@ -440,12 +441,10 @@ export class Analyzer { }; private resolveVariableType(doc: TextDocument, pos: Position, varName: string): string | null { - console.info(`resolveVariableType: looking for "${varName}"`); // Check for known variable type overrides first const knownType = Analyzer.KNOWN_VARIABLE_TYPES[varName]; if (knownType) { - console.info(` Using known variable type for ${varName} -> ${knownType}`); return knownType; } @@ -459,7 +458,6 @@ export class Analyzer { // Example: void SomeFunc(PlayerBase p) { p. } → type is "PlayerBase" for (const param of containingFunc.parameters || []) { if (param.name === varName) { - console.info(` Found param ${varName} -> ${param.type?.identifier}`); return param.type?.identifier || null; } } @@ -467,7 +465,6 @@ export class Analyzer { // Check local variables for (const local of containingFunc.locals || []) { if (local.name === varName) { - console.info(` Found local ${varName} -> ${local.type?.identifier}`); return local.type?.identifier || null; } } @@ -481,7 +478,6 @@ export class Analyzer { for (const classNode of classHierarchy) { for (const member of classNode.members || []) { if (member.kind === 'VarDecl' && member.name === varName) { - console.info(` Found field ${varName} in ${classNode.name} -> ${(member as VarDeclNode).type?.identifier}`); return (member as VarDeclNode).type?.identifier || null; } } @@ -492,7 +488,6 @@ export class Analyzer { for (const [uri, fileAst] of this.docCache) { for (const node of fileAst.body) { if (node.kind === 'VarDecl' && node.name === varName) { - console.info(` Found global ${varName} -> ${(node as VarDeclNode).type?.identifier}`); return (node as VarDeclNode).type?.identifier || null; } } @@ -504,14 +499,12 @@ export class Analyzer { // Pattern: Type varName; or Type varName = const varDeclMatch = text.match(new RegExp(`(\\w+)\\s+${varName}\\s*[;=]`)); if (varDeclMatch) { - console.info(` Found via regex ${varName} -> ${varDeclMatch[1]}`); return varDeclMatch[1]; } // Pattern: (Type varName) or (Type varName,) - function parameters const paramMatch = text.match(new RegExp(`[,(]\\s*(\\w+)\\s+${varName}\\s*[,)]`)); if (paramMatch) { - console.info(` Found param via regex ${varName} -> ${paramMatch[1]}`); return paramMatch[1]; } @@ -540,12 +533,10 @@ export class Analyzer { * Searches top-level functions and class methods across all indexed files */ private resolveFunctionReturnType(funcName: string): string | null { - console.info(`resolveFunctionReturnType: looking for "${funcName}" in ${this.docCache.size} indexed files`); // Check for known overrides first (e.g., GetGame() returns CGame, not Game) const knownType = Analyzer.KNOWN_RETURN_TYPES[funcName]; if (knownType) { - console.info(` Using known return type for ${funcName} -> ${knownType}`); return knownType; } @@ -556,10 +547,8 @@ export class Analyzer { if (node.kind === 'FunctionDecl' && node.name === funcName) { const func = node as FunctionDeclNode; const returnType = func.returnType?.identifier; - console.info(` Found top-level function ${funcName} -> ${returnType}`); - // Skip void functions - if (returnType && returnType !== 'void') { - return returnType; + if (returnType) { + return returnType; // Return 'void' too - we want to detect void assignments } } @@ -569,9 +558,8 @@ export class Analyzer { if (member.kind === 'FunctionDecl' && member.name === funcName) { const func = member as FunctionDeclNode; const returnType = func.returnType?.identifier; - console.info(` Found ${funcName} in class ${node.name} -> ${returnType}`); - if (returnType && returnType !== 'void') { - return returnType; + if (returnType) { + return returnType; // Return 'void' too } } } @@ -579,10 +567,126 @@ export class Analyzer { } } - console.info(` ${funcName} not found in ${this.docCache.size} cached documents`); return null; } + /** + * Resolve the return type of a method within a specific class hierarchy + * @param className The class to search in (and its parent classes) + * @param methodName The method name to find + */ + private resolveMethodReturnType(className: string, methodName: string): string | null { + const visited = new Set(); + const classesToSearch = this.getClassHierarchyOrdered(className, visited); + + for (const classNode of classesToSearch) { + for (const member of classNode.members || []) { + if (member.kind === 'FunctionDecl' && member.name === methodName) { + const func = member as FunctionDeclNode; + const returnType = func.returnType?.identifier; + if (returnType) { + return returnType; // Return 'void' too - we want to detect void assignments + } + } + } + } + + return null; + } + + /** + * Resolve the final return type of a method chain like "U().Msg().SetMeta(...)" + * Parses the chain and follows each call to determine the final return type. + * @param chainText The full chain text starting from the first function + * @returns The return type of the final call in the chain, or null if unresolved + */ + private resolveChainReturnType(chainText: string): string | null { + // Parse the chain: extract each function/method call in sequence + // Examples: + // "U().Msg().SetMeta(foo, bar)" -> ["U", "Msg", "SetMeta"] + // "GetGame().GetTime()" -> ["GetGame", "GetTime"] + + const calls: string[] = []; + let remaining = chainText.trim(); + + // First call: funcName(...) + const firstMatch = remaining.match(/^(\w+)\s*\(/); + if (!firstMatch) { + return null; + } + calls.push(firstMatch[1]); + + // Skip past the first call's arguments + remaining = remaining.substring(firstMatch[0].length); + let parenDepth = 1; + let i = 0; + + while (i < remaining.length && parenDepth > 0) { + if (remaining[i] === '(') parenDepth++; + else if (remaining[i] === ')') parenDepth--; + i++; + } + + remaining = remaining.substring(i).trim(); + + // Continue parsing chained calls: .methodName(...) + while (remaining.startsWith('.')) { + remaining = remaining.substring(1).trim(); + + const methodMatch = remaining.match(/^(\w+)\s*\(/); + if (!methodMatch) { + // Might be a property access, not a method call + const propMatch = remaining.match(/^(\w+)/); + if (propMatch) { + calls.push(propMatch[1]); + } + break; + } + + calls.push(methodMatch[1]); + + // Skip past this call's arguments + remaining = remaining.substring(methodMatch[0].length); + parenDepth = 1; + i = 0; + + while (i < remaining.length && parenDepth > 0) { + if (remaining[i] === '(') parenDepth++; + else if (remaining[i] === ')') parenDepth--; + i++; + } + + remaining = remaining.substring(i).trim(); + } + + if (calls.length === 0) { + return null; + } + + + // Resolve the chain step by step + // First call is a function (global or static) + let currentType = this.resolveFunctionReturnType(calls[0]); + if (!currentType) { + return null; + } + + + // Each subsequent call is a method on the current type + for (let j = 1; j < calls.length; j++) { + const methodName = calls[j]; + const nextType = this.resolveMethodReturnType(currentType, methodName); + + if (!nextType) { + return null; + } + + currentType = nextType; + } + + return currentType; + } + /** * Find the function containing the given position */ @@ -639,12 +743,10 @@ export class Analyzer { const results: CompletionResult[] = []; const seen = new Set(); // Deduplicate by name - console.info(`getClassMemberCompletions: "${className}" prefix="${prefix}"`); // Get the complete class hierarchy including modded classes const classHierarchy = this.getClassHierarchyOrdered(className, new Set()); - console.info(` Class hierarchy: [${classHierarchy.map(c => c.name).join(', ')}]`); for (const classNode of classHierarchy) { for (const member of classNode.members || []) { @@ -797,16 +899,28 @@ export class Analyzer { const text = doc.getText(); const token = getTokenAtPosition(text, offset); - if (!token || token.kind !== TokenKind.Identifier) return []; + if (!token) return []; + + // Allow identifiers AND type-keywords (string, int, float, bool, vector, typename) + // These are keywords in the lexer but also real classes defined in enconvert.c / enstring.c + const typeKeywords = new Set(['string', 'int', 'float', 'bool', 'vector', 'typename', 'void']); + if (token.kind !== TokenKind.Identifier && + !(token.kind === TokenKind.Keyword && typeKeywords.has(token.value))) { + return []; + } const name = token.value; - console.info(`resolveDefinitions: "${name}"`); - // Check if this is a member access (e.g., player.GetInputType) + // Check if this is a member access (e.g., player.GetInputType or GetGame().GetTime()) // Look backwards from the token start to find a dot const textBeforeToken = text.substring(0, token.start); + + // Pattern 1: variable.method (e.g., player.GetInputType) const memberMatch = textBeforeToken.match(/(\w+)\s*\.\s*$/); + // Pattern 2: functionCall().method (e.g., GetGame().GetTime()) + const chainedCallMatch = textBeforeToken.match(/(\w+)\s*\([^)]*\)\s*\.\s*$/); + if (memberMatch) { // MEMBER ACCESS: Resolve the variable type and search only that class hierarchy const varName = memberMatch[1]; @@ -828,6 +942,19 @@ export class Analyzer { } } + if (chainedCallMatch) { + // CHAINED CALL: Resolve the return type of the function call + const funcName = chainedCallMatch[1]; + const returnType = this.resolveFunctionReturnType(funcName); + + if (returnType) { + const classMatches = this.findMemberInClassHierarchy(returnType, name); + if (classMatches.length > 0) { + return classMatches; + } + } + } + // Check if we're inside a class - prioritize current class and inheritance const ast = this.ensure(doc); const containingClass = this.findContainingClass(ast, _pos); @@ -981,14 +1108,12 @@ export class Analyzer { for (const node of ast.body) { if (node.kind === 'ClassDecl' && node.name === className) { const classNode = node as ClassDeclNode; - console.info(` findAllClassesByName("${className}"): found in ${uri} (modded=${classNode.modifiers?.includes('modded')}, members=${classNode.members?.length || 0})`); matches.push(classNode); } } } if (matches.length === 0) { - console.info(` findAllClassesByName("${className}"): NO MATCHES FOUND`); } return matches; @@ -1227,7 +1352,9 @@ export class Analyzer { const varDeclPattern = /\b(int|float|bool|string|auto|vector|Man|PlayerBase|\w+)\s+(\w+)\s*(?:=|;|,|\)|<)/g; // Pattern to detect function declarations - const funcDeclPattern = /\b(void|int|float|bool|string|auto|override\s+\w+|\w+)\s+(\w+)\s*\([^)]*\)\s*\{?\s*$/; + // Must have: optional modifiers, return type (including generics), function name, parentheses for params + // Excludes: array access like m_Foo[0] and assignments + const funcDeclPattern = /^\s*(?:static\s+|private\s+|protected\s+|override\s+|proto\s+|native\s+)*(?:void|int|float|bool|string|auto|ref\s+\w+|[\w<>,\s]+)\s+(\w+)\s*\([^)]*\)\s*\{?\s*$/; // Pattern to detect for/foreach/while loops (their vars go to parent scope in Enforce) const loopPattern = /\b(for|foreach|while)\s*\(/; @@ -1245,23 +1372,80 @@ export class Analyzer { let inClass = false; let classBraceDepth = 0; + // Track block comments + let inBlockComment = false; + // Process line by line to track scope const lines = text.split('\n'); for (let lineNum = 0; lineNum < lines.length; lineNum++) { const line = lines[lineNum]; + const trimmedLine = line.trim(); + + // Handle block comments /* ... */ + if (inBlockComment) { + if (line.includes('*/')) { + inBlockComment = false; + } + continue; // Skip lines inside block comments + } + + // Check for block comment start + if (trimmedLine.startsWith('/*') || trimmedLine.startsWith('/**')) { + if (line.includes('*/')) { + // Single-line block comment like /* foo */ - skip entire line + continue; + } else { + // Multi-line block comment starts here + inBlockComment = true; + continue; + } + } + + // Skip lines that are just block comment content + if (trimmedLine.startsWith('*') && !trimmedLine.startsWith('*/')) { + continue; + } + + // Skip lines containing doc comment markers (they may contain signature examples) + if (trimmedLine.includes('@param') || trimmedLine.includes('@note') || + trimmedLine.includes('@return') || trimmedLine.includes('@usage') || + trimmedLine.includes('@example') || trimmedLine.includes('@code')) { + continue; + } + + // Strip trailing comments for pattern matching + // Use indexOf for reliability + const commentIdx = line.indexOf('//'); + let lineNoComment = (commentIdx >= 0 ? line.substring(0, commentIdx) : line); + + // Also strip inline block comments like: code /* comment */ more code + lineNoComment = lineNoComment.replace(/\/\*.*?\*\//g, ''); + + // Strip string literals to avoid detecting patterns inside strings + // e.g., "string cbFunction" in debug messages + lineNoComment = lineNoComment.replace(/"(?:[^"\\]|\\.)*"/g, '""'); // Replace "..." with "" + lineNoComment = lineNoComment.replace(/'(?:[^'\\]|\\.)*'/g, "''"); // Replace '...' with '' + + lineNoComment = lineNoComment.trim(); // Check if this line starts a class - if (classDeclPattern.test(line.trim()) && !inClass) { + if (classDeclPattern.test(lineNoComment)) { + // Starting a new class - reset all class-related state inClass = true; classBraceDepth = braceDepth; classFieldScope = new Map(); // Fresh class field scope + // Reset scope stack to just global scope + scopeStack = [new Map()]; + inFunction = false; } // Check if this line starts a new function - if (funcDeclPattern.test(line.trim())) { - // New function - reset to: global scope + class fields + new function scope - scopeStack = [scopeStack[0], classFieldScope, new Map()]; + const isFuncDecl = funcDeclPattern.test(lineNoComment); + if (isFuncDecl) { + // New function - reset to: global scope + class fields (copy) + new function scope + // We must copy classFieldScope to avoid it being modified by function-local variables + scopeStack = [scopeStack[0], new Map(classFieldScope), new Map()]; inFunction = true; functionBraceDepth = braceDepth; } @@ -1273,10 +1457,13 @@ export class Analyzer { // FIRST: Find variable declarations on this line BEFORE processing braces // This ensures for loop variables (int j in "for (int j = 0...") // are added to the current scope before we push a new scope for { + // Use lineNoComment to avoid matching variables in comments let match; varDeclPattern.lastIndex = 0; - while ((match = varDeclPattern.exec(line)) !== null) { + // Use lineNoComment but keep original line for position calculation + const lineForVars = lineNoComment; + while ((match = varDeclPattern.exec(lineForVars)) !== null) { const typeName = match[1]; const varName = match[2]; @@ -1292,7 +1479,8 @@ export class Analyzer { // Check all scopes in the stack for existing declaration let foundDuplicate = false; - for (const scope of scopeStack) { + for (let si = 0; si < scopeStack.length; si++) { + const scope = scopeStack[si]; const existing = scope.get(varName); if (existing) { const startPos = { line: lineNum, character: match.index + match[1].length + 1 }; @@ -1379,23 +1567,11 @@ export class Analyzer { return { compatible: true, isDowncast: false, isUpcast: false }; } - // Primitive type compatibility - const numericTypes = new Set(['int', 'float', 'bool']); - if (numericTypes.has(declNorm) && numericTypes.has(assignNorm)) { - // int/float/bool are compatible with each other (implicit conversion) - return { compatible: true, isDowncast: false, isUpcast: false }; - } + // Case-insensitive comparison for primitive names (e.g. String vs string) + const declLower = declNorm.toLowerCase(); + const assignLower = assignNorm.toLowerCase(); - // string is only compatible with string - if (declNorm === 'string' || assignNorm === 'string') { - if (declNorm !== assignNorm) { - return { - compatible: false, - isDowncast: false, - isUpcast: false, - message: `Cannot convert '${assignNorm}' to 'string'` - }; - } + if (declLower === assignLower) { return { compatible: true, isDowncast: false, isUpcast: false }; } @@ -1409,7 +1585,7 @@ export class Analyzer { }; } - // auto/typename/Class are wildcards + // auto/typename/Class are wildcards - always compatible if (declNorm === 'auto' || assignNorm === 'auto' || declNorm === 'typename' || assignNorm === 'typename' || declNorm === 'Class' || assignNorm === 'Class') { @@ -1422,9 +1598,11 @@ export class Analyzer { return { compatible: bothArrays, isDowncast: false, isUpcast: false }; } - // Check class hierarchy - // UPCAST: Assigning derived (child) to base (parent) - ALWAYS SAFE - // Example: Man m = playerBase; where PlayerBase extends Man + // --- TRY INDEXED CLASS HIERARCHY FIRST --- + // If types are indexed as classes (including primitives like string, int from enconvert.c), + // use the hierarchy to determine compatibility before falling back to hardcoded rules. + + // Check class hierarchy for UPCAST const assignedHierarchy = this.getClassHierarchyOrdered(assignNorm, new Set()); for (const classNode of assignedHierarchy) { if (classNode.name === declNorm) { @@ -1433,16 +1611,12 @@ export class Analyzer { } } - // DOWNCAST: Assigning base (parent) to derived (child) - REQUIRES CAST - // Example: PlayerBase p = man; where Man is parent of PlayerBase - // Should use: PlayerBase p = PlayerBase.Cast(man); - // Or: Class.CastTo(p, man); + // Check class hierarchy for DOWNCAST const declaredHierarchy = this.getClassHierarchyOrdered(declNorm, new Set()); for (const classNode of declaredHierarchy) { if (classNode.name === assignNorm) { - // assignedType is a parent of declaredType - this is a downcast (risky) return { - compatible: true, // Technically compiles but risky + compatible: true, isDowncast: true, isUpcast: false, message: `Unsafe downcast from '${assignNorm}' to '${declNorm}'. Use '${declNorm}.Cast(value)' or 'Class.CastTo(target, value)' instead.` @@ -1450,11 +1624,32 @@ export class Analyzer { } } - // Check primitive types BEFORE checking if classes exist - // Primitives won't be found as classes, so we need to handle them first - const primitiveTypes = new Set(['int', 'float', 'bool', 'string', 'void', 'vector']); - const declIsPrimitive = primitiveTypes.has(declNorm); - const assignIsPrimitive = primitiveTypes.has(assignNorm); + // --- FALLBACK: hardcoded primitive compatibility --- + // Only used if types aren't found in the indexed class hierarchy + + // Numeric types are compatible with each other (implicit conversion) + const numericTypes = new Set(['int', 'float', 'bool']); + if (numericTypes.has(declLower) && numericTypes.has(assignLower)) { + return { compatible: true, isDowncast: false, isUpcast: false }; + } + + // Hardcoded primitives - used as fallback when not indexed + const hardcodedPrimitives = new Set(['int', 'float', 'bool', 'string', 'void', 'vector']); + const declIsPrimitive = hardcodedPrimitives.has(declLower); + const assignIsPrimitive = hardcodedPrimitives.has(assignLower); + + // string is only compatible with string + if (declLower === 'string' || assignLower === 'string') { + if (declLower !== assignLower) { + return { + compatible: false, + isDowncast: false, + isUpcast: false, + message: `Cannot convert '${assignNorm}' to '${declNorm}'` + }; + } + return { compatible: true, isDowncast: false, isUpcast: false }; + } // Primitive vs class (or vice versa) is never compatible if (declIsPrimitive !== assignIsPrimitive) { @@ -1504,34 +1699,129 @@ export class Analyzer { const text = doc.getText(); const ast = this.ensure(doc); - // Build a map of variable names to their types from the AST - const variableTypes = new Map(); + // Scoped variable tracking - each variable knows its valid line range + interface ScopedVar { + type: string; + startLine: number; + endLine: number; // -1 means class field (valid everywhere in class) + isClassField: boolean; + } + + // Map of variable name -> array of scoped declarations + const scopedVars = new Map(); + + // Helper to add a scoped variable + const addScopedVar = (name: string, type: string, startLine: number, endLine: number, isClassField: boolean) => { + if (!scopedVars.has(name)) { + scopedVars.set(name, []); + } + scopedVars.get(name)!.push({ type, startLine, endLine, isClassField }); + }; + + // Helper to get the type of a variable at a specific line + const getVarTypeAtLine = (name: string, line: number): string | undefined => { + const vars = scopedVars.get(name); + if (!vars) return undefined; + + // Find the most specific scope that contains this line + // Priority: 1) local vars in range, 2) class fields in range + let bestMatch: ScopedVar | undefined; + + for (const v of vars) { + // ALL variables (including class fields) must have the line within their scope range + if (line < v.startLine || line > v.endLine) { + continue; + } + + if (v.isClassField) { + // Class field in range - prefer smaller (more specific) scope + if (!bestMatch || (bestMatch.isClassField && + (v.endLine - v.startLine) < (bestMatch.endLine - bestMatch.startLine))) { + bestMatch = v; + } + } else { + // Local variable in range - prefer over class fields + // and prefer smaller (more specific) ranges + if (!bestMatch || bestMatch.isClassField || + (v.endLine - v.startLine) < (bestMatch.endLine - bestMatch.startLine)) { + bestMatch = v; + } + } + } + + return bestMatch?.type; + }; - // Collect types from all declarations in this file - const collectVarTypes = (nodes: any[], scope?: string) => { + // Collect types from all declarations in this file with proper scoping + const collectVarTypes = (nodes: any[], classStartLine?: number, classEndLine?: number) => { for (const node of nodes) { + // Top-level var declarations (globals) if (node.kind === 'VarDecl' && node.name && node.type?.identifier) { - variableTypes.set(node.name, node.type.identifier); + const startLine = node.start?.line ?? 0; + addScopedVar(node.name, node.type.identifier, startLine, Number.MAX_SAFE_INTEGER, false); } + if (node.kind === 'FunctionDecl') { - // Collect parameters + const funcStart = node.start?.line ?? 0; + const funcEnd = node.end?.line ?? Number.MAX_SAFE_INTEGER; + + // Collect parameters - scoped to this function for (const param of node.parameters || []) { if (param.name && param.type?.identifier) { - variableTypes.set(param.name, param.type.identifier); + addScopedVar(param.name, param.type.identifier, funcStart, funcEnd, false); } } - // Collect locals + // Collect locals - scoped to this function for (const local of node.locals || []) { if (local.name && local.type?.identifier) { - variableTypes.set(local.name, local.type.identifier); + const localStart = local.start?.line ?? funcStart; + addScopedVar(local.name, local.type.identifier, localStart, funcEnd, false); } } } + if (node.kind === 'ClassDecl') { - // Collect class fields + const clsStart = node.start?.line ?? 0; + const clsEnd = node.end?.line ?? Number.MAX_SAFE_INTEGER; + + // Collect class fields - they're accessible anywhere in the class for (const member of node.members || []) { if (member.kind === 'VarDecl' && member.name && member.type?.identifier) { - variableTypes.set(member.name, member.type.identifier); + addScopedVar(member.name, member.type.identifier, clsStart, clsEnd, true); + } + // Also process methods within the class + if (member.kind === 'FunctionDecl') { + const funcStart = member.start?.line ?? clsStart; + const funcEnd = member.end?.line ?? clsEnd; + + for (const param of member.parameters || []) { + if (param.name && param.type?.identifier) { + addScopedVar(param.name, param.type.identifier, funcStart, funcEnd, false); + } + } + for (const local of member.locals || []) { + if (local.name && local.type?.identifier) { + const localStart = local.start?.line ?? funcStart; + addScopedVar(local.name, local.type.identifier, localStart, funcEnd, false); + } + } + } + } + + // Also collect inherited fields from parent classes + // This ensures fields from base classes are accessible in derived classes + if (node.base?.identifier) { + const parentClasses = this.getClassHierarchyOrdered(node.base.identifier, new Set()); + for (const parentClass of parentClasses) { + for (const member of parentClass.members || []) { + if (member.kind === 'VarDecl' && member.name) { + const varMember = member as VarDeclNode; + if (varMember.type?.identifier) { + // Add inherited fields with the child class's scope + addScopedVar(member.name, varMember.type.identifier, clsStart, clsEnd, true); + } + } + } } } } @@ -1540,28 +1830,200 @@ export class Analyzer { collectVarTypes(ast.body); - // Also scan the raw text for variable declarations to catch any the AST missed - // Pattern: Type varName; or Type varName = ... - const varDeclScanPattern = /\b(\w+)\s+(\w+)\s*(?:=|;)/g; - let scanMatch; - while ((scanMatch = varDeclScanPattern.exec(text)) !== null) { - const typeName = scanMatch[1]; - const varName = scanMatch[2]; - // Skip keywords - if (!['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', 'else', 'void', 'override', 'static', 'private', 'protected', 'const', 'ref', 'autoptr'].includes(typeName)) { - // Only add if not already in map (AST takes precedence) - if (!variableTypes.has(varName)) { - variableTypes.set(varName, typeName); + // The parser doesn't parse function bodies (locals is always []), + // so we need to scan for local variable declarations using regex. + // We scan the text line-by-line, tracking which function scope we're in. + { + const lines = text.split('\n'); + let inBlockComment = false; + + // For each function in the AST, scan its body for local declarations + for (const node of ast.body) { + if (node.kind === 'ClassDecl') { + const classNode = node as ClassDeclNode; + for (const member of classNode.members || []) { + if (member.kind === 'FunctionDecl') { + const func = member as FunctionDeclNode; + const funcStart = func.start?.line ?? 0; + const funcEnd = func.end?.line ?? 0; + if (funcEnd <= funcStart) continue; + + // Scan lines within this function for variable declarations + for (let lineIdx = funcStart; lineIdx <= funcEnd && lineIdx < lines.length; lineIdx++) { + let line = lines[lineIdx]; + + // Handle block comments + if (inBlockComment) { + if (line.includes('*/')) inBlockComment = false; + continue; + } + if (line.trimStart().startsWith('/*')) { + if (!line.includes('*/')) inBlockComment = true; + continue; + } + + // Strip comments and strings + const commentIdx = line.indexOf('//'); + if (commentIdx >= 0) line = line.substring(0, commentIdx); + line = line.replace(/"(?:[^"\\]|\\.)*"/g, '""'); + line = line.replace(/'(?:[^'\\]|\\.)*'/g, "''"); + line = line.trim(); + + // Skip empty, control flow, return, etc. + if (!line) continue; + + // Match: Type varName; or Type varName = ...; + // Must start with a type (capitalized or known primitive) + // Exclude: keywords, function calls, return statements + const localDeclPattern = /\b([A-Z]\w+|int|float|bool|string|auto|vector|ref|autoptr)\s+(\w+)\s*(?:[=;,])/g; + let m; + while ((m = localDeclPattern.exec(line)) !== null) { + const typeName = m[1]; + const varName = m[2]; + + // Skip if type is a keyword/modifier + if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', + 'else', 'case', 'override', 'static', 'private', 'protected', 'ref', 'autoptr', + 'const', 'proto', 'native', 'Print', 'foreach'].includes(typeName)) { + continue; + } + // Skip if varName is a keyword + if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'true', 'false', 'null'].includes(varName)) { + continue; + } + + addScopedVar(varName, typeName, lineIdx, funcEnd, false); + } + } + } + } + } + // Also scan top-level functions + if (node.kind === 'FunctionDecl') { + const func = node as FunctionDeclNode; + const funcStart = func.start?.line ?? 0; + const funcEnd = func.end?.line ?? 0; + if (funcEnd <= funcStart) continue; + + for (let lineIdx = funcStart; lineIdx <= funcEnd && lineIdx < lines.length; lineIdx++) { + let line = lines[lineIdx]; + + if (inBlockComment) { + if (line.includes('*/')) inBlockComment = false; + continue; + } + if (line.trimStart().startsWith('/*')) { + if (!line.includes('*/')) inBlockComment = true; + continue; + } + + const commentIdx = line.indexOf('//'); + if (commentIdx >= 0) line = line.substring(0, commentIdx); + line = line.replace(/"(?:[^"\\]|\\.)*"/g, '""'); + line = line.replace(/'(?:[^'\\]|\\.)*'/g, "''"); + line = line.trim(); + + if (!line) continue; + + const localDeclPattern = /\b([A-Z]\w+|int|float|bool|string|auto|vector|ref|autoptr)\s+(\w+)\s*(?:[=;,])/g; + let m; + while ((m = localDeclPattern.exec(line)) !== null) { + const typeName = m[1]; + const varName = m[2]; + + if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', + 'else', 'case', 'override', 'static', 'private', 'protected', 'ref', 'autoptr', + 'const', 'proto', 'native', 'Print', 'foreach'].includes(typeName)) { + continue; + } + if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'true', 'false', 'null'].includes(varName)) { + continue; + } + + addScopedVar(varName, typeName, lineIdx, funcEnd, false); + } + } } } } + // Helper to check if a position is inside a comment or string + const isInsideCommentOrString = (position: number): boolean => { + // Check single-line comments + let lineStart = text.lastIndexOf('\n', position) + 1; + let lineEnd = text.indexOf('\n', position); + if (lineEnd === -1) lineEnd = text.length; + const line = text.substring(lineStart, lineEnd); + const posInLine = position - lineStart; + + // Check if there's a // before this position on the same line + const commentIdx = line.indexOf('//'); + if (commentIdx >= 0 && commentIdx < posInLine) { + return true; + } + + // Check block comments - scan backwards for /* that isn't closed + let i = position - 1; + while (i >= 0) { + if (i > 0 && text[i-1] === '*' && text[i] === '/') { + // Found end of block comment, we're outside + break; + } + if (i > 0 && text[i-1] === '/' && text[i] === '*') { + // Found start of block comment, we're inside + return true; + } + i--; + } + + // Check strings - count unescaped quotes before position on same line + let inString = false; + let stringChar = ''; + for (let j = 0; j < posInLine; j++) { + const ch = line[j]; + if (!inString && (ch === '"' || ch === "'")) { + inString = true; + stringChar = ch; + } else if (inString && ch === stringChar && (j === 0 || line[j-1] !== '\\')) { + inString = false; + } + } + return inString; + }; + + // For variable type scanning, we can still use stripped text since we just need types + const textForScanning = text + .replace(/\/\/.*$/gm, '') // Remove single-line comments + .replace(/\/\*[\s\S]*?\*\//g, '') // Remove multi-line comments + .replace(/"(?:[^"\\]|\\.)*"/g, '""') // Replace "..." with "" + .replace(/'(?:[^'\\]|\\.)*'/g, "''"); // Replace '...' with '' + + // Helper to get line number from character position + const getLineFromPos = (pos: number): number => { + let line = 0; + for (let i = 0; i < pos && i < text.length; i++) { + if (text[i] === '\n') line++; + } + return line; + }; + // Pattern 1: Type varName = FunctionCall(); // e.g., int i = GetGame(); - const funcAssignPattern = /\b(\w+)\s+(\w+)\s*=\s*(\w+)\s*\(/g; + // Use [ \t]+ between type and varName to prevent matching across line breaks + const funcAssignPattern = /\b(\w+)[ \t]+(\w+)\s*=\s*(\w+)\s*\(/g; let match; while ((match = funcAssignPattern.exec(text)) !== null) { + // Skip if inside comment or string + if (isInsideCommentOrString(match.index)) { + continue; + } + + // Skip if the match spans multiple lines (regex \s* can cross newlines) + if (match[0].includes('\n')) { + continue; + } + const declaredType = match[1]; const varName = match[2]; const funcName = match[3]; @@ -1571,23 +2033,94 @@ export class Analyzer { continue; } - // Get the return type of the function - const returnType = this.resolveFunctionReturnType(funcName); + // Check if this is a method chain like GetGame().GetTime() + // Look at what comes after this match + const afterMatch = text.substring(match.index + match[0].length); + // Find the closing paren and see if there's a dot after + let parenDepth = 1; + let chainDetected = false; + for (let i = 0; i < afterMatch.length && parenDepth > 0; i++) { + if (afterMatch[i] === '(') parenDepth++; + else if (afterMatch[i] === ')') parenDepth--; + if (parenDepth === 0) { + // Check if there's a dot after the closing paren + const remainder = afterMatch.substring(i + 1).trim(); + if (remainder.startsWith('.')) { + chainDetected = true; + } + break; + } + } + + // Resolve return type - either single function or full chain + let returnType: string | null; + let highlightLength = match[0].length; // Default to just the match + + if (chainDetected) { + // Find the end of the full statement (semicolon or closing paren+semicolon) + // Much simpler: just find where the statement ends + const stmtEnd = afterMatch.indexOf(';'); + let chainEnd = stmtEnd >= 0 ? stmtEnd : afterMatch.length; + + // For highlighting, we want to include up to but not including the semicolon + // But we need to find where the actual chain ends (last closing paren before semicolon) + let lastParen = chainEnd; + for (let i = chainEnd - 1; i >= 0; i--) { + if (afterMatch[i] === ')') { + lastParen = i + 1; + break; + } + } + chainEnd = lastParen; + + const fullChainText = funcName + '(' + afterMatch.substring(0, chainEnd); + returnType = this.resolveChainReturnType(fullChainText); + highlightLength = match[0].length + chainEnd; + } else { + // Get the return type of the single function + // Need to find where single function call ends + let singleEnd = 0; + let depth = 1; + for (let i = 0; i < afterMatch.length && depth > 0; i++) { + if (afterMatch[i] === '(') depth++; + else if (afterMatch[i] === ')') depth--; + if (depth === 0) { + singleEnd = i + 1; + break; + } + } + returnType = this.resolveFunctionReturnType(funcName); + highlightLength = match[0].length + singleEnd; + } if (returnType) { - this.addTypeMismatchDiagnostic(doc, diags, match.index, match[0].length, declaredType, returnType); + this.addTypeMismatchDiagnostic(doc, diags, match.index, highlightLength, declaredType, returnType); } } // Pattern 2: Type varName = otherVar; // e.g., int i = p; where p is PlayerBase - const varDeclAssignPattern = /\b(\w+)\s+(\w+)\s*=\s*(\w+)\s*;/g; + // Use [ \t]+ between type and varName to prevent matching across line breaks + const varDeclAssignPattern = /\b(\w+)[ \t]+(\w+)\s*=\s*(\w+)\s*;/g; while ((match = varDeclAssignPattern.exec(text)) !== null) { + // Skip if inside comment or string + if (isInsideCommentOrString(match.index)) { + continue; + } + const declaredType = match[1]; const varName = match[2]; const sourceVar = match[3]; + // Skip if the core assignment part spans multiple lines + // Build the core: "Type varName = source;" - check this for newlines + const coreP2 = declaredType + ' ' + varName + match[0].substring(match[0].indexOf(varName) + varName.length); + if (coreP2.includes('\n')) { + continue; + } + + // Skip if declared type is a keyword if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', 'else'].includes(declaredType)) { continue; @@ -1598,8 +2131,9 @@ export class Analyzer { continue; } - // Look up the type of the source variable - const sourceType = variableTypes.get(sourceVar); + // Look up the type of the source variable at this line + const lineNum = getLineFromPos(match.index); + const sourceType = getVarTypeAtLine(sourceVar, lineNum); if (sourceType) { this.addTypeMismatchDiagnostic(doc, diags, match.index, match[0].length, declaredType, sourceType); @@ -1611,10 +2145,22 @@ export class Analyzer { const reassignPattern = /(?:^|[;{})\n])(\s*)(\w+)\s*=\s*(\w+)\s*;/g; while ((match = reassignPattern.exec(text)) !== null) { + // Skip if inside comment or string + if (isInsideCommentOrString(match.index)) { + continue; + } + const leadingWhitespace = match[1]; const targetVar = match[2]; const sourceVar = match[3]; + // Skip if the core assignment part spans multiple lines + // Core is "targetVar = sourceVar;" - exclude leading delimiter + whitespace + const coreP3 = match[0].substring(1 + leadingWhitespace.length); + if (coreP3.includes('\n')) { + continue; + } + // Skip keywords if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'else'].includes(targetVar)) { continue; @@ -1625,11 +2171,22 @@ export class Analyzer { continue; } - // Look up types for both variables - const targetType = variableTypes.get(targetVar); - const sourceType = variableTypes.get(sourceVar); + // Look up types for both variables at this line + const lineNum = getLineFromPos(match.index); + const targetType = getVarTypeAtLine(targetVar, lineNum); + const sourceType = getVarTypeAtLine(sourceVar, lineNum); + // Only check if we have confident types for both + // Skip if either type looks like a generic parameter (single letter) or class name if (targetType && sourceType) { + // Skip generic type parameters and potential misparses + if (/^[A-Z]$/.test(targetType) || /^[A-Z]$/.test(sourceType)) { + continue; + } + // Skip if types are identical (even if both are wrong, at least they match) + if (targetType === sourceType) { + continue; + } // Calculate actual start position (skip the leading delimiter and whitespace) const actualStart = match.index + 1 + leadingWhitespace.length; const actualLength = match[0].length - 1 - leadingWhitespace.length; @@ -1642,23 +2199,89 @@ export class Analyzer { const reassignFuncPattern = /(?:^|[;{})\n])(\s*)(\w+)\s*=\s*(\w+)\s*\(/g; while ((match = reassignFuncPattern.exec(text)) !== null) { + // Skip if inside comment or string + if (isInsideCommentOrString(match.index)) { + continue; + } + const leadingWhitespace = match[1]; const targetVar = match[2]; const funcName = match[3]; + // Skip if the core assignment part spans multiple lines + // Core is "targetVar = funcName(" - exclude leading delimiter + whitespace + const coreP4 = match[0].substring(1 + leadingWhitespace.length); + if (coreP4.includes('\n')) { + continue; + } // Skip keywords if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'else'].includes(targetVar)) { continue; } - // Look up type of target variable and return type of function - const targetType = variableTypes.get(targetVar); - const returnType = this.resolveFunctionReturnType(funcName); + // Check if this is a method chain like U().Msg().SetMeta() + const afterMatch = text.substring(match.index + match[0].length); + let parenDepth = 1; + let chainDetected = false; + for (let i = 0; i < afterMatch.length && parenDepth > 0; i++) { + if (afterMatch[i] === '(') parenDepth++; + else if (afterMatch[i] === ')') parenDepth--; + if (parenDepth === 0) { + const remainder = afterMatch.substring(i + 1).trim(); + if (remainder.startsWith('.')) { + chainDetected = true; + } + break; + } + } + + // Look up type of target variable at this line and resolve return type (single or chain) + const lineNum = getLineFromPos(match.index); + const targetType = getVarTypeAtLine(targetVar, lineNum); + let returnType: string | null; + let chainEnd = 0; + + if (chainDetected) { + // Find the end of the full statement (semicolon) + const stmtEnd = afterMatch.indexOf(';'); + chainEnd = stmtEnd >= 0 ? stmtEnd : afterMatch.length; + + // Find where the actual chain ends (last closing paren before semicolon) + for (let i = chainEnd - 1; i >= 0; i--) { + if (afterMatch[i] === ')') { + chainEnd = i + 1; + break; + } + } + + const fullChainText = funcName + '(' + afterMatch.substring(0, chainEnd); + returnType = this.resolveChainReturnType(fullChainText); + } else { + // Find where single function call ends + let depth = 1; + for (let i = 0; i < afterMatch.length && depth > 0; i++) { + if (afterMatch[i] === '(') depth++; + else if (afterMatch[i] === ')') depth--; + if (depth === 0) { + chainEnd = i + 1; + break; + } + } + returnType = this.resolveFunctionReturnType(funcName); + } if (targetType && returnType) { + // Skip if either type is a generic parameter (single uppercase letter like T, K, V) + if (/^[A-Z]$/.test(targetType) || /^[A-Z]$/.test(returnType)) { + continue; + } + // Skip if types are identical + if (targetType === returnType) { + continue; + } // Calculate actual start position (skip the leading delimiter and whitespace) const actualStart = match.index + 1 + leadingWhitespace.length; - const actualLength = match[0].length - 1 - leadingWhitespace.length; + const actualLength = match[0].length - 1 - leadingWhitespace.length + chainEnd; this.addTypeMismatchDiagnostic(doc, diags, actualStart, actualLength, targetType, returnType); } } @@ -1710,43 +2333,53 @@ export class Analyzer { const text = doc.getText(); const lines = text.split('\n'); + // Track if we're inside a block comment + let inBlockComment = false; + + // Track brace depth - anything inside {} is fine (enums, class bodies, arrays) + // Only unclosed () across lines is the actual multi-line statement problem + let braceDepth = 0; + for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); - // Skip empty lines and comments + // Skip empty lines and single-line comments if (!line || line.startsWith('//')) continue; - // Check if line ends with a continuation operator or unclosed construct - // These indicate a multi-line statement which is not allowed + // Skip lines that are just comment content (start with *) + if (line.startsWith('*')) continue; + + // Track block comments /* ... */ + if (line.includes('/*')) { + inBlockComment = true; + } + if (line.includes('*/')) { + inBlockComment = false; + continue; + } + if (inBlockComment) { + continue; + } - // Pattern 1: Line ends with binary operator (excluding comment lines) - // e.g., "text" + or a && or b || - const endsWithOperator = /[+\-*\/&|<>=!,]\s*$/.test(line) && !line.startsWith('//'); + // Track brace depth + const openBraces = (line.match(/\{/g) || []).length; + const closeBraces = (line.match(/\}/g) || []).length; + braceDepth += openBraces - closeBraces; - // Pattern 2: Unclosed parenthesis (more ( than )) + // Only check for unclosed parentheses - this is the real multi-line issue + // e.g., Print("text" + + // "more"); <-- not allowed in Enforce Script const openParens = (line.match(/\(/g) || []).length; const closeParens = (line.match(/\)/g) || []).length; const unclosedParens = openParens > closeParens; - // Pattern 3: Unclosed brackets - const openBrackets = (line.match(/\[/g) || []).length; - const closeBrackets = (line.match(/\]/g) || []).length; - const unclosedBrackets = openBrackets > closeBrackets; - - // Exclude lines that are valid multi-line constructs - // - Function/class declarations with { at end - // - Control flow with { at end - // - Lines ending with { or ; are fine - const endsWithBraceOrSemi = /[{};]\s*$/.test(line); - const isDeclarationStart = /^(class|enum|if|else|for|while|switch|foreach)\b/.test(line); - - // Report error if this looks like a multi-line statement - if ((endsWithOperator || unclosedParens || unclosedBrackets) && - !endsWithBraceOrSemi && - !isDeclarationStart && - !line.endsWith('{') && - i + 1 < lines.length) { - + // Skip lines ending with { or ; or } - those are complete + const endsWithTerminator = /[{};]\s*$/.test(line); + + // Skip declaration starts (class, if, for, etc.) + const isDeclarationStart = /^(class|modded|enum|struct|typedef|if|else|for|while|switch|foreach)\b/.test(line); + + if (unclosedParens && !endsWithTerminator && !isDeclarationStart && i + 1 < lines.length) { // Check if next non-empty line continues this statement let nextLineIdx = i + 1; while (nextLineIdx < lines.length && !lines[nextLineIdx].trim()) { @@ -1755,7 +2388,6 @@ export class Analyzer { if (nextLineIdx < lines.length) { const nextLine = lines[nextLineIdx].trim(); - // If next line doesn't start with { and isn't empty, it's a continuation if (nextLine && !nextLine.startsWith('{') && !nextLine.startsWith('//')) { diags.push({ message: 'Multi-line statements are not supported in Enforce Script. Each statement must be on a single line.', @@ -1785,9 +2417,23 @@ export class Analyzer { 'void', 'int', 'float', 'bool', 'string', 'vector', 'typename', 'Class', 'auto', 'array', 'set', 'map', 'ref', 'autoptr', 'proto', 'private', 'protected', 'static', 'const', 'owned', - 'out', 'inout', 'notnull', 'modded', 'sealed', 'event', 'native' + 'out', 'inout', 'notnull', 'modded', 'sealed', 'event', 'native', + // Common generic type parameter names - these are placeholders, not real types + 'T', 'T1', 'T2', 'T3', 'TKey', 'TValue', 'TItem', 'TElement' ]); + // Collect generic type parameters from the current file's class declarations + // so that template classes like Container work correctly + const genericParams = new Set(); + for (const node of ast.body) { + if (node.kind === 'ClassDecl') { + const classNode = node as ClassDeclNode; + for (const gv of classNode.genericVars || []) { + genericParams.add(gv); + } + } + } + // Require a significant index before flagging unknown types // This helps avoid false positives during initial indexing // and for types wrapped in #ifdef that we can't see @@ -1800,8 +2446,17 @@ export class Analyzer { const typeExists = (typeName: string): boolean => { if (!typeName) return true; if (primitives.has(typeName)) return true; + if (genericParams.has(typeName)) return true; // Generic type parameter + + // Single uppercase letters are likely generic type parameters + if (/^[A-Z]$/.test(typeName)) return true; // Check for class, enum, or typedef with this name + // Use the class finder methods for consistency with hover/go-to-definition + if (this.findClassByName(typeName)) return true; + if (this.findEnumByName(typeName)) return true; + + // Also check typedefs and any top-level symbol with matching name for (const [uri, fileAst] of this.docCache) { for (const node of fileAst.body) { if (node.name === typeName) { @@ -1809,6 +2464,14 @@ export class Analyzer { } } } + + // Also check current file's AST (in case it wasn't cached yet) + for (const node of ast.body) { + if (node.name === typeName) { + return true; + } + } + return false; }; From 5b69d12302c90144f2e158f4ad55f135b77fa3e5 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Fri, 6 Feb 2026 10:49:50 -0500 Subject: [PATCH 04/46] Handle nested generics & improve static completions parser: Improve skipping of initializer expressions by treating brackets as balanced (handles nested generics like '>>' and '<<') and add a safety break on ';' to avoid eating past statement boundaries. graph: Enhance getStaticMemberCompletions to walk the full class hierarchy (parents and modded classes), deduplicate members, and include static variables as well as functions (with parameter detail and insertText). Also includes small cleanups: use a trimmed variable for line processing, remove a redundant comment, and skip common keywords when scanning coreP4 lines. --- server/src/analysis/ast/parser.ts | 10 ++++-- server/src/analysis/project/graph.ts | 54 ++++++++++++++++++---------- 2 files changed, 43 insertions(+), 21 deletions(-) diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index c17c88c..4a60e8e 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -611,12 +611,18 @@ export function parse( } if (curTok.value === '(' || curTok.value === '[' || curTok.value === '{' || curTok.value === '<') { - // skip initializer expression + // skip initializer expression (balanced brackets) + // Must handle '>>' as two consecutive '>' closes for nested generics + // e.g.: new array>(); let depth = 1; while (!eof() && depth > 0) { const val = peek().value; if (val === '(' || val === '[' || val === '{' || val === '<') depth++; - if (val === ')' || val === ']' || val === '}' || val === '>') depth--; + else if (val === ')' || val === ']' || val === '}' || val === '>') depth--; + else if (val === '>>') depth -= 2; + else if (val === '<<') depth += 2; + // Safety: don't eat past statement boundary + if (val === ';' && depth > 0) break; next(); } } diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 131b83a..14b62d0 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -795,27 +795,44 @@ export class Analyzer { /** * Get static member completions for a class (ClassName.StaticMethod()) + * Walks the full hierarchy: parent classes + modded classes */ private getStaticMemberCompletions(classNode: ClassDeclNode, prefix: string): CompletionResult[] { const results: CompletionResult[] = []; + const seen = new Set(); - for (const member of classNode.members || []) { - if (!member.name) continue; - if (!member.modifiers?.includes('static')) continue; - if (prefix && !member.name.toLowerCase().startsWith(prefix.toLowerCase())) continue; - - if (member.kind === 'FunctionDecl') { - const func = member as FunctionDeclNode; - const params = func.parameters?.map(p => - `${p.type?.identifier || 'auto'} ${p.name}` - ).join(', ') || ''; + // Walk full hierarchy: parents + modded versions + const classHierarchy = this.getClassHierarchyOrdered(classNode.name, new Set()); + + for (const cls of classHierarchy) { + for (const member of cls.members || []) { + if (!member.name) continue; + if (!member.modifiers?.includes('static')) continue; + if (seen.has(member.name)) continue; + if (prefix && !member.name.toLowerCase().startsWith(prefix.toLowerCase())) continue; - results.push({ - name: `${func.name}(${params})`, - kind: 'function', - detail: `${func.returnType?.identifier || 'void'} (static)`, - insertText: `${func.name}()` - }); + seen.add(member.name); + + if (member.kind === 'FunctionDecl') { + const func = member as FunctionDeclNode; + const params = func.parameters?.map(p => + `${p.type?.identifier || 'auto'} ${p.name}` + ).join(', ') || ''; + + results.push({ + name: `${func.name}(${params})`, + kind: 'function', + detail: `${func.returnType?.identifier || 'void'} (static)`, + insertText: `${func.name}()` + }); + } else if (member.kind === 'VarDecl') { + const field = member as VarDeclNode; + results.push({ + name: field.name, + kind: 'variable', + detail: `${field.type?.identifier || 'auto'} (static)` + }); + } } } @@ -1427,7 +1444,7 @@ export class Analyzer { lineNoComment = lineNoComment.replace(/"(?:[^"\\]|\\.)*"/g, '""'); // Replace "..." with "" lineNoComment = lineNoComment.replace(/'(?:[^'\\]|\\.)*'/g, "''"); // Replace '...' with '' - lineNoComment = lineNoComment.trim(); + const lineNoCommentTrimmed = lineNoComment.trim(); // Check if this line starts a class if (classDeclPattern.test(lineNoComment)) { @@ -1606,7 +1623,6 @@ export class Analyzer { const assignedHierarchy = this.getClassHierarchyOrdered(assignNorm, new Set()); for (const classNode of assignedHierarchy) { if (classNode.name === declNorm) { - // declaredType is a parent of assignedType - this is an upcast (safe) return { compatible: true, isDowncast: false, isUpcast: true }; } } @@ -1662,7 +1678,6 @@ export class Analyzer { } // If both are primitives but different (and not numeric), they're not compatible - // Note: numeric types (int/float/bool) were already handled above if (declIsPrimitive && assignIsPrimitive && declNorm !== assignNorm) { return { compatible: false, @@ -2214,6 +2229,7 @@ export class Analyzer { if (coreP4.includes('\n')) { continue; } + // Skip keywords if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'else'].includes(targetVar)) { continue; From 25bb95369eed4f075b205f19fe453a67f3d61f36 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Fri, 6 Feb 2026 11:26:02 -0500 Subject: [PATCH 05/46] Add module detection and cross-module diagnostics Record script module level and improve indexing/diagnostics across the analyzer. - parser.ts: added optional `module` on File and improved local variable detection inside function/blocks to populate function `locals` (used for type checking). - graph.ts: implemented module-name/path parsing (1_Core .. 5_Mission), track parse error counts, expose index stats, determine module for a symbol, avoid misclassifying for-loops as function declarations, and emit warnings when types or base classes are accessed across incompatible module levels. Also ensure local variable types are checked. - index.ts: log summary stats after indexing (files, classes, functions, parse errors) and per-module file counts; produce a diagnostic breakdown when checking the workspace. These changes enable cross-module visibility checks (higher-numbered modules are not visible to lower-numbered ones), better diagnostics, and improved indexing information for developers. --- server/src/analysis/ast/parser.ts | 42 ++++++++- server/src/analysis/project/graph.ts | 124 +++++++++++++++++++++++++-- server/src/index.ts | 40 ++++++++- 3 files changed, 197 insertions(+), 9 deletions(-) diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index 4a60e8e..1c5c557 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -163,6 +163,7 @@ export interface File { body: SymbolNodeBase[] version: number diagnostics: Diagnostic[] // Parser-generated diagnostics (e.g., ternary operator warnings) + module?: number // Script module level (1=Core, 2=GameLib, 3=Game, 4=World, 5=Mission) } // parse entry point @@ -501,9 +502,14 @@ export function parse( // int x; // if (condition) x = 1; else x = 0; // ==================================================================== + const locals: VarDeclNode[] = []; if (peek().value === '{') { next(); let depth = 1; + // Track previous tokens to detect local variable declarations + // Pattern: [modifiers...] TypeName VarName (= | ; | ,) + let prevPrev: Token | null = null; + let prev: Token | null = null; while (depth > 0 && !eof()) { const t = next(); if (t.value === '{') depth++; @@ -532,6 +538,40 @@ export function parse( addDiagnostic(t, 'Ternary operator (? :) is not supported in Enforce Script. Use if/else statement instead.', DiagnosticSeverity.Error); } } + + // Detect local variable declarations: + // TypeName varName ; or TypeName varName = or TypeName varName , + // prevPrev = type token, prev = name token, t = ; or = or , + if (prev && prevPrev && (t.value === ';' || t.value === '=' || t.value === ',')) { + const isTypeTok = prevPrev.kind === TokenKind.Identifier + || (prevPrev.kind === TokenKind.Keyword && isPrimitiveType(prevPrev.value)); + const isNameTok = prev.kind === TokenKind.Identifier; + if (isTypeTok && isNameTok) { + locals.push({ + kind: 'VarDecl', + uri: doc.uri, + name: prev.value, + nameStart: doc.positionAt(prev.start), + nameEnd: doc.positionAt(prev.end), + type: { + kind: 'Type', + uri: doc.uri, + identifier: prevPrev.value, + start: doc.positionAt(prevPrev.start), + end: doc.positionAt(prevPrev.end), + arrayDims: [], + modifiers: [], + }, + annotations: [], + modifiers: [], + start: doc.positionAt(prevPrev.start), + end: doc.positionAt(prev.end), + }); + } + } + + prevPrev = prev; + prev = t; } } @@ -543,7 +583,7 @@ export function parse( nameEnd: doc.positionAt(nameTok.end), returnType: baseTypeNode, parameters: params, - locals: [], //locals, + locals: locals, annotations: annotations, modifiers: mods, start: baseTypeNode.start, diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 14b62d0..f88207f 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -141,6 +141,48 @@ function formatDeclaration(node: SymbolNodeBase): string { return `(Unknown ${node.kind}) ${node.name}`; } +// ==================================================================== +// Script Module Detection +// ==================================================================== +// DayZ scripts are organised into numbered modules: +// 1_Core → 2_GameLib → 3_Game → 4_World → 5_Mission +// A lower module CANNOT reference types from a higher module. +// Modders sometimes use shorter names like "game", "world", "mission". + +const MODULE_NAMES: Record = { + 1: '1_Core', + 2: '2_GameLib', + 3: '3_Game', + 4: '4_World', + 5: '5_Mission', +}; + +/** Numbered format: /1_core/, /4_World/ etc. */ +const MODULE_NUMBERED = /[/\\]([1-5])_[a-z]+[/\\]/i; + +/** Short-name lookup for un-numbered folders like /world/, /game/, /mission/ */ +const MODULE_SHORT_NAMES: Record = { + core: 1, + gamelib: 2, + game: 3, + world: 4, + mission: 5, +}; +const MODULE_SHORT = /[/\\](core|gamelib|game|world|mission)[/\\]/i; + +/** Extract the script module level (1–5) from a file URI or path. Returns 0 if unknown. */ +function getModuleLevel(uriOrPath: string): number { + // Try the canonical numbered format first (e.g. 4_World) + const num = MODULE_NUMBERED.exec(uriOrPath); + if (num) return parseInt(num[1], 10); + + // Fall back to short names (e.g. just "world" or "game") + const short = MODULE_SHORT.exec(uriOrPath); + if (short) return MODULE_SHORT_NAMES[short[1].toLowerCase()] ?? 0; + + return 0; +} + /** Singleton façade that lazily analyses files and answers LSP queries. */ export class Analyzer { private static _instance: Analyzer; @@ -150,6 +192,28 @@ export class Analyzer { } private docCache = new Map(); + private parseErrorCount = 0; + + /** Return summary stats about everything indexed so far. */ + getIndexStats() { + let classes = 0, functions = 0, enums = 0, typedefs = 0, globals = 0; + const moduleCounts: Record = {}; + for (const file of this.docCache.values()) { + if (file.module && file.module > 0) { + moduleCounts[file.module] = (moduleCounts[file.module] || 0) + 1; + } + for (const node of file.body) { + switch (node.kind) { + case 'ClassDecl': classes++; break; + case 'FunctionDecl': functions++; break; + case 'EnumDecl': enums++; break; + case 'Typedef': typedefs++; break; + case 'VarDecl': globals++; break; + } + } + } + return { files: this.docCache.size, classes, functions, enums, typedefs, globals, parseErrors: this.parseErrorCount, moduleCounts }; + } private ensure(doc: TextDocument): File { // 1 · cache hit @@ -163,11 +227,13 @@ export class Analyzer { try { // 2 · happy path ─ parse & cache const ast = parse(doc); // pass full TextDocument + ast.module = getModuleLevel(doc.uri); this.docCache.set(normalizeUri(doc.uri), ast); return ast; } catch (err) { // 3 · graceful error handling if (err instanceof ParseError) { + this.parseErrorCount++; // VS Code recognises “path:line:col” as a jump-to link const fsPath = url.fileURLToPath(err.uri); // file:/// → p:\foo\bar.c console.error(`${fsPath}:${err.line}:${err.column} ${err.message}`); @@ -867,6 +933,21 @@ export class Analyzer { return null; } + /** + * Find the module level (1–5) where a symbol is defined. + * Returns 0 if the symbol is not found or has no module info. + */ + private getModuleForSymbol(symbolName: string): number { + for (const [uri, ast] of this.docCache) { + for (const node of ast.body) { + if (node.name === symbolName) { + return ast.module || 0; + } + } + } + return 0; + } + /** * Get completions for enum members (e.g., MuzzleState. → shows U, L, etc.) */ @@ -1457,8 +1538,13 @@ export class Analyzer { inFunction = false; } + // Check if this line has a for/foreach/while loop + // In Enforce Script, loop variables are scoped to the PARENT scope, not the loop block + const isLoopLine = loopPattern.test(lineNoComment); + // Check if this line starts a new function - const isFuncDecl = funcDeclPattern.test(lineNoComment); + // Must NOT be a loop line — for(int i ...) looks like a func decl to the regex + const isFuncDecl = !isLoopLine && funcDeclPattern.test(lineNoComment); if (isFuncDecl) { // New function - reset to: global scope + class fields (copy) + new function scope // We must copy classFieldScope to avoid it being modified by function-local variables @@ -1467,10 +1553,6 @@ export class Analyzer { functionBraceDepth = braceDepth; } - // Check if this line has a for/foreach/while loop - // In Enforce Script, loop variables are scoped to the PARENT scope, not the loop block - const isLoopLine = loopPattern.test(line); - // FIRST: Find variable declarations on this line BEFORE processing braces // This ensures for loop variables (int j in "for (int j = 0...") // are added to the current scope before we push a new scope for { @@ -2458,6 +2540,9 @@ export class Analyzer { return; // Not enough files indexed to be confident } + // Determine the module level of the current file (0 = unknown) + const currentModule = ast.module || 0; + // Check if a type exists const typeExists = (typeName: string): boolean => { if (!typeName) return true; @@ -2491,7 +2576,7 @@ export class Analyzer { return false; }; - // Check a type node for unknown types + // Check a type node for unknown types and cross-module access const checkType = (type: TypeNode | undefined): void => { if (!type) return; @@ -2501,6 +2586,16 @@ export class Analyzer { range: { start: type.start, end: type.end }, severity: DiagnosticSeverity.Warning }); + } else if (currentModule > 0) { + // Type exists — check cross-module accessibility + const typeModule = this.getModuleForSymbol(type.identifier); + if (typeModule > 0 && typeModule > currentModule) { + diags.push({ + message: `Type '${type.identifier}' is defined in ${MODULE_NAMES[typeModule] || 'module ' + typeModule} and cannot be used from ${MODULE_NAMES[currentModule] || 'module ' + currentModule}. Higher-numbered modules are not visible to lower-numbered modules.`, + range: { start: type.start, end: type.end }, + severity: DiagnosticSeverity.Warning + }); + } } // Check generic arguments too @@ -2515,13 +2610,22 @@ export class Analyzer { if (node.kind === 'ClassDecl') { const classNode = node as ClassDeclNode; - // Check base class exists + // Check base class exists and is accessible from this module if (classNode.base && !typeExists(classNode.base.identifier)) { diags.push({ message: `Unknown base class '${classNode.base.identifier}'`, range: { start: classNode.base.start, end: classNode.base.end }, severity: DiagnosticSeverity.Warning }); + } else if (classNode.base && currentModule > 0) { + const baseModule = this.getModuleForSymbol(classNode.base.identifier); + if (baseModule > 0 && baseModule > currentModule) { + diags.push({ + message: `Base class '${classNode.base.identifier}' is defined in ${MODULE_NAMES[baseModule] || 'module ' + baseModule} and cannot be extended from ${MODULE_NAMES[currentModule] || 'module ' + currentModule}. Higher-numbered modules are not visible to lower-numbered modules.`, + range: { start: classNode.base.start, end: classNode.base.end }, + severity: DiagnosticSeverity.Warning + }); + } } // Check class members @@ -2534,6 +2638,9 @@ export class Analyzer { for (const param of func.parameters || []) { checkType(param.type); } + for (const local of func.locals || []) { + checkType(local.type); + } } } } @@ -2550,6 +2657,9 @@ export class Analyzer { for (const param of func.parameters || []) { checkType(param.type); } + for (const local of func.locals || []) { + checkType(local.type); + } } } } diff --git a/server/src/index.ts b/server/src/index.ts index c252d35..fb9d213 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -72,7 +72,21 @@ connection.onInitialized(async () => { Analyzer.instance().runDiagnostics(doc); // will parse & cache } - console.log('Indexing complete.'); + const stats = Analyzer.instance().getIndexStats(); + const moduleNames: Record = { 1: '1_Core', 2: '2_GameLib', 3: '3_Game', 4: '4_World', 5: '5_Mission' }; + console.log( + `Indexing complete: ${stats.files} files, ` + + `${stats.classes} classes, ${stats.functions} functions, ` + + `${stats.enums} enums, ${stats.typedefs} typedefs, ${stats.globals} globals` + + (stats.parseErrors > 0 ? ` (${stats.parseErrors} parse errors)` : '') + ); + // Log per-module file counts + const modParts = Object.entries(stats.moduleCounts) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([m, count]) => `${moduleNames[Number(m)] || m}: ${count}`); + if (modParts.length > 0) { + console.log(` Modules: ${modParts.join(', ')}`); + } // Notify client that indexing is complete - trigger refresh of open files connection.sendNotification('enscript/indexingComplete', { @@ -102,6 +116,30 @@ connection.onRequest('enscript/checkWorkspace', async () => { } console.log(`Checked ${files.length} files, found issues in ${allDiagnostics.length} files`); + + // Verbose breakdown by diagnostic category + if (allDiagnostics.length > 0) { + let unknownTypes = 0, typeMismatches = 0, duplicateVars = 0, multiLine = 0, crossModule = 0, parserDiags = 0; + for (const { diagnostics } of allDiagnostics) { + for (const d of diagnostics) { + const msg = d.message; + if (msg.startsWith('Unknown type') || msg.startsWith('Unknown base class')) unknownTypes++; + else if (msg.includes('cannot be used from') || msg.includes('cannot be extended from')) crossModule++; + else if (msg.startsWith('Type mismatch') || msg.startsWith('Unsafe downcast')) typeMismatches++; + else if (msg.includes('already declared')) duplicateVars++; + else if (msg.includes('Multi-line')) multiLine++; + else parserDiags++; + } + } + const parts: string[] = []; + if (unknownTypes) parts.push(`${unknownTypes} unknown types`); + if (crossModule) parts.push(`${crossModule} cross-module`); + if (typeMismatches) parts.push(`${typeMismatches} type mismatches`); + if (duplicateVars) parts.push(`${duplicateVars} duplicate vars`); + if (multiLine) parts.push(`${multiLine} multi-line`); + if (parserDiags) parts.push(`${parserDiags} parser warnings`); + console.log(` Breakdown: ${parts.join(', ')}`); + } return { filesChecked: files.length, filesWithIssues: allDiagnostics.length, From ad9418495aec8bfe75298717a90e5ff8329d4f84 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Fri, 6 Feb 2026 14:01:36 -0500 Subject: [PATCH 06/46] Add typedef and template resolution, diagnostics Enhances the analyzer to properly handle typedefs and generic/template types and improves diagnostics and completions. Changes include: - Attach a parse-error diagnostic stub to docCache on parser exceptions so runDiagnostics picks it up. - Resolve typedefs and full TypeNode info (including genericArgs) via new helpers: resolveTypedefNode, resolveTypedef, resolveVariableTypeNode, resolveFunctionReturnTypeNode, resolveMethodReturnTypeNode. - Build template substitution maps for generic classes (buildTemplateMap, getClassGenericVars) and use them to substitute generic parameter names in completions and chain/type resolution. - Add variable-chain resolution (resolveVariableChainType) and enhance chain return type resolution to propagate typedefs/generic substitutions across method/property chains. - Update getClassMemberCompletions to accept an optional templateMap and display substituted return/field types in completion details. - Improve type-mismatch checks to skip unresolvable template parameters and add patterns to detect mismatches involving variable method/property chains (new diagnostics patterns). - Minor parsing and highlight improvements for function/chain detection. - Add 'typedef' declaration support to the language grammar (enscript.tmLanguage.json). These changes enable accurate completion signatures and better type diagnostics when using typedefs and generic/template types. --- server/src/analysis/ast/parser.ts | 1 + server/src/analysis/project/graph.ts | 735 +++++++++++++++++++++------ syntaxes/enscript.tmLanguage.json | 21 + 3 files changed, 592 insertions(+), 165 deletions(-) diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index 1c5c557..613a2b1 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -373,6 +373,7 @@ export function parse( name: nameTok.value, nameStart: doc.positionAt(nameTok.start), nameEnd: doc.positionAt(nameTok.end), + genericVars: genericVars, base: base, annotations: annotations, modifiers: mods, diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index f88207f..8ff7d4a 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -238,18 +238,20 @@ export class Analyzer { const fsPath = url.fileURLToPath(err.uri); // file:/// → p:\foo\bar.c console.error(`${fsPath}:${err.line}:${err.column} ${err.message}`); - // // also publish a real diagnostic so the Problems panel shows it - // const diagnostic: Diagnostic = { - // message: err.message, - // range: { - // start: { line: err.line - 1, character: err.column - 1 }, - // end: { line: err.line - 1, character: err.column } - // }, - // severity: DiagnosticSeverity.Error, - // source: 'parser' - // }; - // connection.sendDiagnostics({ uri: err.uri, diagnostics: [diagnostic] }); - // Stack trace removed to reduce noise during indexing + // Return stub with parse error diagnostic attached + // so runDiagnostics() picks it up via ast.diagnostics + const parseErrorDiag: Diagnostic = { + message: `${err.message} (parse error — other diagnostics for this file are suppressed until this is fixed)`, + range: { + start: { line: err.line - 1, character: err.column - 1 }, + end: { line: err.line - 1, character: err.column } + }, + severity: DiagnosticSeverity.Error, + source: 'enfusion-script' + }; + const stub: File = { body: [], version: doc.version, diagnostics: [parseErrorDiag] }; + this.docCache.set(normalizeUri(doc.uri), stub); + return stub; } else { // unexpected failure console.error(String(err)); @@ -368,10 +370,39 @@ export class Analyzer { const varType = this.resolveVariableType(doc, pos, name); if (varType) { + // ================================================================ + // TYPEDEF + TEMPLATE TYPE RESOLUTION FOR COMPLETIONS + // ================================================================ + // When a variable has a typedef'd type (e.g., testMapType → map), + // we need to: + // 1. Resolve the typedef to the underlying class name + // 2. Build a template substitution map (TKey→string, TValue→string) + // 3. Pass the map to getClassMemberCompletions so it can replace + // generic param names with concrete types in the completion details + // + // This also handles direct generic declarations like: map myMap; + // In that case we get the TypeNode (which has genericArgs) and build the map. + // ================================================================ + const typedefNode = this.resolveTypedefNode(varType); + let resolvedType: string; + let tplMap: Map | undefined; + + if (typedefNode) { + // Typedef path: e.g., testMapType → oldType is map + resolvedType = typedefNode.oldType.identifier; + tplMap = this.buildTemplateMap(resolvedType, typedefNode.oldType.genericArgs); + } else { + resolvedType = varType; + // Direct generic path: e.g., map myMap + // resolveVariableTypeNode returns the full TypeNode with genericArgs + const varTypeNode = this.resolveVariableTypeNode(doc, pos, name); + if (varTypeNode?.genericArgs && varTypeNode.genericArgs.length > 0) { + tplMap = this.buildTemplateMap(resolvedType, varTypeNode.genericArgs); + } + } // Get methods/fields for this type (including inherited) - const members = this.getClassMemberCompletions(varType, prefix); + const members = this.getClassMemberCompletions(resolvedType, prefix, tplMap); return members; - } else { } // If name looks like a class name (starts with uppercase), @@ -494,10 +525,6 @@ export class Analyzer { return results; } - /** - * Resolve the type of a variable at a given position - * Checks: function parameters, local variables, class fields - */ /** * Known DayZ global variables that have a more specific type than declared. * Example: g_Game is declared as "Game" but is actually "CGame" @@ -506,6 +533,11 @@ export class Analyzer { 'g_Game': 'CGame', }; + /** + * Resolve the type of a variable at a given position. + * Checks known overrides, then delegates AST lookup to resolveVariableTypeNode, + * and falls back to regex patterns for variables the AST misses. + */ private resolveVariableType(doc: TextDocument, pos: Position, varName: string): string | null { // Check for known variable type overrides first @@ -514,72 +546,81 @@ export class Analyzer { return knownType; } + // Delegate the AST-based lookup to resolveVariableTypeNode + const typeNode = this.resolveVariableTypeNode(doc, pos, varName); + if (typeNode) { + return typeNode.identifier || null; + } + + // Regex fallbacks for cases the AST-based lookup misses + // (e.g., variables in unparsed regions) + const text = doc.getText(); + + // Pattern: Type varName; or Type varName = + const varDeclMatch = text.match(new RegExp(`(\\w+)\\s+${varName}\\s*[;=]`)); + if (varDeclMatch) { + return varDeclMatch[1]; + } + + // Pattern: (Type varName) or (Type varName,) - function parameters + const paramMatch = text.match(new RegExp(`[,(]\\s*(\\w+)\\s+${varName}\\s*[,)]`)); + if (paramMatch) { + return paramMatch[1]; + } + + // Pattern: out Type varName or inout Type varName + const outParamMatch = text.match(new RegExp(`(?:out|inout)\\s+(\\w+)\\s+${varName}\\s*[,)]`)); + if (outParamMatch) { + return outParamMatch[1]; + } + + return null; + } + + /** + * Resolve a variable's full TypeNode (including genericArgs). + * Unlike resolveVariableType() which returns just the type name string, + * this returns the complete TypeNode so we can access generic type arguments. + * + * This is needed for direct generic declarations like: + * map myMap; → TypeNode { identifier: "map", genericArgs: ["string", "int"] } + * + * Search order: function params → function locals → class fields → global variables + * @returns The full TypeNode or null if not found + */ + private resolveVariableTypeNode(doc: TextDocument, pos: Position, varName: string): TypeNode | null { const ast = this.ensure(doc); - // Find which function we're inside const containingFunc = this.findContainingFunction(ast, pos); - if (containingFunc) { - // Check function parameters first - // Example: void SomeFunc(PlayerBase p) { p. } → type is "PlayerBase" for (const param of containingFunc.parameters || []) { - if (param.name === varName) { - return param.type?.identifier || null; - } + if (param.name === varName && param.type) return param.type; } - - // Check local variables for (const local of containingFunc.locals || []) { - if (local.name === varName) { - return local.type?.identifier || null; - } + if (local.name === varName && local.type) return local.type; } } - // Check if we're inside a class - look for fields + inherited fields const containingClass = this.findContainingClass(ast, pos); if (containingClass) { - // Check current class and all parent classes (including modded) const classHierarchy = this.getClassHierarchyOrdered(containingClass.name, new Set()); for (const classNode of classHierarchy) { for (const member of classNode.members || []) { - if (member.kind === 'VarDecl' && member.name === varName) { - return (member as VarDeclNode).type?.identifier || null; + if (member.kind === 'VarDecl' && member.name === varName && (member as VarDeclNode).type) { + return (member as VarDeclNode).type!; } } } } - // Check global variables in ALL indexed files for (const [uri, fileAst] of this.docCache) { for (const node of fileAst.body) { - if (node.kind === 'VarDecl' && node.name === varName) { - return (node as VarDeclNode).type?.identifier || null; + if (node.kind === 'VarDecl' && node.name === varName && (node as VarDeclNode).type) { + return (node as VarDeclNode).type!; } } } - // Try scanning the current document for variable declarations - const text = doc.getText(); - - // Pattern: Type varName; or Type varName = - const varDeclMatch = text.match(new RegExp(`(\\w+)\\s+${varName}\\s*[;=]`)); - if (varDeclMatch) { - return varDeclMatch[1]; - } - - // Pattern: (Type varName) or (Type varName,) - function parameters - const paramMatch = text.match(new RegExp(`[,(]\\s*(\\w+)\\s+${varName}\\s*[,)]`)); - if (paramMatch) { - return paramMatch[1]; - } - - // Pattern: out Type varName or inout Type varName - const outParamMatch = text.match(new RegExp(`(?:out|inout)\\s+(\\w+)\\s+${varName}\\s*[,)]`)); - if (outParamMatch) { - return outParamMatch[1]; - } - return null; } @@ -599,11 +640,19 @@ export class Analyzer { * Searches top-level functions and class methods across all indexed files */ private resolveFunctionReturnType(funcName: string): string | null { + return this.resolveFunctionReturnTypeNode(funcName)?.identifier ?? null; + } + + /** + * Resolve the return type of a global/static function, returning full TypeNode info. + */ + private resolveFunctionReturnTypeNode(funcName: string): TypeNode | null { // Check for known overrides first (e.g., GetGame() returns CGame, not Game) const knownType = Analyzer.KNOWN_RETURN_TYPES[funcName]; if (knownType) { - return knownType; + // Return a synthetic TypeNode for known types + return { kind: 'Type', identifier: knownType, arrayDims: [], modifiers: [], uri: '', start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } as TypeNode; } // Search all indexed documents for a function with this name @@ -612,9 +661,8 @@ export class Analyzer { // Top-level function if (node.kind === 'FunctionDecl' && node.name === funcName) { const func = node as FunctionDeclNode; - const returnType = func.returnType?.identifier; - if (returnType) { - return returnType; // Return 'void' too - we want to detect void assignments + if (func.returnType?.identifier) { + return func.returnType; } } @@ -623,9 +671,8 @@ export class Analyzer { for (const member of (node as ClassDeclNode).members || []) { if (member.kind === 'FunctionDecl' && member.name === funcName) { const func = member as FunctionDeclNode; - const returnType = func.returnType?.identifier; - if (returnType) { - return returnType; // Return 'void' too + if (func.returnType?.identifier) { + return func.returnType; } } } @@ -642,16 +689,33 @@ export class Analyzer { * @param methodName The method name to find */ private resolveMethodReturnType(className: string, methodName: string): string | null { + const result = this.resolveMethodReturnTypeNode(className, methodName); + return result?.identifier ?? null; + } + + /** + * Resolve the return type of a method/field within a class hierarchy, returning full type info. + * Includes genericArgs for template types like map. + */ + private resolveMethodReturnTypeNode(className: string, methodName: string): TypeNode | null { + // Resolve typedefs first (e.g., testMapType → map) + const resolvedClass = this.resolveTypedef(className); const visited = new Set(); - const classesToSearch = this.getClassHierarchyOrdered(className, visited); + const classesToSearch = this.getClassHierarchyOrdered(resolvedClass, visited); for (const classNode of classesToSearch) { for (const member of classNode.members || []) { if (member.kind === 'FunctionDecl' && member.name === methodName) { const func = member as FunctionDeclNode; - const returnType = func.returnType?.identifier; - if (returnType) { - return returnType; // Return 'void' too - we want to detect void assignments + if (func.returnType?.identifier) { + return func.returnType; + } + } + // Also check fields (VarDecl members) + if (member.kind === 'VarDecl' && member.name === methodName) { + const varNode = member as VarDeclNode; + if (varNode.type?.identifier) { + return varNode.type; } } } @@ -660,97 +724,261 @@ export class Analyzer { return null; } + /** + * Get the genericVars (template parameter names) for a class by name. + * e.g. for "map" returns ["TKey", "TValue"] + */ + private getClassGenericVars(className: string): string[] | undefined { + for (const [uri, ast] of this.docCache) { + for (const node of ast.body) { + if (node.kind === 'ClassDecl' && node.name === className) { + return (node as ClassDeclNode).genericVars; + } + } + } + return undefined; + } + + /** + * Build a template substitution map from a class's genericVars and concrete genericArgs. + * + * Maps the class's formal template parameter names to the concrete types provided + * by a typedef or direct generic instantiation. + * + * Example: + * class map { ... } + * typedef map TMyMap; + * → buildTemplateMap("map", [{identifier:"string"}, {identifier:"int"}]) + * → Map { "TKey" → "string", "TValue" → "int" } + * + * The className is first resolved through typedefs (in case the caller + * passes a typedef alias instead of the actual class name). + * + * @param className The class name (will be resolved through typedefs) + * @param genericArgs Concrete type arguments from the typedef/instantiation + * @returns Map of template param name → concrete type name (empty if not generic) + */ + private buildTemplateMap(className: string, genericArgs?: TypeNode[]): Map { + const templateMap = new Map(); + if (!genericArgs || genericArgs.length === 0) return templateMap; + + // Resolve through typedefs first + const resolvedClass = this.resolveTypedef(className); + const genericVars = this.getClassGenericVars(resolvedClass); + if (!genericVars) return templateMap; + + for (let i = 0; i < Math.min(genericVars.length, genericArgs.length); i++) { + templateMap.set(genericVars[i], genericArgs[i].identifier); + } + return templateMap; + } + + /** + * Resolve a type name through typedefs to the underlying class name. + * e.g., "testMapType" → "map" if typedef map testMapType; + * Returns the original typeName if it's not a typedef. + */ + private resolveTypedef(typeName: string): string { + const node = this.resolveTypedefNode(typeName); + return node ? node.oldType.identifier : typeName; + } + + /** + * Find the TypedefNode for a given type name. + * Returns null if the type is not a typedef. + */ + private resolveTypedefNode(typeName: string): TypedefNode | null { + for (const [uri, ast] of this.docCache) { + for (const node of ast.body) { + if (node.kind === 'Typedef' && node.name === typeName) { + return node as TypedefNode; + } + } + } + return null; + } + /** * Resolve the final return type of a method chain like "U().Msg().SetMeta(...)" * Parses the chain and follows each call to determine the final return type. * @param chainText The full chain text starting from the first function * @returns The return type of the final call in the chain, or null if unresolved */ - private resolveChainReturnType(chainText: string): string | null { - // Parse the chain: extract each function/method call in sequence - // Examples: - // "U().Msg().SetMeta(foo, bar)" -> ["U", "Msg", "SetMeta"] - // "GetGame().GetTime()" -> ["GetGame", "GetTime"] - + /** + * Parse chained member accesses from text like ".Method(args).Prop.Other()" + * into a list of member names: ["Method", "Prop", "Other"]. + * Handles both method calls (with parenthesized arguments) and property accesses. + */ + private parseChainMembers(text: string): string[] { const calls: string[] = []; - let remaining = chainText.trim(); + let remaining = text.trim(); - // First call: funcName(...) - const firstMatch = remaining.match(/^(\w+)\s*\(/); - if (!firstMatch) { - return null; - } - calls.push(firstMatch[1]); - - // Skip past the first call's arguments - remaining = remaining.substring(firstMatch[0].length); - let parenDepth = 1; - let i = 0; - - while (i < remaining.length && parenDepth > 0) { - if (remaining[i] === '(') parenDepth++; - else if (remaining[i] === ')') parenDepth--; - i++; - } - - remaining = remaining.substring(i).trim(); - - // Continue parsing chained calls: .methodName(...) while (remaining.startsWith('.')) { remaining = remaining.substring(1).trim(); const methodMatch = remaining.match(/^(\w+)\s*\(/); if (!methodMatch) { - // Might be a property access, not a method call + // Property access (no parens), e.g., .Icons const propMatch = remaining.match(/^(\w+)/); if (propMatch) { calls.push(propMatch[1]); + remaining = remaining.substring(propMatch[0].length).trim(); + continue; } break; } calls.push(methodMatch[1]); - // Skip past this call's arguments + // Skip past this call's arguments (balanced parens) remaining = remaining.substring(methodMatch[0].length); - parenDepth = 1; - i = 0; - + let parenDepth = 1, i = 0; while (i < remaining.length && parenDepth > 0) { if (remaining[i] === '(') parenDepth++; else if (remaining[i] === ')') parenDepth--; i++; } - remaining = remaining.substring(i).trim(); } - if (calls.length === 0) { - return null; + return calls; + } + + /** + * Resolve a sequence of member accesses on a type, tracking template parameter + * substitution at each step. + * + * At each step: + * 1. Look up the member's return TypeNode in the class hierarchy + * 2. Apply template substitution (e.g., TKey → string) + * 3. If the result is a typedef, expand it and rebuild the template map + * 4. If the result has its own generic args, propagate them + * + * @param calls Ordered member names to resolve (e.g., ["Get", "Length"]) + * @param currentType The starting type (already typedef-resolved) + * @param templateMap The starting template substitution map + * @returns The final resolved type, or null if any step fails + */ + private resolveChainSteps( + calls: string[], + currentType: string, + templateMap: Map + ): string | null { + for (const memberName of calls) { + const nextTypeNode = this.resolveMethodReturnTypeNode(currentType, memberName); + if (!nextTypeNode?.identifier) return null; + + let resolvedType = nextTypeNode.identifier; + + // Apply template substitution (e.g., GetKey() returns TKey → "string") + if (templateMap.has(resolvedType)) { + resolvedType = templateMap.get(resolvedType)!; + } + + // Resolve through typedefs and rebuild template map for the next step + const stepTypedef = this.resolveTypedefNode(resolvedType); + if (stepTypedef) { + resolvedType = stepTypedef.oldType.identifier; + if (stepTypedef.oldType.genericArgs && stepTypedef.oldType.genericArgs.length > 0) { + templateMap = this.buildTemplateMap(resolvedType, stepTypedef.oldType.genericArgs); + } else { + templateMap = new Map(); + } + } else if (nextTypeNode.genericArgs && nextTypeNode.genericArgs.length > 0) { + // Substitute any generic args that reference template params + const substitutedArgs = nextTypeNode.genericArgs.map(arg => { + const subId = templateMap.get(arg.identifier); + if (subId) return { ...arg, identifier: subId } as TypeNode; + return arg; + }); + templateMap = this.buildTemplateMap(resolvedType, substitutedArgs); + } else { + templateMap = new Map(); + } + + currentType = resolvedType; } + return currentType; + } + + /** + * Resolve the final return type of a function chain like "U().Msg().SetMeta(...)". + * Parses the chain, resolves the first function call, then delegates to + * resolveChainSteps for subsequent member accesses. + */ + private resolveChainReturnType(chainText: string): string | null { + // Parse the first call: funcName(args) + const remaining = chainText.trim(); + const firstMatch = remaining.match(/^(\w+)\s*\(/); + if (!firstMatch) return null; + + const firstFunc = firstMatch[1]; - // Resolve the chain step by step - // First call is a function (global or static) - let currentType = this.resolveFunctionReturnType(calls[0]); - if (!currentType) { - return null; + // Skip past the first call's arguments (balanced parens) + let afterFirst = remaining.substring(firstMatch[0].length); + let parenDepth = 1, i = 0; + while (i < afterFirst.length && parenDepth > 0) { + if (afterFirst[i] === '(') parenDepth++; + else if (afterFirst[i] === ')') parenDepth--; + i++; } + afterFirst = afterFirst.substring(i).trim(); + // Parse remaining chain members: .Method().Prop.Other() + const calls = this.parseChainMembers(afterFirst); - // Each subsequent call is a method on the current type - for (let j = 1; j < calls.length; j++) { - const methodName = calls[j]; - const nextType = this.resolveMethodReturnType(currentType, methodName); - - if (!nextType) { - return null; - } - - currentType = nextType; + // Resolve the first function's return type + const firstTypeNode = this.resolveFunctionReturnTypeNode(firstFunc); + if (!firstTypeNode?.identifier) return null; + + let currentType = firstTypeNode.identifier; + + // Resolve typedef and build initial template map + let templateMap: Map; + const typedefNode = this.resolveTypedefNode(currentType); + if (typedefNode) { + currentType = typedefNode.oldType.identifier; + templateMap = this.buildTemplateMap(currentType, typedefNode.oldType.genericArgs); + } else { + templateMap = this.buildTemplateMap(currentType, firstTypeNode.genericArgs); } - return currentType; + // If no chained calls, return the first function's resolved type + if (calls.length === 0) return currentType; + + // Delegate remaining chain steps + return this.resolveChainSteps(calls, currentType, templateMap); + } + + /** + * Resolve the return type of a variable method/property chain like "testMap.Get(key)". + * Resolves the variable's type (through typedefs), builds the template map, + * then delegates to resolveChainSteps for the member accesses. + * + * Used by type mismatch checking (Patterns 5 & 6) to detect errors like: + * int x = testMap.Get("key"); // map.Get returns string, not int + * + * @param varType The declared type of the variable (may be a typedef alias) + * @param chainText The chain text after the variable (e.g., ".Get(key)") + * @returns The resolved concrete return type, or null if unresolvable + */ + private resolveVariableChainType(varType: string, chainText: string): string | null { + const calls = this.parseChainMembers(chainText); + if (calls.length === 0) return null; + + // Resolve starting type through typedef and build template map + let currentType = varType; + let templateMap: Map; + const typedefNode = this.resolveTypedefNode(currentType); + if (typedefNode) { + currentType = typedefNode.oldType.identifier; + templateMap = this.buildTemplateMap(currentType, typedefNode.oldType.genericArgs); + } else { + templateMap = new Map(); + } + + return this.resolveChainSteps(calls, currentType, templateMap); } /** @@ -802,17 +1030,33 @@ export class Analyzer { } /** - * Get member completions for a class type (methods + fields) - * Walks the FULL inheritance chain INCLUDING modded classes + * Get member completions for a class type (methods + fields). + * Walks the FULL inheritance chain INCLUDING modded classes. + * + * @param className The resolved class name (e.g., "map", not the typedef alias) + * @param prefix Filter prefix for completion items (case-insensitive) + * @param templateMap Optional map of generic param names → concrete types. + * When provided, substitutes generic names in completion details. + * e.g., { "TKey": "string", "TValue": "int" } would show + * Get() as returning "int" instead of "TValue". */ - private getClassMemberCompletions(className: string, prefix: string): CompletionResult[] { + private getClassMemberCompletions(className: string, prefix: string, templateMap?: Map): CompletionResult[] { const results: CompletionResult[] = []; const seen = new Set(); // Deduplicate by name + // Helper to substitute generic type names with concrete types from templateMap. + // e.g., subst("TValue") → "string" when templateMap has { TValue: "string" } + // Returns the original name unchanged if not in the map or map is empty. + const subst = (typeName: string | undefined): string | undefined => { + if (!typeName || !templateMap || templateMap.size === 0) return typeName; + return templateMap.get(typeName) || typeName; + }; // Get the complete class hierarchy including modded classes const classHierarchy = this.getClassHierarchyOrdered(className, new Set()); + // Collect all class names in the hierarchy to filter out constructors/destructors + const classNames = new Set(classHierarchy.map(c => c.name)); for (const classNode of classHierarchy) { for (const member of classNode.members || []) { @@ -823,14 +1067,19 @@ export class Analyzer { // Skip static members for instance completions if (member.modifiers?.includes('static')) continue; + // Skip constructors and destructors — not valid for instance dot-access + if (classNames.has(member.name) || member.name.startsWith('~')) continue; + seen.add(member.name); if (member.kind === 'FunctionDecl') { const func = member as FunctionDeclNode; const params = func.parameters?.map(p => - `${p.type?.identifier || 'auto'} ${p.name}` + `${subst(p.type?.identifier) || 'auto'} ${p.name}` ).join(', ') || ''; + const resolvedReturnType = subst(func.returnType?.identifier) || 'void'; + // Show visibility modifier if present const visibility = func.modifiers?.find(m => ['private', 'protected'].includes(m)) || ''; const visPrefix = visibility ? `${visibility} ` : ''; @@ -838,19 +1087,20 @@ export class Analyzer { results.push({ name: func.name, kind: 'function', - detail: `${visPrefix}${func.returnType?.identifier || 'void'} - ${classNode.name}`, + detail: `${visPrefix}${resolvedReturnType}(${params}) - ${classNode.name}`, insertText: `${func.name}()`, - returnType: func.returnType?.identifier + returnType: resolvedReturnType }); } else if (member.kind === 'VarDecl') { const field = member as VarDeclNode; + const resolvedFieldType = subst(field.type?.identifier) || 'auto'; const visibility = field.modifiers?.find(m => ['private', 'protected'].includes(m)) || ''; const visPrefix = visibility ? `${visibility} ` : ''; results.push({ name: field.name, kind: 'variable', - detail: `${visPrefix}${field.type?.identifier || 'auto'} - ${classNode.name}` + detail: `${visPrefix}${resolvedFieldType} - ${classNode.name}` }); } } @@ -1022,9 +1272,12 @@ export class Analyzer { if (memberMatch) { // MEMBER ACCESS: Resolve the variable type and search only that class hierarchy const varName = memberMatch[1]; - const varType = this.resolveVariableType(doc, _pos, varName); + let varType = this.resolveVariableType(doc, _pos, varName); if (varType) { + // Resolve through typedefs so go-to-definition works on typedef'd variables + // e.g., testMap.Get → varType="testMapType" → resolve to "map" → find Get in map hierarchy + varType = this.resolveTypedef(varType); const classMatches = this.findMemberInClassHierarchy(varType, name); if (classMatches.length > 0) { return classMatches; @@ -1566,8 +1819,10 @@ export class Analyzer { const typeName = match[1]; const varName = match[2]; - // Skip if it looks like a function call or keyword - if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', 'else', 'foreach', 'void', 'override', 'static', 'private', 'protected', 'const', 'ref', 'autoptr', 'proto', 'native', 'modded', 'sealed', 'event'].includes(typeName)) { + // Skip keywords that are not actual type names. + // 'typedef' is included because lines like "typedef set TFloatSet;" + // match the varDeclPattern as type=typedef, name=set — which is a false positive. + if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', 'else', 'foreach', 'void', 'override', 'static', 'private', 'protected', 'const', 'ref', 'autoptr', 'proto', 'native', 'modded', 'sealed', 'event', 'typedef'].includes(typeName)) { continue; } @@ -1691,6 +1946,17 @@ export class Analyzer { return { compatible: true, isDowncast: false, isUpcast: false }; } + // Skip if either type is an unresolvable template parameter (TKey, TValue, T, etc.) + // These can't be checked without full template substitution, so assume compatible. + // Check: if the type doesn't exist as a known class, enum, or typedef in the index, + // it's likely a template parameter or something we can't verify. + const hardcodedPrimitives = new Set(['int', 'float', 'bool', 'string', 'void', 'vector']); + const declIsKnown = hardcodedPrimitives.has(declLower) || this.findAllClassesByName(declNorm).length > 0; + const assignIsKnown = hardcodedPrimitives.has(assignLower) || this.findAllClassesByName(assignNorm).length > 0; + if (!declIsKnown || !assignIsKnown) { + return { compatible: true, isDowncast: false, isUpcast: false }; // Unresolvable type + } + // array types - need to check element type compatibility if (declNorm.startsWith('array<') || assignNorm.startsWith('array<')) { const bothArrays = declNorm.startsWith('array') && assignNorm.startsWith('array'); @@ -1731,8 +1997,6 @@ export class Analyzer { return { compatible: true, isDowncast: false, isUpcast: false }; } - // Hardcoded primitives - used as fallback when not indexed - const hardcodedPrimitives = new Set(['int', 'float', 'bool', 'string', 'void', 'vector']); const declIsPrimitive = hardcodedPrimitives.has(declLower); const assignIsPrimitive = hardcodedPrimitives.has(assignLower); @@ -1769,14 +2033,6 @@ export class Analyzer { }; } - // If we can't determine the types (not in cache), assume compatible - const declExists = this.findAllClassesByName(declNorm).length > 0; - const assignExists = this.findAllClassesByName(assignNorm).length > 0; - - if (!declExists || !assignExists) { - return { compatible: true, isDowncast: false, isUpcast: false }; // Unknown types - } - // No compatibility found - types are unrelated return { compatible: false, @@ -2126,7 +2382,7 @@ export class Analyzer { const funcName = match[3]; // Skip if declared type is a keyword that's not a type - if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum'].includes(declaredType)) { + if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', 'typedef'].includes(declaredType)) { continue; } @@ -2154,25 +2410,56 @@ export class Analyzer { let highlightLength = match[0].length; // Default to just the match if (chainDetected) { - // Find the end of the full statement (semicolon or closing paren+semicolon) - // Much simpler: just find where the statement ends + // Find end of the chain by tracking parens and dot-access patterns. + // Stop at operators (+, -, *, etc.) or semicolons outside the chain. const stmtEnd = afterMatch.indexOf(';'); let chainEnd = stmtEnd >= 0 ? stmtEnd : afterMatch.length; - // For highlighting, we want to include up to but not including the semicolon - // But we need to find where the actual chain ends (last closing paren before semicolon) - let lastParen = chainEnd; - for (let i = chainEnd - 1; i >= 0; i--) { - if (afterMatch[i] === ')') { - lastParen = i + 1; - break; - } - } - chainEnd = lastParen; - + // For type resolution, pass everything up to ';' - resolveChainReturnType + // handles trailing non-chain text gracefully const fullChainText = funcName + '(' + afterMatch.substring(0, chainEnd); returnType = this.resolveChainReturnType(fullChainText); - highlightLength = match[0].length + chainEnd; + + // For highlight, find where the chain actually ends (last ')' or property name) + // by scanning: balanced parens, then optional .identifier or .identifier(...) + let hlEnd = 0; + let depth = 1; + // First: find closing paren of first call + for (let ci = 0; ci < afterMatch.length && depth > 0; ci++) { + if (afterMatch[ci] === '(') depth++; + else if (afterMatch[ci] === ')') depth--; + if (depth === 0) { hlEnd = ci + 1; break; } + } + // Then: continue following .identifier and .identifier(...) + let pos = hlEnd; + while (pos < afterMatch.length) { + // Skip whitespace + let ws = pos; + while (ws < afterMatch.length && (afterMatch[ws] === ' ' || afterMatch[ws] === '\t')) ws++; + if (ws >= afterMatch.length || afterMatch[ws] !== '.') break; + ws++; // skip dot + while (ws < afterMatch.length && (afterMatch[ws] === ' ' || afterMatch[ws] === '\t')) ws++; + // Match identifier + const idStart = ws; + while (ws < afterMatch.length && /\w/.test(afterMatch[ws])) ws++; + if (ws === idStart) break; // no identifier after dot + pos = ws; + hlEnd = pos; + // Check for (...) + let ps = pos; + while (ps < afterMatch.length && (afterMatch[ps] === ' ' || afterMatch[ps] === '\t')) ps++; + if (ps < afterMatch.length && afterMatch[ps] === '(') { + depth = 1; ps++; + while (ps < afterMatch.length && depth > 0) { + if (afterMatch[ps] === '(') depth++; + else if (afterMatch[ps] === ')') depth--; + ps++; + } + pos = ps; + hlEnd = pos; + } + } + highlightLength = match[0].length + hlEnd; } else { // Get the return type of the single function // Need to find where single function call ends @@ -2219,7 +2506,7 @@ export class Analyzer { // Skip if declared type is a keyword - if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', 'else'].includes(declaredType)) { + if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', 'else', 'typedef'].includes(declaredType)) { continue; } @@ -2344,16 +2631,41 @@ export class Analyzer { const stmtEnd = afterMatch.indexOf(';'); chainEnd = stmtEnd >= 0 ? stmtEnd : afterMatch.length; - // Find where the actual chain ends (last closing paren before semicolon) - for (let i = chainEnd - 1; i >= 0; i--) { - if (afterMatch[i] === ')') { - chainEnd = i + 1; - break; - } - } - const fullChainText = funcName + '(' + afterMatch.substring(0, chainEnd); returnType = this.resolveChainReturnType(fullChainText); + + // For highlight, find where the chain actually ends (last ')' or property name) + let hlEnd = 0; + let depth2 = 1; + for (let ci = 0; ci < afterMatch.length && depth2 > 0; ci++) { + if (afterMatch[ci] === '(') depth2++; + else if (afterMatch[ci] === ')') depth2--; + if (depth2 === 0) { hlEnd = ci + 1; break; } + } + let pos = hlEnd; + while (pos < afterMatch.length) { + let ws = pos; + while (ws < afterMatch.length && (afterMatch[ws] === ' ' || afterMatch[ws] === '\t')) ws++; + if (ws >= afterMatch.length || afterMatch[ws] !== '.') break; + ws++; + while (ws < afterMatch.length && (afterMatch[ws] === ' ' || afterMatch[ws] === '\t')) ws++; + const idStart = ws; + while (ws < afterMatch.length && /\w/.test(afterMatch[ws])) ws++; + if (ws === idStart) break; + pos = ws; hlEnd = pos; + let ps = pos; + while (ps < afterMatch.length && (afterMatch[ps] === ' ' || afterMatch[ps] === '\t')) ps++; + if (ps < afterMatch.length && afterMatch[ps] === '(') { + depth2 = 1; ps++; + while (ps < afterMatch.length && depth2 > 0) { + if (afterMatch[ps] === '(') depth2++; + else if (afterMatch[ps] === ')') depth2--; + ps++; + } + pos = ps; hlEnd = pos; + } + } + chainEnd = hlEnd; } else { // Find where single function call ends let depth = 1; @@ -2383,6 +2695,99 @@ export class Analyzer { this.addTypeMismatchDiagnostic(doc, diags, actualStart, actualLength, targetType, returnType); } } + + // ================================================================ + // Pattern 5: Type varName = someVar.Method(); + // ================================================================ + // Detects type mismatches in declarations where the RHS is a variable + // method chain. Example: + // typedef map TMap; + // TMap m; + // int x = m.Get("key"); // ERROR: Get returns string, not int + // + // Uses resolveVariableChainType to resolve the chain through typedefs + // and template substitution. + // ================================================================ + const varChainDeclPattern = /\b(\w+)[ \t]+(\w+)\s*=\s*(\w+)\s*\./g; + + while ((match = varChainDeclPattern.exec(text)) !== null) { + if (isInsideCommentOrString(match.index)) continue; + if (match[0].includes('\n')) continue; + + const declaredType = match[1]; + const varName = match[2]; + const sourceVar = match[3]; + + if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', 'typedef'].includes(declaredType)) continue; + + // Get the type of the source variable + const lineNum = getLineFromPos(match.index); + const sourceVarType = getVarTypeAtLine(sourceVar, lineNum); + if (!sourceVarType) continue; + + // Get the chain text from the dot onwards + const afterDot = text.substring(match.index + match[0].length); + const stmtEnd = afterDot.indexOf(';'); + const chainText = '.' + afterDot.substring(0, stmtEnd >= 0 ? stmtEnd : afterDot.length); + + // Resolve the chain + const returnType = this.resolveVariableChainType(sourceVarType, chainText); + if (!returnType) continue; + + // Calculate highlight: from match start to end of chain (before semicolon) + const highlightLength = match[0].length + (stmtEnd >= 0 ? stmtEnd : afterDot.length); + + this.addTypeMismatchDiagnostic(doc, diags, match.index, highlightLength, declaredType, returnType); + } + + // ================================================================ + // Pattern 6: varName = someVar.Method(); (reassignment) + // ================================================================ + // Same as Pattern 5 but for reassignments where the variable was + // already declared. Looks up the target variable's type from the scope. + // Example: + // string s; + // s = testMap.Get("key"); // OK: string = string + // int n; + // n = testMap.Get("key"); // ERROR: int ≠ string + // + // The regex starts with a statement boundary (;, {, }, ), newline) + // to avoid matching inside expressions. + // ================================================================ + const varChainReassignPattern = /(?:^|[;{})\n])(\s*)(\w+)\s*=\s*(\w+)\s*\./g; + + while ((match = varChainReassignPattern.exec(text)) !== null) { + if (isInsideCommentOrString(match.index)) continue; + + const leadingWs = match[1]; + const targetVar = match[2]; + const sourceVar = match[3]; + + const coreP6 = match[0].substring(1 + leadingWs.length); + if (coreP6.includes('\n')) continue; + + if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'else'].includes(targetVar)) continue; + + const lineNum = getLineFromPos(match.index); + const targetType = getVarTypeAtLine(targetVar, lineNum); + const sourceVarType = getVarTypeAtLine(sourceVar, lineNum); + if (!targetType || !sourceVarType) continue; + + // Get chain text + const afterDot = text.substring(match.index + match[0].length); + const stmtEnd = afterDot.indexOf(';'); + const chainText = '.' + afterDot.substring(0, stmtEnd >= 0 ? stmtEnd : afterDot.length); + + const returnType = this.resolveVariableChainType(sourceVarType, chainText); + if (!returnType) continue; + + if (/^[A-Z]$/.test(targetType) || /^[A-Z]$/.test(returnType)) continue; + if (targetType === returnType) continue; + + const actualStart = match.index + 1 + leadingWs.length; + const actualLength = match[0].length - 1 - leadingWs.length + (stmtEnd >= 0 ? stmtEnd : afterDot.length); + this.addTypeMismatchDiagnostic(doc, diags, actualStart, actualLength, targetType, returnType); + } } /** diff --git a/syntaxes/enscript.tmLanguage.json b/syntaxes/enscript.tmLanguage.json index e6e0966..a02366a 100644 --- a/syntaxes/enscript.tmLanguage.json +++ b/syntaxes/enscript.tmLanguage.json @@ -88,6 +88,9 @@ { "include": "#enum-declaration" }, + { + "include": "#typedef-declaration" + }, { "include": "#interface-declaration" }, @@ -713,6 +716,24 @@ } ] }, + "typedef-declaration": { + "match": "(? Date: Fri, 6 Feb 2026 14:08:23 -0500 Subject: [PATCH 07/46] Support multi-level chained member completion Add handling for multi-dot/member chains (e.g. a.b.c or obj.Get("x").Field) so completion can resolve through intermediate calls/fields and return members for the final resolved type. Introduces detection/parse logic for chain segments, builds template substitution maps for typedefs/generics, and resolves each chain step. Change resolveChainSteps to return both the final type and template map ({type, templateMap}) and update its callers to unwrap that result when needed. Uses the resolved type to fetch class member completions via existing lookup helpers. --- server/src/analysis/project/graph.ts | 94 ++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 5 deletions(-) diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 8ff7d4a..97d53ba 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -329,6 +329,50 @@ export class Analyzer { // Check if we're after a dot (member completion) const textBeforeCursor = text.substring(0, offset); + // ================================================================ + // MULTI-LEVEL CHAIN COMPLETION + // ================================================================ + // Handles chains like: param.param4.G or param.Get("x").To + // Detects 2+ dot-separated segments, resolves through the chain + // (with typedef/template substitution), and offers completions + // for the final resolved type. + // ================================================================ + const multiDotMatch = textBeforeCursor.match(/(\w+)((?:\s*\.\s*\w+(?:\s*\([^)]*\))?)+)\s*\.\s*(\w*)$/); + if (multiDotMatch) { + const rootName = multiDotMatch[1]; + const middleChain = multiDotMatch[2]; // e.g., ".param4" or ".Get(key).field" + const prefix = multiDotMatch[3] || ''; + + // Resolve the root variable's type + const rootType = this.resolveVariableType(doc, pos, rootName); + if (rootType) { + // Resolve root through typedef and build initial template map + let currentType = rootType; + let templateMap: Map; + const typedefNode = this.resolveTypedefNode(currentType); + if (typedefNode) { + currentType = typedefNode.oldType.identifier; + templateMap = this.buildTemplateMap(currentType, typedefNode.oldType.genericArgs); + } else { + const varTypeNode = this.resolveVariableTypeNode(doc, pos, rootName); + if (varTypeNode?.genericArgs && varTypeNode.genericArgs.length > 0) { + templateMap = this.buildTemplateMap(currentType, varTypeNode.genericArgs); + } else { + templateMap = new Map(); + } + } + + // Resolve through the middle chain steps + const chainMembers = this.parseChainMembers(middleChain); + if (chainMembers.length > 0) { + const result = this.resolveChainSteps(chainMembers, currentType, templateMap); + if (result) { + return this.getClassMemberCompletions(result.type, prefix, result.templateMap.size > 0 ? result.templateMap : undefined); + } + } + } + } + // Match both variable.method and function().method patterns // Pattern 1: variable. or variable.prefix // Pattern 2: function(). or function().prefix @@ -857,13 +901,13 @@ export class Analyzer { * @param calls Ordered member names to resolve (e.g., ["Get", "Length"]) * @param currentType The starting type (already typedef-resolved) * @param templateMap The starting template substitution map - * @returns The final resolved type, or null if any step fails + * @returns The final resolved type and template map, or null if any step fails */ private resolveChainSteps( calls: string[], currentType: string, templateMap: Map - ): string | null { + ): { type: string; templateMap: Map } | null { for (const memberName of calls) { const nextTypeNode = this.resolveMethodReturnTypeNode(currentType, memberName); if (!nextTypeNode?.identifier) return null; @@ -899,7 +943,7 @@ export class Analyzer { currentType = resolvedType; } - return currentType; + return { type: currentType, templateMap }; } /** @@ -948,7 +992,7 @@ export class Analyzer { if (calls.length === 0) return currentType; // Delegate remaining chain steps - return this.resolveChainSteps(calls, currentType, templateMap); + return this.resolveChainSteps(calls, currentType, templateMap)?.type ?? null; } /** @@ -978,7 +1022,7 @@ export class Analyzer { templateMap = new Map(); } - return this.resolveChainSteps(calls, currentType, templateMap); + return this.resolveChainSteps(calls, currentType, templateMap)?.type ?? null; } /** @@ -1263,6 +1307,46 @@ export class Analyzer { // Look backwards from the token start to find a dot const textBeforeToken = text.substring(0, token.start); + // Multi-level chain: e.g., param.param4.GetSomething or param.Get("x").field + // Captures root variable + middle chain segments before the final dot + const multiLevelMatch = textBeforeToken.match(/(\w+)((?:\s*\.\s*\w+(?:\s*\([^)]*\))?)+)\s*\.\s*$/); + if (multiLevelMatch) { + const rootName = multiLevelMatch[1]; + const middleChain = multiLevelMatch[2]; // e.g., ".param4" or ".Get(key).field" + + let rootType = this.resolveVariableType(doc, _pos, rootName); + if (rootType) { + // Resolve root through typedef and build initial template map + let currentType = rootType; + let templateMap: Map; + const typedefNode = this.resolveTypedefNode(currentType); + if (typedefNode) { + currentType = typedefNode.oldType.identifier; + templateMap = this.buildTemplateMap(currentType, typedefNode.oldType.genericArgs); + } else { + currentType = this.resolveTypedef(currentType); + const varTypeNode = this.resolveVariableTypeNode(doc, _pos, rootName); + if (varTypeNode?.genericArgs && varTypeNode.genericArgs.length > 0) { + templateMap = this.buildTemplateMap(currentType, varTypeNode.genericArgs); + } else { + templateMap = new Map(); + } + } + + // Resolve through the middle chain to get the final type + const chainMembers = this.parseChainMembers(middleChain); + if (chainMembers.length > 0) { + const result = this.resolveChainSteps(chainMembers, currentType, templateMap); + if (result) { + const classMatches = this.findMemberInClassHierarchy(result.type, name); + if (classMatches.length > 0) { + return classMatches; + } + } + } + } + } + // Pattern 1: variable.method (e.g., player.GetInputType) const memberMatch = textBeforeToken.match(/(\w+)\s*\.\s*$/); From 0712ea8f8994820fa79f90958f7d98bf549ccfed Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sat, 7 Feb 2026 00:00:58 -0500 Subject: [PATCH 08/46] Improve parser and add call-argument validation Parser: add hasDefault to VarDeclNode and mark parameters with default values; skip remaining tokens when parsing defaults; better detection of local var declarations including generic/angle-bracket types by walking backwards to find the real type token; track token indices to compute accurate source ranges. Graph/Analyzer: add keyword blacklist and foreach regex fallback to avoid false-positive type matches; treat array-indexing chains as unresolved to avoid spurious type results; make getClassHierarchyOrdered resolve typedefs and fall back to implicit 'Class' root when bases are missing. Scope and declarations: broaden function/class/ proto/native regexes, populate class field scope from parent classes, track parent method signatures and warn on missing 'override' when appropriate, skip proto/native params for variable extraction, and recognize foreach loop variables. Add a comprehensive function-call argument validator: collect overloads, parse balanced argument lists, infer argument types, resolve chains/method contexts, and validate argument counts and types against overloads (reporting detailed diagnostics). Overall these changes improve AST accuracy, reduce false positives, and add checks for incorrect function calls and missing overrides. --- server/src/analysis/ast/parser.ts | 48 +- server/src/analysis/project/graph.ts | 893 ++++++++++++++++++++++++++- 2 files changed, 919 insertions(+), 22 deletions(-) diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index 613a2b1..b0055dd 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -150,6 +150,7 @@ export interface TypedefNode extends SymbolNodeBase { export interface VarDeclNode extends SymbolNodeBase { kind: 'VarDecl'; type: TypeNode; + hasDefault?: boolean; // true if parameter has a default value (e.g., int x = 5) } export interface FunctionDeclNode extends SymbolNodeBase { @@ -261,7 +262,7 @@ export function parse( expect('('); while (!eof() && peek().value !== ')') { const varDecl = expectVarDecl(doc, true); - // ignore default values + // Skip any remaining tokens until ')' or ',' (default values are already consumed by parseDecl) while (!eof() && peek().value !== ')' && peek().value !== ',') next(); @@ -510,9 +511,12 @@ export function parse( // Track previous tokens to detect local variable declarations // Pattern: [modifiers...] TypeName VarName (= | ; | ,) let prevPrev: Token | null = null; + let prevPrevIdx = -1; let prev: Token | null = null; + let prevIdx = -1; while (depth > 0 && !eof()) { const t = next(); + const tIdx = pos - 1; // index of the token that next() just returned if (t.value === '{') depth++; else if (t.value === '}') depth--; // Detect ternary operator (condition ? true : false) @@ -543,9 +547,34 @@ export function parse( // Detect local variable declarations: // TypeName varName ; or TypeName varName = or TypeName varName , // prevPrev = type token, prev = name token, t = ; or = or , + // For generic types like array, prevPrev is '>' — we need to + // walk backwards past balanced angle brackets to find the actual type name. if (prev && prevPrev && (t.value === ';' || t.value === '=' || t.value === ',')) { - const isTypeTok = prevPrev.kind === TokenKind.Identifier - || (prevPrev.kind === TokenKind.Keyword && isPrimitiveType(prevPrev.value)); + let typeTok = prevPrev; + if (prevPrev.value === '>') { + // Walk backwards through tokens to find matching '<' and the type before it + let angleDepth = 1; + let searchPos = prevPrevIdx - 1; // start before the '>' + while (searchPos >= 0 && angleDepth > 0) { + const st = toks[searchPos]; + if (st.value === '>') angleDepth++; + else if (st.value === '<') angleDepth--; + searchPos--; + } + // After the loop, searchPos has been decremented past '<', + // so it now points to the type name token (e.g., 'array') + if (searchPos >= 0 && angleDepth === 0) { + // Skip past any trivia tokens (comments, preprocessor) + while (searchPos >= 0 && (toks[searchPos].kind === TokenKind.Comment || toks[searchPos].kind === TokenKind.Preproc)) { + searchPos--; + } + if (searchPos >= 0) { + typeTok = toks[searchPos]; + } + } + } + const isTypeTok = typeTok.kind === TokenKind.Identifier + || (typeTok.kind === TokenKind.Keyword && isPrimitiveType(typeTok.value)); const isNameTok = prev.kind === TokenKind.Identifier; if (isTypeTok && isNameTok) { locals.push({ @@ -557,22 +586,24 @@ export function parse( type: { kind: 'Type', uri: doc.uri, - identifier: prevPrev.value, - start: doc.positionAt(prevPrev.start), - end: doc.positionAt(prevPrev.end), + identifier: typeTok.value, + start: doc.positionAt(typeTok.start), + end: doc.positionAt(typeTok.end), arrayDims: [], modifiers: [], }, annotations: [], modifiers: [], - start: doc.positionAt(prevPrev.start), + start: doc.positionAt(typeTok.start), end: doc.positionAt(prev.end), }); } } prevPrev = prev; + prevPrevIdx = prevIdx; prev = t; + prevIdx = tIdx; } } @@ -595,6 +626,7 @@ export function parse( // variable const vars: VarDeclNode[] = []; + let sawDefault = false; while (!eof()) { const typeNode = structuredClone(baseTypeNode); @@ -611,6 +643,7 @@ export function parse( // value initialization (skip for now) if (peek().value === '=') { + sawDefault = true; next(); // Handle EOF after = (incomplete code) @@ -684,6 +717,7 @@ export function parse( nameStart: doc.positionAt(nameTok.start), nameEnd: doc.positionAt(nameTok.end), type: baseTypeNode, + hasDefault: sawDefault || undefined, annotations: annotations, modifiers: mods, start: baseTypeNode.start, diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 97d53ba..367a00b 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -599,10 +599,11 @@ export class Analyzer { // Regex fallbacks for cases the AST-based lookup misses // (e.g., variables in unparsed regions) const text = doc.getText(); + const regexKeywords = new Set(['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', 'else', 'foreach', 'void', 'override', 'static', 'private', 'protected', 'const', 'ref', 'autoptr', 'proto', 'native', 'modded', 'sealed', 'event', 'typedef', 'case', 'break', 'continue', 'this', 'super', 'null', 'true', 'false', 'out', 'inout', 'volatile']); // Pattern: Type varName; or Type varName = const varDeclMatch = text.match(new RegExp(`(\\w+)\\s+${varName}\\s*[;=]`)); - if (varDeclMatch) { + if (varDeclMatch && !regexKeywords.has(varDeclMatch[1])) { return varDeclMatch[1]; } @@ -618,6 +619,12 @@ export class Analyzer { return outParamMatch[1]; } + // Pattern: foreach (Type varName : collection) + const foreachMatch = text.match(new RegExp(`foreach\\s*\\(\\s*(\\w+)\\s+${varName}\\s*:`)); + if (foreachMatch && !regexKeywords.has(foreachMatch[1])) { + return foreachMatch[1]; + } + return null; } @@ -1022,7 +1029,19 @@ export class Analyzer { templateMap = new Map(); } - return this.resolveChainSteps(calls, currentType, templateMap)?.type ?? null; + const result = this.resolveChainSteps(calls, currentType, templateMap)?.type ?? null; + + // If the chain text ends with array indexing like [expr], + // the result is the element type, not the container type. + // Since we don't always know the element type, return null to avoid + // false type mismatch errors. + if (result && /\[.*\]\s*$/.test(chainText)) { + if (['array', 'set', 'map'].includes(result)) { + return null; + } + } + + return result; } /** @@ -1477,7 +1496,15 @@ export class Analyzer { // Find all classes with this name (original + modded versions) const classNodes = this.findAllClassesByName(className); - if (classNodes.length === 0) return []; + if (classNodes.length === 0) { + // className might be a typedef (e.g., typedef ItemBase InventoryItemSuper) + // Resolve through typedef and retry with the underlying type + const resolved = this.resolveTypedef(className); + if (resolved !== className) { + return this.getClassHierarchyOrdered(resolved, visited); + } + return []; + } // Separate original class from modded classes const originalClass = classNodes.find(c => !c.modifiers?.includes('modded')); @@ -1487,9 +1514,20 @@ export class Analyzer { const baseClassName = (originalClass || classNodes[0])?.base?.identifier; // Recursively get parent hierarchy FIRST (so base classes come first) - const parentHierarchy: ClassDeclNode[] = baseClassName - ? this.getClassHierarchyOrdered(baseClassName, visited) - : []; + // If no explicit base class, implicitly inherit from 'Class' (the root of all + // Enforce Script classes), so built-in methods like Cast, CastTo, etc. are found. + // Also: if explicit base can't be found (e.g. engine-internal class), still + // fall through to 'Class' so built-in methods are always available. + let parentHierarchy: ClassDeclNode[] = []; + if (baseClassName) { + parentHierarchy = this.getClassHierarchyOrdered(baseClassName, visited); + if (parentHierarchy.length === 0 && className !== 'Class') { + // Explicit base not found — still inherit from Class + parentHierarchy = this.getClassHierarchyOrdered('Class', visited); + } + } else if (className !== 'Class') { + parentHierarchy = this.getClassHierarchyOrdered('Class', visited); + } // Build result: parents first, then this class (original + modded) const result: ClassDeclNode[] = [...parentHierarchy]; @@ -1752,6 +1790,9 @@ export class Analyzer { // Check for type mismatches in assignments this.checkTypeMismatches(doc, diags); + + // Check function call arguments (param count and types) + this.checkFunctionCallArgs(doc, diags); } // Check for multi-line statements (not supported in Enforce Script) @@ -1789,13 +1830,18 @@ export class Analyzer { // Pattern to detect function declarations // Must have: optional modifiers, return type (including generics), function name, parentheses for params // Excludes: array access like m_Foo[0] and assignments - const funcDeclPattern = /^\s*(?:static\s+|private\s+|protected\s+|override\s+|proto\s+|native\s+)*(?:void|int|float|bool|string|auto|ref\s+\w+|[\w<>,\s]+)\s+(\w+)\s*\([^)]*\)\s*\{?\s*$/; + // Matches lines ending with { (normal), {} (empty body), ; (proto/native), or ) (brace on next line) + const funcDeclPattern = /^\s*(?:static\s+|private\s+|protected\s+|override\s+|proto\s+|native\s+|volatile\s+|event\s+)*(?:void|int|float|bool|string|auto|ref\s+\w+(?:\s*<[\w,\s<>]+>)?|\w+(?:\s*<[\w,\s<>]+>)?)\s+(\w+)\s*\([^)]*\)\s*(?:\{\}?|;)?\s*$/; + + // Pattern to detect proto/native function declarations (no body, just ;) + // Params in these don't create real variables — skip them for duplicate checking + const protoFuncPattern = /^\s*(?:static\s+|private\s+|protected\s+|override\s+|proto\s+|native\s+|volatile\s+|event\s+)*(?:proto|native)\s+/; // Pattern to detect for/foreach/while loops (their vars go to parent scope in Enforce) const loopPattern = /\b(for|foreach|while)\s*\(/; - // Pattern to detect class declarations - const classDeclPattern = /\b(?:modded\s+)?class\s+(\w+)/; + // Pattern to detect class declarations (handles template params like ) + const classDeclPattern = /\b(?:modded\s+)?class\s+(\w+)(?:<[^>]*>)?(?:\s*(?::\s*|\s+extends\s+)(\w+))?/; // Track when we enter/exit functions let inFunction = false; @@ -1806,6 +1852,10 @@ export class Analyzer { let classFieldScope: Map = new Map(); let inClass = false; let classBraceDepth = 0; + let currentClassName = ''; + // Track parent method names + param counts for missing override warnings + // Maps method name -> array of param counts from parent classes + let parentMethodSignatures: Map = new Map(); // Track block comments let inBlockComment = false; @@ -1865,13 +1915,41 @@ export class Analyzer { const lineNoCommentTrimmed = lineNoComment.trim(); // Check if this line starts a class - if (classDeclPattern.test(lineNoComment)) { + const classMatch = lineNoComment.match(classDeclPattern); + if (classMatch) { // Starting a new class - reset all class-related state inClass = true; classBraceDepth = braceDepth; classFieldScope = new Map(); // Fresh class field scope - // Reset scope stack to just global scope - scopeStack = [new Map()]; + currentClassName = classMatch[1]; + const baseClassName = classMatch[2]; + parentMethodSignatures = new Map(); + + // Pre-populate classFieldScope with inherited fields from parent classes + if (baseClassName) { + const hierarchy = this.getClassHierarchyOrdered(baseClassName, new Set()); + for (const parentClass of hierarchy) { + for (const member of parentClass.members || []) { + if (member.kind === 'VarDecl' && member.name) { + classFieldScope.set(member.name, { + line: member.start?.line ?? -1, + character: member.start?.character ?? 0 + }); + } + if (member.kind === 'FunctionDecl' && member.name) { + const paramCount = (member as any).parameters?.length ?? 0; + if (!parentMethodSignatures.has(member.name)) { + parentMethodSignatures.set(member.name, []); + } + parentMethodSignatures.get(member.name)!.push(paramCount); + } + } + } + } + + // Reset scope stack to global scope + class fields (with inherited fields) + // This ensures class-level field declarations are checked against parent fields + scopeStack = [new Map(), classFieldScope]; inFunction = false; } @@ -1881,15 +1959,56 @@ export class Analyzer { // Check if this line starts a new function // Must NOT be a loop line — for(int i ...) looks like a func decl to the regex - const isFuncDecl = !isLoopLine && funcDeclPattern.test(lineNoComment); + // Also exclude control flow statements (if, else, switch, do) which can false-match + const isControlFlow = /\b(if|else|switch|do|return|new|delete|throw)\b/.test(lineNoComment); + const isFuncDecl = !isLoopLine && !isControlFlow && funcDeclPattern.test(lineNoComment); + if (isFuncDecl) { // New function - reset to: global scope + class fields (copy) + new function scope // We must copy classFieldScope to avoid it being modified by function-local variables + const wasInFunction = inFunction; scopeStack = [scopeStack[0], new Map(classFieldScope), new Map()]; inFunction = true; functionBraceDepth = braceDepth; + + // Check for missing override keyword — only at class level, not inside a nested function + if (inClass && !wasInFunction && parentMethodSignatures.size > 0) { + const funcNameMatch = lineNoComment.match(funcDeclPattern); + if (funcNameMatch) { + const declaredFuncName = funcNameMatch[1]; + const hasOverride = /\boverride\b/.test(lineNoComment); + if (!hasOverride && parentMethodSignatures.has(declaredFuncName)) { + // Don't warn for constructors (function name === class name) + if (declaredFuncName !== currentClassName) { + // Extract param count from this declaration to compare with parent + // Only warn if a parent has a method with the SAME param count (true override) + // Different param counts = overload, not override + const paramStr = lineNoComment.match(/\(([^)]*)\)/); + const childParamCount = paramStr && paramStr[1].trim() !== '' + ? paramStr[1].split(',').length + : 0; + const parentParamCounts = parentMethodSignatures.get(declaredFuncName)!; + if (parentParamCounts.includes(childParamCount)) { + const fnStart = lineNoComment.indexOf(declaredFuncName); + diags.push({ + message: `Method '${declaredFuncName}' overrides a method from a parent class but is missing the 'override' keyword.`, + range: { + start: { line: lineNum, character: fnStart }, + end: { line: lineNum, character: fnStart + declaredFuncName.length } + }, + severity: DiagnosticSeverity.Warning + }); + } + } + } + } + } } + // Skip variable extraction for proto/native function declarations — + // their parameters don't create real variables in any scope + const isProtoOrNative = protoFuncPattern.test(lineNoComment); + // FIRST: Find variable declarations on this line BEFORE processing braces // This ensures for loop variables (int j in "for (int j = 0...") // are added to the current scope before we push a new scope for { @@ -1899,7 +2018,7 @@ export class Analyzer { // Use lineNoComment but keep original line for position calculation const lineForVars = lineNoComment; - while ((match = varDeclPattern.exec(lineForVars)) !== null) { + while (!isProtoOrNative && (match = varDeclPattern.exec(lineForVars)) !== null) { const typeName = match[1]; const varName = match[2]; @@ -2084,8 +2203,12 @@ export class Analyzer { const declIsPrimitive = hardcodedPrimitives.has(declLower); const assignIsPrimitive = hardcodedPrimitives.has(assignLower); - // string is only compatible with string + // string is compatible with string, and string can be implicitly converted to vector + // in Enforce Script (e.g., "0 0 0" is a valid vector literal) if (declLower === 'string' || assignLower === 'string') { + if (declLower === 'vector' && assignLower === 'string') { + return { compatible: true, isDowncast: false, isUpcast: false }; + } if (declLower !== assignLower) { return { compatible: false, @@ -2331,6 +2454,13 @@ export class Analyzer { addScopedVar(varName, typeName, lineIdx, funcEnd, false); } + // Also match foreach variable declarations: + // foreach (Type varName : collection) + const foreachPattern = /\bforeach\s*\(\s*([A-Z]\w+|int|float|bool|string|auto)\s+(\w+)\s*:/g; + let fm; + while ((fm = foreachPattern.exec(line)) !== null) { + addScopedVar(fm[2], fm[1], lineIdx, funcEnd, false); + } } } } @@ -2379,6 +2509,13 @@ export class Analyzer { addScopedVar(varName, typeName, lineIdx, funcEnd, false); } + // Also match foreach variable declarations: + // foreach (Type varName : collection) + const foreachPatternTL = /\bforeach\s*\(\s*([A-Z]\w+|int|float|bool|string|auto)\s+(\w+)\s*:/g; + let fmTL; + while ((fmTL = foreachPatternTL.exec(line)) !== null) { + addScopedVar(fmTL[2], fmTL[1], lineIdx, funcEnd, false); + } } } } @@ -2874,6 +3011,732 @@ export class Analyzer { } } + // ======================================================================== + // FUNCTION CALL ARGUMENT VALIDATION + // ======================================================================== + // Validates function/method call arguments: + // 1. Argument count — too few (missing required params) or too many + // 2. Argument types — each argument's type vs the parameter's declared type + // + // Handles: + // - Overloaded functions (multiple declarations with same name) + // - Default parameter values (params with defaults are optional) + // - Constructor calls (new ClassName(...)) + // - Method calls (obj.Method(...)) + // - Global function calls (FuncName(...)) + // - out/inout parameter modifiers + // + // A call is valid if ANY overload accepts it. Errors are only reported + // when NO overload matches. + // ======================================================================== + + /** + * Find all overloads of a function/method by name. + * For methods, searches the class hierarchy. For globals, searches all files. + * Returns all FunctionDeclNode[] with that name. + */ + private findFunctionOverloads(funcName: string, className?: string): FunctionDeclNode[] { + const overloads: FunctionDeclNode[] = []; + + if (className) { + // Method: search class hierarchy + const resolved = this.resolveTypedef(className); + const hierarchy = this.getClassHierarchyOrdered(resolved, new Set()); + for (const classNode of hierarchy) { + for (const member of classNode.members || []) { + if (member.kind === 'FunctionDecl' && member.name === funcName) { + overloads.push(member as FunctionDeclNode); + } + } + } + } else { + // Global function: search all files + for (const [uri, ast] of this.docCache) { + for (const node of ast.body) { + if (node.kind === 'FunctionDecl' && node.name === funcName) { + overloads.push(node as FunctionDeclNode); + } + // Also check class methods (for unqualified calls from within a class) + if (node.kind === 'ClassDecl') { + for (const member of (node as ClassDeclNode).members || []) { + if (member.kind === 'FunctionDecl' && member.name === funcName) { + overloads.push(member as FunctionDeclNode); + } + } + } + } + } + } + + return overloads; + } + + /** + * Parse the argument list text of a function call into individual argument strings. + * Handles nested parentheses, brackets, strings, and template args. + * e.g., 'a, Func(b, c), "hello, world"' → ["a", "Func(b, c)", '"hello, world"'] + */ + private parseCallArguments(argsText: string): string[] { + const args: string[] = []; + let depth = 0; // () depth + let bracketDepth = 0; // <> and [] depth + let braceDepth = 0; // {} depth (array literals like {1,2,3}) + let inString = false; + let stringChar = ''; + let current = ''; + + for (let i = 0; i < argsText.length; i++) { + const ch = argsText[i]; + + // Handle strings + if (!inString && (ch === '"' || ch === "'")) { + inString = true; + stringChar = ch; + current += ch; + continue; + } + if (inString) { + current += ch; + if (ch === stringChar && (i === 0 || argsText[i - 1] !== '\\')) { + inString = false; + } + continue; + } + + // Track nesting + if (ch === '(' || ch === '[') { depth++; current += ch; continue; } + if (ch === ')' || ch === ']') { depth--; current += ch; continue; } + if (ch === '<') { bracketDepth++; current += ch; continue; } + if (ch === '>') { bracketDepth--; current += ch; continue; } + if (ch === '{') { braceDepth++; current += ch; continue; } + if (ch === '}') { braceDepth--; current += ch; continue; } + + // Split on comma only at top level (no nesting of any kind) + if (ch === ',' && depth === 0 && bracketDepth === 0 && braceDepth === 0) { + args.push(current.trim()); + current = ''; + continue; + } + + current += ch; + } + + const last = current.trim(); + if (last) args.push(last); + + return args; + } + + /** + * Infer the type of a call argument expression. + * Returns the type name or null if unresolvable. + */ + private inferArgType( + argText: string, + getVarType: (name: string) => string | undefined + ): string | null { + const arg = argText.trim(); + if (!arg) return null; + + // String literal + if (arg.startsWith('"') || arg.startsWith("'")) return 'string'; + + // Numeric literal + if (/^-?\d+$/.test(arg)) return 'int'; + if (/^-?\d+\.\d*$/.test(arg) || /^-?\.\d+$/.test(arg)) return 'float'; + + // Boolean literal + if (arg === 'true' || arg === 'false') return 'bool'; + + // null + if (arg === 'null' || arg === 'NULL') return null; // null is compatible with any ref type + + // new ClassName(...) + const newMatch = arg.match(/^new\s+(\w+)/); + if (newMatch) return newMatch[1]; + + // Cast: ClassName.Cast(expr) or ClassName.Cast(expr).Chain(...) + const castMatch = arg.match(/^(\w+)\.Cast\s*\(/); + if (castMatch) { + const castType = castMatch[1]; + // Skip past the balanced parens of Cast(...) + const openIdx = arg.indexOf('(', castMatch[0].length - 1); + let depth = 1; + let i = openIdx + 1; + while (i < arg.length && depth > 0) { + const ch = arg[i]; + if (ch === '(') depth++; + else if (ch === ')') depth--; + else if (ch === '"' || ch === "'") { + const q = ch; + i++; + while (i < arg.length && arg[i] !== q) { + if (arg[i] === '\\') i++; + i++; + } + } + i++; + } + // Check if there's a chain after Cast(...): e.g., .GetCurrentSkinIdx() + const afterCast = arg.substring(i).trim(); + if (afterCast.startsWith('.')) { + return this.resolveVariableChainType(castType, afterCast); + } + return castType; + } + + // Simple variable reference + if (/^\w+$/.test(arg)) { + const varType = getVarType(arg); + if (varType) return varType; + // Could be an enum value or class name - can't determine type + return null; + } + + // Function call: FuncName(...) or FuncName(...).Chain(...) + const funcCallMatch = arg.match(/^(\w+)\s*\(/); + if (funcCallMatch) { + const rootType = this.resolveFunctionReturnType(funcCallMatch[1]); + if (rootType) { + // Check if there's a chain after the closing paren: FuncName(...).Something + const openIdx = arg.indexOf('('); + let depth = 1; + let i = openIdx + 1; + while (i < arg.length && depth > 0) { + const ch = arg[i]; + if (ch === '(') depth++; + else if (ch === ')') depth--; + else if (ch === '"' || ch === "'") { + const q = ch; + i++; + while (i < arg.length && arg[i] !== q) { + if (arg[i] === '\\') i++; + i++; + } + } + i++; + } + // i now points just past the closing paren + const afterCall = arg.substring(i).trim(); + if (afterCall.startsWith('.')) { + // There's a chain after the function call — resolve it + return this.resolveVariableChainType(rootType, afterCall); + } + } + return rootType; + } + + // Method chain: var.Method(...) + const chainMatch = arg.match(/^(\w+)\s*\./); + if (chainMatch) { + const varType = getVarType(chainMatch[1]); + if (varType) { + const chainText = arg.substring(chainMatch[0].length - 1); // keep the dot + // If the chain ends with a method call, use resolveVariableChainType + if (chainText.includes('(')) { + return this.resolveVariableChainType(varType, chainText); + } + // Property access: resolve the field type + const members = this.parseChainMembers(chainText); + if (members.length > 0) { + const resolved = this.resolveChainSteps(members, this.resolveTypedef(varType), new Map()); + return resolved?.type ?? null; + } + } + } + + // Can't determine type + return null; + } + + /** + * Check a single function call's arguments against all overloads. + * Returns null if any overload matches, or an error message if none do. + */ + private validateCallAgainstOverloads( + overloads: FunctionDeclNode[], + argTypes: (string | null)[], + argCount: number, + funcName: string + ): { message: string; severity: 'error' | 'warning' } | null { + if (overloads.length === 0) return null; // No declarations found, skip + + // Try each overload - if ANY matches, the call is valid + let bestError: string | null = null; + let closestOverload: FunctionDeclNode | null = null; + let smallestParamDiff = Infinity; + + for (const overload of overloads) { + const params = overload.parameters || []; + const requiredCount = params.filter(p => !p.hasDefault).length; + const totalCount = params.length; + + // Check argument count + if (argCount < requiredCount) { + const diff = requiredCount - argCount; + if (diff < smallestParamDiff) { + smallestParamDiff = diff; + closestOverload = overload; + const missing = params.slice(argCount).filter(p => !p.hasDefault) + .map(p => `${p.type?.identifier || '?'} ${p.name}`).join(', '); + bestError = `Missing required argument(s): ${missing}`; + } + continue; // Try other overloads + } + + if (argCount > totalCount) { + const diff = argCount - totalCount; + if (diff < smallestParamDiff) { + smallestParamDiff = diff; + closestOverload = overload; + bestError = `Too many arguments: expected ${totalCount === requiredCount ? totalCount : `${requiredCount}-${totalCount}`}, got ${argCount}`; + } + continue; // Try other overloads + } + + // Argument count is valid, check types + let typeMismatch = false; + let mismatchMsg = ''; + + for (let i = 0; i < argCount; i++) { + const argType = argTypes[i]; + if (!argType) continue; // Couldn't resolve arg type, skip this param + + // Skip void arg types — typically a function reference (method without parens) + // rather than a meaningful value, so type checking would be misleading + if (argType === 'void') continue; + + const param = params[i]; + if (!param?.type?.identifier) continue; + + const paramType = param.type.identifier; + + // Skip auto/typename/Class/void/func params - they accept anything + // In Enforce Script, void parameters are generic "any type" placeholders, + // and func/function params accept function references which look like identifiers. + // Also skip container types (array, set, map) since we don't compare generics yet. + if (paramType === 'auto' || paramType === 'typename' || paramType === 'Class' || paramType === 'void' || paramType === 'func' || paramType === 'function' || paramType === 'array' || paramType === 'set' || paramType === 'map') continue; + + // Skip out/inout params - their types flow differently + if (param.modifiers?.includes('out') || param.modifiers?.includes('inout')) continue; + + const compat = this.checkTypeCompatibility(paramType, argType); + if (!compat.compatible) { + typeMismatch = true; + mismatchMsg = `Argument ${i + 1}: cannot pass '${argType}' as '${paramType} ${param.name}'`; + break; + } + } + + if (!typeMismatch) { + // This overload matches — call is valid + return null; + } + + // This overload didn't match on types + if (smallestParamDiff > 0 || !bestError) { + smallestParamDiff = 0; + closestOverload = overload; + bestError = mismatchMsg; + } + } + + if (!bestError) return null; + + // Build the signature of the closest matching overload for context + if (closestOverload) { + const sig = closestOverload.parameters + .map(p => `${p.type?.identifier || '?'} ${p.name}${p.hasDefault ? '?' : ''}`) + .join(', '); + const prefix = overloads.length > 1 + ? `No matching overload for '${funcName}': ` + : `'${funcName}(${sig})': `; + return { message: prefix + bestError, severity: 'error' }; + } + + return { message: bestError, severity: 'error' }; + } + + /** + * Validate function/method call arguments in the document. + * Checks argument count and types against all overloads. + */ + private checkFunctionCallArgs(doc: TextDocument, diags: Diagnostic[]): void { + const text = doc.getText(); + const ast = this.ensure(doc); + + // Build scoped variable type lookup (reuse same approach as checkTypeMismatches) + const varTypes = new Map(); + + const addVar = (name: string, type: string, start: number, end: number) => { + if (!varTypes.has(name)) varTypes.set(name, []); + varTypes.get(name)!.push({ type, startLine: start, endLine: end }); + }; + + // Collect variable types from AST + for (const node of ast.body) { + if (node.kind === 'VarDecl' && node.name && (node as VarDeclNode).type?.identifier) { + addVar(node.name, (node as VarDeclNode).type.identifier, node.start?.line ?? 0, Number.MAX_SAFE_INTEGER); + } + if (node.kind === 'ClassDecl') { + const cls = node as ClassDeclNode; + const clsStart = cls.start?.line ?? 0; + const clsEnd = cls.end?.line ?? Number.MAX_SAFE_INTEGER; + for (const member of cls.members || []) { + if (member.kind === 'VarDecl' && member.name && (member as VarDeclNode).type?.identifier) { + addVar(member.name, (member as VarDeclNode).type.identifier, clsStart, clsEnd); + } + if (member.kind === 'FunctionDecl') { + const func = member as FunctionDeclNode; + const fStart = func.start?.line ?? clsStart; + const fEnd = func.end?.line ?? clsEnd; + for (const p of func.parameters || []) { + if (p.name && p.type?.identifier) addVar(p.name, p.type.identifier, fStart, fEnd); + } + for (const l of func.locals || []) { + if (l.name && l.type?.identifier) addVar(l.name, l.type.identifier, l.start?.line ?? fStart, fEnd); + } + } + } + } + if (node.kind === 'FunctionDecl') { + const func = node as FunctionDeclNode; + const fStart = func.start?.line ?? 0; + const fEnd = func.end?.line ?? Number.MAX_SAFE_INTEGER; + for (const p of func.parameters || []) { + if (p.name && p.type?.identifier) addVar(p.name, p.type.identifier, fStart, fEnd); + } + } + } + + const getVarTypeAtLine = (name: string, line: number): string | undefined => { + const entries = varTypes.get(name); + if (entries) { + let best: { type: string; startLine: number; endLine: number } | undefined; + for (const e of entries) { + if (line >= e.startLine && line <= e.endLine) { + if (!best || (e.endLine - e.startLine) < (best.endLine - best.startLine)) { + best = e; + } + } + } + if (best) return best.type; + } + // Fall back to full cross-file resolution (globals, class hierarchy, etc.) + const pos: Position = { line, character: 0 }; + return this.resolveVariableType(doc, pos, name) ?? undefined; + }; + + // Helper: check if position is in comment or string + const isInsideCommentOrString = (position: number): boolean => { + let lineStart = text.lastIndexOf('\n', position) + 1; + let lineEnd = text.indexOf('\n', position); + if (lineEnd === -1) lineEnd = text.length; + const line = text.substring(lineStart, lineEnd); + const posInLine = position - lineStart; + + const commentIdx = line.indexOf('//'); + if (commentIdx >= 0 && commentIdx < posInLine) return true; + + let i = position - 1; + while (i >= 0) { + if (i > 0 && text[i-1] === '*' && text[i] === '/') break; + if (i > 0 && text[i-1] === '/' && text[i] === '*') return true; + i--; + } + + let inStr = false; + let sCh = ''; + for (let j = 0; j < posInLine; j++) { + const ch = line[j]; + if (!inStr && (ch === '"' || ch === "'")) { inStr = true; sCh = ch; } + else if (inStr && ch === sCh && (j === 0 || line[j-1] !== '\\')) { inStr = false; } + } + return inStr; + }; + + const getLineFromPos = (pos: number): number => { + let line = 0; + for (let i = 0; i < pos && i < text.length; i++) { + if (text[i] === '\n') line++; + } + return line; + }; + + // Keywords and built-ins that look like function calls but aren't + const skipNames = new Set([ + 'if', 'while', 'for', 'foreach', 'switch', 'return', 'new', 'delete', + 'super', 'this', 'class', 'enum', 'typedef', 'Print', 'PrintFormat', + 'cast', 'sizeof', 'typeof', 'typename', 'thread', 'ref', + 'array', 'set', 'map', 'autoptr' + ]); + + // Find function calls: FuncName(args) or obj.Method(args) + // We scan for pattern: identifier ( ... ) + // Use regex to find call sites, then extract balanced args + const callPattern = /\b(\w+)\s*\(/g; + let match: RegExpExecArray | null; + + while ((match = callPattern.exec(text)) !== null) { + if (isInsideCommentOrString(match.index)) continue; + + const funcName = match[1]; + if (skipNames.has(funcName)) continue; + + // Skip destructor calls: ~ClassName() — destructors take no args + if (match.index > 0 && text[match.index - 1] === '~') continue; + + // Skip annotations inside square brackets: [NonSerialized()], [Attribute()] + // Walk backwards from match to find if we're inside [...] + let bracketCheck = match.index - 1; + while (bracketCheck >= 0 && text[bracketCheck] === ' ') bracketCheck--; + if (bracketCheck >= 0 && text[bracketCheck] === '[') continue; + // Also handle: [Attr(param)] where there's content before + let inBracket = false; + for (let bi = match.index - 1; bi >= 0; bi--) { + if (text[bi] === '\n' || text[bi] === ';' || text[bi] === '}' || text[bi] === '{') break; + if (text[bi] === ']') break; // not inside brackets + if (text[bi] === '[') { inBracket = true; break; } + } + if (inBracket) continue; + + // Skip constructor calls: new ClassName(...) + const beforeNew = text.substring(Math.max(0, match.index - 10), match.index).trimEnd(); + if (beforeNew.endsWith('new')) continue; + + // Skip declarations: "void FuncName(" or "int FuncName(" etc. + // If preceded by a type + space, it's likely a declaration not a call + const beforeCall = text.substring(Math.max(0, match.index - 80), match.index); + // Check if this is a function declaration (type immediately before name) + const declCheck = beforeCall.match(/(?:void|int|float|bool|string|auto|vector|override\s+\w+|static\s+\w+|\w+)\s+$/); + if (declCheck) { + // Could be a declaration. Check more carefully — if the next non-whitespace + // before the type name is '{', ';', or start-of-line, it's a declaration + const typeName = declCheck[0].trim(); + // Skip if it looks like a declaration context (not preceded by = or , or ( ) + const preDeclText = text.substring(Math.max(0, match.index - 80), match.index - typeName.length).trimEnd(); + const lastChar = preDeclText[preDeclText.length - 1]; + if (!lastChar || lastChar === '{' || lastChar === '}' || lastChar === ';' || lastChar === ')' || lastChar === '\n') { + continue; // It's a declaration, skip + } + } + + // Extract the balanced argument text + const argsStart = match.index + match[0].length; + let depth = 1; + let pos = argsStart; + while (pos < text.length && depth > 0) { + const ch = text[pos]; + if (ch === '(') depth++; + else if (ch === ')') depth--; + // Skip strings inside args + else if (ch === '"' || ch === "'") { + const quote = ch; + pos++; + while (pos < text.length && text[pos] !== quote) { + if (text[pos] === '\\') pos++; // skip escaped char + pos++; + } + } + pos++; + } + if (depth !== 0) continue; // Unbalanced parens + + const argsText = text.substring(argsStart, pos - 1).trim(); + const argStrings = argsText ? this.parseCallArguments(argsText) : []; + const argCount = argStrings.length; + + // Determine if this is a method call or global call + const textBeforeFunc = text.substring(Math.max(0, match.index - 200), match.index); + const lineNum = getLineFromPos(match.index); + + let overloads: FunctionDeclNode[] = []; + let chainAttempted = false; + + // Check for method call: something.FuncName( + const dotMatch = textBeforeFunc.match(/(\w+)\s*\.\s*$/); + if (dotMatch) { + const objName = dotMatch[1]; + let objType = getVarTypeAtLine(objName, lineNum); + if (objType) { + objType = this.resolveTypedef(objType); + overloads = this.findFunctionOverloads(funcName, objType); + } else { + // Check if there's a multi-dot chain before: e.g., g_Game.GameScript.Method( + // or GetGame().GameScript.Method( (function call root) + // Try to resolve the full chain to get the correct type + const preDot = textBeforeFunc.substring(0, dotMatch.index!).replace(/[\s.]+$/, ''); + // Try matching chain with possible function call at the root: FuncName(...).X.Y + const chainWithCallParts = preDot.match(/(\w+)\s*\([^)]*\)((?:\s*\.\s*\w+(?:\s*\([^)]*\))?)*)\s*$/); + const chainParts = preDot.match(/(\w+(?:\s*\.\s*\w+)*)\s*$/); + if (chainWithCallParts) { + // Root is a function call: GetGame().GameScript.Method( + const rootFuncName = chainWithCallParts[1]; + let rootType = this.resolveFunctionReturnType(rootFuncName) ?? undefined; + if (rootType) { + rootType = this.resolveTypedef(rootType); + const afterCall = chainWithCallParts[2] || ''; + // Build chain from after the function call through objName + const middleParts = afterCall.split(/\s*\.\s*/).filter(Boolean) + .map(p => p.replace(/\s*\([^)]*\)$/, '')); // strip parens from method calls + middleParts.push(objName); + const chainText = '.' + middleParts.join('.'); + const resolved = this.resolveVariableChainType(rootType, chainText); + if (resolved) { + overloads = this.findFunctionOverloads(funcName, resolved); + } + } + } else if (chainParts) { + const rootAndChain = chainParts[1].split(/\s*\.\s*/); + const rootName = rootAndChain[0]; + let rootType = getVarTypeAtLine(rootName, lineNum); + if (rootType) { + rootType = this.resolveTypedef(rootType); + // Resolve the chain members (everything between root and objName) + const middleParts = rootAndChain.slice(1); + middleParts.push(objName); + const chainText = '.' + middleParts.join('.'); + const resolved = this.resolveVariableChainType(rootType, chainText); + if (resolved) { + overloads = this.findFunctionOverloads(funcName, resolved); + } + } + } + // Fall back to static class call if chain didn't resolve + if (overloads.length === 0 && objName[0] === objName[0].toUpperCase()) { + overloads = this.findFunctionOverloads(funcName, objName); + } + } + } else { + // Check for chain call: e.g., U().globals().FuncName( or var.method().FuncName( + // The simple dotMatch fails when a ')' precedes the dot (chain with call results) + const trimBefore = textBeforeFunc.replace(/\s+$/, ''); + if (trimBefore.endsWith('.')) { + chainAttempted = true; + // Walk backwards to extract the full chain expression before the trailing dot + let p = trimBefore.length - 2; // start before the dot + while (p >= 0) { + // Skip whitespace + while (p >= 0 && /\s/.test(trimBefore[p])) p--; + if (p < 0) break; + // Optional balanced parens (method call arguments) + if (trimBefore[p] === ')') { + let depth = 1; p--; + while (p >= 0 && depth > 0) { + if (trimBefore[p] === '(') depth--; + else if (trimBefore[p] === ')') depth++; + p--; + } + // p is now before the '(' + while (p >= 0 && /\s/.test(trimBefore[p])) p--; + } + // Required: identifier + if (p < 0 || !/\w/.test(trimBefore[p])) break; + while (p >= 0 && /\w/.test(trimBefore[p])) p--; + const idStart = p + 1; + // Check for preceding dot (more chain) + let pp = p; + while (pp >= 0 && /\s/.test(trimBefore[pp])) pp--; + if (pp >= 0 && trimBefore[pp] === '.') { + p = pp - 1; + continue; + } + // No more dots — idStart is the beginning of the expression + p = idStart - 1; + break; + } + const exprStart = p + 1; + const chainExpr = trimBefore.substring(exprStart, trimBefore.length - 1); // exclude trailing dot + if (chainExpr) { + const rootM = chainExpr.match(/^(\w+)/); + if (rootM) { + const rootName = rootM[1]; + const afterRoot = chainExpr.substring(rootName.length); + let rootType: string | undefined; + let chainRemainder: string; + if (afterRoot.trimStart().startsWith('(')) { + // Root is a function call: FuncName(...).chain... + rootType = this.resolveFunctionReturnType(rootName) ?? undefined; + const parenStart = afterRoot.indexOf('('); + let d = 1, i = parenStart + 1; + while (i < afterRoot.length && d > 0) { + if (afterRoot[i] === '(') d++; + else if (afterRoot[i] === ')') d--; + i++; + } + chainRemainder = afterRoot.substring(i); + } else { + // Root is a variable + rootType = getVarTypeAtLine(rootName, lineNum); + if (rootType) rootType = this.resolveTypedef(rootType); + chainRemainder = afterRoot; + } + // Resolve through the remaining chain if present + if (rootType && chainRemainder.trim().startsWith('.')) { + const resolved = this.resolveVariableChainType(rootType, chainRemainder.trim()); + if (resolved) { + overloads = this.findFunctionOverloads(funcName, resolved); + } + } else if (rootType) { + overloads = this.findFunctionOverloads(funcName, rootType); + } + } + } + } + + // Fall back to global or unqualified call only if no chain was detected. + // If a chain was attempted but couldn't resolve, skip — don't match the wrong function. + if (!chainAttempted && overloads.length === 0) { + const containingClass = this.findContainingClass(ast, doc.positionAt(match.index)); + if (containingClass) { + overloads = this.findFunctionOverloads(funcName, containingClass.name); + } + if (overloads.length === 0) { + overloads = this.findFunctionOverloads(funcName); + } + } + } + + if (overloads.length === 0) { + // Warn about unknown functions only when the index is large enough to be confident + if (this.docCache.size >= 500) { + // Skip warning for chain calls where we couldn't resolve the target type — + // we don't know what class the method belongs to + const isUnresolvedChain = chainAttempted || (dotMatch && !getVarTypeAtLine(dotMatch[1], lineNum) && dotMatch[1][0] !== dotMatch[1][0].toUpperCase()); + if (!isUnresolvedChain) { + const startPos = doc.positionAt(match.index); + const endPos = doc.positionAt(match.index + funcName.length); + diags.push({ + message: dotMatch + ? `Unknown method '${funcName}' on type '${getVarTypeAtLine(dotMatch[1], lineNum) || dotMatch[1]}'` + : `Unknown function '${funcName}'`, + range: { start: startPos, end: endPos }, + severity: DiagnosticSeverity.Warning + }); + } + } + continue; + } + + // Infer argument types + const argTypes: (string | null)[] = argStrings.map(arg => + this.inferArgType(arg, (name) => getVarTypeAtLine(name, lineNum)) + ); + + // Validate against overloads + const result = this.validateCallAgainstOverloads(overloads, argTypes, argCount, funcName); + if (result) { + const startPos = doc.positionAt(match.index); + const endPos = doc.positionAt(pos - 1); // end of closing paren + diags.push({ + message: result.message, + range: { start: startPos, end: endPos }, + severity: result.severity === 'error' ? DiagnosticSeverity.Error : DiagnosticSeverity.Warning + }); + } + } + } + /** * Helper to add a type mismatch diagnostic if needed */ From 858bfccd845c3e73cdc79e2d402f300a424dfa45 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sat, 7 Feb 2026 00:25:29 -0500 Subject: [PATCH 09/46] Support preprocessor defines and parsing Add configurable preprocessor symbol handling so #ifdef/#ifndef/#else/#endif blocks can be processed based on user-defined symbols. Changes include: - package.json: add enscript.preprocessorDefines setting to configure active preprocessor symbols. - parser.ts: extend parse signature to accept an optional Set of defines and pass them into the lexer. - lexer.ts: add defines parameter and implement logic to process or skip #ifdef/#ifndef branches based on the defines set; emit #else/#endif appropriately. Preserve nested directives and avoid false matches inside strings/comments. - project/graph.ts: store preprocessor defines in Analyzer, add setPreprocessorDefines(defines: string[]) API, pass defines into parse, and adjust function-declaration regex; also fix string-escape handling by counting backslashes to correctly detect escaped quotes in multiple places. - index.ts: read preprocessorDefines from configuration and configure the Analyzer instance (with logging). These changes allow users to include code guarded by preprocessor symbols by declaring them in settings, improving parsing accuracy for conditional compilation regions and fixing several escaped-quote edge cases. --- package.json | 8 ++ server/src/analysis/ast/parser.ts | 5 +- server/src/analysis/lexer/lexer.ts | 92 +++++++++++++++---- server/src/analysis/project/graph.ts | 131 ++++++++++++++++++++++++--- server/src/index.ts | 7 ++ 5 files changed, 212 insertions(+), 31 deletions(-) diff --git a/package.json b/package.json index a46bdc3..4c97f49 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,14 @@ "items": { "type": "string" } + }, + "enscript.preprocessorDefines": { + "type": "array", + "default": [], + "description": "List of preprocessor symbols to treat as defined. When set, #ifdef blocks for these symbols will be processed instead of skipped (e.g. [\"ENF_DONE\"] to include proto declarations guarded by #ifdef ENF_DONE).", + "items": { + "type": "string" + } } } } diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index b0055dd..82c454e 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -170,9 +170,10 @@ export interface File { // parse entry point export function parse( doc: TextDocument, - conn?: Connection // optional – pass from index.ts to auto-log + conn?: Connection, // optional – pass from index.ts to auto-log + defines?: Set // optional – preprocessor defines to treat as active ): File { - const toks = lex(doc.getText()); + const toks = lex(doc.getText(), defines); const text = doc.getText(); let pos = 0; diff --git a/server/src/analysis/lexer/lexer.ts b/server/src/analysis/lexer/lexer.ts index 8282a85..eaee0c9 100644 --- a/server/src/analysis/lexer/lexer.ts +++ b/server/src/analysis/lexer/lexer.ts @@ -46,7 +46,7 @@ import { Token, TokenKind } from './token'; import { keywords, punct, multiCharOps } from './rules'; -export function lex(text: string): Token[] { +export function lex(text: string, defines?: Set): Token[] { const toks: Token[] = []; let i = 0; @@ -88,9 +88,10 @@ export function lex(text: string): Token[] { // pre-processor (#define, #ifdef, #else, #endif, etc.) // Strategy for #ifdef/#else/#endif: - // - Skip the #ifdef branch entirely (until #else or #endif) - // - Process the #else branch (if present) - // - This ensures we consistently get the "fallback" code path + // - By default: skip the #ifdef/#ifndef branch, process the #else branch + // - If the symbol is in the `defines` set: process the #ifdef branch, skip #else + // - For #ifndef: inverted logic (process if NOT defined) + // - User can configure which defines are active via settings // - Handles nested #ifdef by tracking depth if (ch === '#') { const lineStart = i; @@ -98,15 +99,73 @@ export function lex(text: string): Token[] { const directive = text.slice(lineStart, i).trim(); // Check if this is #ifdef or #ifndef - if (directive.match(/^#\s*(ifdef|ifndef)\b/)) { - // Skip the #ifdef branch until we find #else or #endif at same nesting level + const ifdefMatch = directive.match(/^#\s*(ifdef|ifndef)\s+(\w+)/); + if (ifdefMatch) { + const isIfdef = ifdefMatch[1] === 'ifdef'; + const symbol = ifdefMatch[2]; + const isDefined = defines?.has(symbol) ?? false; + // Process first branch if: (#ifdef and defined) or (#ifndef and NOT defined) + const processFirstBranch = isIfdef ? isDefined : !isDefined; + + if (processFirstBranch) { + // Process the #ifdef/#ifndef branch — just emit directive, continue lexing + push(TokenKind.Preproc, directive, lineStart); + // We'll handle #else (skip) and #endif (emit) when we encounter them + continue; + } else { + // Skip the #ifdef/#ifndef branch until #else or #endif + let depth = 1; + + while (depth > 0 && i < text.length) { + while (i < text.length && text[i] !== '#') { + if (text[i] === '"') { + i++; + while (i < text.length && text[i] !== '"') { + if (text[i] === '\\' && i + 1 < text.length) i++; + i++; + } + if (i < text.length) i++; + } else if (text[i] === '/' && i + 1 < text.length && text[i + 1] === '/') { + while (i < text.length && text[i] !== '\n') i++; + } else if (text[i] === '/' && i + 1 < text.length && text[i + 1] === '*') { + i += 2; + while (i + 1 < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++; + i += 2; + } else { + i++; + } + } + + if (i >= text.length) break; + + const dStart = i; + while (i < text.length && text[i] !== '\n' && text[i] !== '\r') i++; + const d = text.slice(dStart, i).trim(); + + if (d.match(/^#\s*(ifdef|ifndef)\b/)) { + depth++; + } else if (d.match(/^#\s*endif\b/)) { + depth--; + } else if (d.match(/^#\s*else\b/) && depth === 1) { + // Found #else at our level - stop skipping, process #else branch + depth = 0; + } + } + + push(TokenKind.Preproc, text.slice(lineStart, i), lineStart); + continue; + } + } + + // #else — we only reach here if we're PROCESSING the first branch + // (otherwise the skip loop above would have consumed #else) + // Now skip from #else until #endif + if (directive.match(/^#\s*else\b/)) { + const elseStart = lineStart; let depth = 1; - const ifdefStart = lineStart; while (depth > 0 && i < text.length) { - // Find next preprocessor directive while (i < text.length && text[i] !== '#') { - // Skip strings to avoid matching # inside strings if (text[i] === '"') { i++; while (i < text.length && text[i] !== '"') { @@ -115,10 +174,8 @@ export function lex(text: string): Token[] { } if (i < text.length) i++; } else if (text[i] === '/' && i + 1 < text.length && text[i + 1] === '/') { - // Skip single line comment while (i < text.length && text[i] !== '\n') i++; } else if (text[i] === '/' && i + 1 < text.length && text[i + 1] === '*') { - // Skip multi-line comment i += 2; while (i + 1 < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++; i += 2; @@ -129,7 +186,6 @@ export function lex(text: string): Token[] { if (i >= text.length) break; - // Read the directive const dStart = i; while (i < text.length && text[i] !== '\n' && text[i] !== '\r') i++; const d = text.slice(dStart, i).trim(); @@ -138,14 +194,16 @@ export function lex(text: string): Token[] { depth++; } else if (d.match(/^#\s*endif\b/)) { depth--; - } else if (d.match(/^#\s*else\b/) && depth === 1) { - // Found #else at our level - stop skipping, process #else branch - depth = 0; } } - // Emit the whole skipped block as a single preproc token - push(TokenKind.Preproc, text.slice(lineStart, i), lineStart); + push(TokenKind.Preproc, text.slice(elseStart, i), elseStart); + continue; + } + + // #endif — just emit as preproc token + if (directive.match(/^#\s*endif\b/)) { + push(TokenKind.Preproc, directive, lineStart); continue; } diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 367a00b..0911b52 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -193,6 +193,96 @@ export class Analyzer { private docCache = new Map(); private parseErrorCount = 0; + private preprocessorDefines: Set = new Set(); + + /** Set preprocessor defines that should be treated as active in #ifdef directives */ + setPreprocessorDefines(defines: string[]): void { + this.preprocessorDefines = new Set(defines); + } + + /** + * Strip out skipped #ifdef/#ifndef regions from text, preserving line structure. + * Replaces skipped content with spaces so line/column positions remain valid. + * Uses the same logic as the lexer: skip first branch by default, process #else; + * if a define is in preprocessorDefines, process the first branch and skip #else. + */ + private stripSkippedIfdefRegions(text: string): string { + const result = text.split(''); + const lines = text.split('\n'); + + // Blank out a range of characters (preserve newlines for line numbers) + const blankOut = (start: number, end: number) => { + for (let i = start; i < end && i < result.length; i++) { + if (result[i] !== '\n' && result[i] !== '\r') { + result[i] = ' '; + } + } + }; + + // Find offset of line start + let i = 0; + + // Stack of ifdef states for nesting + // Each entry: { skippingFirstBranch, inElseBranch, depth relative to this ifdef } + interface IfdefState { + processFirstBranch: boolean; + inElseBranch: boolean; + directiveStart: number; + } + const stack: IfdefState[] = []; + + // Are we currently in a region that should be blanked? + const isSkipping = (): boolean => { + for (const s of stack) { + if (!s.processFirstBranch && !s.inElseBranch) return true; // in first branch, should skip + if (s.processFirstBranch && s.inElseBranch) return true; // in else branch, should skip + } + return false; + }; + + for (let lineNum = 0; lineNum < lines.length; lineNum++) { + const line = lines[lineNum]; + const trimmed = line.trim(); + + const ifdefMatch = trimmed.match(/^#\s*(ifdef|ifndef)\s+(\w+)/); + if (ifdefMatch) { + const isIfdef = ifdefMatch[1] === 'ifdef'; + const symbol = ifdefMatch[2]; + const isDefined = this.preprocessorDefines.has(symbol); + const processFirst = isIfdef ? isDefined : !isDefined; + + stack.push({ processFirstBranch: processFirst, inElseBranch: false, directiveStart: i }); + + // Blank the directive line itself + blankOut(i, i + line.length); + i += line.length + 1; // +1 for \n + continue; + } + + if (trimmed.match(/^#\s*else\b/) && stack.length > 0) { + stack[stack.length - 1].inElseBranch = true; + blankOut(i, i + line.length); + i += line.length + 1; + continue; + } + + if (trimmed.match(/^#\s*endif\b/) && stack.length > 0) { + stack.pop(); + blankOut(i, i + line.length); + i += line.length + 1; + continue; + } + + // If we're in a skipped region, blank this line + if (isSkipping()) { + blankOut(i, i + line.length); + } + + i += line.length + 1; // +1 for \n + } + + return result.join(''); + } /** Return summary stats about everything indexed so far. */ getIndexStats() { @@ -226,7 +316,7 @@ export class Analyzer { try { // 2 · happy path ─ parse & cache - const ast = parse(doc); // pass full TextDocument + const ast = parse(doc, undefined, this.preprocessorDefines); // pass full TextDocument + defines ast.module = getModuleLevel(doc.uri); this.docCache.set(normalizeUri(doc.uri), ast); return ast; @@ -1816,7 +1906,7 @@ export class Analyzer { * for (int j = 0; j < 10; j++) { } // ERROR: 'j' already declared */ private checkDuplicateVariables(doc: TextDocument, diags: Diagnostic[]): void { - const text = doc.getText(); + const text = this.stripSkippedIfdefRegions(doc.getText()); // Track variables by scope - use a stack of scopes // Each scope has a map of variable names to their declaration info @@ -1831,7 +1921,7 @@ export class Analyzer { // Must have: optional modifiers, return type (including generics), function name, parentheses for params // Excludes: array access like m_Foo[0] and assignments // Matches lines ending with { (normal), {} (empty body), ; (proto/native), or ) (brace on next line) - const funcDeclPattern = /^\s*(?:static\s+|private\s+|protected\s+|override\s+|proto\s+|native\s+|volatile\s+|event\s+)*(?:void|int|float|bool|string|auto|ref\s+\w+(?:\s*<[\w,\s<>]+>)?|\w+(?:\s*<[\w,\s<>]+>)?)\s+(\w+)\s*\([^)]*\)\s*(?:\{\}?|;)?\s*$/; + const funcDeclPattern = /^\s*(?:static\s+|private\s+|protected\s+|override\s+|proto\s+|native\s+|volatile\s+|event\s+)*(?:void|int|float|bool|string|auto|ref\s+\w+(?:\s*<[\w,\s<>]+>)?|\w+(?:\s*<[\w,\s<>]+>)?)\s+(\w+)\s*\([^)]*\)\s*(?:\{[^}]*\}?|;)?\s*$/; // Pattern to detect proto/native function declarations (no body, just ;) // Params in these don't create real variables — skip them for duplicate checking @@ -2025,7 +2115,7 @@ export class Analyzer { // Skip keywords that are not actual type names. // 'typedef' is included because lines like "typedef set TFloatSet;" // match the varDeclPattern as type=typedef, name=set — which is a false positive. - if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', 'else', 'foreach', 'void', 'override', 'static', 'private', 'protected', 'const', 'ref', 'autoptr', 'proto', 'native', 'modded', 'sealed', 'event', 'typedef'].includes(typeName)) { + if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', 'else', 'foreach', 'void', 'override', 'static', 'private', 'protected', 'const', 'ref', 'autoptr', 'proto', 'native', 'modded', 'sealed', 'event', 'typedef', 'out', 'inout', 'notnull', 'owned', 'local', 'volatile', 'external', 'abstract', 'final', 'reference'].includes(typeName)) { continue; } @@ -2256,7 +2346,7 @@ export class Analyzer { * - Re-assignment: varName = otherVar; */ private checkTypeMismatches(doc: TextDocument, diags: Diagnostic[]): void { - const text = doc.getText(); + const text = this.stripSkippedIfdefRegions(doc.getText()); const ast = this.ensure(doc); // Scoped variable tracking - each variable knows its valid line range @@ -2558,8 +2648,13 @@ export class Analyzer { if (!inString && (ch === '"' || ch === "'")) { inString = true; stringChar = ch; - } else if (inString && ch === stringChar && (j === 0 || line[j-1] !== '\\')) { - inString = false; + } else if (inString && ch === stringChar) { + let backslashCount = 0; + let bi = j - 1; + while (bi >= 0 && line[bi] === '\\') { backslashCount++; bi--; } + if (backslashCount % 2 === 0) { + inString = false; + } } } return inString; @@ -3097,8 +3192,15 @@ export class Analyzer { } if (inString) { current += ch; - if (ch === stringChar && (i === 0 || argsText[i - 1] !== '\\')) { - inString = false; + if (ch === stringChar) { + // Count consecutive backslashes before this quote + let backslashCount = 0; + let bi = i - 1; + while (bi >= 0 && argsText[bi] === '\\') { backslashCount++; bi--; } + // Even number of backslashes (including 0) means quote is NOT escaped + if (backslashCount % 2 === 0) { + inString = false; + } } continue; } @@ -3362,7 +3464,7 @@ export class Analyzer { * Checks argument count and types against all overloads. */ private checkFunctionCallArgs(doc: TextDocument, diags: Diagnostic[]): void { - const text = doc.getText(); + const text = this.stripSkippedIfdefRegions(doc.getText()); const ast = this.ensure(doc); // Build scoped variable type lookup (reuse same approach as checkTypeMismatches) @@ -3450,7 +3552,12 @@ export class Analyzer { for (let j = 0; j < posInLine; j++) { const ch = line[j]; if (!inStr && (ch === '"' || ch === "'")) { inStr = true; sCh = ch; } - else if (inStr && ch === sCh && (j === 0 || line[j-1] !== '\\')) { inStr = false; } + else if (inStr && ch === sCh) { + let backslashCount = 0; + let bi = j - 1; + while (bi >= 0 && line[bi] === '\\') { backslashCount++; bi--; } + if (backslashCount % 2 === 0) { inStr = false; } + } } return inStr; }; @@ -3780,7 +3887,7 @@ export class Analyzer { * "more text"); // ERROR! */ private checkMultiLineStatements(doc: TextDocument, diags: Diagnostic[]): void { - const text = doc.getText(); + const text = this.stripSkippedIfdefRegions(doc.getText()); const lines = text.split('\n'); // Track if we're inside a block comment diff --git a/server/src/index.ts b/server/src/index.ts index fb9d213..8fe34b1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -48,6 +48,13 @@ connection.onInitialize((_params: InitializeParams): InitializeResult => { connection.onInitialized(async () => { const config = await getConfiguration(connection); const includePaths = config.includePaths as string[] || []; + const preprocessorDefines = config.preprocessorDefines as string[] || []; + + // Configure preprocessor defines + if (preprocessorDefines.length > 0) { + Analyzer.instance().setPreprocessorDefines(preprocessorDefines); + console.log(`Preprocessor defines: ${preprocessorDefines.join(', ')}`); + } const pathsToIndex = [workspaceRoot, ...includePaths]; const allFiles: string[] = []; From fa1a01f1e00c7514646754260a280cabd5a9f91b Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sat, 7 Feb 2026 13:40:07 -0500 Subject: [PATCH 10/46] Template-aware type resolution and chain parsing Improve AST parsing and type resolution for chained/member calls and templates. - parser.ts: Fix parenthesized-args parsing by tracking nested parentheses depth and correctly consuming tokens; more robustly collect string/number args and skip unexpected tokens. - graph.ts: Add resolveMethodCallWithTemplates to walk class/typedef hierarchies and apply generic typedef substitutions when resolving unqualified/inherited method return types. - Enhance parseChainMembers to ignore array indexing (e.g., .Items[0].Name) and skip indexing after method calls. - Make inferArgType template-aware by accepting a containingClassName and attempting method resolution with template substitution before falling back to global functions. - Improve chain resolution logic to handle complex expressions (function-call roots, chained calls, static/class access) and better determine overloads. - Add a lightweight regex-based scan of source text to discover local variable and foreach declarations (used to populate varTypes) when the parser doesn't include function bodies. These changes aim to reduce false negatives/positives when inferring types for chained calls, array/index usage, and methods inherited via typedefs with generics. --- server/src/analysis/ast/parser.ts | 20 +- server/src/analysis/project/graph.ts | 374 +++++++++++++++++++++++---- 2 files changed, 340 insertions(+), 54 deletions(-) diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index 82c454e..a4c00d0 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -967,16 +967,22 @@ export function parse( if (peek().value === '(') { expect('('); - while (peek().value !== ')') { - if (peek().kind === TokenKind.String || peek().kind === TokenKind.Number) { + let depth = 1; + while (depth > 0 && pos < toks.length) { + const tok = peek(); + if (tok.value === '(') { depth++; next(); } + else if (tok.value === ')') { + depth--; + if (depth > 0) next(); // consume inner ')' + } + else if (tok.kind === TokenKind.String || tok.kind === TokenKind.Number) { args.push(next().value); - } else { - next(); // skip unexpected stuff } - - if (peek().value === ',') next(); + else { + next(); // skip identifiers, dots, commas, etc. + } } - expect(')'); + expect(')'); // consume the final ')' } const endTok = expect(']'); diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 0911b52..2092cc8 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -834,6 +834,73 @@ export class Analyzer { return result?.identifier ?? null; } + /** + * Resolve the return type of a method call within a class, applying template substitution. + * + * Walks the class hierarchy. At each base class, if that base was reached through + * a typedef with generic args (e.g., typedef array UCurrencyBase), + * builds a template map so that methods returning template params (like array.Get() → T) + * are substituted with the concrete type (UCurrencyValue). + * + * This is the same logic as resolveVariableChainType / resolveChainSteps, but for + * unqualified method calls inside a class (i.e., calling inherited methods). + */ + private resolveMethodCallWithTemplates(className: string, methodName: string): string | null { + // Walk the class hierarchy, building up the template map at each typedef step + const visited = new Set(); + let templateMap = new Map(); + + const walk = (name: string): string | null => { + if (visited.has(name)) return null; + visited.add(name); + + // Resolve through typedef: e.g., UCurrencyBase → array + const typedefNode = this.resolveTypedefNode(name); + let resolvedName = name; + if (typedefNode) { + resolvedName = typedefNode.oldType.identifier; + if (typedefNode.oldType.genericArgs && typedefNode.oldType.genericArgs.length > 0) { + // Build template map: e.g., T → UCurrencyValue for array + const newMap = this.buildTemplateMap(resolvedName, typedefNode.oldType.genericArgs); + // Merge with existing map (inner maps take precedence) + for (const [k, v] of newMap) { + templateMap.set(k, v); + } + } + } + + // Find all classes with this name + const classNodes = this.findAllClassesByName(resolvedName); + for (const classNode of classNodes) { + // Check if this class directly defines the method + for (const member of classNode.members || []) { + if (member.kind === 'FunctionDecl' && member.name === methodName) { + const func = member as FunctionDeclNode; + if (func.returnType?.identifier) { + let retType = func.returnType.identifier; + // Apply template substitution + if (templateMap.has(retType)) { + retType = templateMap.get(retType)!; + } + return retType; + } + } + } + } + + // Check base class + const originalClass = classNodes.find(c => !c.modifiers?.includes('modded')); + const baseClassName = (originalClass || classNodes[0])?.base?.identifier; + if (baseClassName) { + return walk(baseClassName); + } + + return null; + }; + + return walk(className); + } + /** * Resolve the return type of a method/field within a class hierarchy, returning full type info. * Includes genericArgs for template types like map. @@ -948,7 +1015,10 @@ export class Analyzer { /** * Parse chained member accesses from text like ".Method(args).Prop.Other()" * into a list of member names: ["Method", "Prop", "Other"]. - * Handles both method calls (with parenthesized arguments) and property accesses. + * Handles both method calls (with parenthesized arguments), property accesses, + * and array indexing (e.g., ".Items[0].Name" → ["Items", "Name"]). + * Array indexing is skipped as it doesn't change the chain resolution + * (the element type is handled separately). */ private parseChainMembers(text: string): string[] { const calls: string[] = []; @@ -964,6 +1034,16 @@ export class Analyzer { if (propMatch) { calls.push(propMatch[1]); remaining = remaining.substring(propMatch[0].length).trim(); + // Skip any array indexing like [0], [i], [expr] after the property + while (remaining.startsWith('[')) { + let depth = 1, k = 1; + while (k < remaining.length && depth > 0) { + if (remaining[k] === '[') depth++; + else if (remaining[k] === ']') depth--; + k++; + } + remaining = remaining.substring(k).trim(); + } continue; } break; @@ -980,6 +1060,16 @@ export class Analyzer { i++; } remaining = remaining.substring(i).trim(); + // Skip any array indexing after the method call, e.g., .GetItems()[0].Name + while (remaining.startsWith('[')) { + let depth = 1, k = 1; + while (k < remaining.length && depth > 0) { + if (remaining[k] === '[') depth++; + else if (remaining[k] === ']') depth--; + k++; + } + remaining = remaining.substring(k).trim(); + } } return calls; @@ -2789,7 +2879,19 @@ export class Analyzer { break; } } - returnType = this.resolveFunctionReturnType(funcName); + // Try to resolve as a method of the containing class first (for unqualified calls like Find()) + // This handles cases where the method is inherited or defined in the current class + const lineNum = getLineFromPos(match.index); + const containingClass = this.findContainingClass(ast, { line: lineNum, character: 0 }); + if (containingClass) { + returnType = this.resolveMethodReturnType(containingClass.name, funcName); + } else { + returnType = null; + } + // Fall back to global function lookup if not found in class hierarchy + if (!returnType) { + returnType = this.resolveFunctionReturnType(funcName); + } highlightLength = match[0].length + singleEnd; } @@ -2993,7 +3095,17 @@ export class Analyzer { break; } } - returnType = this.resolveFunctionReturnType(funcName); + // Try to resolve as a method of the containing class first (for unqualified calls like Find()) + const containingClass = this.findContainingClass(ast, { line: lineNum, character: 0 }); + if (containingClass) { + returnType = this.resolveMethodReturnType(containingClass.name, funcName); + } else { + returnType = null; + } + // Fall back to global function lookup if not found in class hierarchy + if (!returnType) { + returnType = this.resolveFunctionReturnType(funcName); + } } if (targetType && returnType) { @@ -3235,7 +3347,8 @@ export class Analyzer { */ private inferArgType( argText: string, - getVarType: (name: string) => string | undefined + getVarType: (name: string) => string | undefined, + containingClassName?: string ): string | null { const arg = argText.trim(); if (!arg) return null; @@ -3295,10 +3408,28 @@ export class Analyzer { return null; } + // Variable with array indexing: varName[expr] + // We can't easily determine the element type, so return null to avoid false positives. + const arrayAccessMatch = arg.match(/^(\w+)\s*\[/); + if (arrayAccessMatch && !arg.match(/^(\w+)\s*\(/)) { + return null; + } + // Function call: FuncName(...) or FuncName(...).Chain(...) const funcCallMatch = arg.match(/^(\w+)\s*\(/); if (funcCallMatch) { - const rootType = this.resolveFunctionReturnType(funcCallMatch[1]); + const calledFunc = funcCallMatch[1]; + + // Try resolving as a method of the containing class first (with template substitution). + // This handles cases like Get(i) inside a class that extends array via typedef. + let rootType: string | null = null; + if (containingClassName) { + rootType = this.resolveMethodCallWithTemplates(containingClassName, calledFunc); + } + // Fall back to global function resolution + if (!rootType) { + rootType = this.resolveFunctionReturnType(calledFunc); + } if (rootType) { // Check if there's a chain after the closing paren: FuncName(...).Something const openIdx = arg.indexOf('('); @@ -3342,7 +3473,15 @@ export class Analyzer { const members = this.parseChainMembers(chainText); if (members.length > 0) { const resolved = this.resolveChainSteps(members, this.resolveTypedef(varType), new Map()); - return resolved?.type ?? null; + if (resolved?.type) { + // If the arg ends with array indexing [expr], the result is the element type. + // Since we can't always determine the element type, return null to avoid + // false type mismatch errors for container types. + if (/\[.*\]\s*$/.test(arg)) { + return null; + } + return resolved.type; + } } } } @@ -3511,6 +3650,91 @@ export class Analyzer { } } + // The parser doesn't parse function bodies (locals is always []), + // so we need to scan for local variable declarations using regex. + // This includes regular variable declarations and foreach loop variables. + { + const lines = text.split('\n'); + let inBlockComment = false; + + const scanFunctionBody = (funcStart: number, funcEnd: number) => { + for (let lineIdx = funcStart; lineIdx <= funcEnd && lineIdx < lines.length; lineIdx++) { + let line = lines[lineIdx]; + + // Handle block comments + if (inBlockComment) { + if (line.includes('*/')) inBlockComment = false; + continue; + } + if (line.trimStart().startsWith('/*')) { + if (!line.includes('*/')) inBlockComment = true; + continue; + } + + // Strip comments and strings + const commentIdx = line.indexOf('//'); + if (commentIdx >= 0) line = line.substring(0, commentIdx); + line = line.replace(/"(?:[^"\\]|\\.)*"/g, '""'); + line = line.replace(/'(?:[^'\\]|\\.)*'/g, "''"); + line = line.trim(); + + if (!line) continue; + + // Match: Type varName; or Type varName = ...; + const localDeclPattern = /\b([A-Z]\w+|int|float|bool|string|auto|vector|ref|autoptr)\s+(\w+)\s*(?:[=;,])/g; + let m; + while ((m = localDeclPattern.exec(line)) !== null) { + const typeName = m[1]; + const varName = m[2]; + + // Skip if type is a keyword/modifier + if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', + 'else', 'case', 'override', 'static', 'private', 'protected', 'ref', 'autoptr', + 'const', 'proto', 'native', 'Print', 'foreach'].includes(typeName)) { + continue; + } + // Skip if varName is a keyword + if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'true', 'false', 'null'].includes(varName)) { + continue; + } + + addVar(varName, typeName, lineIdx, funcEnd); + } + + // Match foreach variable declarations: foreach (Type varName : collection) + const foreachPattern = /\bforeach\s*\(\s*([A-Z]\w+|int|float|bool|string|auto)\s+(\w+)\s*:/g; + let fm; + while ((fm = foreachPattern.exec(line)) !== null) { + addVar(fm[2], fm[1], lineIdx, funcEnd); + } + } + }; + + for (const node of ast.body) { + if (node.kind === 'ClassDecl') { + const classNode = node as ClassDeclNode; + for (const member of classNode.members || []) { + if (member.kind === 'FunctionDecl') { + const func = member as FunctionDeclNode; + const funcStart = func.start?.line ?? 0; + const funcEnd = func.end?.line ?? 0; + if (funcEnd > funcStart) { + scanFunctionBody(funcStart, funcEnd); + } + } + } + } + if (node.kind === 'FunctionDecl') { + const func = node as FunctionDeclNode; + const funcStart = func.start?.line ?? 0; + const funcEnd = func.end?.line ?? 0; + if (funcEnd > funcStart) { + scanFunctionBody(funcStart, funcEnd); + } + } + } + } + const getVarTypeAtLine = (name: string, line: number): string | undefined => { const entries = varTypes.get(name); if (entries) { @@ -3659,6 +3883,13 @@ export class Analyzer { let overloads: FunctionDeclNode[] = []; let chainAttempted = false; + let containingClassName: string | undefined; + + // Try to find the containing class for this call site (needed for template resolution) + { + const cc = this.findContainingClass(ast, doc.positionAt(match.index)); + if (cc) containingClassName = cc.name; + } // Check for method call: something.FuncName( const dotMatch = textBeforeFunc.match(/(\w+)\s*\.\s*$/); @@ -3669,43 +3900,84 @@ export class Analyzer { objType = this.resolveTypedef(objType); overloads = this.findFunctionOverloads(funcName, objType); } else { - // Check if there's a multi-dot chain before: e.g., g_Game.GameScript.Method( - // or GetGame().GameScript.Method( (function call root) - // Try to resolve the full chain to get the correct type - const preDot = textBeforeFunc.substring(0, dotMatch.index!).replace(/[\s.]+$/, ''); - // Try matching chain with possible function call at the root: FuncName(...).X.Y - const chainWithCallParts = preDot.match(/(\w+)\s*\([^)]*\)((?:\s*\.\s*\w+(?:\s*\([^)]*\))?)*)\s*$/); - const chainParts = preDot.match(/(\w+(?:\s*\.\s*\w+)*)\s*$/); - if (chainWithCallParts) { - // Root is a function call: GetGame().GameScript.Method( - const rootFuncName = chainWithCallParts[1]; - let rootType = this.resolveFunctionReturnType(rootFuncName) ?? undefined; - if (rootType) { - rootType = this.resolveTypedef(rootType); - const afterCall = chainWithCallParts[2] || ''; - // Build chain from after the function call through objName - const middleParts = afterCall.split(/\s*\.\s*/).filter(Boolean) - .map(p => p.replace(/\s*\([^)]*\)$/, '')); // strip parens from method calls - middleParts.push(objName); - const chainText = '.' + middleParts.join('.'); - const resolved = this.resolveVariableChainType(rootType, chainText); - if (resolved) { - overloads = this.findFunctionOverloads(funcName, resolved); + // objName is not a simple variable. Use the backward-walking chain parser + // to extract the full expression (e.g., Currencies.Get(i).MoneyValues) + // and resolve its type, then find the function on that type. + const fullTextBefore = textBeforeFunc.replace(/\s+$/, ''); + // fullTextBefore ends with "objName." — walk backwards from the end to extract + // the entire chain expression including objName. + if (fullTextBefore.endsWith('.')) { + let p = fullTextBefore.length - 2; // start before trailing dot + while (p >= 0) { + while (p >= 0 && /\s/.test(fullTextBefore[p])) p--; + if (p < 0) break; + if (fullTextBefore[p] === ')') { + let depth = 1; p--; + while (p >= 0 && depth > 0) { + if (fullTextBefore[p] === '(') depth--; + else if (fullTextBefore[p] === ')') depth++; + p--; + } + while (p >= 0 && /\s/.test(fullTextBefore[p])) p--; + } + if (p < 0 || !/\w/.test(fullTextBefore[p])) break; + while (p >= 0 && /\w/.test(fullTextBefore[p])) p--; + const idStart = p + 1; + let pp = p; + while (pp >= 0 && /\s/.test(fullTextBefore[pp])) pp--; + if (pp >= 0 && fullTextBefore[pp] === '.') { + p = pp - 1; + continue; } + p = idStart - 1; + break; } - } else if (chainParts) { - const rootAndChain = chainParts[1].split(/\s*\.\s*/); - const rootName = rootAndChain[0]; - let rootType = getVarTypeAtLine(rootName, lineNum); - if (rootType) { - rootType = this.resolveTypedef(rootType); - // Resolve the chain members (everything between root and objName) - const middleParts = rootAndChain.slice(1); - middleParts.push(objName); - const chainText = '.' + middleParts.join('.'); - const resolved = this.resolveVariableChainType(rootType, chainText); - if (resolved) { - overloads = this.findFunctionOverloads(funcName, resolved); + const exprStart = p + 1; + const chainExpr = fullTextBefore.substring(exprStart, fullTextBefore.length - 1); // exclude trailing dot + if (chainExpr) { + const rootM = chainExpr.match(/^(\w+)/); + if (rootM) { + const rootName = rootM[1]; + const afterRoot = chainExpr.substring(rootName.length); + let rootType: string | undefined; + let chainRemainder: string; + if (afterRoot.trimStart().startsWith('(')) { + // Root is a function call: FuncName(...).chain... + rootType = this.resolveFunctionReturnType(rootName) ?? undefined; + if (!rootType && containingClassName) { + rootType = this.resolveMethodCallWithTemplates(containingClassName, rootName) ?? undefined; + } + const parenStart = afterRoot.indexOf('('); + let d = 1, ii = parenStart + 1; + while (ii < afterRoot.length && d > 0) { + if (afterRoot[ii] === '(') d++; + else if (afterRoot[ii] === ')') d--; + ii++; + } + chainRemainder = afterRoot.substring(ii); + } else { + // Root is a variable or class name (for static access) + rootType = getVarTypeAtLine(rootName, lineNum); + if (rootType) { + rootType = this.resolveTypedef(rootType); + } else { + // Check if root is a class name (for static field/method access) + const classNodes = this.findAllClassesByName(rootName); + if (classNodes.length > 0) { + rootType = rootName; + } + } + chainRemainder = afterRoot; + } + // Resolve through the remaining chain if present + if (rootType && chainRemainder.trim().startsWith('.')) { + const resolved = this.resolveVariableChainType(rootType, chainRemainder.trim()); + if (resolved) { + overloads = this.findFunctionOverloads(funcName, resolved); + } + } else if (rootType) { + overloads = this.findFunctionOverloads(funcName, rootType); + } } } } @@ -3773,9 +4045,18 @@ export class Analyzer { } chainRemainder = afterRoot.substring(i); } else { - // Root is a variable + // Root is a variable or class name (for static access) rootType = getVarTypeAtLine(rootName, lineNum); - if (rootType) rootType = this.resolveTypedef(rootType); + if (rootType) { + rootType = this.resolveTypedef(rootType); + } else { + // Check if root is a class name (for static field/method access like ClassName.StaticField) + const classNodes = this.findAllClassesByName(rootName); + if (classNodes.length > 0) { + // Root is a class name - treat the class itself as the "type" for static access + rootType = rootName; + } + } chainRemainder = afterRoot; } // Resolve through the remaining chain if present @@ -3794,9 +4075,8 @@ export class Analyzer { // Fall back to global or unqualified call only if no chain was detected. // If a chain was attempted but couldn't resolve, skip — don't match the wrong function. if (!chainAttempted && overloads.length === 0) { - const containingClass = this.findContainingClass(ast, doc.positionAt(match.index)); - if (containingClass) { - overloads = this.findFunctionOverloads(funcName, containingClass.name); + if (containingClassName) { + overloads = this.findFunctionOverloads(funcName, containingClassName); } if (overloads.length === 0) { overloads = this.findFunctionOverloads(funcName); @@ -3827,7 +4107,7 @@ export class Analyzer { // Infer argument types const argTypes: (string | null)[] = argStrings.map(arg => - this.inferArgType(arg, (name) => getVarTypeAtLine(name, lineNum)) + this.inferArgType(arg, (name) => getVarTypeAtLine(name, lineNum), containingClassName) ); // Validate against overloads From 3d400d2ad0cfd43fe4937c9443fafa273eb667a5 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sat, 7 Feb 2026 19:25:08 -0500 Subject: [PATCH 11/46] Add AST cache and indexing optimizations Introduce a persistent AST cache (ASTCache) stored at .vscode/.enscript-cache.json to speed up subsequent launches by loading pre-parsed ASTs when file mtimes match. Integrate cache into the indexing flow (server/index.ts): attempt cache load, inject cached ASTs into Analyzer, track hit/miss stats, provide indexing progress notifications, and persist cache on completion. Major Analyzer optimizations: add global symbol index and dedicated indexes for classes, enums, functions and typedefs (with prefix buckets and sorted arrays) and update codepaths to use these indexes for much faster lookups and completions; add methods to inject cached ASTs and parse+cache documents. UI/UX changes: suppress hover console logging, and add a VS Code status bar item to display indexing start/progress/complete via LSP notifications. --- server/src/analysis/project/cache.ts | 231 +++++++++- server/src/analysis/project/graph.ts | 615 +++++++++++++++++++-------- server/src/index.ts | 56 ++- server/src/lsp/handlers/hover.ts | 2 +- src/extension.ts | 32 +- 5 files changed, 750 insertions(+), 186 deletions(-) diff --git a/server/src/analysis/project/cache.ts b/server/src/analysis/project/cache.ts index f897952..636b60c 100644 --- a/server/src/analysis/project/cache.ts +++ b/server/src/analysis/project/cache.ts @@ -1 +1,230 @@ -// TODO – JSON serialisation of AST for faster startup +/** + * Persistent AST Cache + * ==================== + * + * Caches parsed ASTs to disk to dramatically speed up subsequent VS Code launches. + * + * Strategy: + * - Store AST + file modification time (mtime) in a JSON cache file + * - On startup, check if file's mtime matches cached mtime + * - If match: load AST from cache (fast) + * - If mismatch or not in cache: parse file (slow) and update cache + * + * Cache location: {workspaceRoot}/.vscode/.enscript-cache.json + * + * Cache format: + * { + * "version": 1, + * "entries": { + * "file:///path/to/file.c": { + * "mtime": 1234567890, + * "ast": { ... } + * } + * } + * } + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as url from 'url'; +import { File } from '../ast/parser'; + +const CACHE_VERSION = 1; +const CACHE_FILENAME = '.enscript-cache.json'; + +interface CacheEntry { + mtime: number; + ast: File; +} + +interface CacheData { + version: number; + entries: Record; +} + +export class ASTCache { + private cacheDir: string; + private cachePath: string; + private cache: CacheData; + private dirty = false; + private enabled = true; + + constructor(workspaceRoot: string) { + this.cacheDir = path.join(workspaceRoot, '.vscode'); + this.cachePath = path.join(this.cacheDir, CACHE_FILENAME); + this.cache = { version: CACHE_VERSION, entries: {} }; + console.log(`AST cache path: ${this.cachePath}`); + this.load(); + } + + /** + * Disable cache (e.g., for testing or if issues arise) + */ + disable(): void { + this.enabled = false; + } + + /** + * Check if we have a valid cached AST for a file + * @param filePath Absolute file path + * @returns Cached AST if valid, null if needs re-parsing + */ + get(filePath: string): File | null { + if (!this.enabled) return null; + + try { + const uri = this.pathToUri(filePath); + const entry = this.cache.entries[uri]; + + if (!entry) return null; + + // Check if file has been modified since caching + const stats = fs.statSync(filePath); + const currentMtime = stats.mtimeMs; + + if (entry.mtime === currentMtime) { + return entry.ast; + } + + // File modified, cache invalid + return null; + } catch { + return null; + } + } + + /** + * Store a parsed AST in the cache + * @param filePath Absolute file path + * @param ast Parsed AST + */ + set(filePath: string, ast: File): void { + if (!this.enabled) return; + + try { + const uri = this.pathToUri(filePath); + const stats = fs.statSync(filePath); + + this.cache.entries[uri] = { + mtime: stats.mtimeMs, + ast: ast + }; + this.dirty = true; + } catch { + // Ignore errors - cache is optional + } + } + + /** + * Persist cache to disk + * Call this after initial indexing or periodically + */ + save(): void { + if (!this.enabled || !this.dirty) return; + + try { + // Ensure .vscode directory exists + if (!fs.existsSync(this.cacheDir)) { + fs.mkdirSync(this.cacheDir, { recursive: true }); + } + + // Write cache atomically (write to temp, then rename) + const tempPath = this.cachePath + '.tmp'; + const data = JSON.stringify(this.cache); + fs.writeFileSync(tempPath, data, 'utf8'); + fs.renameSync(tempPath, this.cachePath); + + this.dirty = false; + console.log(`AST cache saved: ${Object.keys(this.cache.entries).length} entries`); + } catch (err) { + console.warn(`Failed to save AST cache: ${err}`); + } + } + + /** + * Load cache from disk + */ + private load(): void { + try { + if (!fs.existsSync(this.cachePath)) { + return; + } + + const data = fs.readFileSync(this.cachePath, 'utf8'); + const parsed = JSON.parse(data) as CacheData; + + // Version check - invalidate if cache format changed + if (parsed.version !== CACHE_VERSION) { + console.log(`AST cache version mismatch (${parsed.version} vs ${CACHE_VERSION}), clearing cache`); + return; + } + + this.cache = parsed; + console.log(`AST cache loaded: ${Object.keys(this.cache.entries).length} entries`); + } catch (err) { + console.warn(`Failed to load AST cache: ${err}`); + // Start fresh + this.cache = { version: CACHE_VERSION, entries: {} }; + } + } + + /** + * Clear the cache completely + */ + clear(): void { + this.cache = { version: CACHE_VERSION, entries: {} }; + this.dirty = true; + try { + if (fs.existsSync(this.cachePath)) { + fs.unlinkSync(this.cachePath); + } + } catch { + // Ignore + } + } + + /** + * Remove a specific file from cache (e.g., when deleted) + */ + invalidate(filePath: string): void { + const uri = this.pathToUri(filePath); + if (this.cache.entries[uri]) { + delete this.cache.entries[uri]; + this.dirty = true; + } + } + + /** + * Get cache statistics + */ + getStats(): { entries: number; cacheHits: number; cacheMisses: number } { + return { + entries: Object.keys(this.cache.entries).length, + cacheHits: this.cacheHits, + cacheMisses: this.cacheMisses + }; + } + + private cacheHits = 0; + private cacheMisses = 0; + + /** + * Track cache hit for statistics + */ + recordHit(): void { + this.cacheHits++; + } + + /** + * Track cache miss for statistics + */ + recordMiss(): void { + this.cacheMisses++; + } + + private pathToUri(filePath: string): string { + // Use Node's url module for consistent URI generation + // This matches what's used in index.ts: url.pathToFileURL(filePath).toString() + return url.pathToFileURL(filePath).toString(); + } +} diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 2092cc8..4591d07 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -67,6 +67,19 @@ interface CompletionResult { returnType?: string; } +/** + * Global symbol entry for the pre-built symbol index + */ +interface GlobalSymbolEntry { + name: string; + nameLower: string; // Pre-computed lowercase for fast prefix matching + kind: string; + detail?: string; + insertText?: string; + returnType?: string; + uri: string; // Source file URI for deduplication on updates +} + /** * Returns the token at a specific offset (e.g. mouse hover or cursor position). * Lexes only a small window around the position for performance. @@ -194,6 +207,223 @@ export class Analyzer { private docCache = new Map(); private parseErrorCount = 0; private preprocessorDefines: Set = new Set(); + + // ================================================================ + // GLOBAL SYMBOL INDEX for fast completions + // ================================================================ + // Pre-built index partitioned by first letter for O(1) bucket lookup. + // Each bucket is a sorted array for deterministic ordering. + // Updated incrementally when files change. + // ================================================================ + + /** Main symbol map: name -> entry */ + private globalSymbolIndex: Map = new Map(); + + /** Prefix buckets: first lowercase letter -> sorted array of names */ + private symbolsByPrefix: Map = new Map(); + + /** All symbol names sorted (for no-prefix completions) */ + private sortedSymbolNames: string[] = []; + + /** Flag to mark when sorted arrays need rebuild */ + private symbolIndexDirty = false; + + // ================================================================ + // CLASS INDEX for fast class lookups + // ================================================================ + // Maps class names to their ClassDeclNode references. + // Supports multiple entries per name (modded classes). + // Avoids iterating docCache on every findAllClassesByName call. + // ================================================================ + + /** Class index: className -> array of ClassDeclNode (supports modded classes) */ + private classIndex: Map = new Map(); + + /** Enum index: enumName -> EnumDeclNode */ + private enumIndex: Map = new Map(); + + /** Function index: funcName -> FunctionDeclNode[] */ + private functionIndex: Map = new Map(); + + /** Typedef index: name -> TypedefNode */ + private typedefIndex: Map = new Map(); + + /** Update all indexes from a file's AST */ + private updateAllIndexes(uri: string, ast: File): void { + // Remove old entries from this URI + this.removeIndexEntriesForUri(uri); + + // Add new entries + for (const node of ast.body) { + if (!node.name) continue; + + if (node.kind === 'ClassDecl') { + const classNode = node as ClassDeclNode; + (classNode as any)._sourceUri = uri; // Tag with source URI for removal + let existing = this.classIndex.get(node.name); + if (!existing) { + existing = []; + this.classIndex.set(node.name, existing); + } + existing.push(classNode); + } else if (node.kind === 'EnumDecl') { + (node as any)._sourceUri = uri; + this.enumIndex.set(node.name, node as EnumDeclNode); + } else if (node.kind === 'FunctionDecl') { + const funcNode = node as FunctionDeclNode; + (funcNode as any)._sourceUri = uri; + let existing = this.functionIndex.get(node.name); + if (!existing) { + existing = []; + this.functionIndex.set(node.name, existing); + } + existing.push(funcNode); + } else if (node.kind === 'Typedef') { + (node as any)._sourceUri = uri; + this.typedefIndex.set(node.name, node as TypedefNode); + } + } + } + + /** Remove all index entries from a specific URI */ + private removeIndexEntriesForUri(uri: string): void { + // Remove from class index + for (const [name, classes] of this.classIndex) { + const filtered = classes.filter((c: any) => c._sourceUri !== uri); + if (filtered.length === 0) { + this.classIndex.delete(name); + } else if (filtered.length !== classes.length) { + this.classIndex.set(name, filtered); + } + } + + // Remove from enum index + for (const [name, node] of this.enumIndex) { + if ((node as any)._sourceUri === uri) { + this.enumIndex.delete(name); + } + } + + // Remove from function index + for (const [name, funcs] of this.functionIndex) { + const filtered = funcs.filter((f: any) => f._sourceUri !== uri); + if (filtered.length === 0) { + this.functionIndex.delete(name); + } else if (filtered.length !== funcs.length) { + this.functionIndex.set(name, filtered); + } + } + + // Remove from typedef index + for (const [name, node] of this.typedefIndex) { + if ((node as any)._sourceUri === uri) { + this.typedefIndex.delete(name); + } + } + } + + /** Rebuild sorted arrays from the symbol index */ + private rebuildSortedSymbolArrays(): void { + if (!this.symbolIndexDirty) return; + + // Clear prefix buckets + this.symbolsByPrefix.clear(); + + // Build buckets by first letter + for (const name of this.globalSymbolIndex.keys()) { + const firstChar = name[0]?.toLowerCase() || '_'; + let bucket = this.symbolsByPrefix.get(firstChar); + if (!bucket) { + bucket = []; + this.symbolsByPrefix.set(firstChar, bucket); + } + bucket.push(name); + } + + // Sort each bucket for deterministic ordering + for (const bucket of this.symbolsByPrefix.values()) { + bucket.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + } + + // Build sorted master list + this.sortedSymbolNames = Array.from(this.globalSymbolIndex.keys()) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + + this.symbolIndexDirty = false; + } + + /** Rebuild the global symbol index from a file's AST */ + private updateGlobalSymbolIndex(uri: string, ast: File): void { + // First remove any existing symbols from this URI + for (const [name, entry] of this.globalSymbolIndex) { + if (entry.uri === uri) { + this.globalSymbolIndex.delete(name); + } + } + + // Add new symbols from the AST + for (const node of ast.body) { + if (!node.name) continue; + + let entry: GlobalSymbolEntry | undefined; + + if (node.kind === 'ClassDecl') { + const classNode = node as ClassDeclNode; + entry = { + name: node.name, + nameLower: node.name.toLowerCase(), // Pre-compute for fast matching + kind: 'class', + detail: classNode.base?.identifier + ? `extends ${classNode.base.identifier}` + : 'class', + uri + }; + } else if (node.kind === 'FunctionDecl') { + const func = node as FunctionDeclNode; + entry = { + name: func.name, + nameLower: func.name.toLowerCase(), + kind: 'function', + detail: func.returnType?.identifier || 'void', + insertText: `${func.name}()`, + returnType: func.returnType?.identifier, + uri + }; + } else if (node.kind === 'VarDecl') { + const v = node as VarDeclNode; + entry = { + name: v.name, + nameLower: v.name.toLowerCase(), + kind: 'variable', + detail: v.type?.identifier || 'auto', + uri + }; + } else if (node.kind === 'EnumDecl') { + entry = { + name: node.name, + nameLower: node.name.toLowerCase(), + kind: 'enum', + detail: 'enum', + uri + }; + } else if (node.kind === 'Typedef') { + entry = { + name: node.name, + nameLower: node.name.toLowerCase(), + kind: 'typedef', + detail: `typedef ${(node as TypedefNode).oldType?.identifier}`, + uri + }; + } + + if (entry) { + this.globalSymbolIndex.set(node.name, entry); + } + } + + // Mark sorted arrays as needing rebuild + this.symbolIndexDirty = true; + } /** Set preprocessor defines that should be treated as active in #ifdef directives */ setPreprocessorDefines(defines: string[]): void { @@ -305,6 +535,29 @@ export class Analyzer { return { files: this.docCache.size, classes, functions, enums, typedefs, globals, parseErrors: this.parseErrorCount, moduleCounts }; } + /** + * Inject a pre-parsed AST directly into the cache (used by persistent cache on startup) + * @param uri The document URI + * @param ast The pre-parsed AST from disk cache + */ + injectCachedAST(uri: string, ast: File): void { + const normalizedUri = normalizeUri(uri); + ast.module = getModuleLevel(uri); + this.docCache.set(normalizedUri, ast); + // Update indexes for fast lookups + this.updateGlobalSymbolIndex(normalizedUri, ast); + this.updateAllIndexes(normalizedUri, ast); + } + + /** + * Parse a document and return the AST (used during indexing to populate persistent cache) + * @param doc The TextDocument to parse + * @returns The parsed AST (or a stub on error) + */ + parseAndCache(doc: TextDocument): File { + return this.ensure(doc); + } + private ensure(doc: TextDocument): File { // 1 · cache hit const currVersion = doc.version; @@ -317,8 +570,12 @@ export class Analyzer { try { // 2 · happy path ─ parse & cache const ast = parse(doc, undefined, this.preprocessorDefines); // pass full TextDocument + defines + const normalizedUri = normalizeUri(doc.uri); ast.module = getModuleLevel(doc.uri); - this.docCache.set(normalizeUri(doc.uri), ast); + this.docCache.set(normalizedUri, ast); + // Update indexes for fast lookups + this.updateGlobalSymbolIndex(normalizedUri, ast); + this.updateAllIndexes(normalizedUri, ast); return ast; } catch (err) { // 3 · graceful error handling @@ -607,79 +864,57 @@ export class Analyzer { } } - // Add all top-level symbols from ALL indexed documents - for (const [uri, fileAst] of this.docCache) { - for (const node of fileAst.body) { - if (!node.name) continue; - if (seen.has(node.name)) continue; - if (prefix && !node.name.toLowerCase().startsWith(prefix)) continue; - - seen.add(node.name); - - if (node.kind === 'ClassDecl') { - results.push({ - name: node.name, - kind: 'class', - detail: (node as ClassDeclNode).base?.identifier - ? `extends ${(node as ClassDeclNode).base?.identifier}` - : 'class' - }); - } else if (node.kind === 'FunctionDecl') { - const func = node as FunctionDeclNode; - results.push({ - name: func.name, - kind: 'function', - detail: func.returnType?.identifier || 'void', - insertText: `${func.name}()`, - returnType: func.returnType?.identifier - }); - } else if (node.kind === 'VarDecl') { - const v = node as VarDeclNode; - results.push({ - name: v.name, - kind: 'variable', - detail: v.type?.identifier || 'auto' - }); - } else if (node.kind === 'EnumDecl') { - results.push({ - name: node.name, - kind: 'enum', - detail: 'enum' - }); - } else if (node.kind === 'Typedef') { - results.push({ - name: node.name, - kind: 'typedef', - detail: `typedef ${(node as TypedefNode).oldType?.identifier}` - }); - } - } + // ================================================================ + // FAST GLOBAL SYMBOL LOOKUP using pre-built sorted index + // ================================================================ + // Uses prefix-partitioned buckets for O(bucket_size) lookup instead + // of O(total_symbols). Sorted arrays ensure deterministic ordering. + // Pre-computed lowercase names avoid repeated .toLowerCase() calls. + // ================================================================ + + // Ensure sorted arrays are up to date + this.rebuildSortedSymbolArrays(); + + // Choose which symbol names to iterate based on prefix + let symbolNames: string[]; + if (prefix.length > 0) { + // Use prefix bucket for faster lookup + const bucket = this.symbolsByPrefix.get(prefix[0]) || []; + symbolNames = bucket; + } else { + // No prefix - use full sorted list + symbolNames = this.sortedSymbolNames; + } + + for (const name of symbolNames) { + if (seen.has(name)) continue; + + const entry = this.globalSymbolIndex.get(name); + if (!entry) continue; + + // Use pre-computed lowercase for fast prefix matching + if (prefix && !entry.nameLower.startsWith(prefix)) continue; + + seen.add(name); + results.push({ + name: entry.name, + kind: entry.kind, + detail: entry.detail, + insertText: entry.insertText, + returnType: entry.returnType + }); } return results; } - /** - * Known DayZ global variables that have a more specific type than declared. - * Example: g_Game is declared as "Game" but is actually "CGame" - */ - private static readonly KNOWN_VARIABLE_TYPES: Record = { - 'g_Game': 'CGame', - }; - /** * Resolve the type of a variable at a given position. - * Checks known overrides, then delegates AST lookup to resolveVariableTypeNode, - * and falls back to regex patterns for variables the AST misses. + * Checks AST lookup first, then falls back to regex patterns + * for variables the AST misses. */ private resolveVariableType(doc: TextDocument, pos: Position, varName: string): string | null { - // Check for known variable type overrides first - const knownType = Analyzer.KNOWN_VARIABLE_TYPES[varName]; - if (knownType) { - return knownType; - } - // Delegate the AST-based lookup to resolveVariableTypeNode const typeNode = this.resolveVariableTypeNode(doc, pos, varName); if (typeNode) { @@ -765,17 +1000,6 @@ export class Analyzer { return null; } - /** - * Known DayZ singleton functions that return a more specific type than declared. - * These functions are declared to return base class but actually return derived class. - * Example: GetGame() is declared as returning "Game" but actually returns "CGame" - */ - private static readonly KNOWN_RETURN_TYPES: Record = { - 'GetGame': 'CGame', - 'GetDayZGame': 'DayZGame', - 'g_Game': 'CGame', - }; - /** * Resolve the return type of a function by name * Searches top-level functions and class methods across all indexed files @@ -786,35 +1010,29 @@ export class Analyzer { /** * Resolve the return type of a global/static function, returning full TypeNode info. + * Uses pre-built indexes for fast lookup. */ private resolveFunctionReturnTypeNode(funcName: string): TypeNode | null { - // Check for known overrides first (e.g., GetGame() returns CGame, not Game) - const knownType = Analyzer.KNOWN_RETURN_TYPES[funcName]; - if (knownType) { - // Return a synthetic TypeNode for known types - return { kind: 'Type', identifier: knownType, arrayDims: [], modifiers: [], uri: '', start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } as TypeNode; + // Check top-level functions via function index (O(1)) + const funcs = this.functionIndex.get(funcName); + if (funcs && funcs.length > 0) { + for (const func of funcs) { + if (func.returnType?.identifier) { + return func.returnType; + } + } } - // Search all indexed documents for a function with this name - for (const [uri, ast] of this.docCache) { - for (const node of ast.body) { - // Top-level function - if (node.kind === 'FunctionDecl' && node.name === funcName) { - const func = node as FunctionDeclNode; - if (func.returnType?.identifier) { - return func.returnType; - } - } - - // Class method (for static calls or when we don't know the class) - if (node.kind === 'ClassDecl') { - for (const member of (node as ClassDeclNode).members || []) { - if (member.kind === 'FunctionDecl' && member.name === funcName) { - const func = member as FunctionDeclNode; - if (func.returnType?.identifier) { - return func.returnType; - } + // Check class methods across all classes (still need to iterate but on indexed classes) + // This is for static calls or when we don't know the class + for (const [className, classes] of this.classIndex) { + for (const classNode of classes) { + for (const member of classNode.members || []) { + if (member.kind === 'FunctionDecl' && member.name === funcName) { + const func = member as FunctionDeclNode; + if (func.returnType?.identifier) { + return func.returnType; } } } @@ -935,14 +1153,14 @@ export class Analyzer { /** * Get the genericVars (template parameter names) for a class by name. * e.g. for "map" returns ["TKey", "TValue"] + * Uses the pre-built class index for O(1) lookup. */ private getClassGenericVars(className: string): string[] | undefined { - for (const [uri, ast] of this.docCache) { - for (const node of ast.body) { - if (node.kind === 'ClassDecl' && node.name === className) { - return (node as ClassDeclNode).genericVars; - } - } + const classes = this.classIndex.get(className); + if (classes && classes.length > 0) { + // Prefer non-modded class for the template definition + const origClass = classes.find(c => !c.modifiers?.includes('modded')) || classes[0]; + return origClass.genericVars; } return undefined; } @@ -994,16 +1212,10 @@ export class Analyzer { /** * Find the TypedefNode for a given type name. * Returns null if the type is not a typedef. + * Uses the pre-built typedef index for O(1) lookup. */ private resolveTypedefNode(typeName: string): TypedefNode | null { - for (const [uri, ast] of this.docCache) { - for (const node of ast.body) { - if (node.kind === 'Typedef' && node.name === typeName) { - return node as TypedefNode; - } - } - } - return null; + return this.typedefIndex.get(typeName) || null; } /** @@ -1400,44 +1612,71 @@ export class Analyzer { /** * Find a class by name across all cached documents + * Uses the pre-built class index for O(1) lookup. */ private findClassByName(className: string): ClassDeclNode | null { - for (const [uri, ast] of this.docCache) { - for (const node of ast.body) { - if (node.kind === 'ClassDecl' && node.name === className) { - return node as ClassDeclNode; - } - } + const classes = this.classIndex.get(className); + // Return the first non-modded class, or the first modded one if no original exists + if (classes && classes.length > 0) { + return classes.find(c => !c.modifiers?.includes('modded')) || classes[0]; } return null; } /** * Find an enum by name across all indexed files + * Uses the pre-built enum index for O(1) lookup. */ private findEnumByName(enumName: string): EnumDeclNode | null { - for (const [uri, ast] of this.docCache) { - for (const node of ast.body) { - if (node.kind === 'EnumDecl' && node.name === enumName) { - return node as EnumDeclNode; - } - } - } - return null; + return this.enumIndex.get(enumName) || null; } /** * Find the module level (1–5) where a symbol is defined. * Returns 0 if the symbol is not found or has no module info. + * Uses pre-built indexes for fast lookup. */ private getModuleForSymbol(symbolName: string): number { - for (const [uri, ast] of this.docCache) { - for (const node of ast.body) { - if (node.name === symbolName) { - return ast.module || 0; - } + // Check class index + const classes = this.classIndex.get(symbolName); + if (classes && classes.length > 0) { + const sourceUri = (classes[0] as any)._sourceUri; + if (sourceUri) { + const ast = this.docCache.get(sourceUri); + if (ast?.module) return ast.module; + } + } + + // Check enum index + const enumNode = this.enumIndex.get(symbolName); + if (enumNode) { + const sourceUri = (enumNode as any)._sourceUri; + if (sourceUri) { + const ast = this.docCache.get(sourceUri); + if (ast?.module) return ast.module; + } + } + + // Check function index + const funcs = this.functionIndex.get(symbolName); + if (funcs && funcs.length > 0) { + const sourceUri = (funcs[0] as any)._sourceUri; + if (sourceUri) { + const ast = this.docCache.get(sourceUri); + if (ast?.module) return ast.module; + } + } + + // Check typedef index + const typedefNode = this.typedefIndex.get(symbolName); + if (typedefNode) { + const sourceUri = (typedefNode as any)._sourceUri; + if (sourceUri) { + const ast = this.docCache.get(sourceUri); + if (ast?.module) return ast.module; } } + return 0; } @@ -1601,7 +1840,7 @@ export class Analyzer { } } - // FALLBACK: Global search - but with proper scoping rules + // FALLBACK: Global search using pre-built indexes // - Enum members ONLY if accessed via EnumName.member // - Class members ONLY if inside that class (already checked above) const matches: SymbolNodeBase[] = []; @@ -1610,30 +1849,62 @@ export class Analyzer { const enumMemberMatch = textBeforeToken.match(/(\w+)\s*\.\s*$/); const isEnumAccess = enumMemberMatch && enumMemberMatch[1][0] === enumMemberMatch[1][0].toUpperCase(); - // iterate all loaded documents - for (const [uri, ast] of this.docCache) { - for (const node of ast.body) { - // top-level match (classes, functions, global variables, enums, typedefs) - if (node.name === name) { - matches.push(node as SymbolNodeBase); + // Check class index + const classes = this.classIndex.get(name); + if (classes) { + for (const c of classes) matches.push(c as SymbolNodeBase); + } + + // Check function index + const funcs = this.functionIndex.get(name); + if (funcs) { + for (const f of funcs) matches.push(f as SymbolNodeBase); + } + + // Check enum index + const enumNode = this.enumIndex.get(name); + if (enumNode) { + matches.push(enumNode as SymbolNodeBase); + } + + // Check typedef index + const typedefNode = this.typedefIndex.get(name); + if (typedefNode) { + matches.push(typedefNode as SymbolNodeBase); + } + + // Check global symbol index for variables + const globalSymbol = this.globalSymbolIndex.get(name); + if (globalSymbol && globalSymbol.kind === 'variable') { + // Need to find the actual VarDeclNode - search docCache for this specific variable + const ast = this.docCache.get(globalSymbol.uri); + if (ast) { + for (const node of ast.body) { + if (node.kind === 'VarDecl' && node.name === name) { + matches.push(node as SymbolNodeBase); + break; + } } + } + } - // Enum member match - ONLY if accessed via EnumName.member - if (isEnumAccess && enumMemberMatch && node.kind === 'EnumDecl' && node.name === enumMemberMatch[1]) { - for (const member of (node as EnumDeclNode).members) { - if (member.name === name) { - matches.push(member as SymbolNodeBase); - } + // Enum member match - ONLY if accessed via EnumName.member + if (isEnumAccess && enumMemberMatch) { + const enumDecl = this.enumIndex.get(enumMemberMatch[1]); + if (enumDecl) { + for (const member of enumDecl.members) { + if (member.name === name) { + matches.push(member as SymbolNodeBase); } } - - // Class members are NOT included in global search - // They should only be found via: - // 1. Member access (player.Method) - handled above - // 2. Inside the class (this.Method or just Method) - handled above - // 3. Inheritance chain - handled above } } + + // Class members are NOT included in global search + // They should only be found via: + // 1. Member access (player.Method) - handled above + // 2. Inside the class (this.Method or just Method) - handled above + // 3. Inheritance chain - handled above return matches; } @@ -1753,23 +2024,10 @@ export class Analyzer { /** * Find all classes with a given name (handles modded classes) * In Enforce Script, multiple 'modded class X' can exist for the same class + * Uses the pre-built class index for O(1) lookup. */ private findAllClassesByName(className: string): ClassDeclNode[] { - const matches: ClassDeclNode[] = []; - - for (const [uri, ast] of this.docCache) { - for (const node of ast.body) { - if (node.kind === 'ClassDecl' && node.name === className) { - const classNode = node as ClassDeclNode; - matches.push(classNode); - } - } - } - - if (matches.length === 0) { - } - - return matches; + return this.classIndex.get(className) || []; } getHover(doc: TextDocument, _pos: Position): string | null { @@ -3257,18 +3515,19 @@ export class Analyzer { } } } else { - // Global function: search all files - for (const [uri, ast] of this.docCache) { - for (const node of ast.body) { - if (node.kind === 'FunctionDecl' && node.name === funcName) { - overloads.push(node as FunctionDeclNode); - } - // Also check class methods (for unqualified calls from within a class) - if (node.kind === 'ClassDecl') { - for (const member of (node as ClassDeclNode).members || []) { - if (member.kind === 'FunctionDecl' && member.name === funcName) { - overloads.push(member as FunctionDeclNode); - } + // Global function: use function index for fast lookup + const funcs = this.functionIndex.get(funcName); + if (funcs) { + overloads.push(...funcs); + } + + // Also check class methods (for unqualified calls from within a class) + // This still needs to iterate class index since we don't index methods separately + for (const [className, classes] of this.classIndex) { + for (const classNode of classes) { + for (const member of classNode.members || []) { + if (member.kind === 'FunctionDecl' && member.name === funcName) { + overloads.push(member as FunctionDeclNode); } } } diff --git a/server/src/index.ts b/server/src/index.ts index 8fe34b1..2d7e58a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -14,6 +14,7 @@ import * as fs from 'fs/promises'; import { findAllFiles, readFileUtf8 } from './util/fs'; import { Analyzer } from './analysis/project/graph'; import { getConfiguration } from './util/config'; +import { ASTCache } from './analysis/project/cache'; // Create LSP connection (stdio or Node IPC autodetect). @@ -71,22 +72,67 @@ connection.onInitialized(async () => { console.log(`Indexing ${allFiles.length} EnScript files...`); - for (const filePath of allFiles) { + // Notify client that indexing is starting + connection.sendNotification('enscript/indexingStart', { + fileCount: allFiles.length + }); + + // Initialize persistent AST cache for faster subsequent launches + const astCache = new ASTCache(workspaceRoot); + let cacheHits = 0; + let cacheMisses = 0; + const startTime = Date.now(); + let lastProgressUpdate = 0; + + for (let i = 0; i < allFiles.length; i++) { + const filePath = allFiles[i]; const uri = url.pathToFileURL(filePath).toString(); - const text = await readFileUtf8(filePath); - const doc = TextDocument.create(uri, 'enscript', 1, text); + + // Try to load from persistent cache first + const cachedAst = astCache.get(filePath); + if (cachedAst) { + // Cache hit - inject directly into Analyzer's memory cache + Analyzer.instance().injectCachedAST(uri, cachedAst); + cacheHits++; + } else { + // Cache miss - need to read and parse + const text = await readFileUtf8(filePath); + const doc = TextDocument.create(uri, 'enscript', 1, text); + const ast = Analyzer.instance().parseAndCache(doc); + + // Store in persistent cache for next launch + if (ast && ast.body.length > 0) { + astCache.set(filePath, ast); + } + cacheMisses++; + } - Analyzer.instance().runDiagnostics(doc); // will parse & cache + // Send progress updates every 500ms or every 100 files + const now = Date.now(); + if (now - lastProgressUpdate > 500 || (i + 1) % 100 === 0) { + connection.sendNotification('enscript/indexingProgress', { + current: i + 1, + total: allFiles.length, + percent: Math.round((i + 1) / allFiles.length * 100) + }); + lastProgressUpdate = now; + } } + // Save the cache to disk + astCache.save(); + + const elapsed = Date.now() - startTime; + const stats = Analyzer.instance().getIndexStats(); const moduleNames: Record = { 1: '1_Core', 2: '2_GameLib', 3: '3_Game', 4: '4_World', 5: '5_Mission' }; console.log( - `Indexing complete: ${stats.files} files, ` + + `Indexing complete in ${elapsed}ms: ${stats.files} files, ` + `${stats.classes} classes, ${stats.functions} functions, ` + `${stats.enums} enums, ${stats.typedefs} typedefs, ${stats.globals} globals` + (stats.parseErrors > 0 ? ` (${stats.parseErrors} parse errors)` : '') ); + console.log(` Cache: ${cacheHits} hits, ${cacheMisses} misses (${cacheHits > 0 ? Math.round(cacheHits / (cacheHits + cacheMisses) * 100) : 0}% hit rate)`); // Log per-module file counts const modParts = Object.entries(stats.moduleCounts) .sort(([a], [b]) => Number(a) - Number(b)) diff --git a/server/src/lsp/handlers/hover.ts b/server/src/lsp/handlers/hover.ts index 7750ee2..209cf5e 100644 --- a/server/src/lsp/handlers/hover.ts +++ b/server/src/lsp/handlers/hover.ts @@ -4,7 +4,7 @@ import { Analyzer } from '../../analysis/project/graph'; export function registerHover(conn: Connection, docs: TextDocuments): void { conn.onHover((params: HoverParams): Hover | null => { - conn.console.info(`onHover ${params.textDocument.uri}`); + //conn.console.info(`onHover ${params.textDocument.uri}`); const doc = docs.get(params.textDocument.uri); if (!doc) return null; diff --git a/src/extension.ts b/src/extension.ts index 8ad448d..99c43b6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,6 +8,7 @@ import { } from 'vscode-languageclient/node'; let client: LanguageClient | undefined; +let statusBarItem: vscode.StatusBarItem | undefined; export async function activate(context: vscode.ExtensionContext) { const serverModule = path.join(__dirname, '..', 'server', 'out', 'index.js'); @@ -39,9 +40,38 @@ export async function activate(context: vscode.ExtensionContext) { client.start(); context.subscriptions.push(client); + // Create status bar item for indexing progress + statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); + context.subscriptions.push(statusBarItem); + + // Listen for indexing start notification + client.onNotification('enscript/indexingStart', (params: { fileCount: number }) => { + if (statusBarItem) { + statusBarItem.text = `$(sync~spin) Enscript: Indexing ${params.fileCount} files...`; + statusBarItem.tooltip = 'EnScript is indexing your workspace. Autocomplete will be available shortly.'; + statusBarItem.show(); + } + }); + + // Listen for indexing progress notification + client.onNotification('enscript/indexingProgress', (params: { current: number; total: number; percent: number }) => { + if (statusBarItem) { + statusBarItem.text = `$(sync~spin) Enscript: Indexing ${params.current}/${params.total} (${params.percent}%)`; + } + }); + // Listen for indexing complete notification - refresh diagnostics on open files client.onNotification('enscript/indexingComplete', (params: { fileCount: number }) => { - vscode.window.showInformationMessage(`Enscript: Indexed ${params.fileCount} files. Refreshing diagnostics...`); + if (statusBarItem) { + statusBarItem.text = `$(check) Enscript: Ready`; + statusBarItem.tooltip = `Indexed ${params.fileCount} files`; + // Hide after 5 seconds + setTimeout(() => { + if (statusBarItem) { + statusBarItem.hide(); + } + }, 5000); + } // Trigger a re-validation of all open enscript documents for (const doc of vscode.workspace.textDocuments) { From 56dffb47343fae39b2fcdb6dc98b5f19985c0d3f Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sat, 7 Feb 2026 22:08:24 -0500 Subject: [PATCH 12/46] Improve generic parsing and hover/type resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parser: Handle nested generic closing tokens ('>>') when walking balanced angle brackets so generic types like map> are parsed correctly. graph: Add template substitution support to format declarations and implement buildHoverTemplateMap so hover text can show concrete generic types (e.g. {TKey: string}). Prefer classes over typedefs in resolveTypedefNode to avoid opaque-handle collisions. Also: refine various parsing heuristics and robustness — tighten variable-declaration regexes, handle multi-line function declarations and parameter lists, improve block-comment/inline-comment handling, skip var extraction inside multi-line func params, expand function-declaration regex to accept different trailing forms, and enhance member-chain resolution and overload lookup (including preferring class static access and preserving raw types for chain-based template resolution). These changes improve accuracy of type resolution, hover info, and function lookup for generic/chain expressions. --- server/src/analysis/ast/parser.ts | 14 +- server/src/analysis/project/graph.ts | 252 +++++++++++++++++++++++---- 2 files changed, 227 insertions(+), 39 deletions(-) diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index a4c00d0..0178297 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -548,17 +548,21 @@ export function parse( // Detect local variable declarations: // TypeName varName ; or TypeName varName = or TypeName varName , // prevPrev = type token, prev = name token, t = ; or = or , - // For generic types like array, prevPrev is '>' — we need to + // For generic types like array, prevPrev is '>' or '>>' — we need to // walk backwards past balanced angle brackets to find the actual type name. + // The '>>' token represents two closing brackets (nested generics like + // map>) and must be counted as 2. if (prev && prevPrev && (t.value === ';' || t.value === '=' || t.value === ',')) { let typeTok = prevPrev; - if (prevPrev.value === '>') { + if (prevPrev.value === '>' || prevPrev.value === '>>') { // Walk backwards through tokens to find matching '<' and the type before it - let angleDepth = 1; - let searchPos = prevPrevIdx - 1; // start before the '>' + // '>>' counts as 2 closing brackets (nested generics) + let angleDepth = prevPrev.value === '>>' ? 2 : 1; + let searchPos = prevPrevIdx - 1; // start before the '>' or '>>' while (searchPos >= 0 && angleDepth > 0) { const st = toks[searchPos]; - if (st.value === '>') angleDepth++; + if (st.value === '>>') angleDepth += 2; + else if (st.value === '>') angleDepth++; else if (st.value === '<') angleDepth--; searchPos--; } diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 4591d07..4ab47ad 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -108,18 +108,24 @@ export function getTokenAtPosition(text: string, offset: number): Token | null { return null; } -function formatDeclaration(node: SymbolNodeBase): string { +function formatDeclaration(node: SymbolNodeBase, templateMap?: Map): string { + // Helper: substitute generic type params with concrete types from templateMap + const subst = (typeName: string): string => { + if (!templateMap || templateMap.size === 0) return typeName; + return templateMap.get(typeName) || typeName; + }; + let fmt: string | null = null; switch (node.kind) { case 'FunctionDecl': { const _node = node as FunctionDeclNode; - fmt = `${(_node.modifiers.length ? _node.modifiers.join(' ') + ' ': '')}${_node.returnType.identifier} ${_node.name}(${_node.parameters?.map(p => (p.modifiers.length ? p.modifiers.join(' ') + ' ': '') + p.type.identifier + ' ' + p.name).join(', ') ?? ''})`; + fmt = `${(_node.modifiers.length ? _node.modifiers.join(' ') + ' ': '')}${subst(_node.returnType.identifier)} ${_node.name}(${_node.parameters?.map(p => (p.modifiers.length ? p.modifiers.join(' ') + ' ': '') + subst(p.type.identifier) + ' ' + p.name).join(', ') ?? ''})`; break; } case 'VarDecl': { const _node = node as VarDeclNode; - fmt = `${(_node.modifiers.length ? _node.modifiers.join(' ') + ' ': '')}${_node.type.identifier} ${_node.name}`; + fmt = `${(_node.modifiers.length ? _node.modifiers.join(' ') + ' ': '')}${subst(_node.type.identifier)} ${_node.name}`; break; } @@ -927,7 +933,8 @@ export class Analyzer { const regexKeywords = new Set(['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', 'else', 'foreach', 'void', 'override', 'static', 'private', 'protected', 'const', 'ref', 'autoptr', 'proto', 'native', 'modded', 'sealed', 'event', 'typedef', 'case', 'break', 'continue', 'this', 'super', 'null', 'true', 'false', 'out', 'inout', 'volatile']); // Pattern: Type varName; or Type varName = - const varDeclMatch = text.match(new RegExp(`(\\w+)\\s+${varName}\\s*[;=]`)); + // Use word boundary to avoid matching inside larger expressions + const varDeclMatch = text.match(new RegExp(`(?:^|[{;,\\s])\\s*(\\w+)\\s+${varName}\\s*[;=]`)); if (varDeclMatch && !regexKeywords.has(varDeclMatch[1])) { return varDeclMatch[1]; } @@ -1213,9 +1220,24 @@ export class Analyzer { * Find the TypedefNode for a given type name. * Returns null if the type is not a typedef. * Uses the pre-built typedef index for O(1) lookup. + * + * IMPORTANT: If a class with the same name also exists, the class takes + * precedence over the typedef. This handles DayZ's opaque handle pattern + * where e.g. "typedef int[] Material;" coexists with "class Material { ... }". + * The typedef is just the internal engine representation; the class is + * the actual script-level type with methods. */ private resolveTypedefNode(typeName: string): TypedefNode | null { - return this.typedefIndex.get(typeName) || null; + const node = this.typedefIndex.get(typeName); + if (!node) return null; + + // If a class with the same name exists, prefer the class over the typedef. + // This handles opaque handle types like: typedef int[] Material; class Material { ... } + if (this.classIndex.has(typeName)) { + return null; + } + + return node; } /** @@ -2031,14 +2053,117 @@ export class Analyzer { } getHover(doc: TextDocument, _pos: Position): string | null { + // Build template context for member accesses so hover shows + // concrete types (e.g., "string" instead of "TValue") + const templateMap = this.buildHoverTemplateMap(doc, _pos); + const symbols = this.resolveDefinitions(doc, _pos); if (symbols.length === 0) return null; return symbols - .map((s) => formatDeclaration(s)) + .map((s) => formatDeclaration(s, templateMap)) .join('\n\n'); } + /** + * Build a template substitution map for hover at the given position. + * When hovering over a member of a generic/typedef'd variable (e.g., testMap.Get), + * this builds a map like { TKey: "string", TValue: "int" } so the hover can + * display concrete types instead of generic parameter names. + */ + private buildHoverTemplateMap(doc: TextDocument, _pos: Position): Map | undefined { + const offset = doc.offsetAt(_pos); + const text = doc.getText(); + const token = getTokenAtPosition(text, offset); + if (!token) return undefined; + + const typeKeywords = new Set(['string', 'int', 'float', 'bool', 'vector', 'typename', 'void']); + if (token.kind !== TokenKind.Identifier && + !(token.kind === TokenKind.Keyword && typeKeywords.has(token.value))) { + return undefined; + } + + const textBeforeToken = text.substring(0, token.start); + + // Multi-level chain: e.g., param.field.Get + const multiLevelMatch = textBeforeToken.match(/(\w+)((?:\s*\.\s*\w+(?:\s*\([^)]*\))?)+)\s*\.\s*$/); + if (multiLevelMatch) { + const rootName = multiLevelMatch[1]; + const middleChain = multiLevelMatch[2]; + + const rootType = this.resolveVariableType(doc, _pos, rootName); + if (rootType) { + let currentType = rootType; + let templateMap: Map; + const typedefNode = this.resolveTypedefNode(currentType); + if (typedefNode) { + currentType = typedefNode.oldType.identifier; + templateMap = this.buildTemplateMap(currentType, typedefNode.oldType.genericArgs); + } else { + currentType = this.resolveTypedef(currentType); + const varTypeNode = this.resolveVariableTypeNode(doc, _pos, rootName); + if (varTypeNode?.genericArgs && varTypeNode.genericArgs.length > 0) { + templateMap = this.buildTemplateMap(currentType, varTypeNode.genericArgs); + } else { + templateMap = new Map(); + } + } + + const chainMembers = this.parseChainMembers(middleChain); + if (chainMembers.length > 0) { + const result = this.resolveChainSteps(chainMembers, currentType, templateMap); + if (result && result.templateMap.size > 0) { + return result.templateMap; + } + } + } + } + + // Single-level: variable.member (e.g., testMap.Get) + const memberMatch = textBeforeToken.match(/(\w+)\s*\.\s*$/); + if (memberMatch) { + const varName = memberMatch[1]; + const varType = this.resolveVariableType(doc, _pos, varName); + if (varType) { + const typedefNode = this.resolveTypedefNode(varType); + if (typedefNode) { + const resolvedType = typedefNode.oldType.identifier; + const tplMap = this.buildTemplateMap(resolvedType, typedefNode.oldType.genericArgs); + if (tplMap.size > 0) return tplMap; + } else { + // Direct generic declaration: map myMap; + const varTypeNode = this.resolveVariableTypeNode(doc, _pos, varName); + if (varTypeNode?.genericArgs && varTypeNode.genericArgs.length > 0) { + const resolvedType = this.resolveTypedef(varType); + const tplMap = this.buildTemplateMap(resolvedType, varTypeNode.genericArgs); + if (tplMap.size > 0) return tplMap; + } + } + } + } + + // Function call chain: GetSomething().member + const chainedCallMatch = textBeforeToken.match(/(\w+)\s*\([^)]*\)\s*\.\s*$/); + if (chainedCallMatch) { + const funcName = chainedCallMatch[1]; + const returnTypeNode = this.resolveFunctionReturnTypeNode(funcName); + if (returnTypeNode?.identifier) { + let resolvedType = returnTypeNode.identifier; + const typedefNode = this.resolveTypedefNode(resolvedType); + if (typedefNode) { + resolvedType = typedefNode.oldType.identifier; + const tplMap = this.buildTemplateMap(resolvedType, typedefNode.oldType.genericArgs); + if (tplMap.size > 0) return tplMap; + } else if (returnTypeNode.genericArgs && returnTypeNode.genericArgs.length > 0) { + const tplMap = this.buildTemplateMap(resolvedType, returnTypeNode.genericArgs); + if (tplMap.size > 0) return tplMap; + } + } + } + + return undefined; + } + findReferences(doc: TextDocument, _pos: Position, _inc: boolean) { return []; } @@ -2268,8 +2393,8 @@ export class Analyzer { // Pattern to detect function declarations // Must have: optional modifiers, return type (including generics), function name, parentheses for params // Excludes: array access like m_Foo[0] and assignments - // Matches lines ending with { (normal), {} (empty body), ; (proto/native), or ) (brace on next line) - const funcDeclPattern = /^\s*(?:static\s+|private\s+|protected\s+|override\s+|proto\s+|native\s+|volatile\s+|event\s+)*(?:void|int|float|bool|string|auto|ref\s+\w+(?:\s*<[\w,\s<>]+>)?|\w+(?:\s*<[\w,\s<>]+>)?)\s+(\w+)\s*\([^)]*\)\s*(?:\{[^}]*\}?|;)?\s*$/; + // Matches lines ending with { (normal), {} (empty body), {}; (empty body + semicolon), ; (proto/native), or ) (brace on next line) + const funcDeclPattern = /^\s*(?:static\s+|private\s+|protected\s+|override\s+|proto\s+|native\s+|volatile\s+|event\s+)*(?:void|int|float|bool|string|auto|ref\s+\w+(?:\s*<[\w,\s<>]+>)?|\w+(?:\s*<[\w,\s<>]+>)?)\s+(\w+)\s*\([^)]*\)\s*(?:\{[^}]*\}?;?|;)?\s*$/; // Pattern to detect proto/native function declarations (no body, just ;) // Params in these don't create real variables — skip them for duplicate checking @@ -2286,6 +2411,11 @@ export class Analyzer { let functionBraceDepth = 0; let braceDepth = 0; + // Track multi-line function declarations (parameters spanning multiple lines) + // When a function declaration has ( but no ) on the same line, we set this flag + // and skip variable extraction until we find the closing ) + let inMultiLineFuncDecl = false; + // Track class fields - these need to be visible inside methods let classFieldScope: Map = new Map(); let inClass = false; @@ -2316,8 +2446,15 @@ export class Analyzer { // Check for block comment start if (trimmedLine.startsWith('/*') || trimmedLine.startsWith('/**')) { if (line.includes('*/')) { - // Single-line block comment like /* foo */ - skip entire line - continue; + // Single-line block comment like /* foo */ — but only skip if the + // ENTIRE line is a comment. If there's code AFTER the closing */, + // fall through to normal processing where inline /*...*/ stripping + // handles it. Example: /*sealed*/ bool IsFlipped() + const afterComment = line.substring(line.indexOf('*/') + 2).trim(); + if (!afterComment) { + continue; + } + // Code follows the block comment — fall through to process it } else { // Multi-line block comment starts here inBlockComment = true; @@ -2391,6 +2528,22 @@ export class Analyzer { inFunction = false; } + // Handle multi-line function declarations: if we're inside one, + // check if this line has the closing ')' + if (inMultiLineFuncDecl) { + if (lineNoComment.includes(')')) { + // End of multi-line param list — trigger scope reset now + inMultiLineFuncDecl = false; + const wasInFunction = inFunction; + scopeStack = [scopeStack[0], new Map(classFieldScope), new Map()]; + inFunction = true; + functionBraceDepth = braceDepth; + } + // Skip variable extraction for parameter continuation lines + // (they are function params, handled by the scope reset above) + // Still process braces below though + } + // Check if this line has a for/foreach/while loop // In Enforce Script, loop variables are scoped to the PARENT scope, not the loop block const isLoopLine = loopPattern.test(lineNoComment); @@ -2398,8 +2551,23 @@ export class Analyzer { // Check if this line starts a new function // Must NOT be a loop line — for(int i ...) looks like a func decl to the regex // Also exclude control flow statements (if, else, switch, do) which can false-match - const isControlFlow = /\b(if|else|switch|do|return|new|delete|throw)\b/.test(lineNoComment); - const isFuncDecl = !isLoopLine && !isControlFlow && funcDeclPattern.test(lineNoComment); + // IMPORTANT: Only check for control flow keywords BEFORE the first '{', because + // single-line function bodies like "bool Foo() { return true; }" contain keywords + // like 'return' in the body that would cause a false isControlFlow=true. + const declPart = lineNoComment.includes('{') ? lineNoComment.substring(0, lineNoComment.indexOf('{')) : lineNoComment; + const isControlFlow = /\b(if|else|switch|do|return|new|delete|throw)\b/.test(declPart); + let isFuncDecl = !isLoopLine && !isControlFlow && funcDeclPattern.test(lineNoComment); + + // Detect multi-line function declarations: looks like a func decl start + // (has modifiers + return type + name + open paren) but no closing ')' on this line + if (!isFuncDecl && !isLoopLine && !isControlFlow && !inMultiLineFuncDecl) { + // Pattern: optional modifiers, return type, function name, opening '(' but no closing ')' + const multiLineFuncStart = /^\s*(?:static\s+|private\s+|protected\s+|override\s+|proto\s+|native\s+|volatile\s+|event\s+)*(?:void|int|float|bool|string|auto|ref\s+\w+(?:\s*<[\w,\s<>]+>)?|\w+(?:\s*<[\w,\s<>]+>)?)\s+\w+\s*\(/.test(lineNoComment); + if (multiLineFuncStart && !lineNoComment.includes(')')) { + inMultiLineFuncDecl = true; + // Scope reset will happen when we find the closing ')' + } + } if (isFuncDecl) { // New function - reset to: global scope + class fields (copy) + new function scope @@ -2451,12 +2619,13 @@ export class Analyzer { // This ensures for loop variables (int j in "for (int j = 0...") // are added to the current scope before we push a new scope for { // Use lineNoComment to avoid matching variables in comments + // Skip if we're in a multi-line function declaration (params are not local vars) let match; varDeclPattern.lastIndex = 0; // Use lineNoComment but keep original line for position calculation const lineForVars = lineNoComment; - while (!isProtoOrNative && (match = varDeclPattern.exec(lineForVars)) !== null) { + while (!isProtoOrNative && !inMultiLineFuncDecl && (match = varDeclPattern.exec(lineForVars)) !== null) { const typeName = match[1]; const varName = match[2]; @@ -4098,7 +4267,8 @@ export class Analyzer { // If preceded by a type + space, it's likely a declaration not a call const beforeCall = text.substring(Math.max(0, match.index - 80), match.index); // Check if this is a function declaration (type immediately before name) - const declCheck = beforeCall.match(/(?:void|int|float|bool|string|auto|vector|override\s+\w+|static\s+\w+|\w+)\s+$/); + // Also handles generic types like array or map + const declCheck = beforeCall.match(/(?:void|int|float|bool|string|auto|vector|override\s+\w+|static\s+\w+|\w+(?:<[^>]*>)?)\s+$/); if (declCheck) { // Could be a declaration. Check more carefully — if the next non-whitespace // before the type name is '{', ';', or start-of-line, it's a declaration @@ -4154,14 +4324,30 @@ export class Analyzer { const dotMatch = textBeforeFunc.match(/(\w+)\s*\.\s*$/); if (dotMatch) { const objName = dotMatch[1]; + + // Check if objName is a class name FIRST (for static access like ClassName.StaticMethod) + // This must come before getVarTypeAtLine because regex fallbacks can + // produce false positives (e.g., matching "new InventoryLocation;" as type "new") + if (objName[0] === objName[0].toUpperCase() && this.classIndex.has(objName)) { + overloads = this.findFunctionOverloads(funcName, objName); + } + + if (overloads.length === 0) { let objType = getVarTypeAtLine(objName, lineNum); if (objType) { objType = this.resolveTypedef(objType); overloads = this.findFunctionOverloads(funcName, objType); - } else { - // objName is not a simple variable. Use the backward-walking chain parser - // to extract the full expression (e.g., Currencies.Get(i).MoneyValues) - // and resolve its type, then find the function on that type. + } + + // If simple variable lookup didn't find overloads (or objType was falsy), + // try chain resolution. This handles multi-step chains like + // data.m_Modifiers.Count() where dotMatch captures "m_Modifiers" but + // it's actually a member of "data", not a standalone variable. + // Without this fallback, a wrong type from regex fallback (e.g., an + // unrelated "int m_Modifiers;" in another class) would short-circuit + // the chain resolution and produce false "Unknown method" errors. + if (overloads.length === 0) { + chainAttempted = true; const fullTextBefore = textBeforeFunc.replace(/\s+$/, ''); // fullTextBefore ends with "objName." — walk backwards from the end to extract // the entire chain expression including objName. @@ -4217,9 +4403,7 @@ export class Analyzer { } else { // Root is a variable or class name (for static access) rootType = getVarTypeAtLine(rootName, lineNum); - if (rootType) { - rootType = this.resolveTypedef(rootType); - } else { + if (!rootType) { // Check if root is a class name (for static field/method access) const classNodes = this.findAllClassesByName(rootName); if (classNodes.length > 0) { @@ -4228,14 +4412,16 @@ export class Analyzer { } chainRemainder = afterRoot; } - // Resolve through the remaining chain if present + // Resolve through the remaining chain if present. + // Pass the raw (non-typedef-resolved) rootType to resolveVariableChainType + // so it can build the proper template map from the typedef's generic args. if (rootType && chainRemainder.trim().startsWith('.')) { const resolved = this.resolveVariableChainType(rootType, chainRemainder.trim()); if (resolved) { overloads = this.findFunctionOverloads(funcName, resolved); } } else if (rootType) { - overloads = this.findFunctionOverloads(funcName, rootType); + overloads = this.findFunctionOverloads(funcName, this.resolveTypedef(rootType)); } } } @@ -4245,6 +4431,7 @@ export class Analyzer { overloads = this.findFunctionOverloads(funcName, objName); } } + } // end of overloads.length === 0 check } else { // Check for chain call: e.g., U().globals().FuncName( or var.method().FuncName( // The simple dotMatch fails when a ')' precedes the dot (chain with call results) @@ -4305,27 +4492,24 @@ export class Analyzer { chainRemainder = afterRoot.substring(i); } else { // Root is a variable or class name (for static access) - rootType = getVarTypeAtLine(rootName, lineNum); - if (rootType) { - rootType = this.resolveTypedef(rootType); + // Check class name first to avoid regex false positives + if (rootName[0] === rootName[0].toUpperCase() && this.classIndex.has(rootName)) { + rootType = rootName; } else { - // Check if root is a class name (for static field/method access like ClassName.StaticField) - const classNodes = this.findAllClassesByName(rootName); - if (classNodes.length > 0) { - // Root is a class name - treat the class itself as the "type" for static access - rootType = rootName; - } + rootType = getVarTypeAtLine(rootName, lineNum); } chainRemainder = afterRoot; } - // Resolve through the remaining chain if present + // Resolve through the remaining chain if present. + // Pass the raw (non-typedef-resolved) rootType to resolveVariableChainType + // so it can build the proper template map from the typedef's generic args. if (rootType && chainRemainder.trim().startsWith('.')) { const resolved = this.resolveVariableChainType(rootType, chainRemainder.trim()); if (resolved) { overloads = this.findFunctionOverloads(funcName, resolved); } } else if (rootType) { - overloads = this.findFunctionOverloads(funcName, rootType); + overloads = this.findFunctionOverloads(funcName, this.resolveTypedef(rootType)); } } } From 020d6a1cdf5cd3a45010bf5f59613e2796478572 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sat, 7 Feb 2026 22:23:45 -0500 Subject: [PATCH 13/46] Refactor diagnostics: shared context & optimizations Introduce a shared diagnostic context computed once per runDiagnostics to avoid duplicated work across checkers. Add buildLineOffsets and getLineFromOffset for O(1) line lookups, and a centralized isInsideCommentOrStringAt helper. Implement buildScopedVarMap to unify AST- and regex-based scoped variable collection (including class fields and inherited fields) and reuse it across checkTypeMismatches and checkFunctionCallArgs. Update runDiagnostics to compute stripped text, lines, and lineOffsets once and pass them into various checkers; adjust signatures of checkDuplicateVariables, checkTypeMismatches, checkFunctionCallArgs, and checkMultiLineStatements accordingly. Remove numerous per-checker duplicate scans and inline helpers, preserving previous scoping/priority semantics while improving performance and maintainability. --- server/src/analysis/project/graph.ts | 841 +++++++++++---------------- 1 file changed, 352 insertions(+), 489 deletions(-) diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 4ab47ad..21e3b59 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -2336,6 +2336,301 @@ export class Analyzer { // This prevents false positives during initial indexing private static readonly MIN_INDEX_SIZE_FOR_TYPE_CHECKS = 100; + // ======================================================================== + // DIAGNOSTIC CONTEXT — shared pre-computed data for all diagnostic passes + // ======================================================================== + // Built once per runDiagnostics() invocation instead of duplicated in + // each checker. Contains: + // - text: ifdef-stripped source text (preserves line numbers) + // - lines: text.split('\n') — used by line-based scanners + // - lineOffsets: cumulative character offsets per line for O(1) lookup + // - ast: parsed AST for this document + // - doc: the TextDocument (needed for positionAt) + // - scopedVars: unified scoped variable map (used by type mismatch and + // call arg checkers with different lookup semantics) + // ======================================================================== + + /** + * Pre-computed line offset table for O(1) line-from-position lookup. + * lineOffsets[i] = character index where line i starts. + * To find which line a character position is on, binary-search this array. + */ + private static buildLineOffsets(text: string): number[] { + const offsets: number[] = [0]; // Line 0 starts at offset 0 + for (let i = 0; i < text.length; i++) { + if (text[i] === '\n') { + offsets.push(i + 1); + } + } + return offsets; + } + + /** + * O(1) line number from character position via binary search on pre-built offsets. + * Equivalent to the old getLineFromPos but without O(n) scan per call. + */ + private static getLineFromOffset(lineOffsets: number[], pos: number): number { + // Binary search: find the last lineOffsets[i] <= pos + let lo = 0, hi = lineOffsets.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (lineOffsets[mid] <= pos) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo; + } + + /** + * Check if a character position falls inside a comment or string literal. + * Uses the same algorithm as the previously duplicated closures in + * checkTypeMismatches and checkFunctionCallArgs. + * + * @param text The full (ifdef-stripped) source text + * @param position Character offset to test + */ + private static isInsideCommentOrStringAt(text: string, position: number): boolean { + // Check single-line comments + let lineStart = text.lastIndexOf('\n', position) + 1; + let lineEnd = text.indexOf('\n', position); + if (lineEnd === -1) lineEnd = text.length; + const line = text.substring(lineStart, lineEnd); + const posInLine = position - lineStart; + + // Check if there's a // before this position on the same line + const commentIdx = line.indexOf('//'); + if (commentIdx >= 0 && commentIdx < posInLine) { + return true; + } + + // Check block comments - scan backwards for /* that isn't closed + let i = position - 1; + while (i >= 0) { + if (i > 0 && text[i - 1] === '*' && text[i] === '/') { + // Found end of block comment, we're outside + break; + } + if (i > 0 && text[i - 1] === '/' && text[i] === '*') { + // Found start of block comment, we're inside + return true; + } + i--; + } + + // Check strings - count unescaped quotes before position on same line + let inString = false; + let stringChar = ''; + for (let j = 0; j < posInLine; j++) { + const ch = line[j]; + if (!inString && (ch === '"' || ch === "'")) { + inString = true; + stringChar = ch; + } else if (inString && ch === stringChar) { + let backslashCount = 0; + let bi = j - 1; + while (bi >= 0 && line[bi] === '\\') { backslashCount++; bi--; } + if (backslashCount % 2 === 0) { + inString = false; + } + } + } + return inString; + } + + /** Scoped variable entry — tracks type and scope range with class-field distinction */ + private static readonly SCOPED_VAR_TYPE_KEYWORDS = new Set([ + 'if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', + 'else', 'case', 'override', 'static', 'private', 'protected', 'ref', 'autoptr', + 'const', 'proto', 'native', 'Print', 'foreach' + ]); + private static readonly SCOPED_VAR_NAME_KEYWORDS = new Set([ + 'if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'true', 'false', 'null' + ]); + + /** + * Build the unified scoped variable map from AST declarations + regex body scan. + * Contains ALL variables: globals, class fields, inherited fields, func params, + * func locals (from AST), and regex-detected locals in function bodies. + * + * This replaces the per-checker duplicate data collection while preserving + * every data source exactly. + */ + private buildScopedVarMap( + ast: File, + text: string, + lines: string[] + ): Map { + type ScopedVarEntry = { type: string; startLine: number; endLine: number; isClassField: boolean }; + const scopedVars = new Map(); + + const add = (name: string, type: string, startLine: number, endLine: number, isClassField: boolean) => { + if (!scopedVars.has(name)) { + scopedVars.set(name, []); + } + scopedVars.get(name)!.push({ type, startLine, endLine, isClassField }); + }; + + // ── Phase A: AST-based collection ────────────────────────────────── + for (const node of ast.body) { + // Top-level var declarations (globals) + if (node.kind === 'VarDecl' && node.name && (node as VarDeclNode).type?.identifier) { + const startLine = node.start?.line ?? 0; + add(node.name, (node as VarDeclNode).type.identifier, startLine, Number.MAX_SAFE_INTEGER, false); + } + + if (node.kind === 'FunctionDecl') { + const funcStart = node.start?.line ?? 0; + const funcEnd = node.end?.line ?? Number.MAX_SAFE_INTEGER; + for (const param of (node as FunctionDeclNode).parameters || []) { + if (param.name && param.type?.identifier) { + add(param.name, param.type.identifier, funcStart, funcEnd, false); + } + } + // Collect locals from AST (parser-detected locals) + for (const local of (node as FunctionDeclNode).locals || []) { + if (local.name && local.type?.identifier) { + const localStart = local.start?.line ?? funcStart; + add(local.name, local.type.identifier, localStart, funcEnd, false); + } + } + } + + if (node.kind === 'ClassDecl') { + const cls = node as ClassDeclNode; + const clsStart = cls.start?.line ?? 0; + const clsEnd = cls.end?.line ?? Number.MAX_SAFE_INTEGER; + + for (const member of cls.members || []) { + // Class fields — marked as class fields for priority lookup + if (member.kind === 'VarDecl' && member.name && (member as VarDeclNode).type?.identifier) { + add(member.name, (member as VarDeclNode).type.identifier, clsStart, clsEnd, true); + } + // Methods — collect params and locals + if (member.kind === 'FunctionDecl') { + const func = member as FunctionDeclNode; + const fStart = func.start?.line ?? clsStart; + const fEnd = func.end?.line ?? clsEnd; + for (const p of func.parameters || []) { + if (p.name && p.type?.identifier) { + add(p.name, p.type.identifier, fStart, fEnd, false); + } + } + for (const l of func.locals || []) { + if (l.name && l.type?.identifier) { + const localStart = l.start?.line ?? fStart; + add(l.name, l.type.identifier, localStart, fEnd, false); + } + } + } + } + + // Inherited fields from parent classes — marked as class fields. + // This was only done in checkTypeMismatches; checkFunctionCallArgs + // compensated via resolveVariableType fallback. Including them in the + // shared map is safe because they have large ranges (class scope) and + // checkFunctionCallArgs' smallest-range heuristic will prefer local + // declarations over them anyway, matching prior behavior. + if (cls.base?.identifier) { + const parentClasses = this.getClassHierarchyOrdered(cls.base.identifier, new Set()); + for (const parentClass of parentClasses) { + for (const member of parentClass.members || []) { + if (member.kind === 'VarDecl' && member.name) { + const varMember = member as VarDeclNode; + if (varMember.type?.identifier) { + add(member.name, varMember.type.identifier, clsStart, clsEnd, true); + } + } + } + } + } + } + } + + // ── Phase B: Regex-based local variable scan ─────────────────────── + // The parser's locals detection covers many cases but can miss some. + // This regex scan supplements it for function bodies. This preserves + // every regex pattern and keyword filter from both original scanners. + { + let inBlockComment = false; + + const scanFunctionBody = (funcStart: number, funcEnd: number) => { + for (let lineIdx = funcStart; lineIdx <= funcEnd && lineIdx < lines.length; lineIdx++) { + let line = lines[lineIdx]; + + // Handle block comments + if (inBlockComment) { + if (line.includes('*/')) inBlockComment = false; + continue; + } + if (line.trimStart().startsWith('/*')) { + if (!line.includes('*/')) inBlockComment = true; + continue; + } + + // Strip comments and strings + const commentIdx = line.indexOf('//'); + if (commentIdx >= 0) line = line.substring(0, commentIdx); + line = line.replace(/"(?:[^"\\]|\\.)*"/g, '""'); + line = line.replace(/'(?:[^'\\]|\\.)*'/g, "''"); + line = line.trim(); + + // Skip empty lines + if (!line) continue; + + // Match: Type varName; or Type varName = ...; + const localDeclPattern = /\b([A-Z]\w+|int|float|bool|string|auto|vector|ref|autoptr)\s+(\w+)\s*(?:[=;,])/g; + let m; + while ((m = localDeclPattern.exec(line)) !== null) { + const typeName = m[1]; + const varName = m[2]; + + if (Analyzer.SCOPED_VAR_TYPE_KEYWORDS.has(typeName)) continue; + if (Analyzer.SCOPED_VAR_NAME_KEYWORDS.has(varName)) continue; + + add(varName, typeName, lineIdx, funcEnd, false); + } + + // Also match foreach variable declarations: + // foreach (Type varName : collection) + const foreachPattern = /\bforeach\s*\(\s*([A-Z]\w+|int|float|bool|string|auto)\s+(\w+)\s*:/g; + let fm; + while ((fm = foreachPattern.exec(line)) !== null) { + add(fm[2], fm[1], lineIdx, funcEnd, false); + } + } + }; + + for (const node of ast.body) { + if (node.kind === 'ClassDecl') { + const classNode = node as ClassDeclNode; + for (const member of classNode.members || []) { + if (member.kind === 'FunctionDecl') { + const func = member as FunctionDeclNode; + const funcStart = func.start?.line ?? 0; + const funcEnd = func.end?.line ?? 0; + if (funcEnd > funcStart) { + scanFunctionBody(funcStart, funcEnd); + } + } + } + } + // Also scan top-level functions + if (node.kind === 'FunctionDecl') { + const func = node as FunctionDeclNode; + const funcStart = func.start?.line ?? 0; + const funcEnd = func.end?.line ?? 0; + if (funcEnd > funcStart) { + scanFunctionBody(funcStart, funcEnd); + } + } + } + } + + return scopedVars; + } + runDiagnostics(doc: TextDocument): Diagnostic[] { const ast = this.ensure(doc); const diags: Diagnostic[] = []; @@ -2345,26 +2640,39 @@ export class Analyzer { diags.push(...ast.diagnostics); } + // ── Build shared diagnostic context once ─────────────────────────── + // These pre-computed values are passed to each checker so the + // expensive work (ifdef stripping, line splitting, line offset table, + // scoped variable map) is done only once per diagnostic run. + const text = this.stripSkippedIfdefRegions(doc.getText()); + const lines = text.split('\n'); + const lineOffsets = Analyzer.buildLineOffsets(text); + // Only run type/symbol checks if we have enough indexed files // This prevents false positives during initial workspace indexing if (this.docCache.size >= Analyzer.MIN_INDEX_SIZE_FOR_TYPE_CHECKS) { // Check for unknown types and symbols this.checkUnknownSymbols(ast, diags); + // Build scoped variable map once (used by both type mismatch and + // call arg checkers). Placed here because it requires the index + // to be populated (getClassHierarchyOrdered for inherited fields). + const scopedVars = this.buildScopedVarMap(ast, text, lines); + // Check for type mismatches in assignments - this.checkTypeMismatches(doc, diags); + this.checkTypeMismatches(doc, diags, text, lines, lineOffsets, ast, scopedVars); // Check function call arguments (param count and types) - this.checkFunctionCallArgs(doc, diags); + this.checkFunctionCallArgs(doc, diags, text, lines, lineOffsets, ast, scopedVars); } // Check for multi-line statements (not supported in Enforce Script) // This doesn't require indexing - it's purely syntactic - this.checkMultiLineStatements(doc, diags); + this.checkMultiLineStatements(doc, diags, text, lines); // Check for duplicate variable declarations in same scope // Enforce Script doesn't allow duplicate variable names even in sibling for loops - this.checkDuplicateVariables(doc, diags); + this.checkDuplicateVariables(doc, diags, text); return diags; } @@ -2378,8 +2686,7 @@ export class Analyzer { * for (int j = 0; j < 10; j++) { } * for (int j = 0; j < 10; j++) { } // ERROR: 'j' already declared */ - private checkDuplicateVariables(doc: TextDocument, diags: Diagnostic[]): void { - const text = this.stripSkippedIfdefRegions(doc.getText()); + private checkDuplicateVariables(doc: TextDocument, diags: Diagnostic[], text: string): void { // Track variables by scope - use a stack of scopes // Each scope has a map of variable names to their declaration info @@ -2862,37 +3169,25 @@ export class Analyzer { * - Declaration with init: Type varName = FunctionCall(); * - Re-assignment: varName = otherVar; */ - private checkTypeMismatches(doc: TextDocument, diags: Diagnostic[]): void { - const text = this.stripSkippedIfdefRegions(doc.getText()); - const ast = this.ensure(doc); - - // Scoped variable tracking - each variable knows its valid line range - interface ScopedVar { - type: string; - startLine: number; - endLine: number; // -1 means class field (valid everywhere in class) - isClassField: boolean; - } - - // Map of variable name -> array of scoped declarations - const scopedVars = new Map(); - - // Helper to add a scoped variable - const addScopedVar = (name: string, type: string, startLine: number, endLine: number, isClassField: boolean) => { - if (!scopedVars.has(name)) { - scopedVars.set(name, []); - } - scopedVars.get(name)!.push({ type, startLine, endLine, isClassField }); - }; - + private checkTypeMismatches( + doc: TextDocument, + diags: Diagnostic[], + text: string, + lines: string[], + lineOffsets: number[], + ast: File, + scopedVars: Map + ): void { // Helper to get the type of a variable at a specific line + // Uses isClassField priority: local vars preferred over class fields, + // then smaller ranges preferred within the same category. const getVarTypeAtLine = (name: string, line: number): string | undefined => { const vars = scopedVars.get(name); if (!vars) return undefined; // Find the most specific scope that contains this line // Priority: 1) local vars in range, 2) class fields in range - let bestMatch: ScopedVar | undefined; + let bestMatch: { type: string; startLine: number; endLine: number; isClassField: boolean } | undefined; for (const v of vars) { // ALL variables (including class fields) must have the line within their scope range @@ -2919,264 +3214,6 @@ export class Analyzer { return bestMatch?.type; }; - // Collect types from all declarations in this file with proper scoping - const collectVarTypes = (nodes: any[], classStartLine?: number, classEndLine?: number) => { - for (const node of nodes) { - // Top-level var declarations (globals) - if (node.kind === 'VarDecl' && node.name && node.type?.identifier) { - const startLine = node.start?.line ?? 0; - addScopedVar(node.name, node.type.identifier, startLine, Number.MAX_SAFE_INTEGER, false); - } - - if (node.kind === 'FunctionDecl') { - const funcStart = node.start?.line ?? 0; - const funcEnd = node.end?.line ?? Number.MAX_SAFE_INTEGER; - - // Collect parameters - scoped to this function - for (const param of node.parameters || []) { - if (param.name && param.type?.identifier) { - addScopedVar(param.name, param.type.identifier, funcStart, funcEnd, false); - } - } - // Collect locals - scoped to this function - for (const local of node.locals || []) { - if (local.name && local.type?.identifier) { - const localStart = local.start?.line ?? funcStart; - addScopedVar(local.name, local.type.identifier, localStart, funcEnd, false); - } - } - } - - if (node.kind === 'ClassDecl') { - const clsStart = node.start?.line ?? 0; - const clsEnd = node.end?.line ?? Number.MAX_SAFE_INTEGER; - - // Collect class fields - they're accessible anywhere in the class - for (const member of node.members || []) { - if (member.kind === 'VarDecl' && member.name && member.type?.identifier) { - addScopedVar(member.name, member.type.identifier, clsStart, clsEnd, true); - } - // Also process methods within the class - if (member.kind === 'FunctionDecl') { - const funcStart = member.start?.line ?? clsStart; - const funcEnd = member.end?.line ?? clsEnd; - - for (const param of member.parameters || []) { - if (param.name && param.type?.identifier) { - addScopedVar(param.name, param.type.identifier, funcStart, funcEnd, false); - } - } - for (const local of member.locals || []) { - if (local.name && local.type?.identifier) { - const localStart = local.start?.line ?? funcStart; - addScopedVar(local.name, local.type.identifier, localStart, funcEnd, false); - } - } - } - } - - // Also collect inherited fields from parent classes - // This ensures fields from base classes are accessible in derived classes - if (node.base?.identifier) { - const parentClasses = this.getClassHierarchyOrdered(node.base.identifier, new Set()); - for (const parentClass of parentClasses) { - for (const member of parentClass.members || []) { - if (member.kind === 'VarDecl' && member.name) { - const varMember = member as VarDeclNode; - if (varMember.type?.identifier) { - // Add inherited fields with the child class's scope - addScopedVar(member.name, varMember.type.identifier, clsStart, clsEnd, true); - } - } - } - } - } - } - } - }; - - collectVarTypes(ast.body); - - // The parser doesn't parse function bodies (locals is always []), - // so we need to scan for local variable declarations using regex. - // We scan the text line-by-line, tracking which function scope we're in. - { - const lines = text.split('\n'); - let inBlockComment = false; - - // For each function in the AST, scan its body for local declarations - for (const node of ast.body) { - if (node.kind === 'ClassDecl') { - const classNode = node as ClassDeclNode; - for (const member of classNode.members || []) { - if (member.kind === 'FunctionDecl') { - const func = member as FunctionDeclNode; - const funcStart = func.start?.line ?? 0; - const funcEnd = func.end?.line ?? 0; - if (funcEnd <= funcStart) continue; - - // Scan lines within this function for variable declarations - for (let lineIdx = funcStart; lineIdx <= funcEnd && lineIdx < lines.length; lineIdx++) { - let line = lines[lineIdx]; - - // Handle block comments - if (inBlockComment) { - if (line.includes('*/')) inBlockComment = false; - continue; - } - if (line.trimStart().startsWith('/*')) { - if (!line.includes('*/')) inBlockComment = true; - continue; - } - - // Strip comments and strings - const commentIdx = line.indexOf('//'); - if (commentIdx >= 0) line = line.substring(0, commentIdx); - line = line.replace(/"(?:[^"\\]|\\.)*"/g, '""'); - line = line.replace(/'(?:[^'\\]|\\.)*'/g, "''"); - line = line.trim(); - - // Skip empty, control flow, return, etc. - if (!line) continue; - - // Match: Type varName; or Type varName = ...; - // Must start with a type (capitalized or known primitive) - // Exclude: keywords, function calls, return statements - const localDeclPattern = /\b([A-Z]\w+|int|float|bool|string|auto|vector|ref|autoptr)\s+(\w+)\s*(?:[=;,])/g; - let m; - while ((m = localDeclPattern.exec(line)) !== null) { - const typeName = m[1]; - const varName = m[2]; - - // Skip if type is a keyword/modifier - if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', - 'else', 'case', 'override', 'static', 'private', 'protected', 'ref', 'autoptr', - 'const', 'proto', 'native', 'Print', 'foreach'].includes(typeName)) { - continue; - } - // Skip if varName is a keyword - if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'true', 'false', 'null'].includes(varName)) { - continue; - } - - addScopedVar(varName, typeName, lineIdx, funcEnd, false); - } - // Also match foreach variable declarations: - // foreach (Type varName : collection) - const foreachPattern = /\bforeach\s*\(\s*([A-Z]\w+|int|float|bool|string|auto)\s+(\w+)\s*:/g; - let fm; - while ((fm = foreachPattern.exec(line)) !== null) { - addScopedVar(fm[2], fm[1], lineIdx, funcEnd, false); - } - } - } - } - } - // Also scan top-level functions - if (node.kind === 'FunctionDecl') { - const func = node as FunctionDeclNode; - const funcStart = func.start?.line ?? 0; - const funcEnd = func.end?.line ?? 0; - if (funcEnd <= funcStart) continue; - - for (let lineIdx = funcStart; lineIdx <= funcEnd && lineIdx < lines.length; lineIdx++) { - let line = lines[lineIdx]; - - if (inBlockComment) { - if (line.includes('*/')) inBlockComment = false; - continue; - } - if (line.trimStart().startsWith('/*')) { - if (!line.includes('*/')) inBlockComment = true; - continue; - } - - const commentIdx = line.indexOf('//'); - if (commentIdx >= 0) line = line.substring(0, commentIdx); - line = line.replace(/"(?:[^"\\]|\\.)*"/g, '""'); - line = line.replace(/'(?:[^'\\]|\\.)*'/g, "''"); - line = line.trim(); - - if (!line) continue; - - const localDeclPattern = /\b([A-Z]\w+|int|float|bool|string|auto|vector|ref|autoptr)\s+(\w+)\s*(?:[=;,])/g; - let m; - while ((m = localDeclPattern.exec(line)) !== null) { - const typeName = m[1]; - const varName = m[2]; - - if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', - 'else', 'case', 'override', 'static', 'private', 'protected', 'ref', 'autoptr', - 'const', 'proto', 'native', 'Print', 'foreach'].includes(typeName)) { - continue; - } - if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'true', 'false', 'null'].includes(varName)) { - continue; - } - - addScopedVar(varName, typeName, lineIdx, funcEnd, false); - } - // Also match foreach variable declarations: - // foreach (Type varName : collection) - const foreachPatternTL = /\bforeach\s*\(\s*([A-Z]\w+|int|float|bool|string|auto)\s+(\w+)\s*:/g; - let fmTL; - while ((fmTL = foreachPatternTL.exec(line)) !== null) { - addScopedVar(fmTL[2], fmTL[1], lineIdx, funcEnd, false); - } - } - } - } - } - - // Helper to check if a position is inside a comment or string - const isInsideCommentOrString = (position: number): boolean => { - // Check single-line comments - let lineStart = text.lastIndexOf('\n', position) + 1; - let lineEnd = text.indexOf('\n', position); - if (lineEnd === -1) lineEnd = text.length; - const line = text.substring(lineStart, lineEnd); - const posInLine = position - lineStart; - - // Check if there's a // before this position on the same line - const commentIdx = line.indexOf('//'); - if (commentIdx >= 0 && commentIdx < posInLine) { - return true; - } - - // Check block comments - scan backwards for /* that isn't closed - let i = position - 1; - while (i >= 0) { - if (i > 0 && text[i-1] === '*' && text[i] === '/') { - // Found end of block comment, we're outside - break; - } - if (i > 0 && text[i-1] === '/' && text[i] === '*') { - // Found start of block comment, we're inside - return true; - } - i--; - } - - // Check strings - count unescaped quotes before position on same line - let inString = false; - let stringChar = ''; - for (let j = 0; j < posInLine; j++) { - const ch = line[j]; - if (!inString && (ch === '"' || ch === "'")) { - inString = true; - stringChar = ch; - } else if (inString && ch === stringChar) { - let backslashCount = 0; - let bi = j - 1; - while (bi >= 0 && line[bi] === '\\') { backslashCount++; bi--; } - if (backslashCount % 2 === 0) { - inString = false; - } - } - } - return inString; - }; - // For variable type scanning, we can still use stripped text since we just need types const textForScanning = text .replace(/\/\/.*$/gm, '') // Remove single-line comments @@ -3184,15 +3221,6 @@ export class Analyzer { .replace(/"(?:[^"\\]|\\.)*"/g, '""') // Replace "..." with "" .replace(/'(?:[^'\\]|\\.)*'/g, "''"); // Replace '...' with '' - // Helper to get line number from character position - const getLineFromPos = (pos: number): number => { - let line = 0; - for (let i = 0; i < pos && i < text.length; i++) { - if (text[i] === '\n') line++; - } - return line; - }; - // Pattern 1: Type varName = FunctionCall(); // e.g., int i = GetGame(); // Use [ \t]+ between type and varName to prevent matching across line breaks @@ -3201,7 +3229,7 @@ export class Analyzer { let match; while ((match = funcAssignPattern.exec(text)) !== null) { // Skip if inside comment or string - if (isInsideCommentOrString(match.index)) { + if (Analyzer.isInsideCommentOrStringAt(text, match.index)) { continue; } @@ -3308,7 +3336,7 @@ export class Analyzer { } // Try to resolve as a method of the containing class first (for unqualified calls like Find()) // This handles cases where the method is inherited or defined in the current class - const lineNum = getLineFromPos(match.index); + const lineNum = Analyzer.getLineFromOffset(lineOffsets, match.index); const containingClass = this.findContainingClass(ast, { line: lineNum, character: 0 }); if (containingClass) { returnType = this.resolveMethodReturnType(containingClass.name, funcName); @@ -3334,7 +3362,7 @@ export class Analyzer { while ((match = varDeclAssignPattern.exec(text)) !== null) { // Skip if inside comment or string - if (isInsideCommentOrString(match.index)) { + if (Analyzer.isInsideCommentOrStringAt(text, match.index)) { continue; } @@ -3361,7 +3389,7 @@ export class Analyzer { } // Look up the type of the source variable at this line - const lineNum = getLineFromPos(match.index); + const lineNum = Analyzer.getLineFromOffset(lineOffsets, match.index); const sourceType = getVarTypeAtLine(sourceVar, lineNum); if (sourceType) { @@ -3375,7 +3403,7 @@ export class Analyzer { while ((match = reassignPattern.exec(text)) !== null) { // Skip if inside comment or string - if (isInsideCommentOrString(match.index)) { + if (Analyzer.isInsideCommentOrStringAt(text, match.index)) { continue; } @@ -3401,7 +3429,7 @@ export class Analyzer { } // Look up types for both variables at this line - const lineNum = getLineFromPos(match.index); + const lineNum = Analyzer.getLineFromOffset(lineOffsets, match.index); const targetType = getVarTypeAtLine(targetVar, lineNum); const sourceType = getVarTypeAtLine(sourceVar, lineNum); @@ -3429,7 +3457,7 @@ export class Analyzer { while ((match = reassignFuncPattern.exec(text)) !== null) { // Skip if inside comment or string - if (isInsideCommentOrString(match.index)) { + if (Analyzer.isInsideCommentOrStringAt(text, match.index)) { continue; } @@ -3466,7 +3494,7 @@ export class Analyzer { } // Look up type of target variable at this line and resolve return type (single or chain) - const lineNum = getLineFromPos(match.index); + const lineNum = Analyzer.getLineFromOffset(lineOffsets, match.index); const targetType = getVarTypeAtLine(targetVar, lineNum); let returnType: string | null; let chainEnd = 0; @@ -3566,7 +3594,7 @@ export class Analyzer { const varChainDeclPattern = /\b(\w+)[ \t]+(\w+)\s*=\s*(\w+)\s*\./g; while ((match = varChainDeclPattern.exec(text)) !== null) { - if (isInsideCommentOrString(match.index)) continue; + if (Analyzer.isInsideCommentOrStringAt(text, match.index)) continue; if (match[0].includes('\n')) continue; const declaredType = match[1]; @@ -3576,7 +3604,7 @@ export class Analyzer { if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', 'typedef'].includes(declaredType)) continue; // Get the type of the source variable - const lineNum = getLineFromPos(match.index); + const lineNum = Analyzer.getLineFromOffset(lineOffsets, match.index); const sourceVarType = getVarTypeAtLine(sourceVar, lineNum); if (!sourceVarType) continue; @@ -3612,7 +3640,7 @@ export class Analyzer { const varChainReassignPattern = /(?:^|[;{})\n])(\s*)(\w+)\s*=\s*(\w+)\s*\./g; while ((match = varChainReassignPattern.exec(text)) !== null) { - if (isInsideCommentOrString(match.index)) continue; + if (Analyzer.isInsideCommentOrStringAt(text, match.index)) continue; const leadingWs = match[1]; const targetVar = match[2]; @@ -3623,7 +3651,7 @@ export class Analyzer { if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'else'].includes(targetVar)) continue; - const lineNum = getLineFromPos(match.index); + const lineNum = Analyzer.getLineFromOffset(lineOffsets, match.index); const targetType = getVarTypeAtLine(targetVar, lineNum); const sourceVarType = getVarTypeAtLine(sourceVar, lineNum); if (!targetType || !sourceVarType) continue; @@ -4030,141 +4058,19 @@ export class Analyzer { * Validate function/method call arguments in the document. * Checks argument count and types against all overloads. */ - private checkFunctionCallArgs(doc: TextDocument, diags: Diagnostic[]): void { - const text = this.stripSkippedIfdefRegions(doc.getText()); - const ast = this.ensure(doc); - - // Build scoped variable type lookup (reuse same approach as checkTypeMismatches) - const varTypes = new Map(); - - const addVar = (name: string, type: string, start: number, end: number) => { - if (!varTypes.has(name)) varTypes.set(name, []); - varTypes.get(name)!.push({ type, startLine: start, endLine: end }); - }; - - // Collect variable types from AST - for (const node of ast.body) { - if (node.kind === 'VarDecl' && node.name && (node as VarDeclNode).type?.identifier) { - addVar(node.name, (node as VarDeclNode).type.identifier, node.start?.line ?? 0, Number.MAX_SAFE_INTEGER); - } - if (node.kind === 'ClassDecl') { - const cls = node as ClassDeclNode; - const clsStart = cls.start?.line ?? 0; - const clsEnd = cls.end?.line ?? Number.MAX_SAFE_INTEGER; - for (const member of cls.members || []) { - if (member.kind === 'VarDecl' && member.name && (member as VarDeclNode).type?.identifier) { - addVar(member.name, (member as VarDeclNode).type.identifier, clsStart, clsEnd); - } - if (member.kind === 'FunctionDecl') { - const func = member as FunctionDeclNode; - const fStart = func.start?.line ?? clsStart; - const fEnd = func.end?.line ?? clsEnd; - for (const p of func.parameters || []) { - if (p.name && p.type?.identifier) addVar(p.name, p.type.identifier, fStart, fEnd); - } - for (const l of func.locals || []) { - if (l.name && l.type?.identifier) addVar(l.name, l.type.identifier, l.start?.line ?? fStart, fEnd); - } - } - } - } - if (node.kind === 'FunctionDecl') { - const func = node as FunctionDeclNode; - const fStart = func.start?.line ?? 0; - const fEnd = func.end?.line ?? Number.MAX_SAFE_INTEGER; - for (const p of func.parameters || []) { - if (p.name && p.type?.identifier) addVar(p.name, p.type.identifier, fStart, fEnd); - } - } - } - - // The parser doesn't parse function bodies (locals is always []), - // so we need to scan for local variable declarations using regex. - // This includes regular variable declarations and foreach loop variables. - { - const lines = text.split('\n'); - let inBlockComment = false; - - const scanFunctionBody = (funcStart: number, funcEnd: number) => { - for (let lineIdx = funcStart; lineIdx <= funcEnd && lineIdx < lines.length; lineIdx++) { - let line = lines[lineIdx]; - - // Handle block comments - if (inBlockComment) { - if (line.includes('*/')) inBlockComment = false; - continue; - } - if (line.trimStart().startsWith('/*')) { - if (!line.includes('*/')) inBlockComment = true; - continue; - } - - // Strip comments and strings - const commentIdx = line.indexOf('//'); - if (commentIdx >= 0) line = line.substring(0, commentIdx); - line = line.replace(/"(?:[^"\\]|\\.)*"/g, '""'); - line = line.replace(/'(?:[^'\\]|\\.)*'/g, "''"); - line = line.trim(); - - if (!line) continue; - - // Match: Type varName; or Type varName = ...; - const localDeclPattern = /\b([A-Z]\w+|int|float|bool|string|auto|vector|ref|autoptr)\s+(\w+)\s*(?:[=;,])/g; - let m; - while ((m = localDeclPattern.exec(line)) !== null) { - const typeName = m[1]; - const varName = m[2]; - - // Skip if type is a keyword/modifier - if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', - 'else', 'case', 'override', 'static', 'private', 'protected', 'ref', 'autoptr', - 'const', 'proto', 'native', 'Print', 'foreach'].includes(typeName)) { - continue; - } - // Skip if varName is a keyword - if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'true', 'false', 'null'].includes(varName)) { - continue; - } - - addVar(varName, typeName, lineIdx, funcEnd); - } - - // Match foreach variable declarations: foreach (Type varName : collection) - const foreachPattern = /\bforeach\s*\(\s*([A-Z]\w+|int|float|bool|string|auto)\s+(\w+)\s*:/g; - let fm; - while ((fm = foreachPattern.exec(line)) !== null) { - addVar(fm[2], fm[1], lineIdx, funcEnd); - } - } - }; - - for (const node of ast.body) { - if (node.kind === 'ClassDecl') { - const classNode = node as ClassDeclNode; - for (const member of classNode.members || []) { - if (member.kind === 'FunctionDecl') { - const func = member as FunctionDeclNode; - const funcStart = func.start?.line ?? 0; - const funcEnd = func.end?.line ?? 0; - if (funcEnd > funcStart) { - scanFunctionBody(funcStart, funcEnd); - } - } - } - } - if (node.kind === 'FunctionDecl') { - const func = node as FunctionDeclNode; - const funcStart = func.start?.line ?? 0; - const funcEnd = func.end?.line ?? 0; - if (funcEnd > funcStart) { - scanFunctionBody(funcStart, funcEnd); - } - } - } - } - + private checkFunctionCallArgs( + doc: TextDocument, + diags: Diagnostic[], + text: string, + lines: string[], + lineOffsets: number[], + ast: File, + scopedVars: Map + ): void { + // Variable type lookup — uses simple smallest-range heuristic (no isClassField + // distinction), with resolveVariableType fallback for cross-file resolution. const getVarTypeAtLine = (name: string, line: number): string | undefined => { - const entries = varTypes.get(name); + const entries = scopedVars.get(name); if (entries) { let best: { type: string; startLine: number; endLine: number } | undefined; for (const e of entries) { @@ -4181,47 +4087,6 @@ export class Analyzer { return this.resolveVariableType(doc, pos, name) ?? undefined; }; - // Helper: check if position is in comment or string - const isInsideCommentOrString = (position: number): boolean => { - let lineStart = text.lastIndexOf('\n', position) + 1; - let lineEnd = text.indexOf('\n', position); - if (lineEnd === -1) lineEnd = text.length; - const line = text.substring(lineStart, lineEnd); - const posInLine = position - lineStart; - - const commentIdx = line.indexOf('//'); - if (commentIdx >= 0 && commentIdx < posInLine) return true; - - let i = position - 1; - while (i >= 0) { - if (i > 0 && text[i-1] === '*' && text[i] === '/') break; - if (i > 0 && text[i-1] === '/' && text[i] === '*') return true; - i--; - } - - let inStr = false; - let sCh = ''; - for (let j = 0; j < posInLine; j++) { - const ch = line[j]; - if (!inStr && (ch === '"' || ch === "'")) { inStr = true; sCh = ch; } - else if (inStr && ch === sCh) { - let backslashCount = 0; - let bi = j - 1; - while (bi >= 0 && line[bi] === '\\') { backslashCount++; bi--; } - if (backslashCount % 2 === 0) { inStr = false; } - } - } - return inStr; - }; - - const getLineFromPos = (pos: number): number => { - let line = 0; - for (let i = 0; i < pos && i < text.length; i++) { - if (text[i] === '\n') line++; - } - return line; - }; - // Keywords and built-ins that look like function calls but aren't const skipNames = new Set([ 'if', 'while', 'for', 'foreach', 'switch', 'return', 'new', 'delete', @@ -4237,7 +4102,7 @@ export class Analyzer { let match: RegExpExecArray | null; while ((match = callPattern.exec(text)) !== null) { - if (isInsideCommentOrString(match.index)) continue; + if (Analyzer.isInsideCommentOrStringAt(text, match.index)) continue; const funcName = match[1]; if (skipNames.has(funcName)) continue; @@ -4308,7 +4173,7 @@ export class Analyzer { // Determine if this is a method call or global call const textBeforeFunc = text.substring(Math.max(0, match.index - 200), match.index); - const lineNum = getLineFromPos(match.index); + const lineNum = Analyzer.getLineFromOffset(lineOffsets, match.index); let overloads: FunctionDeclNode[] = []; let chainAttempted = false; @@ -4609,9 +4474,7 @@ export class Analyzer { * Print("text" + * "more text"); // ERROR! */ - private checkMultiLineStatements(doc: TextDocument, diags: Diagnostic[]): void { - const text = this.stripSkippedIfdefRegions(doc.getText()); - const lines = text.split('\n'); + private checkMultiLineStatements(doc: TextDocument, diags: Diagnostic[], text: string, lines: string[]): void { // Track if we're inside a block comment let inBlockComment = false; From 6d66991e19fd0b09e4961d8589497a74a50331c7 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sat, 7 Feb 2026 22:33:33 -0500 Subject: [PATCH 14/46] Improve parser local var detection; remove regex scan Add support for detecting more local variable patterns in the token parser and remove the redundant regex-based scanner. Changes include: - parser.ts: treat 'auto' as a primitive type and recognize ':' as a local-declaration separator (covers foreach variables). Comments clarify why ':' is safe to treat as a trigger and handling of generic closing tokens ('>' / '>>'). - graph.ts: remove the regex Phase B local-scan and related keyword sets; update buildScopedVarMap to rely solely on the parser-detected locals (signature simplified to accept only the AST). Update call sites accordingly and document that parser detection now covers auto-typed, foreach, and generic declarations. - test/parser.test.ts: add tests to cover foreach locals (single and two-variable forms), auto-typed locals, and ensure no false positives from case labels. These changes centralize local variable detection in the parser, eliminate duplicate scanning logic, and add tests to prevent regressions. --- server/src/analysis/ast/parser.ts | 9 +- server/src/analysis/project/graph.ts | 119 +++++---------------------- test/parser.test.ts | 79 ++++++++++++++++++ 3 files changed, 105 insertions(+), 102 deletions(-) diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index 0178297..7484f87 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -243,7 +243,7 @@ export function parse( /* helper: check if a keyword is a primitive type */ const isPrimitiveType = (value: string): boolean => { - return ['void', 'int', 'float', 'bool', 'string', 'vector', 'typename'].includes(value); + return ['void', 'int', 'float', 'bool', 'string', 'vector', 'typename', 'auto'].includes(value); }; /* read & return one identifier or keyword token */ @@ -547,12 +547,15 @@ export function parse( // Detect local variable declarations: // TypeName varName ; or TypeName varName = or TypeName varName , - // prevPrev = type token, prev = name token, t = ; or = or , + // TypeName varName : (foreach variable: foreach (Type var : collection)) + // prevPrev = type token, prev = name token, t = ; or = or , or : + // The ':' trigger is safe because other uses of ':' inside function bodies + // (case labels, default:, ternary) don't have a valid type+name pair preceding them. // For generic types like array, prevPrev is '>' or '>>' — we need to // walk backwards past balanced angle brackets to find the actual type name. // The '>>' token represents two closing brackets (nested generics like // map>) and must be counted as 2. - if (prev && prevPrev && (t.value === ';' || t.value === '=' || t.value === ',')) { + if (prev && prevPrev && (t.value === ';' || t.value === '=' || t.value === ',' || t.value === ':')) { let typeTok = prevPrev; if (prevPrev.value === '>' || prevPrev.value === '>>') { // Walk backwards through tokens to find matching '<' and the type before it diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 21e3b59..15ac4f9 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -2346,8 +2346,10 @@ export class Analyzer { // - lineOffsets: cumulative character offsets per line for O(1) lookup // - ast: parsed AST for this document // - doc: the TextDocument (needed for positionAt) - // - scopedVars: unified scoped variable map (used by type mismatch and - // call arg checkers with different lookup semantics) + // - scopedVars: unified scoped variable map built entirely from the + // parser's AST (locals are now detected by the parser, + // not regex). Used by type mismatch and call arg checkers + // with different lookup semantics. // ======================================================================== /** @@ -2439,28 +2441,18 @@ export class Analyzer { return inString; } - /** Scoped variable entry — tracks type and scope range with class-field distinction */ - private static readonly SCOPED_VAR_TYPE_KEYWORDS = new Set([ - 'if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', - 'else', 'case', 'override', 'static', 'private', 'protected', 'ref', 'autoptr', - 'const', 'proto', 'native', 'Print', 'foreach' - ]); - private static readonly SCOPED_VAR_NAME_KEYWORDS = new Set([ - 'if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'true', 'false', 'null' - ]); - /** - * Build the unified scoped variable map from AST declarations + regex body scan. + * Build the unified scoped variable map from AST declarations. * Contains ALL variables: globals, class fields, inherited fields, func params, - * func locals (from AST), and regex-detected locals in function bodies. + * and func locals (detected by the parser — including foreach variables and + * auto-typed variables). * - * This replaces the per-checker duplicate data collection while preserving - * every data source exactly. + * The parser's token-based local detection (prevPrev/prev/t pattern matching) + * handles all cases previously covered by the regex scanners, and its results + * are cached per file version, so this work is only done once. */ private buildScopedVarMap( - ast: File, - text: string, - lines: string[] + ast: File ): Map { type ScopedVarEntry = { type: string; startLine: number; endLine: number; isClassField: boolean }; const scopedVars = new Map(); @@ -2548,85 +2540,14 @@ export class Analyzer { } } - // ── Phase B: Regex-based local variable scan ─────────────────────── - // The parser's locals detection covers many cases but can miss some. - // This regex scan supplements it for function bodies. This preserves - // every regex pattern and keyword filter from both original scanners. - { - let inBlockComment = false; - - const scanFunctionBody = (funcStart: number, funcEnd: number) => { - for (let lineIdx = funcStart; lineIdx <= funcEnd && lineIdx < lines.length; lineIdx++) { - let line = lines[lineIdx]; - - // Handle block comments - if (inBlockComment) { - if (line.includes('*/')) inBlockComment = false; - continue; - } - if (line.trimStart().startsWith('/*')) { - if (!line.includes('*/')) inBlockComment = true; - continue; - } - - // Strip comments and strings - const commentIdx = line.indexOf('//'); - if (commentIdx >= 0) line = line.substring(0, commentIdx); - line = line.replace(/"(?:[^"\\]|\\.)*"/g, '""'); - line = line.replace(/'(?:[^'\\]|\\.)*'/g, "''"); - line = line.trim(); - - // Skip empty lines - if (!line) continue; - - // Match: Type varName; or Type varName = ...; - const localDeclPattern = /\b([A-Z]\w+|int|float|bool|string|auto|vector|ref|autoptr)\s+(\w+)\s*(?:[=;,])/g; - let m; - while ((m = localDeclPattern.exec(line)) !== null) { - const typeName = m[1]; - const varName = m[2]; - - if (Analyzer.SCOPED_VAR_TYPE_KEYWORDS.has(typeName)) continue; - if (Analyzer.SCOPED_VAR_NAME_KEYWORDS.has(varName)) continue; - - add(varName, typeName, lineIdx, funcEnd, false); - } - - // Also match foreach variable declarations: - // foreach (Type varName : collection) - const foreachPattern = /\bforeach\s*\(\s*([A-Z]\w+|int|float|bool|string|auto)\s+(\w+)\s*:/g; - let fm; - while ((fm = foreachPattern.exec(line)) !== null) { - add(fm[2], fm[1], lineIdx, funcEnd, false); - } - } - }; - - for (const node of ast.body) { - if (node.kind === 'ClassDecl') { - const classNode = node as ClassDeclNode; - for (const member of classNode.members || []) { - if (member.kind === 'FunctionDecl') { - const func = member as FunctionDeclNode; - const funcStart = func.start?.line ?? 0; - const funcEnd = func.end?.line ?? 0; - if (funcEnd > funcStart) { - scanFunctionBody(funcStart, funcEnd); - } - } - } - } - // Also scan top-level functions - if (node.kind === 'FunctionDecl') { - const func = node as FunctionDeclNode; - const funcStart = func.start?.line ?? 0; - const funcEnd = func.end?.line ?? 0; - if (funcEnd > funcStart) { - scanFunctionBody(funcStart, funcEnd); - } - } - } - } + // NOTE: The parser now handles ALL local variable detection, including: + // - Standard declarations: Type varName ; / = / , + // - foreach variables: foreach (Type varName : collection) + // - auto-typed variables: auto varName = expr; + // - Generic-typed variables: array varName; + // The regex-based Phase B scanner was removed as the parser's + // locals detection (via prevPrev/prev/current token tracking) now + // covers every case the regex did, and its results are cached. return scopedVars; } @@ -2657,7 +2578,7 @@ export class Analyzer { // Build scoped variable map once (used by both type mismatch and // call arg checkers). Placed here because it requires the index // to be populated (getClassHierarchyOrdered for inherited fields). - const scopedVars = this.buildScopedVarMap(ast, text, lines); + const scopedVars = this.buildScopedVarMap(ast); // Check for type mismatches in assignments this.checkTypeMismatches(doc, diags, text, lines, lineOffsets, ast, scopedVars); diff --git a/test/parser.test.ts b/test/parser.test.ts index b4c5d37..647b3ed 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -12,6 +12,85 @@ test('parses class declaration', () => { expect(ast.body[0]).toHaveProperty('kind', 'ClassDecl'); }); +test('detects foreach local variables', () => { + const code = `class Foo { + void DoStuff() { + array players; + foreach (PlayerBase player : players) { + player.DoSomething(); + } + } +};`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const cls = ast.body[0] as any; + const func = cls.members[0] as any; + // Should detect: players (from 'array players;') and player (from foreach) + const localNames = func.locals.map((l: any) => l.name); + expect(localNames).toContain('players'); + expect(localNames).toContain('player'); + const playerLocal = func.locals.find((l: any) => l.name === 'player'); + expect(playerLocal.type.identifier).toBe('PlayerBase'); +}); + +test('detects auto-typed local variables', () => { + const code = `class Foo { + void Bar() { + auto x = GetSomething(); + int y = 5; + } +};`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const cls = ast.body[0] as any; + const func = cls.members[0] as any; + const localNames = func.locals.map((l: any) => l.name); + expect(localNames).toContain('x'); + expect(localNames).toContain('y'); + const autoLocal = func.locals.find((l: any) => l.name === 'x'); + expect(autoLocal.type.identifier).toBe('auto'); +}); + +test('detects two-variable foreach locals', () => { + const code = `class Foo { + void Bar() { + map m; + foreach (string key, int val : m) { + Print(key); + } + } +};`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const cls = ast.body[0] as any; + const func = cls.members[0] as any; + const localNames = func.locals.map((l: any) => l.name); + expect(localNames).toContain('m'); + expect(localNames).toContain('key'); + expect(localNames).toContain('val'); +}); + +test('does not false-positive on case labels', () => { + const code = `class Foo { + void Bar() { + switch (x) { + case 0: + break; + case MyEnum.VALUE: + break; + default: + break; + } + } +};`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const cls = ast.body[0] as any; + const func = cls.members[0] as any; + // Should NOT detect any local variables (no valid declarations) + expect(func.locals.length).toBe(0); +}); + test('playground', () => { const target_file = path.join("P:\\enscript\\test", "test_enscript.c"); const text = fs.readFileSync(target_file, "utf8"); From 2a2ae2757d584520daf5b5cf1de6ad818ad1a2d8 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sat, 7 Feb 2026 22:59:40 -0500 Subject: [PATCH 15/46] Track var scopeEnd and preprocessor regions Parser: record scopeEnd on function-body local VarDecls by tracking nested brace scopes so locals get an end Position; add skippedRegions to File to record character ranges blanked by #ifdef processing. Analyzer: add computeSkippedRegions and applySkippedRegions to compute and apply #ifdef blanking (store regions on the AST at parse time and use cached regions during diagnostics). Refactor duplicate-variable checking to use the AST (locals with scopeEnd, params, class fields, inherited fields and globals) instead of text-based scanning; also check missing override on overridden methods. Tests: add parser tests for scopeEnd behavior (nested blocks, loop variables, sibling scopes). These changes improve accuracy of duplicate-variable detection and preserve line/column mappings when stripping preprocessor regions. --- server/src/analysis/ast/parser.ts | 32 +- server/src/analysis/project/graph.ts | 654 ++++++++++++--------------- test/parser.test.ts | 85 ++++ 3 files changed, 408 insertions(+), 363 deletions(-) diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index 7484f87..43db7f2 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -151,6 +151,7 @@ export interface VarDeclNode extends SymbolNodeBase { kind: 'VarDecl'; type: TypeNode; hasDefault?: boolean; // true if parameter has a default value (e.g., int x = 5) + scopeEnd?: Position; // End of the brace-scope where this local is visible (set for function-body locals) } export interface FunctionDeclNode extends SymbolNodeBase { @@ -165,6 +166,7 @@ export interface File { version: number diagnostics: Diagnostic[] // Parser-generated diagnostics (e.g., ternary operator warnings) module?: number // Script module level (1=Core, 2=GameLib, 3=Game, 4=World, 5=Mission) + skippedRegions?: { start: number, end: number }[] // Character ranges blanked by #ifdef processing } // parse entry point @@ -509,6 +511,11 @@ export function parse( if (peek().value === '{') { next(); let depth = 1; + // Scope tracking: each entry holds locals declared in that brace scope. + // When '}' closes a scope, scopeEnd is set on all locals in it. + // This enables AST-based duplicate variable checking with proper + // scope overlap detection (sibling scopes don't conflict). + const bodyScopes: VarDeclNode[][] = [[]]; // [0] = function body scope // Track previous tokens to detect local variable declarations // Pattern: [modifiers...] TypeName VarName (= | ; | ,) let prevPrev: Token | null = null; @@ -518,8 +525,20 @@ export function parse( while (depth > 0 && !eof()) { const t = next(); const tIdx = pos - 1; // index of the token that next() just returned - if (t.value === '{') depth++; - else if (t.value === '}') depth--; + if (t.value === '{') { + depth++; + bodyScopes.push([]); + } else if (t.value === '}') { + depth--; + // Pop scope and set scopeEnd for all locals declared in it + if (bodyScopes.length > 0) { + const closingLocals = bodyScopes.pop()!; + const endPos = doc.positionAt(t.start); + for (const local of closingLocals) { + local.scopeEnd = endPos; + } + } + } // Detect ternary operator (condition ? true : false) // This is invalid in Enforce Script else if (t.value === '?' && depth > 0) { @@ -585,7 +604,7 @@ export function parse( || (typeTok.kind === TokenKind.Keyword && isPrimitiveType(typeTok.value)); const isNameTok = prev.kind === TokenKind.Identifier; if (isTypeTok && isNameTok) { - locals.push({ + const local: VarDeclNode = { kind: 'VarDecl', uri: doc.uri, name: prev.value, @@ -604,7 +623,12 @@ export function parse( modifiers: [], start: doc.positionAt(typeTok.start), end: doc.positionAt(prev.end), - }); + }; + locals.push(local); + // Track in scope stack so scopeEnd is set when '}' closes this scope + if (bodyScopes.length > 0) { + bodyScopes[bodyScopes.length - 1].push(local); + } } } diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 15ac4f9..0f33d5a 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -437,86 +437,93 @@ export class Analyzer { } /** - * Strip out skipped #ifdef/#ifndef regions from text, preserving line structure. - * Replaces skipped content with spaces so line/column positions remain valid. + * Compute character ranges that should be blanked for #ifdef/#ifndef regions. + * Returns an array of { start, end } pairs (character offsets) covering: + * - All directive lines (#ifdef, #ifndef, #else, #endif) — always blanked + * - Content in skipped branches — conditionally blanked + * * Uses the same logic as the lexer: skip first branch by default, process #else; - * if a define is in preprocessorDefines, process the first branch and skip #else. + * if a define is in the defines set, process the first branch and skip #else. + * + * The result is stored on the cached AST so it doesn't need to be recomputed + * on every diagnostic run. */ - private stripSkippedIfdefRegions(text: string): string { - const result = text.split(''); + static computeSkippedRegions(text: string, defines: Set): { start: number, end: number }[] { + const regions: { start: number, end: number }[] = []; const lines = text.split('\n'); - - // Blank out a range of characters (preserve newlines for line numbers) - const blankOut = (start: number, end: number) => { - for (let i = start; i < end && i < result.length; i++) { - if (result[i] !== '\n' && result[i] !== '\r') { - result[i] = ' '; - } - } - }; - - // Find offset of line start - let i = 0; - - // Stack of ifdef states for nesting - // Each entry: { skippingFirstBranch, inElseBranch, depth relative to this ifdef } + interface IfdefState { processFirstBranch: boolean; inElseBranch: boolean; - directiveStart: number; } const stack: IfdefState[] = []; - - // Are we currently in a region that should be blanked? + const isSkipping = (): boolean => { for (const s of stack) { - if (!s.processFirstBranch && !s.inElseBranch) return true; // in first branch, should skip - if (s.processFirstBranch && s.inElseBranch) return true; // in else branch, should skip + if (!s.processFirstBranch && !s.inElseBranch) return true; + if (s.processFirstBranch && s.inElseBranch) return true; } return false; }; - + + let offset = 0; for (let lineNum = 0; lineNum < lines.length; lineNum++) { const line = lines[lineNum]; const trimmed = line.trim(); - + const lineEnd = offset + line.length; + const ifdefMatch = trimmed.match(/^#\s*(ifdef|ifndef)\s+(\w+)/); if (ifdefMatch) { const isIfdef = ifdefMatch[1] === 'ifdef'; const symbol = ifdefMatch[2]; - const isDefined = this.preprocessorDefines.has(symbol); + const isDefined = defines.has(symbol); const processFirst = isIfdef ? isDefined : !isDefined; - - stack.push({ processFirstBranch: processFirst, inElseBranch: false, directiveStart: i }); - - // Blank the directive line itself - blankOut(i, i + line.length); - i += line.length + 1; // +1 for \n + + stack.push({ processFirstBranch: processFirst, inElseBranch: false }); + regions.push({ start: offset, end: lineEnd }); + offset = lineEnd + 1; continue; } - + if (trimmed.match(/^#\s*else\b/) && stack.length > 0) { stack[stack.length - 1].inElseBranch = true; - blankOut(i, i + line.length); - i += line.length + 1; + regions.push({ start: offset, end: lineEnd }); + offset = lineEnd + 1; continue; } - + if (trimmed.match(/^#\s*endif\b/) && stack.length > 0) { stack.pop(); - blankOut(i, i + line.length); - i += line.length + 1; + regions.push({ start: offset, end: lineEnd }); + offset = lineEnd + 1; continue; } - - // If we're in a skipped region, blank this line + if (isSkipping()) { - blankOut(i, i + line.length); + regions.push({ start: offset, end: lineEnd }); + } + + offset = lineEnd + 1; + } + + return regions; + } + + /** + * Apply pre-computed skipped regions to blank out text. + * Replaces skipped content with spaces, preserving newlines so + * line/column positions remain valid. + */ + static applySkippedRegions(text: string, regions?: { start: number, end: number }[]): string { + if (!regions || regions.length === 0) return text; + const result = text.split(''); + for (const region of regions) { + for (let i = region.start; i < region.end && i < result.length; i++) { + if (result[i] !== '\n' && result[i] !== '\r') { + result[i] = ' '; + } } - - i += line.length + 1; // +1 for \n } - return result.join(''); } @@ -578,6 +585,9 @@ export class Analyzer { const ast = parse(doc, undefined, this.preprocessorDefines); // pass full TextDocument + defines const normalizedUri = normalizeUri(doc.uri); ast.module = getModuleLevel(doc.uri); + // Pre-compute skipped #ifdef regions so runDiagnostics can + // apply them from cache instead of re-scanning directives. + ast.skippedRegions = Analyzer.computeSkippedRegions(doc.getText(), this.preprocessorDefines); this.docCache.set(normalizedUri, ast); // Update indexes for fast lookups this.updateGlobalSymbolIndex(normalizedUri, ast); @@ -2565,7 +2575,9 @@ export class Analyzer { // These pre-computed values are passed to each checker so the // expensive work (ifdef stripping, line splitting, line offset table, // scoped variable map) is done only once per diagnostic run. - const text = this.stripSkippedIfdefRegions(doc.getText()); + // Skipped regions are cached on the AST so only the cheap blanking + // step runs here; the directive-parsing logic ran once at parse time. + const text = Analyzer.applySkippedRegions(doc.getText(), ast.skippedRegions); const lines = text.split('\n'); const lineOffsets = Analyzer.buildLineOffsets(text); @@ -2592,342 +2604,266 @@ export class Analyzer { this.checkMultiLineStatements(doc, diags, text, lines); // Check for duplicate variable declarations in same scope - // Enforce Script doesn't allow duplicate variable names even in sibling for loops - this.checkDuplicateVariables(doc, diags, text); + // Now AST-based: uses parser's locals with scopeEnd ranges instead + // of re-parsing text line-by-line. Also checks missing 'override' keyword. + this.checkDuplicateVariables(ast, diags); return diags; } /** - * Check for duplicate variable declarations within the same scope. - * In Enforce Script, you cannot have two for loops with the same loop variable - * at the same scope level, even though they're "separate" blocks. - * - * Example that causes error: - * for (int j = 0; j < 10; j++) { } - * for (int j = 0; j < 10; j++) { } // ERROR: 'j' already declared + * Check for duplicate variable declarations within the same scope (AST-based). + * + * Walks the parsed AST instead of re-scanning the text line-by-line. + * Each local in a function body carries a `scopeEnd` set by the parser, + * so we can determine whether two locals' scopes overlap. + * + * Also checks: + * – class fields vs inherited fields + * – function locals/params vs class fields, inherited fields, globals + * – missing `override` keyword on methods that override a parent method + * + * In Enforce Script: + * - Variables in outer scopes are visible in inner scopes (no shadowing) + * - Loop control variables live in the PARENT scope, not the loop block + * (the parser naturally captures this because the declaration token + * appears before the opening '{' of the loop body) + * - Two variables with the same name in sibling, non-overlapping scopes + * do NOT conflict (handled via scopeEnd range checking) */ - private checkDuplicateVariables(doc: TextDocument, diags: Diagnostic[], text: string): void { - - // Track variables by scope - use a stack of scopes - // Each scope has a map of variable names to their declaration info - type VarInfo = { line: number; character: number }; - let scopeStack: Map[] = [new Map()]; // Start with global/class scope - - // Pattern to find variable declarations - // Matches: Type varName in various contexts (including for loop init) - const varDeclPattern = /\b(int|float|bool|string|auto|vector|Man|PlayerBase|\w+)\s+(\w+)\s*(?:=|;|,|\)|<)/g; - - // Pattern to detect function declarations - // Must have: optional modifiers, return type (including generics), function name, parentheses for params - // Excludes: array access like m_Foo[0] and assignments - // Matches lines ending with { (normal), {} (empty body), {}; (empty body + semicolon), ; (proto/native), or ) (brace on next line) - const funcDeclPattern = /^\s*(?:static\s+|private\s+|protected\s+|override\s+|proto\s+|native\s+|volatile\s+|event\s+)*(?:void|int|float|bool|string|auto|ref\s+\w+(?:\s*<[\w,\s<>]+>)?|\w+(?:\s*<[\w,\s<>]+>)?)\s+(\w+)\s*\([^)]*\)\s*(?:\{[^}]*\}?;?|;)?\s*$/; + private checkDuplicateVariables(ast: File, diags: Diagnostic[]): void { + + // ── Position helpers ─────────────────────────────────────────────── + const posBefore = (a: Position, b: Position): boolean => + a.line < b.line || (a.line === b.line && a.character < b.character); + + // Do two locals' visibility ranges overlap? + // Each local is visible from its declaration (`start`) to its `scopeEnd`. + const localScopesOverlap = (a: VarDeclNode, b: VarDeclNode): boolean => { + if (!a.scopeEnd || !b.scopeEnd) return true; // no scopeEnd → function-wide + // a visible at b's start: a.start <= b.start < a.scopeEnd + const aVisAtB = !posBefore(b.start, a.start) && posBefore(b.start, a.scopeEnd); + // b visible at a's start: b.start <= a.start < b.scopeEnd + const bVisAtA = !posBefore(a.start, b.start) && posBefore(a.start, b.scopeEnd); + return aVisAtB || bVisAtA; + }; - // Pattern to detect proto/native function declarations (no body, just ;) - // Params in these don't create real variables — skip them for duplicate checking - const protoFuncPattern = /^\s*(?:static\s+|private\s+|protected\s+|override\s+|proto\s+|native\s+|volatile\s+|event\s+)*(?:proto|native)\s+/; - - // Pattern to detect for/foreach/while loops (their vars go to parent scope in Enforce) - const loopPattern = /\b(for|foreach|while)\s*\(/; - - // Pattern to detect class declarations (handles template params like ) - const classDeclPattern = /\b(?:modded\s+)?class\s+(\w+)(?:<[^>]*>)?(?:\s*(?::\s*|\s+extends\s+)(\w+))?/; - - // Track when we enter/exit functions - let inFunction = false; - let functionBraceDepth = 0; - let braceDepth = 0; - - // Track multi-line function declarations (parameters spanning multiple lines) - // When a function declaration has ( but no ) on the same line, we set this flag - // and skip variable extraction until we find the closing ) - let inMultiLineFuncDecl = false; - - // Track class fields - these need to be visible inside methods - let classFieldScope: Map = new Map(); - let inClass = false; - let classBraceDepth = 0; - let currentClassName = ''; - // Track parent method names + param counts for missing override warnings - // Maps method name -> array of param counts from parent classes - let parentMethodSignatures: Map = new Map(); - - // Track block comments - let inBlockComment = false; - - // Process line by line to track scope - const lines = text.split('\n'); - - for (let lineNum = 0; lineNum < lines.length; lineNum++) { - const line = lines[lineNum]; - const trimmedLine = line.trim(); - - // Handle block comments /* ... */ - if (inBlockComment) { - if (line.includes('*/')) { - inBlockComment = false; - } - continue; // Skip lines inside block comments - } - - // Check for block comment start - if (trimmedLine.startsWith('/*') || trimmedLine.startsWith('/**')) { - if (line.includes('*/')) { - // Single-line block comment like /* foo */ — but only skip if the - // ENTIRE line is a comment. If there's code AFTER the closing */, - // fall through to normal processing where inline /*...*/ stripping - // handles it. Example: /*sealed*/ bool IsFlipped() - const afterComment = line.substring(line.indexOf('*/') + 2).trim(); - if (!afterComment) { - continue; - } - // Code follows the block comment — fall through to process it + // Report a duplicate-variable diagnostic + const reportDup = (name: string, decl: SymbolNodeBase, existingLine: number) => { + diags.push({ + message: `Variable '${name}' is already declared at line ${existingLine + 1}. Enforce Script does not allow duplicate variable names in the same scope.`, + range: { start: decl.nameStart, end: decl.nameEnd }, + severity: DiagnosticSeverity.Error + }); + }; + + // ── 1. Collect global variables ──────────────────────────────────── + const globalNames = new Map(); + for (const node of ast.body) { + if (node.kind === 'VarDecl' && node.name) { + const existing = globalNames.get(node.name); + if (existing) { + reportDup(node.name, node, existing.start.line); } else { - // Multi-line block comment starts here - inBlockComment = true; - continue; + globalNames.set(node.name, node); } } - - // Skip lines that are just block comment content - if (trimmedLine.startsWith('*') && !trimmedLine.startsWith('*/')) { - continue; + } + + // ── 2. Process classes ───────────────────────────────────────────── + for (const node of ast.body) { + if (node.kind === 'ClassDecl') { + this.checkDuplicatesInClass(node as ClassDeclNode, globalNames, diags, reportDup); } - - // Skip lines containing doc comment markers (they may contain signature examples) - if (trimmedLine.includes('@param') || trimmedLine.includes('@note') || - trimmedLine.includes('@return') || trimmedLine.includes('@usage') || - trimmedLine.includes('@example') || trimmedLine.includes('@code')) { - continue; + } + + // ── 3. Process free functions (not inside a class) ───────────────── + for (const node of ast.body) { + if (node.kind === 'FunctionDecl') { + const func = node as FunctionDeclNode; + const isProtoOrNative = func.modifiers.includes('proto') || func.modifiers.includes('native'); + if (!isProtoOrNative) { + this.checkDuplicatesInFunction( + func, new Map(), new Map(), globalNames, diags, + localScopesOverlap, reportDup + ); + } } - - // Strip trailing comments for pattern matching - // Use indexOf for reliability - const commentIdx = line.indexOf('//'); - let lineNoComment = (commentIdx >= 0 ? line.substring(0, commentIdx) : line); - - // Also strip inline block comments like: code /* comment */ more code - lineNoComment = lineNoComment.replace(/\/\*.*?\*\//g, ''); - - // Strip string literals to avoid detecting patterns inside strings - // e.g., "string cbFunction" in debug messages - lineNoComment = lineNoComment.replace(/"(?:[^"\\]|\\.)*"/g, '""'); // Replace "..." with "" - lineNoComment = lineNoComment.replace(/'(?:[^'\\]|\\.)*'/g, "''"); // Replace '...' with '' - - const lineNoCommentTrimmed = lineNoComment.trim(); - - // Check if this line starts a class - const classMatch = lineNoComment.match(classDeclPattern); - if (classMatch) { - // Starting a new class - reset all class-related state - inClass = true; - classBraceDepth = braceDepth; - classFieldScope = new Map(); // Fresh class field scope - currentClassName = classMatch[1]; - const baseClassName = classMatch[2]; - parentMethodSignatures = new Map(); - - // Pre-populate classFieldScope with inherited fields from parent classes - if (baseClassName) { - const hierarchy = this.getClassHierarchyOrdered(baseClassName, new Set()); - for (const parentClass of hierarchy) { - for (const member of parentClass.members || []) { - if (member.kind === 'VarDecl' && member.name) { - classFieldScope.set(member.name, { - line: member.start?.line ?? -1, - character: member.start?.character ?? 0 - }); - } - if (member.kind === 'FunctionDecl' && member.name) { - const paramCount = (member as any).parameters?.length ?? 0; - if (!parentMethodSignatures.has(member.name)) { - parentMethodSignatures.set(member.name, []); - } - parentMethodSignatures.get(member.name)!.push(paramCount); - } + } + } + + /** + * Check duplicates within a single class: fields vs inherited, missing override, + * and delegate to checkDuplicatesInFunction for each method. + */ + private checkDuplicatesInClass( + cls: ClassDeclNode, + globalNames: Map, + diags: Diagnostic[], + reportDup: (name: string, decl: SymbolNodeBase, existingLine: number) => void + ): void { + + // Position helper (kept local so each call is self-contained) + const posBefore = (a: Position, b: Position): boolean => + a.line < b.line || (a.line === b.line && a.character < b.character); + const localScopesOverlap = (a: VarDeclNode, b: VarDeclNode): boolean => { + if (!a.scopeEnd || !b.scopeEnd) return true; + const aVisAtB = !posBefore(b.start, a.start) && posBefore(b.start, a.scopeEnd); + const bVisAtA = !posBefore(a.start, b.start) && posBefore(a.start, b.scopeEnd); + return aVisAtB || bVisAtA; + }; + + // ── Collect class fields ─────────────────────────────────────────── + const classFields = new Map(); + for (const member of cls.members || []) { + if (member.kind === 'VarDecl' && member.name) { + classFields.set(member.name, member); + } + } + + // ── Collect inherited fields & parent method signatures ──────────── + const inheritedFields = new Map(); + const parentMethodSigs = new Map(); + + if (cls.base?.identifier) { + const hierarchy = this.getClassHierarchyOrdered(cls.base.identifier, new Set()); + for (const parentClass of hierarchy) { + for (const member of parentClass.members || []) { + if (member.kind === 'VarDecl' && member.name) { + if (!inheritedFields.has(member.name)) { + inheritedFields.set(member.name, member); } } - } - - // Reset scope stack to global scope + class fields (with inherited fields) - // This ensures class-level field declarations are checked against parent fields - scopeStack = [new Map(), classFieldScope]; - inFunction = false; - } - - // Handle multi-line function declarations: if we're inside one, - // check if this line has the closing ')' - if (inMultiLineFuncDecl) { - if (lineNoComment.includes(')')) { - // End of multi-line param list — trigger scope reset now - inMultiLineFuncDecl = false; - const wasInFunction = inFunction; - scopeStack = [scopeStack[0], new Map(classFieldScope), new Map()]; - inFunction = true; - functionBraceDepth = braceDepth; - } - // Skip variable extraction for parameter continuation lines - // (they are function params, handled by the scope reset above) - // Still process braces below though - } - - // Check if this line has a for/foreach/while loop - // In Enforce Script, loop variables are scoped to the PARENT scope, not the loop block - const isLoopLine = loopPattern.test(lineNoComment); - - // Check if this line starts a new function - // Must NOT be a loop line — for(int i ...) looks like a func decl to the regex - // Also exclude control flow statements (if, else, switch, do) which can false-match - // IMPORTANT: Only check for control flow keywords BEFORE the first '{', because - // single-line function bodies like "bool Foo() { return true; }" contain keywords - // like 'return' in the body that would cause a false isControlFlow=true. - const declPart = lineNoComment.includes('{') ? lineNoComment.substring(0, lineNoComment.indexOf('{')) : lineNoComment; - const isControlFlow = /\b(if|else|switch|do|return|new|delete|throw)\b/.test(declPart); - let isFuncDecl = !isLoopLine && !isControlFlow && funcDeclPattern.test(lineNoComment); - - // Detect multi-line function declarations: looks like a func decl start - // (has modifiers + return type + name + open paren) but no closing ')' on this line - if (!isFuncDecl && !isLoopLine && !isControlFlow && !inMultiLineFuncDecl) { - // Pattern: optional modifiers, return type, function name, opening '(' but no closing ')' - const multiLineFuncStart = /^\s*(?:static\s+|private\s+|protected\s+|override\s+|proto\s+|native\s+|volatile\s+|event\s+)*(?:void|int|float|bool|string|auto|ref\s+\w+(?:\s*<[\w,\s<>]+>)?|\w+(?:\s*<[\w,\s<>]+>)?)\s+\w+\s*\(/.test(lineNoComment); - if (multiLineFuncStart && !lineNoComment.includes(')')) { - inMultiLineFuncDecl = true; - // Scope reset will happen when we find the closing ')' - } - } - - if (isFuncDecl) { - // New function - reset to: global scope + class fields (copy) + new function scope - // We must copy classFieldScope to avoid it being modified by function-local variables - const wasInFunction = inFunction; - scopeStack = [scopeStack[0], new Map(classFieldScope), new Map()]; - inFunction = true; - functionBraceDepth = braceDepth; - - // Check for missing override keyword — only at class level, not inside a nested function - if (inClass && !wasInFunction && parentMethodSignatures.size > 0) { - const funcNameMatch = lineNoComment.match(funcDeclPattern); - if (funcNameMatch) { - const declaredFuncName = funcNameMatch[1]; - const hasOverride = /\boverride\b/.test(lineNoComment); - if (!hasOverride && parentMethodSignatures.has(declaredFuncName)) { - // Don't warn for constructors (function name === class name) - if (declaredFuncName !== currentClassName) { - // Extract param count from this declaration to compare with parent - // Only warn if a parent has a method with the SAME param count (true override) - // Different param counts = overload, not override - const paramStr = lineNoComment.match(/\(([^)]*)\)/); - const childParamCount = paramStr && paramStr[1].trim() !== '' - ? paramStr[1].split(',').length - : 0; - const parentParamCounts = parentMethodSignatures.get(declaredFuncName)!; - if (parentParamCounts.includes(childParamCount)) { - const fnStart = lineNoComment.indexOf(declaredFuncName); - diags.push({ - message: `Method '${declaredFuncName}' overrides a method from a parent class but is missing the 'override' keyword.`, - range: { - start: { line: lineNum, character: fnStart }, - end: { line: lineNum, character: fnStart + declaredFuncName.length } - }, - severity: DiagnosticSeverity.Warning - }); - } - } + if (member.kind === 'FunctionDecl' && member.name) { + const paramCount = (member as FunctionDeclNode).parameters?.length ?? 0; + if (!parentMethodSigs.has(member.name)) { + parentMethodSigs.set(member.name, []); } + parentMethodSigs.get(member.name)!.push(paramCount); } } } - - // Skip variable extraction for proto/native function declarations — - // their parameters don't create real variables in any scope - const isProtoOrNative = protoFuncPattern.test(lineNoComment); + } - // FIRST: Find variable declarations on this line BEFORE processing braces - // This ensures for loop variables (int j in "for (int j = 0...") - // are added to the current scope before we push a new scope for { - // Use lineNoComment to avoid matching variables in comments - // Skip if we're in a multi-line function declaration (params are not local vars) - let match; - varDeclPattern.lastIndex = 0; - - // Use lineNoComment but keep original line for position calculation - const lineForVars = lineNoComment; - while (!isProtoOrNative && !inMultiLineFuncDecl && (match = varDeclPattern.exec(lineForVars)) !== null) { - const typeName = match[1]; - const varName = match[2]; - - // Skip keywords that are not actual type names. - // 'typedef' is included because lines like "typedef set TFloatSet;" - // match the varDeclPattern as type=typedef, name=set — which is a false positive. - if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', 'else', 'foreach', 'void', 'override', 'static', 'private', 'protected', 'const', 'ref', 'autoptr', 'proto', 'native', 'modded', 'sealed', 'event', 'typedef', 'out', 'inout', 'notnull', 'owned', 'local', 'volatile', 'external', 'abstract', 'final', 'reference'].includes(typeName)) { - continue; - } - - // Skip common false positives - if (['this', 'super', 'null', 'true', 'false'].includes(varName)) { - continue; - } - - // Check all scopes in the stack for existing declaration - let foundDuplicate = false; - for (let si = 0; si < scopeStack.length; si++) { - const scope = scopeStack[si]; - const existing = scope.get(varName); - if (existing) { - const startPos = { line: lineNum, character: match.index + match[1].length + 1 }; - const endPos = { line: lineNum, character: match.index + match[1].length + 1 + varName.length }; - + // ── Check class fields against inherited fields & globals ────────── + for (const [fieldName, fieldNode] of classFields) { + const inh = inheritedFields.get(fieldName); + if (inh) { + reportDup(fieldName, fieldNode, inh.start.line); + continue; + } + const glob = globalNames.get(fieldName); + if (glob) { + reportDup(fieldName, fieldNode, glob.start.line); + } + } + + // ── Check each method ────────────────────────────────────────────── + for (const member of cls.members || []) { + if (member.kind !== 'FunctionDecl') continue; + const func = member as FunctionDeclNode; + + // Missing override check + if (parentMethodSigs.size > 0 && func.name && func.name !== cls.name) { + const hasOverride = func.modifiers.includes('override'); + if (!hasOverride && parentMethodSigs.has(func.name)) { + const childParamCount = func.parameters?.length ?? 0; + const parentParamCounts = parentMethodSigs.get(func.name)!; + if (parentParamCounts.includes(childParamCount)) { diags.push({ - message: `Variable '${varName}' is already declared at line ${existing.line + 1}. Enforce Script does not allow duplicate variable names in the same scope.`, - range: { start: startPos, end: endPos }, - severity: DiagnosticSeverity.Error - }); - foundDuplicate = true; - break; - } - } - - // Record this declaration in the appropriate scope - if (!foundDuplicate && scopeStack.length > 0) { - // If we're at class level (in a class but not in a function), record as class field - if (inClass && !inFunction && braceDepth === classBraceDepth + 1) { - classFieldScope.set(varName, { - line: lineNum, - character: match.index + message: `Method '${func.name}' overrides a method from a parent class but is missing the 'override' keyword.`, + range: { start: func.nameStart, end: func.nameEnd }, + severity: DiagnosticSeverity.Warning }); } - // Always add to current scope stack - scopeStack[scopeStack.length - 1].set(varName, { - line: lineNum, - character: match.index - }); } } - - // THEN: Process braces AFTER variable declarations - // This ensures for loop vars are in parent scope - for (let charIdx = 0; charIdx < line.length; charIdx++) { - const char = line[charIdx]; - if (char === '{') { - braceDepth++; - // Push new scope - scopeStack.push(new Map()); - } else if (char === '}') { - braceDepth--; - // Pop scope - if (scopeStack.length > 1) { - scopeStack.pop(); - } - // Check if we exited a function - if (inFunction && braceDepth <= functionBraceDepth) { - inFunction = false; - } - // Check if we exited a class - if (inClass && braceDepth <= classBraceDepth) { - inClass = false; - classFieldScope = new Map(); - } + + // Duplicate locals check (skip proto/native) + const isProtoOrNative = func.modifiers.includes('proto') || func.modifiers.includes('native'); + if (!isProtoOrNative) { + // Build combined ancestor map: globals < inherited < class fields + // (insertion order means closer scopes overwrite farther ones, + // but for duplicate checking we iterate all of them, so the order + // only controls which "existing line" is reported first — we + // scan outermost first, matching the old text-based behaviour.) + this.checkDuplicatesInFunction( + func, classFields, inheritedFields, globalNames, diags, + localScopesOverlap, reportDup + ); + } + } + } + + /** + * Check for duplicate locals/params within a single function, also + * checking against class fields, inherited fields, and globals. + */ + private checkDuplicatesInFunction( + func: FunctionDeclNode, + classFields: Map, + inheritedFields: Map, + globalNames: Map, + diags: Diagnostic[], + localScopesOverlap: (a: VarDeclNode, b: VarDeclNode) => boolean, + reportDup: (name: string, decl: SymbolNodeBase, existingLine: number) => void + ): void { + + // Scan order: globals → inherited → class fields (outermost first) + // This matches the old text-based checker's scope-stack[0..n] scan order. + const findAncestorConflict = (name: string): SymbolNodeBase | undefined => { + return globalNames.get(name) + ?? inheritedFields.get(name) + ?? classFields.get(name); + }; + + // ── Check parameters ───────────────────────────────────────── + // Build a set of already-seen param names for intra-param dups. + const paramNames = new Map(); + for (const p of func.parameters || []) { + if (!p.name) continue; + // Check against ancestors + const ancestor = findAncestorConflict(p.name); + if (ancestor) { + reportDup(p.name, p, ancestor.start.line); + continue; // don't add to paramNames if it's already a dup + } + // Check against prior params in same function + const priorParam = paramNames.get(p.name); + if (priorParam) { + reportDup(p.name, p, priorParam.start.line); + } else { + paramNames.set(p.name, p); + } + } + + // ── Check locals ───────────────────────────────────────────── + const locals = func.locals || []; + for (let i = 0; i < locals.length; i++) { + const local = locals[i]; + if (!local.name) continue; + + // Check against ancestors (globals/inherited/class fields) + const ancestor = findAncestorConflict(local.name); + if (ancestor) { + reportDup(local.name, local, ancestor.start.line); + continue; + } + + // Check against parameters + const param = paramNames.get(local.name); + if (param) { + reportDup(local.name, local, param.start.line); + continue; + } + + // Check against earlier locals with overlapping scopes + for (let j = 0; j < i; j++) { + const other = locals[j]; + if (other.name !== local.name) continue; + if (localScopesOverlap(other, local)) { + reportDup(local.name, local, other.start.line); + break; // only report first conflict } } } diff --git a/test/parser.test.ts b/test/parser.test.ts index 647b3ed..fd0e9a6 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -91,6 +91,91 @@ test('does not false-positive on case labels', () => { expect(func.locals.length).toBe(0); }); +// ── Phase 3: scopeEnd tests ──────────────────────────────────────────── + +test('scopeEnd is set for locals in nested blocks', () => { + const code = `class Foo { + void Bar() { + int outer = 1; + if (true) { + int inner = 2; + } + } +};`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const cls = ast.body[0] as any; + const func = cls.members[0] as any; + expect(func.locals.length).toBe(2); + + const outerLocal = func.locals.find((l: any) => l.name === 'outer'); + const innerLocal = func.locals.find((l: any) => l.name === 'inner'); + expect(outerLocal).toBeDefined(); + expect(innerLocal).toBeDefined(); + + // outer is in the function body scope — its scopeEnd is the function's closing '}' + expect(outerLocal.scopeEnd).toBeDefined(); + // inner is in the if-block scope — its scopeEnd is the if-block's closing '}' + expect(innerLocal.scopeEnd).toBeDefined(); + + // inner's scope should end BEFORE outer's scope + // (inner ends at the if-block '}', outer ends at the function '}') + expect(innerLocal.scopeEnd.line).toBeLessThan(outerLocal.scopeEnd.line); +}); + +test('scopeEnd for loop variables is function scope', () => { + // for-loop variables are declared before the '{', so they belong + // to the parent (function body) scope, not the loop block scope. + const code = `class Foo { + void Bar() { + for (int j = 0; j < 10; j++) { + int inside = 1; + } + } +};`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const cls = ast.body[0] as any; + const func = cls.members[0] as any; + + const jLocal = func.locals.find((l: any) => l.name === 'j'); + const insideLocal = func.locals.find((l: any) => l.name === 'inside'); + expect(jLocal).toBeDefined(); + expect(insideLocal).toBeDefined(); + + // j is in the function body scope (declared before the for-loop '{') + // inside is in the for-loop block scope + // So j.scopeEnd should be >= insideLocal.scopeEnd + expect(jLocal.scopeEnd.line).toBeGreaterThanOrEqual(insideLocal.scopeEnd.line); +}); + +test('scopeEnd allows sibling-block variables with same name', () => { + // Variables in sibling (non-overlapping) blocks have separate scopes + const code = `class Foo { + void Bar() { + if (true) { + int x = 1; + } + if (true) { + int x = 2; + } + } +};`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const cls = ast.body[0] as any; + const func = cls.members[0] as any; + + // Both 'x' locals should exist + const xLocals = func.locals.filter((l: any) => l.name === 'x'); + expect(xLocals.length).toBe(2); + + // Their scopes should NOT overlap: first x's scopeEnd < second x's start + const first = xLocals[0]; + const second = xLocals[1]; + expect(first.scopeEnd.line).toBeLessThanOrEqual(second.start.line); +}); + test('playground', () => { const target_file = path.join("P:\\enscript\\test", "test_enscript.c"); const text = fs.readFileSync(target_file, "utf8"); From 117d1a68c0da86dff8b686b4e6494b180a599291 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sun, 8 Feb 2026 21:30:58 -0500 Subject: [PATCH 16/46] Remove AST cache and fix parsing bugs Remove the persistent AST cache and its usage during indexing; deduplicate files by realpath when gathering files to index. In the parser, add diagnostic for multi-line string literals and harden generic-type detection by stopping angle-bracket walk-back at statement/scope boundaries (including adding '[' to declaration heuristics). Add handling for array-style indexing (resolveIndexedType) so function/chain return types account for trailing [..] access. Update regexes to detect C-style array declarations and parameter patterns. Add tests to prevent false positives where comparison operators or cross-function code caused incorrect generic/type locals. --- server/src/analysis/ast/parser.ts | 32 +++- server/src/analysis/project/cache.ts | 230 --------------------------- server/src/analysis/project/graph.ts | 54 ++++--- server/src/index.ts | 48 +++--- test/parser.test.ts | 78 +++++++++ 5 files changed, 163 insertions(+), 279 deletions(-) delete mode 100644 server/src/analysis/project/cache.ts diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index 43db7f2..11f0962 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -187,6 +187,26 @@ export function parse( // ==================================================================== const diagnostics: Diagnostic[] = []; + // ==================================================================== + // MULTI-LINE STRING DETECTION + // ==================================================================== + // Enforce Script does not support multi-line string literals. + // Detect any string token that spans multiple lines and report an error. + // ==================================================================== + for (const tok of toks) { + if (tok.kind === TokenKind.String && tok.value.includes('\n')) { + diagnostics.push({ + range: { + start: doc.positionAt(tok.start), + end: doc.positionAt(tok.end) + }, + message: 'Multi-line string literals are not supported in Enforce Script. Use string concatenation with + instead.', + severity: DiagnosticSeverity.Error, + source: 'enforce-script' + }); + } + } + /** * Add a diagnostic error or warning */ @@ -574,7 +594,14 @@ export function parse( // walk backwards past balanced angle brackets to find the actual type name. // The '>>' token represents two closing brackets (nested generics like // map>) and must be counted as 2. - if (prev && prevPrev && (t.value === ';' || t.value === '=' || t.value === ',' || t.value === ':')) { + // + // IMPORTANT: The walk-back must stop at statement/scope boundaries + // (';', '{', '}') because comparison operators also use '<' and '>'. + // Without this, expressions like `tier.radius > maxSafeRadius;` can + // walk back to `<` from a for-loop condition `i < tierCount`, falsely + // detecting `i maxSafeRadius` as a generic-typed variable declaration. + // Valid generic types like `array` never span these boundaries. + if (prev && prevPrev && (t.value === ';' || t.value === '=' || t.value === ',' || t.value === ':' || t.value === '[')) { let typeTok = prevPrev; if (prevPrev.value === '>' || prevPrev.value === '>>') { // Walk backwards through tokens to find matching '<' and the type before it @@ -586,6 +613,9 @@ export function parse( if (st.value === '>>') angleDepth += 2; else if (st.value === '>') angleDepth++; else if (st.value === '<') angleDepth--; + // Stop at statement/scope boundaries — a <> pair that + // spans these is a comparison operator, not a generic type. + else if (st.value === ';' || st.value === '{' || st.value === '}') break; searchPos--; } // After the loop, searchPos has been decremented past '<', diff --git a/server/src/analysis/project/cache.ts b/server/src/analysis/project/cache.ts deleted file mode 100644 index 636b60c..0000000 --- a/server/src/analysis/project/cache.ts +++ /dev/null @@ -1,230 +0,0 @@ -/** - * Persistent AST Cache - * ==================== - * - * Caches parsed ASTs to disk to dramatically speed up subsequent VS Code launches. - * - * Strategy: - * - Store AST + file modification time (mtime) in a JSON cache file - * - On startup, check if file's mtime matches cached mtime - * - If match: load AST from cache (fast) - * - If mismatch or not in cache: parse file (slow) and update cache - * - * Cache location: {workspaceRoot}/.vscode/.enscript-cache.json - * - * Cache format: - * { - * "version": 1, - * "entries": { - * "file:///path/to/file.c": { - * "mtime": 1234567890, - * "ast": { ... } - * } - * } - * } - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import * as url from 'url'; -import { File } from '../ast/parser'; - -const CACHE_VERSION = 1; -const CACHE_FILENAME = '.enscript-cache.json'; - -interface CacheEntry { - mtime: number; - ast: File; -} - -interface CacheData { - version: number; - entries: Record; -} - -export class ASTCache { - private cacheDir: string; - private cachePath: string; - private cache: CacheData; - private dirty = false; - private enabled = true; - - constructor(workspaceRoot: string) { - this.cacheDir = path.join(workspaceRoot, '.vscode'); - this.cachePath = path.join(this.cacheDir, CACHE_FILENAME); - this.cache = { version: CACHE_VERSION, entries: {} }; - console.log(`AST cache path: ${this.cachePath}`); - this.load(); - } - - /** - * Disable cache (e.g., for testing or if issues arise) - */ - disable(): void { - this.enabled = false; - } - - /** - * Check if we have a valid cached AST for a file - * @param filePath Absolute file path - * @returns Cached AST if valid, null if needs re-parsing - */ - get(filePath: string): File | null { - if (!this.enabled) return null; - - try { - const uri = this.pathToUri(filePath); - const entry = this.cache.entries[uri]; - - if (!entry) return null; - - // Check if file has been modified since caching - const stats = fs.statSync(filePath); - const currentMtime = stats.mtimeMs; - - if (entry.mtime === currentMtime) { - return entry.ast; - } - - // File modified, cache invalid - return null; - } catch { - return null; - } - } - - /** - * Store a parsed AST in the cache - * @param filePath Absolute file path - * @param ast Parsed AST - */ - set(filePath: string, ast: File): void { - if (!this.enabled) return; - - try { - const uri = this.pathToUri(filePath); - const stats = fs.statSync(filePath); - - this.cache.entries[uri] = { - mtime: stats.mtimeMs, - ast: ast - }; - this.dirty = true; - } catch { - // Ignore errors - cache is optional - } - } - - /** - * Persist cache to disk - * Call this after initial indexing or periodically - */ - save(): void { - if (!this.enabled || !this.dirty) return; - - try { - // Ensure .vscode directory exists - if (!fs.existsSync(this.cacheDir)) { - fs.mkdirSync(this.cacheDir, { recursive: true }); - } - - // Write cache atomically (write to temp, then rename) - const tempPath = this.cachePath + '.tmp'; - const data = JSON.stringify(this.cache); - fs.writeFileSync(tempPath, data, 'utf8'); - fs.renameSync(tempPath, this.cachePath); - - this.dirty = false; - console.log(`AST cache saved: ${Object.keys(this.cache.entries).length} entries`); - } catch (err) { - console.warn(`Failed to save AST cache: ${err}`); - } - } - - /** - * Load cache from disk - */ - private load(): void { - try { - if (!fs.existsSync(this.cachePath)) { - return; - } - - const data = fs.readFileSync(this.cachePath, 'utf8'); - const parsed = JSON.parse(data) as CacheData; - - // Version check - invalidate if cache format changed - if (parsed.version !== CACHE_VERSION) { - console.log(`AST cache version mismatch (${parsed.version} vs ${CACHE_VERSION}), clearing cache`); - return; - } - - this.cache = parsed; - console.log(`AST cache loaded: ${Object.keys(this.cache.entries).length} entries`); - } catch (err) { - console.warn(`Failed to load AST cache: ${err}`); - // Start fresh - this.cache = { version: CACHE_VERSION, entries: {} }; - } - } - - /** - * Clear the cache completely - */ - clear(): void { - this.cache = { version: CACHE_VERSION, entries: {} }; - this.dirty = true; - try { - if (fs.existsSync(this.cachePath)) { - fs.unlinkSync(this.cachePath); - } - } catch { - // Ignore - } - } - - /** - * Remove a specific file from cache (e.g., when deleted) - */ - invalidate(filePath: string): void { - const uri = this.pathToUri(filePath); - if (this.cache.entries[uri]) { - delete this.cache.entries[uri]; - this.dirty = true; - } - } - - /** - * Get cache statistics - */ - getStats(): { entries: number; cacheHits: number; cacheMisses: number } { - return { - entries: Object.keys(this.cache.entries).length, - cacheHits: this.cacheHits, - cacheMisses: this.cacheMisses - }; - } - - private cacheHits = 0; - private cacheMisses = 0; - - /** - * Track cache hit for statistics - */ - recordHit(): void { - this.cacheHits++; - } - - /** - * Track cache miss for statistics - */ - recordMiss(): void { - this.cacheMisses++; - } - - private pathToUri(filePath: string): string { - // Use Node's url module for consistent URI generation - // This matches what's used in index.ts: url.pathToFileURL(filePath).toString() - return url.pathToFileURL(filePath).toString(); - } -} diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 0f33d5a..3e4ffab 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -549,21 +549,7 @@ export class Analyzer { } /** - * Inject a pre-parsed AST directly into the cache (used by persistent cache on startup) - * @param uri The document URI - * @param ast The pre-parsed AST from disk cache - */ - injectCachedAST(uri: string, ast: File): void { - const normalizedUri = normalizeUri(uri); - ast.module = getModuleLevel(uri); - this.docCache.set(normalizedUri, ast); - // Update indexes for fast lookups - this.updateGlobalSymbolIndex(normalizedUri, ast); - this.updateAllIndexes(normalizedUri, ast); - } - - /** - * Parse a document and return the AST (used during indexing to populate persistent cache) + * Parse a document and return the AST (used during indexing) * @param doc The TextDocument to parse * @returns The parsed AST (or a stub on error) */ @@ -942,15 +928,15 @@ export class Analyzer { const text = doc.getText(); const regexKeywords = new Set(['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', 'else', 'foreach', 'void', 'override', 'static', 'private', 'protected', 'const', 'ref', 'autoptr', 'proto', 'native', 'modded', 'sealed', 'event', 'typedef', 'case', 'break', 'continue', 'this', 'super', 'null', 'true', 'false', 'out', 'inout', 'volatile']); - // Pattern: Type varName; or Type varName = + // Pattern: Type varName; or Type varName = or Type varName[ (C-style array) // Use word boundary to avoid matching inside larger expressions - const varDeclMatch = text.match(new RegExp(`(?:^|[{;,\\s])\\s*(\\w+)\\s+${varName}\\s*[;=]`)); + const varDeclMatch = text.match(new RegExp(`(?:^|[{;,\\s])\\s*(\\w+)\\s+${varName}\\s*[;=\\[]`)); if (varDeclMatch && !regexKeywords.has(varDeclMatch[1])) { return varDeclMatch[1]; } - // Pattern: (Type varName) or (Type varName,) - function parameters - const paramMatch = text.match(new RegExp(`[,(]\\s*(\\w+)\\s+${varName}\\s*[,)]`)); + // Pattern: (Type varName) or (Type varName,) or (Type varName[) - function parameters + const paramMatch = text.match(new RegExp(`[,(]\\s*(\\w+)\\s+${varName}\\s*[,)\\[]`)); if (paramMatch) { return paramMatch[1]; } @@ -1226,6 +1212,20 @@ export class Analyzer { return node ? node.oldType.identifier : typeName; } + /** + * Given a type that is being indexed with [], return the element type. + * e.g., vector[0] → float, string[0] → string, array[0] → null (skip check) + * Returns null if the indexed type cannot be determined (to avoid false positives). + */ + private resolveIndexedType(containerType: string): string | null { + const lower = containerType.toLowerCase(); + if (lower === 'vector') return 'float'; + if (lower === 'string') return 'string'; + // For arrays, maps, sets, etc., we can't easily determine the element type + // from just the base type name, so return null to skip the type check. + return null; + } + /** * Find the TypedefNode for a given type name. * Returns null if the type is not a typedef. @@ -3204,6 +3204,12 @@ export class Analyzer { if (!returnType) { returnType = this.resolveFunctionReturnType(funcName); } + // Check for array indexing after the function call, e.g. GetOrientation()[0] + // If present, adjust the return type to the element type + const afterCall = afterMatch.substring(singleEnd).trim(); + if (afterCall.startsWith('[') && returnType) { + returnType = this.resolveIndexedType(returnType); + } highlightLength = match[0].length + singleEnd; } @@ -3418,6 +3424,12 @@ export class Analyzer { if (!returnType) { returnType = this.resolveFunctionReturnType(funcName); } + // Check for array indexing after the function call, e.g. GetOrientation()[0] + // If present, adjust the return type to the element type + const afterCall = afterMatch.substring(chainEnd).trim(); + if (afterCall.startsWith('[') && returnType) { + returnType = this.resolveIndexedType(returnType); + } } if (targetType && returnType) { @@ -3768,6 +3780,10 @@ export class Analyzer { // There's a chain after the function call — resolve it return this.resolveVariableChainType(rootType, afterCall); } + // Check for array indexing after the function call: FuncName(...)[expr] + if (afterCall.startsWith('[')) { + return this.resolveIndexedType(rootType); + } } return rootType; } diff --git a/server/src/index.ts b/server/src/index.ts index 2d7e58a..8e920c1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -14,7 +14,6 @@ import * as fs from 'fs/promises'; import { findAllFiles, readFileUtf8 } from './util/fs'; import { Analyzer } from './analysis/project/graph'; import { getConfiguration } from './util/config'; -import { ASTCache } from './analysis/project/cache'; // Create LSP connection (stdio or Node IPC autodetect). @@ -59,28 +58,38 @@ connection.onInitialized(async () => { const pathsToIndex = [workspaceRoot, ...includePaths]; const allFiles: string[] = []; + const seenRealPaths = new Set(); for (const basePath of pathsToIndex) { console.log(`Adding folder ${basePath} to indexing`); try { const files = await findAllFiles(basePath, ['.c']); - allFiles.push(...files); + for (const file of files) { + // Deduplicate by resolved real path (handles subst drives, junctions, symlinks) + try { + const realPath = await fs.realpath(file); + const normalizedReal = realPath.toLowerCase(); + if (!seenRealPaths.has(normalizedReal)) { + seenRealPaths.add(normalizedReal); + allFiles.push(file); + } + } catch { + // If realpath fails, include the file anyway + allFiles.push(file); + } + } } catch (err) { console.warn(`Failed to scan path: ${basePath} – ${String(err)}`); } } - console.log(`Indexing ${allFiles.length} EnScript files...`); + console.log(`Indexing ${allFiles.length} EnScript files (${seenRealPaths.size} unique)...`); // Notify client that indexing is starting connection.sendNotification('enscript/indexingStart', { fileCount: allFiles.length }); - // Initialize persistent AST cache for faster subsequent launches - const astCache = new ASTCache(workspaceRoot); - let cacheHits = 0; - let cacheMisses = 0; const startTime = Date.now(); let lastProgressUpdate = 0; @@ -88,24 +97,9 @@ connection.onInitialized(async () => { const filePath = allFiles[i]; const uri = url.pathToFileURL(filePath).toString(); - // Try to load from persistent cache first - const cachedAst = astCache.get(filePath); - if (cachedAst) { - // Cache hit - inject directly into Analyzer's memory cache - Analyzer.instance().injectCachedAST(uri, cachedAst); - cacheHits++; - } else { - // Cache miss - need to read and parse - const text = await readFileUtf8(filePath); - const doc = TextDocument.create(uri, 'enscript', 1, text); - const ast = Analyzer.instance().parseAndCache(doc); - - // Store in persistent cache for next launch - if (ast && ast.body.length > 0) { - astCache.set(filePath, ast); - } - cacheMisses++; - } + const text = await readFileUtf8(filePath); + const doc = TextDocument.create(uri, 'enscript', 1, text); + Analyzer.instance().parseAndCache(doc); // Send progress updates every 500ms or every 100 files const now = Date.now(); @@ -118,9 +112,6 @@ connection.onInitialized(async () => { lastProgressUpdate = now; } } - - // Save the cache to disk - astCache.save(); const elapsed = Date.now() - startTime; @@ -132,7 +123,6 @@ connection.onInitialized(async () => { `${stats.enums} enums, ${stats.typedefs} typedefs, ${stats.globals} globals` + (stats.parseErrors > 0 ? ` (${stats.parseErrors} parse errors)` : '') ); - console.log(` Cache: ${cacheHits} hits, ${cacheMisses} misses (${cacheHits > 0 ? Math.round(cacheHits / (cacheHits + cacheMisses) * 100) : 0}% hit rate)`); // Log per-module file counts const modParts = Object.entries(stats.moduleCounts) .sort(([a], [b]) => Number(a) - Number(b)) diff --git a/test/parser.test.ts b/test/parser.test.ts index fd0e9a6..0c9f272 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -176,6 +176,84 @@ test('scopeEnd allows sibling-block variables with same name', () => { expect(first.scopeEnd.line).toBeLessThanOrEqual(second.start.line); }); +test('comparison operators do not false-positive as generic types', () => { + // Bug: `tier.radius > maxSafeRadius;` walked back to `<` in `i < tierCount`, + // creating a false local with type='i'. The angle bracket walk-back must stop + // at statement boundaries (`;`, `{`, `}`). + const code = `class Foo { + void Bar() { + int tierCount = 10; + for (int i = 0; i < tierCount; i++) { + float radius = 50.0; + float maxSafe = 100.0; + bool wouldOverlap = radius > maxSafe; + } + } +};`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const cls = ast.body[0] as any; + const func = cls.members[0] as any; + + // Should detect: tierCount, i, radius, maxSafe, wouldOverlap + const localNames = func.locals.map((l: any) => l.name); + expect(localNames).toContain('tierCount'); + expect(localNames).toContain('i'); + expect(localNames).toContain('radius'); + expect(localNames).toContain('maxSafe'); + expect(localNames).toContain('wouldOverlap'); + + // MUST NOT have 'maxSafe' with type='i' (the false positive) + // All locals with name 'maxSafe' should have type 'float', never 'i' + const maxSafeLocals = func.locals.filter((l: any) => l.name === 'maxSafe'); + for (const loc of maxSafeLocals) { + expect(loc.type.identifier).toBe('float'); + } + + // The 'i' local should have type 'int' + const iLocal = func.locals.find((l: any) => l.name === 'i'); + expect(iLocal.type.identifier).toBe('int'); + + // Should NOT have any false locals with type='i' and name='maxSafe' + const falseLocals = func.locals.filter((l: any) => l.type.identifier === 'i'); + expect(falseLocals.length).toBe(0); +}); + +test('comparison > across functions does not false-positive', () => { + // Bug: `return currentCount > maxTerritories;` in one method walked back + // across function boundaries to find `<` in `currentCount < maxTerritories` + // in a previous method, falsely detecting type='currentCount'. + const code = `class Foo { + bool MethodA(string arg) { + int currentCount = 5; + bool canClaim = currentCount < 10; + return canClaim; + } + bool MethodB(string arg) { + int currentCount = 5; + return currentCount > 10; + } +};`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const cls = ast.body[0] as any; + + const methodA = cls.members.find((m: any) => m.name === 'MethodA'); + const methodB = cls.members.find((m: any) => m.name === 'MethodB'); + + // MethodA locals: currentCount (int), canClaim (bool) + expect(methodA.locals.map((l: any) => l.name)).toEqual( + expect.arrayContaining(['currentCount', 'canClaim']) + ); + + // MethodB locals: currentCount (int) only — NO false locals + const methodBNames = methodB.locals.map((l: any) => l.name); + expect(methodBNames).toContain('currentCount'); + // Should NOT have a false local with type='currentCount' + const falseLocals = methodB.locals.filter((l: any) => l.type.identifier === 'currentCount'); + expect(falseLocals.length).toBe(0); +}); + test('playground', () => { const target_file = path.join("P:\\enscript\\test", "test_enscript.c"); const text = fs.readFileSync(target_file, "utf8"); From ed238366f65d8213c1d7fa54d306feada338d591 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sun, 8 Feb 2026 21:41:04 -0500 Subject: [PATCH 17/46] Move multi-line string check to Analyzer Remove multi-line string literal detection from the parser and add a dedicated check in Analyzer that scans the raw document text (so it still reports even if parsing fails). The new check skips single-line and block comments, handles escape sequences, and emits the same diagnostic message/range. Also tweak multi-line statement detection (ignore preprocessor lines, better operator-continuation heuristics). Update tests to assert the parser no longer emits multi-line string diagnostics and to prevent false positives for single-line strings. Files changed: removed check from server/src/analysis/ast/parser.ts, added checkMultiLineStrings and related wiring in server/src/analysis/project/graph.ts, and added tests in test/parser.test.ts. --- server/src/analysis/ast/parser.ts | 20 ------- server/src/analysis/project/graph.ts | 88 +++++++++++++++++++++++++++- test/parser.test.ts | 20 +++++++ 3 files changed, 107 insertions(+), 21 deletions(-) diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index 11f0962..b13c7cd 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -187,26 +187,6 @@ export function parse( // ==================================================================== const diagnostics: Diagnostic[] = []; - // ==================================================================== - // MULTI-LINE STRING DETECTION - // ==================================================================== - // Enforce Script does not support multi-line string literals. - // Detect any string token that spans multiple lines and report an error. - // ==================================================================== - for (const tok of toks) { - if (tok.kind === TokenKind.String && tok.value.includes('\n')) { - diagnostics.push({ - range: { - start: doc.positionAt(tok.start), - end: doc.positionAt(tok.end) - }, - message: 'Multi-line string literals are not supported in Enforce Script. Use string concatenation with + instead.', - severity: DiagnosticSeverity.Error, - source: 'enforce-script' - }); - } - } - /** * Add a diagnostic error or warning */ diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 3e4ffab..1adb2ae 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -2570,6 +2570,12 @@ export class Analyzer { if (ast.diagnostics && ast.diagnostics.length > 0) { diags.push(...ast.diagnostics); } + + // ── Multi-line string literal detection ─────────────────────────── + // Enforce Script does not support multi-line strings. + // Scan the raw text for string literals that span multiple lines. + // Done here (not in the parser) so it works even when parsing fails. + this.checkMultiLineStrings(doc, diags); // ── Build shared diagnostic context once ─────────────────────────── // These pre-computed values are passed to each checker so the @@ -2602,6 +2608,7 @@ export class Analyzer { // Check for multi-line statements (not supported in Enforce Script) // This doesn't require indexing - it's purely syntactic this.checkMultiLineStatements(doc, diags, text, lines); + // Check for duplicate variable declarations in same scope // Now AST-based: uses parser's locals with scopeEnd ranges instead @@ -4339,6 +4346,63 @@ export class Analyzer { // Upcast is fine - no warning needed } + /** + * Check for multi-line string literals (not supported in Enforce Script). + * Scans the raw text for quoted strings that span across newlines. + * This runs independently of the parser so it works even when parsing fails. + */ + private checkMultiLineStrings(doc: TextDocument, diags: Diagnostic[]): void { + const text = doc.getText(); + let i = 0; + while (i < text.length) { + const ch = text[i]; + + // Skip single-line comments + if (ch === '/' && text[i + 1] === '/') { + while (i < text.length && text[i] !== '\n') i++; + continue; + } + // Skip block comments + if (ch === '/' && text[i + 1] === '*') { + i += 2; + while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++; + i += 2; + continue; + } + + // String literal + if (ch === '"') { + const start = i; + i++; // skip opening quote + let hasNewline = false; + while (i < text.length && text[i] !== '"') { + if (text[i] === '\\' && i + 1 < text.length) { + i += 2; // skip escape sequence + } else { + if (text[i] === '\n') hasNewline = true; + i++; + } + } + i++; // skip closing quote (or end of file) + + if (hasNewline) { + diags.push({ + range: { + start: doc.positionAt(start), + end: doc.positionAt(i) + }, + message: 'Multi-line string literals are not supported in Enforce Script. Use string concatenation with + instead.', + severity: DiagnosticSeverity.Error, + source: 'enforce-script' + }); + } + continue; + } + + i++; + } + } + /** * Check for multi-line statements which are NOT supported in Enforce Script. * Each statement must be on a single line. @@ -4395,7 +4459,29 @@ export class Analyzer { // Skip declaration starts (class, if, for, etc.) const isDeclarationStart = /^(class|modded|enum|struct|typedef|if|else|for|while|switch|foreach)\b/.test(line); - if (unclosedParens && !endsWithTerminator && !isDeclarationStart && i + 1 < lines.length) { + // Skip preprocessor lines + if (line.startsWith('#')) continue; + + // Detect expression continuation via operators: + // string x = "a" + b + ← line ends with binary operator + // "c"; + const endsWithBinaryOp = /(?:\+(?!\+)|-(?!-)|[*\/%&|^~]|&&|\|\|)\s*$/.test(line); + + // Detect continuation on next line starting with operator: + // string x = "a" + b ← line does NOT end with ; or operator + // + "c"; ← next line STARTS with + + let nextLineContinuation = false; + if (!unclosedParens && !endsWithBinaryOp && !endsWithTerminator && !isDeclarationStart) { + let nextIdx = i + 1; + while (nextIdx < lines.length && !lines[nextIdx].trim()) nextIdx++; + if (nextIdx < lines.length) { + const nextTrimmed = lines[nextIdx].trim(); + // Next line starts with a binary operator (but not ++ or --) + nextLineContinuation = /^(?:\+(?!\+)|-(?!-)|[*\/%]|&&|\|\||\.(?!\.))/.test(nextTrimmed); + } + } + + if ((unclosedParens || endsWithBinaryOp || nextLineContinuation) && !endsWithTerminator && !isDeclarationStart && i + 1 < lines.length) { // Check if next non-empty line continues this statement let nextLineIdx = i + 1; while (nextLineIdx < lines.length && !lines[nextLineIdx].trim()) { diff --git a/test/parser.test.ts b/test/parser.test.ts index 0c9f272..7bd6fe5 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -254,6 +254,26 @@ test('comparison > across functions does not false-positive', () => { expect(falseLocals.length).toBe(0); }); +test('multi-line string detection moved to runDiagnostics', () => { + // Multi-line string detection was moved from the parser to Analyzer.runDiagnostics() + // so it works even when the parser throws a ParseError. + // The parser itself should NOT produce multi-line string diagnostics anymore. + const code = 'class Foo {\n void Bar() {\n string s = "hello\nworld";\n }\n};'; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const mlDiag = ast.diagnostics.find(d => d.message.includes('Multi-line string')); + expect(mlDiag).toBeUndefined(); +}); + +test('no false positive for single-line strings', () => { + const code = 'class Foo {\n void Bar() {\n string s = "hello" + " " + "world";\n }\n};'; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + // Should NOT have a multi-line string diagnostic + const mlDiag = ast.diagnostics.find(d => d.message.includes('Multi-line string')); + expect(mlDiag).toBeUndefined(); +}); + test('playground', () => { const target_file = path.join("P:\\enscript\\test", "test_enscript.c"); const text = fs.readFileSync(target_file, "utf8"); From f6e53feca12a20f1a1837d0ee94eb1ee1df58981 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sun, 8 Feb 2026 21:44:32 -0500 Subject: [PATCH 18/46] Strip inline comments before parsing Remove inline // comments (using a trimmed `codePart`) and skip lines that become empty after stripping. Use `codePart` instead of the raw line for parentheses counting, terminator checks, declaration-start detection, preprocessor checks, and binary-operator continuation detection to avoid false positives from comment content (e.g. `%` in comments). Braces counting is preserved as before. --- server/src/analysis/project/graph.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 1adb2ae..64afbc1 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -4446,26 +4446,33 @@ export class Analyzer { const closeBraces = (line.match(/\}/g) || []).length; braceDepth += openBraces - closeBraces; + // Strip inline comments before checking operators/terminators + // "x = 1.0; // 100%" → "x = 1.0;" (otherwise % looks like a binary op) + const codePart = line.replace(/\/\/.*$/, '').trimEnd(); + + // Skip lines that are only a comment (nothing left after stripping) + if (!codePart) continue; + // Only check for unclosed parentheses - this is the real multi-line issue // e.g., Print("text" + // "more"); <-- not allowed in Enforce Script - const openParens = (line.match(/\(/g) || []).length; - const closeParens = (line.match(/\)/g) || []).length; + const openParens = (codePart.match(/\(/g) || []).length; + const closeParens = (codePart.match(/\)/g) || []).length; const unclosedParens = openParens > closeParens; // Skip lines ending with { or ; or } - those are complete - const endsWithTerminator = /[{};]\s*$/.test(line); + const endsWithTerminator = /[{};]\s*$/.test(codePart); // Skip declaration starts (class, if, for, etc.) - const isDeclarationStart = /^(class|modded|enum|struct|typedef|if|else|for|while|switch|foreach)\b/.test(line); + const isDeclarationStart = /^(class|modded|enum|struct|typedef|if|else|for|while|switch|foreach)\b/.test(codePart); // Skip preprocessor lines - if (line.startsWith('#')) continue; + if (codePart.startsWith('#')) continue; // Detect expression continuation via operators: // string x = "a" + b + ← line ends with binary operator // "c"; - const endsWithBinaryOp = /(?:\+(?!\+)|-(?!-)|[*\/%&|^~]|&&|\|\|)\s*$/.test(line); + const endsWithBinaryOp = /(?:\+(?!\+)|-(?!-)|[*\/%&|^~]|&&|\|\|)\s*$/.test(codePart); // Detect continuation on next line starting with operator: // string x = "a" + b ← line does NOT end with ; or operator From 57166c09036faf7dc61596414dc7e180082cf50f Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sun, 8 Feb 2026 22:09:07 -0500 Subject: [PATCH 19/46] Detect and resolve indexing after call chains Add hasIndexingAfterCall(text) to detect `[...]` occurrences outside of parenthesised argument lists and use it to resolve element types when indexing appears after function/method calls (e.g. `GetOrientation()[0]`, `.GetAimDelta(pDt)[0] * RAD2DEG`). Replace the previous regex-based check with this safer check in chain resolution paths and call resolveIndexedType(...) when appropriate. Also add 'case' to the skipNames set. This avoids false positives from bracketed indices inside call arguments (e.g. `.GetSurface(pos[0], pos[2])`). --- server/src/analysis/project/graph.ts | 52 ++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 64afbc1..7e70174 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -1226,6 +1226,24 @@ export class Analyzer { return null; } + /** + * Check if a chain/expression text contains `[...]` indexing OUTSIDE of + * parenthesised argument lists. e.g.: + * ".GetOrientation()[0]" → true ([0] is after the call) + * ".GetAimDelta(pDt)[0] * RAD2DEG" → true + * ".GetSurface(pos[0], pos[2])" → false ([0]/[2] inside args) + */ + private hasIndexingAfterCall(text: string): boolean { + let parenDepth = 0; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '(') parenDepth++; + else if (ch === ')') { if (parenDepth > 0) parenDepth--; } + else if (ch === '[' && parenDepth === 0) return true; + } + return false; + } + /** * Find the TypedefNode for a given type name. * Returns null if the type is not a typedef. @@ -1420,10 +1438,23 @@ export class Analyzer { } // If no chained calls, return the first function's resolved type - if (calls.length === 0) return currentType; + if (calls.length === 0) { + // Check for array indexing after the single call, e.g., GetOrientation()[0] + if (this.hasIndexingAfterCall(afterFirst)) { + return this.resolveIndexedType(currentType); + } + return currentType; + } // Delegate remaining chain steps - return this.resolveChainSteps(calls, currentType, templateMap)?.type ?? null; + const result = this.resolveChainSteps(calls, currentType, templateMap)?.type ?? null; + + // If the chain contains array indexing outside of args, resolve to element type + if (result && this.hasIndexingAfterCall(afterFirst)) { + return this.resolveIndexedType(result); + } + + return result; } /** @@ -1455,14 +1486,13 @@ export class Analyzer { const result = this.resolveChainSteps(calls, currentType, templateMap)?.type ?? null; - // If the chain text ends with array indexing like [expr], - // the result is the element type, not the container type. - // Since we don't always know the element type, return null to avoid - // false type mismatch errors. - if (result && /\[.*\]\s*$/.test(chainText)) { - if (['array', 'set', 'map'].includes(result)) { - return null; - } + // If the chain contains array indexing like [expr] after the last + // method call, resolve to the element type. Matches [0], [i], etc. + // even when followed by arithmetic like * Math.RAD2DEG. + // Uses hasIndexingAfterCall to avoid false positives from [] + // inside function arguments like .GetSurface(pos[0], pos[2]). + if (result && this.hasIndexingAfterCall(chainText)) { + return this.resolveIndexedType(result); } return result; @@ -3969,7 +3999,7 @@ export class Analyzer { // Keywords and built-ins that look like function calls but aren't const skipNames = new Set([ - 'if', 'while', 'for', 'foreach', 'switch', 'return', 'new', 'delete', + 'if', 'while', 'for', 'foreach', 'switch', 'case', 'return', 'new', 'delete', 'super', 'this', 'class', 'enum', 'typedef', 'Print', 'PrintFormat', 'cast', 'sizeof', 'typeof', 'typename', 'thread', 'ref', 'array', 'set', 'map', 'autoptr' From 298a7b95c11cf2bd84d29db587ef53b658fed23c Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sun, 8 Feb 2026 22:09:53 -0500 Subject: [PATCH 20/46] Set default enscript.includePaths to P:\scripts Change default enscript.includePaths from an empty array to ["P:\scripts"] so editors will index base game script files by default (e.g. P:\scripts). To make it more beginner Friendly. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4c97f49..255098c 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "properties": { "enscript.includePaths": { "type": "array", - "default": [], + "default": ["P:\\scripts"], "description": "List of paths to index for base game data (e.g. P:\\scripts\\ folder)", "items": { "type": "string" From 229ca0bc2ae2a3b33bf897728cd62be73b2e09b8 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sun, 8 Feb 2026 22:15:05 -0500 Subject: [PATCH 21/46] Only report diagnostics for workspace files Add workspace/include path awareness to the Analyzer and gate diagnostics by workspace membership. Introduces includePaths and workspaceRoot fields with setIncludePaths/setWorkspaceRoot setters (normalizing to lowercase forward-slash paths), and an isWorkspaceFile(uri) helper that resolves file:// URIs. index.ts now sets the analyzer's include paths and workspace root on initialization. diagnostics handler skips reporting for files outside the workspace (sending empty diagnostics) to suppress diagnostics for external include-path files. --- server/src/analysis/project/graph.ts | 24 ++++++++++++++++++++++++ server/src/index.ts | 6 ++++++ server/src/lsp/handlers/diagnostics.ts | 5 +++++ 3 files changed, 35 insertions(+) diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 7e70174..e4bcaa8 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -436,6 +436,30 @@ export class Analyzer { this.preprocessorDefines = new Set(defines); } + /** Store include paths so diagnostics can be suppressed for external files */ + private includePaths: string[] = []; + private workspaceRoot: string = ''; + + setIncludePaths(paths: string[]): void { + this.includePaths = paths.map(p => p.replace(/\\/g, '/').toLowerCase()); + } + + setWorkspaceRoot(root: string): void { + this.workspaceRoot = root.replace(/\\/g, '/').toLowerCase(); + } + + /** Check if a URI belongs to the workspace (not an external include path file) */ + isWorkspaceFile(uri: string): boolean { + if (!this.workspaceRoot) return true; // no workspace root set, allow all + let fsPath: string; + try { + fsPath = url.fileURLToPath(uri).replace(/\\/g, '/').toLowerCase(); + } catch { + fsPath = uri.replace(/\\/g, '/').toLowerCase(); + } + return fsPath.startsWith(this.workspaceRoot); + } + /** * Compute character ranges that should be blanked for #ifdef/#ifndef regions. * Returns an array of { start, end } pairs (character offsets) covering: diff --git a/server/src/index.ts b/server/src/index.ts index 8e920c1..92ec5a1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -50,6 +50,12 @@ connection.onInitialized(async () => { const includePaths = config.includePaths as string[] || []; const preprocessorDefines = config.preprocessorDefines as string[] || []; + // Store include paths on the analyzer so diagnostics can be suppressed + if (includePaths.length > 0) { + Analyzer.instance().setIncludePaths(includePaths); + } + Analyzer.instance().setWorkspaceRoot(workspaceRoot); + // Configure preprocessor defines if (preprocessorDefines.length > 0) { Analyzer.instance().setPreprocessorDefines(preprocessorDefines); diff --git a/server/src/lsp/handlers/diagnostics.ts b/server/src/lsp/handlers/diagnostics.ts index 5e42539..4665c8f 100644 --- a/server/src/lsp/handlers/diagnostics.ts +++ b/server/src/lsp/handlers/diagnostics.ts @@ -12,6 +12,11 @@ export function registerDiagnostics(conn: Connection, docs: TextDocuments) => { + // Only report diagnostics for files inside the workspace folder + if (!analyser.isWorkspaceFile(change.document.uri)) { + conn.sendDiagnostics({ uri: change.document.uri, diagnostics: [] }); + return; + } const diagnostics = analyser.runDiagnostics(change.document); conn.sendDiagnostics({ uri: change.document.uri, diagnostics }); }; From f4ab031f2fef9cc09db74431bdb67d4d307c7ebf Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sun, 8 Feb 2026 22:37:38 -0500 Subject: [PATCH 22/46] Ignore commented params; reduce false positives Parser: detect block-comments inside function parameter lists (DayZ convention for commented-out engine params), count comma-separated segments and inject dummy optional VarDeclNodes so arg counts match engine signatures. Analyzer/graph: add hasTopLevelComparisonOperator to detect top-level comparison/boolean operators and skip spurious type-mismatch diagnostics when an expression evaluates to bool (e.g. a == b or x && y). Strip block comments from call-argument text (preserving lengths) before parsing args, neutralize string contents when locating '//' comments so URLs/strings aren't mis-parsed, and treat trailing commas as terminators in completion heuristics. These changes reduce false positives and improve robustness around commented parameters, comparisons, and inline comments. --- server/src/analysis/ast/parser.ts | 53 +++++++++++++++++ server/src/analysis/project/graph.ts | 88 ++++++++++++++++++++++++++-- 2 files changed, 137 insertions(+), 4 deletions(-) diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index b13c7cd..2a7d132 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -262,7 +262,35 @@ export function parse( /* scan parameter list quickly, ignore default values */ const fastParamScan = (doc: TextDocument): VarDeclNode[] => { const list: VarDeclNode[] = []; + const parenStart = pos; // save position before '(' expect('('); + + // Count commented-out parameters: block comments between ( and ) that + // contain commas indicate hidden engine parameters. This is a DayZ convention: + // void Func(EntityAI item/*, Widget w*/, int x = 0) + // Here "Widget w" is commented out but still exists in the engine. + // We need to count these so callers passing the right number of args aren't flagged. + let commentedParamCount = 0; + { + let scanPos = parenStart + 1; // after '(' + let scanDepth = 1; + while (scanPos < toks.length && scanDepth > 0) { + const tok = toks[scanPos]; + if (tok.value === '(') scanDepth++; + else if (tok.value === ')') { scanDepth--; if (scanDepth === 0) break; } + if (tok.kind === TokenKind.Comment && tok.value.startsWith('/*')) { + // Count commas inside this block comment — each comma = one hidden param boundary + // But we also need to count the param itself if it doesn't end with a comma + // Simple heuristic: if the comment contains what looks like a type+name, count it + const commentContent = tok.value.slice(2, -2).trim(); + // Count the number of comma-separated segments that look like parameters + const segments = commentContent.split(',').map(s => s.trim()).filter(s => s.length > 0); + commentedParamCount += segments.length; + } + scanPos++; + } + } + while (!eof() && peek().value !== ')') { const varDecl = expectVarDecl(doc, true); // Skip any remaining tokens until ')' or ',' (default values are already consumed by parseDecl) @@ -275,6 +303,31 @@ export function parse( } expect(')'); + + // Add dummy parameters for commented-out engine params + for (let ci = 0; ci < commentedParamCount; ci++) { + list.push({ + kind: 'VarDecl', + uri: doc.uri, + name: `__commented_param_${ci}`, + nameStart: { line: 0, character: 0 }, + nameEnd: { line: 0, character: 0 }, + type: { + kind: 'Type', + uri: doc.uri, + identifier: 'void', + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + arrayDims: [], + modifiers: [], + }, + annotations: [], + modifiers: [], + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + hasDefault: true, // Mark as optional so callers can omit + } as VarDeclNode); + } return list; }; diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index e4bcaa8..b3e9c00 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -1268,6 +1268,38 @@ export class Analyzer { return false; } + /** + * Check if text contains a top-level comparison or boolean operator. + * "Top-level" means not inside parentheses or brackets. + * Used to detect expressions like: item.GetType() == receiver_item.GetType() + * where the overall result is bool, even though individual calls return string. + */ + private hasTopLevelComparisonOperator(text: string): boolean { + let depth = 0; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + // Skip over string literals so operators inside strings are ignored + if (ch === '"' || ch === "'") { + const q = ch; + i++; + while (i < text.length && text[i] !== q) { + if (text[i] === '\\') i++; + i++; + } + continue; + } + if (ch === '(' || ch === '[') { depth++; continue; } + if (ch === ')' || ch === ']') { if (depth > 0) depth--; continue; } + if (depth > 0) continue; + // Check for two-character operators first + const next = i + 1 < text.length ? text[i + 1] : ''; + if ((ch === '=' || ch === '!' || ch === '<' || ch === '>') && next === '=') return true; + if (ch === '&' && next === '&') return true; + if (ch === '|' && next === '|') return true; + } + return false; + } + /** * Find the TypedefNode for a given type name. * Returns null if the type is not a typedef. @@ -3275,6 +3307,15 @@ export class Analyzer { } if (returnType) { + // Skip if the full RHS contains a comparison/boolean operator and declared type is bool + // e.g., bool equal_typed = item.GetType() == receiver_item.GetType(); + if (declaredType === 'bool') { + const stmtEnd1 = afterMatch.indexOf(';'); + const fullRhs = funcName + '(' + afterMatch.substring(0, stmtEnd1 >= 0 ? stmtEnd1 : afterMatch.length); + if (this.hasTopLevelComparisonOperator(fullRhs)) { + continue; + } + } this.addTypeMismatchDiagnostic(doc, diags, match.index, highlightLength, declaredType, returnType); } } @@ -3502,6 +3543,14 @@ export class Analyzer { if (targetType === returnType) { continue; } + // Skip if the full RHS contains a comparison/boolean operator and target type is bool + if (targetType === 'bool') { + const stmtEnd4 = afterMatch.indexOf(';'); + const fullRhs4 = funcName + '(' + afterMatch.substring(0, stmtEnd4 >= 0 ? stmtEnd4 : afterMatch.length); + if (this.hasTopLevelComparisonOperator(fullRhs4)) { + continue; + } + } // Calculate actual start position (skip the leading delimiter and whitespace) const actualStart = match.index + 1 + leadingWhitespace.length; const actualLength = match[0].length - 1 - leadingWhitespace.length + chainEnd; @@ -3550,6 +3599,13 @@ export class Analyzer { // Calculate highlight: from match start to end of chain (before semicolon) const highlightLength = match[0].length + (stmtEnd >= 0 ? stmtEnd : afterDot.length); + // Skip if the full RHS contains a comparison/boolean operator and declared type is bool + if (declaredType === 'bool') { + const fullRhs5 = chainText; // chainText is everything from '.' to ';' + if (this.hasTopLevelComparisonOperator(fullRhs5)) { + continue; + } + } this.addTypeMismatchDiagnostic(doc, diags, match.index, highlightLength, declaredType, returnType); } @@ -3597,6 +3653,13 @@ export class Analyzer { if (/^[A-Z]$/.test(targetType) || /^[A-Z]$/.test(returnType)) continue; if (targetType === returnType) continue; + // Skip if the full RHS contains a comparison/boolean operator and target type is bool + if (targetType === 'bool') { + if (this.hasTopLevelComparisonOperator(chainText)) { + continue; + } + } + const actualStart = match.index + 1 + leadingWs.length; const actualLength = match[0].length - 1 - leadingWs.length + (stmtEnd >= 0 ? stmtEnd : afterDot.length); this.addTypeMismatchDiagnostic(doc, diags, actualStart, actualLength, targetType, returnType); @@ -3739,6 +3802,11 @@ export class Analyzer { const arg = argText.trim(); if (!arg) return null; + // If the argument contains a top-level comparison/boolean operator, + // the expression evaluates to bool regardless of operand types. + // e.g., pm.CreateParticleByPath(path, pp) == null → bool + if (this.hasTopLevelComparisonOperator(arg)) return 'bool'; + // String literal if (arg.startsWith('"') || arg.startsWith("'")) return 'string'; @@ -4101,7 +4169,12 @@ export class Analyzer { } if (depth !== 0) continue; // Unbalanced parens - const argsText = text.substring(argsStart, pos - 1).trim(); + // Strip /* ... */ block comments from args text — DayZ convention uses + // these to comment out parameters that still exist in the engine. + // e.g., Func(item/*, widget*/, x) → Func(item , x) + // Replace with spaces to preserve character positions for line counting. + const rawArgsText = text.substring(argsStart, pos - 1).trim(); + const argsText = rawArgsText.replace(/\/\*[\s\S]*?\*\//g, m => ' '.repeat(m.length)).trim(); const argStrings = argsText ? this.parseCallArguments(argsText) : []; const argCount = argStrings.length; @@ -4502,7 +4575,13 @@ export class Analyzer { // Strip inline comments before checking operators/terminators // "x = 1.0; // 100%" → "x = 1.0;" (otherwise % looks like a binary op) - const codePart = line.replace(/\/\/.*$/, '').trimEnd(); + // Neutralize string contents (replace chars with spaces) so "//" inside strings + // isn't treated as a comment. Must preserve length so indices stay aligned. + // e.g., GetGame().OpenURL("https://example.com"); — the :// is NOT a comment + const neutralized = line.replace(/"(?:[^"\\]|\\.)*"/g, m => '"' + ' '.repeat(m.length - 2) + '"') + .replace(/'(?:[^'\\]|\\.)*'/g, m => "'" + ' '.repeat(m.length - 2) + "'"); + const commentIdx = neutralized.indexOf('//'); + const codePart = (commentIdx >= 0 ? line.substring(0, commentIdx) : line).trimEnd(); // Skip lines that are only a comment (nothing left after stripping) if (!codePart) continue; @@ -4514,8 +4593,9 @@ export class Analyzer { const closeParens = (codePart.match(/\)/g) || []).length; const unclosedParens = openParens > closeParens; - // Skip lines ending with { or ; or } - those are complete - const endsWithTerminator = /[{};]\s*$/.test(codePart); + // Skip lines ending with { or ; or } or , - those are complete + // Comma handles enum values, array initializers, etc. + const endsWithTerminator = /[{};,]\s*$/.test(codePart); // Skip declaration starts (class, if, for, etc.) const isDeclarationStart = /^(class|modded|enum|struct|typedef|if|else|for|while|switch|foreach)\b/.test(codePart); From 39782b4208a85074c871048f4b3440bc9c95bdac Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Mon, 9 Feb 2026 18:40:08 -0500 Subject: [PATCH 23/46] Support implicit constructors via constructor index Add a constructorIndex to track which class names have explicit constructor methods and populate it when indexing ClassDecl members. Use this index to resolve constructor return types in resolveFunctionReturnTypeNode and resolveMethodReturnTypeNode by returning a TypeNode for the class. Also treat classes/typedefs with no explicit constructor as implicit constructors in resolveFunctionReturnType and fallback resolution for call chains. Remove constructorIndex entries when classes are removed. Minor fixes: correct declaration-preceding-text calculation (use full match) and skip unknown-function warnings when the name is a known class/typedef (constructor call). --- server/src/analysis/project/graph.ts | 60 +++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index b3e9c00..065b6eb 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -254,6 +254,9 @@ export class Analyzer { /** Typedef index: name -> TypedefNode */ private typedefIndex: Map = new Map(); + /** Constructor index: className -> className (tracks which class names have constructors) */ + private constructorIndex: Map = new Map(); + /** Update all indexes from a file's AST */ private updateAllIndexes(uri: string, ast: File): void { // Remove old entries from this URI @@ -265,13 +268,21 @@ export class Analyzer { if (node.kind === 'ClassDecl') { const classNode = node as ClassDeclNode; - (classNode as any)._sourceUri = uri; // Tag with source URI for removal + (classNode as any)._sourceUri = uri; let existing = this.classIndex.get(node.name); if (!existing) { existing = []; this.classIndex.set(node.name, existing); } existing.push(classNode); + + // Track constructors: any member function whose name matches the class name + for (const member of classNode.members || []) { + if (member.kind === 'FunctionDecl' && member.name === node.name) { + this.constructorIndex.set(node.name, node.name); + break; + } + } } else if (node.kind === 'EnumDecl') { (node as any)._sourceUri = uri; this.enumIndex.set(node.name, node as EnumDeclNode); @@ -326,6 +337,14 @@ export class Analyzer { this.typedefIndex.delete(name); } } + + // Remove constructor index entries for classes that were removed + // Re-check: if classIndex no longer has the name, remove from constructorIndex + for (const [name] of this.constructorIndex) { + if (!this.classIndex.has(name)) { + this.constructorIndex.delete(name); + } + } } /** Rebuild sorted arrays from the symbol index */ @@ -1032,7 +1051,12 @@ export class Analyzer { * Searches top-level functions and class methods across all indexed files */ private resolveFunctionReturnType(funcName: string): string | null { - return this.resolveFunctionReturnTypeNode(funcName)?.identifier ?? null; + const result = this.resolveFunctionReturnTypeNode(funcName)?.identifier ?? null; + // Implicit constructors: class/typedef exists but has no explicit constructor declaration + if (!result && (this.classIndex.has(funcName) || this.typedefIndex.has(funcName))) { + return funcName; + } + return result; } /** @@ -1041,6 +1065,13 @@ export class Analyzer { */ private resolveFunctionReturnTypeNode(funcName: string): TypeNode | null { + // Check if this is a constructor call via the pre-built index (O(1)) + const ctorClass = this.constructorIndex.get(funcName); + if (ctorClass) { + // Constructors are declared void but return an instance of the class + return { identifier: ctorClass, arrayDims: [], modifiers: [], kind: 'Type', uri: '', start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } as TypeNode; + } + // Check top-level functions via function index (O(1)) const funcs = this.functionIndex.get(funcName); if (funcs && funcs.length > 0) { @@ -1151,6 +1182,12 @@ export class Analyzer { * Includes genericArgs for template types like map. */ private resolveMethodReturnTypeNode(className: string, methodName: string): TypeNode | null { + // Constructor call: if looking up a method whose name matches a known constructor + const ctorClass = this.constructorIndex.get(methodName); + if (ctorClass) { + return { identifier: ctorClass, arrayDims: [], modifiers: [], kind: 'Type', uri: '', start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } as TypeNode; + } + // Resolve typedefs first (e.g., testMapType → map) const resolvedClass = this.resolveTypedef(className); const visited = new Set(); @@ -1479,7 +1516,14 @@ export class Analyzer { // Resolve the first function's return type const firstTypeNode = this.resolveFunctionReturnTypeNode(firstFunc); - if (!firstTypeNode?.identifier) return null; + if (!firstTypeNode?.identifier) { + // No return type found — check if it's an implicit constructor (class/typedef with no declaration) + if (this.classIndex.has(firstFunc) || this.typedefIndex.has(firstFunc)) { + if (calls.length === 0) return firstFunc; + return this.resolveVariableChainType(firstFunc, calls.join('.')); + } + return null; + } let currentType = firstTypeNode.identifier; @@ -4139,9 +4183,9 @@ export class Analyzer { if (declCheck) { // Could be a declaration. Check more carefully — if the next non-whitespace // before the type name is '{', ';', or start-of-line, it's a declaration - const typeName = declCheck[0].trim(); + const fullMatch = declCheck[0]; // includes trailing whitespace // Skip if it looks like a declaration context (not preceded by = or , or ( ) - const preDeclText = text.substring(Math.max(0, match.index - 80), match.index - typeName.length).trimEnd(); + const preDeclText = text.substring(Math.max(0, match.index - 80), match.index - fullMatch.length).trimEnd(); const lastChar = preDeclText[preDeclText.length - 1]; if (!lastChar || lastChar === '{' || lastChar === '}' || lastChar === ';' || lastChar === ')' || lastChar === '\n') { continue; // It's a declaration, skip @@ -4400,6 +4444,12 @@ export class Analyzer { } if (overloads.length === 0) { + // Skip if the function name is a known class or typedef — it's a constructor call + // e.g., TStringArray() where TStringArray is typedef array + if (this.classIndex.has(funcName) || this.typedefIndex.has(funcName)) { + continue; + } + // Warn about unknown functions only when the index is large enough to be confident if (this.docCache.size >= 500) { // Skip warning for chain calls where we couldn't resolve the target type — From d46ae15941d79f7bafed849e75b73b02ada5dab9 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Wed, 11 Feb 2026 21:36:42 -0500 Subject: [PATCH 24/46] Track and validate function return statements Add return-statement tracking to the parser and validate returns in the analyzer. Introduce ReturnStatementInfo and new FunctionDeclNode fields (returnStatements, hasBody) and update the parser to collect return locations and expression ranges. In the analyzer, add checkReturnStatements plus helpers (resolveReturnExpressionType, hasTopLevelBinaryOperator) to detect missing returns, void-return violations, type mismatches and downcast warnings. Also refactor method/constructor resolution and override checks: prefer class members before constructors, store parent method overloads as FunctionDeclNode lists, add override-signature mismatch checking, and tighten static-class call detection for chained expressions. Add unit tests for return-statement detection and update a small chain-resolution fix (prefixed '.' for variable-chain resolution). --- server/src/analysis/ast/parser.ts | 57 ++++ server/src/analysis/project/graph.ts | 476 +++++++++++++++++++++++++-- test/parser.test.ts | 114 +++++++ 3 files changed, 628 insertions(+), 19 deletions(-) diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index 2a7d132..aca0416 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -154,11 +154,22 @@ export interface VarDeclNode extends SymbolNodeBase { scopeEnd?: Position; // End of the brace-scope where this local is visible (set for function-body locals) } +/** Info about a return statement found inside a function body */ +export interface ReturnStatementInfo { + start: Position; // Position of the 'return' keyword + end: Position; // Position after the ';' + isEmpty: boolean; // true for bare 'return;' (no expression) + exprStart: number; // Character offset of expression start (after 'return ') + exprEnd: number; // Character offset of expression end (before ';') +} + export interface FunctionDeclNode extends SymbolNodeBase { kind: 'FunctionDecl'; parameters: VarDeclNode[]; returnType: TypeNode; locals: VarDeclNode[]; + returnStatements: ReturnStatementInfo[]; // All return statements found in the body + hasBody: boolean; // true if function has a { } body (not proto/native) } export interface File { @@ -561,7 +572,10 @@ export function parse( // if (condition) x = 1; else x = 0; // ==================================================================== const locals: VarDeclNode[] = []; + const returnStatements: ReturnStatementInfo[] = []; + let hasBody = false; if (peek().value === '{') { + hasBody = true; next(); let depth = 1; // Scope tracking: each entry holds locals declared in that brace scope. @@ -617,6 +631,47 @@ export function parse( } } + // ================================================================ + // RETURN STATEMENT DETECTION + // ================================================================ + // Detect 'return' keyword and capture the expression that follows. + // Tracks the char offset range [exprStart..exprEnd) so the + // diagnostics engine can resolve the returned expression's type. + // A bare 'return;' is flagged as isEmpty for void-return checks. + // ================================================================ + if (t.kind === TokenKind.Keyword && t.value === 'return' && depth > 0) { + const retStart = doc.positionAt(t.start); + // Scan forward to the ';' to capture the expression range + const exprStartOffset = t.end; // right after the 'return' token + let exprEndOffset = exprStartOffset; + let semiOffset = exprStartOffset; + let scanIdx = pos; + while (scanIdx < toks.length) { + const scanTok = toks[scanIdx]; + if (scanTok.value === ';') { + semiOffset = scanTok.end; + exprEndOffset = scanTok.start; + break; + } + // Stop at block boundaries to be safe + if (scanTok.value === '{' || scanTok.value === '}') { + exprEndOffset = scanTok.start; + semiOffset = scanTok.start; + break; + } + scanIdx++; + } + const isEmpty = exprEndOffset <= exprStartOffset || + doc.getText().substring(exprStartOffset, exprEndOffset).trim().length === 0; + returnStatements.push({ + start: retStart, + end: doc.positionAt(semiOffset), + isEmpty, + exprStart: exprStartOffset, + exprEnd: exprEndOffset, + }); + } + // Detect local variable declarations: // TypeName varName ; or TypeName varName = or TypeName varName , // TypeName varName : (foreach variable: foreach (Type var : collection)) @@ -711,6 +766,8 @@ export function parse( returnType: baseTypeNode, parameters: params, locals: locals, + returnStatements: returnStatements, + hasBody: hasBody, annotations: annotations, modifiers: mods, start: baseTypeNode.start, diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 065b6eb..cb5788e 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -38,7 +38,7 @@ import { TextDocument } from 'vscode-languageserver-textdocument'; import { Position, Range, Location, SymbolInformation, SymbolKind, Diagnostic, DiagnosticSeverity } from 'vscode-languageserver'; -import { parse, ParseError, ClassDeclNode, File, SymbolNodeBase, FunctionDeclNode, VarDeclNode, TypedefNode, toSymbolKind, EnumDeclNode, EnumMemberDeclNode, TypeNode } from '../ast/parser'; +import { parse, ParseError, ClassDeclNode, File, SymbolNodeBase, FunctionDeclNode, VarDeclNode, TypedefNode, toSymbolKind, EnumDeclNode, EnumMemberDeclNode, TypeNode, ReturnStatementInfo } from '../ast/parser'; import { prettyPrint } from '../ast/printer'; import { lex } from '../lexer/lexer'; import { Token, TokenKind } from '../lexer/token'; @@ -1182,17 +1182,15 @@ export class Analyzer { * Includes genericArgs for template types like map. */ private resolveMethodReturnTypeNode(className: string, methodName: string): TypeNode | null { - // Constructor call: if looking up a method whose name matches a known constructor - const ctorClass = this.constructorIndex.get(methodName); - if (ctorClass) { - return { identifier: ctorClass, arrayDims: [], modifiers: [], kind: 'Type', uri: '', start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } as TypeNode; - } - // Resolve typedefs first (e.g., testMapType → map) const resolvedClass = this.resolveTypedef(className); const visited = new Set(); const classesToSearch = this.getClassHierarchyOrdered(resolvedClass, visited); + // Search class members FIRST — a field or method on the target class takes + // priority over a global constructor with the same name. This prevents + // e.g. `marker.Icon` (a string field named "Icon") from being resolved as + // the constructor of a class called "Icon". for (const classNode of classesToSearch) { for (const member of classNode.members || []) { if (member.kind === 'FunctionDecl' && member.name === methodName) { @@ -1211,6 +1209,14 @@ export class Analyzer { } } + // Fallback: constructor call — if no member was found on the class, check + // if the name matches a known constructor (e.g. chaining into a constructor + // call like `obj.SomeType()` where SomeType is a class). + const ctorClass = this.constructorIndex.get(methodName); + if (ctorClass) { + return { identifier: ctorClass, arrayDims: [], modifiers: [], kind: 'Type', uri: '', start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } as TypeNode; + } + return null; } @@ -1520,7 +1526,7 @@ export class Analyzer { // No return type found — check if it's an implicit constructor (class/typedef with no declaration) if (this.classIndex.has(firstFunc) || this.typedefIndex.has(firstFunc)) { if (calls.length === 0) return firstFunc; - return this.resolveVariableChainType(firstFunc, calls.join('.')); + return this.resolveVariableChainType(firstFunc, '.' + calls.join('.')); } return null; } @@ -2733,6 +2739,10 @@ export class Analyzer { // Check function call arguments (param count and types) this.checkFunctionCallArgs(doc, diags, text, lines, lineOffsets, ast, scopedVars); + + // Check return statements: missing returns in non-void functions + // and return type mismatches (including downcast warnings) + this.checkReturnStatements(doc, diags, text, lineOffsets, ast, scopedVars); } // Check for multi-line statements (not supported in Enforce Script) @@ -2748,6 +2758,320 @@ export class Analyzer { return diags; } + // ==================================================================== + // RETURN STATEMENT VALIDATION + // ==================================================================== + // Checks: + // 1. Non-void functions with bodies must have at least one return statement + // 2. Return expressions must be type-compatible with the declared return type + // 3. Downcast warnings for return expressions (e.g., returning a parent type + // where a child type is expected) + // 4. Void functions should not return a value + // ==================================================================== + + /** + * Check return statements in all functions (top-level and class methods). + * + * Uses the parser's ReturnStatementInfo to: + * - Detect missing return statements in non-void functions + * - Validate return expression types against declared return types + * - Issue downcast warnings for implicit narrowing conversions + */ + private checkReturnStatements( + doc: TextDocument, + diags: Diagnostic[], + text: string, + lineOffsets: number[], + ast: File, + scopedVars: Map + ): void { + // Helper to resolve the type of a variable at a specific line + const getVarTypeAtLine = (name: string, line: number): string | undefined => { + const vars = scopedVars.get(name); + if (!vars) return undefined; + let bestMatch: { type: string; startLine: number; endLine: number; isClassField: boolean } | undefined; + for (const v of vars) { + if (line < v.startLine || line > v.endLine) continue; + if (v.isClassField) { + if (!bestMatch || (bestMatch.isClassField && + (v.endLine - v.startLine) < (bestMatch.endLine - bestMatch.startLine))) { + bestMatch = v; + } + } else { + if (!bestMatch || bestMatch.isClassField || + (v.endLine - v.startLine) < (bestMatch.endLine - bestMatch.startLine)) { + bestMatch = v; + } + } + } + return bestMatch?.type; + }; + + // Skip constructors, destructors, proto/native functions + const shouldCheckFunction = (func: FunctionDeclNode, className?: string): boolean => { + // Must have a body + if (!func.hasBody) return false; + // Skip proto/native (no body to check) + if (func.modifiers.includes('proto') || func.modifiers.includes('native')) return false; + // Skip constructors (name matches class name) + if (className && func.name === className) return false; + // Skip destructors (name starts with ~) + if (func.name.startsWith('~')) return false; + return true; + }; + + // Check a single function's return statements + const checkFunction = (func: FunctionDeclNode, className?: string): void => { + if (!shouldCheckFunction(func, className)) return; + + const returnType = func.returnType?.identifier ?? 'void'; + const isVoid = returnType === 'void'; + const returns = func.returnStatements || []; + + // 1. Non-void functions must have at least one return statement + if (!isVoid && returns.length === 0) { + diags.push({ + message: `Function '${func.name}' has return type '${returnType}' but has no return statement.`, + range: { start: func.nameStart, end: func.nameEnd }, + severity: DiagnosticSeverity.Warning + }); + return; // No returns to type-check + } + + // 2. Validate each return statement + for (const ret of returns) { + if (isVoid) { + // Void function returning a value + if (!ret.isEmpty) { + diags.push({ + message: `Function '${func.name}' has return type 'void' but returns a value.`, + range: { start: ret.start, end: ret.end }, + severity: DiagnosticSeverity.Error + }); + } + continue; + } + + // Non-void function with bare 'return;' + if (ret.isEmpty) { + diags.push({ + message: `Function '${func.name}' has return type '${returnType}' but returns nothing. Expected a value of type '${returnType}'.`, + range: { start: ret.start, end: ret.end }, + severity: DiagnosticSeverity.Error + }); + continue; + } + + // Try to resolve the type of the return expression + const exprText = text.substring(ret.exprStart, ret.exprEnd).trim(); + if (!exprText) continue; + + const resolvedType = this.resolveReturnExpressionType( + exprText, doc, ast, ret.start.line, lineOffsets, getVarTypeAtLine, className + ); + + if (!resolvedType) continue; // Can't resolve — skip + + // 3. Check type compatibility (errors and downcast warnings) + const compat = this.checkTypeCompatibility(returnType, resolvedType); + + if (!compat.compatible) { + diags.push({ + message: compat.message || `Return type mismatch in '${func.name}': cannot return '${resolvedType}' as '${returnType}'.`, + range: { start: ret.start, end: ret.end }, + severity: DiagnosticSeverity.Error + }); + } else if (compat.isDowncast) { + diags.push({ + message: compat.message || `Unsafe downcast in return of '${func.name}': returning '${resolvedType}' as '${returnType}'. Use '${returnType}.Cast(value)' or 'Class.CastTo(target, value)' instead.`, + range: { start: ret.start, end: ret.end }, + severity: DiagnosticSeverity.Warning + }); + } + } + }; + + // Process all top-level functions + for (const node of ast.body) { + if (node.kind === 'FunctionDecl') { + checkFunction(node as FunctionDeclNode); + } + // Process class methods + if (node.kind === 'ClassDecl') { + const cls = node as ClassDeclNode; + for (const member of cls.members || []) { + if (member.kind === 'FunctionDecl') { + checkFunction(member as FunctionDeclNode, cls.name); + } + } + } + } + } + + /** + * Resolve the type of a return expression. + * + * Handles common patterns: + * - Literals: null, true, false, integers, floats, strings + * - Variable references: return varName; + * - Function calls: return FuncCall(); + * - Method chains: return obj.Method().Prop; + * - Constructor calls: return new ClassName(); + * - Cast expressions: return ClassName.Cast(expr); + * - Enum values: return EnumName.VALUE; + * + * @returns The resolved type name, or null if unresolvable + */ + private resolveReturnExpressionType( + expr: string, + doc: TextDocument, + ast: File, + line: number, + lineOffsets: number[], + getVarTypeAtLine: (name: string, line: number) => string | undefined, + className?: string + ): string | null { + // Strip outer parentheses: return (expr) → expr + let trimmed = expr.trim(); + while (trimmed.startsWith('(') && trimmed.endsWith(')')) { + // Make sure parens are balanced (not just matching start/end) + let depth = 0; + let balanced = true; + for (let i = 0; i < trimmed.length - 1; i++) { + if (trimmed[i] === '(') depth++; + else if (trimmed[i] === ')') depth--; + if (depth === 0) { balanced = false; break; } + } + if (!balanced) break; + trimmed = trimmed.substring(1, trimmed.length - 1).trim(); + } + + // Skip expressions with top-level binary operators (&&, ||, +, -, *, /, %, ==, !=, <, >, etc.) + // These produce results that are hard to type-check without full expression typing + if (this.hasTopLevelBinaryOperator(trimmed)) { + // But if the expression contains comparison/logical operators, the result is bool + if (this.hasTopLevelComparisonOperator(trimmed)) { + return 'bool'; + } + return null; // Too complex to resolve + } + + // --- Literal patterns --- + if (trimmed === 'null' || trimmed === 'NULL') return 'null'; + if (trimmed === 'true' || trimmed === 'false') return 'bool'; + if (/^-?\d+$/.test(trimmed)) return 'int'; + if (/^-?\d+\.\d*f?$/.test(trimmed) || /^-?\.\d+f?$/.test(trimmed)) return 'float'; + if (/^".*"$/.test(trimmed)) return 'string'; + // Vector literal: "x y z" is handled by string → vector compat in checkTypeCompatibility + + // --- 'new ClassName(...)' --- + const newMatch = trimmed.match(/^new\s+(\w+)\s*\(/); + if (newMatch) return newMatch[1]; + + // --- Cast: ClassName.Cast(expr) --- + const castMatch = trimmed.match(/^(\w+)\s*\.\s*Cast\s*\(/); + if (castMatch) return castMatch[1]; + + // --- Class.CastTo pattern: Class.CastTo(target, source) returns bool --- + if (/^Class\s*\.\s*CastTo\s*\(/.test(trimmed)) return 'bool'; + + // --- Enum value: EnumName.VALUE --- + const enumDotMatch = trimmed.match(/^(\w+)\s*\.\s*(\w+)$/); + if (enumDotMatch) { + const potentialEnum = enumDotMatch[1]; + if (this.enumIndex.has(potentialEnum)) { + return potentialEnum; + } + // Could also be a static field access — fall through + } + + // --- Function call (possibly chained): FuncName(...) or FuncName(...).Method(...) --- + const funcCallMatch = trimmed.match(/^(\w+)\s*\(/); + if (funcCallMatch) { + const funcName = funcCallMatch[1]; + // Check for chaining (. after the closing paren) + const afterFuncName = trimmed.substring(funcCallMatch[0].length); + let parenDepth = 1, ci = 0; + while (ci < afterFuncName.length && parenDepth > 0) { + if (afterFuncName[ci] === '(') parenDepth++; + else if (afterFuncName[ci] === ')') parenDepth--; + ci++; + } + const afterCall = afterFuncName.substring(ci).trim(); + + if (afterCall.startsWith('.')) { + // Method chain — delegate to resolveChainReturnType + return this.resolveChainReturnType(trimmed); + } + // Single function call — resolve as method of containing class first + if (className) { + const methodType = this.resolveMethodReturnType(className, funcName); + if (methodType) return methodType; + } + return this.resolveFunctionReturnType(funcName); + } + + // --- Variable.method() or variable.property chain --- + const varChainMatch = trimmed.match(/^(\w+)\s*\.\s*(.+)$/); + if (varChainMatch) { + const rootName = varChainMatch[1]; + const chainText = '.' + varChainMatch[2]; + // Resolve the variable's type + let rootType = getVarTypeAtLine(rootName, line); + if (!rootType && rootName === 'this' && className) { + rootType = className; + } + if (rootType) { + const resolved = this.resolveVariableChainType(rootType, chainText); + if (resolved) return resolved; + } + } + + // --- Simple variable reference --- + const simpleVarMatch = trimmed.match(/^(\w+)$/); + if (simpleVarMatch) { + const varName = simpleVarMatch[1]; + // 'this' resolves to the containing class type + if (varName === 'this' && className) return className; + // Check local/param/field/global variable + const varType = getVarTypeAtLine(varName, line); + if (varType) return varType; + // Check if it's an enum value or class name used as type + if (this.enumIndex.has(varName)) return varName; + if (this.classIndex.has(varName)) return varName; + // Could be a global function name used as a value (unlikely but possible) + return null; + } + + return null; // Unresolvable expression + } + + /** + * Check if an expression has a top-level binary operator (outside of balanced parens/brackets). + * Used to skip complex expressions in return type resolution. + */ + private hasTopLevelBinaryOperator(expr: string): boolean { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const ch = expr[i]; + if (ch === '(' || ch === '[') depth++; + else if (ch === ')' || ch === ']') depth--; + if (depth === 0) { + // Check for binary operators (but not unary minus or method chains) + const rest = expr.substring(i); + if (/^[\+\-\*\/%](?!=)/.test(rest) && i > 0 && /\w/.test(expr[i - 1])) return true; + if (/^&&|^\|\|/.test(rest)) return true; + if (/^==|^!=|^<=|^>=/.test(rest)) return true; + // Be careful with < and > — they could be generics + if ((ch === '<' || ch === '>') && i > 0 && /\w/.test(expr[i - 1])) { + // If followed by another < or >, or if followed by a type name and comma, it's generic + // Simple heuristic: skip + } + } + } + return false; + } + /** * Check for duplicate variable declarations within the same scope (AST-based). * @@ -2860,7 +3184,7 @@ export class Analyzer { // ── Collect inherited fields & parent method signatures ──────────── const inheritedFields = new Map(); - const parentMethodSigs = new Map(); + const parentMethods = new Map(); if (cls.base?.identifier) { const hierarchy = this.getClassHierarchyOrdered(cls.base.identifier, new Set()); @@ -2872,11 +3196,10 @@ export class Analyzer { } } if (member.kind === 'FunctionDecl' && member.name) { - const paramCount = (member as FunctionDeclNode).parameters?.length ?? 0; - if (!parentMethodSigs.has(member.name)) { - parentMethodSigs.set(member.name, []); + if (!parentMethods.has(member.name)) { + parentMethods.set(member.name, []); } - parentMethodSigs.get(member.name)!.push(paramCount); + parentMethods.get(member.name)!.push(member as FunctionDeclNode); } } } @@ -2901,12 +3224,13 @@ export class Analyzer { const func = member as FunctionDeclNode; // Missing override check - if (parentMethodSigs.size > 0 && func.name && func.name !== cls.name) { + if (parentMethods.size > 0 && func.name && func.name !== cls.name) { const hasOverride = func.modifiers.includes('override'); - if (!hasOverride && parentMethodSigs.has(func.name)) { + const parentOverloads = parentMethods.get(func.name); + if (!hasOverride && parentOverloads) { const childParamCount = func.parameters?.length ?? 0; - const parentParamCounts = parentMethodSigs.get(func.name)!; - if (parentParamCounts.includes(childParamCount)) { + const hasMatchingParamCount = parentOverloads.some(p => (p.parameters?.length ?? 0) === childParamCount); + if (hasMatchingParamCount) { diags.push({ message: `Method '${func.name}' overrides a method from a parent class but is missing the 'override' keyword.`, range: { start: func.nameStart, end: func.nameEnd }, @@ -2914,6 +3238,21 @@ export class Analyzer { }); } } + + // Override signature mismatch check + // When 'override' is present, validate the signature exactly matches + // at least one parent overload (return type, param types, names, + // modifiers like out/inout, and default presence). + if (hasOverride && parentOverloads) { + const mismatch = this.checkOverrideSignatureMismatch(func, parentOverloads); + if (mismatch) { + diags.push({ + message: mismatch, + range: { start: func.nameStart, end: func.nameEnd }, + severity: DiagnosticSeverity.Warning + }); + } + } } // Duplicate locals check (skip proto/native) @@ -2932,6 +3271,98 @@ export class Analyzer { } } + /** + * Check whether an override method's signature exactly matches at least one + * parent overload. Returns a human-readable mismatch message, or null if + * a matching overload was found. + * + * Checks: return type, parameter count, and for each parameter: type, + * name, modifiers (out/inout/notnull), and default presence. + */ + private checkOverrideSignatureMismatch( + child: FunctionDeclNode, + parentOverloads: FunctionDeclNode[] + ): string | null { + // Helper: get the relevant modifiers for a parameter (out, inout, notnull) + const paramMods = (p: VarDeclNode): string[] => + (p.modifiers || []).filter(m => m === 'out' || m === 'inout' || m === 'notnull'); + + // Helper: compare two TypeNode identifiers (case-sensitive) + const typeEq = (a: TypeNode | undefined, b: TypeNode | undefined): boolean => { + if (!a && !b) return true; + if (!a || !b) return false; + return a.identifier === b.identifier; + }; + + // Try every parent overload — if ANY matches exactly, the override is valid + let closestMismatch: string | null = null; + + for (const parent of parentOverloads) { + const childParams = child.parameters || []; + const parentParams = parent.parameters || []; + + // --- Return type --- + if (!typeEq(child.returnType, parent.returnType)) { + const childRet = child.returnType?.identifier ?? 'void'; + const parentRet = parent.returnType?.identifier ?? 'void'; + closestMismatch = `Override '${child.name}' return type '${childRet}' does not match parent return type '${parentRet}'.`; + continue; + } + + // --- Parameter count --- + if (childParams.length !== parentParams.length) { + closestMismatch = `Override '${child.name}' has ${childParams.length} parameter(s) but parent has ${parentParams.length}.`; + continue; + } + + // --- Per-parameter comparison --- + let paramMismatch: string | null = null; + for (let i = 0; i < childParams.length; i++) { + const cp = childParams[i]; + const pp = parentParams[i]; + + // Type check + if (!typeEq(cp.type, pp.type)) { + paramMismatch = `Override '${child.name}' parameter ${i + 1} type '${cp.type?.identifier ?? '?'}' does not match parent type '${pp.type?.identifier ?? '?'}'.`; + break; + } + + // Name check + if (cp.name !== pp.name) { + paramMismatch = `Override '${child.name}' parameter ${i + 1} name '${cp.name}' does not match parent name '${pp.name}'.`; + break; + } + + // Modifier check (out, inout, notnull) + const cMods = paramMods(cp).sort(); + const pMods = paramMods(pp).sort(); + if (cMods.length !== pMods.length || cMods.some((m, j) => m !== pMods[j])) { + paramMismatch = `Override '${child.name}' parameter ${i + 1} '${cp.name}' modifiers [${cMods.join(', ')}] do not match parent modifiers [${pMods.join(', ')}].`; + break; + } + + // Default presence check + if (!!cp.hasDefault !== !!pp.hasDefault) { + const childHas = cp.hasDefault ? 'has' : 'missing'; + const parentHas = pp.hasDefault ? 'has' : 'missing'; + paramMismatch = `Override '${child.name}' parameter ${i + 1} '${cp.name}' default value mismatch: override ${childHas} default, parent ${parentHas} default.`; + break; + } + } + + if (paramMismatch) { + closestMismatch = paramMismatch; + continue; + } + + // All checks passed — this overload matches exactly + return null; + } + + // No overload matched; return the mismatch from the closest one + return closestMismatch; + } + /** * Check for duplicate locals/params within a single function, also * checking against class fields, inherited fields, and globals. @@ -4244,7 +4675,13 @@ export class Analyzer { // Check if objName is a class name FIRST (for static access like ClassName.StaticMethod) // This must come before getVarTypeAtLine because regex fallbacks can // produce false positives (e.g., matching "new InventoryLocation;" as type "new") - if (objName[0] === objName[0].toUpperCase() && this.classIndex.has(objName)) { + // BUT: only treat it as static access if objName is NOT part of a larger + // chain (i.e., not preceded by a dot or closing paren). For chains like + // GetBasicMapConfig().Icons.Find(), "Icons" is a member access, not a + // static class reference, even if a class named "Icons" exists. + const beforeObjName = textBeforeFunc.substring(0, dotMatch.index).trimEnd(); + const isPartOfChain = beforeObjName.length > 0 && (beforeObjName[beforeObjName.length - 1] === '.' || beforeObjName[beforeObjName.length - 1] === ')'); + if (!isPartOfChain && objName[0] === objName[0].toUpperCase() && this.classIndex.has(objName)) { overloads = this.findFunctionOverloads(funcName, objName); } @@ -4343,7 +4780,8 @@ export class Analyzer { } } // Fall back to static class call if chain didn't resolve - if (overloads.length === 0 && objName[0] === objName[0].toUpperCase()) { + // Only for true static access, not when objName is part of a chain + if (overloads.length === 0 && !isPartOfChain && objName[0] === objName[0].toUpperCase()) { overloads = this.findFunctionOverloads(funcName, objName); } } diff --git a/test/parser.test.ts b/test/parser.test.ts index 7bd6fe5..8ff67a5 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -274,6 +274,120 @@ test('no false positive for single-line strings', () => { expect(mlDiag).toBeUndefined(); }); +// ── Return statement detection tests ────────────────────────────────────── + +test('detects return statements in function bodies', () => { + const code = `class Foo { + int GetValue() { + return 42; + } +};`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const cls = ast.body[0] as any; + const func = cls.members[0] as any; + expect(func.hasBody).toBe(true); + expect(func.returnStatements.length).toBe(1); + expect(func.returnStatements[0].isEmpty).toBe(false); +}); + +test('detects bare return in void function', () => { + const code = `class Foo { + void DoStuff() { + if (true) + return; + Print("hello"); + } +};`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const cls = ast.body[0] as any; + const func = cls.members[0] as any; + expect(func.hasBody).toBe(true); + expect(func.returnStatements.length).toBe(1); + expect(func.returnStatements[0].isEmpty).toBe(true); +}); + +test('detects multiple return statements', () => { + const code = `class Foo { + int GetValue(bool flag) { + if (flag) + return 1; + return 0; + } +};`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const cls = ast.body[0] as any; + const func = cls.members[0] as any; + expect(func.returnStatements.length).toBe(2); + expect(func.returnStatements[0].isEmpty).toBe(false); + expect(func.returnStatements[1].isEmpty).toBe(false); +}); + +test('proto functions have no body', () => { + const code = `class Foo { + proto int GetValue(); +};`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const cls = ast.body[0] as any; + const func = cls.members[0] as any; + expect(func.hasBody).toBe(false); + expect(func.returnStatements.length).toBe(0); +}); + +test('detects return this in method', () => { + const code = `class Foo { + Foo Clone() { + return this; + } +};`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const cls = ast.body[0] as any; + const func = cls.members[0] as any; + expect(func.returnStatements.length).toBe(1); + const ret = func.returnStatements[0]; + expect(ret.isEmpty).toBe(false); + const exprText = code.substring(ret.exprStart, ret.exprEnd).trim(); + expect(exprText).toBe('this'); +}); + +test('detects return this.Method() chain', () => { + const code = `class Foo { + string GetName() { + return this.m_name; + } +};`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const cls = ast.body[0] as any; + const func = cls.members[0] as any; + expect(func.returnStatements.length).toBe(1); + const ret = func.returnStatements[0]; + const exprText = code.substring(ret.exprStart, ret.exprEnd).trim(); + expect(exprText).toBe('this.m_name'); +}); + +test('detects return with expression text', () => { + const code = `class Foo { + string GetName() { + return m_name; + } +};`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const cls = ast.body[0] as any; + const func = cls.members[0] as any; + expect(func.returnStatements.length).toBe(1); + const ret = func.returnStatements[0]; + expect(ret.isEmpty).toBe(false); + // The expression text between exprStart and exprEnd should contain 'm_name' + const exprText = code.substring(ret.exprStart, ret.exprEnd).trim(); + expect(exprText).toBe('m_name'); +}); + test('playground', () => { const target_file = path.join("P:\\enscript\\test", "test_enscript.c"); const text = fs.readFileSync(target_file, "utf8"); From d903b3104c605c908ee2b7bcd7abc88b693262d2 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Thu, 12 Feb 2026 20:37:49 -0500 Subject: [PATCH 25/46] Add isOverride and modded-class override checks Expose an isOverride flag on parsed FunctionDecl nodes (set from the 'override' modifier) and update analyzer inheritance checks to handle 'modded' classes. Analyzer now distinguishes true parents (original class + its base hierarchy) from sibling modded versions, collects sibling-introduction info for methods, and adjusts diagnostics: missing override, override signature mismatches, spurious override (no parent), and duplicate introductions across modded siblings are handled with the new logic. This makes override warnings correct and deterministic in the presence of modded class variants. --- server/src/analysis/ast/parser.ts | 2 + server/src/analysis/project/graph.ts | 142 ++++++++++++++++++++------- 2 files changed, 110 insertions(+), 34 deletions(-) diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index aca0416..9d45b63 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -170,6 +170,7 @@ export interface FunctionDeclNode extends SymbolNodeBase { locals: VarDeclNode[]; returnStatements: ReturnStatementInfo[]; // All return statements found in the body hasBody: boolean; // true if function has a { } body (not proto/native) + isOverride: boolean; // true if declared with the 'override' keyword } export interface File { @@ -768,6 +769,7 @@ export function parse( locals: locals, returnStatements: returnStatements, hasBody: hasBody, + isOverride: mods.includes('override'), annotations: annotations, modifiers: mods, start: baseTypeNode.start, diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index cb5788e..4dcb0e0 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -3186,23 +3186,76 @@ export class Analyzer { const inheritedFields = new Map(); const parentMethods = new Map(); - if (cls.base?.identifier) { - const hierarchy = this.getClassHierarchyOrdered(cls.base.identifier, new Set()); - for (const parentClass of hierarchy) { - for (const member of parentClass.members || []) { - if (member.kind === 'VarDecl' && member.name) { - if (!inheritedFields.has(member.name)) { - inheritedFields.set(member.name, member); - } - } + const isModded = cls.modifiers?.includes('modded'); + + // For modded classes: look up the original class (non-modded) + its base + // hierarchy as the "true parents." Other modded versions of the same + // class are "sibling mods" — they can introduce methods, but their + // indexing order is non-deterministic so we can't treat them as parents. + // + // - "missing override" → only fires if method is in true parents + // - "override without parent" → suppressed only if a sibling INTRODUCES + // the method (defines it without override); if all siblings also use + // override, nobody introduced it so the warning fires. + // - "duplicate across mods" → fires when siblings both define the same + // method without override (one will shadow the other) + // - signature mismatch → checked against true parents + const hierarchyToSearch: ClassDeclNode[] = []; + // Map + const moddedSiblingMethods = new Map(); + + if (isModded) { + const allVersions = this.findAllClassesByName(cls.name); + const originalClass = allVersions.find(c => !c.modifiers?.includes('modded')); + + // True parents: the original class + its full base hierarchy + if (originalClass) { + hierarchyToSearch.push(originalClass); + if (originalClass.base?.identifier) { + const baseHierarchy = this.getClassHierarchyOrdered(originalClass.base.identifier, new Set()); + hierarchyToSearch.push(...baseHierarchy); + } + } + + // Sibling mods: collect method names from other modded versions, + // tracking whether any sibling INTRODUCES the method (defines it + // without 'override'). This lets us distinguish: + // - Sibling introduces → our 'override' is valid, suppress warning + // - All siblings also 'override' → nobody introduced it, warn + for (const ver of allVersions) { + if (ver === cls || ver === originalClass) continue; + for (const member of ver.members || []) { if (member.kind === 'FunctionDecl' && member.name) { - if (!parentMethods.has(member.name)) { - parentMethods.set(member.name, []); + const func = member as FunctionDeclNode; + const existing = moddedSiblingMethods.get(member.name); + const isIntroduction = !func.isOverride; + if (existing) { + if (isIntroduction) existing.anyIntroduced = true; + } else { + moddedSiblingMethods.set(member.name, { anyIntroduced: isIntroduction }); } - parentMethods.get(member.name)!.push(member as FunctionDeclNode); } } } + } else if (cls.base?.identifier) { + const hierarchy = this.getClassHierarchyOrdered(cls.base.identifier, new Set()); + hierarchyToSearch.push(...hierarchy); + } + + for (const parentClass of hierarchyToSearch) { + for (const member of parentClass.members || []) { + if (member.kind === 'VarDecl' && member.name) { + if (!inheritedFields.has(member.name)) { + inheritedFields.set(member.name, member); + } + } + if (member.kind === 'FunctionDecl' && member.name) { + if (!parentMethods.has(member.name)) { + parentMethods.set(member.name, []); + } + parentMethods.get(member.name)!.push(member as FunctionDeclNode); + } + } } // ── Check class fields against inherited fields & globals ────────── @@ -3223,31 +3276,52 @@ export class Analyzer { if (member.kind !== 'FunctionDecl') continue; const func = member as FunctionDeclNode; - // Missing override check - if (parentMethods.size > 0 && func.name && func.name !== cls.name) { - const hasOverride = func.modifiers.includes('override'); + // Override / inheritance checks (skip constructors) + if (func.name && func.name !== cls.name) { const parentOverloads = parentMethods.get(func.name); - if (!hasOverride && parentOverloads) { - const childParamCount = func.parameters?.length ?? 0; - const hasMatchingParamCount = parentOverloads.some(p => (p.parameters?.length ?? 0) === childParamCount); - if (hasMatchingParamCount) { - diags.push({ - message: `Method '${func.name}' overrides a method from a parent class but is missing the 'override' keyword.`, - range: { start: func.nameStart, end: func.nameEnd }, - severity: DiagnosticSeverity.Warning - }); - } - } - // Override signature mismatch check - // When 'override' is present, validate the signature exactly matches - // at least one parent overload (return type, param types, names, - // modifiers like out/inout, and default presence). - if (hasOverride && parentOverloads) { - const mismatch = this.checkOverrideSignatureMismatch(func, parentOverloads); - if (mismatch) { + if (parentOverloads) { + // Method exists in a parent — check override usage + if (!func.isOverride) { + // Missing 'override' keyword (only if param count matches) + const childParamCount = func.parameters?.length ?? 0; + if (parentOverloads.some(p => (p.parameters?.length ?? 0) === childParamCount)) { + diags.push({ + message: `Method '${func.name}' overrides a method from a parent class but is missing the 'override' keyword.`, + range: { start: func.nameStart, end: func.nameEnd }, + severity: DiagnosticSeverity.Warning + }); + } + } else { + // Has 'override' — validate signature matches a parent overload + const mismatch = this.checkOverrideSignatureMismatch(func, parentOverloads); + if (mismatch) { + diags.push({ + message: mismatch, + range: { start: func.nameStart, end: func.nameEnd }, + severity: DiagnosticSeverity.Warning + }); + } + } + } else if (this.docCache.size >= Analyzer.MIN_INDEX_SIZE_FOR_TYPE_CHECKS) { + // Method NOT in any parent — check for modded-class edge cases + const siblingInfo = moddedSiblingMethods.get(func.name); + + if (func.isOverride) { + // 'override' on a method no parent has — only valid if a + // sibling mod introduces it (defines without override). + if (!siblingInfo?.anyIntroduced) { + diags.push({ + message: `Method '${func.name}' is marked 'override' but no matching method was found in any parent class.`, + range: { start: func.nameStart, end: func.nameEnd }, + severity: DiagnosticSeverity.Warning + }); + } + } else if (isModded && siblingInfo?.anyIntroduced) { + // Two mods both introduce the same method without override — + // one will shadow the other depending on load order. diags.push({ - message: mismatch, + message: `Method '${func.name}' is also defined in another modded version of '${cls.name}' without 'override'. One definition will shadow the other depending on mod load order.`, range: { start: func.nameStart, end: func.nameEnd }, severity: DiagnosticSeverity.Warning }); From 71d9460ef42004d4b04f22295f94eb00ac006076 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Thu, 12 Feb 2026 22:22:02 -0500 Subject: [PATCH 26/46] Improve return parsing and inheritance handling parser.ts: Enhance return-statement scanning to handle array-literal initializers ({...}), treat a newline-starting next token as a bare `return` (no semicolon), stop at closing block braces, and record the return token's line. This makes return expression range detection more robust for multi-token/line cases. graph.ts: Use scopeEnd for local variable end-line ranges. Improve class hierarchy resolution by looking up base classes (and implicitly the root 'Class') to avoid including the class itself or modded siblings. Add handling for array-literal types and chained Cast(...) expressions. When building inheritance data, prefer methods from original (non-modded) parents for "missing override" diagnostics (collecting originalParentMethods) and skip destructor names in override checks. These changes reduce false positives and better reflect modding semantics when checking overrides and inherited members. --- server/src/analysis/ast/parser.ts | 33 ++++++- server/src/analysis/project/graph.ts | 124 +++++++++++++++++++++------ 2 files changed, 129 insertions(+), 28 deletions(-) diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index 9d45b63..d8e65d2 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -642,24 +642,53 @@ export function parse( // ================================================================ if (t.kind === TokenKind.Keyword && t.value === 'return' && depth > 0) { const retStart = doc.positionAt(t.start); + const retLine = retStart.line; // Scan forward to the ';' to capture the expression range const exprStartOffset = t.end; // right after the 'return' token let exprEndOffset = exprStartOffset; let semiOffset = exprStartOffset; let scanIdx = pos; + let foundTerminator = false; while (scanIdx < toks.length) { const scanTok = toks[scanIdx]; if (scanTok.value === ';') { semiOffset = scanTok.end; exprEndOffset = scanTok.start; + foundTerminator = true; break; } - // Stop at block boundaries to be safe - if (scanTok.value === '{' || scanTok.value === '}') { + // Array literal initializer: {val1, val2, ...} + // Scan through balanced braces as part of the expression + if (scanTok.value === '{') { + let braceDepth = 1; + scanIdx++; + while (scanIdx < toks.length && braceDepth > 0) { + if (toks[scanIdx].value === '{') braceDepth++; + else if (toks[scanIdx].value === '}') braceDepth--; + scanIdx++; + } + continue; // Continue looking for ';' after the array literal + } + // Stop at closing brace — end of enclosing block + if (scanTok.value === '}') { exprEndOffset = scanTok.start; semiOffset = scanTok.start; + foundTerminator = true; break; } + // If the next non-whitespace token is on a different + // line, treat this as a bare 'return' (no semicolon). + // EnforceScript allows bare returns without ';'. + if (scanIdx > pos - 1) { + const tokLine = doc.positionAt(scanTok.start).line; + if (tokLine > retLine) { + // Next token is on a new line — bare return + exprEndOffset = exprStartOffset; + semiOffset = t.end; + foundTerminator = true; + break; + } + } scanIdx++; } const isEmpty = exprEndOffset <= exprStartOffset || diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 4dcb0e0..3dd19ed 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -2630,7 +2630,8 @@ export class Analyzer { for (const local of (node as FunctionDeclNode).locals || []) { if (local.name && local.type?.identifier) { const localStart = local.start?.line ?? funcStart; - add(local.name, local.type.identifier, localStart, funcEnd, false); + const localEnd = local.scopeEnd?.line ?? funcEnd; + add(local.name, local.type.identifier, localStart, localEnd, false); } } } @@ -2658,7 +2659,8 @@ export class Analyzer { for (const l of func.locals || []) { if (l.name && l.type?.identifier) { const localStart = l.start?.line ?? fStart; - add(l.name, l.type.identifier, localStart, fEnd, false); + const localEnd = l.scopeEnd?.line ?? fEnd; + add(l.name, l.type.identifier, localStart, localEnd, false); } } } @@ -2670,15 +2672,20 @@ export class Analyzer { // shared map is safe because they have large ranges (class scope) and // checkFunctionCallArgs' smallest-range heuristic will prefer local // declarations over them anyway, matching prior behavior. + // Look up the BASE hierarchy (not cls.name) to avoid including + // the class itself — reference comparison can fail across re-parses. + let parentClasses: ClassDeclNode[] = []; if (cls.base?.identifier) { - const parentClasses = this.getClassHierarchyOrdered(cls.base.identifier, new Set()); - for (const parentClass of parentClasses) { - for (const member of parentClass.members || []) { - if (member.kind === 'VarDecl' && member.name) { - const varMember = member as VarDeclNode; - if (varMember.type?.identifier) { - add(member.name, varMember.type.identifier, clsStart, clsEnd, true); - } + parentClasses = this.getClassHierarchyOrdered(cls.base.identifier, new Set()); + } else if (cls.name !== 'Class') { + parentClasses = this.getClassHierarchyOrdered('Class', new Set()); + } + for (const parentClass of parentClasses) { + for (const member of parentClass.members || []) { + if (member.kind === 'VarDecl' && member.name) { + const varMember = member as VarDeclNode; + if (varMember.type?.identifier) { + add(member.name, varMember.type.identifier, clsStart, clsEnd, true); } } } @@ -2968,9 +2975,29 @@ export class Analyzer { const newMatch = trimmed.match(/^new\s+(\w+)\s*\(/); if (newMatch) return newMatch[1]; - // --- Cast: ClassName.Cast(expr) --- + // --- Array literal: {val1, val2, ...} --- + if (trimmed.startsWith('{') && trimmed.endsWith('}')) return 'array'; + + // --- Cast: ClassName.Cast(expr) possibly chained --- const castMatch = trimmed.match(/^(\w+)\s*\.\s*Cast\s*\(/); - if (castMatch) return castMatch[1]; + if (castMatch) { + const castType = castMatch[1]; + // Check for chaining after Cast(...) + const afterCastStart = trimmed.substring(castMatch[0].length); + let parenDepth = 1, ci = 0; + while (ci < afterCastStart.length && parenDepth > 0) { + if (afterCastStart[ci] === '(') parenDepth++; + else if (afterCastStart[ci] === ')') parenDepth--; + ci++; + } + const afterClose = afterCastStart.substring(ci).trim(); + if (afterClose.startsWith('.')) { + // Chain continues after Cast — resolve from castType + const resolved = this.resolveVariableChainType(castType, afterClose); + if (resolved) return resolved; + } + return castType; + } // --- Class.CastTo pattern: Class.CastTo(target, source) returns bool --- if (/^Class\s*\.\s*CastTo\s*\(/.test(trimmed)) return 'bool'; @@ -3208,12 +3235,27 @@ export class Analyzer { const allVersions = this.findAllClassesByName(cls.name); const originalClass = allVersions.find(c => !c.modifiers?.includes('modded')); - // True parents: the original class + its full base hierarchy + // True parents: the original (non-modded) class + its base hierarchy. + // We look up the BASE of the original to avoid including the original's + // own modded siblings. Then add the original itself. if (originalClass) { hierarchyToSearch.push(originalClass); if (originalClass.base?.identifier) { const baseHierarchy = this.getClassHierarchyOrdered(originalClass.base.identifier, new Set()); hierarchyToSearch.push(...baseHierarchy); + } else if (originalClass.name !== 'Class') { + const classHierarchy = this.getClassHierarchyOrdered('Class', new Set()); + hierarchyToSearch.push(...classHierarchy); + } + } else { + // No original found (all modded) — try to get base from first modded + const anyBase = allVersions[0]?.base?.identifier; + if (anyBase) { + const baseHierarchy = this.getClassHierarchyOrdered(anyBase, new Set()); + hierarchyToSearch.push(...baseHierarchy); + } else if (cls.name !== 'Class') { + const classHierarchy = this.getClassHierarchyOrdered('Class', new Set()); + hierarchyToSearch.push(...classHierarchy); } } @@ -3237,9 +3279,16 @@ export class Analyzer { } } } - } else if (cls.base?.identifier) { - const hierarchy = this.getClassHierarchyOrdered(cls.base.identifier, new Set()); - hierarchyToSearch.push(...hierarchy); + } else { + // Non-modded class: get parent hierarchy + implicit Class root + if (cls.base?.identifier) { + const hierarchy = this.getClassHierarchyOrdered(cls.base.identifier, new Set()); + hierarchyToSearch.push(...hierarchy); + } else if (cls.name !== 'Class') { + // No explicit base — implicitly inherits from Class + const hierarchy = this.getClassHierarchyOrdered('Class', new Set()); + hierarchyToSearch.push(...hierarchy); + } } for (const parentClass of hierarchyToSearch) { @@ -3258,6 +3307,22 @@ export class Analyzer { } } + // For "missing override" checks, methods introduced by MODDED parent + // classes should not force children to add 'override'. Only methods + // from original (non-modded) parent definitions count. + const originalParentMethods = new Map(); + for (const parentClass of hierarchyToSearch) { + if (parentClass.modifiers?.includes('modded')) continue; + for (const member of parentClass.members || []) { + if (member.kind === 'FunctionDecl' && member.name) { + if (!originalParentMethods.has(member.name)) { + originalParentMethods.set(member.name, []); + } + originalParentMethods.get(member.name)!.push(member as FunctionDeclNode); + } + } + } + // ── Check class fields against inherited fields & globals ────────── for (const [fieldName, fieldNode] of classFields) { const inh = inheritedFields.get(fieldName); @@ -3276,21 +3341,28 @@ export class Analyzer { if (member.kind !== 'FunctionDecl') continue; const func = member as FunctionDeclNode; - // Override / inheritance checks (skip constructors) - if (func.name && func.name !== cls.name) { + // Override / inheritance checks (skip constructors and destructors) + if (func.name && func.name !== cls.name && !func.name.startsWith('~')) { const parentOverloads = parentMethods.get(func.name); + // For "missing override": only original (non-modded) parent methods + // count. A modded parent adding a method shouldn't force children + // to add 'override' — the child may be the original introducer. + const originalOverloads = originalParentMethods.get(func.name); if (parentOverloads) { // Method exists in a parent — check override usage if (!func.isOverride) { - // Missing 'override' keyword (only if param count matches) - const childParamCount = func.parameters?.length ?? 0; - if (parentOverloads.some(p => (p.parameters?.length ?? 0) === childParamCount)) { - diags.push({ - message: `Method '${func.name}' overrides a method from a parent class but is missing the 'override' keyword.`, - range: { start: func.nameStart, end: func.nameEnd }, - severity: DiagnosticSeverity.Warning - }); + // Only warn "missing override" if the method exists in an + // ORIGINAL (non-modded) parent class definition. + if (originalOverloads) { + const childParamCount = func.parameters?.length ?? 0; + if (originalOverloads.some(p => (p.parameters?.length ?? 0) === childParamCount)) { + diags.push({ + message: `Method '${func.name}' overrides a method from a parent class but is missing the 'override' keyword.`, + range: { start: func.nameStart, end: func.nameEnd }, + severity: DiagnosticSeverity.Warning + }); + } } } else { // Has 'override' — validate signature matches a parent overload From 55efd7a6f5e580ff34fac7e69b6c6f585f9c248e Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Fri, 13 Feb 2026 21:40:14 -0500 Subject: [PATCH 27/46] Resolve method returns and add modded class checks Improve return-type resolution by falling back to methods on the containing class when a free function lookup fails, and avoid an unsafe global search across all class methods. Propagate an optional className into resolveChainReturnType and update call sites to use findContainingClass so chained calls and indexed chains can resolve methods correctly. Fix typedef/template handling during chain resolution (variable renames) and produce consistent TypeNode wrappers when falling back to containing-class methods. Add checkModdedClassModules to validate that `modded` classes live in the same script module as their original class and emit diagnostics when they do not. --- server/src/analysis/project/graph.ts | 154 +++++++++++++++++++++------ 1 file changed, 119 insertions(+), 35 deletions(-) diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 3dd19ed..b3becaa 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -796,7 +796,14 @@ export class Analyzer { // If it's a function call like GetGame(). → look up the function's return type if (hasParens) { - const returnType = this.resolveFunctionReturnType(name); + let returnType = this.resolveFunctionReturnType(name); + // Fall back to method of containing class (e.g., GetInstance() inside a class) + if (!returnType) { + const cc = this.findContainingClass(ast, pos); + if (cc) { + returnType = this.resolveMethodReturnType(cc.name, name); + } + } if (returnType) { return this.getClassMemberCompletions(returnType, prefix); } @@ -1082,20 +1089,13 @@ export class Analyzer { } } - // Check class methods across all classes (still need to iterate but on indexed classes) - // This is for static calls or when we don't know the class - for (const [className, classes] of this.classIndex) { - for (const classNode of classes) { - for (const member of classNode.members || []) { - if (member.kind === 'FunctionDecl' && member.name === funcName) { - const func = member as FunctionDeclNode; - if (func.returnType?.identifier) { - return func.returnType; - } - } - } - } - } + // NOTE: We intentionally do NOT fall back to searching all class methods here. + // An unqualified function call should only resolve to: + // 1) A constructor (checked above) + // 2) A top-level (global) function (checked above) + // 3) A method in the containing class hierarchy (handled by resolveMethodReturnType) + // Searching all class methods would return arbitrary results (e.g., GetInstance() + // matching NotificationSystem instead of the caller's own class). return null; } @@ -1499,7 +1499,7 @@ export class Analyzer { * Parses the chain, resolves the first function call, then delegates to * resolveChainSteps for subsequent member accesses. */ - private resolveChainReturnType(chainText: string): string | null { + private resolveChainReturnType(chainText: string, className?: string): string | null { // Parse the first call: funcName(args) const remaining = chainText.trim(); const firstMatch = remaining.match(/^(\w+)\s*\(/); @@ -1522,38 +1522,47 @@ export class Analyzer { // Resolve the first function's return type const firstTypeNode = this.resolveFunctionReturnTypeNode(firstFunc); - if (!firstTypeNode?.identifier) { - // No return type found — check if it's an implicit constructor (class/typedef with no declaration) - if (this.classIndex.has(firstFunc) || this.typedefIndex.has(firstFunc)) { - if (calls.length === 0) return firstFunc; - return this.resolveVariableChainType(firstFunc, '.' + calls.join('.')); + let currentType: string | null = firstTypeNode?.identifier ?? null; + let resolvedTypeNode = firstTypeNode; + + if (!currentType) { + // Try resolving as a method of the containing class + if (className) { + currentType = this.resolveMethodReturnType(className, firstFunc); + } + if (!currentType) { + // Check if it's an implicit constructor (class/typedef with no declaration) + if (this.classIndex.has(firstFunc) || this.typedefIndex.has(firstFunc)) { + if (calls.length === 0) return firstFunc; + return this.resolveVariableChainType(firstFunc, '.' + calls.join('.')); + } + return null; } - return null; } - let currentType = firstTypeNode.identifier; + let currentType2 = currentType; // Resolve typedef and build initial template map let templateMap: Map; - const typedefNode = this.resolveTypedefNode(currentType); + const typedefNode = this.resolveTypedefNode(currentType2); if (typedefNode) { - currentType = typedefNode.oldType.identifier; - templateMap = this.buildTemplateMap(currentType, typedefNode.oldType.genericArgs); + currentType2 = typedefNode.oldType.identifier; + templateMap = this.buildTemplateMap(currentType2, typedefNode.oldType.genericArgs); } else { - templateMap = this.buildTemplateMap(currentType, firstTypeNode.genericArgs); + templateMap = this.buildTemplateMap(currentType2, resolvedTypeNode?.genericArgs); } // If no chained calls, return the first function's resolved type if (calls.length === 0) { // Check for array indexing after the single call, e.g., GetOrientation()[0] if (this.hasIndexingAfterCall(afterFirst)) { - return this.resolveIndexedType(currentType); + return this.resolveIndexedType(currentType2); } - return currentType; + return currentType2; } // Delegate remaining chain steps - const result = this.resolveChainSteps(calls, currentType, templateMap)?.type ?? null; + const result = this.resolveChainSteps(calls, currentType2, templateMap)?.type ?? null; // If the chain contains array indexing outside of args, resolve to element type if (result && this.hasIndexingAfterCall(afterFirst)) { @@ -1986,7 +1995,15 @@ export class Analyzer { if (chainedCallMatch) { // CHAINED CALL: Resolve the return type of the function call const funcName = chainedCallMatch[1]; - const returnType = this.resolveFunctionReturnType(funcName); + let returnType = this.resolveFunctionReturnType(funcName); + // Fall back to method of containing class (e.g., GetInstance().member inside a class) + if (!returnType) { + const ast2 = this.ensure(doc); + const cc = this.findContainingClass(ast2, _pos); + if (cc) { + returnType = this.resolveMethodReturnType(cc.name, funcName); + } + } if (returnType) { const classMatches = this.findMemberInClassHierarchy(returnType, name); @@ -2292,7 +2309,18 @@ export class Analyzer { const chainedCallMatch = textBeforeToken.match(/(\w+)\s*\([^)]*\)\s*\.\s*$/); if (chainedCallMatch) { const funcName = chainedCallMatch[1]; - const returnTypeNode = this.resolveFunctionReturnTypeNode(funcName); + let returnTypeNode = this.resolveFunctionReturnTypeNode(funcName); + // Fall back to method of containing class + if (!returnTypeNode?.identifier) { + const ast2 = this.ensure(doc); + const cc = this.findContainingClass(ast2, _pos); + if (cc) { + const methodType = this.resolveMethodReturnType(cc.name, funcName); + if (methodType) { + returnTypeNode = { identifier: methodType, arrayDims: [], modifiers: [], kind: 'Type', uri: '', start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } as TypeNode; + } + } + } if (returnTypeNode?.identifier) { let resolvedType = returnTypeNode.identifier; const typedefNode = this.resolveTypedefNode(resolvedType); @@ -2750,6 +2778,10 @@ export class Analyzer { // Check return statements: missing returns in non-void functions // and return type mismatches (including downcast warnings) this.checkReturnStatements(doc, diags, text, lineOffsets, ast, scopedVars); + + // Check that modded classes are in the same script module (or higher) + // as the original class they are modding + this.checkModdedClassModules(ast, diags); } // Check for multi-line statements (not supported in Enforce Script) @@ -3028,7 +3060,7 @@ export class Analyzer { if (afterCall.startsWith('.')) { // Method chain — delegate to resolveChainReturnType - return this.resolveChainReturnType(trimmed); + return this.resolveChainReturnType(trimmed, className); } // Single function call — resolve as method of containing class first if (className) { @@ -3850,7 +3882,9 @@ export class Analyzer { // For type resolution, pass everything up to ';' - resolveChainReturnType // handles trailing non-chain text gracefully const fullChainText = funcName + '(' + afterMatch.substring(0, chainEnd); - returnType = this.resolveChainReturnType(fullChainText); + const lineNumForChain = Analyzer.getLineFromOffset(lineOffsets, match.index); + const chainClass = this.findContainingClass(ast, { line: lineNumForChain, character: 0 }); + returnType = this.resolveChainReturnType(fullChainText, chainClass?.name); // For highlight, find where the chain actually ends (last ')' or property name) // by scanning: balanced parens, then optional .identifier or .identifier(...) @@ -4091,7 +4125,8 @@ export class Analyzer { chainEnd = stmtEnd >= 0 ? stmtEnd : afterMatch.length; const fullChainText = funcName + '(' + afterMatch.substring(0, chainEnd); - returnType = this.resolveChainReturnType(fullChainText); + const chainClass2 = this.findContainingClass(ast, { line: lineNum, character: 0 }); + returnType = this.resolveChainReturnType(fullChainText, chainClass2?.name); // For highlight, find where the chain actually ends (last ')' or property name) let hlEnd = 0; @@ -4982,6 +5017,9 @@ export class Analyzer { if (afterRoot.trimStart().startsWith('(')) { // Root is a function call: FuncName(...).chain... rootType = this.resolveFunctionReturnType(rootName) ?? undefined; + if (!rootType && containingClassName) { + rootType = this.resolveMethodCallWithTemplates(containingClassName, rootName) ?? undefined; + } const parenStart = afterRoot.indexOf('('); let d = 1, i = parenStart + 1; while (i < afterRoot.length && d > 0) { @@ -5280,6 +5318,52 @@ export class Analyzer { } } + // ==================================================================== + // MODDED CLASS MODULE VALIDATION + // ==================================================================== + + /** + * Check that modded classes are placed in the correct script module. + * + * A `modded class` must be in the SAME script module as the original class. + * For example: + * - modded class PlayerBase (4_World) must be in 4_World + * - modded class MissionServer (5_Mission) must be in 5_Mission + * + * Placing it in a different module (higher or lower) will cause issues. + */ + private checkModdedClassModules(ast: File, diags: Diagnostic[]): void { + for (const node of ast.body) { + if (node.kind !== 'ClassDecl') continue; + const cls = node as ClassDeclNode; + if (!cls.modifiers?.includes('modded')) continue; + + // Get the module level of this modded class from its URI + const moddedLevel = getModuleLevel(cls.uri); + if (moddedLevel === 0) continue; // Can't determine module — skip + + // Find the original (non-modded) class in the index + const allVersions = this.classIndex.get(cls.name); + if (!allVersions || allVersions.length === 0) continue; + + const original = allVersions.find(c => !c.modifiers?.includes('modded')); + if (!original) continue; // No original found — all modded, can't check + + const originalLevel = getModuleLevel((original as any)._sourceUri ?? original.uri); + if (originalLevel === 0) continue; // Can't determine original's module + + if (moddedLevel !== originalLevel) { + const moddedModuleName = MODULE_NAMES[moddedLevel] || `module ${moddedLevel}`; + const originalModuleName = MODULE_NAMES[originalLevel] || `module ${originalLevel}`; + diags.push({ + message: `Modded class '${cls.name}' is in ${moddedModuleName} but the original class is in ${originalModuleName}. A modded class must be in the same module as the original.`, + range: { start: cls.nameStart, end: cls.nameEnd }, + severity: DiagnosticSeverity.Error + }); + } + } + } + /** * Check for unknown/undefined symbols in the AST * Generates warnings for: From 079198a9ddd1f2f8cf7b707f2ef2311c387b864b Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sat, 14 Feb 2026 11:38:28 -0500 Subject: [PATCH 28/46] Parser error recovery and 'this' resolution Add error-recovery to the parser so a single broken declaration (top-level or class member) produces a diagnostic instead of aborting the whole file. The parser now skips tokens to the next sensible boundary (';', balanced '{}', or outer class '}') after reporting ParseError. Update Analyzer to resolve the 'this' keyword to the containing class (both as a type and symbol), prefer local AST class members before walking classIndex, and add 'this' entries for method scopes when building indexes. Remove registration of the documents handler and add tests to verify parser error recovery for class members and top-level declarations. --- server/src/analysis/ast/parser.ts | 65 +++++++++++++++++++++++++--- server/src/analysis/project/graph.ts | 58 +++++++++++++++++++++++-- server/src/lsp/registerAll.ts | 2 - test/parser.test.ts | 37 ++++++++++++++++ 4 files changed, 152 insertions(+), 10 deletions(-) diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index d8e65d2..662573e 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -356,7 +356,8 @@ export function parse( diagnostics: diagnostics // Include parser diagnostics }; - // main loop + // main loop – with error recovery so one broken declaration + // doesn't kill parsing for the entire file. while (!eof()) { if (eof()) break; @@ -366,8 +367,35 @@ export function parse( continue; } - const nodes = parseDecl(doc, 0); // depth = 0 - file.body.push(...nodes); + try { + const nodes = parseDecl(doc, 0); // depth = 0 + file.body.push(...nodes); + } catch (err) { + // Record the parse error as a diagnostic instead of aborting + if (err instanceof ParseError) { + addDiagnostic( + toks[Math.min(pos, toks.length - 1)], + err.message, + DiagnosticSeverity.Error + ); + } + // Skip to the next top-level boundary: + // - ';' at brace depth 0 (end of broken variable/etc.) + // - closing a balanced { } (end of broken function/class body) + // - unmatched '}' at depth 0 (shouldn't happen at top level) + let braceDepth = 0; + while (!eof()) { + const v = peek().value; + if (v === '{') { braceDepth++; next(); } + else if (v === '}') { + if (braceDepth === 0) { next(); break; } + braceDepth--; next(); + if (braceDepth === 0) break; // closed a balanced block + } + else if (v === ';' && braceDepth === 0) { next(); break; } + else { next(); } + } + } } return file; @@ -431,8 +459,35 @@ export function parse( next(); continue; } - const m = parseDecl(doc, depth + 1); - members.push(...m); + try { + const m = parseDecl(doc, depth + 1); + members.push(...m); + } catch (err) { + // Record error but keep parsing remaining class members + if (err instanceof ParseError) { + addDiagnostic( + toks[Math.min(pos, toks.length - 1)], + err.message, + DiagnosticSeverity.Error + ); + } + // Skip to the next member boundary: + // - ';' at brace depth 0 (end of broken variable) + // - closing a balanced { } (end of broken function body) + // - outer class '}' (stop WITHOUT consuming it) + let bd = 0; + while (!eof()) { + const v = peek().value; + if (v === '{') { bd++; next(); } + else if (v === '}') { + if (bd === 0) break; // outer class '}' — don't consume + bd--; next(); + if (bd === 0) break; // closed a balanced block + } + else if (v === ';' && bd === 0) { next(); break; } + else { next(); } + } + } } expect('}'); diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index b3becaa..dc0ea96 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -967,6 +967,13 @@ export class Analyzer { */ private resolveVariableType(doc: TextDocument, pos: Position, varName: string): string | null { + // Handle 'this' keyword — resolve to containing class type + if (varName === 'this') { + const ast = this.ensure(doc); + const containingClass = this.findContainingClass(ast, pos); + return containingClass?.name ?? null; + } + // Delegate the AST-based lookup to resolveVariableTypeNode const typeNode = this.resolveVariableTypeNode(doc, pos, varName); if (typeNode) { @@ -1020,6 +1027,15 @@ export class Analyzer { private resolveVariableTypeNode(doc: TextDocument, pos: Position, varName: string): TypeNode | null { const ast = this.ensure(doc); + // Handle 'this' keyword — resolve to containing class type + if (varName === 'this') { + const containingClass = this.findContainingClass(ast, pos); + if (containingClass) { + return { identifier: containingClass.name, arrayDims: [], modifiers: [], kind: 'Type', uri: '', start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } as TypeNode; + } + return null; + } + const containingFunc = this.findContainingFunction(ast, pos); if (containingFunc) { for (const param of containingFunc.parameters || []) { @@ -1032,8 +1048,18 @@ export class Analyzer { const containingClass = this.findContainingClass(ast, pos); if (containingClass) { + // First search the local AST class members directly — this always works + // even if classIndex is stale or hasn't been populated yet + for (const member of containingClass.members || []) { + if (member.kind === 'VarDecl' && member.name === varName && (member as VarDeclNode).type) { + return (member as VarDeclNode).type!; + } + } + // Then walk the class hierarchy via classIndex for inherited members const classHierarchy = this.getClassHierarchyOrdered(containingClass.name, new Set()); for (const classNode of classHierarchy) { + // Skip self — already searched above via the local AST + if (classNode.name === containingClass.name) continue; for (const member of classNode.members || []) { if (member.kind === 'VarDecl' && member.name === varName && (member as VarDeclNode).type) { return (member as VarDeclNode).type!; @@ -1912,11 +1938,28 @@ export class Analyzer { // These are keywords in the lexer but also real classes defined in enconvert.c / enstring.c const typeKeywords = new Set(['string', 'int', 'float', 'bool', 'vector', 'typename', 'void']); if (token.kind !== TokenKind.Identifier && - !(token.kind === TokenKind.Keyword && typeKeywords.has(token.value))) { + !(token.kind === TokenKind.Keyword && typeKeywords.has(token.value)) && + !(token.kind === TokenKind.Keyword && token.value === 'this')) { return []; } const name = token.value; + + // Handle 'this' keyword — navigate to the containing class definition + if (name === 'this') { + const ast = this.ensure(doc); + const containingClass = this.findContainingClass(ast, _pos); + if (containingClass) { + // Return the class from classIndex for proper URI/position + const indexed = this.classIndex.get(containingClass.name); + if (indexed && indexed.length > 0) { + return [indexed[0] as SymbolNodeBase]; + } + // Fallback: return the local AST class node + return [containingClass as SymbolNodeBase]; + } + return []; + } // Check if this is a member access (e.g., player.GetInputType or GetGame().GetTime()) // Look backwards from the token start to find a dot @@ -2018,11 +2061,18 @@ export class Analyzer { const containingClass = this.findContainingClass(ast, _pos); if (containingClass) { - // First, look in current class hierarchy + // First, look in current class hierarchy via classIndex const hierarchyMatches = this.findMemberInClassHierarchy(containingClass.name, name); if (hierarchyMatches.length > 0) { return hierarchyMatches; } + // Fallback: search local AST class members directly — works even if + // classIndex is stale or not yet populated for this file + for (const member of containingClass.members || []) { + if (member.name === name) { + return [member as SymbolNodeBase]; + } + } } // FALLBACK: Global search using pre-built indexes @@ -2674,11 +2724,13 @@ export class Analyzer { if (member.kind === 'VarDecl' && member.name && (member as VarDeclNode).type?.identifier) { add(member.name, (member as VarDeclNode).type.identifier, clsStart, clsEnd, true); } - // Methods — collect params and locals + // Methods — collect params, locals, and 'this' reference if (member.kind === 'FunctionDecl') { const func = member as FunctionDeclNode; const fStart = func.start?.line ?? clsStart; const fEnd = func.end?.line ?? clsEnd; + // Add 'this' as the containing class type for each method scope + add('this', cls.name, fStart, fEnd, false); for (const p of func.parameters || []) { if (p.name && p.type?.identifier) { add(p.name, p.type.identifier, fStart, fEnd, false); diff --git a/server/src/lsp/registerAll.ts b/server/src/lsp/registerAll.ts index 47ce93d..dcc2651 100644 --- a/server/src/lsp/registerAll.ts +++ b/server/src/lsp/registerAll.ts @@ -7,7 +7,6 @@ import { registerReferences } from './handlers/references'; import { registerRename } from './handlers/rename'; import { registerWorkspaceSymbol } from './handlers/workspaceSymbol'; import { registerDiagnostics } from './handlers/diagnostics'; -import { registerDocuments } from './handlers/documents'; import { registerDumpDiagnostics } from './handlers/dumpDiagnostics'; import { registerIncludePaths } from './handlers/includePaths'; @@ -19,7 +18,6 @@ export function registerAllHandlers(conn: Connection, docs: TextDocuments { expect(exprText).toBe('m_name'); }); +test('error recovery: broken class member does not kill entire class', () => { + // One broken member (badFunc has invalid syntax), rest should still parse + const code = `class MyClass { + int m_health; + void BadFunc(@@@ GARBAGE) { } + float m_speed; + void GoodFunc() { + int x = 5; + } +};`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + // Class should still be parsed + expect(ast.body.length).toBe(1); + expect(ast.body[0]).toHaveProperty('kind', 'ClassDecl'); + const cls = ast.body[0] as any; + expect(cls.name).toBe('MyClass'); + // Members before and after the broken one should still be present + const memberNames = cls.members.map((m: any) => m.name); + expect(memberNames).toContain('m_health'); + expect(memberNames).toContain('m_speed'); + expect(memberNames).toContain('GoodFunc'); +}); + +test('error recovery: broken top-level decl does not kill other declarations', () => { + // First class is fine, second has garbage, third is fine + const code = `class A { int x; }; +@@@ GARBAGE @@@; +class B { float y; };`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + // Both valid classes should be recovered + const classNames = ast.body.filter((n: any) => n.kind === 'ClassDecl').map((n: any) => n.name); + expect(classNames).toContain('A'); + expect(classNames).toContain('B'); +}); + test('playground', () => { const target_file = path.join("P:\\enscript\\test", "test_enscript.c"); const text = fs.readFileSync(target_file, "utf8"); From d0b13c26137aed829827979add009f08c007a6e8 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sun, 15 Feb 2026 22:43:45 -0500 Subject: [PATCH 29/46] Refine unknown method warning logic Improve Analyzer diagnostics for unknown function/method calls by better detecting chained calls and confident receiver types. Introduces dotIsPartOfChain and computes dotObj, dotObjType and dotObjIsKnownClass to decide if a method call is an unresolved chain. Adjusts warning conditions so global/unknown receivers still require a large index to warn, but calls with a known receiver type (explicit obj.Method or known class) can produce warnings even with a smaller index. Also refactors diagnostic message construction to show the resolved receiver type when available, reducing false positives for chain calls. --- server/src/analysis/project/graph.ts | 43 +++++++++++++++++----------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index dc0ea96..451351f 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -4892,6 +4892,7 @@ export class Analyzer { let overloads: FunctionDeclNode[] = []; let chainAttempted = false; + let dotIsPartOfChain = false; let containingClassName: string | undefined; // Try to find the containing class for this call site (needed for template resolution) @@ -4914,6 +4915,7 @@ export class Analyzer { // static class reference, even if a class named "Icons" exists. const beforeObjName = textBeforeFunc.substring(0, dotMatch.index).trimEnd(); const isPartOfChain = beforeObjName.length > 0 && (beforeObjName[beforeObjName.length - 1] === '.' || beforeObjName[beforeObjName.length - 1] === ')'); + dotIsPartOfChain = isPartOfChain; if (!isPartOfChain && objName[0] === objName[0].toUpperCase() && this.classIndex.has(objName)) { overloads = this.findFunctionOverloads(funcName, objName); } @@ -5124,22 +5126,31 @@ export class Analyzer { continue; } - // Warn about unknown functions only when the index is large enough to be confident - if (this.docCache.size >= 500) { - // Skip warning for chain calls where we couldn't resolve the target type — - // we don't know what class the method belongs to - const isUnresolvedChain = chainAttempted || (dotMatch && !getVarTypeAtLine(dotMatch[1], lineNum) && dotMatch[1][0] !== dotMatch[1][0].toUpperCase()); - if (!isUnresolvedChain) { - const startPos = doc.positionAt(match.index); - const endPos = doc.positionAt(match.index + funcName.length); - diags.push({ - message: dotMatch - ? `Unknown method '${funcName}' on type '${getVarTypeAtLine(dotMatch[1], lineNum) || dotMatch[1]}'` - : `Unknown function '${funcName}'`, - range: { start: startPos, end: endPos }, - severity: DiagnosticSeverity.Warning - }); - } + // Skip warning for chain calls where we couldn't resolve the target type — + // we don't know what class the method belongs to + const dotObj = dotMatch ? dotMatch[1] : undefined; + const dotObjType = dotObj ? getVarTypeAtLine(dotObj, lineNum) : undefined; + const dotObjIsKnownClass = !!dotObj && dotObj[0] === dotObj[0].toUpperCase() && this.classIndex.has(dotObj); + const isUnresolvedChain = + (!dotMatch && chainAttempted) || + (!!dotMatch && (dotIsPartOfChain || (!dotObjType && !dotObjIsKnownClass))); + + // Global/unknown receiver calls need a large index to avoid noise. + // But when receiver type is known (obj.Method), we can warn confidently + // even with a smaller index. + const hasConfidentReceiverType = !!dotMatch && (!!dotObjType || dotObjIsKnownClass); + const canWarn = this.docCache.size >= 500 || hasConfidentReceiverType; + + if (canWarn && !isUnresolvedChain) { + const startPos = doc.positionAt(match.index); + const endPos = doc.positionAt(match.index + funcName.length); + diags.push({ + message: dotMatch + ? `Unknown method '${funcName}' on type '${dotObjType || dotObj}'` + : `Unknown function '${funcName}'`, + range: { start: startPos, end: endPos }, + severity: DiagnosticSeverity.Warning + }); } continue; } From e9fa7586373bb2cd8269af95dae0b3842fe7c433 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sun, 15 Feb 2026 23:30:26 -0500 Subject: [PATCH 30/46] Add DayZ config language & diagnostics Introduce a lightweight DayZ config language mode and basic static checks. Adds language configuration (language-configuration-dayzcpp.json), a TextMate grammar (syntaxes/dayzcpp.tmLanguage.json) and diagnostics (src/dayzcppDiagnostics.ts) that warn about common config issues (double backslashes, mixed slashes, absolute Windows paths, suspicious forward-class/inheritance and possible missing semicolons). Registers the new "dayzcpp" language in package.json (associating config.cpp) and activates diagnostics from extension.ts. README updated to document the new DayZ config highlighting and warnings. --- README.md | 9 + language-configuration-dayzcpp.json | 59 ++++++ package.json | 16 ++ src/dayzcppDiagnostics.ts | 100 +++++++++ src/extension.ts | 3 + syntaxes/dayzcpp.tmLanguage.json | 317 ++++++++++++++++++++++++++++ 6 files changed, 504 insertions(+) create mode 100644 language-configuration-dayzcpp.json create mode 100644 src/dayzcppDiagnostics.ts create mode 100644 syntaxes/dayzcpp.tmLanguage.json diff --git a/README.md b/README.md index ade10da..25926d0 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,15 @@ Find your extracted scripts folder (usually `P:\scripts`) and add it to user set ![syntax](https://raw.githubusercontent.com/yuvalino/enscript/refs/heads/main/media/syntax.jpg) +### DayZ `config.cpp` / `mod.cpp` basic highlighting + +This extension now includes a separate lightweight language mode for DayZ config-style `config.cpp` and `mod.cpp` files. + +- It provides basic highlighting for class blocks, key/value assignments, arrays, strings, numbers, comments, and preprocessor lines. +- It also provides lightweight warnings for common config mistakes (especially AI-generated ones), such as doubled backslashes in paths, mixed slash styles, accidental absolute Windows paths, and suspicious assignment/class declaration forms. +- It is intentionally minimal and isolated from the EnScript language server features. +- It only auto-associates files named `config.cpp` and `mod.cpp`, so regular C++ projects are not affected. + 2. **Hover & Jump to Definition:** Indexed symbols have their own hover and may be Ctrl+Click'ed to jump to definition. ![definition.gif](https://raw.githubusercontent.com/yuvalino/enscript/refs/heads/main/media/definition.gif) diff --git a/language-configuration-dayzcpp.json b/language-configuration-dayzcpp.json new file mode 100644 index 0000000..a5ef95d --- /dev/null +++ b/language-configuration-dayzcpp.json @@ -0,0 +1,59 @@ +{ + "comments": { + "lineComment": "//", + "blockComment": [ + "/*", + "*/" + ] + }, + "brackets": [ + [ + "{", + "}" + ], + [ + "[", + "]" + ], + [ + "(", + ")" + ] + ], + "autoClosingPairs": [ + { + "open": "{", + "close": "}" + }, + { + "open": "[", + "close": "]" + }, + { + "open": "(", + "close": ")" + }, + { + "open": "\"", + "close": "\"" + } + ], + "surroundingPairs": [ + [ + "{", + "}" + ], + [ + "[", + "]" + ], + [ + "(", + ")" + ], + [ + "\"", + "\"" + ] + ] +} diff --git a/package.json b/package.json index 255098c..7a7aa70 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,17 @@ ".cproj" ], "configuration": "./language-configuration.json" + }, + { + "id": "dayzcpp", + "aliases": [ + "DayZ Config Cpp", + "dayzcpp" + ], + "filenames": [ + "config.cpp" + ], + "configuration": "./language-configuration-dayzcpp.json" } ], "grammars": [ @@ -36,6 +47,11 @@ "language": "enscript", "scopeName": "source.enscript", "path": "./syntaxes/enscript.tmLanguage.json" + }, + { + "language": "dayzcpp", + "scopeName": "source.dayzcpp", + "path": "./syntaxes/dayzcpp.tmLanguage.json" } ], "commands": [ diff --git a/src/dayzcppDiagnostics.ts b/src/dayzcppDiagnostics.ts new file mode 100644 index 0000000..c8cc1f3 --- /dev/null +++ b/src/dayzcppDiagnostics.ts @@ -0,0 +1,100 @@ +import * as vscode from 'vscode'; + +const ASSET_PATH_EXT_REGEX = /\.(?:p3d|paa|rvmat|xml|layout)\b/i; +const PATH_HINT_REGEX = /(?:^|[\\/])(dz|dayzexpansion|basicmap|playermarkets|_uframework)(?:[\\/]|$)/i; + +function isLikelyPathValue(value: string): boolean { + return value.includes('\\') || value.includes('/') || ASSET_PATH_EXT_REGEX.test(value) || PATH_HINT_REGEX.test(value); +} + +export function activateDayzCppDiagnostics(context: vscode.ExtensionContext): void { + const collection = vscode.languages.createDiagnosticCollection('dayzcpp'); + context.subscriptions.push(collection); + + const validate = (doc: vscode.TextDocument) => { + if (doc.languageId !== 'dayzcpp') { + collection.delete(doc.uri); + return; + } + + const diagnostics: vscode.Diagnostic[] = []; + const text = doc.getText(); + + const stringRegex = /"([^"\n]*)"/g; + let strMatch: RegExpExecArray | null; + + while ((strMatch = stringRegex.exec(text)) !== null) { + const rawWithQuotes = strMatch[0]; + const value = strMatch[1]; + const startOffset = strMatch.index; + const endOffset = startOffset + rawWithQuotes.length; + const range = new vscode.Range(doc.positionAt(startOffset), doc.positionAt(endOffset)); + + if (!isLikelyPathValue(value)) continue; + + if (value.includes('\\\\')) { + diagnostics.push(new vscode.Diagnostic( + range, + 'DayZ config path likely contains double backslashes. Use single backslashes in config.cpp/mod.cpp paths (example: "\\dz\\weapons\\...").', + vscode.DiagnosticSeverity.Warning + )); + } + + if (value.includes('\\') && value.includes('/')) { + diagnostics.push(new vscode.Diagnostic( + range, + 'Mixed slash styles in path. Prefer consistent DayZ-style single backslashes.', + vscode.DiagnosticSeverity.Warning + )); + } + + if (/^[A-Za-z]:\\/.test(value)) { + diagnostics.push(new vscode.Diagnostic( + range, + 'Absolute Windows filesystem path detected. DayZ config values should usually use game-relative paths.', + vscode.DiagnosticSeverity.Warning + )); + } + } + + const lineCount = doc.lineCount; + for (let line = 0; line < lineCount; line++) { + const lineText = doc.lineAt(line).text; + + const classForwardWithBase = lineText.match(/^\s*class\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z_][A-Za-z0-9_]*)\s*;/); + if (classForwardWithBase) { + const start = lineText.indexOf(classForwardWithBase[0].trim()); + const range = new vscode.Range(line, Math.max(0, start), line, Math.max(0, start) + classForwardWithBase[0].trim().length); + diagnostics.push(new vscode.Diagnostic( + range, + 'Suspicious class declaration: forward declarations usually do not include inheritance. Use either "class A;" or a full class body.', + vscode.DiagnosticSeverity.Warning + )); + } + + const assignmentNoSemicolon = lineText.match(/^\s*[A-Za-z_][A-Za-z0-9_]*(?:\s*\[\s*\])?\s*=\s*[^;{}]*$/); + if (assignmentNoSemicolon) { + const trimmed = lineText.trim(); + if (!trimmed.endsWith('{') && !trimmed.endsWith('}') && !trimmed.endsWith(',') && !trimmed.endsWith('=') && !trimmed.startsWith('//')) { + const range = new vscode.Range(line, 0, line, lineText.length); + diagnostics.push(new vscode.Diagnostic( + range, + 'Possible missing semicolon in config assignment.', + vscode.DiagnosticSeverity.Warning + )); + } + } + } + + collection.set(doc.uri, diagnostics); + }; + + context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(validate)); + context.subscriptions.push(vscode.workspace.onDidChangeTextDocument((e) => validate(e.document))); + context.subscriptions.push(vscode.workspace.onDidSaveTextDocument(validate)); + context.subscriptions.push(vscode.workspace.onDidCloseTextDocument((doc) => collection.delete(doc.uri))); + + for (const doc of vscode.workspace.textDocuments) { + validate(doc); + } +} diff --git a/src/extension.ts b/src/extension.ts index 99c43b6..8f25dd3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,11 +6,14 @@ import { ServerOptions, TransportKind } from 'vscode-languageclient/node'; +import { activateDayzCppDiagnostics } from './dayzcppDiagnostics'; let client: LanguageClient | undefined; let statusBarItem: vscode.StatusBarItem | undefined; export async function activate(context: vscode.ExtensionContext) { + activateDayzCppDiagnostics(context); + const serverModule = path.join(__dirname, '..', 'server', 'out', 'index.js'); const debugOptions = { execArgv: ['--nolazy', '--inspect=6009'] }; diff --git a/syntaxes/dayzcpp.tmLanguage.json b/syntaxes/dayzcpp.tmLanguage.json new file mode 100644 index 0000000..d5e4b14 --- /dev/null +++ b/syntaxes/dayzcpp.tmLanguage.json @@ -0,0 +1,317 @@ +{ + "name": "DayZ Config Cpp", + "scopeName": "source.dayzcpp", + "fileTypes": [ + "cpp" + ], + "patterns": [ + { + "include": "#comments" + }, + { + "include": "#preprocessor" + }, + { + "include": "#cfg-root-class" + }, + { + "include": "#forward-class-declaration" + }, + { + "include": "#inline-empty-class" + }, + { + "include": "#class-declaration" + }, + { + "include": "#script-module-class" + }, + { + "include": "#macro-symbols" + }, + { + "include": "#common-property-assignment" + }, + { + "include": "#array-assignment" + }, + { + "include": "#property-assignment" + }, + { + "include": "#operators-and-punctuation" + }, + { + "include": "#keywords" + }, + { + "include": "#constants" + }, + { + "include": "#path-strings" + }, + { + "include": "#strings" + }, + { + "include": "#numbers" + } + ], + "repository": { + "comments": { + "patterns": [ + { + "name": "comment.line.double-slash.dayzcpp", + "match": "//.*$" + }, + { + "name": "comment.block.dayzcpp", + "begin": "/\\*", + "end": "\\*/" + } + ] + }, + "preprocessor": { + "patterns": [ + { + "name": "meta.preprocessor.dayzcpp", + "match": "^\\s*(#\\s*[A-Za-z_][A-Za-z0-9_]*)", + "captures": { + "1": { + "name": "keyword.control.import.dayzcpp" + } + } + } + ] + }, + "cfg-root-class": { + "patterns": [ + { + "name": "meta.class.cfg-root.dayzcpp", + "match": "\\b(class)\\s+(CfgPatches|CfgVehicles|CfgWeapons|CfgMagazines|CfgAmmo|CfgSlots|CfgNonAIVehicles|CfgMods|CfgSoundSets|CfgSoundShaders|CfgSounds|CfgSoundTables|CfgSurfaces|CfgWorlds|CfgMovesBasic|CfgMovesMaleSdr|CfgVehiclesProxy|CfgEventTypes|CfgSoundGlobals|CfgEnvSounds)", + "captures": { + "1": { + "name": "storage.type.class.dayzcpp" + }, + "2": { + "name": "support.type.cfg-section.dayzcpp" + } + } + } + ] + }, + "forward-class-declaration": { + "patterns": [ + { + "name": "meta.class.forward.dayzcpp", + "match": "\\b(class)\\s+([A-Za-z_][A-Za-z0-9_]*)(\\s*;)", + "captures": { + "1": { + "name": "storage.type.class.dayzcpp" + }, + "2": { + "name": "entity.name.type.class.dayzcpp" + }, + "3": { + "name": "punctuation.separator.key-value.dayzcpp" + } + } + } + ] + }, + "inline-empty-class": { + "patterns": [ + { + "name": "meta.class.inline-empty.dayzcpp", + "match": "\\b(class)\\s+([A-Za-z_][A-Za-z0-9_]*)(?:\\s*:\\s*([A-Za-z_][A-Za-z0-9_]*))?\\s*(\\{\\s*\\})\\s*(;)", + "captures": { + "1": { + "name": "storage.type.class.dayzcpp" + }, + "2": { + "name": "entity.name.type.class.dayzcpp" + }, + "3": { + "name": "entity.other.inherited-class.dayzcpp" + }, + "4": { + "name": "meta.block.empty.dayzcpp" + }, + "5": { + "name": "punctuation.separator.key-value.dayzcpp" + } + } + } + ] + }, + "class-declaration": { + "patterns": [ + { + "name": "meta.class.dayzcpp", + "match": "\\b(class)\\s+([A-Za-z_][A-Za-z0-9_]*)(?:\\s*:\\s*([A-Za-z_][A-Za-z0-9_]*))?", + "captures": { + "1": { + "name": "storage.type.class.dayzcpp" + }, + "2": { + "name": "entity.name.type.class.dayzcpp" + }, + "3": { + "name": "entity.other.inherited-class.dayzcpp" + } + } + } + ] + }, + "script-module-class": { + "patterns": [ + { + "name": "support.class.script-module.dayzcpp", + "match": "\\b(engineScriptModule|gameLibScriptModule|gameScriptModule|worldScriptModule|missionScriptModule)\\b" + } + ] + }, + "macro-symbols": { + "patterns": [ + { + "name": "constant.other.macro.dayzcpp", + "match": "\\$[A-Za-z_][A-Za-z0-9_]*" + } + ] + }, + "common-property-assignment": { + "patterns": [ + { + "name": "meta.assignment.common-property.dayzcpp", + "match": "\\b(scope|minScope|scopeArsenal|scopeCurator|displayName|descriptionShort|model|simulation|inventorySlot|requiredVersion|maxSpeed|minSpeed|acceleration|mass|weight|itemsCargoSize|hiddenSelections|hiddenSelectionsTextures|requiredAddons|units|weapons|magazines|attachments|cargoClass|vehicleClass|editorCategory|editorSubcategory|author|authorID|credits|name|version|dir|picture|action|hideName|hidePicture|extra|type|inputs|defines|dependencies|files|value|count|damage|ammo|soundSetShot|soundSetShotExt|itemSize|itemBehaviour|rotationFlags|absorbency|repairableWithKits|repairCosts|hitpoints|healthLevels|source|animPeriod|initPhase|stackedUnit|quantityBar|varQuantityInit|varQuantityMin|varQuantityMax|varQuantityDestroyOnMin|hasIcon|autoSwitchOffWhenInCargo|energyUsagePerSecond|plugType|attachmentAction)\\b\\s*(=)", + "captures": { + "1": { + "name": "variable.other.property.common.dayzcpp" + }, + "2": { + "name": "keyword.operator.assignment.dayzcpp" + } + } + } + ] + }, + "array-assignment": { + "patterns": [ + { + "name": "meta.assignment.array.dayzcpp", + "match": "\\b([A-Za-z_][A-Za-z0-9_]*)(\\s*\\[\\s*\\])\\s*(=)", + "captures": { + "1": { + "name": "variable.other.property.dayzcpp" + }, + "2": { + "name": "punctuation.definition.array.dayzcpp" + }, + "3": { + "name": "keyword.operator.assignment.dayzcpp" + } + } + } + ] + }, + "property-assignment": { + "patterns": [ + { + "name": "meta.assignment.property.dayzcpp", + "match": "\\b([A-Za-z_][A-Za-z0-9_]*)(?!\\s*\\[)\\s*(=)", + "captures": { + "1": { + "name": "variable.other.property.dayzcpp" + }, + "2": { + "name": "keyword.operator.assignment.dayzcpp" + } + } + } + ] + }, + "operators-and-punctuation": { + "patterns": [ + { + "name": "keyword.operator.assignment.dayzcpp", + "match": "=" + }, + { + "name": "punctuation.separator.key-value.dayzcpp", + "match": ";" + }, + { + "name": "punctuation.separator.comma.dayzcpp", + "match": "," + }, + { + "name": "punctuation.definition.block.begin.dayzcpp", + "match": "\\{" + }, + { + "name": "punctuation.definition.block.end.dayzcpp", + "match": "\\}" + }, + { + "name": "punctuation.definition.array.dayzcpp", + "match": "\\[\\s*\\]" + }, + { + "name": "punctuation.definition.inheritance.dayzcpp", + "match": ":" + } + ] + }, + "keywords": { + "patterns": [ + { + "name": "keyword.control.dayzcpp", + "match": "\\b(class|enum|delete|new|extern)\\b" + } + ] + }, + "constants": { + "patterns": [ + { + "name": "constant.language.dayzcpp", + "match": "\\b(true|false|null|NULL)\\b" + } + ] + }, + "strings": { + "patterns": [ + { + "name": "string.quoted.double.dayzcpp", + "begin": "\"", + "end": "\"", + "patterns": [ + { + "name": "constant.character.escape.dayzcpp", + "match": "\\\\." + } + ] + } + ] + }, + "path-strings": { + "patterns": [ + { + "name": "string.quoted.double.path.dayzcpp", + "match": "\"[^\"\\n]*\\.(?:p3d|paa|rvmat|xml|layout)\"" + }, + { + "name": "string.quoted.double.path.dayzcpp", + "match": "\"(?:[A-Za-z]:)?(?:\\\\|/)?(?:dz|DZ|DayZExpansion|BasicMap|PlayerMarkets|_UFramework)[^\"\\n]*\"" + } + ] + }, + "numbers": { + "patterns": [ + { + "name": "constant.numeric.dayzcpp", + "match": "\\b-?(?:0x[0-9A-Fa-f]+|\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)\\b" + } + ] + } + } +} From 7182719a0c65a26c170357107d915581f647300c Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Thu, 19 Feb 2026 19:57:50 -0500 Subject: [PATCH 31/46] Normalize file URIs to lowercase on Windows Lowercase file:// URIs that contain a drive letter so Windows paths (which are case-insensitive) produce consistent cache keys. The normalized URI from vscode-uri is tested for the percent-encoded drive-letter pattern (e.g. file:///c%3A/), and when matched the entire URI is lowercased to avoid mismatches between the workspace scanner and editor document URIs. --- server/src/util/uri.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/server/src/util/uri.ts b/server/src/util/uri.ts index 61336ae..b5fbf63 100644 --- a/server/src/util/uri.ts +++ b/server/src/util/uri.ts @@ -1,5 +1,13 @@ import { URI } from 'vscode-uri'; export function normalizeUri(uri: string): string { - return URI.parse(uri).toString(); + const normalized = URI.parse(uri).toString(); + // On Windows, file paths are case-insensitive. Lowercase the entire URI + // for file:// URIs with a drive letter (e.g. file:///c%3A/...) so the same + // physical file always maps to the same cache key regardless of path casing + // differences between the workspace scanner and the editor's document URIs. + if (/^file:\/\/\/[a-z]%3A/i.test(normalized)) { + return normalized.toLowerCase(); + } + return normalized; } \ No newline at end of file From 15853e82c3ea532cb81952dbf73d2acbec3c06b6 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Thu, 19 Feb 2026 20:55:52 -0500 Subject: [PATCH 32/46] Unify and robustify chain resolution Replace fragile regex-based dot-chain handling with a unified, backward-scanning parser and resolver. Adds parseExpressionChainBackward and resolveFullChain to correctly handle arbitrarily deep chains, nested parentheses, indexed access, `this`/`super`, function calls and mixed call/field segments. Introduces counting of indexing levels and resolveIndexedContainerType/peelIndexingLevels to resolve element types via container Get() methods (with special-case handling for Cast and primitives like vector/string). Propagates template/generic maps through chain steps and supports enum member completions. Improvements also include string-literal-aware parenthesis scanning, heuristics to avoid treating generic angle brackets as comparisons, reconstruction of generic return type strings, and deduplication/normalization of symbols and class nodes across different URIs/paths to avoid duplicate completions/definitions. --- server/src/analysis/project/graph.ts | 1428 +++++++++++++++++--------- 1 file changed, 917 insertions(+), 511 deletions(-) diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 451351f..11eeae3 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -722,146 +722,68 @@ export class Analyzer { const textBeforeCursor = text.substring(0, offset); // ================================================================ - // MULTI-LEVEL CHAIN COMPLETION + // UNIFIED CHAIN COMPLETION — handles all dot-chain patterns: + // variable.prefix, func().prefix, var.field.method().prefix, + // func().field.method().prefix, Class.prefix, this.prefix, + // and arbitrarily deep chains with nested parentheses. // ================================================================ - // Handles chains like: param.param4.G or param.Get("x").To - // Detects 2+ dot-separated segments, resolves through the chain - // (with typedef/template substitution), and offers completions - // for the final resolved type. - // ================================================================ - const multiDotMatch = textBeforeCursor.match(/(\w+)((?:\s*\.\s*\w+(?:\s*\([^)]*\))?)+)\s*\.\s*(\w*)$/); - if (multiDotMatch) { - const rootName = multiDotMatch[1]; - const middleChain = multiDotMatch[2]; // e.g., ".param4" or ".Get(key).field" - const prefix = multiDotMatch[3] || ''; - - // Resolve the root variable's type - const rootType = this.resolveVariableType(doc, pos, rootName); - if (rootType) { - // Resolve root through typedef and build initial template map - let currentType = rootType; - let templateMap: Map; - const typedefNode = this.resolveTypedefNode(currentType); - if (typedefNode) { - currentType = typedefNode.oldType.identifier; - templateMap = this.buildTemplateMap(currentType, typedefNode.oldType.genericArgs); - } else { - const varTypeNode = this.resolveVariableTypeNode(doc, pos, rootName); - if (varTypeNode?.genericArgs && varTypeNode.genericArgs.length > 0) { - templateMap = this.buildTemplateMap(currentType, varTypeNode.genericArgs); - } else { - templateMap = new Map(); - } - } - - // Resolve through the middle chain steps - const chainMembers = this.parseChainMembers(middleChain); - if (chainMembers.length > 0) { - const result = this.resolveChainSteps(chainMembers, currentType, templateMap); - if (result) { - return this.getClassMemberCompletions(result.type, prefix, result.templateMap.size > 0 ? result.templateMap : undefined); - } - } - } - } - - // Match both variable.method and function().method patterns - // Pattern 1: variable. or variable.prefix - // Pattern 2: function(). or function().prefix - // Pattern 3: function(args). or function(args).prefix - const dotMatch = textBeforeCursor.match(/(\w+)(\([^)]*\))?\s*\.\s*(\w*)$/); - - if (dotMatch) { - // MEMBER COMPLETION MODE - const name = dotMatch[1]; - const hasParens = !!dotMatch[2]; // true if it's a function call like GetGame() - const prefix = dotMatch[3] || ''; - - - // Handle 'this' keyword - if (name === 'this') { - const containingClass = this.findContainingClass(ast, pos); - if (containingClass) { - return this.getClassMemberCompletions(containingClass.name, prefix); - } - } - - // Handle 'super' keyword - if (name === 'super') { - const containingClass = this.findContainingClass(ast, pos); - if (containingClass?.base?.identifier) { - return this.getClassMemberCompletions(containingClass.base.identifier, prefix); + // Extract the prefix (partial identifier being typed after the last dot) + // and the "before-dot" text for chain resolution. + const completionDotMatch = textBeforeCursor.match(/\.\s*(\w*)$/); + if (completionDotMatch) { + const prefix = completionDotMatch[1] || ''; + // Text up to (and including) the dot, for chain parsing + const textUpToDot = textBeforeCursor.substring(0, textBeforeCursor.length - completionDotMatch[0].length + 1); + // textUpToDot ends with '.', which is what parseExpressionChainBackward expects + + const chainResult = this.resolveFullChain(textUpToDot, doc, pos, ast); + if (chainResult) { + // Check if it's an enum type → show enum members + const enumNode = this.findEnumByName(chainResult.type); + if (enumNode) { + return this.getEnumMemberCompletions(enumNode, prefix); } + // Show class members (methods, fields, statics) + return this.getClassMemberCompletions( + chainResult.type, + prefix, + chainResult.templateMap.size > 0 ? chainResult.templateMap : undefined + ); } - // If it's a function call like GetGame(). → look up the function's return type - if (hasParens) { - let returnType = this.resolveFunctionReturnType(name); - // Fall back to method of containing class (e.g., GetInstance() inside a class) - if (!returnType) { - const cc = this.findContainingClass(ast, pos); - if (cc) { - returnType = this.resolveMethodReturnType(cc.name, name); + // Fallback for simple `name.` where the name is a class for statics + // or an unresolved variable — try class static completion + const simpleNameMatch = textUpToDot.match(/(\w+)\s*\.$/); + if (simpleNameMatch) { + const name = simpleNameMatch[1]; + + // Handle 'this' keyword + if (name === 'this') { + const containingClass = this.findContainingClass(ast, pos); + if (containingClass) { + return this.getClassMemberCompletions(containingClass.name, prefix); } } - if (returnType) { - return this.getClassMemberCompletions(returnType, prefix); - } - } - - // Try to resolve the variable's type - const varType = this.resolveVariableType(doc, pos, name); - - if (varType) { - // ================================================================ - // TYPEDEF + TEMPLATE TYPE RESOLUTION FOR COMPLETIONS - // ================================================================ - // When a variable has a typedef'd type (e.g., testMapType → map), - // we need to: - // 1. Resolve the typedef to the underlying class name - // 2. Build a template substitution map (TKey→string, TValue→string) - // 3. Pass the map to getClassMemberCompletions so it can replace - // generic param names with concrete types in the completion details - // - // This also handles direct generic declarations like: map myMap; - // In that case we get the TypeNode (which has genericArgs) and build the map. - // ================================================================ - const typedefNode = this.resolveTypedefNode(varType); - let resolvedType: string; - let tplMap: Map | undefined; - if (typedefNode) { - // Typedef path: e.g., testMapType → oldType is map - resolvedType = typedefNode.oldType.identifier; - tplMap = this.buildTemplateMap(resolvedType, typedefNode.oldType.genericArgs); - } else { - resolvedType = varType; - // Direct generic path: e.g., map myMap - // resolveVariableTypeNode returns the full TypeNode with genericArgs - const varTypeNode = this.resolveVariableTypeNode(doc, pos, name); - if (varTypeNode?.genericArgs && varTypeNode.genericArgs.length > 0) { - tplMap = this.buildTemplateMap(resolvedType, varTypeNode.genericArgs); + // Handle 'super' keyword + if (name === 'super') { + const containingClass = this.findContainingClass(ast, pos); + if (containingClass?.base?.identifier) { + return this.getClassMemberCompletions(containingClass.base.identifier, prefix); } } - // Get methods/fields for this type (including inherited) - const members = this.getClassMemberCompletions(resolvedType, prefix, tplMap); - return members; - } - - // If name looks like a class name (starts with uppercase), - // it might be a static method call: ClassName.StaticMethod() - // OR an enum access: EnumName.EnumValue - if (name[0] === name[0].toUpperCase()) { - // First check if it's an enum - const enumNode = this.findEnumByName(name); - if (enumNode) { - return this.getEnumMemberCompletions(enumNode, prefix); - } - // Otherwise check for class static members - const classNode = this.findClassByName(name); - if (classNode) { - return this.getStaticMemberCompletions(classNode, prefix); + if (name[0] === name[0].toUpperCase()) { + // Check for enum + const enumNode2 = this.findEnumByName(name); + if (enumNode2) { + return this.getEnumMemberCompletions(enumNode2, prefix); + } + // Check for class statics + const classNode = this.findClassByName(name); + if (classNode) { + return this.getStaticMemberCompletions(classNode, prefix); + } } } @@ -986,14 +908,16 @@ export class Analyzer { const regexKeywords = new Set(['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', 'else', 'foreach', 'void', 'override', 'static', 'private', 'protected', 'const', 'ref', 'autoptr', 'proto', 'native', 'modded', 'sealed', 'event', 'typedef', 'case', 'break', 'continue', 'this', 'super', 'null', 'true', 'false', 'out', 'inout', 'volatile']); // Pattern: Type varName; or Type varName = or Type varName[ (C-style array) + // Also handles generic types: Type varName; // Use word boundary to avoid matching inside larger expressions - const varDeclMatch = text.match(new RegExp(`(?:^|[{;,\\s])\\s*(\\w+)\\s+${varName}\\s*[;=\\[]`)); + const varDeclMatch = text.match(new RegExp(`(?:^|[{;,\\s])\\s*(\\w+)(?:\\s*<[^>]*>)?\\s+${varName}\\s*[;=\\[(]`)); if (varDeclMatch && !regexKeywords.has(varDeclMatch[1])) { return varDeclMatch[1]; } // Pattern: (Type varName) or (Type varName,) or (Type varName[) - function parameters - const paramMatch = text.match(new RegExp(`[,(]\\s*(\\w+)\\s+${varName}\\s*[,)\\[]`)); + // Also handles generic types + const paramMatch = text.match(new RegExp(`[,(]\\s*(\\w+)(?:\\s*<[^>]*>)?\\s+${varName}\\s*[,)\\[]`)); if (paramMatch) { return paramMatch[1]; } @@ -1307,34 +1231,52 @@ export class Analyzer { /** * Given a type that is being indexed with [], return the element type. - * e.g., vector[0] → float, string[0] → string, array[0] → null (skip check) + * e.g., vector[0] → float, string[0] → string + * Uses the Get method's return type for classes (container[i] == container.Get(i)). * Returns null if the indexed type cannot be determined (to avoid false positives). */ private resolveIndexedType(containerType: string): string | null { const lower = containerType.toLowerCase(); if (lower === 'vector') return 'float'; if (lower === 'string') return 'string'; - // For arrays, maps, sets, etc., we can't easily determine the element type - // from just the base type name, so return null to skip the type check. + // Look up the Get method's return type on this class + const getReturnType = this.resolveMethodReturnTypeNode(containerType, 'Get'); + if (getReturnType?.identifier) { + const retType = getReturnType.identifier; + // If Get returns a template param (e.g. T, TValue), we can't resolve without + // concrete generic args, so return null to skip the check + const genericVars = this.getClassGenericVars(containerType); + if (genericVars?.includes(retType)) return null; + return retType; + } return null; } /** - * Check if a chain/expression text contains `[...]` indexing OUTSIDE of - * parenthesised argument lists. e.g.: - * ".GetOrientation()[0]" → true ([0] is after the call) - * ".GetAimDelta(pDt)[0] * RAD2DEG" → true - * ".GetSurface(pos[0], pos[2])" → false ([0]/[2] inside args) + * Count the number of `[...]` indexing levels OUTSIDE of parenthesised + * argument lists. e.g.: + * ".GetOrientation()[0]" → 1 + * ".m_Patterns[id1][id2]" → 2 (2D matrix) + * ".GetSurface(pos[0], pos[2])" → 0 ([0]/[2] inside args) */ - private hasIndexingAfterCall(text: string): boolean { + private countIndexingLevels(text: string): number { let parenDepth = 0; + let bracketDepth = 0; + let count = 0; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (ch === '(') parenDepth++; else if (ch === ')') { if (parenDepth > 0) parenDepth--; } - else if (ch === '[' && parenDepth === 0) return true; + else if (parenDepth === 0) { + if (ch === '[') { + if (bracketDepth === 0) count++; + bracketDepth++; + } else if (ch === ']') { + if (bracketDepth > 0) bracketDepth--; + } + } } - return false; + return count; } /** @@ -1365,6 +1307,46 @@ export class Analyzer { if ((ch === '=' || ch === '!' || ch === '<' || ch === '>') && next === '=') return true; if (ch === '&' && next === '&') return true; if (ch === '|' && next === '|') return true; + // Single < and > as comparison operators (not template angle brackets). + // Exclude bit-shift operators << and >> (and compound <<= >>=). + // Heuristic: treat as comparison when preceded by ), ], digit, or word char + // AND when the < doesn't look like a generic type argument list. + if ((ch === '<' || ch === '>') && i > 0) { + // Skip <<, >>, <<=, >>= (bit shift operators, not comparisons) + if (next === ch) { i++; continue; } // << or >> — skip both chars + if (next === '=') continue; // <= or >= (already handled above as two-char ops) + // Also skip if the PREVIOUS char is < or > (second char of << or >>) + if (text[i - 1] === ch) continue; + + // For '<', check if this looks like a generic angle bracket , + // by scanning ahead for a matching '>' with only type-like content inside. + if (ch === '<') { + let angleDep = 1; + let looksGeneric = true; + let j = i + 1; + while (j < text.length && angleDep > 0) { + const c = text[j]; + if (c === '<') angleDep++; + else if (c === '>') angleDep--; + // Generic args contain: word chars, commas, spaces, nested <> + else if (!/[\w\s,]/.test(c)) { looksGeneric = false; break; } + j++; + } + if (looksGeneric && angleDep === 0) { + i = j - 1; // Skip past the closing '>' so it doesn't trigger as comparison + continue; + } + } + + let pi = i - 1; + while (pi >= 0 && (text[pi] === ' ' || text[pi] === '\t')) pi--; + if (pi >= 0) { + const prev = text[pi]; + if (prev === ')' || prev === ']' || /[\w\d]/.test(prev)) { + return true; + } + } + } } return false; } @@ -1399,6 +1381,312 @@ export class Analyzer { * @param chainText The full chain text starting from the first function * @returns The return type of the final call in the chain, or null if unresolved */ + + // ======================================================================== + // ROBUST EXPRESSION CHAIN PARSER — backward-scanning, paren-aware + // ======================================================================== + // Replaces fragile regex patterns that couldn't handle nested parentheses. + // Parses backwards from the end of `textBeforeToken` (which ends at a dot), + // extracting chain segments like: + // "GetGame().GetObjectByNetworkId(low, high)." + // → [{ name: "GetGame", isCall: true }, { name: "GetObjectByNetworkId", isCall: true }] + // + // "a.b.c().d.e().f." + // → [{ name: "a" }, { name: "b" }, { name: "c", isCall: true }, + // { name: "d" }, { name: "e", isCall: true }, { name: "f" }] + // + // Handles arbitrarily deep chains (7+), nested parentheses in function + // arguments, mixed static/instance/function-call segments, and `this`/`super`. + // ======================================================================== + + /** + * Parse an expression chain backward from text ending with a dot. + * Properly handles nested parentheses (e.g., `Cast(GetGame().Foo(a, b))`) + * and mixed call/field chains of arbitrary depth. + * + * @returns Array of chain segments in forward order (root first), empty if no chain found. + */ + private parseExpressionChainBackward(text: string): { name: string; isCall: boolean; isIndexed: boolean }[] { + const segments: { name: string; isCall: boolean; isIndexed: boolean }[] = []; + let i = text.length - 1; + + // Skip trailing whitespace + while (i >= 0 && /\s/.test(text[i])) i--; + + // Must end with '.' + if (i < 0 || text[i] !== '.') return []; + i--; // skip the dot + + // Parse segments backward + while (true) { + // Skip whitespace + while (i >= 0 && /\s/.test(text[i])) i--; + if (i < 0) break; + + let isCall = false; + let isIndexed = false; + + // Handle trailing ')' (function call) and/or ']' (array indexing) + // in a loop, since they can appear in any order and combination: + // e.g., GetItems()[0] scans backward as ']' then ')'. + while (i >= 0 && (text[i] === ')' || text[i] === ']')) { + if (text[i] === ')') { + isCall = true; + let depth = 1; + i--; // skip ')' + while (i >= 0 && depth > 0) { + if (text[i] === ')') depth++; + else if (text[i] === '(') depth--; + // Skip string literals backward: when we hit a closing quote, + // scan back to the matching open quote (handling escapes) + else if (text[i] === '"' || text[i] === "'") { + const q = text[i]; + i--; + while (i >= 0 && text[i] !== q) i--; + // i now on the opening quote — the loop's i-- will move past it + } + i--; + } + // i is now one before the '(' — skip whitespace + while (i >= 0 && /\s/.test(text[i])) i--; + } else if (text[i] === ']') { + isIndexed = true; + let depth = 1; + i--; // skip ']' + while (i >= 0 && depth > 0) { + if (text[i] === ']') depth++; + else if (text[i] === '[') depth--; + else if (text[i] === '"' || text[i] === "'") { + const q = text[i]; + i--; + while (i >= 0 && text[i] !== q) i--; + } + i--; + } + while (i >= 0 && /\s/.test(text[i])) i--; + } + } + + // Read identifier backward + if (i < 0 || !/\w/.test(text[i])) break; + const end = i + 1; + while (i >= 0 && /\w/.test(text[i])) i--; + const name = text.substring(i + 1, end); + + // Skip keywords that aren't valid chain roots (return, if, etc.) + // but allow 'this' and 'super' + if (!name || (name !== 'this' && name !== 'super' && /^(return|if|else|while|for|foreach|switch|case|new|delete|typeof|class|modded|static|private|protected|ref|autoptr|auto|void|int|float|bool|string|vector|const|override|break|continue|null|true|false)$/.test(name))) { + break; + } + + segments.unshift({ name, isCall, isIndexed }); + + // Check for dot before this segment + let j = i; + while (j >= 0 && /\s/.test(text[j])) j--; + if (j >= 0 && text[j] === '.') { + i = j - 1; // skip the dot and continue + } else { + break; // no more chain + } + } + + // Validate: 'this' and 'super' are only valid as the first segment. + // If they appear mid-chain (e.g., obj.this.Method), reject the chain. + for (let s = 1; s < segments.length; s++) { + if (segments[s].name === 'this' || segments[s].name === 'super') { + return []; + } + } + + return segments; + } + + /** + * Check if a type name is a template parameter of the containing class. + * If so, return the upper-bound type (constraint type or 'Class' as default). + * In Enforce Script, generic params are declared as "Class T", so the + * default upper bound for any template param is 'Class'. + * + * Also walks up the class hierarchy — if the containing class inherits + * from a generic base, the base's template params are also checked. + * + * @returns The resolved upper-bound type name, or null if not a template param. + */ + private resolveTemplateParam(typeName: string, ast: File, pos: Position): string | null { + const cc = this.findContainingClass(ast, pos); + if (!cc) return null; + + // Check the containing class's own template params + if (cc.genericVars && cc.genericVars.includes(typeName)) { + return 'Class'; + } + + // Also walk the class hierarchy in case a parent class defines the template param + const hierarchy = this.getClassHierarchyOrdered(cc.name, new Set()); + for (const cls of hierarchy) { + if (cls.genericVars && cls.genericVars.includes(typeName)) { + return 'Class'; + } + } + + return null; + } + + /** + * Resolve the root segment of an expression chain to a type. + * Handles variables, `this`, `super`, class names (static access), + * function calls, and method calls within the containing class. + * + * @returns Resolved type and template map, or null if unresolvable. + */ + private resolveChainRoot( + root: { name: string; isCall: boolean }, + doc: TextDocument, + pos: Position, + ast: File + ): { type: string; templateMap: Map } | null { + if (root.isCall) { + // Function/method call root: e.g., GetGame() or GetInstance() + let rootType = this.resolveFunctionReturnType(root.name); + if (!rootType) { + const cc = this.findContainingClass(ast, pos); + if (cc) { + rootType = this.resolveMethodReturnType(cc.name, root.name); + } + } + // Also check if it's an implicit constructor (class name used as call) + if (!rootType) { + if (this.classIndex.has(root.name)) { + rootType = root.name; + } else { + const resolved = this.resolveTypedef(root.name); + if (resolved !== root.name) { + rootType = resolved; + } + } + } + if (!rootType) return null; + + const resolved = this.resolveTypedef(rootType); + // Try to get template map from function return type node + const returnTypeNode = this.resolveFunctionReturnTypeNode(root.name); + let templateMap: Map = new Map(); + if (returnTypeNode?.genericArgs && returnTypeNode.genericArgs.length > 0) { + templateMap = this.buildTemplateMap(resolved, returnTypeNode.genericArgs); + } else { + const typedefNode = this.resolveTypedefNode(rootType); + if (typedefNode?.oldType.genericArgs && typedefNode.oldType.genericArgs.length > 0) { + templateMap = this.buildTemplateMap(typedefNode.oldType.identifier, typedefNode.oldType.genericArgs); + } + } + return { type: resolved, templateMap }; + + } else { + // Variable/property/class/this/super root + + // Handle 'this' + if (root.name === 'this') { + const cc = this.findContainingClass(ast, pos); + if (cc) return { type: cc.name, templateMap: new Map() }; + return null; + } + + // Handle 'super' + if (root.name === 'super') { + const cc = this.findContainingClass(ast, pos); + if (cc?.base?.identifier) return { type: cc.base.identifier, templateMap: new Map() }; + return null; + } + + // Try as variable first (most common) + const varType = this.resolveVariableType(doc, pos, root.name); + if (varType) { + let currentType = varType; + let templateMap: Map; + const typedefNode = this.resolveTypedefNode(currentType); + if (typedefNode) { + currentType = typedefNode.oldType.identifier; + templateMap = this.buildTemplateMap(currentType, typedefNode.oldType.genericArgs); + } else { + currentType = this.resolveTypedef(currentType); + const varTypeNode = this.resolveVariableTypeNode(doc, pos, root.name); + if (varTypeNode?.genericArgs && varTypeNode.genericArgs.length > 0) { + templateMap = this.buildTemplateMap(currentType, varTypeNode.genericArgs); + } else { + templateMap = new Map(); + } + } + + // If the resolved type is a template parameter (e.g., T, TKey), + // resolve it to its upper-bound type (Class by default in Enforce Script). + // This allows method lookups on template-typed variables like m_Entity + // to find methods on the base Class type instead of failing silently. + if (!this.classIndex.has(currentType) && !this.typedefIndex.has(currentType)) { + const resolved = this.resolveTemplateParam(currentType, ast, pos); + if (resolved) { + currentType = resolved; + templateMap = new Map(); + } + } + + return { type: currentType, templateMap }; + } + + // Try as class name (static access: e.g., PlayerBase.Cast) + if (/^[A-Z]/.test(root.name)) { + if (this.classIndex.has(root.name)) { + return { type: root.name, templateMap: new Map() }; + } + // Try typedef + const resolved = this.resolveTypedef(root.name); + if (resolved !== root.name) { + return { type: resolved, templateMap: new Map() }; + } + } + + return null; + } + } + + /** + * Full chain resolution: parse `textBeforeToken` backward, resolve each + * segment, and return the final type + template map. + * Works for any chain depth (1+) with mixed calls, fields, statics. + * + * @returns The final resolved type and template map, or null if chain + * parsing fails or any link can't be resolved. + */ + resolveFullChain( + textBeforeToken: string, + doc: TextDocument, + pos: Position, + ast: File + ): { type: string; templateMap: Map } | null { + const chain = this.parseExpressionChainBackward(textBeforeToken); + if (chain.length === 0) return null; + + const root = chain[0]; + const rest = chain.slice(1); + + let rootResult = this.resolveChainRoot(root, doc, pos, ast); + if (!rootResult) return null; + + // If the root was indexed (e.g., items[0].), dereference to element type + if (root.isIndexed) { + rootResult = this.resolveIndexedContainerType(rootResult); + } + + if (rest.length === 0) { + return rootResult; + } + + // Pass rest segments with isIndexed info to resolveChainSteps + const memberSegments = rest.map(s => ({ name: s.name, isIndexed: s.isIndexed })); + const result = this.resolveChainStepsWithIndexing(memberSegments, rootResult.type, rootResult.templateMap); + return result; + } + /** * Parse chained member accesses from text like ".Method(args).Prop.Other()" * into a list of member names: ["Method", "Prop", "Other"]. @@ -1438,12 +1726,21 @@ export class Analyzer { calls.push(methodMatch[1]); - // Skip past this call's arguments (balanced parens) + // Skip past this call's arguments (balanced parens, string-literal aware) remaining = remaining.substring(methodMatch[0].length); let parenDepth = 1, i = 0; while (i < remaining.length && parenDepth > 0) { - if (remaining[i] === '(') parenDepth++; - else if (remaining[i] === ')') parenDepth--; + const ch = remaining[i]; + if (ch === '(') parenDepth++; + else if (ch === ')') parenDepth--; + else if (ch === '"' || ch === "'") { + const q = ch; + i++; + while (i < remaining.length && remaining[i] !== q) { + if (remaining[i] === '\\') i++; + i++; + } + } i++; } remaining = remaining.substring(i).trim(); @@ -1493,6 +1790,14 @@ export class Analyzer { resolvedType = templateMap.get(resolvedType)!; } + // DayZ pattern: ClassName.Cast(x) returns ClassName, not Class. + // Cast is defined on Class as `proto native Class Cast()`, but by convention + // it returns the type it was called on (acts as a downcast). Keep the + // current receiver type and templateMap unchanged. + if (memberName === 'Cast' && resolvedType === 'Class') { + continue; + } + // Resolve through typedefs and rebuild template map for the next step const stepTypedef = this.resolveTypedefNode(resolvedType); if (stepTypedef) { @@ -1520,6 +1825,98 @@ export class Analyzer { return { type: currentType, templateMap }; } + /** + * Dereference an indexed container type using its Get method return type. + * In Enforce Script, container[i] is syntactic sugar for container.Get(i). + * Resolves the Get method's return type and applies template substitution. + * Falls back to hardcoded rules for vector/string (primitives without class defs). + */ + private resolveIndexedContainerType( + result: { type: string; templateMap: Map } + ): { type: string; templateMap: Map } { + const lower = result.type.toLowerCase(); + + // Primitive indexing fallbacks (no class definition to look up) + if (lower === 'vector') return { type: 'float', templateMap: new Map() }; + if (lower === 'string') return { type: 'string', templateMap: new Map() }; + + // Generic approach: look up the Get method's return type on this class + // container[i] == container.Get(i), so resolve Get's return type + const getReturnType = this.resolveMethodReturnTypeNode(result.type, 'Get'); + if (getReturnType?.identifier) { + let resolvedType = getReturnType.identifier; + // Apply template substitution (e.g., Get returns T → use templateMap to resolve T → string) + if (result.templateMap.has(resolvedType)) { + resolvedType = result.templateMap.get(resolvedType)!; + } + const elemTemplateMap = this.buildTemplateMap(resolvedType, undefined); + return { type: resolvedType, templateMap: elemTemplateMap }; + } + + // Can't determine — return as-is + return result; + } + + /** + * Like resolveChainSteps but handles isIndexed on each segment. + * When a segment is indexed, the resolved type is dereferenced to its element type. + */ + private resolveChainStepsWithIndexing( + segments: { name: string; isIndexed: boolean }[], + currentType: string, + templateMap: Map + ): { type: string; templateMap: Map } | null { + for (const seg of segments) { + const nextTypeNode = this.resolveMethodReturnTypeNode(currentType, seg.name); + if (!nextTypeNode?.identifier) return null; + + let resolvedType = nextTypeNode.identifier; + + // Apply template substitution (e.g., GetKey() returns TKey → "string") + if (templateMap.has(resolvedType)) { + resolvedType = templateMap.get(resolvedType)!; + } + + // DayZ pattern: ClassName.Cast(x) returns ClassName, not Class. + if (seg.name === 'Cast' && resolvedType === 'Class') { + // Keep currentType and templateMap — Cast acts as transparent pass-through + // Still handle indexing below + resolvedType = currentType; + } + + // Resolve through typedefs and rebuild template map for the next step + const stepTypedef = this.resolveTypedefNode(resolvedType); + if (stepTypedef) { + resolvedType = stepTypedef.oldType.identifier; + if (stepTypedef.oldType.genericArgs && stepTypedef.oldType.genericArgs.length > 0) { + templateMap = this.buildTemplateMap(resolvedType, stepTypedef.oldType.genericArgs); + } else { + templateMap = new Map(); + } + } else if (nextTypeNode.genericArgs && nextTypeNode.genericArgs.length > 0) { + const substitutedArgs = nextTypeNode.genericArgs.map(arg => { + const subId = templateMap.get(arg.identifier); + if (subId) return { ...arg, identifier: subId } as TypeNode; + return arg; + }); + templateMap = this.buildTemplateMap(resolvedType, substitutedArgs); + } else { + templateMap = new Map(); + } + + currentType = resolvedType; + + // If this segment was indexed (e.g., .GetItems()[0]), dereference to element type + if (seg.isIndexed) { + const deref = this.resolveIndexedContainerType({ type: currentType, templateMap }); + currentType = deref.type; + templateMap = deref.templateMap; + } + } + + return { type: currentType, templateMap }; + } + /** * Resolve the final return type of a function chain like "U().Msg().SetMeta(...)". * Parses the chain, resolves the first function call, then delegates to @@ -1533,12 +1930,21 @@ export class Analyzer { const firstFunc = firstMatch[1]; - // Skip past the first call's arguments (balanced parens) + // Skip past the first call's arguments (balanced parens, string-literal aware) let afterFirst = remaining.substring(firstMatch[0].length); let parenDepth = 1, i = 0; while (i < afterFirst.length && parenDepth > 0) { - if (afterFirst[i] === '(') parenDepth++; - else if (afterFirst[i] === ')') parenDepth--; + const ch = afterFirst[i]; + if (ch === '(') parenDepth++; + else if (ch === ')') parenDepth--; + else if (ch === '"' || ch === "'") { + const q = ch; + i++; + while (i < afterFirst.length && afterFirst[i] !== q) { + if (afterFirst[i] === '\\') i++; + i++; + } + } i++; } afterFirst = afterFirst.substring(i).trim(); @@ -1581,21 +1987,32 @@ export class Analyzer { // If no chained calls, return the first function's resolved type if (calls.length === 0) { // Check for array indexing after the single call, e.g., GetOrientation()[0] - if (this.hasIndexingAfterCall(afterFirst)) { - return this.resolveIndexedType(currentType2); + const indexLevels0 = this.countIndexingLevels(afterFirst); + if (indexLevels0 > 0) { + let deref = { type: currentType2, templateMap }; + for (let lvl = 0; lvl < indexLevels0; lvl++) { + deref = this.resolveIndexedContainerType(deref); + } + return deref.type; } return currentType2; } // Delegate remaining chain steps - const result = this.resolveChainSteps(calls, currentType2, templateMap)?.type ?? null; + const chainResult = this.resolveChainSteps(calls, currentType2, templateMap); + if (!chainResult) return null; // If the chain contains array indexing outside of args, resolve to element type - if (result && this.hasIndexingAfterCall(afterFirst)) { - return this.resolveIndexedType(result); + const indexLevels1 = this.countIndexingLevels(afterFirst); + if (indexLevels1 > 0) { + let deref = { ...chainResult }; + for (let lvl = 0; lvl < indexLevels1; lvl++) { + deref = this.resolveIndexedContainerType(deref); + } + return deref.type; } - return result; + return chainResult.type; } /** @@ -1625,18 +2042,97 @@ export class Analyzer { templateMap = new Map(); } - const result = this.resolveChainSteps(calls, currentType, templateMap)?.type ?? null; + const chainResult = this.resolveChainSteps(calls, currentType, templateMap); + if (!chainResult) return null; // If the chain contains array indexing like [expr] after the last // method call, resolve to the element type. Matches [0], [i], etc. // even when followed by arithmetic like * Math.RAD2DEG. - // Uses hasIndexingAfterCall to avoid false positives from [] + // Uses countIndexingLevels to avoid false positives from [] // inside function arguments like .GetSurface(pos[0], pos[2]). - if (result && this.hasIndexingAfterCall(chainText)) { - return this.resolveIndexedType(result); + const indexLevels2 = this.countIndexingLevels(chainText); + if (indexLevels2 > 0) { + // For multi-level indexing (e.g., matrix[i][j]), we need the full + // TypeNode of the last member so we can peel off generic args at each + // level. The string-only templateMap loses nested genericArgs. + const lastMember = calls[calls.length - 1]; + const lastStepResult = this.resolveChainSteps(calls.slice(0, -1), currentType, templateMap); + const prevType = lastStepResult ? lastStepResult.type : currentType; + const prevMap = lastStepResult ? lastStepResult.templateMap : templateMap; + const lastTypeNode = this.resolveMethodReturnTypeNode(prevType, lastMember); + + if (lastTypeNode) { + return this.peelIndexingLevels(lastTypeNode, prevMap, indexLevels2); + } + + // Fallback: use the generic loop + let deref = { ...chainResult }; + for (let lvl = 0; lvl < indexLevels2; lvl++) { + deref = this.resolveIndexedContainerType(deref); + } + return deref.type; } - return result; + return chainResult.type; + } + + /** + * Peel off N indexing levels from a TypeNode, preserving nested generic args. + * For example, for `array>` with 2 levels: + * Level 1: array>[i] → array + * Level 2: array[j] → float + */ + private peelIndexingLevels( + typeNode: TypeNode, + outerTemplateMap: Map, + levels: number + ): string | null { + let currentType = typeNode.identifier; + let currentGenericArgs = typeNode.genericArgs; + let templateMap = outerTemplateMap; + + // Apply outer template substitution first (e.g., field type T → array) + if (templateMap.has(currentType)) { + currentType = templateMap.get(currentType)!; + } + + for (let lvl = 0; lvl < levels; lvl++) { + const lower = currentType.toLowerCase(); + if (lower === 'vector') { currentType = 'float'; currentGenericArgs = undefined; continue; } + if (lower === 'string') { currentType = 'string'; currentGenericArgs = undefined; continue; } + + // Build template map for this level from genericArgs + const levelMap = this.buildTemplateMap(currentType, currentGenericArgs); + + // Look up Get method return type + const getReturn = this.resolveMethodReturnTypeNode(currentType, 'Get'); + if (!getReturn?.identifier) return null; + + let elemType = getReturn.identifier; + if (levelMap.has(elemType)) { + elemType = levelMap.get(elemType)!; + } + + // Find the matching generic arg to get its nested genericArgs + let elemGenericArgs: TypeNode[] | undefined; + if (currentGenericArgs) { + // Find which generic arg corresponded to the element type. + // For containers like array, the element is the first generic arg. + // For map, Get returns TValue (the second generic arg). + const genericVars = this.getClassGenericVars(currentType); + if (genericVars && getReturn.identifier) { + const paramIdx = genericVars.indexOf(getReturn.identifier); + if (paramIdx >= 0 && paramIdx < currentGenericArgs.length) { + elemGenericArgs = currentGenericArgs[paramIdx].genericArgs; + } + } + } + + currentType = elemType; + currentGenericArgs = elemGenericArgs; + } + + return currentType; } /** @@ -1965,99 +2461,32 @@ export class Analyzer { // Look backwards from the token start to find a dot const textBeforeToken = text.substring(0, token.start); - // Multi-level chain: e.g., param.param4.GetSomething or param.Get("x").field - // Captures root variable + middle chain segments before the final dot - const multiLevelMatch = textBeforeToken.match(/(\w+)((?:\s*\.\s*\w+(?:\s*\([^)]*\))?)+)\s*\.\s*$/); - if (multiLevelMatch) { - const rootName = multiLevelMatch[1]; - const middleChain = multiLevelMatch[2]; // e.g., ".param4" or ".Get(key).field" - - let rootType = this.resolveVariableType(doc, _pos, rootName); - if (rootType) { - // Resolve root through typedef and build initial template map - let currentType = rootType; - let templateMap: Map; - const typedefNode = this.resolveTypedefNode(currentType); - if (typedefNode) { - currentType = typedefNode.oldType.identifier; - templateMap = this.buildTemplateMap(currentType, typedefNode.oldType.genericArgs); - } else { - currentType = this.resolveTypedef(currentType); - const varTypeNode = this.resolveVariableTypeNode(doc, _pos, rootName); - if (varTypeNode?.genericArgs && varTypeNode.genericArgs.length > 0) { - templateMap = this.buildTemplateMap(currentType, varTypeNode.genericArgs); - } else { - templateMap = new Map(); - } - } - - // Resolve through the middle chain to get the final type - const chainMembers = this.parseChainMembers(middleChain); - if (chainMembers.length > 0) { - const result = this.resolveChainSteps(chainMembers, currentType, templateMap); - if (result) { - const classMatches = this.findMemberInClassHierarchy(result.type, name); - if (classMatches.length > 0) { - return classMatches; - } + // ================================================================ + // UNIFIED CHAIN RESOLUTION — handles all dot-chain patterns: + // variable.member, variable.field.method, func().method, + // func().field.method, Class.StaticMethod, this.field.method, + // and arbitrarily deep chains with nested parentheses. + // ================================================================ + const ast = this.ensure(doc); + const chainResult = this.resolveFullChain(textBeforeToken, doc, _pos, ast); + if (chainResult) { + const classMatches = this.findMemberInClassHierarchy(chainResult.type, name); + if (classMatches.length > 0) { + return classMatches; + } + // If the chain resolved to a type but the member wasn't found, + // check if it's an enum member access (e.g., EnumType.VALUE) + const enumNode = this.enumIndex.get(chainResult.type); + if (enumNode) { + for (const member of enumNode.members) { + if (member.name === name) { + return [member as SymbolNodeBase]; } } } } - // Pattern 1: variable.method (e.g., player.GetInputType) - const memberMatch = textBeforeToken.match(/(\w+)\s*\.\s*$/); - - // Pattern 2: functionCall().method (e.g., GetGame().GetTime()) - const chainedCallMatch = textBeforeToken.match(/(\w+)\s*\([^)]*\)\s*\.\s*$/); - - if (memberMatch) { - // MEMBER ACCESS: Resolve the variable type and search only that class hierarchy - const varName = memberMatch[1]; - let varType = this.resolveVariableType(doc, _pos, varName); - - if (varType) { - // Resolve through typedefs so go-to-definition works on typedef'd variables - // e.g., testMap.Get → varType="testMapType" → resolve to "map" → find Get in map hierarchy - varType = this.resolveTypedef(varType); - const classMatches = this.findMemberInClassHierarchy(varType, name); - if (classMatches.length > 0) { - return classMatches; - } - } - - // If varName looks like a class (uppercase), try static member lookup - if (varName[0] === varName[0].toUpperCase()) { - const classMatches = this.findMemberInClassHierarchy(varName, name); - if (classMatches.length > 0) { - return classMatches; - } - } - } - - if (chainedCallMatch) { - // CHAINED CALL: Resolve the return type of the function call - const funcName = chainedCallMatch[1]; - let returnType = this.resolveFunctionReturnType(funcName); - // Fall back to method of containing class (e.g., GetInstance().member inside a class) - if (!returnType) { - const ast2 = this.ensure(doc); - const cc = this.findContainingClass(ast2, _pos); - if (cc) { - returnType = this.resolveMethodReturnType(cc.name, funcName); - } - } - - if (returnType) { - const classMatches = this.findMemberInClassHierarchy(returnType, name); - if (classMatches.length > 0) { - return classMatches; - } - } - } - // Check if we're inside a class - prioritize current class and inheritance - const ast = this.ensure(doc); const containingClass = this.findContainingClass(ast, _pos); if (containingClass) { @@ -2156,12 +2585,49 @@ export class Analyzer { // Returns in order: base classes first, then derived, with modded grouped by class const classesToSearch = this.getClassHierarchyOrdered(className, visited); + const seenPositions = new Set(); for (const classNode of classesToSearch) { for (const member of classNode.members || []) { if (member.name === memberName) { - matches.push(member as SymbolNodeBase); + // Deduplicate by file path + position to avoid showing + // the same definition twice when the same file is indexed + // under different URIs (e.g., workspace + include path). + const srcUri = (classNode as any)._sourceUri as string | undefined; + const key = `${srcUri ?? ''}:${member.nameStart.line}:${member.nameStart.character}`; + if (!seenPositions.has(key)) { + seenPositions.add(key); + matches.push(member as SymbolNodeBase); + } + } + } + } + + // Second-pass dedup: collapse entries from different URIs whose file + // paths resolve to the same relative path (handles the same mod + // indexed from both the workspace and an include path). + if (matches.length > 1) { + const pathKey = (node: SymbolNodeBase): string => { + const uri = (node as any)._sourceUri as string | undefined; + if (!uri) return ''; + try { + // Extract path from URI, normalize slashes, take last 3 segments + const fsPath = url.fileURLToPath(uri).replace(/\\/g, '/').toLowerCase(); + const parts = fsPath.split('/'); + return parts.slice(-3).join('/') + ':' + node.nameStart.line; + } catch { + return uri + ':' + node.nameStart.line; + } + }; + const seen = new Set(); + const deduped: SymbolNodeBase[] = []; + for (const m of matches) { + const pk = pathKey(m); + if (!seen.has(pk)) { + seen.add(pk); + deduped.push(m); } } + return deduped; } return matches; @@ -2181,7 +2647,30 @@ export class Analyzer { visited.add(className); // Find all classes with this name (original + modded versions) - const classNodes = this.findAllClassesByName(className); + // Deduplicate by _sourceUri in case the same file was indexed under + // different URI casings (Windows path case-insensitivity). + // Also dedup by path suffix to handle the same file indexed from both + // the workspace and an include path under different full URIs. + const rawClassNodes = this.findAllClassesByName(className); + const seenSourceUris = new Set(); + const seenPathSuffixes = new Set(); + const classNodes: ClassDeclNode[] = []; + for (const node of rawClassNodes) { + const srcUri = (node as any)._sourceUri as string | undefined; + if (srcUri) { + if (seenSourceUris.has(srcUri)) continue; + seenSourceUris.add(srcUri); + // Path suffix dedup: same file under different URI roots + try { + const fsPath = url.fileURLToPath(srcUri).replace(/\\/g, '/').toLowerCase(); + const parts = fsPath.split('/'); + const suffix = parts.slice(-3).join('/') + ':' + (node.modifiers?.includes('modded') ? 'modded' : 'orig'); + if (seenPathSuffixes.has(suffix)) continue; + seenPathSuffixes.add(suffix); + } catch { /* ignore path extraction errors */ } + } + classNodes.push(node); + } if (classNodes.length === 0) { // className might be a typedef (e.g., typedef ItemBase InventoryItemSuper) // Resolve through typedef and retry with the underlying type @@ -2298,91 +2787,11 @@ export class Analyzer { const textBeforeToken = text.substring(0, token.start); - // Multi-level chain: e.g., param.field.Get - const multiLevelMatch = textBeforeToken.match(/(\w+)((?:\s*\.\s*\w+(?:\s*\([^)]*\))?)+)\s*\.\s*$/); - if (multiLevelMatch) { - const rootName = multiLevelMatch[1]; - const middleChain = multiLevelMatch[2]; - - const rootType = this.resolveVariableType(doc, _pos, rootName); - if (rootType) { - let currentType = rootType; - let templateMap: Map; - const typedefNode = this.resolveTypedefNode(currentType); - if (typedefNode) { - currentType = typedefNode.oldType.identifier; - templateMap = this.buildTemplateMap(currentType, typedefNode.oldType.genericArgs); - } else { - currentType = this.resolveTypedef(currentType); - const varTypeNode = this.resolveVariableTypeNode(doc, _pos, rootName); - if (varTypeNode?.genericArgs && varTypeNode.genericArgs.length > 0) { - templateMap = this.buildTemplateMap(currentType, varTypeNode.genericArgs); - } else { - templateMap = new Map(); - } - } - - const chainMembers = this.parseChainMembers(middleChain); - if (chainMembers.length > 0) { - const result = this.resolveChainSteps(chainMembers, currentType, templateMap); - if (result && result.templateMap.size > 0) { - return result.templateMap; - } - } - } - } - - // Single-level: variable.member (e.g., testMap.Get) - const memberMatch = textBeforeToken.match(/(\w+)\s*\.\s*$/); - if (memberMatch) { - const varName = memberMatch[1]; - const varType = this.resolveVariableType(doc, _pos, varName); - if (varType) { - const typedefNode = this.resolveTypedefNode(varType); - if (typedefNode) { - const resolvedType = typedefNode.oldType.identifier; - const tplMap = this.buildTemplateMap(resolvedType, typedefNode.oldType.genericArgs); - if (tplMap.size > 0) return tplMap; - } else { - // Direct generic declaration: map myMap; - const varTypeNode = this.resolveVariableTypeNode(doc, _pos, varName); - if (varTypeNode?.genericArgs && varTypeNode.genericArgs.length > 0) { - const resolvedType = this.resolveTypedef(varType); - const tplMap = this.buildTemplateMap(resolvedType, varTypeNode.genericArgs); - if (tplMap.size > 0) return tplMap; - } - } - } - } - - // Function call chain: GetSomething().member - const chainedCallMatch = textBeforeToken.match(/(\w+)\s*\([^)]*\)\s*\.\s*$/); - if (chainedCallMatch) { - const funcName = chainedCallMatch[1]; - let returnTypeNode = this.resolveFunctionReturnTypeNode(funcName); - // Fall back to method of containing class - if (!returnTypeNode?.identifier) { - const ast2 = this.ensure(doc); - const cc = this.findContainingClass(ast2, _pos); - if (cc) { - const methodType = this.resolveMethodReturnType(cc.name, funcName); - if (methodType) { - returnTypeNode = { identifier: methodType, arrayDims: [], modifiers: [], kind: 'Type', uri: '', start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } as TypeNode; - } - } - } - if (returnTypeNode?.identifier) { - let resolvedType = returnTypeNode.identifier; - const typedefNode = this.resolveTypedefNode(resolvedType); - if (typedefNode) { - resolvedType = typedefNode.oldType.identifier; - const tplMap = this.buildTemplateMap(resolvedType, typedefNode.oldType.genericArgs); - if (tplMap.size > 0) return tplMap; - } else if (returnTypeNode.genericArgs && returnTypeNode.genericArgs.length > 0) { - const tplMap = this.buildTemplateMap(resolvedType, returnTypeNode.genericArgs); - if (tplMap.size > 0) return tplMap; - } - } + // Use the unified chain resolver for all dot-chain patterns + const ast = this.ensure(doc); + const chainResult = this.resolveFullChain(textBeforeToken, doc, _pos, ast); + if (chainResult && chainResult.templateMap.size > 0) { + return chainResult.templateMap; } return undefined; @@ -2915,7 +3324,12 @@ export class Analyzer { const checkFunction = (func: FunctionDeclNode, className?: string): void => { if (!shouldCheckFunction(func, className)) return; - const returnType = func.returnType?.identifier ?? 'void'; + let returnType = func.returnType?.identifier ?? 'void'; + // Reconstruct full generic type string (e.g., "array") + // so that type compatibility checks work correctly for generic returns + if (func.returnType?.genericArgs && func.returnType.genericArgs.length > 0) { + returnType = `${returnType}<${func.returnType.genericArgs.map(a => a.identifier).join(', ')}>`; + } const isVoid = returnType === 'void'; const returns = func.returnStatements || []; @@ -3348,8 +3762,29 @@ export class Analyzer { // without 'override'). This lets us distinguish: // - Sibling introduces → our 'override' is valid, suppress warning // - All siblings also 'override' → nobody introduced it, warn + const currentSourceUri = (cls as any)._sourceUri as string | undefined; + // Also extract file-path suffix for cross-URI dedup (same file + // indexed from both workspace and include path under different URIs) + const uriToNormalizedPath = (uri: string | undefined): string => { + if (!uri) return ''; + try { + return url.fileURLToPath(uri).replace(/\\/g, '/').toLowerCase(); + } catch { + return uri; + } + }; + const currentNormPath = uriToNormalizedPath(currentSourceUri); for (const ver of allVersions) { if (ver === cls || ver === originalClass) continue; + // Skip duplicate entries for the same physical file (can happen if + // the file was indexed under a different URI casing, or if the same + // mod is indexed from both the workspace and an include path) + const verSourceUri = (ver as any)._sourceUri as string | undefined; + if (currentSourceUri && verSourceUri && currentSourceUri === verSourceUri) continue; + // Fallback: compare full normalized paths to catch cross-URI + // duplicates where the same file was indexed from different roots + const verNormPath = uriToNormalizedPath(verSourceUri); + if (currentNormPath && verNormPath && currentNormPath === verNormPath) continue; for (const member of ver.members || []) { if (member.kind === 'FunctionDecl' && member.name) { const func = member as FunctionDeclNode; @@ -3476,8 +3911,25 @@ export class Analyzer { } else if (isModded && siblingInfo?.anyIntroduced) { // Two mods both introduce the same method without override — // one will shadow the other depending on load order. + // Find the conflicting file for the message + let conflictFile = ''; + const siblingVersions = this.findAllClassesByName(cls.name); + for (const ver of siblingVersions) { + if (ver === cls) continue; + if (!ver.modifiers?.includes('modded')) continue; + const verUri = (ver as any)._sourceUri as string | undefined; + if (verUri) { + for (const m of ver.members || []) { + if (m.kind === 'FunctionDecl' && m.name === func.name) { + try { conflictFile = ` (see ${url.fileURLToPath(verUri)})`; } catch { conflictFile = ''; } + break; + } + } + if (conflictFile) break; + } + } diags.push({ - message: `Method '${func.name}' is also defined in another modded version of '${cls.name}' without 'override'. One definition will shadow the other depending on mod load order.`, + message: `Method '${func.name}' is also defined in another modded version of '${cls.name}' without 'override'. One definition will shadow the other depending on mod load order.${conflictFile}`, range: { start: func.nameStart, end: func.nameEnd }, severity: DiagnosticSeverity.Warning }); @@ -4307,13 +4759,18 @@ export class Analyzer { // Calculate highlight: from match start to end of chain (before semicolon) const highlightLength = match[0].length + (stmtEnd >= 0 ? stmtEnd : afterDot.length); - // Skip if the full RHS contains a comparison/boolean operator and declared type is bool - if (declaredType === 'bool') { - const fullRhs5 = chainText; // chainText is everything from '.' to ';' - if (this.hasTopLevelComparisonOperator(fullRhs5)) { - continue; - } + // Skip if the full RHS contains a comparison/boolean operator — + // the actual expression type is bool, not the chain's return type. + // When declaredType is bool, the comparison is expected and valid. + // When declaredType is not bool, the mismatch is bool vs declaredType, + // but we skip anyway because the resolved returnType (from the chain before + // the operator) would produce a misleading diagnostic message. + if (this.hasTopLevelComparisonOperator(chainText)) { + continue; } + // Skip unresolved template parameters (e.g., T, TKey, TValue) — + // these occur when generic args couldn't be resolved through typedefs. + if (/^[A-Z]$/.test(declaredType) || /^[A-Z]$/.test(returnType)) continue; this.addTypeMismatchDiagnostic(doc, diags, match.index, highlightLength, declaredType, returnType); } @@ -4361,11 +4818,10 @@ export class Analyzer { if (/^[A-Z]$/.test(targetType) || /^[A-Z]$/.test(returnType)) continue; if (targetType === returnType) continue; - // Skip if the full RHS contains a comparison/boolean operator and target type is bool - if (targetType === 'bool') { - if (this.hasTopLevelComparisonOperator(chainText)) { - continue; - } + // Skip if the full RHS contains a comparison/boolean operator — + // the actual expression type is bool, not the chain's return type. + if (this.hasTopLevelComparisonOperator(chainText)) { + continue; } const actualStart = match.index + 1 + leadingWs.length; @@ -4477,8 +4933,23 @@ export class Analyzer { // Track nesting if (ch === '(' || ch === '[') { depth++; current += ch; continue; } if (ch === ')' || ch === ']') { depth--; current += ch; continue; } - if (ch === '<') { bracketDepth++; current += ch; continue; } - if (ch === '>') { bracketDepth--; current += ch; continue; } + // Distinguish generic angle brackets <> from bit shift <<, >> + if (ch === '<') { + const nextCh = i + 1 < argsText.length ? argsText[i + 1] : ''; + if (nextCh !== '<') { // Not bit shift << + bracketDepth++; + } + current += ch; + continue; + } + if (ch === '>') { + const nextCh = i + 1 < argsText.length ? argsText[i + 1] : ''; + if (nextCh !== '>') { // Not bit shift >> + if (bracketDepth > 0) bracketDepth--; + } + current += ch; + continue; + } if (ch === '{') { braceDepth++; current += ch; continue; } if (ch === '}') { braceDepth--; current += ch; continue; } @@ -4528,10 +4999,15 @@ export class Analyzer { // null if (arg === 'null' || arg === 'NULL') return null; // null is compatible with any ref type - // new ClassName(...) + // new ClassName(...) or new ClassName(...) const newMatch = arg.match(/^new\s+(\w+)/); if (newMatch) return newMatch[1]; + // Templated constructor: new ClassName(...) — handles cases where + // hasTopLevelComparisonOperator didn't catch it (e.g., complex expressions) + const newTemplateMatch = arg.match(/^new\s+(\w+)\s*= 0) { - while (p >= 0 && /\s/.test(fullTextBefore[p])) p--; - if (p < 0) break; - if (fullTextBefore[p] === ')') { - let depth = 1; p--; - while (p >= 0 && depth > 0) { - if (fullTextBefore[p] === '(') depth--; - else if (fullTextBefore[p] === ')') depth++; - p--; - } - while (p >= 0 && /\s/.test(fullTextBefore[p])) p--; - } - if (p < 0 || !/\w/.test(fullTextBefore[p])) break; - while (p >= 0 && /\w/.test(fullTextBefore[p])) p--; - const idStart = p + 1; - let pp = p; - while (pp >= 0 && /\s/.test(fullTextBefore[pp])) pp--; - if (pp >= 0 && fullTextBefore[pp] === '.') { - p = pp - 1; - continue; - } - p = idStart - 1; - break; - } - const exprStart = p + 1; - const chainExpr = fullTextBefore.substring(exprStart, fullTextBefore.length - 1); // exclude trailing dot - if (chainExpr) { - const rootM = chainExpr.match(/^(\w+)/); - if (rootM) { - const rootName = rootM[1]; - const afterRoot = chainExpr.substring(rootName.length); - let rootType: string | undefined; - let chainRemainder: string; - if (afterRoot.trimStart().startsWith('(')) { - // Root is a function call: FuncName(...).chain... - rootType = this.resolveFunctionReturnType(rootName) ?? undefined; - if (!rootType && containingClassName) { - rootType = this.resolveMethodCallWithTemplates(containingClassName, rootName) ?? undefined; - } - const parenStart = afterRoot.indexOf('('); - let d = 1, ii = parenStart + 1; - while (ii < afterRoot.length && d > 0) { - if (afterRoot[ii] === '(') d++; - else if (afterRoot[ii] === ')') d--; - ii++; - } - chainRemainder = afterRoot.substring(ii); - } else { - // Root is a variable or class name (for static access) - rootType = getVarTypeAtLine(rootName, lineNum); - if (!rootType) { - // Check if root is a class name (for static field/method access) - const classNodes = this.findAllClassesByName(rootName); - if (classNodes.length > 0) { - rootType = rootName; - } - } - chainRemainder = afterRoot; - } - // Resolve through the remaining chain if present. - // Pass the raw (non-typedef-resolved) rootType to resolveVariableChainType - // so it can build the proper template map from the typedef's generic args. - if (rootType && chainRemainder.trim().startsWith('.')) { - const resolved = this.resolveVariableChainType(rootType, chainRemainder.trim()); - if (resolved) { - overloads = this.findFunctionOverloads(funcName, resolved); - } - } else if (rootType) { - overloads = this.findFunctionOverloads(funcName, this.resolveTypedef(rootType)); - } - } + const chainResult = this.resolveFullChain(fullTextBefore, doc, { line: lineNum, character: 0 }, ast); + if (chainResult) { + chainResolvedType = chainResult.type; + overloads = this.findFunctionOverloads(funcName, chainResult.type); } } // Fall back to static class call if chain didn't resolve @@ -5027,83 +5449,13 @@ export class Analyzer { const trimBefore = textBeforeFunc.replace(/\s+$/, ''); if (trimBefore.endsWith('.')) { chainAttempted = true; - // Walk backwards to extract the full chain expression before the trailing dot - let p = trimBefore.length - 2; // start before the dot - while (p >= 0) { - // Skip whitespace - while (p >= 0 && /\s/.test(trimBefore[p])) p--; - if (p < 0) break; - // Optional balanced parens (method call arguments) - if (trimBefore[p] === ')') { - let depth = 1; p--; - while (p >= 0 && depth > 0) { - if (trimBefore[p] === '(') depth--; - else if (trimBefore[p] === ')') depth++; - p--; - } - // p is now before the '(' - while (p >= 0 && /\s/.test(trimBefore[p])) p--; - } - // Required: identifier - if (p < 0 || !/\w/.test(trimBefore[p])) break; - while (p >= 0 && /\w/.test(trimBefore[p])) p--; - const idStart = p + 1; - // Check for preceding dot (more chain) - let pp = p; - while (pp >= 0 && /\s/.test(trimBefore[pp])) pp--; - if (pp >= 0 && trimBefore[pp] === '.') { - p = pp - 1; - continue; - } - // No more dots — idStart is the beginning of the expression - p = idStart - 1; - break; - } - const exprStart = p + 1; - const chainExpr = trimBefore.substring(exprStart, trimBefore.length - 1); // exclude trailing dot - if (chainExpr) { - const rootM = chainExpr.match(/^(\w+)/); - if (rootM) { - const rootName = rootM[1]; - const afterRoot = chainExpr.substring(rootName.length); - let rootType: string | undefined; - let chainRemainder: string; - if (afterRoot.trimStart().startsWith('(')) { - // Root is a function call: FuncName(...).chain... - rootType = this.resolveFunctionReturnType(rootName) ?? undefined; - if (!rootType && containingClassName) { - rootType = this.resolveMethodCallWithTemplates(containingClassName, rootName) ?? undefined; - } - const parenStart = afterRoot.indexOf('('); - let d = 1, i = parenStart + 1; - while (i < afterRoot.length && d > 0) { - if (afterRoot[i] === '(') d++; - else if (afterRoot[i] === ')') d--; - i++; - } - chainRemainder = afterRoot.substring(i); - } else { - // Root is a variable or class name (for static access) - // Check class name first to avoid regex false positives - if (rootName[0] === rootName[0].toUpperCase() && this.classIndex.has(rootName)) { - rootType = rootName; - } else { - rootType = getVarTypeAtLine(rootName, lineNum); - } - chainRemainder = afterRoot; - } - // Resolve through the remaining chain if present. - // Pass the raw (non-typedef-resolved) rootType to resolveVariableChainType - // so it can build the proper template map from the typedef's generic args. - if (rootType && chainRemainder.trim().startsWith('.')) { - const resolved = this.resolveVariableChainType(rootType, chainRemainder.trim()); - if (resolved) { - overloads = this.findFunctionOverloads(funcName, resolved); - } - } else if (rootType) { - overloads = this.findFunctionOverloads(funcName, this.resolveTypedef(rootType)); - } - } + // Use the unified backward chain parser + resolver instead of + // hand-rolled backward walking. resolveFullChain handles nested + // parens, function calls, variables, static access, and deep chains. + const chainResult = this.resolveFullChain(trimBefore, doc, { line: lineNum, character: 0 }, ast); + if (chainResult) { + chainResolvedType = chainResult.type; + overloads = this.findFunctionOverloads(funcName, chainResult.type); } } @@ -5127,26 +5479,80 @@ export class Analyzer { } // Skip warning for chain calls where we couldn't resolve the target type — - // we don't know what class the method belongs to + // we don't know what class the method belongs to. + // BUT: if chain resolution DID resolve to a known type (chainResolvedType is set), + // then we know the method doesn't exist on that type and should warn. const dotObj = dotMatch ? dotMatch[1] : undefined; const dotObjType = dotObj ? getVarTypeAtLine(dotObj, lineNum) : undefined; const dotObjIsKnownClass = !!dotObj && dotObj[0] === dotObj[0].toUpperCase() && this.classIndex.has(dotObj); + const chainResolvedToKnownType = !!chainResolvedType && this.classIndex.has(chainResolvedType); + + // Check if the receiver type is actually resolvable (a known class or primitive). + // Suppress warnings only for truly unresolvable types: auto and typename. + // Template params (T, TKey, etc.) are now resolved to their upper bound + // by resolveChainRoot/resolveTemplateParam, so check the containing class's + // genericVars to recognize them as resolvable. + const hardcodedPrimitives = new Set(['int', 'float', 'bool', 'string', 'void', 'vector', 'class']); + const isResolvableType = (t: string | undefined): boolean => { + if (!t) return false; + if (t === 'auto' || t === 'typename') return false; + const resolved = this.resolveTypedef(t); + if (hardcodedPrimitives.has(resolved.toLowerCase()) || this.classIndex.has(resolved)) return true; + // Template params are resolvable — resolveChainRoot resolves them to their upper bound + if (containingClassName) { + const ccClasses = this.classIndex.get(containingClassName); + if (ccClasses) { + for (const cc of ccClasses) { + if (cc.genericVars?.includes(resolved)) return true; + } + } + } + return false; + }; + + // If dotObjType resolved to an unresolvable type, skip the warning + const dotObjTypeIsResolvable = isResolvableType(dotObjType); + const chainTypeIsResolvable = isResolvableType(chainResolvedType ?? undefined); + const isUnresolvedChain = - (!dotMatch && chainAttempted) || - (!!dotMatch && (dotIsPartOfChain || (!dotObjType && !dotObjIsKnownClass))); + (!dotMatch && chainAttempted && !chainResolvedToKnownType) || + (!!dotMatch && (dotIsPartOfChain || (!dotObjType && !dotObjIsKnownClass)) && !chainResolvedToKnownType); // Global/unknown receiver calls need a large index to avoid noise. - // But when receiver type is known (obj.Method), we can warn confidently - // even with a smaller index. - const hasConfidentReceiverType = !!dotMatch && (!!dotObjType || dotObjIsKnownClass); + // But when receiver type is known (obj.Method or resolved chain), we can + // warn confidently even with a smaller index. + // A type must actually be resolvable to be "confident" — auto, T, typename are not. + const hasConfidentReceiverType = (!!dotMatch && ((dotObjTypeIsResolvable) || dotObjIsKnownClass)) || chainTypeIsResolvable; const canWarn = this.docCache.size >= 500 || hasConfidentReceiverType; + // Explicitly skip when the resolved type is auto or typename — + // these are inherently unresolvable and should never produce warnings. + const effectiveType = chainResolvedType || dotObjType; + if (effectiveType === 'auto' || effectiveType === 'typename') continue; + + // Skip when the receiver type's hierarchy is incomplete (has an unresolved + // base class not in the index). The method may exist in unindexed parents. + // This commonly happens with modded classes that extend vanilla types whose + // full hierarchy isn't in the workspace. + if (effectiveType && canWarn && !isUnresolvedChain) { + const hierarchy = this.getClassHierarchyOrdered(effectiveType, new Set()); + const hasIncompleteHierarchy = hierarchy.some(cls => { + if (!cls.base?.identifier) return false; + if (cls.base.identifier === 'Class') return false; + return !this.classIndex.has(cls.base.identifier); + }); + if (hasIncompleteHierarchy) continue; + } + if (canWarn && !isUnresolvedChain) { const startPos = doc.positionAt(match.index); const endPos = doc.positionAt(match.index + funcName.length); + // Prefer chain-resolved type for the message when available, + // since it may be more accurate (e.g., template param T → Class) + const displayType = chainResolvedType || (dotMatch ? (dotObjType || dotObj) : undefined); diags.push({ - message: dotMatch - ? `Unknown method '${funcName}' on type '${dotObjType || dotObj}'` + message: displayType + ? `Unknown method '${funcName}' on type '${displayType}'` : `Unknown function '${funcName}'`, range: { start: startPos, end: endPos }, severity: DiagnosticSeverity.Warning From 03aec19ecf0558810f4e468f8978a5d0c17ef713 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Thu, 19 Feb 2026 21:30:51 -0500 Subject: [PATCH 33/46] Enhance hover: show overrides & inheritance Attach container metadata to member symbols and enhance hover formatting to present override chains and class inheritance. Adds _containerClassName, _containerIsModded, and _sourceUri to member nodes; if multiple member results exist, formatOverrideChain renders a hierarchy with class names and file path/line snippets; class declarations now show a computed inheritance chain via getInheritanceChainNames. Improves hover tooltips for easier navigation and context across class hierarchies. --- server/src/analysis/project/graph.ts | 85 ++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 11eeae3..7e70041 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -2596,6 +2596,10 @@ export class Analyzer { const key = `${srcUri ?? ''}:${member.nameStart.line}:${member.nameStart.character}`; if (!seenPositions.has(key)) { seenPositions.add(key); + // Attach container class info for rich hover display + (member as any)._containerClassName = classNode.name; + (member as any)._containerIsModded = classNode.modifiers?.includes('modded') ?? false; + (member as any)._sourceUri = srcUri; matches.push(member as SymbolNodeBase); } } @@ -2762,11 +2766,92 @@ export class Analyzer { const symbols = this.resolveDefinitions(doc, _pos); if (symbols.length === 0) return null; + // If there are multiple results for the same member name (overrides + // across the class hierarchy), show them as a hierarchy chain with + // class names and file paths for easy navigation. + const hasMemberContext = symbols.some(s => (s as any)._containerClassName); + if (hasMemberContext && symbols.length > 1) { + return this.formatOverrideChain(symbols, templateMap); + } + + // For class definitions, show the full inheritance chain + if (symbols.length >= 1 && symbols[0].kind === 'ClassDecl') { + const cls = symbols[0] as ClassDeclNode; + const chain = this.getInheritanceChainNames(cls.name); + const decl = formatDeclaration(cls, templateMap); + if (chain.length > 1) { + return decl + '\n\n**Inheritance:** ' + chain.join(' → '); + } + return decl; + } + return symbols .map((s) => formatDeclaration(s, templateMap)) .join('\n\n'); } + /** + * Format a list of overrides as a hierarchy chain for the hover tooltip. + * Shows each definition with its containing class and file path. + */ + private formatOverrideChain(symbols: SymbolNodeBase[], templateMap?: Map): string { + const lines: string[] = []; + + for (const sym of symbols) { + const className = (sym as any)._containerClassName as string | undefined; + const isModded = (sym as any)._containerIsModded as boolean | undefined; + const sourceUri = (sym as any)._sourceUri as string | undefined; + + // Format the declaration + const decl = formatDeclaration(sym, templateMap); + + // Build context line: class name + file path + let context = ''; + if (className) { + const prefix = isModded ? 'modded ' : ''; + context = `*${prefix}${className}*`; + } + if (sourceUri) { + try { + const fsPath = url.fileURLToPath(sourceUri).replace(/\\/g, '/'); + // Show the last meaningful path segments (e.g., Scripts/5_Mission/Mission.c) + const parts = fsPath.split('/'); + const shortPath = parts.slice(-3).join('/'); + context += context ? ` — \`${shortPath}:${sym.nameStart.line + 1}\`` : `\`${shortPath}:${sym.nameStart.line + 1}\``; + } catch { /* ignore */ } + } + + if (context) { + lines.push(`${context}\n${decl}`); + } else { + lines.push(decl); + } + } + + return lines.join('\n\n---\n\n'); + } + + /** + * Get the inheritance chain names for a class (bottom-up: target → ... → root). + * e.g., PlayerBase → ManBase → EntityAI → Entity → Managed → Class + */ + private getInheritanceChainNames(className: string): string[] { + const chain: string[] = []; + const visited = new Set(); + let current = className; + while (current && !visited.has(current)) { + visited.add(current); + chain.push(current); + const classes = this.classIndex.get(current); + if (!classes || classes.length === 0) break; + const original = classes.find(c => !c.modifiers?.includes('modded')) || classes[0]; + const base = original.base?.identifier; + if (!base) break; + current = base; + } + return chain; + } + /** * Build a template substitution map for hover at the given position. * When hovering over a member of a generic/typedef'd variable (e.g., testMap.Get), From b0eeec08ac4e6eb46afc98d13ff84ad4e4bdf074 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Thu, 19 Feb 2026 21:31:08 -0500 Subject: [PATCH 34/46] Add comprehensive chain parsing/resolution tests Add test/chain.test.ts: a large suite validating expression chain parsing and resolution. Covers parseExpressionChainBackward and parseChainMembers unit tests, resolveFullChain integration with indexed classes, templates/generics and typedefs, nested-parentheses bug cases, array/map/set indexing, template parameter resolution, class-index generic vars, diagnostic helper parity, operator detection, and multiple edge-case & consistency checks. Tests access some Analyzer internals via casting to any and index small code snippets to exercise type resolution and container dereferencing. --- test/chain.test.ts | 1671 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1671 insertions(+) create mode 100644 test/chain.test.ts diff --git a/test/chain.test.ts b/test/chain.test.ts new file mode 100644 index 0000000..8fbebed --- /dev/null +++ b/test/chain.test.ts @@ -0,0 +1,1671 @@ +/** + * Comprehensive tests for expression chain resolution. + * + * Tests cover: + * 1. parseExpressionChainBackward — pure backward scanning parser + * 2. resolveFullChain — full chain resolution with indexed classes/functions + * 3. Consistency — same results across definitions, hover, completions + */ +import { Analyzer } from '../server/src/analysis/project/graph'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { Position } from 'vscode-languageserver'; + +/** Helper to access private methods for unit testing */ +function parseChainBackward(analyzer: Analyzer, text: string): { name: string; isCall: boolean; isIndexed: boolean }[] { + return (analyzer as any).parseExpressionChainBackward(text); +} + +/** Shorthand for backward parser results without isIndexed (defaults to false) */ +function seg(name: string, isCall: boolean, isIndexed = false): { name: string; isCall: boolean; isIndexed: boolean } { + return { name, isCall, isIndexed }; +} + +/** Helper to get parseChainMembers (forward parser) for comparison */ +function parseChainMembers(analyzer: Analyzer, text: string): string[] { + return (analyzer as any).parseChainMembers(text); +} + +/** Create a fresh Analyzer instance (bypasses singleton) */ +function freshAnalyzer(): Analyzer { + return new (Analyzer as any)(); +} + +/** + * Create and index a document into an analyzer. + * Returns { doc, ast } for use in further calls. + */ +function indexDoc(analyzer: Analyzer, code: string, uri = 'file:///test.enscript') { + const doc = TextDocument.create(uri, 'enscript', 1, code); + const ast = analyzer.parseAndCache(doc); + return { doc, ast }; +} + +// ══════════════════════════════════════════════════════════════════════════════ +// 1. parseExpressionChainBackward — Unit Tests (pure function, no indexing) +// ══════════════════════════════════════════════════════════════════════════════ + +describe('parseExpressionChainBackward', () => { + let analyzer: Analyzer; + beforeAll(() => { analyzer = freshAnalyzer(); }); + + test('simple property access: obj.', () => { + const result = parseChainBackward(analyzer, 'obj.'); + expect(result).toEqual([seg('obj', false)]); + }); + + test('simple method call: obj.Method().', () => { + const result = parseChainBackward(analyzer, 'obj.Method().'); + expect(result).toEqual([ + seg('obj', false), + seg('Method', true), + ]); + }); + + test('function call root: GetGame().', () => { + const result = parseChainBackward(analyzer, 'GetGame().'); + expect(result).toEqual([seg('GetGame', true)]); + }); + + test('two-step property chain: a.b.', () => { + const result = parseChainBackward(analyzer, 'a.b.'); + expect(result).toEqual([ + seg('a', false), + seg('b', false), + ]); + }); + + test('method + property: GetGame().Config.', () => { + const result = parseChainBackward(analyzer, 'GetGame().Config.'); + expect(result).toEqual([ + seg('GetGame', true), + seg('Config', false), + ]); + }); + + test('deep chain (5 segments): a.b().c.d().e.', () => { + const result = parseChainBackward(analyzer, 'a.b().c.d().e.'); + expect(result).toEqual([ + seg('a', false), + seg('b', true), + seg('c', false), + seg('d', true), + seg('e', false), + ]); + }); + + test('nested parentheses: Cast(GetGame().GetObjectByNetworkId(low, high)).', () => { + const result = parseChainBackward(analyzer, 'Cast(GetGame().GetObjectByNetworkId(low, high)).'); + expect(result).toEqual([seg('Cast', true)]); + }); + + test('complex nested: GetGame().GetObjectByNetworkId(a, b).', () => { + const result = parseChainBackward(analyzer, 'GetGame().GetObjectByNetworkId(a, b).'); + expect(result).toEqual([ + seg('GetGame', true), + seg('GetObjectByNetworkId', true), + ]); + }); + + test('deeply nested args: Func(a.B(c.D())).', () => { + const result = parseChainBackward(analyzer, 'Func(a.B(c.D())).'); + expect(result).toEqual([seg('Func', true)]); + }); + + test('array indexing: items[0].', () => { + const result = parseChainBackward(analyzer, 'items[0].'); + expect(result).toEqual([seg('items', false, true)]); + }); + + test('method + array indexing: GetItems()[0].', () => { + const result = parseChainBackward(analyzer, 'GetItems()[0].'); + expect(result).toEqual([seg('GetItems', true, true)]); + }); + + test('chain with array indexing: obj.GetItems()[0].Name.', () => { + const result = parseChainBackward(analyzer, 'obj.GetItems()[0].Name.'); + expect(result).toEqual([ + seg('obj', false), + seg('GetItems', true, true), + seg('Name', false), + ]); + }); + + test('whitespace tolerance: obj . Method( ) .', () => { + const result = parseChainBackward(analyzer, 'obj . Method( ) .'); + expect(result).toEqual([ + seg('obj', false), + seg('Method', true), + ]); + }); + + test('this keyword: this.m_Field.', () => { + const result = parseChainBackward(analyzer, 'this.m_Field.'); + expect(result).toEqual([ + seg('this', false), + seg('m_Field', false), + ]); + }); + + test('super keyword: super.Init().', () => { + const result = parseChainBackward(analyzer, 'super.Init().'); + expect(result).toEqual([ + seg('super', false), + seg('Init', true), + ]); + }); + + test('keyword stops parsing: return obj.', () => { + // "return" is a keyword — parser should stop before it, yielding only "obj" + const result = parseChainBackward(analyzer, 'return obj.'); + expect(result).toEqual([seg('obj', false)]); + }); + + test('keyword stops: if (x) obj.Method().', () => { + // Parser stops at ')' boundary — extracts "obj.Method()" + const result = parseChainBackward(analyzer, 'if (x) obj.Method().'); + expect(result).toEqual([ + seg('obj', false), + seg('Method', true), + ]); + }); + + test('assignment prefix: x = obj.Method().', () => { + // '=' is not a word char, so parser stops before it + const result = parseChainBackward(analyzer, 'x = obj.Method().'); + expect(result).toEqual([ + seg('obj', false), + seg('Method', true), + ]); + }); + + test('empty string returns empty', () => { + expect(parseChainBackward(analyzer, '')).toEqual([]); + }); + + test('just a dot returns empty', () => { + expect(parseChainBackward(analyzer, '.')).toEqual([]); + }); + + test('no trailing dot returns empty', () => { + expect(parseChainBackward(analyzer, 'obj')).toEqual([]); + }); + + test('7-segment deep chain', () => { + const result = parseChainBackward(analyzer, 'a.b().c.d().e.f().g.'); + expect(result).toEqual([ + seg('a', false), + seg('b', true), + seg('c', false), + seg('d', true), + seg('e', false), + seg('f', true), + seg('g', false), + ]); + }); + + test('PlayerBase.Cast(expr).', () => { + const result = parseChainBackward(analyzer, 'PlayerBase.Cast(GetGame().GetObjectByNetworkId(low, high)).'); + expect(result).toEqual([ + seg('PlayerBase', false), + seg('Cast', true), + ]); + }); + + test('consecutive method calls: a().b().c().', () => { + const result = parseChainBackward(analyzer, 'a().b().c().'); + expect(result).toEqual([ + seg('a', true), + seg('b', true), + seg('c', true), + ]); + }); + + test('method with multiple args: obj.Method(a, b, c).', () => { + const result = parseChainBackward(analyzer, 'obj.Method(a, b, c).'); + expect(result).toEqual([ + seg('obj', false), + seg('Method', true), + ]); + }); + + test('method with string arg containing dot: obj.Method("foo.bar").', () => { + // The dot inside the string is inside parens, so it shouldn't break parsing + const result = parseChainBackward(analyzer, 'obj.Method("foo.bar").'); + expect(result).toEqual([ + seg('obj', false), + seg('Method', true), + ]); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 2. parseChainMembers — Forward parser unit tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('parseChainMembers (forward parser)', () => { + let analyzer: Analyzer; + beforeAll(() => { analyzer = freshAnalyzer(); }); + + test('simple property: .Prop', () => { + expect(parseChainMembers(analyzer, '.Prop')).toEqual(['Prop']); + }); + + test('method call: .Method()', () => { + expect(parseChainMembers(analyzer, '.Method()')).toEqual(['Method']); + }); + + test('method with args: .Method(a, b)', () => { + expect(parseChainMembers(analyzer, '.Method(a, b)')).toEqual(['Method']); + }); + + test('chain: .A().B.C()', () => { + expect(parseChainMembers(analyzer, '.A().B.C()')).toEqual(['A', 'B', 'C']); + }); + + test('nested parens: .A(B(c)).D', () => { + expect(parseChainMembers(analyzer, '.A(B(c)).D')).toEqual(['A', 'D']); + }); + + test('array indexing: .Items[0].Name', () => { + expect(parseChainMembers(analyzer, '.Items[0].Name')).toEqual(['Items', 'Name']); + }); + + test('method + array indexing: .GetItems()[0].Name', () => { + expect(parseChainMembers(analyzer, '.GetItems()[0].Name')).toEqual(['GetItems', 'Name']); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 3. resolveFullChain — Integration Tests (requires indexed classes/functions) +// ══════════════════════════════════════════════════════════════════════════════ + +describe('resolveFullChain with indexed classes', () => { + let analyzer: Analyzer; + let mainDoc: TextDocument; + let mainAst: any; + + beforeAll(() => { + analyzer = freshAnalyzer(); + + // Index a "library" document with class hierarchies + const libCode = ` +class CGame { + PlayerBase GetPlayer() { return null; } + World GetWorld() { return null; } +}; + +class World { + string GetName() { return ""; } + float GetTime() { return 0; } +}; + +class EntityAI { + string GetType() { return ""; } + void SetHealth(float hp) {} + Inventory GetInventory() { return null; } +}; + +class Inventory { + int GetItemCount() { return 0; } + ItemBase GetItem(int idx) { return null; } +}; + +class ItemBase { + string GetDisplayName() { return ""; } + float GetQuantity() { return 0; } +}; + +class PlayerBase extends EntityAI { + void Kill() {} + HumanInputController GetInputController() { return null; } +}; + +class HumanInputController { + bool IsUsePressed() { return false; } +}; + +CGame GetGame() { return null; } +PlayerBase GetPlayer() { return null; } +`; + indexDoc(analyzer, libCode, 'file:///lib.enscript'); + + // Index the "main" document where chains will be resolved + const mainCode = ` +class TestClass { + PlayerBase m_Player; + + void TestMethod() { + PlayerBase localPlayer = GetGame().GetPlayer(); + localPlayer.GetType(); + } +}; +`; + const result = indexDoc(analyzer, mainCode, 'file:///main.enscript'); + mainDoc = result.doc; + mainAst = result.ast; + }); + + test('function call root: GetGame(). → CGame', () => { + const result = analyzer.resolveFullChain('GetGame().', mainDoc, { line: 5, character: 0 }, mainAst); + expect(result).not.toBeNull(); + expect(result!.type).toBe('CGame'); + }); + + test('two-step chain: GetGame().GetPlayer(). → PlayerBase', () => { + const result = analyzer.resolveFullChain('GetGame().GetPlayer().', mainDoc, { line: 5, character: 0 }, mainAst); + expect(result).not.toBeNull(); + expect(result!.type).toBe('PlayerBase'); + }); + + test('three-step chain: GetGame().GetPlayer().GetType(). → string', () => { + const result = analyzer.resolveFullChain('GetGame().GetPlayer().GetType().', mainDoc, { line: 5, character: 0 }, mainAst); + expect(result).not.toBeNull(); + expect(result!.type).toBe('string'); + }); + + test('inherited method: GetGame().GetPlayer().GetInventory(). → Inventory', () => { + const result = analyzer.resolveFullChain('GetGame().GetPlayer().GetInventory().', mainDoc, { line: 5, character: 0 }, mainAst); + expect(result).not.toBeNull(); + expect(result!.type).toBe('Inventory'); + }); + + test('deep chain (4 steps): GetGame().GetPlayer().GetInventory().GetItem(0). → ItemBase', () => { + const result = analyzer.resolveFullChain('GetGame().GetPlayer().GetInventory().GetItem(0).', mainDoc, { line: 5, character: 0 }, mainAst); + expect(result).not.toBeNull(); + expect(result!.type).toBe('ItemBase'); + }); + + test('deep chain (5 steps): ...GetItem(0).GetDisplayName(). → string', () => { + const text = 'GetGame().GetPlayer().GetInventory().GetItem(0).GetDisplayName().'; + const result = analyzer.resolveFullChain(text, mainDoc, { line: 5, character: 0 }, mainAst); + expect(result).not.toBeNull(); + expect(result!.type).toBe('string'); + }); + + test('property chain: GetGame().GetWorld(). → World', () => { + const result = analyzer.resolveFullChain('GetGame().GetWorld().', mainDoc, { line: 5, character: 0 }, mainAst); + expect(result).not.toBeNull(); + expect(result!.type).toBe('World'); + }); + + test('nested args in chain: GetGame().GetPlayer(). still resolves', () => { + // Even with complex text before, the chain should parse correctly + const result = analyzer.resolveFullChain('x = GetGame().GetPlayer().', mainDoc, { line: 5, character: 0 }, mainAst); + expect(result).not.toBeNull(); + expect(result!.type).toBe('PlayerBase'); + }); + + test('class field access: this.m_Player. → PlayerBase', () => { + // Position inside TestClass method where this.m_Player is a field + const result = analyzer.resolveFullChain('this.m_Player.', mainDoc, { line: 5, character: 0 }, mainAst); + // 'this' resolves to TestClass, m_Player is a field of type PlayerBase + // This test verifies the chain walks through class fields + if (result) { + expect(result.type).toBe('PlayerBase'); + } + // Note: if this resolves to null, the field lookup might need more context + }); + + test('unresolvable chain returns null', () => { + const result = analyzer.resolveFullChain('unknownVar.unknownMethod().', mainDoc, { line: 5, character: 0 }, mainAst); + expect(result).toBeNull(); + }); + + test('text without trailing dot returns null', () => { + const result = analyzer.resolveFullChain('GetGame()', mainDoc, { line: 5, character: 0 }, mainAst); + expect(result).toBeNull(); + }); + + test('empty text returns null', () => { + const result = analyzer.resolveFullChain('', mainDoc, { line: 5, character: 0 }, mainAst); + expect(result).toBeNull(); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 4. resolveFullChain with templates/typedefs +// ══════════════════════════════════════════════════════════════════════════════ + +describe('resolveFullChain with templates and typedefs', () => { + let analyzer: Analyzer; + let doc: TextDocument; + let ast: any; + + beforeAll(() => { + analyzer = freshAnalyzer(); + + // Index generic classes and typedefs + const code = ` +typedef map TStringIntMap; + +class array { + int Count() { return 0; } + T Get(int index) { return null; } +}; + +class map { + TValue Get(TKey key) { return null; } + int Count() { return 0; } + TKey GetKey(int index) { return null; } +}; + +class Container { + array GetItems() { return null; } + TStringIntMap GetLookup() { return null; } +}; + +class ItemBase { + string GetName() { return ""; } +}; + +Container GetContainer() { return null; } +`; + const result = indexDoc(analyzer, code, 'file:///generics.enscript'); + doc = result.doc; + ast = result.ast; + }); + + test('typedef chain: GetContainer().GetLookup(). → map', () => { + const result = analyzer.resolveFullChain('GetContainer().GetLookup().', doc, { line: 1, character: 0 }, ast); + expect(result).not.toBeNull(); + // TStringIntMap resolves to map, so type should be "map" + expect(result!.type).toBe('map'); + }); + + test('typedef chain with method: GetContainer().GetLookup().Get("key"). → int', () => { + const result = analyzer.resolveFullChain('GetContainer().GetLookup().Get("key").', doc, { line: 1, character: 0 }, ast); + if (result) { + // TStringIntMap = map, so Get(TKey) returns TValue = int + expect(result.type).toBe('int'); + } + }); + + test('generic array chain: GetContainer().GetItems(). → array', () => { + const result = analyzer.resolveFullChain('GetContainer().GetItems().', doc, { line: 1, character: 0 }, ast); + expect(result).not.toBeNull(); + expect(result!.type).toBe('array'); + }); + + test('generic array Get: GetContainer().GetItems().Get(0). → ItemBase', () => { + const result = analyzer.resolveFullChain('GetContainer().GetItems().Get(0).', doc, { line: 1, character: 0 }, ast); + if (result) { + // array.Get(int) returns T = ItemBase + expect(result.type).toBe('ItemBase'); + } + }); + + test('generic through typedef: GetContainer().GetLookup().GetKey(0). → string', () => { + const result = analyzer.resolveFullChain('GetContainer().GetLookup().GetKey(0).', doc, { line: 1, character: 0 }, ast); + if (result) { + // TStringIntMap = map, GetKey returns TKey = string + expect(result.type).toBe('string'); + } + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 5. resolveFullChain with nested parentheses (the original bug) +// ══════════════════════════════════════════════════════════════════════════════ + +describe('resolveFullChain with nested parentheses', () => { + let analyzer: Analyzer; + let doc: TextDocument; + let ast: any; + + beforeAll(() => { + analyzer = freshAnalyzer(); + + const code = ` +class Object { + string GetType() { return ""; } +}; + +class EntityAI extends Object { + int GetID() { return 0; } +}; + +class PlayerBase extends EntityAI { + void Kill() {} +}; + +class CGame { + Object GetObjectByNetworkId(int low, int high) { return null; } + PlayerBase GetPlayer() { return null; } +}; + +CGame GetGame() { return null; } +PlayerBase Cast(Object obj) { return null; } + +class TestClass { + void Test() { + auto obj = GetGame().GetObjectByNetworkId(1, 2); + } +}; +`; + const result = indexDoc(analyzer, code, 'file:///nested.enscript'); + doc = result.doc; + ast = result.ast; + }); + + test('chain with multi-arg call: GetGame().GetObjectByNetworkId(low, high). → Object', () => { + const result = analyzer.resolveFullChain( + 'GetGame().GetObjectByNetworkId(low, high).', + doc, { line: 10, character: 0 }, ast + ); + expect(result).not.toBeNull(); + expect(result!.type).toBe('Object'); + }); + + test('chain with nested call in args: GetGame().GetObjectByNetworkId(GetID(), high). → Object', () => { + const result = analyzer.resolveFullChain( + 'GetGame().GetObjectByNetworkId(GetID(), high).', + doc, { line: 10, character: 0 }, ast + ); + expect(result).not.toBeNull(); + expect(result!.type).toBe('Object'); + }); + + test('Cast wrapping chain: Cast(GetGame().GetObjectByNetworkId(1, 2)). → PlayerBase', () => { + // Cast() is a global function returning PlayerBase + const result = analyzer.resolveFullChain( + 'Cast(GetGame().GetObjectByNetworkId(1, 2)).', + doc, { line: 10, character: 0 }, ast + ); + expect(result).not.toBeNull(); + expect(result!.type).toBe('PlayerBase'); + }); + + test('chain after nested function: GetGame().GetPlayer(). after complex prefix', () => { + // Simulates code like: if (condition) GetGame().GetPlayer(). + const result = analyzer.resolveFullChain( + 'if (true) GetGame().GetPlayer().', + doc, { line: 10, character: 0 }, ast + ); + expect(result).not.toBeNull(); + expect(result!.type).toBe('PlayerBase'); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 6. Consistency: backward parser agrees with forward parser +// ══════════════════════════════════════════════════════════════════════════════ + +describe('backward/forward parser consistency', () => { + let analyzer: Analyzer; + beforeAll(() => { analyzer = freshAnalyzer(); }); + + /** + * For a chain like "root.a().b.c().", + * backward parser extracts: [root, a, b, c] + * forward parser on ".a().b.c()" extracts: [a, b, c] + * The backward parser's result (minus root) should match the forward parser. + */ + function verifyConsistency(chainText: string) { + const backward = parseChainBackward(analyzer, chainText); + if (backward.length === 0) return; // nothing to compare + + // Extract the forward-parseable part: everything after the root segment + const root = backward[0]; + // Find where the root ends in the text (scan from end backward to reconstruct) + // For simplicity, just verify member names match + const backwardMembers = backward.slice(1).map(s => s.name); + + // Build the forward chain text from members + if (backwardMembers.length === 0) return; + + // Reconstruct a simplified forward chain: .Member1().Member2.Member3() + const forwardText = backward.slice(1).map(s => `.${s.name}${s.isCall ? '()' : ''}`).join(''); + const forward = parseChainMembers(analyzer, forwardText); + + expect(forward).toEqual(backwardMembers); + } + + test('simple chain', () => verifyConsistency('obj.Method().')); + test('multi-step', () => verifyConsistency('a.b().c.d().')); + test('all methods', () => verifyConsistency('a().b().c().')); + test('all properties', () => verifyConsistency('a.b.c.d.')); + test('deep chain', () => verifyConsistency('a.b().c.d().e.f().g.')); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 7. resolveChainReturnType / resolveVariableChainType — diagnostic helpers +// ══════════════════════════════════════════════════════════════════════════════ + +describe('diagnostic chain resolution helpers', () => { + let analyzer: Analyzer; + + beforeAll(() => { + analyzer = freshAnalyzer(); + + const code = ` +class CGame { + PlayerBase GetPlayer() { return null; } + World GetWorld() { return null; } +}; + +class World { + string GetName() { return ""; } +}; + +class PlayerBase { + string GetType() { return ""; } + Inventory GetInventory() { return null; } +}; + +class Inventory { + int Count() { return 0; } +}; + +CGame GetGame() { return null; } +`; + indexDoc(analyzer, code, 'file:///diag_lib.enscript'); + }); + + test('resolveChainReturnType: GetGame().GetPlayer() → PlayerBase', () => { + const result = (analyzer as any).resolveChainReturnType('GetGame().GetPlayer()'); + expect(result).toBe('PlayerBase'); + }); + + test('resolveChainReturnType: GetGame().GetPlayer().GetType() → string', () => { + const result = (analyzer as any).resolveChainReturnType('GetGame().GetPlayer().GetType()'); + expect(result).toBe('string'); + }); + + test('resolveChainReturnType: GetGame().GetWorld().GetName() → string', () => { + const result = (analyzer as any).resolveChainReturnType('GetGame().GetWorld().GetName()'); + expect(result).toBe('string'); + }); + + test('resolveChainReturnType: single call GetGame() → CGame', () => { + const result = (analyzer as any).resolveChainReturnType('GetGame()'); + expect(result).toBe('CGame'); + }); + + test('resolveVariableChainType: PlayerBase.GetInventory() → Inventory', () => { + const result = (analyzer as any).resolveVariableChainType('PlayerBase', '.GetInventory()'); + expect(result).toBe('Inventory'); + }); + + test('resolveVariableChainType: PlayerBase.GetInventory().Count() → int', () => { + const result = (analyzer as any).resolveVariableChainType('PlayerBase', '.GetInventory().Count()'); + expect(result).toBe('int'); + }); + + test('resolveChainReturnType: deep 4-step chain', () => { + const result = (analyzer as any).resolveChainReturnType('GetGame().GetPlayer().GetInventory().Count()'); + expect(result).toBe('int'); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 8. Consistency between resolveFullChain and diagnostic chain helpers +// ══════════════════════════════════════════════════════════════════════════════ + +describe('resolveFullChain vs diagnostic helpers consistency', () => { + let analyzer: Analyzer; + let doc: TextDocument; + let ast: any; + + beforeAll(() => { + analyzer = freshAnalyzer(); + + const code = ` +class CGame { + PlayerBase GetPlayer() { return null; } + World GetWorld() { return null; } +}; + +class World { + string GetName() { return ""; } +}; + +class PlayerBase { + string GetType() { return ""; } + Inventory GetInventory() { return null; } +}; + +class Inventory { + int Count() { return 0; } +}; + +CGame GetGame() { return null; } + +class TestClass { + void Test() { + GetGame().GetPlayer(); + } +}; +`; + const result = indexDoc(analyzer, code, 'file:///consistency.enscript'); + doc = result.doc; + ast = result.ast; + }); + + /** + * Verify that resolveFullChain and resolveChainReturnType agree on the type + * for function-rooted chains. + */ + function verifyFuncChainConsistency(chain: string, expectedType: string) { + // resolveFullChain expects text ending with dot + const fullResult = analyzer.resolveFullChain(chain + '.', doc, { line: 10, character: 0 }, ast); + // resolveChainReturnType expects the chain WITHOUT trailing dot + const diagResult = (analyzer as any).resolveChainReturnType(chain); + + expect(fullResult).not.toBeNull(); + expect(fullResult!.type).toBe(expectedType); + expect(diagResult).toBe(expectedType); + } + + test('single function call', () => { + verifyFuncChainConsistency('GetGame()', 'CGame'); + }); + + test('two-step chain', () => { + verifyFuncChainConsistency('GetGame().GetPlayer()', 'PlayerBase'); + }); + + test('three-step chain', () => { + verifyFuncChainConsistency('GetGame().GetPlayer().GetType()', 'string'); + }); + + test('four-step chain', () => { + verifyFuncChainConsistency('GetGame().GetPlayer().GetInventory().Count()', 'int'); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 9. Edge cases and robustness +// ══════════════════════════════════════════════════════════════════════════════ + +describe('edge cases', () => { + let analyzer: Analyzer; + beforeAll(() => { analyzer = freshAnalyzer(); }); + + test('parseExpressionChainBackward handles consecutive dots gracefully', () => { + // ".." is invalid syntax — should return empty or partial + const result = parseChainBackward(analyzer, 'obj..'); + // Second dot with no identifier before it — should stop + // The result may vary, but should not throw + expect(Array.isArray(result)).toBe(true); + }); + + test('parseExpressionChainBackward handles number before dot', () => { + // "123." — digits are \w but not a valid identifier for our purposes + // Parser should extract "123" as a segment (numbers match \w) + const result = parseChainBackward(analyzer, '123.'); + // 123 doesn't match any keyword, so it will be extracted + expect(result.length).toBeGreaterThanOrEqual(0); + }); + + test('parseExpressionChainBackward handles underscore identifiers', () => { + const result = parseChainBackward(analyzer, '_private._field.'); + expect(result).toEqual([ + seg('_private', false), + seg('_field', false), + ]); + }); + + test('parseExpressionChainBackward with empty parens in chain', () => { + const result = parseChainBackward(analyzer, 'a().b().'); + expect(result).toEqual([ + seg('a', true), + seg('b', true), + ]); + }); + + test('very long chain does not stack overflow', () => { + const segments = Array.from({ length: 50 }, (_, i) => `seg${i}()`).join('.'); + const text = segments + '.'; + const result = parseChainBackward(analyzer, text); + expect(result).toHaveLength(50); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 10. Audit-derived edge case tests (Findings from systematic review) +// ══════════════════════════════════════════════════════════════════════════════ + +describe('Finding 1 & 16: string literals inside paren-balancing', () => { + let analyzer: Analyzer; + beforeAll(() => { analyzer = freshAnalyzer(); }); + + test('backward parser: string with closing paren inside args', () => { + // foo(bar("text)here")).Method. + // The ")" inside the string should not break paren depth + const result = parseChainBackward(analyzer, 'foo(bar("text)here")).Method.'); + expect(result).toEqual([ + seg('foo', true), + seg('Method', false), + ]); + }); + + test('backward parser: string with opening paren inside args', () => { + const result = parseChainBackward(analyzer, 'foo("arg(value").Method.'); + expect(result).toEqual([ + seg('foo', true), + seg('Method', false), + ]); + }); + + test('backward parser: single-quoted string with paren', () => { + const result = parseChainBackward(analyzer, "foo('arg)val').Method."); + expect(result).toEqual([ + seg('foo', true), + seg('Method', false), + ]); + }); + + test('forward parser: string with closing paren inside args', () => { + // .Format("name)value").Length should parse as ["Format", "Length"] + const result = parseChainMembers(analyzer, '.Format("name)value").Length'); + expect(result).toEqual(['Format', 'Length']); + }); + + test('forward parser: string with opening paren inside args', () => { + const result = parseChainMembers(analyzer, '.Method("arg(val").Next'); + expect(result).toEqual(['Method', 'Next']); + }); + + test('forward parser: escaped quote in string', () => { + const result = parseChainMembers(analyzer, '.Method("he\\"llo)").Next'); + expect(result).toEqual(['Method', 'Next']); + }); +}); + +describe('Finding 2: expanded keyword stop-list', () => { + let analyzer: Analyzer; + beforeAll(() => { analyzer = freshAnalyzer(); }); + + test('null is rejected as chain root', () => { + const result = parseChainBackward(analyzer, 'null.'); + expect(result).toEqual([]); + }); + + test('true is rejected as chain root', () => { + const result = parseChainBackward(analyzer, 'true.'); + expect(result).toEqual([]); + }); + + test('false is rejected as chain root', () => { + const result = parseChainBackward(analyzer, 'false.'); + expect(result).toEqual([]); + }); + + test('foreach stops parsing', () => { + const result = parseChainBackward(analyzer, 'foreach (x) obj.'); + expect(result).toEqual([seg('obj', false)]); + }); + + test('const stops parsing', () => { + const result = parseChainBackward(analyzer, 'const obj.'); + expect(result).toEqual([seg('obj', false)]); + }); + + test('break is rejected as chain root', () => { + const result = parseChainBackward(analyzer, 'break.'); + expect(result).toEqual([]); + }); + + test('continue is rejected as chain root', () => { + const result = parseChainBackward(analyzer, 'continue.'); + expect(result).toEqual([]); + }); + + test('override stops parsing', () => { + const result = parseChainBackward(analyzer, 'override obj.'); + expect(result).toEqual([seg('obj', false)]); + }); +}); + +describe('Finding 5: uppercase check for class names', () => { + let analyzer: Analyzer; + let doc: TextDocument; + let ast: any; + + beforeAll(() => { + analyzer = freshAnalyzer(); + const code = ` +class MyClass { + string GetName() { return ""; } +}; + +class TestClass { + MyClass _myVar; + void Test() { + _myVar.GetName(); + } +}; +`; + const result = indexDoc(analyzer, code, 'file:///test_uppercase.enscript'); + doc = result.doc; + ast = result.ast; + }); + + test('underscore-prefixed variable is not mistaken for class', () => { + // _myVar should resolve as a variable, not try class lookup + const result = analyzer.resolveFullChain('_myVar.', doc, { line: 7, character: 0 }, ast); + if (result) { + expect(result.type).toBe('MyClass'); + } + }); +}); + +describe('Finding 9/10/20: comparison operator detection', () => { + let analyzer: Analyzer; + + beforeAll(() => { analyzer = freshAnalyzer(); }); + + function hasCompOp(text: string): boolean { + return (analyzer as any).hasTopLevelComparisonOperator(text); + } + + test('detects >', () => { + expect(hasCompOp('.GetCount() > 5')).toBe(true); + }); + + test('detects <', () => { + expect(hasCompOp('.GetHealth() < 100')).toBe(true); + }); + + test('detects >=', () => { + expect(hasCompOp('.GetValue() >= other')).toBe(true); + }); + + test('detects <=', () => { + expect(hasCompOp('.GetValue() <= 0')).toBe(true); + }); + + test('detects ==', () => { + expect(hasCompOp('.GetType() == "test"')).toBe(true); + }); + + test('detects !=', () => { + expect(hasCompOp('.GetType() != "test"')).toBe(true); + }); + + test('detects &&', () => { + expect(hasCompOp('.IsAlive() && other')).toBe(true); + }); + + test('detects ||', () => { + expect(hasCompOp('.IsAlive() || other')).toBe(true); + }); + + test('does not false-positive on operators inside parens', () => { + expect(hasCompOp('.Method(a > b)')).toBe(false); + }); + + test('does not false-positive on operators inside strings', () => { + expect(hasCompOp('.Method("a > b")')).toBe(false); + }); + + test('does not false-positive on simple chain without operators', () => { + expect(hasCompOp('.GetItems().Count()')).toBe(false); + }); +}); + +describe('Finding 19: this/super mid-chain validation', () => { + let analyzer: Analyzer; + beforeAll(() => { analyzer = freshAnalyzer(); }); + + test('this at start is valid', () => { + const result = parseChainBackward(analyzer, 'this.Method().'); + expect(result.length).toBe(2); + expect(result[0].name).toBe('this'); + }); + + test('super at start is valid', () => { + const result = parseChainBackward(analyzer, 'super.Method().'); + expect(result.length).toBe(2); + expect(result[0].name).toBe('super'); + }); + + test('this mid-chain returns empty (invalid)', () => { + // This shouldn't happen in valid code but we validate anyway + // "obj.this.Method." — if it somehow parsed, this/super mid-chain is rejected + // Actually the parser won't produce this because 'this' would stop as keyword... + // but let's verify the post-parse validation works if segments are manually constructed + const result = parseChainBackward(analyzer, 'obj.this.'); + // 'this' is now in the keyword stop-list, so it wouldn't be extracted + // The parser would extract just 'obj' as root... but wait, 'this' IS allowed, + // just not mid-chain. Let's see what happens: + // backward scan: '.', read 'this', check for dot -> '.', read 'obj', no more dots + // segments = [{name:'obj'}, {name:'this'}] + // post-validation: index 1 is 'this' -> return [] + expect(result).toEqual([]); + }); +}); + +describe('Finding 14: static method chains in inferArgType', () => { + let analyzer: Analyzer; + + beforeAll(() => { + analyzer = freshAnalyzer(); + const code = ` +class Math { + static float AbsFloat(float val) { return 0; } + static int AbsInt(int val) { return 0; } +}; + +class Container { + void Process(float val) {} + void ProcessInt(int val) {} +}; +`; + indexDoc(analyzer, code, 'file:///static_test.enscript'); + }); + + test('resolves static method chain in argument', () => { + const getVarType = (_name: string) => undefined; + const result = (analyzer as any).inferArgType('Math.AbsFloat(x)', getVarType, undefined); + // Math is a class, AbsFloat is a static method returning float + // With our fix, inferArgType should now resolve this + if (result) { + expect(result).toBe('float'); + } + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 11. Indexed container type resolution (array/map indexing in chains) +// ══════════════════════════════════════════════════════════════════════════════ + +describe('resolveFullChain with array/map indexing', () => { + let analyzer: Analyzer; + let doc: TextDocument; + let ast: any; + + beforeAll(() => { + analyzer = freshAnalyzer(); + const code = ` +class string { + int Length() { return 0; } + string SubString(int start, int len) { return ""; } +}; + +class array { + int Count() { return 0; } + T Get(int index) { return null; } + void Insert(T item) {} +}; + +class map { + TValue Get(TKey key) { return null; } + TKey GetKey(int index) { return null; } + int Count() { return 0; } +}; + +class set { + void Insert(T item) {} + T Get(int n) { return null; } + int Count() { return 0; } +}; + +class ItemBase { + string GetDisplayName() { return ""; } + float GetHealth() { return 0; } +}; + +class Inventory { + array GetItems() { return null; } + map GetLookup() { return null; } +}; + +class Player { + Inventory GetInventory() { return null; } + array m_Lines; +}; +`; + const result = indexDoc(analyzer, code, 'file:///indexing_test.enscript'); + doc = result.doc; + ast = result.ast; + }); + + test('array indexing on root: items[i]. resolves to element type', () => { + // Backward parser correctly marks items as isIndexed + const chain = parseChainBackward(analyzer, 'items[0].'); + expect(chain).toEqual([seg('items', false, true)]); + }); + + test('method call + indexing: GetItems()[0]. marked correctly', () => { + const chain = parseChainBackward(analyzer, 'GetItems()[0].'); + expect(chain).toEqual([seg('GetItems', true, true)]); + }); + + test('resolveFullChain: field array indexing then method', () => { + // this.m_Lines[i].Length() — m_Lines is array, [i] gives string, Length() is on string + const result = analyzer.resolveFullChain('this.m_Lines[0].', doc, { line: 50, character: 0 }, ast); + // m_Lines resolves to array, indexing gives string + if (result) { + expect(result.type).toBe('string'); + } + }); + + test('resolveFullChain: method returns array, index it', () => { + // GetInventory().GetItems()[0]. → array[0] → ItemBase + const code2 = ` +class CGame { + Player GetPlayer() { return null; } +}; +CGame GetGame() { return null; } +`; + indexDoc(analyzer, code2, 'file:///indexing_game.enscript'); + const result = analyzer.resolveFullChain( + 'GetGame().GetPlayer().GetInventory().GetItems()[0].', + doc, { line: 50, character: 0 }, ast + ); + if (result) { + expect(result.type).toBe('ItemBase'); + } + }); + + test('resolveIndexedContainerType for vector', () => { + const deref = (analyzer as any).resolveIndexedContainerType({ type: 'vector', templateMap: new Map() }); + expect(deref.type).toBe('float'); + }); + + test('resolveIndexedContainerType for string', () => { + const deref = (analyzer as any).resolveIndexedContainerType({ type: 'string', templateMap: new Map() }); + expect(deref.type).toBe('string'); + }); + + test('resolveIndexedContainerType for array with templateMap', () => { + const tm = new Map([['T', 'ItemBase']]); + const deref = (analyzer as any).resolveIndexedContainerType({ type: 'array', templateMap: tm }); + expect(deref.type).toBe('ItemBase'); + }); + + test('resolveIndexedContainerType for map with templateMap', () => { + const tm = new Map([['TKey', 'string'], ['TValue', 'int']]); + const deref = (analyzer as any).resolveIndexedContainerType({ type: 'map', templateMap: tm }); + expect(deref.type).toBe('int'); + }); + + test('resolveIndexedContainerType for set with templateMap', () => { + const tm = new Map([['T', 'string']]); + const deref = (analyzer as any).resolveIndexedContainerType({ type: 'set', templateMap: tm }); + expect(deref.type).toBe('string'); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 12. Template parameter resolution +// ══════════════════════════════════════════════════════════════════════════════ + +describe('template parameter resolution', () => { + // Each test uses a fresh analyzer to avoid URI/version caching issues + test('resolveTemplateParam detects T in containing class', () => { + const analyzer = freshAnalyzer(); + const { doc, ast } = indexDoc(analyzer, ` + class MyContainer { + T m_Value; + void DoStuff() { + m_Value.GetType(); + } + } + `); + // Position inside DoStuff + const pos = { line: 4, character: 20 }; + const result = (analyzer as any).resolveTemplateParam('T', ast, pos); + expect(result).toBe('Class'); + }); + + test('resolveTemplateParam returns null for non-template types', () => { + const analyzer = freshAnalyzer(); + const { doc, ast } = indexDoc(analyzer, ` + class Foo { + void Bar() { + int x; + } + } + `); + const pos = { line: 3, character: 10 }; + const result = (analyzer as any).resolveTemplateParam('int', ast, pos); + expect(result).toBeNull(); + }); + + test('resolveTemplateParam detects T1, T2 in Param-like classes', () => { + const analyzer = freshAnalyzer(); + const { doc, ast } = indexDoc(analyzer, ` + class Param2 { + T1 m_First; + T2 m_Second; + void DoStuff() { + m_First.Thing(); + } + } + `); + const pos = { line: 5, character: 20 }; + expect((analyzer as any).resolveTemplateParam('T1', ast, pos)).toBe('Class'); + expect((analyzer as any).resolveTemplateParam('T2', ast, pos)).toBe('Class'); + expect((analyzer as any).resolveTemplateParam('T3', ast, pos)).toBeNull(); + }); + + test('resolveTemplateParam detects TKey, TValue in map-like classes', () => { + const analyzer = freshAnalyzer(); + const { doc, ast } = indexDoc(analyzer, ` + class CustomMap { + void Lookup() { + TKey k; + } + } + `); + const pos = { line: 3, character: 10 }; + expect((analyzer as any).resolveTemplateParam('TKey', ast, pos)).toBe('Class'); + expect((analyzer as any).resolveTemplateParam('TValue', ast, pos)).toBe('Class'); + }); + + test('resolveChainRoot resolves T-typed variable to Class', () => { + const analyzer = freshAnalyzer(); + const { doc, ast } = indexDoc(analyzer, ` + class MyContainer { + T m_Entity; + void DoStuff() { + m_Entity.GetType(); + } + } + `); + const pos = { line: 4, character: 20 }; + const root = { name: 'm_Entity', isCall: false }; + const result = (analyzer as any).resolveChainRoot(root, doc, pos, ast); + // T should resolve to 'Class' (the upper bound of template params) + expect(result).not.toBeNull(); + expect(result.type).toBe('Class'); + }); + + test('resolveChainRoot preserves concrete types (not template params)', () => { + const analyzer = freshAnalyzer(); + const { doc, ast } = indexDoc(analyzer, ` + class Foo { + int m_Value; + void Bar() { + m_Value; + } + } + `); + const pos = { line: 4, character: 10 }; + const root = { name: 'm_Value', isCall: false }; + const result = (analyzer as any).resolveChainRoot(root, doc, pos, ast); + expect(result).not.toBeNull(); + expect(result.type).toBe('int'); + }); + + test('auto and typename are still unresolvable', () => { + const analyzer = freshAnalyzer(); + // These should NOT be treated as template params + const { doc, ast } = indexDoc(analyzer, ` + class Foo { + void Bar() { + auto x; + } + } + `); + const pos = { line: 3, character: 10 }; + expect((analyzer as any).resolveTemplateParam('auto', ast, pos)).toBeNull(); + expect((analyzer as any).resolveTemplateParam('typename', ast, pos)).toBeNull(); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 13. Container generic vars from classIndex +// ══════════════════════════════════════════════════════════════════════════════ + +describe('built-in container generic vars', () => { + let analyzer: Analyzer; + + beforeAll(() => { + analyzer = freshAnalyzer(); + // Register built-in container classes with their generic vars in the classIndex + indexDoc(analyzer, ` + class array { + T Get(int n); + int Count(); + } + class map { + TValue Get(TKey key); + } + class set { + T Get(int n); + bool Contains(T value); + } + `); + }); + + test('getClassGenericVars returns T for array from classIndex', () => { + const genericVars = (analyzer as any).getClassGenericVars('array'); + expect(genericVars).toEqual(['T']); + }); + + test('getClassGenericVars returns TKey,TValue for map from classIndex', () => { + const genericVars = (analyzer as any).getClassGenericVars('map'); + expect(genericVars).toEqual(['TKey', 'TValue']); + }); + + test('getClassGenericVars returns T for set from classIndex', () => { + const genericVars = (analyzer as any).getClassGenericVars('set'); + expect(genericVars).toEqual(['T']); + }); + + test('buildTemplateMap for array with classIndex', () => { + const args = [{ identifier: 'string', arrayDims: [], modifiers: [], kind: 'Type', uri: '', start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }]; + const map = (analyzer as any).buildTemplateMap('array', args); + expect(map.get('T')).toBe('string'); + }); + + test('buildTemplateMap for map with classIndex', () => { + const args = [ + { identifier: 'string', arrayDims: [], modifiers: [], kind: 'Type', uri: '', start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + { identifier: 'int', arrayDims: [], modifiers: [], kind: 'Type', uri: '', start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } + ]; + const map = (analyzer as any).buildTemplateMap('map', args); + expect(map.get('TKey')).toBe('string'); + expect(map.get('TValue')).toBe('int'); + }); + + test('resolveIndexedContainerType for array with template map from classIndex', () => { + const args = [{ identifier: 'ItemBase', arrayDims: [], modifiers: [], kind: 'Type', uri: '', start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }]; + const templateMap = (analyzer as any).buildTemplateMap('array', args); + const deref = (analyzer as any).resolveIndexedContainerType({ type: 'array', templateMap }); + expect(deref.type).toBe('ItemBase'); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 14. Full chain resolution with templates and indexing +// ══════════════════════════════════════════════════════════════════════════════ + +describe('resolveFullChain with templates and indexing', () => { + test('array variable indexed resolves element type', () => { + const analyzer = freshAnalyzer(); + const { doc, ast } = indexDoc(analyzer, ` + class array { T Get(int n); int Count(); } + class CF_XML_Tag { + int Get(string tokens, int index) { return 0; } + } + class Parser { + ref array _entries; + void Parse() { + _entries[0].Get("test", 1); + } + } + `); + const pos = { line: 8, character: 20 }; + const result = (analyzer as any).resolveFullChain('_entries[0].', doc, pos, ast); + expect(result).not.toBeNull(); + expect(result.type).toBe('CF_XML_Tag'); + }); + + test('map indexed resolves to value type', () => { + const analyzer = freshAnalyzer(); + const { doc, ast } = indexDoc(analyzer, ` + class map { TValue Get(TKey key); } + class PlayerBase { + string GetName() { return ""; } + } + class Manager { + map m_Players; + void DoStuff() { + m_Players["key"].GetName(); + } + } + `); + const pos = { line: 8, character: 20 }; + const result = (analyzer as any).resolveFullChain('m_Players["key"].', doc, pos, ast); + expect(result).not.toBeNull(); + expect(result.type).toBe('PlayerBase'); + }); + + test('chain with T-typed root resolves through template param', () => { + const analyzer = freshAnalyzer(); + const { doc, ast } = indexDoc(analyzer, ` + class MyWrapper { + T m_Value; + void DoStuff() { + m_Value.GetType(); + } + } + `); + const pos = { line: 4, character: 20 }; + const result = (analyzer as any).resolveFullChain('m_Value.', doc, pos, ast); + expect(result).not.toBeNull(); + // T resolves to 'Class' (upper bound) + expect(result.type).toBe('Class'); + }); + + test('array indexed resolves to string element type', () => { + const analyzer = freshAnalyzer(); + const { doc, ast } = indexDoc(analyzer, ` + class array { T Get(int n); int Count(); } + class Foo { + ref array _lines; + void Bar() { + _lines[0].Length(); + } + } + `); + const pos = { line: 5, character: 20 }; + const result = (analyzer as any).resolveFullChain('_lines[0].', doc, pos, ast); + expect(result).not.toBeNull(); + expect(result.type).toBe('string'); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 15. Generic angle brackets vs comparison operators +// ══════════════════════════════════════════════════════════════════════════════ + +describe('hasTopLevelComparisonOperator with generics', () => { + let analyzer: Analyzer; + + beforeAll(() => { + analyzer = freshAnalyzer(); + }); + + test('new Param1(true) is NOT a comparison', () => { + expect((analyzer as any).hasTopLevelComparisonOperator('new Param1(true)')).toBe(false); + }); + + test('CF_DoublyLinkedNode_WeakRef is NOT a comparison', () => { + expect((analyzer as any).hasTopLevelComparisonOperator('CF_DoublyLinkedNode_WeakRef')).toBe(false); + }); + + test('new UAIChatAgentCreateCB(this, "") is NOT a comparison', () => { + expect((analyzer as any).hasTopLevelComparisonOperator('new UAIChatAgentCreateCB(this, "")')).toBe(false); + }); + + test('array is NOT a comparison', () => { + expect((analyzer as any).hasTopLevelComparisonOperator('array')).toBe(false); + }); + + test('nested generics map> is NOT a comparison', () => { + expect((analyzer as any).hasTopLevelComparisonOperator('map>')).toBe(false); + }); + + test('a < b is a comparison', () => { + expect((analyzer as any).hasTopLevelComparisonOperator('a < b')).toBe(true); + }); + + test('a > b is a comparison', () => { + expect((analyzer as any).hasTopLevelComparisonOperator('a > b')).toBe(true); + }); + + test('a << 16 is NOT a comparison (bit shift)', () => { + expect((analyzer as any).hasTopLevelComparisonOperator('a << 16')).toBe(false); + }); + + test('a >> 2 is NOT a comparison (bit shift)', () => { + expect((analyzer as any).hasTopLevelComparisonOperator('a >> 2')).toBe(false); + }); + + test('x == y is a comparison', () => { + expect((analyzer as any).hasTopLevelComparisonOperator('x == y')).toBe(true); + }); + + test('x != y is a comparison', () => { + expect((analyzer as any).hasTopLevelComparisonOperator('x != y')).toBe(true); + }); + + test('inferArgType: new Param1(true) returns Param1', () => { + const getVar = () => undefined; + expect((analyzer as any).inferArgType('new Param1(true)', getVar)).toBe('Param1'); + }); + + test('inferArgType: new UAIChatAgentCreateCB(this, "") returns UAIChatAgentCreateCB', () => { + const getVar = () => undefined; + expect((analyzer as any).inferArgType('new UAIChatAgentCreateCB(this, "")', getVar)).toBe('UAIChatAgentCreateCB'); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 16. Cast() preserves receiver type (DayZ pattern) +// ══════════════════════════════════════════════════════════════════════════════ + +describe('Cast preserves receiver type', () => { + let analyzer: Analyzer; + let doc: TextDocument; + let ast: any; + + beforeAll(() => { + analyzer = freshAnalyzer(); + + const code = ` +class Class { + proto native Class Cast(); +}; + +class Managed extends Class { +}; + +class CF_ModStorageData extends Managed { + int Get() { return 0; } +}; + +class array { + proto T Get(int n); + proto void Insert(T value); +}; + +class TestCast { + ref array m_Entries; + void Test() { + auto val = CF_ModStorageData.Cast(m_Entries[0]).Get(); + } +}; +`; + const result = indexDoc(analyzer, code, 'file:///cast.enscript'); + doc = result.doc; + ast = result.ast; + }); + + test('CF_ModStorageData.Cast(x). resolves to CF_ModStorageData (not Class)', () => { + const result = analyzer.resolveFullChain( + 'CF_ModStorageData.Cast(m_Entries[0]).', + doc, { line: 15, character: 0 }, ast + ); + expect(result).not.toBeNull(); + expect(result!.type).toBe('CF_ModStorageData'); + }); + + test('CF_ModStorageData.Cast(x).Get(). resolves through Cast correctly', () => { + // After Cast, type is still CF_ModStorageData, so Get() returns int + const result = analyzer.resolveFullChain( + 'CF_ModStorageData.Cast(m_Entries[0]).Get().', + doc, { line: 15, character: 0 }, ast + ); + expect(result).not.toBeNull(); + expect(result!.type).toBe('int'); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 17. super resolves to parent class +// ══════════════════════════════════════════════════════════════════════════════ + +describe('super chain resolution', () => { + let analyzer: Analyzer; + let doc: TextDocument; + let ast: any; + + beforeAll(() => { + analyzer = freshAnalyzer(); + + const code = ` +class MissionBase { + void OnClientPrepareEvent() {} + int GetID() { return 0; } +}; + +class MissionServer extends MissionBase { + override void OnClientPrepareEvent() { + super.OnClientPrepareEvent(); + } + void Test() { + super.GetID(); + } +}; +`; + const result = indexDoc(analyzer, code, 'file:///super.enscript'); + doc = result.doc; + ast = result.ast; + }); + + test('super. inside MissionServer resolves to MissionBase', () => { + // Position inside MissionServer body (line ~9) + const result = analyzer.resolveFullChain( + 'super.', + doc, { line: 9, character: 0 }, ast + ); + expect(result).not.toBeNull(); + expect(result!.type).toBe('MissionBase'); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 18. Double-indexing (2D matrix) resolves element type +// ══════════════════════════════════════════════════════════════════════════════ + +describe('double indexing (2D matrix)', () => { + let analyzer: Analyzer; + + beforeAll(() => { + analyzer = freshAnalyzer(); + + const code = ` +class array { + proto T Get(int n); + proto void Insert(T value); +}; + +class ActiveConfig { + ref array> m_BrightnessPatterns; +}; +`; + indexDoc(analyzer, code, 'file:///matrix.enscript'); + }); + + test('countIndexingLevels: .m_BrightnessPatterns[id1][id2] returns 2', () => { + expect((analyzer as any).countIndexingLevels('.m_BrightnessPatterns[id1][id2]')).toBe(2); + }); + + test('countIndexingLevels: .Method(args[0])[1] returns 1', () => { + expect((analyzer as any).countIndexingLevels('.Method(args[0])[1]')).toBe(1); + }); + + test('countIndexingLevels: no indexing returns 0', () => { + expect((analyzer as any).countIndexingLevels('.Method()')).toBe(0); + }); + + test('resolveVariableChainType: m_BrightnessPatterns[id][id] resolves to float', () => { + const result = (analyzer as any).resolveVariableChainType( + 'ActiveConfig', '.m_BrightnessPatterns[id1][id2]' + ); + expect(result).toBe('float'); + }); + + test('resolveVariableChainType: single index on array> resolves to array', () => { + const result = (analyzer as any).resolveVariableChainType( + 'ActiveConfig', '.m_BrightnessPatterns[id1]' + ); + expect(result).toBe('array'); + }); +}); From 73621edc4de2cd812df03fb01f1c1e4ddd83512c Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Fri, 20 Feb 2026 13:15:36 -0500 Subject: [PATCH 35/46] Improve token lookup, indexing, and diagnostics Replace small-window lexing with a lightweight regex-based token lookup that detects comments, block comments, and string literals to avoid preprocessor directives from consuming identifiers; classify keywords vs identifiers and ignore numeric tokens. Add ensureIndexed(doc) as a lightweight re-parse used on every keystroke so hover/definition stay responsive without running full diagnostics. Normalize URIs once and avoid updating global indexes for non-file URIs (e.g. chat/untitled buffers) to prevent polluting global symbol tables; also skip non-file entries in class/function indexing loops. In the documents handler add per-URI debounce timers (300ms) for running heavy diagnostics: run ensureIndexed immediately on content changes, debounce the expensive runDiagnostics, and on save cancel pending debounce and validate immediately. --- server/src/analysis/project/graph.ts | 93 ++++++++++++++++++++-------- server/src/lsp/handlers/documents.ts | 35 ++++++++++- 2 files changed, 101 insertions(+), 27 deletions(-) diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 7e70041..86c2d28 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -40,8 +40,8 @@ import { TextDocument } from 'vscode-languageserver-textdocument'; import { Position, Range, Location, SymbolInformation, SymbolKind, Diagnostic, DiagnosticSeverity } from 'vscode-languageserver'; import { parse, ParseError, ClassDeclNode, File, SymbolNodeBase, FunctionDeclNode, VarDeclNode, TypedefNode, toSymbolKind, EnumDeclNode, EnumMemberDeclNode, TypeNode, ReturnStatementInfo } from '../ast/parser'; import { prettyPrint } from '../ast/printer'; -import { lex } from '../lexer/lexer'; import { Token, TokenKind } from '../lexer/token'; +import { keywords } from '../lexer/rules'; import { normalizeUri } from '../../util/uri'; import * as url from 'node:url'; @@ -82,30 +82,56 @@ interface GlobalSymbolEntry { /** * Returns the token at a specific offset (e.g. mouse hover or cursor position). - * Lexes only a small window around the position for performance. + * + * Uses a simple regex scan instead of the full lexer so that preprocessor + * directives (#ifdef / #else / #endif) in the surrounding context can never + * accidentally "eat" the identifier under the cursor. */ export function getTokenAtPosition(text: string, offset: number): Token | null { - const windowSize = 64; - const start = Math.max(0, offset - windowSize); - const end = Math.min(text.length, offset + windowSize); - const slice = text.slice(start, end); - - const tokens = lex(slice); - - for (const t of tokens) { - const absStart = start + t.start; - const absEnd = start + t.end; - - if (offset >= absStart && offset <= absEnd) { - return { - ...t, - start: absStart, - end: absEnd - }; + // 1 · Detect if the cursor sits inside a // or /* */ comment. + // If so, return null immediately (no hover/def on comments). + // We only need to scan the current line for //, and a short + // window for /* */. + const lineStart = text.lastIndexOf('\n', offset - 1) + 1; + const lineText = text.substring(lineStart, text.indexOf('\n', offset)); + const colInLine = offset - lineStart; + + // Check for // comment: everything after // on this line is a comment + const slashSlash = lineText.indexOf('//'); + if (slashSlash >= 0 && colInLine > slashSlash) return null; + + // Quick check for block comment: scan backward for /* without closing */ + const windowBack = Math.max(0, offset - 500); + const before = text.substring(windowBack, offset); + const lastOpen = before.lastIndexOf('/*'); + const lastClose = before.lastIndexOf('*/'); + if (lastOpen >= 0 && (lastClose < 0 || lastClose < lastOpen)) return null; + + // 2 · Check if cursor is inside a string literal (" ... "). + // Count unescaped quotes from the start of the line to the cursor. + let inString = false; + for (let i = 0; i < colInLine; i++) { + if (lineText[i] === '"' && (i === 0 || lineText[i - 1] !== '\\')) { + inString = !inString; } } + if (inString) return null; + + // 3 · Walk outward from the offset to find the word (identifier/keyword) + // boundaries. Identifiers are [_A-Za-z0-9]+. + let lo = offset; + let hi = offset; + while (lo > 0 && /[_A-Za-z0-9]/.test(text[lo - 1])) lo--; + while (hi < text.length && /[_A-Za-z0-9]/.test(text[hi])) hi++; + if (lo === hi) return null; // cursor is not on a word + + const value = text.substring(lo, hi); - return null; + // 4 · Classify: keyword vs identifier vs number + if (/^\d/.test(value)) return null; // pure numeric — not useful for hover/def + + const kind = keywords.has(value) ? TokenKind.Keyword : TokenKind.Identifier; + return { kind, value, start: lo, end: hi }; } function formatDeclaration(node: SymbolNodeBase, templateMap?: Map): string { @@ -600,10 +626,20 @@ export class Analyzer { return this.ensure(doc); } + /** + * Lightweight re-parse + index update for the active document. + * Called on every keystroke so hover/definition always have a fresh AST, + * without blocking on heavy diagnostic checks. + */ + ensureIndexed(doc: TextDocument): File { + return this.ensure(doc); + } + private ensure(doc: TextDocument): File { // 1 · cache hit + const normalizedUri = normalizeUri(doc.uri); const currVersion = doc.version; - const cachedFile = this.docCache.get(normalizeUri(doc.uri)); + const cachedFile = this.docCache.get(normalizedUri); if (cachedFile && cachedFile.version === currVersion) { return cachedFile; @@ -612,15 +648,18 @@ export class Analyzer { try { // 2 · happy path ─ parse & cache const ast = parse(doc, undefined, this.preprocessorDefines); // pass full TextDocument + defines - const normalizedUri = normalizeUri(doc.uri); ast.module = getModuleLevel(doc.uri); // Pre-compute skipped #ifdef regions so runDiagnostics can // apply them from cache instead of re-scanning directives. ast.skippedRegions = Analyzer.computeSkippedRegions(doc.getText(), this.preprocessorDefines); this.docCache.set(normalizedUri, ast); - // Update indexes for fast lookups - this.updateGlobalSymbolIndex(normalizedUri, ast); - this.updateAllIndexes(normalizedUri, ast); + // Update indexes for fast lookups — only for real files. + // Non-file URIs (e.g. vscode-chat-code-block://, untitled:) + // must not pollute the global class/function indexes. + if (normalizedUri.startsWith('file:')) { + this.updateGlobalSymbolIndex(normalizedUri, ast); + this.updateAllIndexes(normalizedUri, ast); + } return ast; } catch (err) { // 3 · graceful error handling @@ -2662,6 +2701,8 @@ export class Analyzer { for (const node of rawClassNodes) { const srcUri = (node as any)._sourceUri as string | undefined; if (srcUri) { + // Skip non-file entries (e.g. chat code blocks indexed by VS Code) + if (!srcUri.startsWith('file:')) continue; if (seenSourceUris.has(srcUri)) continue; seenSourceUris.add(srcUri); // Path suffix dedup: same file under different URI roots @@ -3870,6 +3911,8 @@ export class Analyzer { // duplicates where the same file was indexed from different roots const verNormPath = uriToNormalizedPath(verSourceUri); if (currentNormPath && verNormPath && currentNormPath === verNormPath) continue; + // Skip non-file entries (e.g. vscode-chat-code-block:// from Copilot Chat) + if (verSourceUri && !verSourceUri.startsWith('file:')) continue; for (const member of ver.members || []) { if (member.kind === 'FunctionDecl' && member.name) { const func = member as FunctionDeclNode; diff --git a/server/src/lsp/handlers/documents.ts b/server/src/lsp/handlers/documents.ts index ae7d94f..fed3197 100644 --- a/server/src/lsp/handlers/documents.ts +++ b/server/src/lsp/handlers/documents.ts @@ -16,7 +16,38 @@ export function registerDocuments(conn: Connection, docs: TextDocuments>(); + const DEBOUNCE_MS = 300; + docs.onDidOpen(validate); - docs.onDidSave(validate); - docs.onDidChangeContent(validate); + docs.onDidSave((change) => { + // On save: cancel any pending debounce and run immediately + const uri = change.document.uri; + const pending = debounceTimers.get(uri); + if (pending) { + clearTimeout(pending); + debounceTimers.delete(uri); + } + validate(change); + }); + docs.onDidChangeContent((change) => { + // On every keystroke: do a lightweight parse + index update + // immediately so hover/definition stay responsive, then + // schedule the HEAVY diagnostic checks after a debounce delay. + analyser.ensureIndexed(change.document); + + const uri = change.document.uri; + const pending = debounceTimers.get(uri); + if (pending) clearTimeout(pending); + debounceTimers.set(uri, setTimeout(() => { + debounceTimers.delete(uri); + // Re-fetch the latest document — the user may have typed more + const latestDoc = docs.get(uri); + if (latestDoc) { + const diagnostics = analyser.runDiagnostics(latestDoc); + conn.sendDiagnostics({ uri, diagnostics }); + } + }, DEBOUNCE_MS)); + }); } From 3ca1d950dfe6f001813ca3b5f77a307f2333d411 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sat, 21 Feb 2026 11:33:30 -0500 Subject: [PATCH 36/46] Multi-root workspace & debounced diagnostics Switch workspace handling from a single workspaceRoot to workspaceRoots (arrays) across Analyzer and server bootstrap so multiple workspace folders are supported. Update indexing, initialization, and checkWorkspace to iterate all workspace roots and report workspaceRoots in the indexingComplete notification. Simplify class-node collection by removing the path-suffix dedupe logic. Add per-file diagnostic debouncing: on change do a fast ensureIndexed then schedule heavy diagnostics after a short debounce, and on save cancel pending debounce and validate immediately; also skip diagnostics for files outside workspace roots. These changes improve multi-root correctness and responsiveness of diagnostics. --- server/src/analysis/project/graph.ts | 23 ++++++--------- server/src/index.ts | 21 ++++++++------ server/src/lsp/handlers/diagnostics.ts | 39 ++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 24 deletions(-) diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 86c2d28..e4b0b20 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -483,26 +483,30 @@ export class Analyzer { /** Store include paths so diagnostics can be suppressed for external files */ private includePaths: string[] = []; - private workspaceRoot: string = ''; + private workspaceRoots: string[] = []; setIncludePaths(paths: string[]): void { this.includePaths = paths.map(p => p.replace(/\\/g, '/').toLowerCase()); } setWorkspaceRoot(root: string): void { - this.workspaceRoot = root.replace(/\\/g, '/').toLowerCase(); + this.workspaceRoots = [root.replace(/\\/g, '/').toLowerCase()]; } - /** Check if a URI belongs to the workspace (not an external include path file) */ + setWorkspaceRoots(roots: string[]): void { + this.workspaceRoots = roots.map(r => r.replace(/\\/g, '/').toLowerCase()); + } + + /** Check if a URI belongs to any workspace folder (not an external include path file) */ isWorkspaceFile(uri: string): boolean { - if (!this.workspaceRoot) return true; // no workspace root set, allow all + if (this.workspaceRoots.length === 0) return true; // no workspace root set, allow all let fsPath: string; try { fsPath = url.fileURLToPath(uri).replace(/\\/g, '/').toLowerCase(); } catch { fsPath = uri.replace(/\\/g, '/').toLowerCase(); } - return fsPath.startsWith(this.workspaceRoot); + return this.workspaceRoots.some(root => fsPath.startsWith(root)); } /** @@ -2696,7 +2700,6 @@ export class Analyzer { // the workspace and an include path under different full URIs. const rawClassNodes = this.findAllClassesByName(className); const seenSourceUris = new Set(); - const seenPathSuffixes = new Set(); const classNodes: ClassDeclNode[] = []; for (const node of rawClassNodes) { const srcUri = (node as any)._sourceUri as string | undefined; @@ -2705,14 +2708,6 @@ export class Analyzer { if (!srcUri.startsWith('file:')) continue; if (seenSourceUris.has(srcUri)) continue; seenSourceUris.add(srcUri); - // Path suffix dedup: same file under different URI roots - try { - const fsPath = url.fileURLToPath(srcUri).replace(/\\/g, '/').toLowerCase(); - const parts = fsPath.split('/'); - const suffix = parts.slice(-3).join('/') + ':' + (node.modifiers?.includes('modded') ? 'modded' : 'orig'); - if (seenPathSuffixes.has(suffix)) continue; - seenPathSuffixes.add(suffix); - } catch { /* ignore path extraction errors */ } } classNodes.push(node); } diff --git a/server/src/index.ts b/server/src/index.ts index 92ec5a1..0cc0762 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -22,14 +22,14 @@ const connection = createConnection(ProposedFeatures.all); // Track open documents — in-memory mirror of the client. export const documents: TextDocuments = new TextDocuments(TextDocument); -let workspaceRoot = ''; +let workspaceRoots: string[] = []; connection.onInitialize((_params: InitializeParams): InitializeResult => { const folders = _params.workspaceFolders ?? []; if (folders.length > 0) { - workspaceRoot = url.fileURLToPath(folders[0].uri); + workspaceRoots = folders.map(f => url.fileURLToPath(f.uri)); } else if (_params.rootUri) { - workspaceRoot = url.fileURLToPath(_params.rootUri); + workspaceRoots = [url.fileURLToPath(_params.rootUri)]; } return { @@ -54,7 +54,7 @@ connection.onInitialized(async () => { if (includePaths.length > 0) { Analyzer.instance().setIncludePaths(includePaths); } - Analyzer.instance().setWorkspaceRoot(workspaceRoot); + Analyzer.instance().setWorkspaceRoots(workspaceRoots); // Configure preprocessor defines if (preprocessorDefines.length > 0) { @@ -62,7 +62,7 @@ connection.onInitialized(async () => { console.log(`Preprocessor defines: ${preprocessorDefines.join(', ')}`); } - const pathsToIndex = [workspaceRoot, ...includePaths]; + const pathsToIndex = [...workspaceRoots, ...includePaths]; const allFiles: string[] = []; const seenRealPaths = new Set(); @@ -140,15 +140,20 @@ connection.onInitialized(async () => { // Notify client that indexing is complete - trigger refresh of open files connection.sendNotification('enscript/indexingComplete', { fileCount: allFiles.length, - workspaceRoot: workspaceRoot + workspaceRoots: workspaceRoots }); }); // Handle request to check all workspace files connection.onRequest('enscript/checkWorkspace', async () => { - console.log(`Checking all workspace files in ${workspaceRoot}...`); + console.log(`Checking all workspace files in ${workspaceRoots.join(', ')}...`); - const files = await findAllFiles(workspaceRoot, ['.c']); + const allCheckFiles: string[] = []; + for (const root of workspaceRoots) { + const found = await findAllFiles(root, ['.c']); + allCheckFiles.push(...found); + } + const files = allCheckFiles; const allDiagnostics: Array<{ uri: string; diagnostics: any[] }> = []; for (const filePath of files) { diff --git a/server/src/lsp/handlers/diagnostics.ts b/server/src/lsp/handlers/diagnostics.ts index 4665c8f..db82301 100644 --- a/server/src/lsp/handlers/diagnostics.ts +++ b/server/src/lsp/handlers/diagnostics.ts @@ -21,7 +21,42 @@ export function registerDiagnostics(conn: Connection, docs: TextDocuments>(); + const DEBOUNCE_MS = 300; + docs.onDidOpen(validate); - docs.onDidSave(validate); - docs.onDidChangeContent(validate); + docs.onDidSave((change) => { + // On save: cancel any pending debounce and run immediately + const uri = change.document.uri; + const pending = debounceTimers.get(uri); + if (pending) { + clearTimeout(pending); + debounceTimers.delete(uri); + } + validate(change); + }); + docs.onDidChangeContent((change) => { + // On every keystroke: do a lightweight parse + index update + // immediately so hover/definition stay responsive, then + // schedule the HEAVY diagnostic checks after a debounce delay. + analyser.ensureIndexed(change.document); + + const uri = change.document.uri; + const pending = debounceTimers.get(uri); + if (pending) clearTimeout(pending); + debounceTimers.set(uri, setTimeout(() => { + debounceTimers.delete(uri); + // Re-fetch the latest document — the user may have typed more + const latestDoc = docs.get(uri); + if (latestDoc) { + if (!analyser.isWorkspaceFile(uri)) { + conn.sendDiagnostics({ uri, diagnostics: [] }); + return; + } + const diagnostics = analyser.runDiagnostics(latestDoc); + conn.sendDiagnostics({ uri, diagnostics }); + } + }, DEBOUNCE_MS)); + }); } From 8a4f0c8a838bb1f943e719eeb7db46a9a2c4af23 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sat, 21 Feb 2026 11:35:13 -0500 Subject: [PATCH 37/46] Limit parser error recovery tokens Add a MAX_RECOVERY_TOKENS cap (500) to the parser's error recovery loop to avoid runaway token skipping. Track skippedTokens and recoveryStartToken so we can emit a warning diagnostic if recovery skips too many tokens and give up, and ensure skippedTokens is incremented for all token-advancing paths. This helps detect and surface out-of-sync parsing instead of silently skipping arbitrarily many tokens. --- server/src/analysis/ast/parser.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index 662573e..a99b9da 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -383,17 +383,27 @@ export function parse( // - ';' at brace depth 0 (end of broken variable/etc.) // - closing a balanced { } (end of broken function/class body) // - unmatched '}' at depth 0 (shouldn't happen at top level) + const MAX_RECOVERY_TOKENS = 500; let braceDepth = 0; - while (!eof()) { + let skippedTokens = 0; + const recoveryStartToken = toks[Math.min(pos, toks.length - 1)]; + while (!eof() && skippedTokens < MAX_RECOVERY_TOKENS) { const v = peek().value; if (v === '{') { braceDepth++; next(); } else if (v === '}') { - if (braceDepth === 0) { next(); break; } - braceDepth--; next(); + if (braceDepth === 0) { next(); skippedTokens++; break; } + braceDepth--; next(); skippedTokens++; if (braceDepth === 0) break; // closed a balanced block } - else if (v === ';' && braceDepth === 0) { next(); break; } - else { next(); } + else if (v === ';' && braceDepth === 0) { next(); skippedTokens++; break; } + else { next(); skippedTokens++; } + } + if (!eof() && skippedTokens >= MAX_RECOVERY_TOKENS) { + addDiagnostic( + recoveryStartToken, + `Error recovery skipped ${skippedTokens} tokens before giving up. Parsing may be out of sync.`, + DiagnosticSeverity.Warning + ); } } } From 94fde5f35d5c90c1622ab20db97a02ac285c754d Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sat, 21 Feb 2026 11:35:49 -0500 Subject: [PATCH 38/46] Limit angle-bracket walk-back to 64 tokens Add a safety guard when searching backwards for matching generic angle brackets by capping the walk-back distance to 64 tokens. This prevents runaway scans if boundary detection fails while preserving expected behavior for normal, compact generic types (e.g. map>). The loop condition was adjusted to respect the computed minimum search position. --- server/src/analysis/ast/parser.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index a99b9da..ae4454f 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -791,7 +791,12 @@ export function parse( // '>>' counts as 2 closing brackets (nested generics) let angleDepth = prevPrev.value === '>>' ? 2 : 1; let searchPos = prevPrevIdx - 1; // start before the '>' or '>>' - while (searchPos >= 0 && angleDepth > 0) { + // Safety guard: limit walk-back distance to avoid runaway scans + // if boundary detection somehow fails. Real generic types are + // compact (e.g. map>), so 64 tokens is generous. + const maxWalkBack = 64; + const minSearchPos = Math.max(0, searchPos - maxWalkBack); + while (searchPos >= minSearchPos && angleDepth > 0) { const st = toks[searchPos]; if (st.value === '>>') angleDepth += 2; else if (st.value === '>') angleDepth++; From ee5db49450056d22654063a53a89886bf2466929 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sat, 21 Feb 2026 11:36:25 -0500 Subject: [PATCH 39/46] Throttle indexing progress to 500ms Update server/src/index.ts to send indexing progress notifications at most every 500ms. Removed the previous extra trigger that sent updates every 100 files and adjusted the comment to reflect the new behavior. This reduces excessive progress notifications during fast indexing runs. --- server/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 0cc0762..3e02265 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -107,9 +107,9 @@ connection.onInitialized(async () => { const doc = TextDocument.create(uri, 'enscript', 1, text); Analyzer.instance().parseAndCache(doc); - // Send progress updates every 500ms or every 100 files + // Send progress updates at most every 500ms const now = Date.now(); - if (now - lastProgressUpdate > 500 || (i + 1) % 100 === 0) { + if (now - lastProgressUpdate >= 500) { connection.sendNotification('enscript/indexingProgress', { current: i + 1, total: allFiles.length, From 10df70104f2347d4255c221b248083188d6bef82 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sat, 21 Feb 2026 11:37:05 -0500 Subject: [PATCH 40/46] Clear debounce timer on document close Add a docs.onDidClose handler to clear any pending debounce timeout for a closed document and remove its entry from the debounceTimers map. This prevents callbacks from firing for documents that have been closed and avoids lingering timers / potential memory leaks. --- server/src/lsp/handlers/documents.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/src/lsp/handlers/documents.ts b/server/src/lsp/handlers/documents.ts index fed3197..c5f8f18 100644 --- a/server/src/lsp/handlers/documents.ts +++ b/server/src/lsp/handlers/documents.ts @@ -50,4 +50,13 @@ export function registerDocuments(conn: Connection, docs: TextDocuments { + const uri = change.document.uri; + const pending = debounceTimers.get(uri); + if (pending) { + clearTimeout(pending); + debounceTimers.delete(uri); + } + }); } From 8911cefff51c28a35ba1114b4f7c09fb20389615 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sat, 21 Feb 2026 11:38:23 -0500 Subject: [PATCH 41/46] Add revalidate notification for open enscript files Replace client-side no-op edits with a proper 'enscript/revalidateOpenFiles' notification. The extension now asks the server to re-run diagnostics on every open document after indexing completes, and the server handles this notification by iterating open documents, checking workspace files via Analyzer, running diagnostics, and sending diagnostics back to the client. This removes the previous hack of performing empty edits to trigger didChange events. --- server/src/index.ts | 12 ++++++++++++ src/extension.ts | 12 ++---------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 3e02265..bc11e3e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -201,6 +201,18 @@ connection.onRequest('enscript/checkWorkspace', async () => { }; }); +// Re-validate every open enscript document (called by the client after indexing completes) +connection.onNotification('enscript/revalidateOpenFiles', () => { + const analyser = Analyzer.instance(); + for (const doc of documents.all()) { + if (!analyser.isWorkspaceFile(doc.uri)) { + continue; + } + const diagnostics = analyser.runDiagnostics(doc); + connection.sendDiagnostics({ uri: doc.uri, diagnostics }); + } +}); + // Wire all feature handlers. registerAllHandlers(connection, documents); diff --git a/src/extension.ts b/src/extension.ts index 8f25dd3..0a04843 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -76,16 +76,8 @@ export async function activate(context: vscode.ExtensionContext) { }, 5000); } - // Trigger a re-validation of all open enscript documents - for (const doc of vscode.workspace.textDocuments) { - if (doc.languageId === 'enscript') { - // Force a change event by doing a no-op edit - const edit = new vscode.WorkspaceEdit(); - // Insert and immediately remove an empty string to trigger didChangeContent - edit.insert(doc.uri, new vscode.Position(0, 0), ''); - vscode.workspace.applyEdit(edit); - } - } + // Ask the server to re-run diagnostics on every open document + client?.sendNotification('enscript/revalidateOpenFiles'); }); context.subscriptions.push( From 20db55c69a4cde251d8b4b096b92382f1b0920dc Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sat, 21 Feb 2026 11:41:10 -0500 Subject: [PATCH 42/46] Add skipPreprocRegion helper and reuse in lexer Introduce skipPreprocRegion(text, pos, stopAtElse) to centralize skipping of preprocessor-disabled regions. The helper scans forward handling nested #ifdef/#ifndef/#endif and can stop at #else/#elif when requested, while recognising strings and comments so '#' inside them won't confuse depth tracking. Replace two duplicated manual-scan blocks in lex() with calls to this helper. Notes: the scanner is a lightweight mirror of the lexer (may not cover extremely unusual constructs) and it intentionally does not support nested block comments, matching the existing compiler behaviour. --- server/src/analysis/lexer/lexer.ts | 151 +++++++++++++++-------------- 1 file changed, 77 insertions(+), 74 deletions(-) diff --git a/server/src/analysis/lexer/lexer.ts b/server/src/analysis/lexer/lexer.ts index eaee0c9..a4f0991 100644 --- a/server/src/analysis/lexer/lexer.ts +++ b/server/src/analysis/lexer/lexer.ts @@ -46,6 +46,79 @@ import { Token, TokenKind } from './token'; import { keywords, punct, multiCharOps } from './rules'; +/** + * Skip over a preprocessor-disabled region, handling nested #ifdef/#endif + * and stopping at the appropriate boundary directive. + * + * While scanning, strings (both single- and double-quoted) and comments + * (line and block) are recognised so that '#' characters inside them do + * not confuse depth tracking. + * + * Limitations: + * - Strings/comments are detected with a lightweight scan that mirrors + * the main lexer but is NOT the main lexer. Extremely unusual + * constructs (e.g. raw string literals, if Enforce ever adds them) + * could theoretically mislead the scanner. + * - Nested block comments are not supported, matching the behaviour + * of the Enforce Script compiler. + * + * @param text Full source text. + * @param pos Index to start scanning (just after the opening directive line). + * @param stopAtElse If true, an `#else` / `#elif` at depth 1 terminates the skip + * (used when skipping the first branch of an #ifdef). + * @returns The index just past the terminating directive line. + */ +function skipPreprocRegion(text: string, pos: number, stopAtElse: boolean): number { + let i = pos; + let depth = 1; + + while (depth > 0 && i < text.length) { + // Advance to the next '#' while skipping over strings & comments + // so that a '#' inside a string or comment is not mistaken for a directive. + while (i < text.length && text[i] !== '#') { + if (text[i] === '"' || text[i] === "'") { + // String / character literal — skip to the matching close quote. + const quote = text[i]; + i++; + while (i < text.length && text[i] !== quote) { + if (text[i] === '\\' && i + 1 < text.length) i++; + i++; + } + if (i < text.length) i++; // consume closing quote + } else if (text[i] === '/' && i + 1 < text.length && text[i + 1] === '/') { + // Line comment — skip to end of line. + while (i < text.length && text[i] !== '\n') i++; + } else if (text[i] === '/' && i + 1 < text.length && text[i + 1] === '*') { + // Block comment — skip to closing */. + i += 2; + while (i + 1 < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++; + if (i + 1 < text.length) i += 2; // consume closing */ + } else { + i++; + } + } + + if (i >= text.length) break; + + // Read the full directive line. + const dStart = i; + while (i < text.length && text[i] !== '\n' && text[i] !== '\r') i++; + const d = text.slice(dStart, i).trim(); + + if (d.match(/^#\s*(ifdef|ifndef)\b/)) { + depth++; + } else if (d.match(/^#\s*endif\b/)) { + depth--; + } else if (stopAtElse && depth === 1 && d.match(/^#\s*(else|elif)\b/)) { + // Found #else/#elif at our level — stop skipping so the caller + // can process the alternative branch. + depth = 0; + } + } + + return i; +} + export function lex(text: string, defines?: Set): Token[] { const toks: Token[] = []; let i = 0; @@ -113,45 +186,8 @@ export function lex(text: string, defines?: Set): Token[] { // We'll handle #else (skip) and #endif (emit) when we encounter them continue; } else { - // Skip the #ifdef/#ifndef branch until #else or #endif - let depth = 1; - - while (depth > 0 && i < text.length) { - while (i < text.length && text[i] !== '#') { - if (text[i] === '"') { - i++; - while (i < text.length && text[i] !== '"') { - if (text[i] === '\\' && i + 1 < text.length) i++; - i++; - } - if (i < text.length) i++; - } else if (text[i] === '/' && i + 1 < text.length && text[i + 1] === '/') { - while (i < text.length && text[i] !== '\n') i++; - } else if (text[i] === '/' && i + 1 < text.length && text[i + 1] === '*') { - i += 2; - while (i + 1 < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++; - i += 2; - } else { - i++; - } - } - - if (i >= text.length) break; - - const dStart = i; - while (i < text.length && text[i] !== '\n' && text[i] !== '\r') i++; - const d = text.slice(dStart, i).trim(); - - if (d.match(/^#\s*(ifdef|ifndef)\b/)) { - depth++; - } else if (d.match(/^#\s*endif\b/)) { - depth--; - } else if (d.match(/^#\s*else\b/) && depth === 1) { - // Found #else at our level - stop skipping, process #else branch - depth = 0; - } - } - + // Skip the #ifdef/#ifndef branch until #else/#elif or #endif + i = skipPreprocRegion(text, i, /* stopAtElse */ true); push(TokenKind.Preproc, text.slice(lineStart, i), lineStart); continue; } @@ -162,41 +198,8 @@ export function lex(text: string, defines?: Set): Token[] { // Now skip from #else until #endif if (directive.match(/^#\s*else\b/)) { const elseStart = lineStart; - let depth = 1; - - while (depth > 0 && i < text.length) { - while (i < text.length && text[i] !== '#') { - if (text[i] === '"') { - i++; - while (i < text.length && text[i] !== '"') { - if (text[i] === '\\' && i + 1 < text.length) i++; - i++; - } - if (i < text.length) i++; - } else if (text[i] === '/' && i + 1 < text.length && text[i + 1] === '/') { - while (i < text.length && text[i] !== '\n') i++; - } else if (text[i] === '/' && i + 1 < text.length && text[i + 1] === '*') { - i += 2; - while (i + 1 < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++; - i += 2; - } else { - i++; - } - } - - if (i >= text.length) break; - - const dStart = i; - while (i < text.length && text[i] !== '\n' && text[i] !== '\r') i++; - const d = text.slice(dStart, i).trim(); - - if (d.match(/^#\s*(ifdef|ifndef)\b/)) { - depth++; - } else if (d.match(/^#\s*endif\b/)) { - depth--; - } - } - + // Skip from #else until the matching #endif + i = skipPreprocRegion(text, i, /* stopAtElse */ false); push(TokenKind.Preproc, text.slice(elseStart, i), elseStart); continue; } From b62524d92ced56e8b3fc6d31e510e478fae7ea41 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sat, 21 Feb 2026 11:46:33 -0500 Subject: [PATCH 43/46] Treat 'vector' as static type in Analyzer Allow the lowercase built-in type 'vector' to be treated as a static/class-like type when resolving types and static method overloads. Introduces local flags (isStaticCandidate / isStaticAccess) to include 'vector' alongside the existing uppercase-class check, and uses that flag when attempting class-type resolution and finding function overloads. This fixes static access resolution for calls like vector.Distance and keeps prior behavior for true class names. --- server/src/analysis/project/graph.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index e4b0b20..cb0cb0d 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -1518,7 +1518,7 @@ export class Analyzer { // Skip keywords that aren't valid chain roots (return, if, etc.) // but allow 'this' and 'super' - if (!name || (name !== 'this' && name !== 'super' && /^(return|if|else|while|for|foreach|switch|case|new|delete|typeof|class|modded|static|private|protected|ref|autoptr|auto|void|int|float|bool|string|vector|const|override|break|continue|null|true|false)$/.test(name))) { + if (!name || (name !== 'this' && name !== 'super' && /^(return|if|else|while|for|foreach|switch|case|new|delete|typeof|class|modded|static|private|protected|ref|autoptr|auto|void|int|float|bool|string|const|override|break|continue|null|true|false)$/.test(name))) { break; } @@ -1677,7 +1677,9 @@ export class Analyzer { } // Try as class name (static access: e.g., PlayerBase.Cast) - if (/^[A-Z]/.test(root.name)) { + // Also handle lowercase built-in types like 'vector' that support static methods + const isStaticCandidate = /^[A-Z]/.test(root.name) || root.name === 'vector'; + if (isStaticCandidate) { if (this.classIndex.has(root.name)) { return { type: root.name, templateMap: new Map() }; } @@ -5528,7 +5530,9 @@ export class Analyzer { const beforeObjName = textBeforeFunc.substring(0, dotMatch.index).trimEnd(); const isPartOfChain = beforeObjName.length > 0 && (beforeObjName[beforeObjName.length - 1] === '.' || beforeObjName[beforeObjName.length - 1] === ')'); dotIsPartOfChain = isPartOfChain; - if (!isPartOfChain && objName[0] === objName[0].toUpperCase() && this.classIndex.has(objName)) { + // Check for static access: ClassName.Method or lowercase built-ins like vector.Distance + const isStaticAccess = objName[0] === objName[0].toUpperCase() || objName === 'vector'; + if (!isPartOfChain && isStaticAccess && this.classIndex.has(objName)) { overloads = this.findFunctionOverloads(funcName, objName); } @@ -5561,7 +5565,7 @@ export class Analyzer { } // Fall back to static class call if chain didn't resolve // Only for true static access, not when objName is part of a chain - if (overloads.length === 0 && !isPartOfChain && objName[0] === objName[0].toUpperCase()) { + if (overloads.length === 0 && !isPartOfChain && isStaticAccess) { overloads = this.findFunctionOverloads(funcName, objName); } } From 244cf9ec3b90f63cbc634e7018428e65224aabc2 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Wed, 25 Feb 2026 21:23:04 -0500 Subject: [PATCH 44/46] Improve template and type resolution/compatibility Enhance template parameter and type compatibility logic. - Resolve method-call templates by building a template map from both typedef expansions and extends clauses with generic args. - When following base classes, apply concrete generic args from extends clauses (with chained-generic substitutions) before walking the hierarchy. - Add resolveTemplateParamThroughHierarchy to walk inheritance and resolve a template parameter to the concrete type provided by child extends clauses (returns concrete type or null). - Update resolveTemplateParam to use the new hierarchy resolver so parent-provided template params can resolve to concrete types instead of always falling back to 'Class'. - Treat Managed as a wildcard like Class/auto/typename and include it in parameter skipping for function checks. - Resolve typedefs before compatibility checks and re-check equality on resolved names to avoid false mismatches. - Implement isKnownType helper to consider classes, enums, typedefs, and typedef-resolved names when deciding if a type is resolvable. - Treat any involvement of enums as compatible to avoid false positives. - Use typedef-resolved names when checking class hierarchy upcast/downcast relationships. - Minor renames and comments clarifying numeric fallback sets and array/primitive handling. These changes reduce false positives around generics, typedefs, enums, and managed base types, and make template resolution across extends clauses more accurate. --- server/src/analysis/project/graph.ts | 177 +++++++++++++++++++++++---- 1 file changed, 153 insertions(+), 24 deletions(-) diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index cb0cb0d..f7d0b4f 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -1115,7 +1115,8 @@ export class Analyzer { * unqualified method calls inside a class (i.e., calling inherited methods). */ private resolveMethodCallWithTemplates(className: string, methodName: string): string | null { - // Walk the class hierarchy, building up the template map at each typedef step + // Walk the class hierarchy, building up the template map at each step + // (both typedef expansions AND extends clauses with generic arguments) const visited = new Set(); let templateMap = new Map(); @@ -1157,11 +1158,21 @@ export class Analyzer { } } - // Check base class + // Check base class, building template map from extends clause generic args const originalClass = classNodes.find(c => !c.modifiers?.includes('modded')); - const baseClassName = (originalClass || classNodes[0])?.base?.identifier; - if (baseClassName) { - return walk(baseClassName); + const baseNode = (originalClass || classNodes[0])?.base; + if (baseNode?.identifier) { + // If the extends clause has generic args (e.g., extends HFSMBase), + // build a template map for the base class's generic parameters + if (baseNode.genericArgs && baseNode.genericArgs.length > 0) { + const newMap = this.buildTemplateMap(baseNode.identifier, baseNode.genericArgs); + for (const [k, v] of newMap) { + // Apply existing template substitutions to the concrete args too + // (handles chained generics) + templateMap.set(k, templateMap.get(v) || v); + } + } + return walk(baseNode.identifier); } return null; @@ -1554,7 +1565,13 @@ export class Analyzer { * Also walks up the class hierarchy — if the containing class inherits * from a generic base, the base's template params are also checked. * - * @returns The resolved upper-bound type name, or null if not a template param. + * When a class extends a generic parent with concrete type arguments + * (e.g., WeaponFSM extends HFSMBase), template + * parameters from the parent are resolved to the concrete arguments + * instead of falling back to the generic upper bound 'Class'. + * + * @returns The resolved concrete type name, or 'Class' as fallback if + * no concrete substitution is found, or null if not a template param. */ private resolveTemplateParam(typeName: string, ast: File, pos: Position): string | null { const cc = this.findContainingClass(ast, pos); @@ -1562,20 +1579,88 @@ export class Analyzer { // Check the containing class's own template params if (cc.genericVars && cc.genericVars.includes(typeName)) { + // The containing class itself declares this template param. + // We can't resolve it further without knowing how this class is instantiated. return 'Class'; } - // Also walk the class hierarchy in case a parent class defines the template param - const hierarchy = this.getClassHierarchyOrdered(cc.name, new Set()); - for (const cls of hierarchy) { - if (cls.genericVars && cls.genericVars.includes(typeName)) { - return 'Class'; - } + // Walk the inheritance chain from the containing class upward. + // At each step, check if the parent class defines the template param. + // If so, find the concrete type argument passed from the child's extends clause. + const concreteType = this.resolveTemplateParamThroughHierarchy(cc.name, typeName); + if (concreteType) { + return concreteType; } return null; } + /** + * Walk the class hierarchy from `startClass` upward, resolving a template + * parameter name to the concrete type argument provided via extends clauses. + * + * Example: + * class HFSMBase { FSMStateBase m_State; } + * class WeaponFSM extends HFSMBase { } + * + * resolveTemplateParamThroughHierarchy("WeaponFSM", "FSMStateBase") + * → finds HFSMBase has genericVars ["FSMStateBase", "FSMEventBase", ...] + * → WeaponFSM's base.genericArgs[0] = "WeaponStateBase" + * → returns "WeaponStateBase" + */ + private resolveTemplateParamThroughHierarchy(startClass: string, templateParam: string): string | null { + const visited = new Set(); + + const walk = (className: string): string | null => { + if (visited.has(className)) return null; + visited.add(className); + + const classNodes = this.findAllClassesByName(className); + if (classNodes.length === 0) return null; + + // For each class node (original + modded), check if its base class + // defines the template parameter we're looking for. + for (const classNode of classNodes) { + if (!classNode.base?.identifier) continue; + + const baseName = classNode.base.identifier; + const baseGenericArgs = classNode.base.genericArgs; + + // Find the base class definition to get its genericVars + const baseClasses = this.findAllClassesByName(baseName); + const baseClass = baseClasses.find(c => !c.modifiers?.includes('modded')) || baseClasses[0]; + + if (baseClass?.genericVars) { + const paramIndex = baseClass.genericVars.indexOf(templateParam); + if (paramIndex !== -1 && baseGenericArgs && paramIndex < baseGenericArgs.length) { + const concreteType = baseGenericArgs[paramIndex].identifier; + // The concrete type might itself be a template param of an + // intermediate class — but usually it's a real class name. + // Check if it resolves to a known class; if not, keep walking. + if (this.classIndex.has(concreteType) || this.typedefIndex.has(concreteType)) { + return concreteType; + } + // Could be a primitive + const primitives = new Set(['int', 'float', 'bool', 'string', 'void', 'vector']); + if (primitives.has(concreteType.toLowerCase())) { + return concreteType; + } + // It's an unknown type (possibly another template param) — still better than 'Class' + return concreteType; + } + } + + // Template param not found on this base — recurse up + const result = walk(baseName); + if (result) return result; + } + + return null; + }; + + return walk(startClass); + } + /** * Resolve the root segment of an expression chain to a type. * Handles variables, `this`, `super`, class names (static access), @@ -4288,24 +4373,68 @@ export class Analyzer { }; } - // auto/typename/Class are wildcards - always compatible + // auto/typename/Class/Managed are wildcards - always compatible + // Managed is the root base class for all managed (ref-counted) classes + // in Enforce Script, so any class is assignable to Managed. if (declNorm === 'auto' || assignNorm === 'auto' || declNorm === 'typename' || assignNorm === 'typename' || - declNorm === 'Class' || assignNorm === 'Class') { + declNorm === 'Class' || assignNorm === 'Class' || + declNorm === 'Managed' || assignNorm === 'Managed') { return { compatible: true, isDowncast: false, isUpcast: false }; } + // Resolve typedefs before checking compatibility so that typedef'd + // names are compared against their underlying types. + const declResolved = this.resolveTypedef(declNorm); + const assignResolved = this.resolveTypedef(assignNorm); + + // If typedef resolution changed anything, re-check with resolved names + if (declResolved !== declNorm || assignResolved !== assignNorm) { + // After resolution, the types might now match directly + if (declResolved === assignResolved) { + return { compatible: true, isDowncast: false, isUpcast: false }; + } + if (declResolved.toLowerCase() === assignResolved.toLowerCase()) { + return { compatible: true, isDowncast: false, isUpcast: false }; + } + } + // Skip if either type is an unresolvable template parameter (TKey, TValue, T, etc.) // These can't be checked without full template substitution, so assume compatible. // Check: if the type doesn't exist as a known class, enum, or typedef in the index, // it's likely a template parameter or something we can't verify. const hardcodedPrimitives = new Set(['int', 'float', 'bool', 'string', 'void', 'vector']); - const declIsKnown = hardcodedPrimitives.has(declLower) || this.findAllClassesByName(declNorm).length > 0; - const assignIsKnown = hardcodedPrimitives.has(assignLower) || this.findAllClassesByName(assignNorm).length > 0; + const isKnownType = (name: string, nameLower: string): boolean => { + if (hardcodedPrimitives.has(nameLower)) return true; + if (this.findAllClassesByName(name).length > 0) return true; + if (this.enumIndex.has(name)) return true; + if (this.typedefIndex.has(name)) return true; + // Also check the resolved form (typedef resolution may have changed the name) + const resolved = this.resolveTypedef(name); + if (resolved !== name) { + if (this.findAllClassesByName(resolved).length > 0) return true; + if (this.enumIndex.has(resolved)) return true; + } + return false; + }; + const declIsKnown = isKnownType(declNorm, declLower); + const assignIsKnown = isKnownType(assignNorm, assignLower); if (!declIsKnown || !assignIsKnown) { return { compatible: true, isDowncast: false, isUpcast: false }; // Unresolvable type } + // --- ENUM TYPE COMPATIBILITY --- + // In Enforce Script, enums are essentially named integers and are very + // loosely typed. They can be freely assigned to/from int, float, bool, + // other enum types, and even used interchangeably in many contexts. + // Treat any type involving an enum as compatible to avoid false positives. + const declIsEnum = this.enumIndex.has(declNorm) || this.enumIndex.has(declResolved); + const assignIsEnum = this.enumIndex.has(assignNorm) || this.enumIndex.has(assignResolved); + + if (declIsEnum || assignIsEnum) { + return { compatible: true, isDowncast: false, isUpcast: false }; + } + // array types - need to check element type compatibility if (declNorm.startsWith('array<') || assignNorm.startsWith('array<')) { const bothArrays = declNorm.startsWith('array') && assignNorm.startsWith('array'); @@ -4316,18 +4445,18 @@ export class Analyzer { // If types are indexed as classes (including primitives like string, int from enconvert.c), // use the hierarchy to determine compatibility before falling back to hardcoded rules. - // Check class hierarchy for UPCAST - const assignedHierarchy = this.getClassHierarchyOrdered(assignNorm, new Set()); + // Check class hierarchy for UPCAST (use resolved names so typedef'd types work) + const assignedHierarchy = this.getClassHierarchyOrdered(assignResolved, new Set()); for (const classNode of assignedHierarchy) { - if (classNode.name === declNorm) { + if (classNode.name === declResolved || classNode.name === declNorm) { return { compatible: true, isDowncast: false, isUpcast: true }; } } // Check class hierarchy for DOWNCAST - const declaredHierarchy = this.getClassHierarchyOrdered(declNorm, new Set()); + const declaredHierarchy = this.getClassHierarchyOrdered(declResolved, new Set()); for (const classNode of declaredHierarchy) { - if (classNode.name === assignNorm) { + if (classNode.name === assignResolved || classNode.name === assignNorm) { return { compatible: true, isDowncast: true, @@ -4341,8 +4470,8 @@ export class Analyzer { // Only used if types aren't found in the indexed class hierarchy // Numeric types are compatible with each other (implicit conversion) - const numericTypes = new Set(['int', 'float', 'bool']); - if (numericTypes.has(declLower) && numericTypes.has(assignLower)) { + const numericTypesFallback = new Set(['int', 'float', 'bool']); + if (numericTypesFallback.has(declLower) && numericTypesFallback.has(assignLower)) { return { compatible: true, isDowncast: false, isUpcast: false }; } @@ -5330,7 +5459,7 @@ export class Analyzer { // In Enforce Script, void parameters are generic "any type" placeholders, // and func/function params accept function references which look like identifiers. // Also skip container types (array, set, map) since we don't compare generics yet. - if (paramType === 'auto' || paramType === 'typename' || paramType === 'Class' || paramType === 'void' || paramType === 'func' || paramType === 'function' || paramType === 'array' || paramType === 'set' || paramType === 'map') continue; + if (paramType === 'auto' || paramType === 'typename' || paramType === 'Class' || paramType === 'Managed' || paramType === 'void' || paramType === 'func' || paramType === 'function' || paramType === 'array' || paramType === 'set' || paramType === 'map') continue; // Skip out/inout params - their types flow differently if (param.modifiers?.includes('out') || param.modifiers?.includes('inout')) continue; From 4cbf8a9282f9c1d1f12fb2514c138e3d6cd50bd2 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Fri, 27 Feb 2026 21:52:33 -0500 Subject: [PATCH 45/46] Skip duplicate const field report in modded classes Avoid false-positive duplicate-field reports when a modded class legitimately redeclares a const field. If isModded is true and the redeclared field has the 'const' modifier, the analyzer now skips reporting the duplicate and continues. This handles the modding override pattern where re-declaration of const fields is allowed. --- server/src/analysis/project/graph.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index f7d0b4f..bce51b6 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -4056,6 +4056,13 @@ export class Analyzer { for (const [fieldName, fieldNode] of classFields) { const inh = inheritedFields.get(fieldName); if (inh) { + // In modded classes, redeclaring a `const` field is the + // legitimate way to override its value — skip the duplicate + // report when BOTH the modded field and the inherited field + // are const. + if (isModded && fieldNode.modifiers?.includes('const')) { + continue; + } reportDup(fieldName, fieldNode, inh.start.line); continue; } From 9ea22c68cd6b7ed0a92469a5aa8943d662709922 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Thu, 5 Mar 2026 10:21:08 -0500 Subject: [PATCH 46/46] Handle watched files; fix generic/bit-shift parsing Add file-watcher handling and reindexing for external FS changes, including deletion support. Introduce Analyzer.removeFromIndex to purge documents and global symbols when files are deleted. Fix parseCallArguments parsing for angle-bracket generics vs bit-shift operators (properly handle << and >> with bracketDepth adjustments). Add comprehensive tests for argument splitting covering generics, nested >> closures, bit shifts, strings and braces, and revalidation of open docs after external reindexing. --- server/src/analysis/project/graph.ts | 41 ++++++++- server/src/index.ts | 51 +++++++++++- test/chain.test.ts | 120 +++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 5 deletions(-) diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index bce51b6..8a735e8 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -639,6 +639,23 @@ export class Analyzer { return this.ensure(doc); } + /** + * Remove a file from all indexes (used when a file is deleted on disk). + * Also removes global symbol index entries and the doc cache entry. + */ + removeFromIndex(uri: string): void { + const normalizedUri = normalizeUri(uri); + this.removeIndexEntriesForUri(normalizedUri); + // Remove from global symbol index + for (const [name, entry] of this.globalSymbolIndex) { + if (entry.uri === normalizedUri) { + this.globalSymbolIndex.delete(name); + } + } + this.symbolIndexDirty = true; + this.docCache.delete(normalizedUri); + } + private ensure(doc: TextDocument): File { // 1 · cache hit const normalizedUri = normalizeUri(doc.uri); @@ -5197,18 +5214,34 @@ export class Analyzer { // Distinguish generic angle brackets <> from bit shift <<, >> if (ch === '<') { const nextCh = i + 1 < argsText.length ? argsText[i + 1] : ''; - if (nextCh !== '<') { // Not bit shift << + if (nextCh === '<') { + // Bit shift <<: consume both characters, don't change bracketDepth + current += '<<'; + i++; + } else { + // Opening generic bracket bracketDepth++; + current += ch; } - current += ch; continue; } if (ch === '>') { const nextCh = i + 1 < argsText.length ? argsText[i + 1] : ''; - if (nextCh !== '>') { // Not bit shift >> + if (nextCh === '>' && bracketDepth >= 2) { + // Two closing generic brackets: >> inside nested generic context + // e.g., Param1>(x) — consume both, decrement twice + bracketDepth -= 2; + current += '>>'; + i++; // skip next > + } else if (nextCh === '>' && bracketDepth === 0) { + // Bit shift >> outside any generic context + current += '>>'; + i++; // skip next > + } else { + // Single > closing one generic bracket (or stray >) if (bracketDepth > 0) bracketDepth--; + current += ch; } - current += ch; continue; } if (ch === '{') { braceDepth++; current += ch; continue; } diff --git a/server/src/index.ts b/server/src/index.ts index bc11e3e..439ef85 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,7 +5,8 @@ import { ProposedFeatures, InitializeParams, InitializeResult, - ConfigurationItem + ConfigurationItem, + FileChangeType } from 'vscode-languageserver/node'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { registerAllHandlers } from './lsp/registerAll'; @@ -201,6 +202,54 @@ connection.onRequest('enscript/checkWorkspace', async () => { }; }); +// Handle file changes on disk (e.g. Copilot edits, git operations, external tools). +// The client sends these via the fileEvents watcher for **/*.c. +// For files NOT open in the editor, we re-read from disk and re-index them. +// For files that ARE open, TextDocuments already tracks their content via +// didOpen/didChange, so we skip them to avoid overwriting in-memory edits. +connection.onDidChangeWatchedFiles(async (params) => { + const analyser = Analyzer.instance(); + let reindexedCount = 0; + + for (const change of params.changes) { + const uri = change.uri; + + if (change.type === FileChangeType.Deleted) { + // File was deleted — remove from index + analyser.removeFromIndex(uri); + // Clear diagnostics for the deleted file + connection.sendDiagnostics({ uri, diagnostics: [] }); + continue; + } + + // Created or Changed — re-read from disk and re-index + // Skip files that are currently open in the editor (TextDocuments + // already keeps them in sync via didChange notifications). + if (documents.get(uri)) continue; + + try { + const filePath = url.fileURLToPath(uri); + const text = await readFileUtf8(filePath); + const doc = TextDocument.create(uri, 'enscript', Date.now(), text); + analyser.parseAndCache(doc); + reindexedCount++; + } catch (err) { + // File may have been deleted between notification and read + console.warn(`Failed to re-index ${uri}: ${err}`); + } + } + + if (reindexedCount > 0) { + console.log(`Re-indexed ${reindexedCount} externally changed file(s)`); + // Re-validate all open documents since the index changed + for (const doc of documents.all()) { + if (!analyser.isWorkspaceFile(doc.uri)) continue; + const diagnostics = analyser.runDiagnostics(doc); + connection.sendDiagnostics({ uri: doc.uri, diagnostics }); + } + } +}); + // Re-validate every open enscript document (called by the client after indexing completes) connection.onNotification('enscript/revalidateOpenFiles', () => { const analyser = Analyzer.instance(); diff --git a/test/chain.test.ts b/test/chain.test.ts index 8fbebed..cf5bdb2 100644 --- a/test/chain.test.ts +++ b/test/chain.test.ts @@ -1669,3 +1669,123 @@ class ActiveConfig { expect(result).toBe('array'); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// parseCallArguments — argument splitting with generics, bit shifts, strings +// ───────────────────────────────────────────────────────────────────────────── + +describe('parseCallArguments', () => { + const analyzer = freshAnalyzer(); + function splitArgs(text: string): string[] { + return (analyzer as any).parseCallArguments(text); + } + + // ── Basic splitting ──────────────────────────────────────────────────── + + test('simple args', () => { + expect(splitArgs('a, b, c')).toEqual(['a', 'b', 'c']); + }); + + test('single arg', () => { + expect(splitArgs('x')).toEqual(['x']); + }); + + test('empty string returns empty', () => { + expect(splitArgs('')).toEqual([]); + }); + + // ── Nested parentheses ───────────────────────────────────────────────── + + test('nested function call', () => { + expect(splitArgs('Func(a, b), c')).toEqual(['Func(a, b)', 'c']); + }); + + test('deeply nested parens', () => { + expect(splitArgs('A(B(C(1))), 2')).toEqual(['A(B(C(1)))', '2']); + }); + + // ── String literals ──────────────────────────────────────────────────── + + test('string with comma', () => { + expect(splitArgs('"hello, world", x')).toEqual(['"hello, world"', 'x']); + }); + + test('string with escaped quote', () => { + expect(splitArgs('"say \\"hi\\"", x')).toEqual(['"say \\"hi\\""', 'x']); + }); + + // ── Generic angle brackets ───────────────────────────────────────────── + + test('simple generic: array', () => { + expect(splitArgs('new array(), x')).toEqual(['new array()', 'x']); + }); + + test('generic with comma in type args: map', () => { + expect(splitArgs('new map(), x')).toEqual(['new map()', 'x']); + }); + + test('nested generics with >> closing: Param1>', () => { + // The key bug fix: >> should be treated as two > closing brackets + expect(splitArgs('new Param1>(x), true, NULL')).toEqual([ + 'new Param1>(x)', 'true', 'NULL' + ]); + }); + + test('nested generics with autoptr: Param1>', () => { + // Exact pattern from the reported false positive + expect(splitArgs('MapItem, BASICMAPRPCS.SAVE_MARKERS, new Param1>(ClientMarkers), true, NULL')).toEqual([ + 'MapItem', 'BASICMAPRPCS.SAVE_MARKERS', + 'new Param1>(ClientMarkers)', + 'true', 'NULL' + ]); + }); + + test('triple nested generics: A>>', () => { + expect(splitArgs('new A>>(x), y')).toEqual([ + 'new A>>(x)', 'y' + ]); + }); + + // ── Bit shift operators ──────────────────────────────────────────────── + + test('bit shift >> at top level', () => { + expect(splitArgs('a >> 2, b')).toEqual(['a >> 2', 'b']); + }); + + test('bit shift << at top level', () => { + expect(splitArgs('a << 2, b')).toEqual(['a << 2', 'b']); + }); + + test('bit shift >> after generic close', () => { + // new Map>(a >> b) — >> closes generics, then >> is bit shift + expect(splitArgs('new Map>(a >> b), c')).toEqual([ + 'new Map>(a >> b)', 'c' + ]); + }); + + test('bit shift << in expression arg', () => { + expect(splitArgs('1 << 3, flags')).toEqual(['1 << 3', 'flags']); + }); + + test('multiple bit shifts', () => { + expect(splitArgs('a << 2, b >> 3, c')).toEqual(['a << 2', 'b >> 3', 'c']); + }); + + // ── Braces (array literals) ──────────────────────────────────────────── + + test('brace literal with commas', () => { + expect(splitArgs('{1, 2, 3}, x')).toEqual(['{1, 2, 3}', 'x']); + }); + + // ── Mixed scenarios ──────────────────────────────────────────────────── + + test('generic + function call + string', () => { + expect(splitArgs('new array(), GetName(), "hello"')).toEqual([ + 'new array()', 'GetName()', '"hello"' + ]); + }); + + test('bit shift inside parens does not affect splitting', () => { + expect(splitArgs('Func(a << 2), b')).toEqual(['Func(a << 2)', 'b']); + }); +});