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/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 24a88a5..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": [ @@ -46,6 +62,10 @@ { "command": "enscript.dumpDiagnostics", "title": "Enscript: Dump Diagnostics" + }, + { + "command": "enscript.checkWorkspace", + "title": "Enscript: Check All Workspace Files" } ], "configuration": { @@ -53,11 +73,19 @@ "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" } + }, + "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" + } } } } @@ -81,6 +109,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..ae4454f 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'; @@ -35,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); @@ -114,6 +150,17 @@ 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) + 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 { @@ -121,20 +168,56 @@ export interface FunctionDeclNode extends SymbolNodeBase { 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) + isOverride: boolean; // true if declared with the 'override' keyword } 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) + skippedRegions?: { start: number, end: number }[] // Character ranges blanked by #ifdef processing } // 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; + + // ==================================================================== + // 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,21 +255,57 @@ 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', 'auto'].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'); }; /* 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); - // ignore default values + // Skip any remaining tokens until ')' or ',' (default values are already consumed by parseDecl) while (!eof() && peek().value !== ')' && peek().value !== ',') next(); @@ -196,6 +315,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; }; @@ -208,10 +352,12 @@ export function parse( // ast root const file: File = { body: [], - version: doc.version + version: doc.version, + 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; @@ -221,20 +367,47 @@ 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) + const MAX_RECOVERY_TOKENS = 500; + let braceDepth = 0; + 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(); skippedTokens++; break; } + braceDepth--; next(); skippedTokens++; + if (braceDepth === 0) break; // closed a balanced block + } + 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 + ); + } + } } - /* 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) @@ -252,6 +425,17 @@ export function parse( mods.push(next().value); } + // Handle EOF after modifiers (e.g., empty file or file ending with modifiers only) + if (eof()) { + return []; + } + + // Handle standalone annotations with no declaration: [Obsolete("...")]; + if (annotations.length > 0 && peek().value === ';') { + next(); // consume the semicolon + return []; + } + const t = peek(); // class @@ -285,8 +469,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('}'); @@ -296,6 +507,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, @@ -368,21 +580,280 @@ 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; + // ==================================================================== + 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. + // 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; + let prevPrevIdx = -1; + let prev: Token | null = null; + let prevIdx = -1; while (depth > 0 && !eof()) { const t = next(); - if (t.value === '{') depth++; - else if (t.value === '}') depth--; + const tIdx = pos - 1; // index of the token that next() just returned + 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) { + // 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); + } + } + + // ================================================================ + // 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); + 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; + } + // 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 || + 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)) + // 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. + // + // 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 + // '>>' counts as 2 closing brackets (nested generics) + let angleDepth = prevPrev.value === '>>' ? 2 : 1; + let searchPos = prevPrevIdx - 1; // start before the '>' or '>>' + // 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++; + 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 '<', + // 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) { + const local: VarDeclNode = { + 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: typeTok.value, + start: doc.positionAt(typeTok.start), + end: doc.positionAt(typeTok.end), + arrayDims: [], + modifiers: [], + }, + annotations: [], + 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); + } + } + } + + prevPrev = prev; + prevPrevIdx = prevIdx; + prev = t; + prevIdx = tIdx; } } @@ -394,7 +865,10 @@ export function parse( nameEnd: doc.positionAt(nameTok.end), returnType: baseTypeNode, parameters: params, - locals: [], //locals, + locals: locals, + returnStatements: returnStatements, + hasBody: hasBody, + isOverride: mods.includes('override'), annotations: annotations, modifiers: mods, start: baseTypeNode.start, @@ -405,6 +879,7 @@ export function parse( // variable const vars: VarDeclNode[] = []; + let sawDefault = false; while (!eof()) { const typeNode = structuredClone(baseTypeNode); @@ -421,26 +896,68 @@ export function parse( // value initialization (skip for now) if (peek().value === '=') { + sawDefault = true; 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 + // 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(); } } 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 !== '%' && 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"); } } @@ -453,6 +970,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, @@ -492,18 +1010,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 +1133,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,7 +1170,45 @@ export function parse( }; } - if (t.kind !== TokenKind.Identifier) throwErr(t, 'identifier'); + // 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(); + const validOpOverloads = new Set([ + '==', '!=', '<=', '>=', '<<', '>>', + '<', '>', '+', '-', '*', '/', '%', + '&', '|', '^', '~', '!', '[', + ]); + if (validOpOverloads.has(opTok.value)) { + 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) { + // 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; } @@ -586,16 +1219,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/lexer/lexer.ts b/server/src/analysis/lexer/lexer.ts index e513764..a4f0991 100644 --- a/server/src/analysis/lexer/lexer.ts +++ b/server/src/analysis/lexer/lexer.ts @@ -1,7 +1,125 @@ +/** + * 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'; + +/** + * 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; -export function lex(text: string): Token[] { + // 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; @@ -41,18 +159,67 @@ export function lex(text: string): Token[] { continue; } - // pre-processor (#define, #ifdef …) + // pre-processor (#define, #ifdef, #else, #endif, etc.) + // Strategy for #ifdef/#else/#endif: + // - 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; 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 + 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/#elif or #endif + i = skipPreprocRegion(text, i, /* stopAtElse */ true); + 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; + // Skip from #else until the matching #endif + i = skipPreprocRegion(text, i, /* stopAtElse */ false); + 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; + } + + // 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 +227,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 +299,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/cache.ts b/server/src/analysis/project/cache.ts deleted file mode 100644 index f897952..0000000 --- a/server/src/analysis/project/cache.ts +++ /dev/null @@ -1 +0,0 @@ -// TODO – JSON serialisation of AST for faster startup diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 6ecdf1a..8a735e8 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -1,9 +1,47 @@ +/** + * 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'; +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'; @@ -18,46 +56,102 @@ interface SymbolEntry { scope: 'global' | 'class' | 'function'; } +/** + * Completion result with optional metadata + */ +interface CompletionResult { + name: string; + kind: string; + detail?: string; + insertText?: string; + 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. + * + * 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); + + // 4 · Classify: keyword vs identifier vs number + if (/^\d/.test(value)) return null; // pure numeric — not useful for hover/def - return null; + const kind = keywords.has(value) ? TokenKind.Keyword : TokenKind.Identifier; + return { kind, value, start: lo, end: hi }; } -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; } @@ -92,6 +186,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; @@ -101,11 +237,430 @@ 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(); + + /** 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 + 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; + 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); + } 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); + } + } + + // 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 */ + 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 { + this.preprocessorDefines = new Set(defines); + } + + /** Store include paths so diagnostics can be suppressed for external files */ + private includePaths: string[] = []; + private workspaceRoots: string[] = []; + + setIncludePaths(paths: string[]): void { + this.includePaths = paths.map(p => p.replace(/\\/g, '/').toLowerCase()); + } + + setWorkspaceRoot(root: string): void { + this.workspaceRoots = [root.replace(/\\/g, '/').toLowerCase()]; + } + + 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.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 this.workspaceRoots.some(root => fsPath.startsWith(root)); + } + + /** + * 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 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. + */ + static computeSkippedRegions(text: string, defines: Set): { start: number, end: number }[] { + const regions: { start: number, end: number }[] = []; + const lines = text.split('\n'); + + interface IfdefState { + processFirstBranch: boolean; + inElseBranch: boolean; + } + const stack: IfdefState[] = []; + + const isSkipping = (): boolean => { + for (const s of stack) { + 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 = defines.has(symbol); + const processFirst = isIfdef ? isDefined : !isDefined; + + 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; + regions.push({ start: offset, end: lineEnd }); + offset = lineEnd + 1; + continue; + } + + if (trimmed.match(/^#\s*endif\b/) && stack.length > 0) { + stack.pop(); + regions.push({ start: offset, end: lineEnd }); + offset = lineEnd + 1; + continue; + } + + if (isSkipping()) { + 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] = ' '; + } + } + } + return result.join(''); + } + + /** 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 }; + } + + /** + * 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) + */ + parseAndCache(doc: TextDocument): File { + 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); + } + + /** + * 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); 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; @@ -113,35 +668,49 @@ export class Analyzer { try { // 2 · happy path ─ parse & cache - const ast = parse(doc); // pass full TextDocument - this.docCache.set(normalizeUri(doc.uri), ast); + const ast = parse(doc, undefined, this.preprocessorDefines); // pass full TextDocument + defines + 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 — 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 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}`); - // // 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] }); - console.error(String(err.stack)); + // 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)); } // 4 · return an empty stub so callers can continue - return { body: [], version: 0 }; + return { body: [], version: 0, diagnostics: [] }; } } @@ -188,129 +757,5541 @@ 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); + + // ================================================================ + // 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. + // ================================================================ + // 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 + ); + } + + // 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); + } + } + + // Handle 'super' keyword + if (name === 'super') { + const containingClass = this.findContainingClass(ast, pos); + if (containingClass?.base?.identifier) { + return this.getClassMemberCompletions(containingClass.base.identifier, 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); + } + } + } + + 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})` + }); + } + } + } + } + + // ================================================================ + // 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; } - resolveDefinitions(doc: TextDocument, _pos: Position): SymbolNodeBase[] { - const offset = doc.offsetAt(_pos); - - const token = getTokenAtPosition(doc.getText(), offset); - if (!token || token.kind !== TokenKind.Identifier) return []; + /** + * Resolve the type of a variable at a given position. + * 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 { + + // 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) { + return typeNode.identifier || null; + } + + // 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 = 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*<[^>]*>)?\\s+${varName}\\s*[;=\\[(]`)); + if (varDeclMatch && !regexKeywords.has(varDeclMatch[1])) { + return varDeclMatch[1]; + } + + // Pattern: (Type varName) or (Type varName,) or (Type varName[) - function parameters + // Also handles generic types + const paramMatch = text.match(new RegExp(`[,(]\\s*(\\w+)(?:\\s*<[^>]*>)?\\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]; + } + + // 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; + } - const name = token.value; - console.info(`resolveDefinitions: "${name}"`); + /** + * 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); + + // 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 || []) { + if (param.name === varName && param.type) return param.type; + } + for (const local of containingFunc.locals || []) { + if (local.name === varName && local.type) return local.type; + } + } + + 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!; + } + } + } + } + + for (const [uri, fileAst] of this.docCache) { + for (const node of fileAst.body) { + if (node.kind === 'VarDecl' && node.name === varName && (node as VarDeclNode).type) { + return (node as VarDeclNode).type!; + } + } + } + + return null; + } - const matches: SymbolNodeBase[] = []; + /** + * 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 { + 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; + } - // iterate all loaded documents - for (const [uri, ast] of this.docCache) { - for (const node of ast.body) { - // top-level match - if (node.name === name) { - matches.push(node as SymbolNodeBase); + /** + * 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 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) { + for (const func of funcs) { + 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; + } + + /** + * 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 result = this.resolveMethodReturnTypeNode(className, methodName); + return result?.identifier ?? null; + } - // class member match - if (node.kind === 'ClassDecl') { - for (const member of (node as ClassDeclNode).members) { - if (member.name === name) { - matches.push(member as SymbolNodeBase); + /** + * 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 step + // (both typedef expansions AND extends clauses with generic arguments) + 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, building template map from extends clause generic args + const originalClass = classNodes.find(c => !c.modifiers?.includes('modded')); + 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; + }; + + return walk(className); + } - // enum member match - if (node.kind === 'EnumDecl') { - for (const member of (node as EnumDeclNode).members) { - if (member.name === name) { - matches.push(member as SymbolNodeBase); - } + /** + * 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(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) { + const func = member as FunctionDeclNode; + 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; } } } } - - return matches; + + // 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; } - getHover(doc: TextDocument, _pos: Position): string | null { - const symbols = this.resolveDefinitions(doc, _pos); - if (symbols.length === 0) return null; - - return symbols - .map((s) => formatDeclaration(s)) - .join('\n\n'); + /** + * 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 { + 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; } - findReferences(doc: TextDocument, _pos: Position, _inc: boolean) { - return []; + /** + * 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; } - prepareRename(doc: TextDocument, _pos: Position): Range | null { - return null; + /** + * 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; } - renameSymbol(doc: TextDocument, _pos: Position, _newName: string) { - return [] as { uri: string; range: Range }[]; + /** + * Given a type that is being indexed with [], return the element type. + * 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'; + // 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; } - getInnerWorkspaceSymbols(uri: string, query: string, members: SymbolNodeBase[], containerName?: string): SymbolInformation[] { - const res: SymbolInformation[] = []; - for (const node of members) { - if (node.name.includes(query)) { - res.push({ - name: node.name, - kind: toSymbolKind(node.kind), - containerName: containerName, - location: { uri, range: { start: node.nameStart, end: node.nameEnd } } - }); + /** + * 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 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 (parenDepth === 0) { + if (ch === '[') { + if (bracketDepth === 0) count++; + bracketDepth++; + } else if (ch === ']') { + if (bracketDepth > 0) bracketDepth--; + } } + } + return count; + } - if (node.kind === "ClassDecl") { - res.push(...this.getInnerWorkspaceSymbols(uri, query, (node as ClassDeclNode).members, node.name)); + /** + * 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 (node.kind === "EnumDecl") { - for (const enumerator of (node as EnumDeclNode).members) { - if (enumerator.name.includes(query)) { - res.push({ - name: enumerator.name, - kind: SymbolKind.EnumMember, - containerName: node.name, - location: { uri, range: { start: enumerator.nameStart, end: enumerator.nameEnd } } - }) + 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; + // 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 res + return false; } - getWorkspaceSymbols(query: string): SymbolInformation[] { - const res: SymbolInformation[] = []; - for (const [uri, ast] of this.docCache) { - res.push(...this.getInnerWorkspaceSymbols(uri, query, ast.body, undefined)); + /** + * 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 { + 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 res; + + return node; } - runDiagnostics(doc: TextDocument) { - const ast = this.ensure(doc); - const diags = [] as any[]; - for (const node of (ast.body as any[])) { - if (node.kind === 'Typedef') { - diags.push({ - message: `Typedef '${node.name}' is never used`, - range: { start: doc.positionAt(node.start), end: doc.positionAt(node.end) }, - severity: 2 - }); - } - } + /** + * 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 + */ + + // ======================================================================== + // 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|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. + * + * 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); + if (!cc) return null; + + // 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'; + } + + // 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), + * 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) + // 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() }; + } + // 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"]. + * 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[] = []; + let remaining = text.trim(); + + while (remaining.startsWith('.')) { + remaining = remaining.substring(1).trim(); + + const methodMatch = remaining.match(/^(\w+)\s*\(/); + if (!methodMatch) { + // 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(); + // 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; + } + + calls.push(methodMatch[1]); + + // 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) { + 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(); + // 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; + } + + /** + * 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 and template map, or null if any step fails + */ + private resolveChainSteps( + calls: string[], + currentType: string, + templateMap: Map + ): { type: string; templateMap: Map } | 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)!; + } + + // 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) { + 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 { 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 + * resolveChainSteps for subsequent member accesses. + */ + private resolveChainReturnType(chainText: string, className?: 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]; + + // 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) { + 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(); + + // Parse remaining chain members: .Method().Prop.Other() + const calls = this.parseChainMembers(afterFirst); + + // Resolve the first function's return type + const firstTypeNode = this.resolveFunctionReturnTypeNode(firstFunc); + 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; + } + } + + let currentType2 = currentType; + + // Resolve typedef and build initial template map + let templateMap: Map; + const typedefNode = this.resolveTypedefNode(currentType2); + if (typedefNode) { + currentType2 = typedefNode.oldType.identifier; + templateMap = this.buildTemplateMap(currentType2, typedefNode.oldType.genericArgs); + } else { + 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] + 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 chainResult = this.resolveChainSteps(calls, currentType2, templateMap); + if (!chainResult) return null; + + // If the chain contains array indexing outside of args, resolve to element type + 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 chainResult.type; + } + + /** + * 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(); + } + + 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 countIndexingLevels to avoid false positives from [] + // inside function arguments like .GetSurface(pos[0], pos[2]). + 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 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; + } + + /** + * 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. + * + * @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, 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 || []) { + 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; + + // 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 => + `${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} ` : ''; + + results.push({ + name: func.name, + kind: 'function', + detail: `${visPrefix}${resolvedReturnType}(${params}) - ${classNode.name}`, + insertText: `${func.name}()`, + 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}${resolvedFieldType} - ${classNode.name}` + }); + } + } + } + + return results; + } + + /** + * 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(); + + // 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; + + 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)` + }); + } + } + } + + return results; + } + + /** + * 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 { + 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 { + 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 { + // 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; + } + + /** + * 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(text, offset); + 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)) && + !(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 + const textBeforeToken = text.substring(0, token.start); + + // ================================================================ + // 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]; + } + } + } + } + + // Check if we're inside a class - prioritize current class and inheritance + const containingClass = this.findContainingClass(ast, _pos); + + if (containingClass) { + // 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 + // - 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(); + + // 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) { + 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 + + 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); + + const seenPositions = new Set(); + for (const classNode of classesToSearch) { + for (const member of classNode.members || []) { + if (member.name === memberName) { + // 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); + // 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); + } + } + } + } + + // 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; + } + + /** + * 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) + // 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 classNodes: ClassDeclNode[] = []; + 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); + } + 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 + 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')); + 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) + // 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]; + + // 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 + * Uses the pre-built class index for O(1) lookup. + */ + private findAllClassesByName(className: string): ClassDeclNode[] { + return this.classIndex.get(className) || []; + } + + 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; + + // 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), + * 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); + + // 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; + } + + findReferences(doc: TextDocument, _pos: Position, _inc: boolean) { + return []; + } + + prepareRename(doc: TextDocument, _pos: Position): Range | null { + return null; + } + + renameSymbol(doc: TextDocument, _pos: Position, _newName: string) { + 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) { + if (node.name.includes(query)) { + res.push({ + name: node.name, + kind: toSymbolKind(node.kind), + containerName: containerName, + location: { uri, range: { start: node.nameStart, end: node.nameEnd } } + }); + } + + if (node.kind === "ClassDecl") { + res.push(...this.getInnerWorkspaceSymbols(uri, query, (node as ClassDeclNode).members, node.name)); + } + + if (node.kind === "EnumDecl") { + for (const enumerator of (node as EnumDeclNode).members) { + if (enumerator.name.includes(query)) { + res.push({ + name: enumerator.name, + kind: SymbolKind.EnumMember, + containerName: node.name, + location: { uri, range: { start: enumerator.nameStart, end: enumerator.nameEnd } } + }) + } + } + } + } + return res + } + + // 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; + + // ======================================================================== + // 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 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. + // ======================================================================== + + /** + * 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; + } + + /** + * Build the unified scoped variable map from AST declarations. + * Contains ALL variables: globals, class fields, inherited fields, func params, + * and func locals (detected by the parser — including foreach variables and + * auto-typed variables). + * + * 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 + ): 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; + const localEnd = local.scopeEnd?.line ?? funcEnd; + add(local.name, local.type.identifier, localStart, localEnd, 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, 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); + } + } + for (const l of func.locals || []) { + if (l.name && l.type?.identifier) { + const localStart = l.start?.line ?? fStart; + const localEnd = l.scopeEnd?.line ?? fEnd; + add(l.name, l.type.identifier, localStart, localEnd, 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. + // 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) { + 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); + } + } + } + } + } + } + + // 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; + } + + 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); + } + + // ── 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 + // expensive work (ifdef stripping, line splitting, line offset table, + // scoped variable map) is done only once per diagnostic run. + // 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); + + // 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); + + // Check for type mismatches in assignments + this.checkTypeMismatches(doc, diags, text, lines, lineOffsets, ast, scopedVars); + + // 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 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) + // 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 + // of re-parsing text line-by-line. Also checks missing 'override' keyword. + this.checkDuplicateVariables(ast, diags); + 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; + + 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 || []; + + // 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]; + + // --- 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) { + 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'; + + // --- 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, className); + } + // 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). + * + * 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(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; + }; + + // 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 { + globalNames.set(node.name, node); + } + } + } + + // ── 2. Process classes ───────────────────────────────────────────── + for (const node of ast.body) { + if (node.kind === 'ClassDecl') { + this.checkDuplicatesInClass(node as ClassDeclNode, globalNames, diags, reportDup); + } + } + + // ── 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 + ); + } + } + } + } + + /** + * 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 parentMethods = new Map(); + + 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 (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); + } + } + + // 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 + 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; + // 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; + const existing = moddedSiblingMethods.get(member.name); + const isIntroduction = !func.isOverride; + if (existing) { + if (isIntroduction) existing.anyIntroduced = true; + } else { + moddedSiblingMethods.set(member.name, { anyIntroduced: isIntroduction }); + } + } + } + } + } 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) { + 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); + } + } + } + + // 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); + 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; + } + 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; + + // 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) { + // 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 + 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. + // 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.${conflictFile}`, + range: { start: func.nameStart, end: func.nameEnd }, + severity: DiagnosticSeverity.Warning + }); + } + } + } + + // 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 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. + */ + 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 + } + } + } + } + + /** + * 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 }; + } + + // Case-insensitive comparison for primitive names (e.g. String vs string) + const declLower = declNorm.toLowerCase(); + const assignLower = assignNorm.toLowerCase(); + + if (declLower === assignLower) { + 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/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 === '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 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'); + return { compatible: bothArrays, isDowncast: false, isUpcast: false }; + } + + // --- 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 (use resolved names so typedef'd types work) + const assignedHierarchy = this.getClassHierarchyOrdered(assignResolved, new Set()); + for (const classNode of assignedHierarchy) { + if (classNode.name === declResolved || classNode.name === declNorm) { + return { compatible: true, isDowncast: false, isUpcast: true }; + } + } + + // Check class hierarchy for DOWNCAST + const declaredHierarchy = this.getClassHierarchyOrdered(declResolved, new Set()); + for (const classNode of declaredHierarchy) { + if (classNode.name === assignResolved || classNode.name === assignNorm) { + return { + compatible: true, + isDowncast: true, + isUpcast: false, + message: `Unsafe downcast from '${assignNorm}' to '${declNorm}'. Use '${declNorm}.Cast(value)' or 'Class.CastTo(target, value)' instead.` + }; + } + } + + // --- 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 numericTypesFallback = new Set(['int', 'float', 'bool']); + if (numericTypesFallback.has(declLower) && numericTypesFallback.has(assignLower)) { + return { compatible: true, isDowncast: false, isUpcast: false }; + } + + const declIsPrimitive = hardcodedPrimitives.has(declLower); + const assignIsPrimitive = hardcodedPrimitives.has(assignLower); + + // 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, + 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) { + 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 + if (declIsPrimitive && assignIsPrimitive && declNorm !== assignNorm) { + return { + compatible: false, + isDowncast: false, + isUpcast: false, + message: `Cannot assign '${assignNorm}' to '${declNorm}'` + }; + } + + // 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[], + 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: { 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 + 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; + }; + + // 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 '' + + // Pattern 1: Type varName = FunctionCall(); + // e.g., int i = GetGame(); + // 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 (Analyzer.isInsideCommentOrStringAt(text, 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]; + + // Skip if declared type is a keyword that's not a type + if (['if', 'while', 'for', 'switch', 'return', 'new', 'delete', 'class', 'enum', 'typedef'].includes(declaredType)) { + continue; + } + + // 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 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 type resolution, pass everything up to ';' - resolveChainReturnType + // handles trailing non-chain text gracefully + const fullChainText = funcName + '(' + afterMatch.substring(0, chainEnd); + 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(...) + 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 + 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; + } + } + // 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 = Analyzer.getLineFromOffset(lineOffsets, 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); + } + // 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; + } + + 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); + } + } + + // Pattern 2: Type varName = otherVar; + // e.g., int i = p; where p is PlayerBase + // 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 (Analyzer.isInsideCommentOrStringAt(text, 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', 'typedef'].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 at this line + const lineNum = Analyzer.getLineFromOffset(lineOffsets, match.index); + const sourceType = getVarTypeAtLine(sourceVar, lineNum); + + 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) { + // Skip if inside comment or string + if (Analyzer.isInsideCommentOrStringAt(text, 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; + } + + // Skip literals + if (/^\d+$/.test(sourceVar) || ['true', 'false', 'null', 'NULL'].includes(sourceVar)) { + continue; + } + + // Look up types for both variables at this line + const lineNum = Analyzer.getLineFromOffset(lineOffsets, 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; + 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) { + // Skip if inside comment or string + if (Analyzer.isInsideCommentOrStringAt(text, 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; + } + + // 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 = Analyzer.getLineFromOffset(lineOffsets, 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; + + const fullChainText = funcName + '(' + afterMatch.substring(0, chainEnd); + 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; + 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; + 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; + } + } + // 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); + } + // 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) { + // 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; + } + // 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; + 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 (Analyzer.isInsideCommentOrStringAt(text, 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 = Analyzer.getLineFromOffset(lineOffsets, 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); + + // 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); + } + + // ================================================================ + // 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 (Analyzer.isInsideCommentOrStringAt(text, 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 = Analyzer.getLineFromOffset(lineOffsets, 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; + + // 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; + const actualLength = match[0].length - 1 - leadingWs.length + (stmtEnd >= 0 ? stmtEnd : afterDot.length); + this.addTypeMismatchDiagnostic(doc, diags, actualStart, actualLength, targetType, returnType); + } + } + + // ======================================================================== + // 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: 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); + } + } + } + } + } + + 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) { + // 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; + } + + // Track nesting + if (ch === '(' || ch === '[') { depth++; current += ch; continue; } + if (ch === ')' || ch === ']') { depth--; current += ch; continue; } + // Distinguish generic angle brackets <> from bit shift <<, >> + if (ch === '<') { + const nextCh = i + 1 < argsText.length ? argsText[i + 1] : ''; + if (nextCh === '<') { + // Bit shift <<: consume both characters, don't change bracketDepth + current += '<<'; + i++; + } else { + // Opening generic bracket + bracketDepth++; + current += ch; + } + continue; + } + if (ch === '>') { + const nextCh = i + 1 < argsText.length ? argsText[i + 1] : ''; + 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; + } + 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, + containingClassName?: string + ): string | null { + 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'; + + // 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(...) 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) { + 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; + } + + // 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 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('('); + 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); + } + // Check for array indexing after the function call: FuncName(...)[expr] + if (afterCall.startsWith('[')) { + return this.resolveIndexedType(rootType); + } + } + return rootType; + } + + // Method chain: var.Method(...) or ClassName.StaticMethod(...) + const chainMatch = arg.match(/^(\w+)\s*\./); + if (chainMatch) { + const rootName = chainMatch[1]; + let varType = getVarType(rootName); + + // If no variable found, check if it's a class name (static access) + if (!varType && /^[A-Z]/.test(rootName) && this.classIndex.has(rootName)) { + varType = rootName; + } + + 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()); + 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; + } + } + } + } + + // 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 === '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; + + 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[], + 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 = scopedVars.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; + }; + + // Keywords and built-ins that look like function calls but aren't + const skipNames = new Set([ + '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' + ]); + + // 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 (Analyzer.isInsideCommentOrStringAt(text, 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) + // 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 + 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 - fullMatch.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 + + // 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; + + // Determine if this is a method call or global call + const textBeforeFunc = text.substring(Math.max(0, match.index - 200), match.index); + const lineNum = Analyzer.getLineFromOffset(lineOffsets, match.index); + + let overloads: FunctionDeclNode[] = []; + let chainAttempted = false; + let chainResolvedType: string | undefined; // Set when chain resolution identifies a known receiver type + let dotIsPartOfChain = 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*$/); + if (dotMatch) { + const objName = dotMatch[1]; + + // Skip super calls entirely — super always calls a valid parent/original + // method. The LSP may not have complete hierarchy knowledge, and in + // modded classes super refers to the previous mod layer, not the parent class. + if (objName === 'super') continue; + + // 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") + // 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] === ')'); + dotIsPartOfChain = isPartOfChain; + // 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); + } + + if (overloads.length === 0) { + let objType = getVarTypeAtLine(objName, lineNum); + if (objType) { + objType = this.resolveTypedef(objType); + overloads = this.findFunctionOverloads(funcName, objType); + } + + // 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+$/, ''); + // 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. + if (fullTextBefore.endsWith('.')) { + 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 + // Only for true static access, not when objName is part of a chain + if (overloads.length === 0 && !isPartOfChain && isStaticAccess) { + 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) + const trimBefore = textBeforeFunc.replace(/\s+$/, ''); + if (trimBefore.endsWith('.')) { + chainAttempted = true; + // 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); + } + } + + // 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) { + if (containingClassName) { + overloads = this.findFunctionOverloads(funcName, containingClassName); + } + if (overloads.length === 0) { + overloads = this.findFunctionOverloads(funcName); + } + } + } + + 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; + } + + // Skip warning for chain calls where we couldn't resolve the target type — + // 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 && !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 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: displayType + ? `Unknown method '${funcName}' on type '${displayType}'` + : `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), containingClassName) + ); + + // 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 + */ + 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 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. + * + * Detects patterns like: + * Print("text" + + * "more text"); // ERROR! + */ + private checkMultiLineStatements(doc: TextDocument, diags: Diagnostic[], text: string, lines: string[]): void { + + // 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 single-line comments + if (!line || line.startsWith('//')) continue; + + // 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; + } + + // Track brace depth + const openBraces = (line.match(/\{/g) || []).length; + 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) + // 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; + + // Only check for unclosed parentheses - this is the real multi-line issue + // e.g., Print("text" + + // "more"); <-- not allowed in Enforce Script + const openParens = (codePart.match(/\(/g) || []).length; + const closeParens = (codePart.match(/\)/g) || []).length; + const unclosedParens = openParens > closeParens; + + // 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); + + // Skip preprocessor lines + if (codePart.startsWith('#')) continue; + + // Detect expression continuation via operators: + // string x = "a" + b + ← line ends with binary operator + // "c"; + const endsWithBinaryOp = /(?:\+(?!\+)|-(?!-)|[*\/%&|^~]|&&|\|\|)\s*$/.test(codePart); + + // 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()) { + nextLineIdx++; + } + + if (nextLineIdx < lines.length) { + const nextLine = lines[nextLineIdx].trim(); + 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 + }); + } + } + } + } + } + + // ==================================================================== + // 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: + * - 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', + // 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 + 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 + } + + // 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; + 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) { + return true; + } + } + } + + // 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; + }; + + // Check a type node for unknown types and cross-module access + const checkType = (type: TypeNode | undefined): void => { + if (!type) return; + + if (!typeExists(type.identifier)) { + diags.push({ + message: `Unknown type '${type.identifier}'`, + 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 + 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 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 + 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); + } + for (const local of func.locals || []) { + checkType(local.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); + } + for (const local of func.locals || []) { + checkType(local.type); + } + } + } + } + private toSymbolKindName(kind: string): SymbolEntry['kind'] { switch (kind) { case 'ClassDecl': return 'class'; diff --git a/server/src/index.ts b/server/src/index.ts index c4087e2..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'; @@ -22,14 +23,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 { @@ -48,31 +49,217 @@ 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[] || []; + + // Store include paths on the analyzer so diagnostics can be suppressed + if (includePaths.length > 0) { + Analyzer.instance().setIncludePaths(includePaths); + } + Analyzer.instance().setWorkspaceRoots(workspaceRoots); + + // Configure preprocessor defines + if (preprocessorDefines.length > 0) { + Analyzer.instance().setPreprocessorDefines(preprocessorDefines); + console.log(`Preprocessor defines: ${preprocessorDefines.join(', ')}`); + } - const pathsToIndex = [workspaceRoot, ...includePaths]; + const pathsToIndex = [...workspaceRoots, ...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 + }); + + 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); + Analyzer.instance().parseAndCache(doc); + + // Send progress updates at most every 500ms + const now = Date.now(); + if (now - lastProgressUpdate >= 500) { + connection.sendNotification('enscript/indexingProgress', { + current: i + 1, + total: allFiles.length, + percent: Math.round((i + 1) / allFiles.length * 100) + }); + lastProgressUpdate = now; + } + } + + 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 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)` : '') + ); + // 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', { + fileCount: allFiles.length, + workspaceRoots: workspaceRoots + }); +}); - for (const filePath of allFiles) { +// Handle request to check all workspace files +connection.onRequest('enscript/checkWorkspace', async () => { + console.log(`Checking all workspace files in ${workspaceRoots.join(', ')}...`); + + 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) { 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`); + + // 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, + totalIssues: allDiagnostics.reduce((sum, d) => sum + d.diagnostics.length, 0) + }; +}); + +// 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; - Analyzer.instance().runDiagnostics(doc); // will parse & cache + 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}`); + } } - console.log('Indexing complete.'); + 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(); + 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. 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; } diff --git a/server/src/lsp/handlers/diagnostics.ts b/server/src/lsp/handlers/diagnostics.ts index 5e42539..db82301 100644 --- a/server/src/lsp/handlers/diagnostics.ts +++ b/server/src/lsp/handlers/diagnostics.ts @@ -12,11 +12,51 @@ 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 }); }; + // Debounce timers per-URI so each file gets its own delay + const debounceTimers = new Map>(); + 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)); + }); } diff --git a/server/src/lsp/handlers/documents.ts b/server/src/lsp/handlers/documents.ts index ae7d94f..c5f8f18 100644 --- a/server/src/lsp/handlers/documents.ts +++ b/server/src/lsp/handlers/documents.ts @@ -16,7 +16,47 @@ 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)); + }); + + docs.onDidClose((change) => { + const uri = change.document.uri; + const pending = debounceTimers.get(uri); + if (pending) { + clearTimeout(pending); + debounceTimers.delete(uri); + } + }); } 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/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 entry.name.endsWith(ext))) { files.push(fullPath); 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 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 02b1ccf..0a04843 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,10 +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'] }; @@ -38,10 +42,66 @@ 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 }) => { + if (statusBarItem) { + statusBarItem.text = `$(check) Enscript: Ready`; + statusBarItem.tooltip = `Indexed ${params.fileCount} files`; + // Hide after 5 seconds + setTimeout(() => { + if (statusBarItem) { + statusBarItem.hide(); + } + }, 5000); + } + + // Ask the server to re-run diagnostics on every open document + client?.sendNotification('enscript/revalidateOpenFiles'); + }); 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 () => { 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" + } + ] + } + } +} 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": "(? { + 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'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 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']); + }); +}); diff --git a/test/parser.test.ts b/test/parser.test.ts index b4c5d37..b52fc05 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -12,6 +12,419 @@ 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); +}); + +// ── 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('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('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(); +}); + +// ── 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('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");