From 457cc7b80d7adcc2bfee591204853b521ddbea7f Mon Sep 17 00:00:00 2001 From: Daniel Harvey Date: Sun, 15 Mar 2026 15:24:05 -0600 Subject: [PATCH 1/7] feat: add @context attribute for context-based output splitting Allow doc comments to declare @context tags (e.g. synced, unsynced) that partition the generated Lua library into mutually exclusive output files per context. Functions tagged with multiple contexts are placed into a shared base type, and EmmyLua class inheritance is generated so that context-specific types extend the shared one. Table names are only remapped when their members span more than one context bucket. --- src/attribute.ts | 6 + src/cli.ts | 44 ++- src/context.ts | 216 ++++++++++++ src/index.ts | 2 + src/test/context.test.ts | 692 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 956 insertions(+), 4 deletions(-) create mode 100644 src/context.ts create mode 100644 src/test/context.test.ts diff --git a/src/attribute.ts b/src/attribute.ts index 0c93644..ae03632 100644 --- a/src/attribute.ts +++ b/src/attribute.ts @@ -91,6 +91,12 @@ function format(attr: string, ...rest: string[]) { } export function formatAttribute(attribute: Readonly): string { + if (attribute.attributeType === "context") { + console.error( + `Attempting to format internal attribute type '${attribute.attributeType}'` + ); + return ""; + } const known = attribute as KnownAttribute; switch (known.attributeType) { case "global": diff --git a/src/cli.ts b/src/cli.ts index 982cbbc..1f27f17 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,15 @@ import { dirname, join, relative } from "path"; import { cwd } from "process"; import { addHeader, formatDocs, getDocs, processDocs } from "."; import project from "../package.json"; +import { + applyFileContexts, + bucketSuffix, + collectAllContexts, + findMultiContextTables, + generateClassDeclarations, + partitionDocsByContext, + remapDocTableNames, +} from "./context"; import { Doc } from "./doc"; import { toResultAsync } from "./result"; @@ -192,11 +201,33 @@ async function runAsync() { }) ); - const valid = processed.filter((e) => e != null); + const valid = processed.filter((e) => e != null) as [string, Doc[]][]; + + applyFileContexts(valid); + const allContexts = collectAllContexts(valid); console.log(chalk`\n{bold.underline Writing output:}\n`); - if (file === undefined) { + if (allContexts.size > 0) { + const buckets = partitionDocsByContext(valid, allContexts); + const tableBuckets = findMultiContextTables(buckets); + + for (const [bucket, entries] of buckets) { + const outPath = join(dest, `${bucket}.lua`); + const sources = entries.map(([p]) => p); + const docs = entries + .flatMap(([, ds]) => ds) + .map((d) => structuredClone(d)); + + for (const [table, bucketSet] of tableBuckets) { + if (bucketSet.size < 2) continue; + remapDocTableNames(docs, table, table + bucketSuffix(bucket)); + } + + const preamble = generateClassDeclarations(tableBuckets, bucket); + await writeLibraryFile(docs, outPath, repo, sources, preamble); + } + } else if (file === undefined) { // Multi-file output. await Promise.all( valid.map(async ([path, ds]) => { @@ -233,12 +264,17 @@ async function writeLibraryFile( docs: Doc[], outPath: string, repo?: string, - sources: string[] = [] + sources: string[] = [], + preamble?: string ) { try { const formattedDocs = formatDocs(processDocs(docs, repo ?? null)); + const header = addHeader("", sources); + const body = preamble + ? `${header}\n${preamble}\n\n${formattedDocs}` + : addHeader(formattedDocs, sources); await mkdir(dirname(outPath), { recursive: true }); - await writeFile(outPath, addHeader(formattedDocs, sources)); + await writeFile(outPath, body); console.log(chalk`{bold.blue ►} '{white ${outPath}}'`); } catch (e) { console.error( diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..8c7179c --- /dev/null +++ b/src/context.ts @@ -0,0 +1,216 @@ +import { + DefaultAttribute, + EnumAttribute, + FieldAttribute, + FunctionAttribute, + GlobalAttribute, + TableAttribute, +} from "./attribute"; +import { Doc, filterAttributes, hasAttribute, removeAttributes } from "./doc"; + +const KNOWN_DOC_TYPES = [ + "function", + "table", + "class", + "enum", + "global", +] as const; + +function isStandaloneContextDoc(doc: Doc): boolean { + return ( + hasAttribute(doc, "context") && + !KNOWN_DOC_TYPES.some((t) => hasAttribute(doc, t)) + ); +} + +export function getDocContexts(doc: Doc): string[] { + const contextAttrs = filterAttributes(doc, "context") as DefaultAttribute[]; + const contexts = new Set(); + for (const attr of contextAttrs) { + for (const part of attr.args.description.split(",")) { + const trimmed = part.trim(); + if (trimmed) contexts.add(trimmed); + } + } + return [...contexts]; +} + +export function removeContextAttributes(docs: Doc[]): Doc[] { + for (const doc of docs) { + removeAttributes(doc, "context"); + } + return docs; +} + +export function applyFileContexts( + fileEntries: readonly (readonly [string, Doc[]])[] +): void { + for (const [, docs] of fileEntries) { + const standaloneIdx = docs.findIndex(isStandaloneContextDoc); + if (standaloneIdx === -1) continue; + + const fileContexts = getDocContexts(docs[standaloneIdx]); + docs.splice(standaloneIdx, 1); + + for (const doc of docs) { + if (!hasAttribute(doc, "context") && fileContexts.length > 0) { + for (const ctx of fileContexts) { + doc.attributes.push({ + attributeType: "context", + args: { description: ctx }, + }); + } + } + } + } +} + +export function collectAllContexts( + fileEntries: readonly (readonly [string, Doc[]])[] +): Set { + const all = new Set(); + for (const [, docs] of fileEntries) { + for (const doc of docs) { + for (const ctx of getDocContexts(doc)) { + all.add(ctx); + } + } + } + return all; +} + +function contextBucketName( + docContexts: string[], + allContexts: Set +): string { + if (docContexts.length === 0) return "shared"; + + const sorted = [...new Set(docContexts)].sort(); + if (sorted.length === allContexts.size) { + const allSorted = [...allContexts].sort(); + if (sorted.every((c, i) => c === allSorted[i])) return "shared"; + } + + return sorted.join("_"); +} + +export function partitionDocsByContext( + fileEntries: readonly (readonly [string, Doc[]])[], + allContexts: Set +): Map { + const buckets = new Map(); + + for (const [path, docs] of fileEntries) { + for (const doc of docs) { + const contexts = getDocContexts(doc); + const bucket = contextBucketName(contexts, allContexts); + + if (!buckets.has(bucket)) { + buckets.set(bucket, []); + } + + const entries = buckets.get(bucket)!; + let fileEntry = entries.find(([p]) => p === path); + if (!fileEntry) { + fileEntry = [path, []]; + entries.push(fileEntry); + } + fileEntry[1].push(doc); + } + } + + return buckets; +} + +const NAME_ATTR_TYPES = [ + "function", + "table", + "enum", + "global", + "field", +] as const; + +export function getDocTableName(doc: Doc): string | null { + for (const attr of doc.attributes) { + switch (attr.attributeType) { + case "table": + case "enum": + return (attr as TableAttribute | EnumAttribute).args.name[0] ?? null; + case "function": { + const name = (attr as FunctionAttribute).args.name; + return name.length > 1 ? name[0] : null; + } + case "global": + case "field": { + const name = (attr as GlobalAttribute | FieldAttribute).args.name; + return name.length > 1 ? name[0] : null; + } + } + } + return null; +} + +export function findMultiContextTables( + buckets: Map +): Map> { + const tableBuckets = new Map>(); + for (const [bucketName, entries] of buckets) { + for (const [, docs] of entries) { + for (const doc of docs) { + const table = getDocTableName(doc); + if (table == null) continue; + if (!tableBuckets.has(table)) tableBuckets.set(table, new Set()); + tableBuckets.get(table)!.add(bucketName); + } + } + } + return tableBuckets; +} + +export function bucketSuffix(bucketName: string): string { + return bucketName + .split("_") + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(""); +} + +export function remapDocTableNames( + docs: Doc[], + tableName: string, + newTableName: string +): void { + for (const doc of docs) { + for (const attr of doc.attributes) { + if (!NAME_ATTR_TYPES.includes(attr.attributeType as any)) continue; + const args = attr.args as { name: readonly string[] }; + if (args.name[0] !== tableName) continue; + (args as { name: string[] }).name = [ + newTableName, + ...args.name.slice(1), + ]; + } + } +} + +export function generateClassDeclarations( + tableBuckets: Map>, + bucketName: string +): string { + const suffix = bucketSuffix(bucketName); + const lines: string[] = []; + + for (const [table, buckets] of tableBuckets) { + if (buckets.size < 2) continue; + const className = `${table}${suffix}`; + if (bucketName === "shared") { + lines.push(`---@class ${className}\n${className} = {}`); + } else { + const sharedClass = `${table}${bucketSuffix("shared")}`; + lines.push( + `---@class ${className} : ${sharedClass}\n${className} = {}` + ); + } + } + + return lines.join("\n\n"); +} diff --git a/src/index.ts b/src/index.ts index fe8fe0d..0ed6a4a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import { getComments } from "./comment"; +import { removeContextAttributes } from "./context"; import { Doc, formatDoc, getDoc, isDocEmpty, removeEmptyDocs } from "./doc"; import { addTableToEnumFields, mergeEnumAttributes } from "./enum"; import { renderStandaloneFields } from "./field"; @@ -45,6 +46,7 @@ function runProcessors(docs: Doc[], processors: readonly DocProcessor[]) { export function processDocs(docs: Doc[], repoUrl: string | null): Doc[] { return runProcessors(docs, [ removeEmptyDocs, + removeContextAttributes, appendSourceLinks(repoUrl), processGlobals, addTables, diff --git a/src/test/context.test.ts b/src/test/context.test.ts new file mode 100644 index 0000000..04e6780 --- /dev/null +++ b/src/test/context.test.ts @@ -0,0 +1,692 @@ +import dedent from "dedent-js"; +import test from "tape"; +import { formatDocs, getDocs, processDocs } from ".."; +import { + applyFileContexts, + bucketSuffix, + collectAllContexts, + findMultiContextTables, + generateClassDeclarations, + getDocContexts, + getDocTableName, + partitionDocsByContext, + remapDocTableNames, +} from "../context"; +import { Doc } from "../doc"; +import { testInput } from "./utility/harness"; + +function parseDocs(input: string, path = "test.cpp"): Doc[] { + const [result, err] = getDocs(input, path); + if (err != null) throw err; + return result[0]; +} + +// --- getDocContexts --- + +test("getDocContexts: single context", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Foo + * @context synced + */ + `); + t.deepEqual(getDocContexts(docs[0]), ["synced"]); + t.end(); +}); + +test("getDocContexts: comma-separated contexts", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Foo + * @context synced, unsynced + */ + `); + t.deepEqual(getDocContexts(docs[0]), ["synced", "unsynced"]); + t.end(); +}); + +test("getDocContexts: multiple @context tags", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Foo + * @context synced + * @context unsynced + */ + `); + t.deepEqual(getDocContexts(docs[0]), ["synced", "unsynced"]); + t.end(); +}); + +test("getDocContexts: deduplicated across tags and commas", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Foo + * @context synced, unsynced + * @context synced + */ + `); + t.deepEqual(getDocContexts(docs[0]), ["synced", "unsynced"]); + t.end(); +}); + +test("getDocContexts: no context returns empty", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Foo + */ + `); + t.deepEqual(getDocContexts(docs[0]), []); + t.end(); +}); + +// --- applyFileContexts --- + +test("applyFileContexts: standalone context applies to all docs in file", (t) => { + const docs = parseDocs( + dedent` + /*** + * @context synced + */ + /*** + * @function Foo + */ + /*** + * @function Bar + */ + `, + "test.cpp" + ); + + const entries: [string, Doc[]][] = [["test.cpp", docs]]; + applyFileContexts(entries); + + const [, fileDocs] = entries[0]; + t.equal(fileDocs.length, 2, "standalone doc removed"); + t.deepEqual(getDocContexts(fileDocs[0]), ["synced"], "Foo gets synced"); + t.deepEqual(getDocContexts(fileDocs[1]), ["synced"], "Bar gets synced"); + t.end(); +}); + +test("applyFileContexts: per-function context overrides file-level", (t) => { + const docs = parseDocs( + dedent` + /*** + * @context synced + */ + /*** + * @function Foo + * @context unsynced + */ + /*** + * @function Bar + */ + `, + "test.cpp" + ); + + const entries: [string, Doc[]][] = [["test.cpp", docs]]; + applyFileContexts(entries); + + const [, fileDocs] = entries[0]; + t.deepEqual( + getDocContexts(fileDocs[0]), + ["unsynced"], + "Foo keeps its own context" + ); + t.deepEqual( + getDocContexts(fileDocs[1]), + ["synced"], + "Bar inherits file-level" + ); + t.end(); +}); + +test("applyFileContexts: no standalone context is a no-op", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Foo + * @context synced + */ + `); + const entries: [string, Doc[]][] = [["test.cpp", docs]]; + applyFileContexts(entries); + + const [, fileDocs] = entries[0]; + t.equal(fileDocs.length, 1); + t.deepEqual(getDocContexts(fileDocs[0]), ["synced"]); + t.end(); +}); + +// --- collectAllContexts --- + +test("collectAllContexts: gathers from all files", (t) => { + const docsA = parseDocs(dedent` + /*** + * @function Foo + * @context synced + */ + `); + const docsB = parseDocs(dedent` + /*** + * @function Bar + * @context unsynced + */ + `); + const entries: [string, Doc[]][] = [ + ["a.cpp", docsA], + ["b.cpp", docsB], + ]; + + const all = collectAllContexts(entries); + t.deepEqual([...all].sort(), ["synced", "unsynced"]); + t.end(); +}); + +test("collectAllContexts: empty when no contexts", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Foo + */ + `); + const all = collectAllContexts([["a.cpp", docs]]); + t.equal(all.size, 0); + t.end(); +}); + +// --- partitionDocsByContext --- + +test("partitionDocsByContext: single context goes to named bucket", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Foo + * @context synced + */ + `); + const all = new Set(["synced", "unsynced"]); + const buckets = partitionDocsByContext([["a.cpp", docs]], all); + + const syncedDocs = buckets.get("synced")!.flatMap(([, d]) => d); + t.equal(syncedDocs.length, 1); + t.end(); +}); + +test("partitionDocsByContext: no context goes to shared", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Foo + */ + `); + const all = new Set(["synced", "unsynced"]); + const buckets = partitionDocsByContext([["a.cpp", docs]], all); + + const sharedDocs = buckets.get("shared")!.flatMap(([, d]) => d); + t.equal(sharedDocs.length, 1); + t.end(); +}); + +test("partitionDocsByContext: all contexts goes to shared", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Foo + * @context synced, unsynced + */ + `); + const all = new Set(["synced", "unsynced"]); + const buckets = partitionDocsByContext([["a.cpp", docs]], all); + + const sharedDocs = buckets.get("shared")!.flatMap(([, d]) => d); + t.equal(sharedDocs.length, 1); + t.end(); +}); + +test("partitionDocsByContext: strict subset gets combined name", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Foo + * @context synced, unsynced + */ + `); + const all = new Set(["synced", "unsynced", "widget"]); + const buckets = partitionDocsByContext([["a.cpp", docs]], all); + + const combinedDocs = buckets.get("synced_unsynced")!.flatMap(([, d]) => d); + t.equal(combinedDocs.length, 1); + t.end(); +}); + +test("partitionDocsByContext: mixed docs across files", (t) => { + const docsA = parseDocs( + dedent` + /*** + * @function Foo + * @context synced + */ + /*** + * @function Bar + */ + `, + "a.cpp" + ); + const docsB = parseDocs( + dedent` + /*** + * @function Baz + * @context unsynced + */ + `, + "b.cpp" + ); + + const all = new Set(["synced", "unsynced"]); + const buckets = partitionDocsByContext( + [ + ["a.cpp", docsA], + ["b.cpp", docsB], + ], + all + ); + + t.ok(buckets.has("synced"), "has synced bucket"); + t.ok(buckets.has("unsynced"), "has unsynced bucket"); + t.ok(buckets.has("shared"), "has shared bucket"); + + const syncedDocs = buckets.get("synced")!.flatMap(([, d]) => d); + const unsyncedDocs = buckets.get("unsynced")!.flatMap(([, d]) => d); + const sharedDocs = buckets.get("shared")!.flatMap(([, d]) => d); + + t.equal(syncedDocs.length, 1, "one synced doc"); + t.equal(unsyncedDocs.length, 1, "one unsynced doc"); + t.equal(sharedDocs.length, 1, "one shared doc"); + t.end(); +}); + +// --- @context stripped from output --- + +testInput( + "context attribute stripped from output", + dedent` + /*** + * Does stuff. + * + * @function Spring.Foo + * @context synced + * @param x integer + */ + `, + dedent` + ---Does stuff. + --- + ---@param x integer + function Spring.Foo(x) end + ` +); + +testInput( + "multiple context attributes stripped from output", + dedent` + /*** + * Does stuff. + * + * @function Spring.Foo + * @context synced + * @context unsynced + * @param x integer + */ + `, + dedent` + ---Does stuff. + --- + ---@param x integer + function Spring.Foo(x) end + ` +); + +testInput( + "comma-separated context stripped from output", + dedent` + /*** + * Does stuff. + * + * @function Spring.Foo + * @context synced, unsynced + */ + `, + dedent` + ---Does stuff. + function Spring.Foo() end + ` +); + +// --- getDocTableName --- + +test("getDocTableName: function with table prefix", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Spring.Foo + */ + `); + t.equal(getDocTableName(docs[0]), "Spring"); + t.end(); +}); + +test("getDocTableName: bare function returns null", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Foo + */ + `); + t.equal(getDocTableName(docs[0]), null); + t.end(); +}); + +test("getDocTableName: table declaration", (t) => { + const docs = parseDocs(dedent` + /*** + * @table Spring + */ + `); + t.equal(getDocTableName(docs[0]), "Spring"); + t.end(); +}); + +test("getDocTableName: no relevant attribute", (t) => { + const docs = parseDocs(dedent` + /*** + * Just a description. + * @context synced + */ + `); + t.equal(getDocTableName(docs[0]), null); + t.end(); +}); + +// --- bucketSuffix --- + +test("bucketSuffix: single word", (t) => { + t.equal(bucketSuffix("synced"), "Synced"); + t.end(); +}); + +test("bucketSuffix: compound name", (t) => { + t.equal(bucketSuffix("synced_unsynced"), "SyncedUnsynced"); + t.end(); +}); + +test("bucketSuffix: shared", (t) => { + t.equal(bucketSuffix("shared"), "Shared"); + t.end(); +}); + +// --- findMultiContextTables --- + +test("findMultiContextTables: table spanning two buckets", (t) => { + const docsA = parseDocs(dedent` + /*** + * @function Spring.Foo + * @context synced + */ + `); + const docsB = parseDocs(dedent` + /*** + * @function Spring.Bar + * @context unsynced + */ + `); + const allContexts = new Set(["synced", "unsynced"]); + const buckets = partitionDocsByContext( + [ + ["a.cpp", docsA], + ["b.cpp", docsB], + ], + allContexts + ); + + const tableBuckets = findMultiContextTables(buckets); + t.ok(tableBuckets.has("Spring")); + t.deepEqual([...tableBuckets.get("Spring")!].sort(), ["synced", "unsynced"]); + t.end(); +}); + +test("findMultiContextTables: single-bucket table not flagged as multi", (t) => { + const docs = parseDocs(dedent` + /*** + * @function VFS.LoadFile + * @context synced, unsynced + */ + `); + const allContexts = new Set(["synced", "unsynced"]); + const buckets = partitionDocsByContext([["a.cpp", docs]], allContexts); + + const tableBuckets = findMultiContextTables(buckets); + t.ok(tableBuckets.has("VFS")); + t.equal(tableBuckets.get("VFS")!.size, 1, "VFS only in shared bucket"); + t.end(); +}); + +test("findMultiContextTables: mixed multi and single-bucket tables", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Spring.Foo + * @context synced + */ + /*** + * @function Spring.Bar + * @context synced, unsynced + */ + /*** + * @function VFS.LoadFile + * @context synced, unsynced + */ + `); + const allContexts = new Set(["synced", "unsynced"]); + const buckets = partitionDocsByContext([["a.cpp", docs]], allContexts); + const tableBuckets = findMultiContextTables(buckets); + + t.equal(tableBuckets.get("Spring")!.size, 2, "Spring spans synced + shared"); + t.equal(tableBuckets.get("VFS")!.size, 1, "VFS only in shared"); + t.end(); +}); + +// --- remapDocTableNames --- + +test("remapDocTableNames: renames function table prefix", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Spring.Foo + */ + `); + remapDocTableNames(docs, "Spring", "SpringSynced"); + const attr = docs[0].attributes.find((a) => a.attributeType === "function"); + t.deepEqual((attr as any).args.name, ["SpringSynced", "Foo"]); + t.end(); +}); + +test("remapDocTableNames: nested name preserves inner segments", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Spring.MoveCtrl.Enable + */ + `); + remapDocTableNames(docs, "Spring", "SpringSynced"); + const attr = docs[0].attributes.find((a) => a.attributeType === "function"); + t.deepEqual((attr as any).args.name, ["SpringSynced", "MoveCtrl", "Enable"]); + t.end(); +}); + +test("remapDocTableNames: non-matching table not touched", (t) => { + const docs = parseDocs(dedent` + /*** + * @function VFS.LoadFile + */ + `); + remapDocTableNames(docs, "Spring", "SpringSynced"); + const attr = docs[0].attributes.find((a) => a.attributeType === "function"); + t.deepEqual((attr as any).args.name, ["VFS", "LoadFile"]); + t.end(); +}); + +test("remapDocTableNames: table declaration renamed", (t) => { + const docs = parseDocs(dedent` + /*** + * @table Spring + */ + `); + remapDocTableNames(docs, "Spring", "SpringSynced"); + const attr = docs[0].attributes.find((a) => a.attributeType === "table"); + t.deepEqual((attr as any).args.name, ["SpringSynced"]); + t.end(); +}); + +// --- generateClassDeclarations --- + +test("generateClassDeclarations: shared bucket gets base class", (t) => { + const tableBuckets = new Map([ + ["Spring", new Set(["synced", "unsynced", "shared"])], + ]); + const result = generateClassDeclarations(tableBuckets, "shared"); + t.ok(result.includes("---@class SpringShared")); + t.ok(result.includes("SpringShared = {}")); + t.ok(!result.includes(":"), "no inheritance for shared"); + t.end(); +}); + +test("generateClassDeclarations: non-shared bucket inherits from shared", (t) => { + const tableBuckets = new Map([ + ["Spring", new Set(["synced", "unsynced", "shared"])], + ]); + const result = generateClassDeclarations(tableBuckets, "synced"); + t.ok(result.includes("---@class SpringSynced : SpringShared")); + t.ok(result.includes("SpringSynced = {}")); + t.end(); +}); + +test("generateClassDeclarations: skips single-bucket tables", (t) => { + const tableBuckets = new Map([ + ["Spring", new Set(["synced", "shared"])], + ["VFS", new Set(["shared"])], + ]); + const result = generateClassDeclarations(tableBuckets, "shared"); + t.ok(result.includes("SpringShared"), "Spring remapped"); + t.ok(!result.includes("VFS"), "VFS not remapped"); + t.end(); +}); + +test("generateClassDeclarations: multiple multi-context tables", (t) => { + const tableBuckets = new Map([ + ["Spring", new Set(["synced", "shared"])], + ["Game", new Set(["synced", "shared"])], + ]); + const result = generateClassDeclarations(tableBuckets, "synced"); + t.ok(result.includes("---@class SpringSynced : SpringShared")); + t.ok(result.includes("---@class GameSynced : GameShared")); + t.end(); +}); + +// --- Integration: file-level context + processDocs --- + +test("integration: file-level context + processDocs strips @context", (t) => { + const docs = parseDocs( + dedent` + /*** + * @context synced + */ + /*** + * Does stuff. + * + * @function Foo + * @param x integer + */ + `, + "test.cpp" + ); + + const entries: [string, Doc[]][] = [["test.cpp", docs]]; + applyFileContexts(entries); + + const allContexts = collectAllContexts(entries); + t.deepEqual([...allContexts], ["synced"]); + + const [, fileDocs] = entries[0]; + const processed = processDocs(fileDocs, null); + const output = formatDocs(processed); + + t.ok(!output.includes("@context"), "no @context in output"); + t.ok(output.includes("function Foo(x) end"), "function present"); + t.end(); +}); + +// --- End-to-end: remapping + class declarations --- + +function processBucket( + bucketName: string, + buckets: Map, + tableBuckets: Map> +): string { + const docs = buckets + .get(bucketName)! + .flatMap(([, ds]) => ds) + .map((d) => structuredClone(d)); + + for (const [table, bucketSet] of tableBuckets) { + if (bucketSet.size < 2) continue; + remapDocTableNames(docs, table, table + bucketSuffix(bucketName)); + } + + return formatDocs(processDocs(docs, null)); +} + +test("end-to-end: context projection remaps multi-bucket tables with class inheritance", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Spring.Foo + * @context synced + */ + /*** + * @function Spring.Baz + * @context synced, unsynced + */ + /*** + * @function VFS.LoadFile + * @context synced, unsynced + */ + `); + const allContexts = new Set(["synced", "unsynced"]); + const buckets = partitionDocsByContext([["a.cpp", docs]], allContexts); + const tableBuckets = findMultiContextTables(buckets); + + t.deepEqual( + [...buckets.keys()].sort(), + ["shared", "synced"], + "partition produces expected buckets" + ); + t.equal(tableBuckets.get("Spring")!.size, 2, "Spring spans synced + shared"); + t.equal(tableBuckets.get("VFS")!.size, 1, "VFS only in shared"); + + const syncedOutput = processBucket("synced", buckets, tableBuckets); + t.ok( + syncedOutput.includes("function SpringSynced.Foo() end"), + "Spring.Foo remapped to SpringSynced.Foo" + ); + t.ok(!syncedOutput.includes("function Spring.Foo"), "original name gone"); + t.ok(!syncedOutput.includes("VFS"), "VFS not in synced bucket"); + + const sharedOutput = processBucket("shared", buckets, tableBuckets); + t.ok( + sharedOutput.includes("function SpringShared.Baz() end"), + "Spring.Baz remapped to SpringShared.Baz" + ); + t.ok( + sharedOutput.includes("function VFS.LoadFile() end"), + "VFS.LoadFile unchanged" + ); + + const syncedPreamble = generateClassDeclarations(tableBuckets, "synced"); + t.ok(syncedPreamble.includes("---@class SpringSynced : SpringShared")); + t.ok(!syncedPreamble.includes("VFS"), "VFS not in class declarations"); + + const sharedPreamble = generateClassDeclarations(tableBuckets, "shared"); + t.ok(sharedPreamble.includes("---@class SpringShared")); + t.ok(!sharedPreamble.includes(":"), "shared class has no parent"); + + t.end(); +}); From 713f74e5e92149014a55b6bee656ee7e2cd017b4 Mon Sep 17 00:00:00 2001 From: Daniel Harvey Date: Tue, 17 Mar 2026 00:55:02 -0600 Subject: [PATCH 2/7] for unlabelled contexts fall back to file name shared was out of control big --- src/cli.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index 1f27f17..48f9d3b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -212,6 +212,9 @@ async function runAsync() { const buckets = partitionDocsByContext(valid, allContexts); const tableBuckets = findMultiContextTables(buckets); + const sharedEntries = buckets.get("shared"); + buckets.delete("shared"); + for (const [bucket, entries] of buckets) { const outPath = join(dest, `${bucket}.lua`); const sources = entries.map(([p]) => p); @@ -227,6 +230,27 @@ async function runAsync() { const preamble = generateClassDeclarations(tableBuckets, bucket); await writeLibraryFile(docs, outPath, repo, sources, preamble); } + + const sharedPreamble = generateClassDeclarations(tableBuckets, "shared"); + if (sharedPreamble) { + await writeLibraryFile([], join(dest, "shared.lua"), repo, [], sharedPreamble); + } + + if (sharedEntries) { + await Promise.all( + sharedEntries.map(async ([path, ds]) => { + if (ds.length === 0) return; + const docs = ds.map((d) => structuredClone(d)); + for (const [table, bucketSet] of tableBuckets) { + if (bucketSet.size < 2) continue; + remapDocTableNames(docs, table, table + bucketSuffix("shared")); + } + const rel = relative(cwd(), path); + const outPath = join(dest, `${rel}.lua`); + await writeLibraryFile(docs, outPath, repo, [path]); + }) + ); + } } else if (file === undefined) { // Multi-file output. await Promise.all( From 6510520a096b7278245a41fd43c9c8b89819522b Mon Sep 17 00:00:00 2001 From: Daniel Harvey Date: Sun, 22 Mar 2026 15:27:35 -0600 Subject: [PATCH 3/7] fix: functional output types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit So I took another look at this and refactored the output layer. No more explicit shared handling in the CLI — it's internal to the bucket naming now. The CLI just gets a flat from , and is a merge step on that same list, not a separate code path. Single write loop, no branching. I also pulled into a generic backed by a constant — so is just the first entry. Future file-level meta attrs just add to the constant and get the validation for free (must be first doc, only one per file). Plus combinatorial tests for 3 contexts covering all subset combinations. --- src/cli.ts | 84 +++++------------------ src/context.ts | 119 +++++++++++++++++++++++++++++--- src/output.ts | 20 ++++++ src/test/context.test.ts | 142 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 287 insertions(+), 78 deletions(-) create mode 100644 src/output.ts diff --git a/src/cli.ts b/src/cli.ts index 48f9d3b..fe7effa 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,14 +12,10 @@ import { addHeader, formatDocs, getDocs, processDocs } from "."; import project from "../package.json"; import { applyFileContexts, - bucketSuffix, - collectAllContexts, - findMultiContextTables, - generateClassDeclarations, - partitionDocsByContext, - remapDocTableNames, + projectContextOutputs, } from "./context"; import { Doc } from "./doc"; +import { mergeFileOutputs } from "./output"; import { toResultAsync } from "./result"; interface Options { @@ -203,74 +199,24 @@ async function runAsync() { const valid = processed.filter((e) => e != null) as [string, Doc[]][]; - applyFileContexts(valid); - const allContexts = collectAllContexts(valid); + errors.push(...applyFileContexts(valid)); + let outputs = projectContextOutputs( + valid, (p) => relative(cwd(), p) + ); + if (file !== undefined) { + outputs = mergeFileOutputs(outputs, file); + } console.log(chalk`\n{bold.underline Writing output:}\n`); - if (allContexts.size > 0) { - const buckets = partitionDocsByContext(valid, allContexts); - const tableBuckets = findMultiContextTables(buckets); - - const sharedEntries = buckets.get("shared"); - buckets.delete("shared"); - - for (const [bucket, entries] of buckets) { - const outPath = join(dest, `${bucket}.lua`); - const sources = entries.map(([p]) => p); - const docs = entries - .flatMap(([, ds]) => ds) - .map((d) => structuredClone(d)); - - for (const [table, bucketSet] of tableBuckets) { - if (bucketSet.size < 2) continue; - remapDocTableNames(docs, table, table + bucketSuffix(bucket)); - } - - const preamble = generateClassDeclarations(tableBuckets, bucket); - await writeLibraryFile(docs, outPath, repo, sources, preamble); - } - - const sharedPreamble = generateClassDeclarations(tableBuckets, "shared"); - if (sharedPreamble) { - await writeLibraryFile([], join(dest, "shared.lua"), repo, [], sharedPreamble); - } - - if (sharedEntries) { - await Promise.all( - sharedEntries.map(async ([path, ds]) => { - if (ds.length === 0) return; - const docs = ds.map((d) => structuredClone(d)); - for (const [table, bucketSet] of tableBuckets) { - if (bucketSet.size < 2) continue; - remapDocTableNames(docs, table, table + bucketSuffix("shared")); - } - const rel = relative(cwd(), path); - const outPath = join(dest, `${rel}.lua`); - await writeLibraryFile(docs, outPath, repo, [path]); - }) - ); - } - } else if (file === undefined) { - // Multi-file output. - await Promise.all( - valid.map(async ([path, ds]) => { - const rel = relative(cwd(), path); - const outPath = join(dest, `${rel}.lua`); - if (ds.length > 0) { - await writeLibraryFile(ds, outPath, repo, [path]); - } - }) - ); - } else { - // Single-file output. - const outPath = join(dest, file); - const sources = valid.map(([path]) => path); + for (const output of outputs) { + if (output.docs.length === 0 && !output.preamble) continue; await writeLibraryFile( - valid.flatMap(([, ds]) => ds), - outPath, + output.docs, + join(dest, output.name), repo, - sources + output.sources, + output.preamble || undefined ); } diff --git a/src/context.ts b/src/context.ts index 8c7179c..2662c07 100644 --- a/src/context.ts +++ b/src/context.ts @@ -7,6 +7,7 @@ import { TableAttribute, } from "./attribute"; import { Doc, filterAttributes, hasAttribute, removeAttributes } from "./doc"; +import { FileOutput } from "./output"; const KNOWN_DOC_TYPES = [ "function", @@ -16,9 +17,11 @@ const KNOWN_DOC_TYPES = [ "global", ] as const; -function isStandaloneContextDoc(doc: Doc): boolean { +const FILE_ATTRIBUTE_TYPES = ["context"] as const; + +function isFileAttributeDoc(doc: Doc): boolean { return ( - hasAttribute(doc, "context") && + FILE_ATTRIBUTE_TYPES.some((t) => hasAttribute(doc, t)) && !KNOWN_DOC_TYPES.some((t) => hasAttribute(doc, t)) ); } @@ -44,13 +47,33 @@ export function removeContextAttributes(docs: Doc[]): Doc[] { export function applyFileContexts( fileEntries: readonly (readonly [string, Doc[]])[] -): void { - for (const [, docs] of fileEntries) { - const standaloneIdx = docs.findIndex(isStandaloneContextDoc); - if (standaloneIdx === -1) continue; +): string[] { + const errors: string[] = []; + + for (const [path, docs] of fileEntries) { + const fileAttrIndices: number[] = []; + for (let i = 0; i < docs.length; i++) { + if (isFileAttributeDoc(docs[i])) fileAttrIndices.push(i); + } - const fileContexts = getDocContexts(docs[standaloneIdx]); - docs.splice(standaloneIdx, 1); + if (fileAttrIndices.length === 0) continue; + + if (fileAttrIndices.length > 1) { + errors.push( + `'${path}': multiple file-level attribute docs (found ${fileAttrIndices.length}, expected at most 1)` + ); + continue; + } + + if (fileAttrIndices[0] !== 0) { + errors.push( + `'${path}': file-level attribute doc must be the first doc in the file` + ); + continue; + } + + const fileContexts = getDocContexts(docs[0]); + docs.splice(0, 1); for (const doc of docs) { if (!hasAttribute(doc, "context") && fileContexts.length > 0) { @@ -63,6 +86,8 @@ export function applyFileContexts( } } } + + return errors; } export function collectAllContexts( @@ -214,3 +239,81 @@ export function generateClassDeclarations( return lines.join("\n\n"); } + +function cloneAndRemapDocs( + entries: [string, Doc[]][], + tableBuckets: Map>, + bucketName: string +): Doc[] { + const docs = entries.flatMap(([, ds]) => ds).map((d) => structuredClone(d)); + for (const [table, bucketSet] of tableBuckets) { + if (bucketSet.size < 2) continue; + remapDocTableNames(docs, table, table + bucketSuffix(bucketName)); + } + return docs; +} + +export function projectContextOutputs( + fileEntries: readonly (readonly [string, Doc[]])[], + resolveOutputName: (sourcePath: string) => string, +): FileOutput[] { + const allContexts = collectAllContexts(fileEntries); + + if (allContexts.size === 0) { + const outputs: FileOutput[] = []; + for (const [path, docs] of fileEntries) { + if (docs.length === 0) continue; + outputs.push({ + name: `${resolveOutputName(path)}.lua`, + docs: [...docs], + sources: [path], + preamble: "", + }); + } + return outputs; + } + + const buckets = partitionDocsByContext(fileEntries, allContexts); + const tableBuckets = findMultiContextTables(buckets); + + const sharedRawEntries = buckets.get("shared") ?? []; + buckets.delete("shared"); + + const outputs: FileOutput[] = []; + + for (const [name, entries] of buckets) { + outputs.push({ + name: `${name}.lua`, + docs: cloneAndRemapDocs(entries, tableBuckets, name), + sources: entries.map(([p]) => p), + preamble: generateClassDeclarations(tableBuckets, name), + }); + } + + const sharedPreamble = generateClassDeclarations(tableBuckets, "shared"); + if (sharedPreamble) { + outputs.push({ + name: "shared.lua", + docs: [], + sources: [], + preamble: sharedPreamble, + }); + } + + for (const [path, rawDocs] of sharedRawEntries) { + if (rawDocs.length === 0) continue; + const docs = rawDocs.map((d) => structuredClone(d)); + for (const [table, bucketSet] of tableBuckets) { + if (bucketSet.size < 2) continue; + remapDocTableNames(docs, table, table + bucketSuffix("shared")); + } + outputs.push({ + name: `${resolveOutputName(path)}.lua`, + docs, + sources: [path], + preamble: "", + }); + } + + return outputs; +} diff --git a/src/output.ts b/src/output.ts new file mode 100644 index 0000000..b794116 --- /dev/null +++ b/src/output.ts @@ -0,0 +1,20 @@ +import { Doc } from "./doc"; + +export interface FileOutput { + name: string; + docs: Doc[]; + sources: string[]; + preamble: string; +} + +export function mergeFileOutputs( + outputs: FileOutput[], + fileName: string, +): FileOutput[] { + return [{ + name: fileName, + docs: outputs.flatMap((o) => o.docs), + sources: [...new Set(outputs.flatMap((o) => o.sources))], + preamble: outputs.map((o) => o.preamble).filter(Boolean).join("\n\n"), + }]; +} diff --git a/src/test/context.test.ts b/src/test/context.test.ts index 04e6780..f28b819 100644 --- a/src/test/context.test.ts +++ b/src/test/context.test.ts @@ -149,14 +149,87 @@ test("applyFileContexts: no standalone context is a no-op", (t) => { */ `); const entries: [string, Doc[]][] = [["test.cpp", docs]]; - applyFileContexts(entries); + const errors = applyFileContexts(entries); + t.equal(errors.length, 0, "no errors"); const [, fileDocs] = entries[0]; t.equal(fileDocs.length, 1); t.deepEqual(getDocContexts(fileDocs[0]), ["synced"]); t.end(); }); +test("applyFileContexts: errors on duplicate file-level context docs", (t) => { + const docs = parseDocs( + dedent` + /*** + * @context synced + */ + /*** + * @context unsynced + */ + /*** + * @function Foo + */ + `, + "dup.cpp" + ); + + const entries: [string, Doc[]][] = [["dup.cpp", docs]]; + const errors = applyFileContexts(entries); + + t.equal(errors.length, 1, "one error returned"); + t.ok(errors[0].includes("multiple file-level attribute docs"), "error mentions duplicates"); + t.equal(entries[0][1].length, 3, "docs unchanged on error"); + t.end(); +}); + +test("applyFileContexts: errors when file-level context is not first", (t) => { + const docs = parseDocs( + dedent` + /*** + * @function Foo + */ + /*** + * @context synced + */ + /*** + * @function Bar + */ + `, + "late.cpp" + ); + + const entries: [string, Doc[]][] = [["late.cpp", docs]]; + const errors = applyFileContexts(entries); + + t.equal(errors.length, 1, "one error returned"); + t.ok(errors[0].includes("must be the first doc"), "error mentions position"); + t.equal(entries[0][1].length, 3, "docs unchanged on error"); + t.end(); +}); + +test("applyFileContexts: valid first-position context returns no errors", (t) => { + const docs = parseDocs( + dedent` + /*** + * @context synced + */ + /*** + * @function Foo + */ + `, + "ok.cpp" + ); + + const entries: [string, Doc[]][] = [["ok.cpp", docs]]; + const errors = applyFileContexts(entries); + + t.equal(errors.length, 0, "no errors"); + t.equal(entries[0][1].length, 1, "standalone doc removed"); + t.deepEqual(getDocContexts(entries[0][1][0]), ["synced"]); + t.end(); +}); + // --- collectAllContexts --- test("collectAllContexts: gathers from all files", (t) => { @@ -300,6 +373,73 @@ test("partitionDocsByContext: mixed docs across files", (t) => { t.end(); }); +test("partitionDocsByContext: 3-context combinatorics (a, b, c)", (t) => { + const docs = parseDocs( + dedent` + /*** + * @function OnlyA + * @context a + */ + /*** + * @function OnlyB + * @context b + */ + /*** + * @function OnlyC + * @context c + */ + /*** + * @function AB + * @context a, b + */ + /*** + * @function BC + * @context b, c + */ + /*** + * @function AC + * @context a, c + */ + /*** + * @function ABC + * @context a, b, c + */ + /*** + * @function NoCtx + */ + `, + "combo.cpp" + ); + + const all = new Set(["a", "b", "c"]); + const buckets = partitionDocsByContext([["combo.cpp", docs]], all); + + const getName = (bucket: string) => + buckets + .get(bucket) + ?.flatMap(([, d]) => d) + .map((d) => (d.attributes.find((a) => a.attributeType === "function") as any).args.name.join(".")) ?? []; + + t.deepEqual(getName("a"), ["OnlyA"], "single context a"); + t.deepEqual(getName("b"), ["OnlyB"], "single context b"); + t.deepEqual(getName("c"), ["OnlyC"], "single context c"); + t.deepEqual(getName("a_b"), ["AB"], "pair a+b is a_b, not shared"); + t.deepEqual(getName("b_c"), ["BC"], "pair b+c is b_c, not shared"); + t.deepEqual(getName("a_c"), ["AC"], "pair a+c is a_c, not shared"); + + const sharedNames = getName("shared"); + t.ok(sharedNames.includes("ABC"), "all contexts -> shared"); + t.ok(sharedNames.includes("NoCtx"), "no context -> shared"); + t.equal(sharedNames.length, 2, "only ABC and NoCtx in shared"); + + t.deepEqual( + [...buckets.keys()].sort(), + ["a", "a_b", "a_c", "b", "b_c", "c", "shared"], + "exactly 7 buckets" + ); + t.end(); +}); + // --- @context stripped from output --- testInput( From 100aef09ac700516ca3bfda01057b17fd2023d78 Mon Sep 17 00:00:00 2001 From: Daniel Harvey Date: Thu, 26 Mar 2026 12:49:06 -0600 Subject: [PATCH 4/7] fix shared bug --- src/context.ts | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/src/context.ts b/src/context.ts index 2662c07..4ed919b 100644 --- a/src/context.ts +++ b/src/context.ts @@ -290,28 +290,12 @@ export function projectContextOutputs( }); } - const sharedPreamble = generateClassDeclarations(tableBuckets, "shared"); - if (sharedPreamble) { + if (sharedRawEntries.length > 0) { outputs.push({ name: "shared.lua", - docs: [], - sources: [], - preamble: sharedPreamble, - }); - } - - for (const [path, rawDocs] of sharedRawEntries) { - if (rawDocs.length === 0) continue; - const docs = rawDocs.map((d) => structuredClone(d)); - for (const [table, bucketSet] of tableBuckets) { - if (bucketSet.size < 2) continue; - remapDocTableNames(docs, table, table + bucketSuffix("shared")); - } - outputs.push({ - name: `${resolveOutputName(path)}.lua`, - docs, - sources: [path], - preamble: "", + docs: cloneAndRemapDocs(sharedRawEntries, tableBuckets, "shared"), + sources: sharedRawEntries.map(([p]) => p), + preamble: generateClassDeclarations(tableBuckets, "shared"), }); } From 119cef7208df9fa2b4e0056c2979d7555f56ccef Mon Sep 17 00:00:00 2001 From: Daniel Harvey Date: Mon, 30 Mar 2026 11:12:53 -0600 Subject: [PATCH 5/7] no inheritance for shared contexts Realized that my AST was producing Shared for all shared functions, and that was accuate. We dont want devs to have to memorize whats in SpringShared and whats in SpringSynced, we want callers to be unambiguous and 1:1 --- src/context.ts | 9 +-------- src/test/context.test.ts | 13 ++++++++----- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/context.ts b/src/context.ts index 4ed919b..2ad4fcb 100644 --- a/src/context.ts +++ b/src/context.ts @@ -227,14 +227,7 @@ export function generateClassDeclarations( for (const [table, buckets] of tableBuckets) { if (buckets.size < 2) continue; const className = `${table}${suffix}`; - if (bucketName === "shared") { - lines.push(`---@class ${className}\n${className} = {}`); - } else { - const sharedClass = `${table}${bucketSuffix("shared")}`; - lines.push( - `---@class ${className} : ${sharedClass}\n${className} = {}` - ); - } + lines.push(`---@class ${className}\n${className} = {}`); } return lines.join("\n\n"); diff --git a/src/test/context.test.ts b/src/test/context.test.ts index f28b819..51518ba 100644 --- a/src/test/context.test.ts +++ b/src/test/context.test.ts @@ -690,13 +690,14 @@ test("generateClassDeclarations: shared bucket gets base class", (t) => { t.end(); }); -test("generateClassDeclarations: non-shared bucket inherits from shared", (t) => { +test("generateClassDeclarations: non-shared bucket gets independent class", (t) => { const tableBuckets = new Map([ ["Spring", new Set(["synced", "unsynced", "shared"])], ]); const result = generateClassDeclarations(tableBuckets, "synced"); - t.ok(result.includes("---@class SpringSynced : SpringShared")); + t.ok(result.includes("---@class SpringSynced")); t.ok(result.includes("SpringSynced = {}")); + t.ok(!result.includes(":"), "no inheritance for non-shared"); t.end(); }); @@ -717,8 +718,9 @@ test("generateClassDeclarations: multiple multi-context tables", (t) => { ["Game", new Set(["synced", "shared"])], ]); const result = generateClassDeclarations(tableBuckets, "synced"); - t.ok(result.includes("---@class SpringSynced : SpringShared")); - t.ok(result.includes("---@class GameSynced : GameShared")); + t.ok(result.includes("---@class SpringSynced")); + t.ok(result.includes("---@class GameSynced")); + t.ok(!result.includes(":"), "no inheritance"); t.end(); }); @@ -821,7 +823,8 @@ test("end-to-end: context projection remaps multi-bucket tables with class inher ); const syncedPreamble = generateClassDeclarations(tableBuckets, "synced"); - t.ok(syncedPreamble.includes("---@class SpringSynced : SpringShared")); + t.ok(syncedPreamble.includes("---@class SpringSynced")); + t.ok(!syncedPreamble.includes(":"), "no inheritance for synced"); t.ok(!syncedPreamble.includes("VFS"), "VFS not in class declarations"); const sharedPreamble = generateClassDeclarations(tableBuckets, "shared"); From 65b71fd73cdc176994160da492d5a1c7ccf05e8a Mon Sep 17 00:00:00 2001 From: Daniel Harvey Date: Wed, 15 Apr 2026 12:27:45 -0600 Subject: [PATCH 6/7] refactor: group outputs by @function table prefix, drop @context indirection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the extractor partitioned output files via a file-level @context tag and rewrote Spring.X → SpringShared/SpringSynced/ SpringUnsynced.X at emit time. Now that every Recoil binding authors its table prefix directly (e.g. @function SpringSynced.X), the remap and partition steps are no longer load-bearing — doc comments can serve as the single source of truth for bucketing. Delete: partitionDocsByContext, remapDocTableNames, promoteMultiContextDocs, findMultiContextTables, generateClassDeclarations, applyFileContexts. Add: projectOutputs (group by literal table prefix in @function) and lintDuplicateDeclarations (error on the same @function name declared across multiple files, replacing the previous auto-promote- to-shared magic). removeContextAttributes stays as a defensive no-op stripper in case a stray @context sneaks in. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli.ts | 14 +- src/context.ts | 345 +++++---------- src/test/context.test.ts | 875 ++++++++------------------------------- 3 files changed, 291 insertions(+), 943 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index fe7effa..070c736 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,14 +6,10 @@ import commandLineUsage from "command-line-usage"; import dedent from "dedent-js"; import { mkdir, readFile, writeFile } from "fs/promises"; import { glob } from "glob"; -import { dirname, join, relative } from "path"; -import { cwd } from "process"; +import { dirname, join } from "path"; import { addHeader, formatDocs, getDocs, processDocs } from "."; import project from "../package.json"; -import { - applyFileContexts, - projectContextOutputs, -} from "./context"; +import { lintDuplicateDeclarations, projectOutputs } from "./context"; import { Doc } from "./doc"; import { mergeFileOutputs } from "./output"; import { toResultAsync } from "./result"; @@ -199,10 +195,8 @@ async function runAsync() { const valid = processed.filter((e) => e != null) as [string, Doc[]][]; - errors.push(...applyFileContexts(valid)); - let outputs = projectContextOutputs( - valid, (p) => relative(cwd(), p) - ); + errors.push(...lintDuplicateDeclarations(valid)); + let outputs = projectOutputs(valid); if (file !== undefined) { outputs = mergeFileOutputs(outputs, file); } diff --git a/src/context.ts b/src/context.ts index 2ad4fcb..a5960ec 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,43 +1,17 @@ import { - DefaultAttribute, EnumAttribute, FieldAttribute, FunctionAttribute, GlobalAttribute, TableAttribute, } from "./attribute"; -import { Doc, filterAttributes, hasAttribute, removeAttributes } from "./doc"; +import { Doc, removeAttributes } from "./doc"; import { FileOutput } from "./output"; -const KNOWN_DOC_TYPES = [ - "function", - "table", - "class", - "enum", - "global", -] as const; - -const FILE_ATTRIBUTE_TYPES = ["context"] as const; - -function isFileAttributeDoc(doc: Doc): boolean { - return ( - FILE_ATTRIBUTE_TYPES.some((t) => hasAttribute(doc, t)) && - !KNOWN_DOC_TYPES.some((t) => hasAttribute(doc, t)) - ); -} - -export function getDocContexts(doc: Doc): string[] { - const contextAttrs = filterAttributes(doc, "context") as DefaultAttribute[]; - const contexts = new Set(); - for (const attr of contextAttrs) { - for (const part of attr.args.description.split(",")) { - const trimmed = part.trim(); - if (trimmed) contexts.add(trimmed); - } - } - return [...contexts]; -} - +// Defensive no-op stripper: the `@context` tag was removed entirely from the +// grammar of this tool (it used to drive per-file bucket assignment before +// the split-tables-as-primary model). Any stray `@context` that sneaks into +// a doc comment gets silently removed here so it doesn't leak into stubs. export function removeContextAttributes(docs: Doc[]): Doc[] { for (const doc of docs) { removeAttributes(doc, "context"); @@ -45,108 +19,10 @@ export function removeContextAttributes(docs: Doc[]): Doc[] { return docs; } -export function applyFileContexts( - fileEntries: readonly (readonly [string, Doc[]])[] -): string[] { - const errors: string[] = []; - - for (const [path, docs] of fileEntries) { - const fileAttrIndices: number[] = []; - for (let i = 0; i < docs.length; i++) { - if (isFileAttributeDoc(docs[i])) fileAttrIndices.push(i); - } - - if (fileAttrIndices.length === 0) continue; - - if (fileAttrIndices.length > 1) { - errors.push( - `'${path}': multiple file-level attribute docs (found ${fileAttrIndices.length}, expected at most 1)` - ); - continue; - } - - if (fileAttrIndices[0] !== 0) { - errors.push( - `'${path}': file-level attribute doc must be the first doc in the file` - ); - continue; - } - - const fileContexts = getDocContexts(docs[0]); - docs.splice(0, 1); - - for (const doc of docs) { - if (!hasAttribute(doc, "context") && fileContexts.length > 0) { - for (const ctx of fileContexts) { - doc.attributes.push({ - attributeType: "context", - args: { description: ctx }, - }); - } - } - } - } - - return errors; -} - -export function collectAllContexts( - fileEntries: readonly (readonly [string, Doc[]])[] -): Set { - const all = new Set(); - for (const [, docs] of fileEntries) { - for (const doc of docs) { - for (const ctx of getDocContexts(doc)) { - all.add(ctx); - } - } - } - return all; -} - -function contextBucketName( - docContexts: string[], - allContexts: Set -): string { - if (docContexts.length === 0) return "shared"; - - const sorted = [...new Set(docContexts)].sort(); - if (sorted.length === allContexts.size) { - const allSorted = [...allContexts].sort(); - if (sorted.every((c, i) => c === allSorted[i])) return "shared"; - } - - return sorted.join("_"); -} - -export function partitionDocsByContext( - fileEntries: readonly (readonly [string, Doc[]])[], - allContexts: Set -): Map { - const buckets = new Map(); - - for (const [path, docs] of fileEntries) { - for (const doc of docs) { - const contexts = getDocContexts(doc); - const bucket = contextBucketName(contexts, allContexts); - - if (!buckets.has(bucket)) { - buckets.set(bucket, []); - } - - const entries = buckets.get(bucket)!; - let fileEntry = entries.find(([p]) => p === path); - if (!fileEntry) { - fileEntry = [path, []]; - entries.push(fileEntry); - } - fileEntry[1].push(doc); - } - } - - return buckets; -} - +// Each doc that declares a table method has a qualified name like +// `SpringSynced.GiveOrderToUnit` — the first identifier is the "table name", +// the rest is the method path. Used both for output-file grouping and for +// duplicate-declaration linting. const NAME_ATTR_TYPES = [ "function", "table", @@ -175,122 +51,131 @@ export function getDocTableName(doc: Doc): string | null { return null; } -export function findMultiContextTables( - buckets: Map -): Map> { - const tableBuckets = new Map>(); - for (const [bucketName, entries] of buckets) { - for (const [, docs] of entries) { - for (const doc of docs) { - const table = getDocTableName(doc); - if (table == null) continue; - if (!tableBuckets.has(table)) tableBuckets.set(table, new Set()); - tableBuckets.get(table)!.add(bucketName); - } - } +function getDocQualifiedName(doc: Doc): string | null { + for (const attr of doc.attributes) { + if (!NAME_ATTR_TYPES.includes(attr.attributeType as any)) continue; + const name = (attr.args as { name?: readonly string[] }).name; + if (name && name.length > 0) return name.join("."); } - return tableBuckets; -} - -export function bucketSuffix(bucketName: string): string { - return bucketName - .split("_") - .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) - .join(""); + return null; } -export function remapDocTableNames( - docs: Doc[], - tableName: string, - newTableName: string -): void { - for (const doc of docs) { - for (const attr of doc.attributes) { - if (!NAME_ATTR_TYPES.includes(attr.attributeType as any)) continue; - const args = attr.args as { name: readonly string[] }; - if (args.name[0] !== tableName) continue; - (args as { name: string[] }).name = [ - newTableName, - ...args.name.slice(1), - ]; - } +// Authors declare Spring API methods under one of three top-level tables; +// each maps to its own output stub file. Tables outside this set (MoveCtrl, +// UnitScript, etc.) fall through to `shared.lua` — they're accessible in +// every Lua context. +const SPRING_OUTPUTS: ReadonlyMap = + new Map([ + [ + "SpringShared", + { + file: "shared.lua", + preamble: "---@class SpringShared\nSpringShared = {}", + }, + ], + [ + "SpringSynced", + { + file: "synced.lua", + preamble: "---@class SpringSynced\nSpringSynced = {}", + }, + ], + [ + "SpringUnsynced", + { + file: "unsynced.lua", + preamble: "---@class SpringUnsynced\nSpringUnsynced = {}", + }, + ], + ]); + +const FALLBACK_OUTPUT = "shared.lua"; + +function outputFileFor(doc: Doc): string { + const table = getDocTableName(doc); + if (table != null) { + const entry = SPRING_OUTPUTS.get(table); + if (entry) return entry.file; } + return FALLBACK_OUTPUT; } -export function generateClassDeclarations( - tableBuckets: Map>, - bucketName: string -): string { - const suffix = bucketSuffix(bucketName); - const lines: string[] = []; +// Lint pass over all input docs: flag any `@function Table.Name` declared in +// more than one file. In the split-tables-as-primary model these collisions +// are authoring bugs — the extractor used to auto-dedup them via a "promote +// to shared" step, but that magic is gone; duplicates must be consolidated +// by hand at the source. +export function lintDuplicateDeclarations( + fileEntries: readonly (readonly [string, Doc[]])[] +): string[] { + const errors: string[] = []; + const firstSeen = new Map(); - for (const [table, buckets] of tableBuckets) { - if (buckets.size < 2) continue; - const className = `${table}${suffix}`; - lines.push(`---@class ${className}\n${className} = {}`); + for (const [path, docs] of fileEntries) { + for (const doc of docs) { + // Only flag function-attribute duplicates — class/table/enum/global can + // legitimately appear in multiple files as repeated declarations. + const hasFunction = doc.attributes.some( + (a) => a.attributeType === "function" + ); + if (!hasFunction) continue; + + const qual = getDocQualifiedName(doc); + if (qual == null) continue; + + const prev = firstSeen.get(qual); + if (prev != null && prev !== path) { + errors.push( + `'${path}': duplicate @function ${qual} (also declared in '${prev}')` + ); + } else if (prev == null) { + firstSeen.set(qual, path); + } + } } - return lines.join("\n\n"); -} - -function cloneAndRemapDocs( - entries: [string, Doc[]][], - tableBuckets: Map>, - bucketName: string -): Doc[] { - const docs = entries.flatMap(([, ds]) => ds).map((d) => structuredClone(d)); - for (const [table, bucketSet] of tableBuckets) { - if (bucketSet.size < 2) continue; - remapDocTableNames(docs, table, table + bucketSuffix(bucketName)); - } - return docs; + return errors; } -export function projectContextOutputs( - fileEntries: readonly (readonly [string, Doc[]])[], - resolveOutputName: (sourcePath: string) => string, +// Group all docs into output files based on the table prefix of each doc's +// declaration. Preamble is synthesized for the three Spring* outputs; for +// any other table (MoveCtrl et al.), the author is expected to provide a +// `@class` declaration in the source. +export function projectOutputs( + fileEntries: readonly (readonly [string, Doc[]])[] ): FileOutput[] { - const allContexts = collectAllContexts(fileEntries); + const byOutput = new Map< + string, + { docs: Doc[]; sources: Set; preambleParts: Set } + >(); - if (allContexts.size === 0) { - const outputs: FileOutput[] = []; - for (const [path, docs] of fileEntries) { - if (docs.length === 0) continue; - outputs.push({ - name: `${resolveOutputName(path)}.lua`, - docs: [...docs], - sources: [path], - preamble: "", - }); + for (const [path, docs] of fileEntries) { + for (const doc of docs) { + const outFile = outputFileFor(doc); + let entry = byOutput.get(outFile); + if (!entry) { + entry = { docs: [], sources: new Set(), preambleParts: new Set() }; + byOutput.set(outFile, entry); + } + entry.docs.push(doc); + entry.sources.add(path); + + const table = getDocTableName(doc); + if (table != null) { + const springPreamble = SPRING_OUTPUTS.get(table)?.preamble; + if (springPreamble != null) entry.preambleParts.add(springPreamble); + } } - return outputs; } - const buckets = partitionDocsByContext(fileEntries, allContexts); - const tableBuckets = findMultiContextTables(buckets); - - const sharedRawEntries = buckets.get("shared") ?? []; - buckets.delete("shared"); - const outputs: FileOutput[] = []; - - for (const [name, entries] of buckets) { + for (const [name, { docs, sources, preambleParts }] of byOutput) { outputs.push({ - name: `${name}.lua`, - docs: cloneAndRemapDocs(entries, tableBuckets, name), - sources: entries.map(([p]) => p), - preamble: generateClassDeclarations(tableBuckets, name), + name, + docs, + sources: [...sources], + preamble: [...preambleParts].join("\n\n"), }); } - - if (sharedRawEntries.length > 0) { - outputs.push({ - name: "shared.lua", - docs: cloneAndRemapDocs(sharedRawEntries, tableBuckets, "shared"), - sources: sharedRawEntries.map(([p]) => p), - preamble: generateClassDeclarations(tableBuckets, "shared"), - }); - } - return outputs; } diff --git a/src/test/context.test.ts b/src/test/context.test.ts index 51518ba..7bae0f8 100644 --- a/src/test/context.test.ts +++ b/src/test/context.test.ts @@ -1,19 +1,13 @@ import dedent from "dedent-js"; import test from "tape"; -import { formatDocs, getDocs, processDocs } from ".."; +import { getDocs } from ".."; import { - applyFileContexts, - bucketSuffix, - collectAllContexts, - findMultiContextTables, - generateClassDeclarations, - getDocContexts, getDocTableName, - partitionDocsByContext, - remapDocTableNames, + lintDuplicateDeclarations, + projectOutputs, + removeContextAttributes, } from "../context"; import { Doc } from "../doc"; -import { testInput } from "./utility/harness"; function parseDocs(input: string, path = "test.cpp"): Doc[] { const [result, err] = getDocs(input, path); @@ -21,815 +15,290 @@ function parseDocs(input: string, path = "test.cpp"): Doc[] { return result[0]; } -// --- getDocContexts --- - -test("getDocContexts: single context", (t) => { - const docs = parseDocs(dedent` - /*** - * @function Foo - * @context synced - */ - `); - t.deepEqual(getDocContexts(docs[0]), ["synced"]); - t.end(); -}); - -test("getDocContexts: comma-separated contexts", (t) => { - const docs = parseDocs(dedent` - /*** - * @function Foo - * @context synced, unsynced - */ - `); - t.deepEqual(getDocContexts(docs[0]), ["synced", "unsynced"]); - t.end(); -}); - -test("getDocContexts: multiple @context tags", (t) => { - const docs = parseDocs(dedent` - /*** - * @function Foo - * @context synced - * @context unsynced - */ - `); - t.deepEqual(getDocContexts(docs[0]), ["synced", "unsynced"]); - t.end(); -}); +// --- getDocTableName --- -test("getDocContexts: deduplicated across tags and commas", (t) => { +test("getDocTableName: function with table prefix", (t) => { const docs = parseDocs(dedent` /*** - * @function Foo - * @context synced, unsynced - * @context synced + * @function Spring.Foo */ `); - t.deepEqual(getDocContexts(docs[0]), ["synced", "unsynced"]); + t.equal(getDocTableName(docs[0]), "Spring"); t.end(); }); -test("getDocContexts: no context returns empty", (t) => { +test("getDocTableName: bare function returns null", (t) => { const docs = parseDocs(dedent` /*** * @function Foo */ `); - t.deepEqual(getDocContexts(docs[0]), []); - t.end(); -}); - -// --- applyFileContexts --- - -test("applyFileContexts: standalone context applies to all docs in file", (t) => { - const docs = parseDocs( - dedent` - /*** - * @context synced - */ - /*** - * @function Foo - */ - /*** - * @function Bar - */ - `, - "test.cpp" - ); - - const entries: [string, Doc[]][] = [["test.cpp", docs]]; - applyFileContexts(entries); - - const [, fileDocs] = entries[0]; - t.equal(fileDocs.length, 2, "standalone doc removed"); - t.deepEqual(getDocContexts(fileDocs[0]), ["synced"], "Foo gets synced"); - t.deepEqual(getDocContexts(fileDocs[1]), ["synced"], "Bar gets synced"); - t.end(); -}); - -test("applyFileContexts: per-function context overrides file-level", (t) => { - const docs = parseDocs( - dedent` - /*** - * @context synced - */ - /*** - * @function Foo - * @context unsynced - */ - /*** - * @function Bar - */ - `, - "test.cpp" - ); - - const entries: [string, Doc[]][] = [["test.cpp", docs]]; - applyFileContexts(entries); - - const [, fileDocs] = entries[0]; - t.deepEqual( - getDocContexts(fileDocs[0]), - ["unsynced"], - "Foo keeps its own context" - ); - t.deepEqual( - getDocContexts(fileDocs[1]), - ["synced"], - "Bar inherits file-level" - ); + t.equal(getDocTableName(docs[0]), null); t.end(); }); -test("applyFileContexts: no standalone context is a no-op", (t) => { +test("getDocTableName: SpringSynced qualified function", (t) => { const docs = parseDocs(dedent` /*** - * @function Foo - * @context synced + * @function SpringSynced.GiveOrderToUnit */ `); - const entries: [string, Doc[]][] = [["test.cpp", docs]]; - const errors = applyFileContexts(entries); - - t.equal(errors.length, 0, "no errors"); - const [, fileDocs] = entries[0]; - t.equal(fileDocs.length, 1); - t.deepEqual(getDocContexts(fileDocs[0]), ["synced"]); - t.end(); -}); - -test("applyFileContexts: errors on duplicate file-level context docs", (t) => { - const docs = parseDocs( - dedent` - /*** - * @context synced - */ - /*** - * @context unsynced - */ - /*** - * @function Foo - */ - `, - "dup.cpp" - ); - - const entries: [string, Doc[]][] = [["dup.cpp", docs]]; - const errors = applyFileContexts(entries); - - t.equal(errors.length, 1, "one error returned"); - t.ok(errors[0].includes("multiple file-level attribute docs"), "error mentions duplicates"); - t.equal(entries[0][1].length, 3, "docs unchanged on error"); + t.equal(getDocTableName(docs[0]), "SpringSynced"); t.end(); }); -test("applyFileContexts: errors when file-level context is not first", (t) => { - const docs = parseDocs( - dedent` - /*** - * @function Foo - */ - /*** - * @context synced - */ - /*** - * @function Bar - */ - `, - "late.cpp" - ); - - const entries: [string, Doc[]][] = [["late.cpp", docs]]; - const errors = applyFileContexts(entries); - - t.equal(errors.length, 1, "one error returned"); - t.ok(errors[0].includes("must be the first doc"), "error mentions position"); - t.equal(entries[0][1].length, 3, "docs unchanged on error"); - t.end(); -}); - -test("applyFileContexts: valid first-position context returns no errors", (t) => { - const docs = parseDocs( - dedent` - /*** - * @context synced - */ - /*** - * @function Foo - */ - `, - "ok.cpp" - ); - - const entries: [string, Doc[]][] = [["ok.cpp", docs]]; - const errors = applyFileContexts(entries); - - t.equal(errors.length, 0, "no errors"); - t.equal(entries[0][1].length, 1, "standalone doc removed"); - t.deepEqual(getDocContexts(entries[0][1][0]), ["synced"]); - t.end(); -}); - -// --- collectAllContexts --- - -test("collectAllContexts: gathers from all files", (t) => { - const docsA = parseDocs(dedent` - /*** - * @function Foo - * @context synced - */ - `); - const docsB = parseDocs(dedent` - /*** - * @function Bar - * @context unsynced - */ - `); - const entries: [string, Doc[]][] = [ - ["a.cpp", docsA], - ["b.cpp", docsB], - ]; - - const all = collectAllContexts(entries); - t.deepEqual([...all].sort(), ["synced", "unsynced"]); - t.end(); -}); - -test("collectAllContexts: empty when no contexts", (t) => { +test("getDocTableName: table declaration", (t) => { const docs = parseDocs(dedent` /*** - * @function Foo + * @table MoveCtrl */ `); - const all = collectAllContexts([["a.cpp", docs]]); - t.equal(all.size, 0); + t.equal(getDocTableName(docs[0]), "MoveCtrl"); t.end(); }); -// --- partitionDocsByContext --- +// --- projectOutputs: Spring-bucket routing --- -test("partitionDocsByContext: single context goes to named bucket", (t) => { +test("projectOutputs: SpringShared goes to shared.lua", (t) => { const docs = parseDocs(dedent` /*** - * @function Foo - * @context synced + * @function SpringShared.Echo */ `); - const all = new Set(["synced", "unsynced"]); - const buckets = partitionDocsByContext([["a.cpp", docs]], all); - - const syncedDocs = buckets.get("synced")!.flatMap(([, d]) => d); - t.equal(syncedDocs.length, 1); + const outputs = projectOutputs([["a.cpp", docs]]); + t.equal(outputs.length, 1); + t.equal(outputs[0].name, "shared.lua"); + t.ok(outputs[0].preamble.includes("---@class SpringShared")); t.end(); }); -test("partitionDocsByContext: no context goes to shared", (t) => { +test("projectOutputs: SpringSynced goes to synced.lua", (t) => { const docs = parseDocs(dedent` /*** - * @function Foo + * @function SpringSynced.GiveOrderToUnit */ `); - const all = new Set(["synced", "unsynced"]); - const buckets = partitionDocsByContext([["a.cpp", docs]], all); - - const sharedDocs = buckets.get("shared")!.flatMap(([, d]) => d); - t.equal(sharedDocs.length, 1); + const outputs = projectOutputs([["a.cpp", docs]]); + t.equal(outputs.length, 1); + t.equal(outputs[0].name, "synced.lua"); + t.ok(outputs[0].preamble.includes("---@class SpringSynced")); t.end(); }); -test("partitionDocsByContext: all contexts goes to shared", (t) => { +test("projectOutputs: SpringUnsynced goes to unsynced.lua", (t) => { const docs = parseDocs(dedent` /*** - * @function Foo - * @context synced, unsynced + * @function SpringUnsynced.GetMouseState */ `); - const all = new Set(["synced", "unsynced"]); - const buckets = partitionDocsByContext([["a.cpp", docs]], all); - - const sharedDocs = buckets.get("shared")!.flatMap(([, d]) => d); - t.equal(sharedDocs.length, 1); + const outputs = projectOutputs([["a.cpp", docs]]); + t.equal(outputs.length, 1); + t.equal(outputs[0].name, "unsynced.lua"); + t.ok(outputs[0].preamble.includes("---@class SpringUnsynced")); t.end(); }); -test("partitionDocsByContext: strict subset gets combined name", (t) => { +test("projectOutputs: non-Spring tables fall through to shared.lua", (t) => { const docs = parseDocs(dedent` /*** - * @function Foo - * @context synced, unsynced + * @function MoveCtrl.Enable */ `); - const all = new Set(["synced", "unsynced", "widget"]); - const buckets = partitionDocsByContext([["a.cpp", docs]], all); - - const combinedDocs = buckets.get("synced_unsynced")!.flatMap(([, d]) => d); - t.equal(combinedDocs.length, 1); + const outputs = projectOutputs([["a.cpp", docs]]); + t.equal(outputs.length, 1); + t.equal(outputs[0].name, "shared.lua"); + // MoveCtrl's @class is authored separately; no preamble synthesized. + t.notOk(outputs[0].preamble.includes("MoveCtrl")); t.end(); }); -test("partitionDocsByContext: mixed docs across files", (t) => { +test("projectOutputs: mixed Spring buckets produce three files", (t) => { const docsA = parseDocs( dedent` - /*** - * @function Foo - * @context synced - */ - /*** - * @function Bar - */ - `, + /*** + * @function SpringSynced.GiveOrderToUnit + */ + `, "a.cpp" ); const docsB = parseDocs( dedent` - /*** - * @function Baz - * @context unsynced - */ - `, + /*** + * @function SpringUnsynced.GetMouseState + */ + `, "b.cpp" ); - - const all = new Set(["synced", "unsynced"]); - const buckets = partitionDocsByContext( - [ - ["a.cpp", docsA], - ["b.cpp", docsB], - ], - all + const docsC = parseDocs( + dedent` + /*** + * @function SpringShared.GetUnitPosition + */ + `, + "c.cpp" ); - - t.ok(buckets.has("synced"), "has synced bucket"); - t.ok(buckets.has("unsynced"), "has unsynced bucket"); - t.ok(buckets.has("shared"), "has shared bucket"); - - const syncedDocs = buckets.get("synced")!.flatMap(([, d]) => d); - const unsyncedDocs = buckets.get("unsynced")!.flatMap(([, d]) => d); - const sharedDocs = buckets.get("shared")!.flatMap(([, d]) => d); - - t.equal(syncedDocs.length, 1, "one synced doc"); - t.equal(unsyncedDocs.length, 1, "one unsynced doc"); - t.equal(sharedDocs.length, 1, "one shared doc"); + const outputs = projectOutputs([ + ["a.cpp", docsA], + ["b.cpp", docsB], + ["c.cpp", docsC], + ]); + const names = outputs.map((o) => o.name).sort(); + t.deepEqual(names, ["shared.lua", "synced.lua", "unsynced.lua"]); t.end(); }); -test("partitionDocsByContext: 3-context combinatorics (a, b, c)", (t) => { - const docs = parseDocs( +test("projectOutputs: same bucket across files merges into one output", (t) => { + const docsA = parseDocs( dedent` - /*** - * @function OnlyA - * @context a - */ - /*** - * @function OnlyB - * @context b - */ - /*** - * @function OnlyC - * @context c - */ - /*** - * @function AB - * @context a, b - */ - /*** - * @function BC - * @context b, c - */ - /*** - * @function AC - * @context a, c - */ - /*** - * @function ABC - * @context a, b, c - */ - /*** - * @function NoCtx - */ - `, - "combo.cpp" + /*** + * @function SpringSynced.Foo + */ + `, + "a.cpp" ); - - const all = new Set(["a", "b", "c"]); - const buckets = partitionDocsByContext([["combo.cpp", docs]], all); - - const getName = (bucket: string) => - buckets - .get(bucket) - ?.flatMap(([, d]) => d) - .map((d) => (d.attributes.find((a) => a.attributeType === "function") as any).args.name.join(".")) ?? []; - - t.deepEqual(getName("a"), ["OnlyA"], "single context a"); - t.deepEqual(getName("b"), ["OnlyB"], "single context b"); - t.deepEqual(getName("c"), ["OnlyC"], "single context c"); - t.deepEqual(getName("a_b"), ["AB"], "pair a+b is a_b, not shared"); - t.deepEqual(getName("b_c"), ["BC"], "pair b+c is b_c, not shared"); - t.deepEqual(getName("a_c"), ["AC"], "pair a+c is a_c, not shared"); - - const sharedNames = getName("shared"); - t.ok(sharedNames.includes("ABC"), "all contexts -> shared"); - t.ok(sharedNames.includes("NoCtx"), "no context -> shared"); - t.equal(sharedNames.length, 2, "only ABC and NoCtx in shared"); - - t.deepEqual( - [...buckets.keys()].sort(), - ["a", "a_b", "a_c", "b", "b_c", "c", "shared"], - "exactly 7 buckets" + const docsB = parseDocs( + dedent` + /*** + * @function SpringSynced.Bar + */ + `, + "b.cpp" ); + const outputs = projectOutputs([ + ["a.cpp", docsA], + ["b.cpp", docsB], + ]); + t.equal(outputs.length, 1); + t.equal(outputs[0].name, "synced.lua"); + t.equal(outputs[0].docs.length, 2); + t.deepEqual(outputs[0].sources.sort(), ["a.cpp", "b.cpp"]); t.end(); }); -// --- @context stripped from output --- - -testInput( - "context attribute stripped from output", - dedent` - /*** - * Does stuff. - * - * @function Spring.Foo - * @context synced - * @param x integer - */ - `, - dedent` - ---Does stuff. - --- - ---@param x integer - function Spring.Foo(x) end - ` -); - -testInput( - "multiple context attributes stripped from output", - dedent` - /*** - * Does stuff. - * - * @function Spring.Foo - * @context synced - * @context unsynced - * @param x integer - */ - `, - dedent` - ---Does stuff. - --- - ---@param x integer - function Spring.Foo(x) end - ` -); - -testInput( - "comma-separated context stripped from output", - dedent` - /*** - * Does stuff. - * - * @function Spring.Foo - * @context synced, unsynced - */ - `, - dedent` - ---Does stuff. - function Spring.Foo() end - ` -); - -// --- getDocTableName --- - -test("getDocTableName: function with table prefix", (t) => { +test("projectOutputs: preamble dedup (one @class per bucket even across docs)", (t) => { const docs = parseDocs(dedent` /*** - * @function Spring.Foo + * @function SpringSynced.Foo */ - `); - t.equal(getDocTableName(docs[0]), "Spring"); - t.end(); -}); -test("getDocTableName: bare function returns null", (t) => { - const docs = parseDocs(dedent` /*** - * @function Foo + * @function SpringSynced.Bar */ `); - t.equal(getDocTableName(docs[0]), null); + const outputs = projectOutputs([["a.cpp", docs]]); + t.equal(outputs.length, 1); + // `---@class SpringSynced` should appear once in the preamble, not twice. + const matches = outputs[0].preamble.match(/---@class SpringSynced/g) ?? []; + t.equal(matches.length, 1); t.end(); }); -test("getDocTableName: table declaration", (t) => { - const docs = parseDocs(dedent` - /*** - * @table Spring - */ - `); - t.equal(getDocTableName(docs[0]), "Spring"); - t.end(); -}); +// --- lintDuplicateDeclarations --- -test("getDocTableName: no relevant attribute", (t) => { - const docs = parseDocs(dedent` - /*** - * Just a description. - * @context synced - */ - `); - t.equal(getDocTableName(docs[0]), null); - t.end(); -}); - -// --- bucketSuffix --- - -test("bucketSuffix: single word", (t) => { - t.equal(bucketSuffix("synced"), "Synced"); - t.end(); -}); - -test("bucketSuffix: compound name", (t) => { - t.equal(bucketSuffix("synced_unsynced"), "SyncedUnsynced"); - t.end(); -}); - -test("bucketSuffix: shared", (t) => { - t.equal(bucketSuffix("shared"), "Shared"); - t.end(); -}); - -// --- findMultiContextTables --- - -test("findMultiContextTables: table spanning two buckets", (t) => { - const docsA = parseDocs(dedent` - /*** - * @function Spring.Foo - * @context synced - */ - `); - const docsB = parseDocs(dedent` - /*** - * @function Spring.Bar - * @context unsynced - */ - `); - const allContexts = new Set(["synced", "unsynced"]); - const buckets = partitionDocsByContext( - [ - ["a.cpp", docsA], - ["b.cpp", docsB], - ], - allContexts +test("lintDuplicateDeclarations: flags same @function in two files", (t) => { + const docsA = parseDocs( + dedent` + /*** + * @function SpringSynced.Foo + */ + `, + "a.cpp" ); - - const tableBuckets = findMultiContextTables(buckets); - t.ok(tableBuckets.has("Spring")); - t.deepEqual([...tableBuckets.get("Spring")!].sort(), ["synced", "unsynced"]); - t.end(); -}); - -test("findMultiContextTables: single-bucket table not flagged as multi", (t) => { - const docs = parseDocs(dedent` - /*** - * @function VFS.LoadFile - * @context synced, unsynced - */ - `); - const allContexts = new Set(["synced", "unsynced"]); - const buckets = partitionDocsByContext([["a.cpp", docs]], allContexts); - - const tableBuckets = findMultiContextTables(buckets); - t.ok(tableBuckets.has("VFS")); - t.equal(tableBuckets.get("VFS")!.size, 1, "VFS only in shared bucket"); - t.end(); -}); - -test("findMultiContextTables: mixed multi and single-bucket tables", (t) => { - const docs = parseDocs(dedent` - /*** - * @function Spring.Foo - * @context synced - */ - /*** - * @function Spring.Bar - * @context synced, unsynced - */ - /*** - * @function VFS.LoadFile - * @context synced, unsynced - */ - `); - const allContexts = new Set(["synced", "unsynced"]); - const buckets = partitionDocsByContext([["a.cpp", docs]], allContexts); - const tableBuckets = findMultiContextTables(buckets); - - t.equal(tableBuckets.get("Spring")!.size, 2, "Spring spans synced + shared"); - t.equal(tableBuckets.get("VFS")!.size, 1, "VFS only in shared"); - t.end(); -}); - -// --- remapDocTableNames --- - -test("remapDocTableNames: renames function table prefix", (t) => { - const docs = parseDocs(dedent` - /*** - * @function Spring.Foo - */ - `); - remapDocTableNames(docs, "Spring", "SpringSynced"); - const attr = docs[0].attributes.find((a) => a.attributeType === "function"); - t.deepEqual((attr as any).args.name, ["SpringSynced", "Foo"]); - t.end(); -}); - -test("remapDocTableNames: nested name preserves inner segments", (t) => { - const docs = parseDocs(dedent` - /*** - * @function Spring.MoveCtrl.Enable - */ - `); - remapDocTableNames(docs, "Spring", "SpringSynced"); - const attr = docs[0].attributes.find((a) => a.attributeType === "function"); - t.deepEqual((attr as any).args.name, ["SpringSynced", "MoveCtrl", "Enable"]); + const docsB = parseDocs( + dedent` + /*** + * @function SpringSynced.Foo + */ + `, + "b.cpp" + ); + const errors = lintDuplicateDeclarations([ + ["a.cpp", docsA], + ["b.cpp", docsB], + ]); + t.equal(errors.length, 1); + t.ok(errors[0].includes("SpringSynced.Foo")); + t.ok(errors[0].includes("a.cpp")); + t.ok(errors[0].includes("b.cpp")); t.end(); }); -test("remapDocTableNames: non-matching table not touched", (t) => { +test("lintDuplicateDeclarations: ignores same @function duplicated in one file", (t) => { const docs = parseDocs(dedent` /*** - * @function VFS.LoadFile + * @function SpringSynced.Foo */ - `); - remapDocTableNames(docs, "Spring", "SpringSynced"); - const attr = docs[0].attributes.find((a) => a.attributeType === "function"); - t.deepEqual((attr as any).args.name, ["VFS", "LoadFile"]); - t.end(); -}); -test("remapDocTableNames: table declaration renamed", (t) => { - const docs = parseDocs(dedent` /*** - * @table Spring + * @function SpringSynced.Foo */ `); - remapDocTableNames(docs, "Spring", "SpringSynced"); - const attr = docs[0].attributes.find((a) => a.attributeType === "table"); - t.deepEqual((attr as any).args.name, ["SpringSynced"]); - t.end(); -}); - -// --- generateClassDeclarations --- - -test("generateClassDeclarations: shared bucket gets base class", (t) => { - const tableBuckets = new Map([ - ["Spring", new Set(["synced", "unsynced", "shared"])], - ]); - const result = generateClassDeclarations(tableBuckets, "shared"); - t.ok(result.includes("---@class SpringShared")); - t.ok(result.includes("SpringShared = {}")); - t.ok(!result.includes(":"), "no inheritance for shared"); - t.end(); -}); - -test("generateClassDeclarations: non-shared bucket gets independent class", (t) => { - const tableBuckets = new Map([ - ["Spring", new Set(["synced", "unsynced", "shared"])], - ]); - const result = generateClassDeclarations(tableBuckets, "synced"); - t.ok(result.includes("---@class SpringSynced")); - t.ok(result.includes("SpringSynced = {}")); - t.ok(!result.includes(":"), "no inheritance for non-shared"); + const errors = lintDuplicateDeclarations([["a.cpp", docs]]); + t.equal(errors.length, 0); t.end(); }); -test("generateClassDeclarations: skips single-bucket tables", (t) => { - const tableBuckets = new Map([ - ["Spring", new Set(["synced", "shared"])], - ["VFS", new Set(["shared"])], - ]); - const result = generateClassDeclarations(tableBuckets, "shared"); - t.ok(result.includes("SpringShared"), "Spring remapped"); - t.ok(!result.includes("VFS"), "VFS not remapped"); - t.end(); -}); - -test("generateClassDeclarations: multiple multi-context tables", (t) => { - const tableBuckets = new Map([ - ["Spring", new Set(["synced", "shared"])], - ["Game", new Set(["synced", "shared"])], +test("lintDuplicateDeclarations: ignores non-function duplicates", (t) => { + const docsA = parseDocs( + dedent` + /*** + * @class MoveCtrl + */ + `, + "a.cpp" + ); + const docsB = parseDocs( + dedent` + /*** + * @class MoveCtrl + */ + `, + "b.cpp" + ); + const errors = lintDuplicateDeclarations([ + ["a.cpp", docsA], + ["b.cpp", docsB], ]); - const result = generateClassDeclarations(tableBuckets, "synced"); - t.ok(result.includes("---@class SpringSynced")); - t.ok(result.includes("---@class GameSynced")); - t.ok(!result.includes(":"), "no inheritance"); + t.equal(errors.length, 0); t.end(); }); -// --- Integration: file-level context + processDocs --- - -test("integration: file-level context + processDocs strips @context", (t) => { - const docs = parseDocs( +test("lintDuplicateDeclarations: silent when all names are unique", (t) => { + const docsA = parseDocs( dedent` - /*** - * @context synced - */ - /*** - * Does stuff. - * - * @function Foo - * @param x integer - */ - `, - "test.cpp" + /*** + * @function SpringSynced.Foo + */ + `, + "a.cpp" ); - - const entries: [string, Doc[]][] = [["test.cpp", docs]]; - applyFileContexts(entries); - - const allContexts = collectAllContexts(entries); - t.deepEqual([...allContexts], ["synced"]); - - const [, fileDocs] = entries[0]; - const processed = processDocs(fileDocs, null); - const output = formatDocs(processed); - - t.ok(!output.includes("@context"), "no @context in output"); - t.ok(output.includes("function Foo(x) end"), "function present"); + const docsB = parseDocs( + dedent` + /*** + * @function SpringSynced.Bar + */ + `, + "b.cpp" + ); + const errors = lintDuplicateDeclarations([ + ["a.cpp", docsA], + ["b.cpp", docsB], + ]); + t.equal(errors.length, 0); t.end(); }); -// --- End-to-end: remapping + class declarations --- +// --- removeContextAttributes (defensive no-op stripper) --- -function processBucket( - bucketName: string, - buckets: Map, - tableBuckets: Map> -): string { - const docs = buckets - .get(bucketName)! - .flatMap(([, ds]) => ds) - .map((d) => structuredClone(d)); - - for (const [table, bucketSet] of tableBuckets) { - if (bucketSet.size < 2) continue; - remapDocTableNames(docs, table, table + bucketSuffix(bucketName)); - } - - return formatDocs(processDocs(docs, null)); -} - -test("end-to-end: context projection remaps multi-bucket tables with class inheritance", (t) => { +test("removeContextAttributes: strips stray @context", (t) => { const docs = parseDocs(dedent` /*** - * @function Spring.Foo + * @function SpringSynced.Foo * @context synced */ - /*** - * @function Spring.Baz - * @context synced, unsynced - */ - /*** - * @function VFS.LoadFile - * @context synced, unsynced - */ `); - const allContexts = new Set(["synced", "unsynced"]); - const buckets = partitionDocsByContext([["a.cpp", docs]], allContexts); - const tableBuckets = findMultiContextTables(buckets); - - t.deepEqual( - [...buckets.keys()].sort(), - ["shared", "synced"], - "partition produces expected buckets" - ); - t.equal(tableBuckets.get("Spring")!.size, 2, "Spring spans synced + shared"); - t.equal(tableBuckets.get("VFS")!.size, 1, "VFS only in shared"); - - const syncedOutput = processBucket("synced", buckets, tableBuckets); - t.ok( - syncedOutput.includes("function SpringSynced.Foo() end"), - "Spring.Foo remapped to SpringSynced.Foo" - ); - t.ok(!syncedOutput.includes("function Spring.Foo"), "original name gone"); - t.ok(!syncedOutput.includes("VFS"), "VFS not in synced bucket"); - - const sharedOutput = processBucket("shared", buckets, tableBuckets); - t.ok( - sharedOutput.includes("function SpringShared.Baz() end"), - "Spring.Baz remapped to SpringShared.Baz" - ); - t.ok( - sharedOutput.includes("function VFS.LoadFile() end"), - "VFS.LoadFile unchanged" - ); - - const syncedPreamble = generateClassDeclarations(tableBuckets, "synced"); - t.ok(syncedPreamble.includes("---@class SpringSynced")); - t.ok(!syncedPreamble.includes(":"), "no inheritance for synced"); - t.ok(!syncedPreamble.includes("VFS"), "VFS not in class declarations"); - - const sharedPreamble = generateClassDeclarations(tableBuckets, "shared"); - t.ok(sharedPreamble.includes("---@class SpringShared")); - t.ok(!sharedPreamble.includes(":"), "shared class has no parent"); - + removeContextAttributes(docs); + const hasContext = docs[0].attributes.some((a) => a.attributeType === "context"); + t.notOk(hasContext); t.end(); }); From a67dbd5472e6dfafc961dffaa3fd1efcfa7eee9b Mon Sep 17 00:00:00 2001 From: Daniel Harvey Date: Wed, 15 Apr 2026 13:34:46 -0600 Subject: [PATCH 7/7] feat: @context fallback routing for non-Spring tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The primary simplification (65b71fd) routed output files purely by the table prefix of each doc's @function/@table/@field declaration. That works for the three Spring* tables, but non-Spring tables like UnitScriptTable and ObjectRenderingTable carry no bucket prefix — their docs all fell through to shared.lua instead of landing in synced.lua / unsynced.lua as the pinned stubs had them. Resurrect the minimum needed: applyFileContexts() to propagate a standalone file-level `@context` doc onto every other doc in the same file, and a fallback tier in outputFileFor() that routes by that `@context` when no Spring prefix is present. Spring prefix still wins on any doc that carries one, so a stray `@context` in a Spring file can't drag its decls out of their declared bucket. What stays deleted from the old model: multi-context partitioning, Spring.X -> SpringBucket.X name-rewriting, auto-dedup of multi-context duplicates, class-inheritance preamble generation, per-file filename- based output. None of those are needed once authors write bucket prefixes explicitly. Recoil source now keeps @context on exactly two files (LuaObjectRendering.cpp, LuaUnitScript.cpp) — both contain non-Spring tables that would otherwise have no bucket signal. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli.ts | 7 +- src/context.ts | 108 +++++++++++++++++++++-- src/test/context.test.ts | 186 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 295 insertions(+), 6 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 070c736..2d5f2a9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,7 +9,11 @@ import { glob } from "glob"; import { dirname, join } from "path"; import { addHeader, formatDocs, getDocs, processDocs } from "."; import project from "../package.json"; -import { lintDuplicateDeclarations, projectOutputs } from "./context"; +import { + applyFileContexts, + lintDuplicateDeclarations, + projectOutputs, +} from "./context"; import { Doc } from "./doc"; import { mergeFileOutputs } from "./output"; import { toResultAsync } from "./result"; @@ -195,6 +199,7 @@ async function runAsync() { const valid = processed.filter((e) => e != null) as [string, Doc[]][]; + errors.push(...applyFileContexts(valid)); errors.push(...lintDuplicateDeclarations(valid)); let outputs = projectOutputs(valid); if (file !== undefined) { diff --git a/src/context.ts b/src/context.ts index a5960ec..384e963 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,17 +1,22 @@ import { + DefaultAttribute, EnumAttribute, FieldAttribute, FunctionAttribute, GlobalAttribute, TableAttribute, } from "./attribute"; -import { Doc, removeAttributes } from "./doc"; +import { + Doc, + filterAttributes, + hasAttribute, + removeAttributes, +} from "./doc"; import { FileOutput } from "./output"; -// Defensive no-op stripper: the `@context` tag was removed entirely from the -// grammar of this tool (it used to drive per-file bucket assignment before -// the split-tables-as-primary model). Any stray `@context` that sneaks into -// a doc comment gets silently removed here so it doesn't leak into stubs. +// Strips `@context` attributes after they've done their job in the pipeline +// (fallback routing in `outputFileFor`). `@context` is an internal signal for +// non-Spring tables — it doesn't belong in the emitted stubs. export function removeContextAttributes(docs: Doc[]): Doc[] { for (const doc of docs) { removeAttributes(doc, "context"); @@ -19,6 +24,83 @@ export function removeContextAttributes(docs: Doc[]): Doc[] { return docs; } +// Doc types that mark a doc as "declaring something" (function, table, class, +// etc.). A doc that has a `@context` and none of these is a file-level marker +// — its context applies to every other doc in the same file. +const DECLARATION_ATTR_TYPES = [ + "function", + "table", + "class", + "enum", + "global", + "field", +] as const; + +function isFileLevelContextDoc(doc: Doc): boolean { + return ( + hasAttribute(doc, "context") && + !DECLARATION_ATTR_TYPES.some((t) => hasAttribute(doc, t)) + ); +} + +// Parse a doc's `@context` attribute list into a set of bucket names. The +// attribute grammar carries comma-separated values in `description` (e.g. +// `@context synced, unsynced`). +export function getDocContexts(doc: Doc): string[] { + const contextAttrs = filterAttributes(doc, "context") as DefaultAttribute[]; + const contexts = new Set(); + for (const attr of contextAttrs) { + for (const part of attr.args.description.split(",")) { + const trimmed = part.trim(); + if (trimmed) contexts.add(trimmed); + } + } + return [...contexts]; +} + +// Find file-level `@context` markers (a standalone doc with only `@context` +// and no declaration) and propagate their context onto every other doc in the +// same file that doesn't already have one. The marker doc is then removed. +// Returns authoring errors (e.g. multiple markers per file, marker not at +// file start) rather than throwing. +export function applyFileContexts( + fileEntries: readonly (readonly [string, Doc[]])[] +): string[] { + const errors: string[] = []; + for (const [path, docs] of fileEntries) { + const markerIndices: number[] = []; + for (let i = 0; i < docs.length; i++) { + if (isFileLevelContextDoc(docs[i])) markerIndices.push(i); + } + if (markerIndices.length === 0) continue; + if (markerIndices.length > 1) { + errors.push( + `'${path}': multiple file-level @context docs (found ${markerIndices.length}, expected at most 1)` + ); + continue; + } + if (markerIndices[0] !== 0) { + errors.push( + `'${path}': file-level @context doc must be the first doc in the file` + ); + continue; + } + const fileContexts = getDocContexts(docs[0]); + docs.splice(0, 1); + if (fileContexts.length === 0) continue; + for (const doc of docs) { + if (hasAttribute(doc, "context")) continue; + for (const ctx of fileContexts) { + doc.attributes.push({ + attributeType: "context", + args: { description: ctx }, + }); + } + } + } + return errors; +} + // Each doc that declares a table method has a qualified name like // `SpringSynced.GiveOrderToUnit` — the first identifier is the "table name", // the rest is the method path. Used both for output-file grouping and for @@ -91,12 +173,28 @@ const SPRING_OUTPUTS: ReadonlyMap = const FALLBACK_OUTPUT = "shared.lua"; +// Non-Spring tables (UnitScript, ObjectRendering, etc.) don't carry a bucket +// in their `@function` prefix, so they rely on a file-level `@context` tag +// (propagated by `applyFileContexts`) to land in the right output. A single +// `synced` or `unsynced` context maps to that bucket; anything else — mixed +// contexts or unrecognized names — falls through to shared. +const CONTEXT_TO_OUTPUT: ReadonlyMap = new Map([ + ["synced", "synced.lua"], + ["unsynced", "unsynced.lua"], + ["shared", "shared.lua"], +]); + function outputFileFor(doc: Doc): string { const table = getDocTableName(doc); if (table != null) { const entry = SPRING_OUTPUTS.get(table); if (entry) return entry.file; } + const contexts = getDocContexts(doc); + if (contexts.length === 1) { + const mapped = CONTEXT_TO_OUTPUT.get(contexts[0]); + if (mapped) return mapped; + } return FALLBACK_OUTPUT; } diff --git a/src/test/context.test.ts b/src/test/context.test.ts index 7bae0f8..9755743 100644 --- a/src/test/context.test.ts +++ b/src/test/context.test.ts @@ -2,6 +2,8 @@ import dedent from "dedent-js"; import test from "tape"; import { getDocs } from ".."; import { + applyFileContexts, + getDocContexts, getDocTableName, lintDuplicateDeclarations, projectOutputs, @@ -302,3 +304,187 @@ test("removeContextAttributes: strips stray @context", (t) => { t.notOk(hasContext); t.end(); }); + +// --- getDocContexts --- + +test("getDocContexts: single context", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Foo.Bar + * @context synced + */ + `); + t.deepEqual(getDocContexts(docs[0]), ["synced"]); + t.end(); +}); + +test("getDocContexts: comma-separated contexts", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Foo.Bar + * @context synced, unsynced + */ + `); + t.deepEqual(getDocContexts(docs[0]).sort(), ["synced", "unsynced"]); + t.end(); +}); + +test("getDocContexts: no @context returns empty", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Foo.Bar + */ + `); + t.deepEqual(getDocContexts(docs[0]), []); + t.end(); +}); + +// --- applyFileContexts --- + +test("applyFileContexts: standalone @context stamps subsequent docs", (t) => { + const docs = parseDocs(dedent` + /*** @context synced */ + + /*** + * @function UnitScript.AttachUnit + */ + + /*** + * @function UnitScript.DropUnit + */ + `); + const entries: [string, Doc[]][] = [["a.cpp", docs]]; + const errors = applyFileContexts(entries); + t.deepEqual(errors, []); + t.equal(entries[0][1].length, 2, "standalone marker doc removed"); + t.deepEqual(getDocContexts(entries[0][1][0]), ["synced"]); + t.deepEqual(getDocContexts(entries[0][1][1]), ["synced"]); + t.end(); +}); + +test("applyFileContexts: existing @context on doc is preserved", (t) => { + const docs = parseDocs(dedent` + /*** @context synced */ + + /*** + * @function Foo.Bar + * @context unsynced + */ + `); + const entries: [string, Doc[]][] = [["a.cpp", docs]]; + applyFileContexts(entries); + t.deepEqual(getDocContexts(entries[0][1][0]), ["unsynced"]); + t.end(); +}); + +test("applyFileContexts: no standalone marker is a no-op", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Foo.Bar + */ + `); + const entries: [string, Doc[]][] = [["a.cpp", docs]]; + const errors = applyFileContexts(entries); + t.deepEqual(errors, []); + t.equal(entries[0][1].length, 1); + t.deepEqual(getDocContexts(entries[0][1][0]), []); + t.end(); +}); + +test("applyFileContexts: marker not at file start is an error", (t) => { + const docs = parseDocs(dedent` + /*** + * @function Foo.Bar + */ + + /*** @context synced */ + `); + const entries: [string, Doc[]][] = [["a.cpp", docs]]; + const errors = applyFileContexts(entries); + t.equal(errors.length, 1); + t.ok(errors[0].includes("must be the first doc")); + t.end(); +}); + +test("applyFileContexts: multiple markers in one file is an error", (t) => { + const docs = parseDocs(dedent` + /*** @context synced */ + + /*** @context unsynced */ + + /*** + * @function Foo.Bar + */ + `); + const entries: [string, Doc[]][] = [["a.cpp", docs]]; + const errors = applyFileContexts(entries); + t.equal(errors.length, 1); + t.ok(errors[0].includes("multiple file-level @context")); + t.end(); +}); + +// --- projectOutputs: @context fallback for non-Spring tables --- + +test("projectOutputs: non-Spring table with @context synced routes to synced.lua", (t) => { + const docs = parseDocs(dedent` + /*** @context synced */ + + /*** + * @function UnitScript.AttachUnit + */ + `); + const entries: [string, Doc[]][] = [["a.cpp", docs]]; + applyFileContexts(entries); + const outputs = projectOutputs(entries); + t.equal(outputs.length, 1); + t.equal(outputs[0].name, "synced.lua"); + t.end(); +}); + +test("projectOutputs: non-Spring table with @context unsynced routes to unsynced.lua", (t) => { + const docs = parseDocs(dedent` + /*** @context unsynced */ + + /*** + * @function ObjectRenderingTable.SetLODCount + */ + `); + const entries: [string, Doc[]][] = [["a.cpp", docs]]; + applyFileContexts(entries); + const outputs = projectOutputs(entries); + t.equal(outputs.length, 1); + t.equal(outputs[0].name, "unsynced.lua"); + t.end(); +}); + +test("projectOutputs: Spring prefix wins over @context fallback", (t) => { + const docs = parseDocs(dedent` + /*** @context unsynced */ + + /*** + * @function SpringSynced.Foo + */ + `); + const entries: [string, Doc[]][] = [["a.cpp", docs]]; + applyFileContexts(entries); + const outputs = projectOutputs(entries); + t.equal(outputs.length, 1); + t.equal(outputs[0].name, "synced.lua"); + t.end(); +}); + +test("projectOutputs: multi-context doc falls through to shared.lua", (t) => { + const docs = parseDocs(dedent` + /*** @context synced, unsynced */ + + /*** + * @function SomeTable.Foo + */ + `); + const entries: [string, Doc[]][] = [["a.cpp", docs]]; + applyFileContexts(entries); + const outputs = projectOutputs(entries); + t.equal(outputs.length, 1); + t.equal(outputs[0].name, "shared.lua"); + t.end(); +});