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..2d5f2a9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,11 +6,16 @@ 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, + lintDuplicateDeclarations, + projectOutputs, +} from "./context"; import { Doc } from "./doc"; +import { mergeFileOutputs } from "./output"; import { toResultAsync } from "./result"; interface Options { @@ -192,30 +197,25 @@ async function runAsync() { }) ); - const valid = processed.filter((e) => e != null); + 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) { + outputs = mergeFileOutputs(outputs, file); + } console.log(chalk`\n{bold.underline Writing output:}\n`); - 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 ); } @@ -233,12 +233,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..384e963 --- /dev/null +++ b/src/context.ts @@ -0,0 +1,279 @@ +import { + DefaultAttribute, + EnumAttribute, + FieldAttribute, + FunctionAttribute, + GlobalAttribute, + TableAttribute, +} from "./attribute"; +import { + Doc, + filterAttributes, + hasAttribute, + removeAttributes, +} from "./doc"; +import { FileOutput } from "./output"; + +// 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"); + } + 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 +// duplicate-declaration linting. +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; +} + +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 null; +} + +// 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"; + +// 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; +} + +// 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 [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 errors; +} + +// 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 byOutput = new Map< + string, + { docs: Doc[]; sources: Set; preambleParts: Set } + >(); + + 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); + } + } + } + + const outputs: FileOutput[] = []; + for (const [name, { docs, sources, preambleParts }] of byOutput) { + outputs.push({ + name, + docs, + sources: [...sources], + preamble: [...preambleParts].join("\n\n"), + }); + } + return outputs; +} 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/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 new file mode 100644 index 0000000..9755743 --- /dev/null +++ b/src/test/context.test.ts @@ -0,0 +1,490 @@ +import dedent from "dedent-js"; +import test from "tape"; +import { getDocs } from ".."; +import { + applyFileContexts, + getDocContexts, + getDocTableName, + lintDuplicateDeclarations, + projectOutputs, + removeContextAttributes, +} from "../context"; +import { Doc } from "../doc"; + +function parseDocs(input: string, path = "test.cpp"): Doc[] { + const [result, err] = getDocs(input, path); + if (err != null) throw err; + return result[0]; +} + +// --- 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: SpringSynced qualified function", (t) => { + const docs = parseDocs(dedent` + /*** + * @function SpringSynced.GiveOrderToUnit + */ + `); + t.equal(getDocTableName(docs[0]), "SpringSynced"); + t.end(); +}); + +test("getDocTableName: table declaration", (t) => { + const docs = parseDocs(dedent` + /*** + * @table MoveCtrl + */ + `); + t.equal(getDocTableName(docs[0]), "MoveCtrl"); + t.end(); +}); + +// --- projectOutputs: Spring-bucket routing --- + +test("projectOutputs: SpringShared goes to shared.lua", (t) => { + const docs = parseDocs(dedent` + /*** + * @function SpringShared.Echo + */ + `); + 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("projectOutputs: SpringSynced goes to synced.lua", (t) => { + const docs = parseDocs(dedent` + /*** + * @function SpringSynced.GiveOrderToUnit + */ + `); + 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("projectOutputs: SpringUnsynced goes to unsynced.lua", (t) => { + const docs = parseDocs(dedent` + /*** + * @function SpringUnsynced.GetMouseState + */ + `); + 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("projectOutputs: non-Spring tables fall through to shared.lua", (t) => { + const docs = parseDocs(dedent` + /*** + * @function MoveCtrl.Enable + */ + `); + 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("projectOutputs: mixed Spring buckets produce three files", (t) => { + const docsA = parseDocs( + dedent` + /*** + * @function SpringSynced.GiveOrderToUnit + */ + `, + "a.cpp" + ); + const docsB = parseDocs( + dedent` + /*** + * @function SpringUnsynced.GetMouseState + */ + `, + "b.cpp" + ); + const docsC = parseDocs( + dedent` + /*** + * @function SpringShared.GetUnitPosition + */ + `, + "c.cpp" + ); + 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("projectOutputs: same bucket across files merges into one output", (t) => { + const docsA = parseDocs( + dedent` + /*** + * @function SpringSynced.Foo + */ + `, + "a.cpp" + ); + 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(); +}); + +test("projectOutputs: preamble dedup (one @class per bucket even across docs)", (t) => { + const docs = parseDocs(dedent` + /*** + * @function SpringSynced.Foo + */ + + /*** + * @function SpringSynced.Bar + */ + `); + 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(); +}); + +// --- lintDuplicateDeclarations --- + +test("lintDuplicateDeclarations: flags same @function in two files", (t) => { + const docsA = parseDocs( + dedent` + /*** + * @function SpringSynced.Foo + */ + `, + "a.cpp" + ); + 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("lintDuplicateDeclarations: ignores same @function duplicated in one file", (t) => { + const docs = parseDocs(dedent` + /*** + * @function SpringSynced.Foo + */ + + /*** + * @function SpringSynced.Foo + */ + `); + const errors = lintDuplicateDeclarations([["a.cpp", docs]]); + t.equal(errors.length, 0); + t.end(); +}); + +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], + ]); + t.equal(errors.length, 0); + t.end(); +}); + +test("lintDuplicateDeclarations: silent when all names are unique", (t) => { + const docsA = parseDocs( + dedent` + /*** + * @function SpringSynced.Foo + */ + `, + "a.cpp" + ); + 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(); +}); + +// --- removeContextAttributes (defensive no-op stripper) --- + +test("removeContextAttributes: strips stray @context", (t) => { + const docs = parseDocs(dedent` + /*** + * @function SpringSynced.Foo + * @context synced + */ + `); + removeContextAttributes(docs); + const hasContext = docs[0].attributes.some((a) => a.attributeType === "context"); + 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(); +});