diff --git a/.gitignore b/.gitignore index a74a0b8..bb6fbb6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules/ package-lock.json *.vsix .vscode/.enscript-cache.json +/docs diff --git a/debug-output.txt b/debug-output.txt new file mode 100644 index 0000000..7c28fb6 --- /dev/null +++ b/debug-output.txt @@ -0,0 +1,5 @@ +[SERVER STARTED] 2026-03-13T17:49:03.438Z +[runDiagnostics] uri=file:///p%3A/scripts/5_mission/mission/missiongameplay.c +[runDiagnostics] uri=file:///p%3A/scripts/5_mission/mission/missiongameplay.c +[runDiagnostics] uri=file:///p%3A/scripts/5_mission/mission/missiongameplay.c +[checkUnknownSymbols] uri=undefined docCache.size=2904 diff --git a/package.json b/package.json index 56a8284..6b6046b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "df-enscript", "displayName": "DaemonForge Enfusion Script", "description": "DaemonForge fork of Enfusion Script — Enforce/Enscript language support, diagnostics, and DayZ game‑specific tooling.", - "version": "0.2.1", + "version": "0.2.2", "publisher": "DaemonForge", "icon": "media/dflogo.png", "engines": { diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index 153a0d9..ba75231 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -163,6 +163,13 @@ export interface ReturnStatementInfo { exprEnd: number; // Character offset of expression end (before ';') } +/** A standalone identifier reference found in a function body expression */ +export interface BodyIdentifierRef { + name: string; + start: Position; + end: Position; +} + export interface FunctionDeclNode extends SymbolNodeBase { kind: 'FunctionDecl'; parameters: VarDeclNode[]; @@ -171,6 +178,8 @@ export interface FunctionDeclNode extends SymbolNodeBase { 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 + bodyTypeRefs: TypeNode[]; // Type references found in the body (e.g., static call targets: ClassName.Method()) + bodyIdentifierRefs: BodyIdentifierRef[]; // Standalone identifiers in expressions (for unknown-symbol checking) } export interface File { @@ -545,6 +554,27 @@ export function parse( start: doc.positionAt(enumMemberNameTok.start), end: doc.positionAt(enumMemberNameTok.end), } as EnumMemberDeclNode); + + while (!eof()) { + const enumSepTok = peek(); + if (enumSepTok.value === ',') { + next(); + break; + } + if (enumSepTok.value === '}') { + break; + } + if (enumSepTok.value === ';') { + addDiagnostic( + enumSepTok, + 'Enum members must be separated by commas, not semicolons.', + DiagnosticSeverity.Error + ); + next(); + break; + } + next(); + } } else next(); } @@ -644,6 +674,9 @@ export function parse( // ==================================================================== const locals: VarDeclNode[] = []; const returnStatements: ReturnStatementInfo[] = []; + const bodyTypeRefs: TypeNode[] = []; + const bodyIdentifierRefs: BodyIdentifierRef[] = []; + const seenIdentifierRefs = new Set(); // dedup let hasBody = false; if (peek().value === '{') { hasBody = true; @@ -660,9 +693,22 @@ export function parse( let prevPrevIdx = -1; let prev: Token | null = null; let prevIdx = -1; + // Track the type token for comma-separated multi-variable declarations + // e.g., `float textX, textY, textZ;` — after detecting textX via the + // normal TypeName VarName pattern, commaChainTypeTok remembers `float` + // so that textY and textZ are also registered as locals. + let commaChainTypeTok: Token | null = null; + let commaChainParenDepth = 0; // paren depth at which the chain was created + let parenDepth = 0; // tracks () nesting to prevent false locals inside call args while (depth > 0 && !eof()) { const t = next(); const tIdx = pos - 1; // index of the token that next() just returned + // Track parenthesis depth BEFORE local detection so the + // comma chain check sees the correct nesting level. + // This prevents `bool hit = Func(a, b, c);` from falsely + // detecting the call arguments as comma-chain bool locals. + if (t.value === '(') parenDepth++; + else if (t.value === ')') parenDepth = Math.max(0, parenDepth - 1); if (t.value === '{') { depth++; bodyScopes.push([]); @@ -789,8 +835,20 @@ export function parse( // 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 === '[')) { + // + // '(' is included to detect constructor-style declarations: + // ScriptInputUserData serializer(); + // where the variable name is followed by parentheses. + if (prev && prevPrev && (t.value === ';' || t.value === '=' || t.value === ',' || t.value === ':' || t.value === '[' || t.value === '(')) { let typeTok = prevPrev; + // Check for comma-separated continuation: `float a, b, c;` + // When prevPrev is ',' and we have a stored type from the chain, + // use that type instead of trying to interpret ',' as a type token. + let isCommaChain = false; + if (prevPrev.value === ',' && commaChainTypeTok && prev.kind === TokenKind.Identifier && parenDepth === commaChainParenDepth) { + typeTok = commaChainTypeTok; + isCommaChain = true; + } if (prevPrev.value === '>' || prevPrev.value === '>>') { // Walk backwards through tokens to find matching '<' and the type before it // '>>' counts as 2 closing brackets (nested generics) @@ -823,7 +881,7 @@ export function parse( } } } - const isTypeTok = typeTok.kind === TokenKind.Identifier + const isTypeTok = isCommaChain || typeTok.kind === TokenKind.Identifier || (typeTok.kind === TokenKind.Keyword && isPrimitiveType(typeTok.value)); const isNameTok = prev.kind === TokenKind.Identifier; if (isTypeTok && isNameTok) { @@ -852,7 +910,81 @@ export function parse( if (bodyScopes.length > 0) { bodyScopes[bodyScopes.length - 1].push(local); } + // Continue or end the comma chain. + // Keep the type alive on ',' (direct: float x, y;) and '=' + // (initializer: float x = 0, y = 0;) so that subsequent + // comma-separated variables with assignments are detected. + commaChainTypeTok = (t.value === ',' || t.value === '=') ? typeTok : null; + commaChainParenDepth = parenDepth; + } else { + // Not a valid declaration — only reset comma chain on + // statement boundaries. Non-boundary triggers (like '(' + // inside a function call in an initializer expression) + // must not kill the chain. + if (t.value === ';') { + commaChainTypeTok = null; + } } + } else { + // Reset comma chain on any non-declaration trigger + if (t.value === ';' || t.value === '{' || t.value === '}') { + commaChainTypeTok = null; + } + } + + // ================================================================ + // STATIC CALL TARGET DETECTION + // ================================================================ + // Detect ClassName.Method() patterns: Identifier followed by '.' + // Captures the identifier as a body type reference so that + // cross-module visibility checks can flag violations like + // using a 5_Mission class from 4_World code. + // Only capture if the identifier starts with uppercase (class + // names are PascalCase) to avoid capturing local variables. + // Skip chained property accesses (e.g., context.Player.Do()) + // by requiring the token before the identifier is NOT a '.'. + // ================================================================ + if (t.value === '.' && prev && prev.kind === TokenKind.Identifier + && /^[A-Z]/.test(prev.value) + && (!prevPrev || prevPrev.value !== '.')) { + // Don't record duplicates for the same identifier in this body + if (!bodyTypeRefs.some(r => r.identifier === prev!.value)) { + bodyTypeRefs.push({ + kind: 'Type', + uri: doc.uri, + identifier: prev.value, + start: doc.positionAt(prev.start), + end: doc.positionAt(prev.end), + arrayDims: [], + modifiers: [], + }); + } + } + + // ================================================================ + // STANDALONE IDENTIFIER REFERENCE DETECTION + // ================================================================ + // Capture identifiers used as values in expressions (not types, + // not call targets, not member-access chains). Used by + // checkUnknownSymbols to flag unresolvable references. + // + // We capture an identifier when it is NOT: + // - preceded by '.' (member access: obj.field) + // - followed by '(' (function call: Func()) + // - followed by '.' (static access: Class.Method, tracked by bodyTypeRefs) + // - a keyword (if, return, new, etc.) + // - part of a declaration (Type VarName handled by local detection) + // ================================================================ + if (prev && prev.kind === TokenKind.Identifier + && (!prevPrev || (prevPrev.value !== '.' && prevPrev.value !== '::')) + && t.value !== '(' + && !seenIdentifierRefs.has(prev.value)) { + seenIdentifierRefs.add(prev.value); + bodyIdentifierRefs.push({ + name: prev.value, + start: doc.positionAt(prev.start), + end: doc.positionAt(prev.end), + }); } prevPrev = prev; @@ -872,6 +1004,8 @@ export function parse( parameters: params, locals: locals, returnStatements: returnStatements, + bodyTypeRefs: bodyTypeRefs, + bodyIdentifierRefs: bodyIdentifierRefs, hasBody: hasBody, isOverride: mods.includes('override'), annotations: annotations, diff --git a/server/src/analysis/lexer/rules.ts b/server/src/analysis/lexer/rules.ts index 11082e8..7a63d7e 100644 --- a/server/src/analysis/lexer/rules.ts +++ b/server/src/analysis/lexer/rules.ts @@ -21,7 +21,7 @@ export const keywords = new Set([ // Class/type declaration keywords 'class', 'enum', 'typedef', 'using', 'extends', // Modifiers - 'modded', 'proto', 'native', 'owned', 'local', 'auto', 'event', + 'modded', 'proto', 'native', 'owned', 'local', 'auto', 'event', 'thread', 'ref', 'reference', 'out', 'inout', 'override', 'private', 'protected', 'public', 'static', 'const', 'notnull', 'external', 'volatile', 'autoptr', diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 1892129..d9bd792 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -46,6 +46,52 @@ import { normalizeUri } from '../../util/uri'; import { DiagnosticEngine } from '../diagnostics/engine'; import * as url from 'node:url'; +/** + * Native engine constants (undocumented ints baked into the DayZ engine). + * These are not declared in any script file but are valid identifiers. + * Add new entries here as they are discovered. + */ +const NATIVE_ENGINE_CONSTANTS: readonly string[] = [ + // Input action constants + 'UAUIBack', 'UAAimRight', 'UAAimLeft', 'UALookAround', 'UAGetOver', + 'UAMoveForward', 'UAMoveBack', 'UAReloadMagazine', 'UAUISelect', + 'UATurbo', 'UAWalkRunTemp','UAGear','UAUIMenu','UADefaultAction', + 'UATempRaiseWeapon','UAAction','UASwitchPreset', 'UAUICtrlX', 'UAUICtrlY', + 'UAUICombine','UAUIRotateInventory','UAMapToggle','UAChat', 'UAGear', + 'UAVoiceDistanceUp', 'UAVoiceDistanceDown','UAZoomInOptics', 'UALeanLeft', + 'UALeanRight', 'UAZoomInOpticsControllerHelper', 'UAZoomOutOptics', + 'UAZoomOutOpticsControllerHelper', 'UAAimDown', 'UAAimUp', 'UAToggleWeapons', + 'UACarShiftGearUp', 'UACarShiftGearDown', 'UAUITabLeft', 'UAUITabRight', + 'UAUIThumbRight','UAUIRight', 'UAUILeft','UAUIGesturesOpen', + 'UAUIQuickbarToggle','UAWalkRunForced','UAMoveRight', 'UAMoveLeft', + 'UAUICopyDebugMonitorPos','UAPersonView','UAUICredits', 'UAUINextDown', + 'UAUINextUp','UAUIUp','UAUIDown','UAUIGesturesOpen', 'UAUIQuickbarToggle', + 'UAZeroingUp', 'UAZeroingDown', 'UANextActionCategory', 'UAPrevActionCategory', + 'UANextAction', 'UAPrevAction', 'UAUIQuickbarRadialOpen', + // + 'DT_CUSTOM', 'DBT_OK', 'DBB_NONE', 'DMT_INFO', 'CT_CLASS', + 'DBT_YESNOCANCEL', 'DBB_YES', 'DBB_NO', 'DBB_CANCEL', 'CT_ARRAY', + 'DMT_QUESTION', 'DBT_YESNO','DMT_EXCLAMATION', + //damageType + 'DT_FIRE_ARM', 'DT_EXPLOSION','DT_CLOSE_COMBAT','DT_CUSTOM', + // Object intersection constants + 'ObjIntersectFire', 'ObjIntersectView', 'ObjIntersectGeom', + 'ObjIntersectIFire', 'ObjIntersectNone', + // Options constants + 'OPTIONS_FIELD_OF_VIEW_MIN', 'OPTIONS_FIELD_OF_VIEW_MAX', + // Input device constants + 'EUAINPUT_DEVICE_CONTROLLER', 'EUAINPUT_DEVICE_MOUSE', 'EUAINPUT_DEVICE_KEYBOARD', + 'EUAINPUT_DEVICE_KEYBOARDMOUSE', + 'LOCK_FROM_SCRIPT', 'HIDE_INV_FROM_SCRIPT', + //Chat Channels + 'CCSystem', 'CCAdmin', 'CCDirect', 'CCMegaphone', 'CCTransmitter', + 'CCPublicAddressSystem', 'CCBattlEye', 'ChatMaxUserLength', + 'ChatMaxSystemLength', + 'VoiceLevelShout', 'VoiceLevelWhisper', 'VoiceLevelTalk', + 'VoiceEffectObstruction', + 'MB_PRESSED_MASK', +]; + interface SymbolEntry { name: string; kind: 'function' | 'class' | 'variable' | 'parameter' | 'field' | 'typedef' | 'enum'; @@ -2848,20 +2894,22 @@ export class Analyzer { 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. + // Deduplicate by (_sourceUri + start position) in case the same file was + // indexed under different URI casings (Windows path case-insensitivity). + // We include the start position (line:char) in the dedup key so that + // distinct class declarations in the SAME file (e.g., `class Foo` and + // `modded class Foo` in one file) are kept as separate entries. const rawClassNodes = this.findAllClassesByName(className); - const seenSourceUris = new Set(); + const seenKeys = 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); + const key = `${srcUri}:${node.start.line}:${node.start.character}`; + if (seenKeys.has(key)) continue; + seenKeys.add(key); } classNodes.push(node); } @@ -3943,6 +3991,9 @@ export class Analyzer { // Check function call arguments (param count and types) this.checkFunctionCallArgs(doc, diags, text, lines, lineOffsets, ast, scopedVars); + // Check access modifier violations (private/protected member access) + this.checkAccessModifierViolations(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); @@ -6156,9 +6207,16 @@ export class Analyzer { // 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') { + const preDeclPos = match.index - fullMatch.length; + const preDeclText = text.substring(Math.max(0, preDeclPos - 80), preDeclPos); + const trimmed = preDeclText.trimEnd(); + const lastChar = trimmed[trimmed.length - 1]; + // Check if there's a newline between the end of preDeclText and the type name. + // This catches the case where the declaration starts on its own line + // (indentation after a comment line would trim away the newline otherwise). + const betweenContent = preDeclText.substring(trimmed.length); + const hasNewline = betweenContent.includes('\n'); + if (!lastChar || lastChar === '{' || lastChar === '}' || lastChar === ';' || lastChar === ')' || hasNewline) { continue; // It's a declaration, skip } } @@ -6406,6 +6464,211 @@ export class Analyzer { } } + /** + * Check for access modifier violations (private/protected member access). + * Scans function bodies for `obj.member` patterns and verifies that the + * accessed member's visibility permits the access from the current context. + * + * Rules (matching DayZ Enforce Script engine): + * - private: only accessible within the declaring class + * - protected: accessible within the declaring class and subclasses + * - public / no modifier: accessible from anywhere + */ + private checkAccessModifierViolations( + doc: TextDocument, + diags: Diagnostic[], + text: string, + lines: string[], + lineOffsets: number[], + ast: File, + scopedVars: Map + ): void { + // Variable type lookup (same helper used by other checkers) + 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; + } + const pos: Position = { line, character: 0 }; + return this.resolveVariableType(doc, pos, name) ?? undefined; + }; + + // Helper: check access and push diagnostic if violated + const checkAccess = ( + memberNode: SymbolNodeBase, + memberName: string, + declaringClassName: string, + containingClassName: string | null, + memberStart: number, + kind: 'Variable' | 'Method' + ): void => { + const isPrivate = memberNode.modifiers?.includes('private'); + const isProtected = memberNode.modifiers?.includes('protected'); + if (!isPrivate && !isProtected) return; + + if (isPrivate) { + if (containingClassName !== declaringClassName) { + const startPos = doc.positionAt(memberStart); + const endPos = doc.positionAt(memberStart + memberName.length); + diags.push({ + message: `${kind} '${memberName}' is private and cannot be accessed from '${containingClassName || 'global scope'}'`, + range: { start: startPos, end: endPos }, + severity: DiagnosticSeverity.Error + }); + } + } else if (isProtected) { + if (!containingClassName) { + const startPos = doc.positionAt(memberStart); + const endPos = doc.positionAt(memberStart + memberName.length); + diags.push({ + message: `${kind} '${memberName}' is protected and cannot be accessed from global scope`, + range: { start: startPos, end: endPos }, + severity: DiagnosticSeverity.Error + }); + } else if (containingClassName !== declaringClassName) { + const currentHierarchy = this.getClassHierarchyOrdered(containingClassName, new Set()); + const isSubclass = currentHierarchy.some(cls => cls.name === declaringClassName); + if (!isSubclass) { + const startPos = doc.positionAt(memberStart); + const endPos = doc.positionAt(memberStart + memberName.length); + diags.push({ + message: `${kind} '${memberName}' is protected and cannot be accessed from '${containingClassName}'`, + range: { start: startPos, end: endPos }, + severity: DiagnosticSeverity.Error + }); + } + } + } + }; + + // Helper: find a member and its declaring class in a type hierarchy + const findMemberInHierarchy = (typeName: string, memberName: string): { node: SymbolNodeBase; declaringClass: string } | null => { + const hierarchy = this.getClassHierarchyOrdered(typeName, new Set()); + for (const cls of hierarchy) { + for (const member of cls.members || []) { + if (member.name === memberName) { + return { node: member, declaringClass: cls.name }; + } + } + } + return null; + }; + + // ── 1. Qualified access: obj.member and obj.Method() ────────────── + // Match obj.member (both field access and method calls) + const memberAccessPattern = /\b(\w+)\s*\.\s*(\w+)\b/g; + let match: RegExpExecArray | null; + + while ((match = memberAccessPattern.exec(text)) !== null) { + if (Analyzer.isInsideCommentOrStringAt(text, match.index)) continue; + + const objName = match[1]; + const memberName = match[2]; + const memberStart = match.index + match[0].indexOf(memberName); + const lineNum = Analyzer.getLineFromOffset(lineOffsets, match.index); + const containingClass = this.findContainingClass(ast, { line: lineNum, character: 0 }); + const containingClassName = containingClass?.name ?? null; + + if (objName === 'this' || objName === 'super') continue; + + let objType: string | undefined; + objType = getVarTypeAtLine(objName, lineNum); + if (objType) { + objType = this.resolveTypedef(objType); + } + if (!objType && objName[0] === objName[0].toUpperCase() && this.classIndex.has(objName)) { + objType = objName; + } + if (!objType) continue; + + const found = findMemberInHierarchy(objType, memberName); + if (!found) continue; + + const isMethod = found.node.kind === 'FunctionDecl'; + checkAccess(found.node, memberName, found.declaringClass, containingClassName, memberStart, isMethod ? 'Method' : 'Variable'); + } + + // ── 2. Unqualified access to inherited private/protected members ── + // Inside a class method, accessing InheritedMethod() or inherited + // fields without qualifier still needs to respect their access level. + for (const node of ast.body) { + if (node.kind !== 'ClassDecl') continue; + const classNode = node as ClassDeclNode; + + // Collect own member names (fields + methods defined in THIS class) + const ownMembers = new Set(); + for (const m of classNode.members || []) { + if (m.name) ownMembers.add(m.name); + } + + for (const member of classNode.members || []) { + if (member.kind !== 'FunctionDecl') continue; + const func = member as FunctionDeclNode; + if (!func.hasBody) continue; + + // Check unqualified method calls: FuncName( + const funcStartOffset = doc.offsetAt(func.start); + const funcEndOffset = doc.offsetAt(func.end); + const bodyText = text.substring(funcStartOffset, funcEndOffset); + const callPattern = /\b(\w+)\s*\(/g; + let callMatch: RegExpExecArray | null; + + while ((callMatch = callPattern.exec(bodyText)) !== null) { + const callName = callMatch[1]; + const absOffset = funcStartOffset + callMatch.index; + if (Analyzer.isInsideCommentOrStringAt(text, absOffset)) continue; + + // Skip if preceded by '.' or '::' (qualified call — handled above) + const charBefore = absOffset > 0 ? text[absOffset - 1] : ''; + if (charBefore === '.') continue; + if (absOffset > 1 && text[absOffset - 2] === ':' && text[absOffset - 1] === ':') continue; + + // Skip if this is a member defined in the current class itself + if (ownMembers.has(callName)) continue; + + // Skip keywords / built-ins + if (keywords.has(callName)) continue; + + // Check if this is an inherited method with access restrictions + const found = findMemberInHierarchy(classNode.name, callName); + if (!found || found.node.kind !== 'FunctionDecl') continue; + + checkAccess(found.node, callName, found.declaringClass, classNode.name, absOffset, 'Method'); + } + + // Check unqualified field references via bodyIdentifierRefs + if (func.bodyIdentifierRefs) { + // Build set of locals + params that shadow inherited names + const localNames = new Set(); + for (const p of func.parameters || []) { if (p.name) localNames.add(p.name); } + for (const l of func.locals || []) { if (l.name) localNames.add(l.name); } + + for (const ref of func.bodyIdentifierRefs) { + // Skip if it's an own member of this class + if (ownMembers.has(ref.name)) continue; + + // Skip if shadowed by a local variable or parameter + if (localNames.has(ref.name)) continue; + + // Check if this is an inherited field with access restrictions + const found = findMemberInHierarchy(classNode.name, ref.name); + if (!found || found.node.kind !== 'VarDecl') continue; + + checkAccess(found.node, ref.name, found.declaringClass, classNode.name, doc.offsetAt(ref.start), 'Variable'); + } + } + } + } + } + /** * Helper to add a type mismatch diagnostic if needed */ @@ -6787,6 +7050,130 @@ export class Analyzer { } }; + // Check a body type ref (static call target like ClassName.Method()). + // Only produces cross-module errors — never "Unknown type" warnings, + // because uppercase identifiers followed by '.' can also be variables + // (e.g., ServerURL.Length()), not just class names. + const checkBodyTypeRef = (type: TypeNode | undefined): void => { + if (!type || currentModule <= 0) return; + + // Only check if this identifier actually resolves to a known class/enum + if (!this.findClassByName(type.identifier) && !this.findEnumByName(type.identifier)) return; + + 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.Error + }); + } + }; + + // Built-in identifiers that are always valid in expressions + const builtinIdentifiers = new Set([ + 'null', 'NULL', 'true', 'false', 'this', 'super', + 'typename', 'string', 'int', 'float', 'bool', 'vector', + 'auto', 'void', 'array', 'set', 'map', + ...NATIVE_ENGINE_CONSTANTS, + ]); + + // Pre-collect all enum member names for fast lookup + const allEnumMembers = new Set(); + for (const [, enumNode] of this.enumIndex) { + for (const member of enumNode.members || []) { + if (member.name) allEnumMembers.add(member.name); + } + } + // Also include enum members from the current file's AST (in case not yet indexed) + for (const node of ast.body) { + if (node.kind === 'EnumDecl') { + for (const member of (node as EnumDeclNode).members || []) { + if (member.name) allEnumMembers.add(member.name); + } + } + } + + // Check whether an identifier used in a function body resolves to a known symbol. + // containingClassName is set when the function is a class method. + const checkBodyIdentifierRefs = (func: FunctionDeclNode, containingClassName: string | null): void => { + if (!func.bodyIdentifierRefs || func.bodyIdentifierRefs.length === 0) return; + + // Build a set of locally-visible names for this function + const localNames = new Set(); + + // Parameters + for (const param of func.parameters || []) { + if (param.name) localNames.add(param.name); + } + // Locals + for (const local of func.locals || []) { + if (local.name) localNames.add(local.name); + } + + // Class members (own + inherited) if inside a class + if (containingClassName) { + const hierarchy = this.getClassHierarchyOrdered(containingClassName, new Set()); + for (const cls of hierarchy) { + for (const member of cls.members || []) { + if (member.name) localNames.add(member.name); + } + } + } + + for (const ref of func.bodyIdentifierRefs) { + const name = ref.name; + + // Skip built-ins and primitives + if (builtinIdentifiers.has(name)) continue; + if (primitives.has(name)) continue; + if (genericParams.has(name)) continue; + + // Skip if it's a locally visible name + if (localNames.has(name)) continue; + + // Skip if it's a known enum member (bare access is valid in Enforce Script) + if (allEnumMembers.has(name)) continue; + + // Skip if it's a known class, enum, or function name + if (this.findClassByName(name)) continue; + if (this.findEnumByName(name)) continue; + if (this.functionIndex.has(name)) continue; + + // Skip if it's in the global symbol index + if (this.globalSymbolIndex.has(name)) continue; + + // Skip if it matches any top-level name in any cached file + let foundInCache = false; + for (const [, fileAst] of this.docCache) { + for (const node of fileAst.body) { + if (node.name === name) { + foundInCache = true; + break; + } + } + if (foundInCache) break; + } + if (foundInCache) continue; + + // Also check current file's AST + let foundInCurrentFile = false; + for (const node of ast.body) { + if (node.name === name) { + foundInCurrentFile = true; + break; + } + } + if (foundInCurrentFile) continue; + + diags.push({ + message: `Unknown identifier '${name}'`, + range: { start: ref.start, end: ref.end }, + severity: DiagnosticSeverity.Warning + }); + } + }; + // Walk the AST for (const node of ast.body) { // Check class declarations @@ -6824,6 +7211,12 @@ export class Analyzer { for (const local of func.locals || []) { checkType(local.type); } + // Check static call targets (e.g., ClassName.Method()) in body + for (const ref of func.bodyTypeRefs || []) { + checkBodyTypeRef(ref); + } + // Check unknown identifiers in body expressions + checkBodyIdentifierRefs(func, classNode.name); } } } @@ -6843,6 +7236,12 @@ export class Analyzer { for (const local of func.locals || []) { checkType(local.type); } + // Check static call targets (e.g., ClassName.Method()) in body + for (const ref of func.bodyTypeRefs || []) { + checkBodyTypeRef(ref); + } + // Check unknown identifiers in body expressions + checkBodyIdentifierRefs(func, null); } } diff --git a/server/src/index.ts b/server/src/index.ts index 5f37def..b0ba88d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -260,7 +260,10 @@ connection.onDidChangeWatchedFiles(async (params) => { 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; + if (!analyser.isWorkspaceFile(doc.uri)) { + connection.sendDiagnostics({ uri: doc.uri, diagnostics: [] }); + continue; + } const diagnostics = analyser.runDiagnostics(doc); connection.sendDiagnostics({ uri: doc.uri, diagnostics }); } @@ -272,6 +275,7 @@ connection.onNotification('enscript/revalidateOpenFiles', () => { const analyser = Analyzer.instance(); for (const doc of documents.all()) { if (!analyser.isWorkspaceFile(doc.uri)) { + connection.sendDiagnostics({ uri: doc.uri, diagnostics: [] }); continue; } const diagnostics = analyser.runDiagnostics(doc); diff --git a/server/src/lsp/handlers/diagnostics.ts b/server/src/lsp/handlers/diagnostics.ts index db82301..c06ecfc 100644 --- a/server/src/lsp/handlers/diagnostics.ts +++ b/server/src/lsp/handlers/diagnostics.ts @@ -7,6 +7,8 @@ import { } from 'vscode-languageserver'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { Analyzer } from '../../analysis/project/graph'; +import * as fs from 'node:fs'; +import * as url from 'node:url'; export function registerDiagnostics(conn: Connection, docs: TextDocuments): void { const analyser = Analyzer.instance(); @@ -59,4 +61,29 @@ export function registerDiagnostics(conn: Connection, docs: TextDocuments { + const uri = change.document.uri; + const pending = debounceTimers.get(uri); + if (pending) { + clearTimeout(pending); + debounceTimers.delete(uri); + } + + // When a file is closed after a rename or deletion, the old URI's + // symbols are still in the index causing duplicate warnings. + // Check if the file still exists on disk — if not, clean up immediately + // instead of waiting for the (potentially delayed) file watcher event. + if (uri.startsWith('file:')) { + try { + const filePath = url.fileURLToPath(uri); + if (!fs.existsSync(filePath)) { + analyser.removeFromIndex(uri); + conn.sendDiagnostics({ uri, diagnostics: [] }); + } + } catch { + // Ignore URI parsing errors for non-file schemes + } + } + }); } diff --git a/test/3_game/test_enscript.c b/test/3_game/test_enscript.c new file mode 100644 index 0000000..51754b1 --- /dev/null +++ b/test/3_game/test_enscript.c @@ -0,0 +1,348 @@ + +typedef map testMapType; +testMapType testMap; + + + typedef Param5 testParamType; + +map testMap2; + + +enum testEnum { + test1, + test2, + test3 +}; + + +enum testEnum2 { + test1 = 1, + test2 = 2;//Should flag enum with semicolon + test3 +}; + +class test { + string a; + int b; + bool c; + //Should flag type not in 3_game + DayZPlayerImplement dp; + TIntArray intArray = {1,2,3,4,5}; + + int testint1, testint2; + + + + //Should flag unknown class + NotARealClass NotARealClass; + + + + + void Test1(string e, int f, bool g = true) { + a = e; + //should flag type mismatch + a = b; + + + + int testint3, testint4, testint5; + + int testint1 = 1; //should flag for duplicate variable name from class variable + testint2 = 2; + testint3 = 3; + testint4 = 4; + testint5 = 5; + testint6 = 6; //Should flag undeclared variable + + testInt(testint1, testint2, testint3); + testInt(testint4, testint6, testint7);//Should flag undeclared variables + + b = a; //Should flag type mismatch + + PlayerBase p;//Should flag class from 4_world and not in 3_game + ManBase m; //Should flag class from 4_world and not in 3_game + + PlayerBase.AbortWeaponEvent();//should flag static function call on class from 4_world and not in 3_game + + PlayerBase p2 = new PlayerBase();//should flag new on class from 4_world and not in 3_game + + bool isPlayer = Class.CastTo(p, m); + + int y = isPlayer ? 1 : 0; //Should flag ternary operator with non-matching types + + p = m;//should flag for down castinging without cast + m = p; + + + + Barrel_ColorBase barrel; //Should flag class from 4_world and not in 3_game + + + + + p = barrel; //should flag for incompatible types + + string tests1 = "test" + a + b //should flag for multi line string concatenation not valid in Enscript + + "string"; + + string tests2 = "test" + a + b + //should flag for multi line string concatenation not valid in Enscript + "string" + e + f + g; + + + + + + int testValue2 = testMap.Get("test2"); //Should flag type mismatch on map get + + + + + + + string testValue = testMap.Get("test" + "1") ; //should not flag + + + + for(int i = 0; i < 10; i++) + { + testMap.Get("test" + i); + } + for(int i = 0; i < 10; i++) //should flag for duplicate loop variable + { + testMap2.Get("test" + i); + } + + + testParamType testp; + + + + + + typedef Param5 testParamType; + + + + + string i = testp.param4.GetHumanInventory().GetEntityInHands().GetPosition(); //Should flag for invalid assignment of vector to string + + + + + p.AfterStoreLoad() + + + DayZPlayerImplement dzp; //should flag for class from 4_world and not in 3_game + + + dzp = testp.param4; + Object o; + e = testMap2.Get("string"); + f = testMap2.Get("string"); + + + test2 t2; + t2.testint1 = 1; //Should not flag as testint1 is public + t2.testint2 = 1; //Should flag for protected variable access + t2.testint3 = 1; //Should flag for private variable access + t2.Test2Public(); + t2.TestProtected(); //Should flag for protected function access + t2.TestPrivate(); //Should flag for private function access + + string teststr = "test"; + string teststr2 = "test"; + string teststr3 = "test"; + + bool tb1 = t2.TestFunction(teststr, teststr2, teststr3); + + bool tb2 = t2.TestModdedFunction(teststr, teststr2, teststr3); // should not flag as a missing function + + } + + void Test3(string e, string f) { + + } + + void Test4(string e, string f){} + + PlayerBase TestPlayerBase(){ //should flag for return type of class from 4_world and not in 3_game + ManBase m; //Should flag class from 4_world and not in 3_game + return m;//Should warn about un safe downcast from ManBase to PlayerBase + } + + + void Test5(string e, string f) + { + + } + + void testInt(int i1, int i2, int i3){ + + } + + void Test2() { + Test1(1,2,true); //Should flag type mismatch on first parameter + + + + + + + Test1("string", 2, "false"); //Should flag type mismatch on third parameter + + Test1("string", 2); + Test1("string", + b, + false); + + } + +} + +class testin extends test { + + string b; //should flag as duplicate variable name from parent class + + void Test6(string a) { //should flag for parameter name that matches class variable + + Test1("string", 2, true); + Test3("string", "string"); + Test4("string", "string"); + Test5("string", "string"); + } + + int Test7() { + + + + return "test"; //should flag for return type mismatch + } + + + + + + override void Test3(string e2, string f) { //should flag for parameter name mismatch with parent class + + } + + + + + + + void Test5(string e, string f){ //should flag for missing override keyword and parameter name mismatch with parent class + + } + + + + + + + override void TestNonExistent(string e, string f){ // should flag for override of non-existent function in parent class + + } + +} + +modded class testin { + override void Test3(string e2, string f) { + + } + + void Test5(string e, string f){ // shoudl flag for missing override keyword and parameter name mismatch with parent class + + } + + override void Test3(string e, string f) { + + } + + void TestModdedFunction(string e, string f){ + + } +} + +modded class testin { + override void Test3(string e2, string f) { + + } + + void Test5(string e, string f){ //should flag for missing override keyword and parameter name mismatch with parent class + + } + + override void Test3(string e, string f) { + + } + + void TestModdedFunction(string e, string f){ + + } + + + void testModdedFunction2(PlayerBase p, string f){ //should flag for parameter of class from 4_world and not in 3_game + + } +} + + +class test2 { + + autoptr TIntArray intArray2 = {1,2,3,4,5}; + + int testint1; + protected int testint2; + private int testint3; + + protected void TestProtected() { + } + + void Test2Public() { + + } + + private void TestPrivate() { + } + + bool TestFunction(string e, string f, string g) { + Param3 testparam = new Param3(e, f, g); + return true; + } +} + +modded class test2 { + + override void TestProtected() { + + } + + override void Test2Public() { + TestPrivate(); //Should flag for private function + } + + //should flag can't override private function from parent class + override void TestPrivate() { + + } + + bool TestModdedFunction(string e, string f, string g) { + Param3 testparam = new Param3(e, f, g); + return true; + } +} + +class test3 extends test2 { + + void Test() { + Test2Public(); + TestProtected(); //should not flagged as protected functions are accessible in child classes + TestPrivate(); //Should flag for private function access + testint2 = 1; //Should not flag as protected variables are accessible in child classes + testint3 = 1; //Should flag for private variable access + } +} +/* +*/ \ No newline at end of file diff --git a/test/features.test.ts b/test/features.test.ts index ee2eaf7..a7ba6b5 100644 --- a/test/features.test.ts +++ b/test/features.test.ts @@ -773,6 +773,35 @@ describe('sealed class inheritance', () => { expect(diags.some(d => d.message.includes('abstract') && d.message.includes('sealed'))).toBe(true); }); + test('runDiagnostics does not flag later comma-separated locals as unknown identifiers', () => { + const analyzer = freshAnalyzer(); + + // Unknown-symbol checks (checkBodyIdentifierRefs) only run when + // docCache.size >= MIN_FILES_FOR_UNKNOWN_TYPE_CHECK (500). + for (let i = 0; i < 501; i++) { + indexDoc(analyzer, `class Dummy${i} { };`, `file:///dummy${i}.enscript`); + } + + const { doc } = indexDoc( + analyzer, + `class Foo { + static string GetDateSafe() { + int yr, mth, day; + GetYearMonthDay(yr, mth, day); + return yr.ToString() + "-" + mth.ToString() + "-" + day.ToString(); + } +};`, + 'file:///comma-locals.enscript' + ); + + const diags = analyzer.runDiagnostics(doc); + const unknownDiags = diags.filter(d => d.message.includes('Unknown identifier')); + + expect(unknownDiags.some(d => d.message.includes("'yr'"))).toBe(false); + expect(unknownDiags.some(d => d.message.includes("'mth'"))).toBe(false); + expect(unknownDiags.some(d => d.message.includes("'day'"))).toBe(false); + }); + test('sealed class check in runDiagnostics detects inheritance violation', () => { // This test verifies the check in checkUnknownSymbols which requires // docCache.size >= MIN_INDEX_SIZE_FOR_TYPE_CHECKS (100). @@ -839,3 +868,301 @@ describe('sealed class inheritance', () => { expect(sealedError).toBeUndefined(); }); }); + +// ══════════════════════════════════════════════════════════════════════════════ +// Modded class method resolution +// ══════════════════════════════════════════════════════════════════════════════ + +describe('modded class method calls', () => { + test('method added in modded class is found via qualified call', () => { + const analyzer = freshAnalyzer(); + for (let i = 0; i < 101; i++) { + indexDoc(analyzer, `class Dummy${i} { };`, `file:///dummy${i}.enscript`); + } + indexDoc(analyzer, `class test2 { + int testint1; + void Test2Public() { } +};`, 'file:///test2.enscript'); + indexDoc(analyzer, `modded class test2 { + bool TestModdedFunction(string e, string f, string g) { + return true; + } +};`, 'file:///test2_modded.enscript'); + const { doc } = indexDoc(analyzer, `class Consumer { + void Test() { + test2 t2; + bool tb = t2.TestModdedFunction("a", "b", "c"); + } +};`, 'file:///consumer.enscript'); + const diags = analyzer.runDiagnostics(doc); + const unknownMethod = diags.find(d => d.message.includes("Unknown method 'TestModdedFunction'")); + expect(unknownMethod).toBeUndefined(); + }); + + test('method added in modded class in SAME file is found', () => { + const analyzer = freshAnalyzer(); + for (let i = 0; i < 101; i++) { + indexDoc(analyzer, `class Dummy${i} { };`, `file:///dummy${i}.enscript`); + } + const { doc } = indexDoc(analyzer, `class test2 { + int testint1; + void Test2Public() { } +}; + +modded class test2 { + bool TestModdedFunction(string e, string f, string g) { + return true; + } +}; + +class Consumer { + void Test() { + test2 t2; + bool tb = t2.TestModdedFunction("a", "b", "c"); + } +};`, 'file:///combined.enscript'); + const diags = analyzer.runDiagnostics(doc); + const unknownMethod = diags.find(d => d.message.includes("Unknown method 'TestModdedFunction'")); + expect(unknownMethod).toBeUndefined(); + }); + + test('same-file original+modded does not cause duplicate completions', () => { + const analyzer = freshAnalyzer(); + for (let i = 0; i < 101; i++) { + indexDoc(analyzer, `class Dummy${i} { };`, `file:///dummy${i}.enscript`); + } + const { doc } = indexDoc(analyzer, `class Base { + void SharedMethod() { } +}; + +modded class Base { + override void SharedMethod() { } + void NewMethod() { } +}; + +class User { + void Test() { + Base b; + b. + } +};`, 'file:///samefile.enscript'); + const completions = analyzer.getCompletions(doc, pos(12, 10)); + // SharedMethod should appear exactly once (not doubled) + const sharedCount = completions.filter(c => c.name === 'SharedMethod').length; + expect(sharedCount).toBe(1); + // NewMethod from modded class should still appear + const newMethod = completions.find(c => c.name === 'NewMethod'); + expect(newMethod).toBeDefined(); + }); + + test('same-file original+modded does not cause duplicate type mismatch errors', () => { + const analyzer = freshAnalyzer(); + for (let i = 0; i < 101; i++) { + indexDoc(analyzer, `class Dummy${i} { };`, `file:///dummy${i}.enscript`); + } + const { doc } = indexDoc(analyzer, `class Base { + int GetValue() { return 0; } +}; + +modded class Base { + override int GetValue() { return 1; } +}; + +class Caller { + void Test() { + Base b; + string s = b.GetValue(); + } +};`, 'file:///samefile2.enscript'); + const diags = analyzer.runDiagnostics(doc); + // Should get at most ONE type mismatch for the assignment, not doubled + const mismatches = diags.filter(d => d.message.includes('GetValue')); + expect(mismatches.length).toBeLessThanOrEqual(1); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// Access modifier violations (private/protected) +// ══════════════════════════════════════════════════════════════════════════════ + +describe('access modifier violations', () => { + function setupAnalyzer() { + const analyzer = freshAnalyzer(); + // Index enough files to pass the threshold + for (let i = 0; i < 101; i++) { + indexDoc(analyzer, `class Dummy${i} { };`, `file:///dummy${i}.enscript`); + } + return analyzer; + } + + test('protected field access from unrelated class produces error', () => { + const analyzer = setupAnalyzer(); + indexDoc(analyzer, `class Entity { + protected int ObjectId; +};`, 'file:///entity.enscript'); + const { doc } = indexDoc(analyzer, `class TerritoryFlag { + void DoStuff(Entity e) { + int id = e.ObjectId; + } +};`, 'file:///territory.enscript'); + const diags = analyzer.runDiagnostics(doc); + const accessError = diags.find(d => d.message.includes("'ObjectId' is protected")); + expect(accessError).toBeDefined(); + }); + + test('private field access from outside class produces error', () => { + const analyzer = setupAnalyzer(); + indexDoc(analyzer, `class Foo { + private int m_secret; +};`, 'file:///foo.enscript'); + const { doc } = indexDoc(analyzer, `class Bar { + void DoStuff(Foo f) { + int x = f.m_secret; + } +};`, 'file:///bar.enscript'); + const diags = analyzer.runDiagnostics(doc); + const accessError = diags.find(d => d.message.includes("'m_secret' is private")); + expect(accessError).toBeDefined(); + }); + + test('protected field access from subclass is OK', () => { + const analyzer = setupAnalyzer(); + indexDoc(analyzer, `class Entity { + protected int ObjectId; +};`, 'file:///entity.enscript'); + const { doc } = indexDoc(analyzer, `class TerritoryFlag extends Entity { + void DoStuff(Entity e) { + int id = e.ObjectId; + } +};`, 'file:///territory.enscript'); + const diags = analyzer.runDiagnostics(doc); + const accessError = diags.find(d => d.message.includes("'ObjectId' is protected")); + expect(accessError).toBeUndefined(); + }); + + test('public field access produces no error', () => { + const analyzer = setupAnalyzer(); + indexDoc(analyzer, `class Foo { + int m_value; +};`, 'file:///foo.enscript'); + const { doc } = indexDoc(analyzer, `class Bar { + void DoStuff(Foo f) { + int x = f.m_value; + } +};`, 'file:///bar.enscript'); + const diags = analyzer.runDiagnostics(doc); + const accessError = diags.find(d => d.message.includes('is private') || d.message.includes('is protected')); + expect(accessError).toBeUndefined(); + }); + + test('private field access from same class is OK', () => { + const analyzer = setupAnalyzer(); + const { doc } = indexDoc(analyzer, `class Foo { + private int m_secret; + void DoStuff(Foo other) { + int x = other.m_secret; + } +};`, 'file:///foo.enscript'); + const diags = analyzer.runDiagnostics(doc); + const accessError = diags.find(d => d.message.includes("'m_secret' is private")); + expect(accessError).toBeUndefined(); + }); + + test('protected method call from unrelated class produces error', () => { + const analyzer = setupAnalyzer(); + indexDoc(analyzer, `class Base { + protected void SecretMethod() { } + void PublicMethod() { } +};`, 'file:///base.enscript'); + const { doc } = indexDoc(analyzer, `class Other { + void DoStuff(Base b) { + b.PublicMethod(); + b.SecretMethod(); + } +};`, 'file:///other.enscript'); + const diags = analyzer.runDiagnostics(doc); + const accessError = diags.find(d => d.message.includes("'SecretMethod' is protected")); + expect(accessError).toBeDefined(); + const noPublicError = diags.find(d => d.message.includes("'PublicMethod' is protected") || d.message.includes("'PublicMethod' is private")); + expect(noPublicError).toBeUndefined(); + }); + + test('private method call from outside class produces error', () => { + const analyzer = setupAnalyzer(); + indexDoc(analyzer, `class Base { + private void InternalMethod() { } +};`, 'file:///base.enscript'); + const { doc } = indexDoc(analyzer, `class Other { + void DoStuff(Base b) { + b.InternalMethod(); + } +};`, 'file:///other.enscript'); + const diags = analyzer.runDiagnostics(doc); + const accessError = diags.find(d => d.message.includes("'InternalMethod' is private")); + expect(accessError).toBeDefined(); + }); + + test('unqualified inherited private method call produces error', () => { + const analyzer = setupAnalyzer(); + indexDoc(analyzer, `class Base { + private void PrivateMethod() { } + protected void ProtectedMethod() { } +};`, 'file:///base.enscript'); + const { doc } = indexDoc(analyzer, `class Child extends Base { + void DoStuff() { + ProtectedMethod(); + PrivateMethod(); + } +};`, 'file:///child.enscript'); + const diags = analyzer.runDiagnostics(doc); + const privateError = diags.find(d => d.message.includes("'PrivateMethod' is private")); + expect(privateError).toBeDefined(); + const protectedError = diags.find(d => d.message.includes("'ProtectedMethod' is protected")); + expect(protectedError).toBeUndefined(); + }); + + test('protected method call from subclass is OK', () => { + const analyzer = setupAnalyzer(); + indexDoc(analyzer, `class Base { + protected void SecretMethod() { } +};`, 'file:///base.enscript'); + const { doc } = indexDoc(analyzer, `class Child extends Base { + void DoStuff(Base b) { + b.SecretMethod(); + } +};`, 'file:///child.enscript'); + const diags = analyzer.runDiagnostics(doc); + const accessError = diags.find(d => d.message.includes("'SecretMethod' is protected")); + expect(accessError).toBeUndefined(); + }); + + test('unqualified inherited private field access produces error', () => { + const analyzer = setupAnalyzer(); + indexDoc(analyzer, `class Base { + private int secretField; +};`, 'file:///base.enscript'); + const { doc } = indexDoc(analyzer, `class Child extends Base { + void DoStuff() { + int x = secretField; + } +};`, 'file:///child.enscript'); + const diags = analyzer.runDiagnostics(doc); + const privateError = diags.find(d => d.message.includes("'secretField' is private")); + expect(privateError).toBeDefined(); + }); + + test('unqualified inherited protected field is OK from subclass', () => { + const analyzer = setupAnalyzer(); + indexDoc(analyzer, `class Base { + protected int protField; +};`, 'file:///base.enscript'); + const { doc } = indexDoc(analyzer, `class Child extends Base { + void DoStuff() { + int x = protField; + } +};`, 'file:///child.enscript'); + const diags = analyzer.runDiagnostics(doc); + const accessError = diags.find(d => d.message.includes("'protField' is protected")); + expect(accessError).toBeUndefined(); + }); +}); diff --git a/test/module-visibility.test.ts b/test/module-visibility.test.ts index 25a839d..0db20a7 100644 --- a/test/module-visibility.test.ts +++ b/test/module-visibility.test.ts @@ -187,3 +187,124 @@ describe('checkUnknownSymbols – cross-module diagnostics are errors', () => { expect(crossModuleDiag).toBeUndefined(); }); }); + +// ══════════════════════════════════════════════════════════════════════════════ +// 3. checkUnknownSymbols — static call targets (ClassName.Method()) +// ══════════════════════════════════════════════════════════════════════════════ + +describe('checkUnknownSymbols – static call cross-module diagnostics', () => { + let analyzer: Analyzer; + + beforeAll(() => { + analyzer = freshAnalyzer(); + // MissionMenu lives in module 5 (5_Mission) + indexDoc(analyzer, 'class MissionMenu {};', 'file:///5_Mission/menu.c'); + // WorldHelper lives in module 4 (4_World) + indexDoc(analyzer, 'class WorldHelper {};', 'file:///4_World/helper.c'); + // CoreUtil lives in module 1 (1_Core) + indexDoc(analyzer, 'class CoreUtil {};', 'file:///1_Core/util.c'); + // Pad the cache so checkUnknownSymbols' internal guard is satisfied + padDocCache(analyzer, 500); + }); + + test('static call on a higher-module class produces an Error diagnostic', () => { + // Module 4 file calls MissionMenu.Open() — MissionMenu is in module 5 + const ast = indexDoc( + analyzer, + 'class MyAction { void Execute() { MissionMenu.Open(); }; };', + 'file:///4_World/action.c' + ); + const diags: any[] = []; + (analyzer as any).checkUnknownSymbols(ast, diags); + + const crossModuleDiag = diags.find((d: any) => + d.message.includes('MissionMenu') && d.message.includes('5_Mission') + ); + expect(crossModuleDiag).toBeDefined(); + expect(crossModuleDiag!.severity).toBe(DiagnosticSeverity.Error); + }); + + test('static call on a same-module class produces no cross-module diagnostic', () => { + // Module 4 file calls WorldHelper.Do() — WorldHelper is also in module 4 + const ast = indexDoc( + analyzer, + 'class MyAction2 { void Execute() { WorldHelper.Do(); }; };', + 'file:///4_World/action2.c' + ); + const diags: any[] = []; + (analyzer as any).checkUnknownSymbols(ast, diags); + + const crossModuleDiag = diags.find((d: any) => + d.message.includes('WorldHelper') && d.message.includes('4_World') + ); + expect(crossModuleDiag).toBeUndefined(); + }); + + test('static call on a lower-module class produces no cross-module diagnostic', () => { + // Module 4 file calls CoreUtil.Do() — CoreUtil is in module 1 (lower) + const ast = indexDoc( + analyzer, + 'class MyAction3 { void Execute() { CoreUtil.Do(); }; };', + 'file:///4_World/action3.c' + ); + const diags: any[] = []; + (analyzer as any).checkUnknownSymbols(ast, diags); + + const crossModuleDiag = diags.find((d: any) => + d.message.includes('CoreUtil') + ); + expect(crossModuleDiag).toBeUndefined(); + }); + + test('static call in a top-level function also detects cross-module violation', () => { + // Module 4 top-level function calls MissionMenu.Open() + const ast = indexDoc( + analyzer, + 'void MyFunc() { MissionMenu.Open(); };', + 'file:///4_World/func.c' + ); + const diags: any[] = []; + (analyzer as any).checkUnknownSymbols(ast, diags); + + const crossModuleDiag = diags.find((d: any) => + d.message.includes('MissionMenu') && d.message.includes('5_Mission') + ); + expect(crossModuleDiag).toBeDefined(); + expect(crossModuleDiag!.severity).toBe(DiagnosticSeverity.Error); + }); + + test('chained property access on uppercase field is NOT treated as static call', () => { + // context.Player.DoSomething() — Player is a property, not a static call + const ast = indexDoc( + analyzer, + 'class MyService { void Process() { context.MissionMenu.Open(); }; };', + 'file:///4_World/service.c' + ); + const diags: any[] = []; + (analyzer as any).checkUnknownSymbols(ast, diags); + + // MissionMenu here is accessed via context.MissionMenu — a chained property, + // NOT a static call. Should produce no cross-module diagnostic. + const crossModuleDiag = diags.find((d: any) => + d.message.includes('MissionMenu') && d.message.includes('5_Mission') + ); + expect(crossModuleDiag).toBeUndefined(); + }); + + test('uppercase variable with dot access does NOT produce unknown type warning', () => { + // ServerURL.Length() — ServerURL is a variable, not a class + // Should not produce any "Unknown type" warning for ServerURL + const ast = indexDoc( + analyzer, + 'class MyConfig { void Load() { string ServerURL = ""; int len = ServerURL.Length(); }; };', + 'file:///3_Game/config.c' + ); + const diags: any[] = []; + (analyzer as any).checkUnknownSymbols(ast, diags); + + const serverUrlDiag = diags.find((d: any) => + d.message.includes('ServerURL') + ); + expect(serverUrlDiag).toBeUndefined(); + }); +}); diff --git a/test/parser.test.ts b/test/parser.test.ts index 5839f12..b1ac116 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -70,6 +70,145 @@ test('detects two-variable foreach locals', () => { expect(localNames).toContain('val'); }); +test('detects comma-separated local variable declarations', () => { + const code = `class Foo { + void Bar() { + float textX, textY; + int a, b, c; + } +};`; + 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); + // All comma-separated variables should be detected + expect(localNames).toContain('textX'); + expect(localNames).toContain('textY'); + expect(localNames).toContain('a'); + expect(localNames).toContain('b'); + expect(localNames).toContain('c'); + // All should have the correct type + expect(func.locals.find((l: any) => l.name === 'textY').type.identifier).toBe('float'); + expect(func.locals.find((l: any) => l.name === 'b').type.identifier).toBe('int'); + expect(func.locals.find((l: any) => l.name === 'c').type.identifier).toBe('int'); +}); + +test('detects constructor-style local variable declarations', () => { + const code = `class Foo { + void Bar() { + ScriptInputUserData serializer(); + int x = 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('serializer'); + expect(localNames).toContain('x'); + const serializerLocal = func.locals.find((l: any) => l.name === 'serializer'); + expect(serializerLocal.type.identifier).toBe('ScriptInputUserData'); +}); + +test('detects comma-separated locals with initializers', () => { + const code = `class Foo { + void Bar() { + float x = 0, y = 0, z = 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; + const localNames = func.locals.map((l: any) => l.name); + expect(localNames).toContain('x'); + expect(localNames).toContain('y'); + expect(localNames).toContain('z'); + expect(func.locals.find((l: any) => l.name === 'x').type.identifier).toBe('float'); + expect(func.locals.find((l: any) => l.name === 'y').type.identifier).toBe('float'); + expect(func.locals.find((l: any) => l.name === 'z').type.identifier).toBe('float'); +}); + +test('comma chain does not leak across statements', () => { + // After `int a, b;` the comma chain must reset on `;`. + // The next statement `Foo(x, y);` must NOT treat y as a local. + const code = `class Foo { + void Bar() { + int a, b; + Foo(x, y); + } +};`; + 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('a'); + expect(localNames).toContain('b'); + // y must NOT be mistakenly detected as a local from a stale comma chain + expect(localNames).not.toContain('y'); + expect(localNames).not.toContain('x'); +}); + +test('comma chain does not leak across brace boundaries', () => { + // The comma chain must be cleared when entering/exiting a brace scope + const code = `class Foo { + void Bar() { + int a, b; + if (true) { + SomeFunc(c, d); + } + } +};`; + 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('a'); + expect(localNames).toContain('b'); + expect(localNames).not.toContain('c'); + expect(localNames).not.toContain('d'); +}); + +test('comma-separated with first var initialized', () => { + // `int a, b = 5;` — a is detected (Type Name ,), b follows via comma chain + const code = `class Foo { + void Bar() { + int a, b = 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('a'); + expect(localNames).toContain('b'); + expect(func.locals.find((l: any) => l.name === 'b').type.identifier).toBe('int'); +}); + +test('comma chain resets after equals trigger', () => { + // After `int x = 0;` the comma chain must be null. + // A subsequent `Func(a, b);` must NOT detect b as a local. + const code = `class Foo { + void Bar() { + int x = 0; + Func(a, b); + } +};`; + 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).not.toContain('a'); + expect(localNames).not.toContain('b'); +}); + test('does not false-positive on case labels', () => { const code = `class Foo { void Bar() { @@ -274,6 +413,159 @@ test('no false positive for single-line strings', () => { expect(mlDiag).toBeUndefined(); }); +test('reports semicolons used between enum members', () => { + const code = `enum NotificationsRPC +{ + NOTIFICATIONS_RPC_CONFIG = 1353998362; +}`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + + expect(ast.body[0]).toHaveProperty('kind', 'EnumDecl'); + + const enumSeparatorDiag = ast.diagnostics.find(d => + d.message.includes('Enum members must be separated by commas') + ); + + expect(enumSeparatorDiag).toBeDefined(); + expect(enumSeparatorDiag?.severity).toBeDefined(); +}); + +// ── Enum separator edge-case tests (false-positive / false-negative) ────── + +test('no false positive: normal comma-separated enum', () => { + const code = `enum EColor { RED, GREEN, BLUE };`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const enumNode = ast.body[0] as any; + expect(enumNode.kind).toBe('EnumDecl'); + expect(enumNode.members.map((m: any) => m.name)).toEqual(['RED', 'GREEN', 'BLUE']); + expect(ast.diagnostics.filter(d => d.message.includes('semicolon'))).toHaveLength(0); +}); + +test('no false positive: enum with assigned numeric values', () => { + const code = `enum ERPCs { RPC_ONE = 1000, RPC_TWO = 2000, RPC_THREE = 3000 };`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const enumNode = ast.body[0] as any; + expect(enumNode.members.map((m: any) => m.name)).toEqual(['RPC_ONE', 'RPC_TWO', 'RPC_THREE']); + expect(ast.diagnostics.filter(d => d.message.includes('semicolon'))).toHaveLength(0); +}); + +test('no false positive: single enum member without trailing comma', () => { + const code = `enum ESingle { ONLY_ONE };`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const enumNode = ast.body[0] as any; + expect(enumNode.members.map((m: any) => m.name)).toEqual(['ONLY_ONE']); + expect(ast.diagnostics.filter(d => d.message.includes('semicolon'))).toHaveLength(0); +}); + +test('no false positive: enum with trailing comma', () => { + const code = `enum ETrailing { A, B, C, };`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const enumNode = ast.body[0] as any; + expect(enumNode.members.map((m: any) => m.name)).toEqual(['A', 'B', 'C']); + expect(ast.diagnostics.filter(d => d.message.includes('semicolon'))).toHaveLength(0); +}); + +test('no false positive: empty enum', () => { + const code = `enum EEmpty { };`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const enumNode = ast.body[0] as any; + expect(enumNode.kind).toBe('EnumDecl'); + expect(enumNode.members).toHaveLength(0); + expect(ast.diagnostics.filter(d => d.message.includes('semicolon'))).toHaveLength(0); +}); + +test('no false positive: enum with base type and commas', () => { + const code = `enum EFlags : int { NONE, READ = 1, WRITE = 2 };`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const enumNode = ast.body[0] as any; + expect(enumNode.members.map((m: any) => m.name)).toEqual(['NONE', 'READ', 'WRITE']); + expect(ast.diagnostics.filter(d => d.message.includes('semicolon'))).toHaveLength(0); +}); + +test('no false positive: enum with hex values', () => { + const code = `enum EHex { A = 0x0001, B = 0x0002, C = 0x0004 };`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const enumNode = ast.body[0] as any; + expect(enumNode.members.map((m: any) => m.name)).toEqual(['A', 'B', 'C']); + expect(ast.diagnostics.filter(d => d.message.includes('semicolon'))).toHaveLength(0); +}); + +test('no false positive: enum followed by class with semicolons', () => { + const code = `enum EColor { RED, GREEN, BLUE }; +class Painter { + EColor m_color; + void SetColor(EColor color) { + m_color = color; + } +};`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + expect(ast.diagnostics.filter(d => d.message.includes('semicolon'))).toHaveLength(0); + expect(ast.body[0]).toHaveProperty('kind', 'EnumDecl'); + expect(ast.body[1]).toHaveProperty('kind', 'ClassDecl'); +}); + +test('flags multiple semicolons in multi-member enum', () => { + const code = `enum ERPCs { + RPC_A = 100; + RPC_B = 200; + RPC_C = 300; +}`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const enumNode = ast.body[0] as any; + expect(enumNode.members.map((m: any) => m.name)).toEqual(['RPC_A', 'RPC_B', 'RPC_C']); + const semiDiags = ast.diagnostics.filter(d => d.message.includes('Enum members must be separated by commas')); + expect(semiDiags).toHaveLength(3); +}); + +test('flags only the semicolon in mixed comma/semicolon enum', () => { + const code = `enum EMixed { A = 1, B = 2; C = 3 };`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const enumNode = ast.body[0] as any; + expect(enumNode.members.map((m: any) => m.name)).toEqual(['A', 'B', 'C']); + const semiDiags = ast.diagnostics.filter(d => d.message.includes('Enum members must be separated by commas')); + expect(semiDiags).toHaveLength(1); +}); + +test('no false positive: enum with negative values', () => { + const code = `enum ESigned { NEG = -1, ZERO = 0, POS = 1 };`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const enumNode = ast.body[0] as any; + expect(enumNode.members.map((m: any) => m.name)).toEqual(['NEG', 'ZERO', 'POS']); + expect(ast.diagnostics.filter(d => d.message.includes('semicolon'))).toHaveLength(0); +}); + +test('no false positive: enum with bitwise OR values', () => { + const code = `enum EBits { A = 1, B = 2, AB = 1 | 2 };`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const enumNode = ast.body[0] as any; + expect(enumNode.members.map((m: any) => m.name)).toEqual(['A', 'B', 'AB']); + expect(ast.diagnostics.filter(d => d.message.includes('semicolon'))).toHaveLength(0); +}); + +test('no false positive: enum with constant identifier value', () => { + // enum member whose value references another identifier + const code = `enum ERef { BASE = 100, DERIVED = BASE };`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const enumNode = ast.body[0] as any; + // BASE is consumed as value expression for DERIVED, not as a false member + expect(enumNode.members.map((m: any) => m.name)).toEqual(['BASE', 'DERIVED']); + expect(ast.diagnostics.filter(d => d.message.includes('semicolon'))).toHaveLength(0); +}); + // ── Return statement detection tests ────────────────────────────────────── test('detects return statements in function bodies', () => { @@ -425,6 +717,212 @@ class B { float y; };`; expect(classNames).toContain('B'); }); +// ============================================================================ +// bodyIdentifierRefs tests +// ============================================================================ + +test('bodyIdentifierRefs: captures standalone identifiers in function body', () => { + const code = `class Foo { + void DoStuff() { + int x = someValue; + if (rpc_type != NOTIFICATIONS_RPC_CONFIG) { + return; + } + } +};`; + 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 refNames = func.bodyIdentifierRefs.map((r: any) => r.name); + expect(refNames).toContain('someValue'); + expect(refNames).toContain('rpc_type'); + expect(refNames).toContain('NOTIFICATIONS_RPC_CONFIG'); +}); + +test('bodyIdentifierRefs: excludes member access after dot', () => { + const code = `class Foo { + void DoStuff() { + obj.field = 1; + obj.Method(); + } +};`; + 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 refNames = func.bodyIdentifierRefs.map((r: any) => r.name); + // obj is captured (standalone), but field and Method are after '.' so excluded + expect(refNames).toContain('obj'); + expect(refNames).not.toContain('field'); + expect(refNames).not.toContain('Method'); +}); + +test('bodyIdentifierRefs: excludes function call targets', () => { + const code = `class Foo { + void DoStuff() { + DoSomething(); + int x = 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; + const refNames = func.bodyIdentifierRefs.map((r: any) => r.name); + // Function call targets (followed by '(') are excluded + expect(refNames).not.toContain('DoSomething'); + expect(refNames).not.toContain('GetValue'); +}); + +test('bodyIdentifierRefs: captures static access targets (validated separately by bodyTypeRefs)', () => { + const code = `class Foo { + void DoStuff() { + int x = SomeClass.CONSTANT; + SomeEnum.VALUE; + } +};`; + 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 refNames = func.bodyIdentifierRefs.map((r: any) => r.name); + // Identifiers followed by '.' ARE captured; they won't produce false positives + // because checkBodyIdentifierRefs resolves them via findClassByName/findEnumByName. + // The bodyTypeRefs system handles their cross-module checks separately. + expect(refNames).toContain('SomeClass'); + expect(refNames).toContain('SomeEnum'); + // Members after '.' are NOT captured (prevPrev is '.') + expect(refNames).not.toContain('CONSTANT'); + expect(refNames).not.toContain('VALUE'); +}); + +test('bodyIdentifierRefs: captures identifiers in expressions with operators', () => { + const code = `class Foo { + void DoStuff() { + int result = a + b * c; + } +};`; + 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 refNames = func.bodyIdentifierRefs.map((r: any) => r.name); + expect(refNames).toContain('a'); + expect(refNames).toContain('b'); + expect(refNames).toContain('c'); +}); + +test('bodyIdentifierRefs: deduplicates identical names', () => { + const code = `class Foo { + void DoStuff() { + int x = val; + int y = val; + } +};`; + 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 valRefs = func.bodyIdentifierRefs.filter((r: any) => r.name === 'val'); + expect(valRefs.length).toBe(1); +}); + +test('bodyIdentifierRefs: excludes scope resolution (::) targets', () => { + const code = `class Foo { + void DoStuff() { + Scope::Method(); + } +};`; + 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 refNames = func.bodyIdentifierRefs.map((r: any) => r.name); + // 'Method' is after '::' so excluded + expect(refNames).not.toContain('Method'); +}); + +test('bodyIdentifierRefs: works for top-level functions', () => { + const code = `void GlobalFunc() { + int x = SOME_CONST; + return; +}`; + const doc = TextDocument.create('file:///test.enscript', 'enscript', 1, code); + const ast = parse(doc); + const func = ast.body[0] as any; + const refNames = func.bodyIdentifierRefs.map((r: any) => r.name); + expect(refNames).toContain('SOME_CONST'); +}); + +test('comma chain does not propagate into function call arguments', () => { + // `bool hit = Func(a, b, hitPosition, c);` — the comma chain from `bool hit =` + // must NOT leak into the function call parens. Arguments like hitPosition + // must NOT be falsely detected as bool locals. + const code = `class Foo { + void Bar() { + vector hitPosition; + bool hit = SomeFunc(playerEyePos, targetPos, hitPosition, hitFraction); + } +};`; + 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('hitPosition'); + expect(localNames).toContain('hit'); + // The function call arguments must NOT be detected as locals + expect(localNames).not.toContain('targetPos'); + expect(localNames).not.toContain('hitFraction'); + // hitPosition must retain its declared type, not be overwritten by a false bool local + expect(func.locals.find((l: any) => l.name === 'hitPosition').type.identifier).toBe('vector'); + expect(func.locals.find((l: any) => l.name === 'hit').type.identifier).toBe('bool'); +}); + +test('comma chain survives function call in initializer', () => { + // `float x = Func(a, b), y = 0;` — chain must survive through parens + const code = `class Foo { + void Bar() { + float x = Func(a, b), y = 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; + const localNames = func.locals.map((l: any) => l.name); + expect(localNames).toContain('x'); + expect(localNames).toContain('y'); + expect(func.locals.find((l: any) => l.name === 'x').type.identifier).toBe('float'); + expect(func.locals.find((l: any) => l.name === 'y').type.identifier).toBe('float'); + // a and b inside the call must NOT be detected as locals + expect(localNames).not.toContain('a'); + expect(localNames).not.toContain('b'); +}); + +test('comma chain does not propagate into generic constructor call', () => { + // `Param3 testparam = new Param3(e, f, g);` + // The comma chain from `testparam =` must NOT leak into the constructor parens. + // Parameters e, f, g must NOT be falsely detected as locals (which would trigger + // duplicate variable errors against the function parameters). + const code = `class Foo { + void TestModdedFunction(string e, string f, string g) { + Param3 testparam = new Param3(e, f, g); + } +};`; + 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('testparam'); + // e, f, g are parameters — they must NOT appear as locals + expect(localNames).not.toContain('e'); + expect(localNames).not.toContain('f'); + expect(localNames).not.toContain('g'); +}); + test('playground', () => { const target_file = path.join("P:\\enscript\\test", "test_enscript.c"); if (!fs.existsSync(target_file)) { diff --git a/test/test_enscript.c b/test/test_enscript.c deleted file mode 100644 index 0678c9f..0000000 --- a/test/test_enscript.c +++ /dev/null @@ -1,25 +0,0 @@ -class StaticGUIUtils -{ - static const int IMAGESETGROUP_INVENTORY = 0; - - - //! Checks for improperly formated, legacy image names and corrects them to default format. - static string VerifyIconImageString(int imageset_group = IMAGESETGROUP_INVENTORY, string icon_name = "") - { - if (icon_name == "") - { - return "set:dayz_inventory image:missing"; - } - - if ( !icon_name.Contains("image:") ) - { - switch (imageset_group) - { - case IMAGESETGROUP_INVENTORY: - return "set:dayz_inventory image:" + icon_name; - } - - } - return icon_name; - } -} \ No newline at end of file