From 47f74e1b5c1cb632e2d024cd7cafecf093b08d1b Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Mon, 9 Mar 2026 14:14:38 -0400 Subject: [PATCH 1/9] Detect cross-module static calls Add detection and diagnostics for static call targets like ClassName.Method() across modules. Parser: add bodyTypeRefs to FunctionDeclNode and record uppercase Identifier '.' patterns (excluding chained property accesses) as TypeNode references. Analyzer: add checkBodyTypeRef to emit an error when a referenced type is defined in a higher-numbered module and wire checks into function and top-level node processing. Tests: add unit tests covering higher/same/lower-module calls, chained property access, and uppercase-variable cases. Also add /docs to .gitignore. --- .gitignore | 1 + server/src/analysis/ast/parser.ts | 32 +++++++ server/src/analysis/project/graph.ts | 28 +++++++ test/module-visibility.test.ts | 121 +++++++++++++++++++++++++++ 4 files changed, 182 insertions(+) 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/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index 153a0d9..e71be81 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -171,6 +171,7 @@ 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()) } export interface File { @@ -644,6 +645,7 @@ export function parse( // ==================================================================== const locals: VarDeclNode[] = []; const returnStatements: ReturnStatementInfo[] = []; + const bodyTypeRefs: TypeNode[] = []; let hasBody = false; if (peek().value === '{') { hasBody = true; @@ -855,6 +857,35 @@ export function parse( } } + // ================================================================ + // 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: [], + }); + } + } + prevPrev = prev; prevPrevIdx = prevIdx; prev = t; @@ -872,6 +903,7 @@ export function parse( parameters: params, locals: locals, returnStatements: returnStatements, + bodyTypeRefs: bodyTypeRefs, hasBody: hasBody, isOverride: mods.includes('override'), annotations: annotations, diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 1892129..06e88e1 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -6787,6 +6787,26 @@ 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 + }); + } + }; + // Walk the AST for (const node of ast.body) { // Check class declarations @@ -6824,6 +6844,10 @@ 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); + } } } } @@ -6843,6 +6867,10 @@ 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); + } } } 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(); + }); +}); From 537213e4a8f671a718859d3847de8e187d524263 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Fri, 13 Mar 2026 13:27:24 -0400 Subject: [PATCH 2/9] Add body identifier checks, comma-locals, enums Enhance parsing and diagnostics: add BodyIdentifierRef to capture standalone identifiers used in function bodies and wire a checkBodyIdentifierRefs routine into the Analyzer to produce warnings for unknown identifiers (with built-in, primitive, enum, local, class, function and index lookups to avoid false positives). Improve local-variable parsing to handle comma-separated declarations via a commaChain token (detects multi-variable declarations like `int a, b, c;` and resets the chain at statement/brace boundaries and on non-declaration triggers). Add enum member separator validation and diagnostic for semicolons between members. Adjust LSP behavior to clear diagnostics for non-workspace files on re-index/revalidate and remove stale index entries immediately when a closed file no longer exists on disk. Include many unit tests exercising the new behaviors, add a sample test file, and add a small debug-output log; also remove an old test fixture file. --- debug-output.txt | 5 + server/src/analysis/ast/parser.ts | 83 +++++- server/src/analysis/project/graph.ts | 107 +++++++ server/src/index.ts | 6 +- server/src/lsp/handlers/diagnostics.ts | 27 ++ test/3_game/test_enscript.c | 275 +++++++++++++++++ test/features.test.ts | 29 ++ test/parser.test.ts | 393 +++++++++++++++++++++++++ test/test_enscript.c | 25 -- 9 files changed, 923 insertions(+), 27 deletions(-) create mode 100644 debug-output.txt create mode 100644 test/3_game/test_enscript.c delete mode 100644 test/test_enscript.c diff --git a/debug-output.txt b/debug-output.txt new file mode 100644 index 0000000..d479b8f --- /dev/null +++ b/debug-output.txt @@ -0,0 +1,5 @@ +[SERVER STARTED] 2026-03-13T17:27:04.315Z +[runDiagnostics] uri=file:///d%3A/Github/enscript/test/3_game/test_enscript.c +[runDiagnostics] uri=file:///d%3A/Github/enscript/test/3_game/test_enscript.c +[runDiagnostics] uri=file:///d%3A/Github/enscript/test/3_game/test_enscript.c +[checkUnknownSymbols] uri=undefined docCache.size=2905 diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index e71be81..0016df8 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[]; @@ -172,6 +179,7 @@ export interface FunctionDeclNode extends SymbolNodeBase { 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 { @@ -546,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(); } @@ -646,6 +675,8 @@ 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; @@ -662,6 +693,11 @@ 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; while (depth > 0 && !eof()) { const t = next(); const tIdx = pos - 1; // index of the token that next() just returned @@ -793,6 +829,14 @@ export function parse( // 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; + // 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) { + 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) @@ -825,7 +869,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) { @@ -854,6 +898,16 @@ export function parse( if (bodyScopes.length > 0) { bodyScopes[bodyScopes.length - 1].push(local); } + // Continue or end the comma chain + commaChainTypeTok = (t.value === ',') ? typeTok : null; + } else { + // Not a valid declaration — reset comma chain + commaChainTypeTok = null; + } + } else { + // Reset comma chain on any non-declaration trigger + if (t.value === ';' || t.value === '{' || t.value === '}') { + commaChainTypeTok = null; } } @@ -886,6 +940,32 @@ export function parse( } } + // ================================================================ + // 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; prevPrevIdx = prevIdx; prev = t; @@ -904,6 +984,7 @@ export function parse( locals: locals, returnStatements: returnStatements, bodyTypeRefs: bodyTypeRefs, + bodyIdentifierRefs: bodyIdentifierRefs, hasBody: hasBody, isOverride: mods.includes('override'), annotations: annotations, diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 06e88e1..ede7a00 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -6807,6 +6807,109 @@ export class Analyzer { } }; + // 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', + ]); + + // 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 @@ -6848,6 +6951,8 @@ export class Analyzer { for (const ref of func.bodyTypeRefs || []) { checkBodyTypeRef(ref); } + // Check unknown identifiers in body expressions + checkBodyIdentifierRefs(func, classNode.name); } } } @@ -6871,6 +6976,8 @@ export class Analyzer { 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..0cdd4ac --- /dev/null +++ b/test/3_game/test_enscript.c @@ -0,0 +1,275 @@ + +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; + + testint1 = 1; + 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"); + + + } + + 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 + + } +} +/* +*/ \ No newline at end of file diff --git a/test/features.test.ts b/test/features.test.ts index ee2eaf7..bc03c89 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). diff --git a/test/parser.test.ts b/test/parser.test.ts index 5839f12..908b101 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -70,6 +70,108 @@ 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('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 +376,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 +680,144 @@ 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('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 From 9f84cef39acce0d158b474be656d3f3f05d13955 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Fri, 13 Mar 2026 14:07:12 -0400 Subject: [PATCH 3/9] Detect constructor-style locals; add constants Treat '(' as a delimiter when recognizing local variable declarations so constructor-style declarations like `ScriptInputUserData serializer();` are detected as locals (server/src/analysis/ast/parser.ts). Add a curated list of native DayZ engine constants and include them in the analyzer's built-in symbol set to avoid reporting them as unknown (server/src/analysis/project/graph.ts). Add a unit test covering the constructor-style local detection (test/parser.test.ts) and update debug output (debug-output.txt). These changes improve parsing accuracy and reduce false unknown-symbol diagnostics. --- debug-output.txt | 10 +++---- server/src/analysis/ast/parser.ts | 6 +++- server/src/analysis/project/graph.ts | 42 ++++++++++++++++++++++++++++ test/parser.test.ts | 18 ++++++++++++ 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/debug-output.txt b/debug-output.txt index d479b8f..7c28fb6 100644 --- a/debug-output.txt +++ b/debug-output.txt @@ -1,5 +1,5 @@ -[SERVER STARTED] 2026-03-13T17:27:04.315Z -[runDiagnostics] uri=file:///d%3A/Github/enscript/test/3_game/test_enscript.c -[runDiagnostics] uri=file:///d%3A/Github/enscript/test/3_game/test_enscript.c -[runDiagnostics] uri=file:///d%3A/Github/enscript/test/3_game/test_enscript.c -[checkUnknownSymbols] uri=undefined docCache.size=2905 +[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/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index 0016df8..5e3a247 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -827,7 +827,11 @@ 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, diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index ede7a00..db95ded 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -46,6 +46,47 @@ 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', + // Dialog / UI constants + 'DT_CUSTOM', 'DBT_OK', 'DBB_NONE', 'DMT_INFO', 'CT_CLASS', + 'DBT_YESNOCANCEL', 'DBB_YES', 'DBB_NO', 'DBB_CANCEL', 'CT_ARRAY', + 'DMT_QUESTION', 'DBT_YESNO', + // 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'; @@ -6812,6 +6853,7 @@ export class Analyzer { '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 diff --git a/test/parser.test.ts b/test/parser.test.ts index 908b101..88102da 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -94,6 +94,24 @@ test('detects comma-separated local variable declarations', () => { 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('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. From 04304ed67d3d36d97d2c25fdec07181e78901f44 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Fri, 13 Mar 2026 16:01:46 -0400 Subject: [PATCH 4/9] Extend native engine constants list Add numerous native engine constants to NATIVE_ENGINE_CONSTANTS (additional UA* UI/input constants for navigation, gestures, quickbar and action categories, UI radial, dialog/exclamation type, and damage types such as DT_FIRE_ARM, DT_EXPLOSION, DT_CLOSE_COMBAT). Update debug-output.txt with extra runDiagnostics/checkUnknownSymbols entries for various Factions scripts. These changes help the analyzer recognize newer engine symbols and reduce unknown-symbol diagnostics. --- server/src/analysis/project/graph.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index db95ded..29cc359 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -64,11 +64,16 @@ const NATIVE_ENGINE_CONSTANTS: readonly string[] = [ 'UACarShiftGearUp', 'UACarShiftGearDown', 'UAUITabLeft', 'UAUITabRight', 'UAUIThumbRight','UAUIRight', 'UAUILeft','UAUIGesturesOpen', 'UAUIQuickbarToggle','UAWalkRunForced','UAMoveRight', 'UAMoveLeft', - 'UAUICopyDebugMonitorPos','UAPersonView','UAUICredits', - // Dialog / UI constants + '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_QUESTION', 'DBT_YESNO','DMT_EXCLAMATION', + //damageType + 'DT_FIRE_ARM', 'DT_EXPLOSION','DT_CLOSE_COMBAT','DT_CUSTOM', // Object intersection constants 'ObjIntersectFire', 'ObjIntersectView', 'ObjIntersectGeom', 'ObjIntersectIFire', 'ObjIntersectNone', From 2c361a9eef1d7c08e26270742bdaf31e63361f9f Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Fri, 13 Mar 2026 18:40:40 -0400 Subject: [PATCH 5/9] Add 'thread' keyword to lexer rules Include 'thread' in the keywords set in server/src/analysis/lexer/rules.ts (under modifiers) so the lexer recognizes 'thread' as a language keyword. --- server/src/analysis/lexer/rules.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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', From e93ddfb607b58d3658d969852f17a348d9259d9a Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Fri, 13 Mar 2026 18:44:57 -0400 Subject: [PATCH 6/9] Fix declaration detection across trimmed newlines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve the raw pre-declaration substring and detect any newlines that were removed by trimEnd so declarations that start on their own line aren't misclassified. The code now computes preDeclPos, separates trimmed text from trailing whitespace, checks for a newline in the trailing portion, and uses that to decide whether to skip (declaration) — fixing cases where indentation or trailing comments caused the previous trim-based check to miss the newline. --- server/src/analysis/project/graph.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index 29cc359..f0c7c12 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -6202,9 +6202,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 } } From cddeb4ef1fc96c3d59b42c12582c12f4175ecc49 Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sat, 14 Mar 2026 15:26:43 -0400 Subject: [PATCH 7/9] Add access checks; fix comma-chain parsing Add access modifier validation and harden local-variable parsing. Introduces Analyzer.checkAccessModifierViolations (server/src/analysis/project/graph.ts) to report private/protected member access violations for qualified and unqualified accesses using class hierarchy lookup. Parser improvements (server/src/analysis/ast/parser.ts) track parenthesis depth and preserve comma-chain types across '=' and ',' while only resetting on statement boundaries to avoid falsely detecting call arguments as locals. Tests updated/added (test/*) to cover modded-method resolution, protected/private access cases, and several comma-chain parsing edge cases; test_enscript.c also extended with class scenarios exercising access rules. --- server/src/analysis/ast/parser.ts | 27 +++- server/src/analysis/project/graph.ts | 208 +++++++++++++++++++++++++ test/3_game/test_enscript.c | 75 ++++++++- test/features.test.ts | 217 +++++++++++++++++++++++++++ test/parser.test.ts | 87 +++++++++++ 5 files changed, 608 insertions(+), 6 deletions(-) diff --git a/server/src/analysis/ast/parser.ts b/server/src/analysis/ast/parser.ts index 5e3a247..ba75231 100644 --- a/server/src/analysis/ast/parser.ts +++ b/server/src/analysis/ast/parser.ts @@ -698,9 +698,17 @@ export function parse( // 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([]); @@ -837,7 +845,7 @@ export function parse( // 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) { + if (prevPrev.value === ',' && commaChainTypeTok && prev.kind === TokenKind.Identifier && parenDepth === commaChainParenDepth) { typeTok = commaChainTypeTok; isCommaChain = true; } @@ -902,11 +910,20 @@ export function parse( if (bodyScopes.length > 0) { bodyScopes[bodyScopes.length - 1].push(local); } - // Continue or end the comma chain - commaChainTypeTok = (t.value === ',') ? typeTok : null; + // 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 — reset comma chain - commaChainTypeTok = null; + // 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 diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index f0c7c12..a6091e3 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -3989,6 +3989,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); @@ -6459,6 +6462,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 */ diff --git a/test/3_game/test_enscript.c b/test/3_game/test_enscript.c index 0cdd4ac..51754b1 100644 --- a/test/3_game/test_enscript.c +++ b/test/3_game/test_enscript.c @@ -48,7 +48,7 @@ class test { int testint3, testint4, testint5; - testint1 = 1; + int testint1 = 1; //should flag for duplicate variable name from class variable testint2 = 2; testint3 = 3; testint4 = 4; @@ -141,6 +141,22 @@ class test { 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 } @@ -271,5 +287,62 @@ modded class testin { } } + + +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 bc03c89..fdca201 100644 --- a/test/features.test.ts +++ b/test/features.test.ts @@ -868,3 +868,220 @@ 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(); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 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/parser.test.ts b/test/parser.test.ts index 88102da..b1ac116 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -112,6 +112,25 @@ test('detects constructor-style local variable declarations', () => { 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. @@ -836,6 +855,74 @@ test('bodyIdentifierRefs: works for top-level functions', () => { 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)) { From 6372530c9c444a2e588b13e1cf3967553d5ec96f Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sat, 14 Mar 2026 15:47:49 -0400 Subject: [PATCH 8/9] Deduplicate class nodes by URI+position When collecting class declarations by name, deduplicate entries using the source URI combined with the node start position (line:character) instead of URI alone so distinct declarations in the same file (e.g. original + modded class declarations) are preserved. Renamed the seen set and build a composite key from _sourceUri and node.start to avoid collapsing separate class nodes. Added tests verifying that a modded method declared in the same file is found and that same-file original+modded declarations do not produce duplicate completions or duplicate type-mismatch diagnostics. --- server/src/analysis/project/graph.ts | 16 +++--- test/features.test.ts | 81 ++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/server/src/analysis/project/graph.ts b/server/src/analysis/project/graph.ts index a6091e3..d9bd792 100644 --- a/server/src/analysis/project/graph.ts +++ b/server/src/analysis/project/graph.ts @@ -2894,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); } diff --git a/test/features.test.ts b/test/features.test.ts index fdca201..a7ba6b5 100644 --- a/test/features.test.ts +++ b/test/features.test.ts @@ -898,6 +898,87 @@ describe('modded class method calls', () => { 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); + }); }); // ══════════════════════════════════════════════════════════════════════════════ From 6c8f107d2c01435f36399fb41b553d83d4ef044e Mon Sep 17 00:00:00 2001 From: Daemon Forge Date: Sat, 14 Mar 2026 15:55:07 -0400 Subject: [PATCH 9/9] Bump version to 0.2.2 Update package.json version from 0.2.1 to 0.2.2 to mark a new patch release. No other changes included. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": {