diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index a26d232ae..014f5cd5d 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -48,6 +48,7 @@ const WASM_GRAMMAR_FILES: Record = { erlang: 'tree-sitter-erlang.wasm', solidity: 'tree-sitter-solidity.wasm', terraform: 'tree-sitter-terraform.wasm', + enforcescript: 'tree-sitter-c_sharp.wasm', arkts: 'tree-sitter-arkts.wasm', nix: 'tree-sitter-nix.wasm', }; @@ -357,6 +358,22 @@ export async function loadGrammarsForLanguages(languages: Language[], wasmBytes? // See: https://github.com/tree-sitter/tree-sitter/issues/2338 for (const lang of toLoad) { try { + // Some grammars ship their own WASMs (not in tree-sitter-wasms, or the + // tree-sitter-wasms build is too old). Lua: tree-sitter-wasms ships an + // ABI-13 build that corrupts the shared WASM heap under web-tree-sitter + // 0.25 (drops nested calls/imports on every file after the first); we + // vendor the upstream ABI-15 wasm instead. C#: the tree-sitter-wasms + // build (ABI 13) has no primary-constructor support and parses + // `class Foo(...)` as an ERROR that swallows the whole class (#237); we + // vendor the upstream ABI-15 tree-sitter-c-sharp 0.23.5 wasm, which parses + // primary constructors natively. Terraform: tree-sitter-wasms does not + // ship HCL/Terraform at all, so we vendor the prebuilt + // tree-sitter-terraform.wasm from @tree-sitter-grammars/tree-sitter-hcl + // 1.2.0 (Apache-2.0) — byte-identical to the npm package's artifact. + const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'enforcescript' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol' || lang === 'vbnet' || lang === 'erlang' || lang === 'terraform') + ? path.join(__dirname, 'wasm', wasmFile) + : require.resolve(`tree-sitter-wasms/out/${wasmFile}`); + const language = await WasmLanguage.load(wasmPath); const bytes = wasmBytes?.[lang]; const language = await WasmLanguage.load(bytes ?? resolveWasmPath(lang)); languageCache.set(lang, language); @@ -591,6 +608,7 @@ export function getLanguageDisplayName(language: Language): string { vbnet: 'Visual Basic .NET', erlang: 'Erlang', terraform: 'Terraform', + enforcescript: 'Enforce Script', arkts: 'ArkTS', unknown: 'Unknown', }; diff --git a/src/extraction/languages/enforcescript.ts b/src/extraction/languages/enforcescript.ts new file mode 100644 index 000000000..4b0332296 --- /dev/null +++ b/src/extraction/languages/enforcescript.ts @@ -0,0 +1,157 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getNodeText } from '../tree-sitter-helpers'; +import type { ExtractorContext, LanguageExtractor } from '../tree-sitter-types'; +import { csharpExtractor } from './csharp'; + +/** + * Enforce Script (DayZ modding language, `.c`) reuses the vendored C# grammar + * — there's no dedicated tree-sitter grammar for it, and its OOP syntax is + * close enough to C# that a handful of textual rewrites ahead of parsing + * (below) let the C# grammar parse it cleanly. All rewrites preserve line + * count (never touch newlines) and, except where noted, preserve exact byte + * offsets too, so node positions stay accurate. + */ + +/** Modded classes: `modded` is rewritten to this real C# modifier keyword + * (same length, so offsets don't shift) so the class still parses as a class + * — `sealed` doesn't exist in Enforce Script, so no real code collides with it. */ +const MODDED_SENTINEL = 'sealed'; + +/** Marks an unresolved reference as the modded→original namesake link so the + * dedicated framework resolver (not general name-matching, which would risk + * a self-loop or an arbitrary pick among same-named files) is the only thing + * that ever claims it. Exported so the resolver can match on the exact prefix. */ +export const MODDED_LINK_PREFIX = '__enforcescript_modded__:'; + +/** + * Blank C-style conditional-compilation directives. Enforce Script uses + * `#ifdef`/`#ifndef` (real C#'s preprocessor only has `#if`/`#elif`/`#else`/ + * `#endif` — no `-def`/`-ndef` forms), which otherwise doesn't parse as a + * directive at all and corrupts the rest of the file's top-level structure. + * Blanking (not converting to `#if`) matches the existing C# extractor's + * policy of indexing every symbol regardless of build flags. + */ +function blankPreprocessorDirectives(source: string): string { + if (source.indexOf('#') === -1) return source; + const re = /^([ \t]*)#[ \t]*(ifdef|ifndef|if|elif|else|endif|define|undef)\b[^\n]*/gm; + return source.replace(re, (m, indent: string) => indent + ' '.repeat(m.length - indent.length)); +} + +/** + * `modded class Foo` / `modded class Foo: Bar` / `modded class Foo extends + * Bar`. The compiler silently ignores any inheritance clause on a modded + * class — `Foo` stays a descendant of its own original, never `Bar` + * (modded-classes.md #2) — so keeping the clause would create a false + * `extends` edge. Rewritten to a bare `sealed class Foo` (no bases at all); + * `extractModifiers`/`synthesizeMembers` below use the sentinel to flag the + * node and link it to its namesake instead. + */ +function markModdedAndStripInheritance(source: string): string { + source = source.replace(/\bmodded\b(?=\s+class\b)/g, MODDED_SENTINEL); + const re = new RegExp(`\\b${MODDED_SENTINEL}\\s+class\\s+\\w+\\s*(:\\s*\\w+|extends\\s+\\w+)`, 'g'); + return source.replace(re, (m, clause: string) => m.slice(0, m.length - clause.length) + clause.replace(/[^\n]/g, ' ')); +} + +/** + * `class Foo extends Bar` — Java-style inheritance real C# doesn't parse at + * all (an ERROR node that detaches the base but leaves the body intact). + * Rewritten to colon form, which the grammar already handles natively. + */ +function convertExtendsToColon(source: string): string { + return source.replace(/\bclass(\s+\w+)\s+extends\b/g, (_m, nameGroup: string) => `class${nameGroup} :` + ' '.repeat('extends'.length - 1)); +} + +/** + * Keyword modifiers with no C# equivalent that otherwise cause the grammar to + * misparse ` ` as `type: modifier, name: Type` with + * the real name orphaned in an ERROR sibling (the documented `proto Man + * GetPlayer()` bug, and the same shape for event/notnull/inout/autoptr). + * `proto(\s+native)?` is blanked as one unit — `native` alone after `proto` + * is blanked would still misparse the same way. `ref` and `auto` are + * deliberately NOT included: `ref` is already a valid C# modifier and `auto` + * parses fine structurally as an (unknown) type-position identifier. + */ +function blankUnknownModifiers(source: string): string { + return source.replace(/\bproto(\s+native)?\b|\bevent\b|\bnotnull\b|\binout\b|\bautoptr\b/g, (m) => ' '.repeat(m.length)); +} + +/** + * `void ~ClassName()` — Enforce Script destructors declare an explicit `void` + * return type (memory-refs.md #9); real C# destructors have none (a distinct + * `destructor_declaration` grammar rule). The leftover `void` collides with + * that rule, and on real files the resulting ERROR cascades and corrupts the + * parse of everything after it (confirmed against a 9.7k-line vanilla file). + */ +function blankVoidBeforeDestructor(source: string): string { + return source.replace(/\bvoid\b(?=\s+~\s*\w+\s*\()/g, (m) => ' '.repeat(m.length)); +} + +/** + * `foreach (Type v: coll)` / `foreach (Type k, Type v: map)` + * (language-rules.md #5) — C#'s foreach uses `in`, not `:`. Same cascading- + * failure shape as the destructor case: foreach appears constantly in real + * code, so one unhandled instance corrupts the rest of the file. NOT byte- + * offset preserving (the standard style has no space before the colon, so + * there's no room for ` in `) — shifts columns for the rest of that one line + * only; no newlines are touched, so every other line stays exact. + */ +function convertForeachColonToIn(source: string): string { + return source.replace(/\bforeach(\s*)\(([^)]*)\)/g, (m, ws: string, inner: string) => { + const colonIdx = inner.indexOf(':'); + if (colonIdx === -1) return m; + const before = inner.slice(0, colonIdx).replace(/\s+$/, ''); + const after = inner.slice(colonIdx + 1).replace(/^\s+/, ''); + return `foreach${ws}(${before} in ${after})`; + }); +} + +function preprocessEnforceScript(source: string): string { + source = blankPreprocessorDirectives(source); + source = markModdedAndStripInheritance(source); + source = convertExtendsToColon(source); + source = blankUnknownModifiers(source); + source = blankVoidBeforeDestructor(source); + source = convertForeachColonToIn(source); + return source; +} + +/** A class_declaration carries the `modded` sentinel modifier iff the source declared it `modded`. */ +function hasModdedSentinel(node: SyntaxNode): boolean { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child?.type === 'modifier' && child.text === MODDED_SENTINEL) return true; + } + return false; +} + +export const enforcescriptExtractor: LanguageExtractor = { + ...csharpExtractor, + preParse: preprocessEnforceScript, + // `void ~ClassName()` parses as a first-class `destructor_declaration` once + // blankVoidBeforeDestructor removes the leading `void` — same name/params/ + // body field shape as method_declaration, so no extra hooks are needed. + methodTypes: [...csharpExtractor.methodTypes, 'destructor_declaration'], + extractModifiers: (node) => (hasModdedSentinel(node) ? ['modded'] : undefined), + // `modded class Foo` has no bases (the inheritance clause was stripped + // pre-parse), so no false `extends` edge is ever created. This links it to + // its namesake instead — the sentinel-prefixed reference name ensures only + // the dedicated framework resolver (frameworks/enforcescript-modded.ts) + // ever resolves it, never general name-matching (which has no self- + // exclusion and would risk a self-loop when this is the only same-named + // symbol in the project). + synthesizeMembers: (classNode, ctx: ExtractorContext) => { + if (!hasModdedSentinel(classNode)) return; + const nameNode = classNode.childForFieldName('name'); + const classId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (!nameNode || !classId) return; + ctx.addUnresolvedReference({ + fromNodeId: classId, + referenceName: MODDED_LINK_PREFIX + getNodeText(nameNode, ctx.source), + referenceKind: 'references', + line: classNode.startPosition.row + 1, + column: classNode.startPosition.column, + filePath: ctx.filePath, + language: 'enforcescript', + }); + }, +}; \ No newline at end of file diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 6b760b01d..ea1a3efcd 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -34,6 +34,7 @@ import { vbnetExtractor } from './vbnet'; import { erlangExtractor } from './erlang'; import { solidityExtractor } from './solidity'; import { terraformExtractor } from './terraform'; +import { enforcescriptExtractor } from './enforcescript'; import { arktsExtractor } from './arkts'; import { nixExtractor } from './nix'; @@ -67,6 +68,7 @@ export const EXTRACTORS: Partial> = { erlang: erlangExtractor, solidity: solidityExtractor, terraform: terraformExtractor, + enforcescript: enforcescriptExtractor, arkts: arktsExtractor, nix: nixExtractor, }; diff --git a/src/resolution/frameworks/enforcescript-modded.ts b/src/resolution/frameworks/enforcescript-modded.ts new file mode 100644 index 000000000..9490e5b2a --- /dev/null +++ b/src/resolution/frameworks/enforcescript-modded.ts @@ -0,0 +1,56 @@ +/** + * Enforce Script `modded class` → original namesake resolver. + * + * A `modded class Foo` extractor node (src/extraction/languages/enforcescript.ts) + * emits a self-referential reference named `__enforcescript_modded__:Foo` + * instead of a real base-class name, because the compiler drops any + * inheritance clause on a modded class outright (modded-classes.md #2) — `Foo` + * stays a descendant of its OWN original, never whatever followed `:`/ + * `extends`. General name-matching is deliberately never given this ref: it + * has no self-exclusion, and `getNodesByName('Foo')` would include the modded + * node itself, risking a self-loop `references` edge when it's the only `Foo` + * in the project. The sentinel prefix guarantees this resolver is the only + * thing that ever claims it (claimsReference below), so self-exclusion and + * the non-modded/earliest-declared preference can be enforced deliberately. + */ + +import type { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +import { MODDED_LINK_PREFIX } from '../../extraction/languages/enforcescript'; + +export const enforcescriptModdedResolver: FrameworkResolver = { + name: 'enforcescript-modded', + languages: ['enforcescript'], + + detect(context: ResolutionContext): boolean { + return context.getNodesByKind('class').some((n) => n.language === 'enforcescript'); + }, + + claimsReference(name: string): boolean { + return name.startsWith(MODDED_LINK_PREFIX); + }, + + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + if (!ref.referenceName.startsWith(MODDED_LINK_PREFIX)) return null; + const name = ref.referenceName.slice(MODDED_LINK_PREFIX.length); + + const candidates = context + .getNodesByName(name) + .filter((n) => n.kind === 'class' && n.language === 'enforcescript' && n.id !== ref.fromNodeId); + if (candidates.length === 0) return null; + + // Prefer the true original (not itself modded) — the one every `new Foo()` + // ultimately bottoms out at. Otherwise fall back to the earliest-declared + // modded version as a best-effort stand-in for "the previous link in the + // modded chain" (modded-classes.md #3) — real load order isn't knowable + // statically, so this is an approximation, not a guarantee. + const original = candidates.find((n) => !n.decorators?.includes('modded')); + const chosen = original ?? candidates.sort((a, b) => a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine)[0]!; + + return { + original: ref, + targetNodeId: chosen.id, + confidence: 0.75, + resolvedBy: 'framework', + }; + }, +}; \ No newline at end of file diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 91da9a01c..72f0cc47f 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -29,6 +29,7 @@ import { expoModulesResolver } from './expo-modules'; import { fabricViewResolver } from './fabric'; import { cicsResolver } from './cics'; import { terraformResolver } from './terraform'; +import { enforcescriptModdedResolver } from './enforcescript-modded'; /** * All registered framework resolvers @@ -76,6 +77,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ cicsResolver, // Terraform / OpenTofu — disambiguate var/local/module/resource refs to same-dir module terraformResolver, + // Enforce Script — link `modded class Foo` to its original namesake + enforcescriptModdedResolver, ]; /** @@ -152,3 +155,4 @@ export { swiftObjcBridgeResolver } from './swift-objc'; export { reactNativeBridgeResolver } from './react-native'; export { expoModulesResolver } from './expo-modules'; export { fabricViewResolver } from './fabric'; +export { enforcescriptModdedResolver } from './enforcescript-modded'; diff --git a/src/types.ts b/src/types.ts index d160d044e..2a1d00a43 100644 --- a/src/types.ts +++ b/src/types.ts @@ -105,6 +105,7 @@ export const LANGUAGES = [ 'vbnet', 'erlang', 'terraform', + 'enforcescript', 'unknown', ] as const;