diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 53e6ae6..335e9e9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -150,7 +150,7 @@ body: attributes: label: Lithos version description: Output of `lithos --version` if available. - placeholder: lithos 0.4.0 + placeholder: lithos 0.4.0-beta.2 validations: required: true diff --git a/Cargo.lock b/Cargo.lock index 4974334..4657eaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1957,7 +1957,7 @@ checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" [[package]] name = "lithos" -version = "0.4.0" +version = "0.4.0-beta.2" dependencies = [ "assert_cmd", "clap 2.34.0", @@ -3019,6 +3019,7 @@ dependencies = [ "rusoto_s3", "schemars", "serde", + "serde_json", "serde_yaml", "sha2", "tempfile", diff --git a/README.md b/README.md index 39bccd2..9a4292b 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Releases are published from [`siriuslatte/lithos`](https://github.com/siriuslatt ```toml # foreman.toml [tools] -lithos = { source = "siriuslatte/lithos", version = "0.4.0" } +lithos = { source = "siriuslatte/lithos", version = "0.4.0-beta.2" } ``` ### Manual diff --git a/docs/packages/lib/src/remark-plugins/transform-lithos-config-examples.ts b/docs/packages/lib/src/remark-plugins/transform-lithos-config-examples.ts index 26d9822..bc972df 100644 --- a/docs/packages/lib/src/remark-plugins/transform-lithos-config-examples.ts +++ b/docs/packages/lib/src/remark-plugins/transform-lithos-config-examples.ts @@ -1,4 +1,10 @@ -import { parse as parseYaml } from 'yaml'; +import { + parseDocument, + isMap, + isSeq, + isScalar, + isPair, +} from 'yaml'; import type { Plugin } from 'unified'; import { visit } from 'unist-util-visit'; @@ -6,12 +12,11 @@ export interface TransformLithosConfigExamplesOptions { mode?: 'all' | 'project'; } -const PROJECT_CONFIG_KEYS = new Set([ - 'owner', - 'payments', - 'environments', - 'target', - 'state', +// Reserved words that cannot be used as bare identifiers in Lua / Luau. +const LUAU_RESERVED = new Set([ + 'and', 'break', 'do', 'else', 'elseif', 'end', 'false', 'for', + 'function', 'if', 'in', 'local', 'nil', 'not', 'or', 'repeat', + 'return', 'then', 'true', 'until', 'while', 'continue', ]); type CodeNode = { @@ -20,6 +25,24 @@ type CodeNode = { value: string; }; +type PathSegment = string | number; +type Path = PathSegment[]; + +type YamlInfo = { + value: unknown; + lineToPath: Map; +}; + +type BuilderOutput = { + text: string; + pathToLine: Map; +}; + +type Highlight = { + raw: string; + lines: Set; +}; + export function createTransformLithosConfigExamples( options: TransformLithosConfigExamplesOptions = {} ): Plugin<[]> { @@ -39,12 +62,19 @@ export function createTransformLithosConfigExamples( return; } - const jsonValue = convertYamlToJson(codeNode.value); - if (!jsonValue) { + const yamlInfo = parseYamlWithLineMap(codeNode.value); + if (yamlInfo === undefined) { return; } - parent.children.splice(index, 1, createTabsNode(codeNode, jsonValue)); + const jsonOutput = buildJsonWithPathLines(yamlInfo.value); + const luauOutput = buildLuauWithPathLines(yamlInfo.value); + + parent.children.splice( + index, + 1, + createTabsNode(codeNode, yamlInfo, jsonOutput, luauOutput) + ); }); }; }; @@ -69,30 +99,22 @@ function shouldTransform(codeNode: CodeNode, mode: 'all' | 'project') { return true; } + // In `project` mode we only transform YAML blocks that explicitly declare a + // `lithos.yml` (or `lithos.yaml`) filename. Snippets without a filename are + // treated as illustrative fragments and left untouched so JSON / Luau tabs + // don't show partial config shapes that the surrounding prose isn't talking + // about. const filename = extractFilenameFromMeta(codeNode.meta); - if (filename) { - const normalizedFilename = filename.toLowerCase().replace(/\\/g, '/'); - if (isExcludedFilename(normalizedFilename)) { - return false; - } - - if (isLithosConfigFilename(normalizedFilename)) { - return true; - } + if (!filename) { + return false; } - try { - const parsed = parseYaml(codeNode.value); - if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { - return false; - } - - return Object.keys(parsed as Record).some((key) => - PROJECT_CONFIG_KEYS.has(key) - ); - } catch { + const normalizedFilename = filename.toLowerCase().replace(/\\/g, '/'); + if (isExcludedFilename(normalizedFilename)) { return false; } + + return isLithosConfigFilename(normalizedFilename); } function isExcludedFilename(filename: string) { @@ -110,32 +132,394 @@ function isLithosConfigFilename(filename: string) { return /(^|\/)lithos\.ya?ml$/.test(filename); } -function convertYamlToJson(yamlSource: string) { +// --------------------------------------------------------------------------- +// YAML parsing with per-line path tracking. Walks the YAML AST and records +// the deepest key / value path that begins on each input line. The result +// powers `{N}` line-highlight translation across formats. +// --------------------------------------------------------------------------- + +function parseYamlWithLineMap(source: string): YamlInfo | undefined { + let doc; try { - const parsed = parseYaml(yamlSource); - return JSON.stringify(parsed, null, 2); + doc = parseDocument(source); } catch { return undefined; } + if (doc.errors && doc.errors.length > 0) { + return undefined; + } + + const value = doc.toJS(); + if (value === undefined || value === null) { + return undefined; + } + + const lineStarts = [0]; + for (let i = 0; i < source.length; i += 1) { + if (source[i] === '\n') { + lineStarts.push(i + 1); + } + } + const offsetToLine = (offset: number) => { + let lo = 0; + let hi = lineStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >>> 1; + if (lineStarts[mid]! <= offset) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo + 1; + }; + + const lineToPath = new Map(); + const setLine = (line: number, path: Path) => { + const existing = lineToPath.get(line); + if (!existing || path.length > existing.length) { + lineToPath.set(line, path.slice()); + } + }; + + function walk(node: any, path: Path) { + if (!node) return; + if (isMap(node)) { + for (const pair of node.items as any[]) { + if (!isPair(pair)) continue; + const key = (pair.key as any) && (pair.key as any).value; + if (key == null) continue; + const childPath: Path = [...path, String(key)]; + if ((pair.key as any) && (pair.key as any).range) { + setLine(offsetToLine((pair.key as any).range[0]), childPath); + } + walk(pair.value, childPath); + } + } else if (isSeq(node)) { + (node.items as any[]).forEach((item, idx) => { + const childPath: Path = [...path, idx]; + if (item && item.range) { + setLine(offsetToLine(item.range[0]), childPath); + } + walk(item, childPath); + }); + } else if (isScalar(node)) { + if ((node as any).range) { + setLine(offsetToLine((node as any).range[0]), path); + } + } + } + walk(doc.contents, []); + + return { value, lineToPath }; } -function createTabsNode(codeNode: CodeNode, jsonValue: string) { +function pathKey(path: Path) { + return path.map((segment) => String(segment)).join('\u0000'); +} + +// --------------------------------------------------------------------------- +// JSON / Luau emitters with per-path line tracking. +// --------------------------------------------------------------------------- + +function buildJsonWithPathLines(rootValue: unknown): BuilderOutput { + const builder = new LineBuilder(); + emitJsonValue(builder, rootValue, [], 0); + return builder.finish(); +} + +function emitJsonValue( + builder: LineBuilder, + value: unknown, + path: Path, + indent: number +) { + if (Array.isArray(value)) { + if (value.length === 0) { + builder.write('[]'); + return; + } + builder.write('['); + builder.newline(); + value.forEach((item, i) => { + const childPath: Path = [...path, i]; + builder.recordPathOnNextLine(childPath); + builder.write(' '.repeat(indent + 1)); + emitJsonValue(builder, item, childPath, indent + 1); + if (i < value.length - 1) builder.write(','); + builder.newline(); + }); + builder.write(' '.repeat(indent) + ']'); + return; + } + if (value && typeof value === 'object') { + const obj = value as Record; + const keys = Object.keys(obj); + if (keys.length === 0) { + builder.write('{}'); + return; + } + builder.write('{'); + builder.newline(); + keys.forEach((key, i) => { + const childPath: Path = [...path, key]; + builder.recordPathOnNextLine(childPath); + builder.write(' '.repeat(indent + 1) + JSON.stringify(key) + ': '); + emitJsonValue(builder, obj[key], childPath, indent + 1); + if (i < keys.length - 1) builder.write(','); + builder.newline(); + }); + builder.write(' '.repeat(indent) + '}'); + return; + } + builder.write(formatJsonScalar(value)); +} + +function formatJsonScalar(value: unknown): string { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'number' && !Number.isFinite(value)) return 'null'; + return JSON.stringify(value); +} + +function buildLuauWithPathLines(rootValue: unknown): BuilderOutput { + const builder = new LineBuilder(); + builder.write('return '); + emitLuauValue(builder, rootValue, [], 0); + builder.newline(); + return builder.finish(); +} + +function emitLuauValue( + builder: LineBuilder, + value: unknown, + path: Path, + indent: number +) { + if (value === null || value === undefined) { + builder.write('nil'); + return; + } + if (typeof value === 'boolean') { + builder.write(value ? 'true' : 'false'); + return; + } + if (typeof value === 'number') { + builder.write(Number.isFinite(value) ? String(value) : 'nil'); + return; + } + if (typeof value === 'string') { + builder.write(formatLuauString(value)); + return; + } + if (Array.isArray(value)) { + if (value.length === 0) { + builder.write('{}'); + return; + } + builder.write('{'); + builder.newline(); + value.forEach((item, i) => { + const childPath: Path = [...path, i]; + builder.recordPathOnNextLine(childPath); + builder.write(' '.repeat(indent + 1)); + emitLuauValue(builder, item, childPath, indent + 1); + builder.write(','); + builder.newline(); + }); + builder.write(' '.repeat(indent) + '}'); + return; + } + if (typeof value === 'object') { + const obj = value as Record; + const keys = Object.keys(obj); + if (keys.length === 0) { + builder.write('{}'); + return; + } + builder.write('{'); + builder.newline(); + keys.forEach((key, i) => { + const childPath: Path = [...path, key]; + builder.recordPathOnNextLine(childPath); + const formattedKey = isLuauIdentifier(key) + ? key + : `[${JSON.stringify(key)}]`; + builder.write(' '.repeat(indent + 1) + formattedKey + ' = '); + emitLuauValue(builder, obj[key], childPath, indent + 1); + builder.write(','); + builder.newline(); + }); + builder.write(' '.repeat(indent) + '}'); + return; + } + builder.write('nil'); +} + +function formatLuauString(value: string): string { + if (!value.includes('\n')) { + return JSON.stringify(value); + } + + let level = 0; + while (value.includes(`]${'='.repeat(level)}]`)) { + level += 1; + } + const padding = '='.repeat(level); + return `[${padding}[\n${value}]${padding}]`; +} + +function isLuauIdentifier(key: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && !LUAU_RESERVED.has(key); +} + +class LineBuilder { + private lines: string[] = []; + private current = ''; + private pathToLine = new Map(); + + write(s: string) { + this.current += s; + } + newline() { + this.lines.push(this.current); + this.current = ''; + } + recordPathOnNextLine(path: Path) { + const key = pathKey(path); + if (!this.pathToLine.has(key)) { + this.pathToLine.set(key, this.lines.length + 1); + } + } + finish(): BuilderOutput { + if (this.current.length > 0) { + this.lines.push(this.current); + this.current = ''; + } + return { text: this.lines.join('\n') + '\n', pathToLine: this.pathToLine }; + } +} + +// --------------------------------------------------------------------------- +// Highlight translation. +// --------------------------------------------------------------------------- + +function parseHighlightMeta(meta: string | undefined): Highlight | null { + if (!meta) return null; + const match = meta.match(/\{([^}]+)\}/); + if (!match || match[1] === undefined) return null; + const lines = new Set(); + for (const part of match[1].split(',')) { + const trimmed = part.trim(); + const rangeMatch = trimmed.match(/^(\d+)\s*-\s*(\d+)$/); + if (rangeMatch && rangeMatch[1] !== undefined && rangeMatch[2] !== undefined) { + const start = parseInt(rangeMatch[1], 10); + const end = parseInt(rangeMatch[2], 10); + for (let i = start; i <= end; i += 1) { + lines.add(i); + } + } else if (/^\d+$/.test(trimmed)) { + lines.add(parseInt(trimmed, 10)); + } + } + return { raw: match[0], lines }; +} + +function formatHighlight(lineSet: Set): string { + if (!lineSet || lineSet.size === 0) return ''; + const sorted = [...lineSet].sort((a, b) => a - b); + const parts: string[] = []; + let i = 0; + while (i < sorted.length) { + let j = i; + while (j + 1 < sorted.length && sorted[j + 1]! === sorted[j]! + 1) { + j += 1; + } + parts.push(i === j ? `${sorted[i]}` : `${sorted[i]}-${sorted[j]}`); + i = j + 1; + } + return `{${parts.join(',')}}`; +} + +function translateHighlight( + originalHighlight: Highlight | null, + yamlLineToPath: Map, + targetPathToLine: Map +): Set { + const result = new Set(); + if (!originalHighlight) return result; + for (const line of originalHighlight.lines) { + const path = yamlLineToPath.get(line); + if (!path) continue; + const targetLine = targetPathToLine.get(pathKey(path)); + if (targetLine !== undefined) { + result.add(targetLine); + } + } + return result; +} + +// --------------------------------------------------------------------------- +// Tab construction. +// --------------------------------------------------------------------------- + +function createTabsNode( + codeNode: CodeNode, + yamlInfo: YamlInfo, + jsonOutput: BuilderOutput, + luauOutput: BuilderOutput +) { + const originalHighlight = parseHighlightMeta(codeNode.meta); + + const jsonHighlight = originalHighlight + ? formatHighlight( + translateHighlight( + originalHighlight, + yamlInfo.lineToPath, + jsonOutput.pathToLine + ) + ) + : ''; + const luauHighlight = originalHighlight + ? formatHighlight( + translateHighlight( + originalHighlight, + yamlInfo.lineToPath, + luauOutput.pathToLine + ) + ) + : ''; + return { type: 'mdxJsxFlowElement', name: 'ConfigFormatTabs', attributes: [], children: [ - createTabNode('YAML', createCodeNode(codeNode, codeNode.value, codeNode.meta)), + createTabNode( + 'YAML', + createCodeNode('yaml', codeNode.value, codeNode.meta) + ), createTabNode( 'JSON', - createCodeNode(codeNode, jsonValue, buildJsonMeta(codeNode.meta)) + createCodeNode( + 'json', + jsonOutput.text, + rewriteMetaForFormat(codeNode.meta, 'lithos.json', jsonHighlight) + ) + ), + createTabNode( + 'Luau', + createCodeNode( + 'lua', + luauOutput.text, + rewriteMetaForFormat(codeNode.meta, 'lithos.luau', luauHighlight) + ) ), ], data: { _mdxExplicitJsx: true }, }; } -function createTabNode(label: string, codeNode: CodeNode) { +function createTabNode(label: string, codeNode: ReturnType) { return { type: 'mdxJsxFlowElement', name: 'ConfigFormatTab', @@ -145,26 +529,46 @@ function createTabNode(label: string, codeNode: CodeNode) { }; } -function createCodeNode(codeNode: CodeNode, value: string, meta?: string) { - return { - type: 'code', - lang: value === codeNode.value ? 'yaml' : 'json', - meta, - value, - }; +function createCodeNode(lang: string, value: string, meta?: string) { + return { type: 'code', lang, meta, value }; } -function buildJsonMeta(meta?: string) { +function rewriteMetaForFormat( + meta: string | undefined, + replacementName: string, + newHighlightStr: string +): string | undefined { + if (!meta) { + return newHighlightStr || undefined; + } + + let result = meta; const filename = extractFilenameFromMeta(meta); - if (!filename) { - return undefined; + if (filename) { + const newFilename = filename.replace( + /lithos\.(ya?ml|json|luau|lua)$/i, + replacementName + ); + result = result.replace( + /(filename|title)="[^"]+"/, + `filename="${newFilename}"` + ); } - const jsonFilename = filename.replace(/lithos\.ya?ml$/i, 'lithos.json'); - return `filename="${jsonFilename}"`; + if (/\{[^}]+\}/.test(result)) { + result = result.replace( + /\s*\{[^}]+\}/, + newHighlightStr ? ` ${newHighlightStr}` : '' + ); + } else if (newHighlightStr) { + result = `${result.trim()} ${newHighlightStr}`; + } + + result = result.trim(); + return result.length > 0 ? result : undefined; } function extractFilenameFromMeta(meta?: string) { const match = meta?.match(/(?:filename|title)="([^"]+)"/); return match?.[1]; -} \ No newline at end of file +} diff --git a/docs/site/components/config-format-tabs.tsx b/docs/site/components/config-format-tabs.tsx index 9d2e61a..847ce51 100644 --- a/docs/site/components/config-format-tabs.tsx +++ b/docs/site/components/config-format-tabs.tsx @@ -29,7 +29,10 @@ export function ConfigFormatTabs({ children }: { children: ReactNode }) { } return ( - item.props.label ?? 'Example')}> + item.props.label ?? 'Example')} + storageKey="lithos-config-format" + > {items.map((item, index) => ( {item.props.children} ))} diff --git a/docs/site/components/home-landing.tsx b/docs/site/components/home-landing.tsx index 8b48d7b..196e44c 100644 --- a/docs/site/components/home-landing.tsx +++ b/docs/site/components/home-landing.tsx @@ -122,7 +122,7 @@ export function HomeLanding() {
- v0.4.0 + v0.4.0-beta.2

Lithos

diff --git a/docs/site/pages/docs/commands.mdx b/docs/site/pages/docs/commands.mdx index 3674c8b..1b978f4 100644 --- a/docs/site/pages/docs/commands.mdx +++ b/docs/site/pages/docs/commands.mdx @@ -192,7 +192,7 @@ To require outputs from game code, write a Luau module: lithos outputs --environment dev --output src/shared/generated/lithosOutputs.luau ``` -```luau +```lua local ReplicatedStorage = game:GetService("ReplicatedStorage") local outputs = require(ReplicatedStorage.shared.generated.lithosOutputs) diff --git a/docs/site/pages/docs/configuration.mdx b/docs/site/pages/docs/configuration.mdx index 2ebc1dc..43272e2 100644 --- a/docs/site/pages/docs/configuration.mdx +++ b/docs/site/pages/docs/configuration.mdx @@ -25,8 +25,9 @@ export async function getStaticProps() { # Configuration -Lithos configs can be written in YAML or JSON. This page covers file -discovery, path resolution, `outputs` defaults, and schema tooling. +Lithos configs can be written in YAML, JSON, or Luau / Lua. This page +covers file discovery, path resolution, `outputs` defaults, scripting +with Lune, and schema tooling. Looking for every supported key? Jump straight to the @@ -44,13 +45,14 @@ order: 1. If `PROJECT` is omitted, the current directory is the project. 2. If `PROJECT` is a directory, Lithos searches it for one of: - `lithos.yml`, `lithos.yaml`, `lithos.json`, then legacy `mantle.yml`, - `mantle.yaml`. + `lithos.yml`, `lithos.yaml`, `lithos.json`, `lithos.luau`, + `lithos.lua`, then legacy `mantle.yml`, `mantle.yaml`. 3. If `PROJECT` is a file, Lithos uses it directly. Explicit paths can - point to YAML or JSON. + point to YAML, JSON, Luau, or Lua. -When more than one discovered file exists, `lithos.*` always wins over -the legacy Mantle names. +When more than one discovered file exists, declarative formats win over +scripts (`.yml` > `.yaml` > `.json` > `.luau` > `.lua`), and `lithos.*` +always wins over the legacy Mantle names. ## File path resolution @@ -121,13 +123,106 @@ outputs: ## YAML and JSON Lithos accepts YAML and JSON interchangeably. Auto-discovery prefers -`lithos.yml` → `lithos.yaml` → `lithos.json`, then the legacy Mantle -names. JSONC and YAML anchors-in-comments are not supported. +`lithos.yml` → `lithos.yaml` → `lithos.json`, then `lithos.luau` → +`lithos.lua`, then the legacy Mantle names. JSONC and YAML +anchors-in-comments are not supported. If you need a YAML refresher, see [Learn YAML in Y Minutes](https://learnxinyminutes.com/docs/yaml/) or the [examples repo](https://github.com/siriuslatte/lithos/tree/main/examples). +## Luau and Lua + +For projects whose configuration would otherwise be repetitive +(many similar places, programmatically derived asset lists, +environment-driven branches), Lithos can evaluate a Luau or Lua file +instead of a static YAML / JSON document. + + + Luau configs are executed by [Lune](https://lune-org.github.io/docs), + an external Luau runtime. Install Lune (via + [Aftman](https://github.com/LPGhatguy/aftman), + [Foreman](https://github.com/Roblox/foreman), or your package manager) + and make sure the `lune` binary is on `PATH`. To pin a specific + binary, set `LITHOS_LUNE=/path/to/lune`. + + +Your script must `return` a table whose shape matches the same schema +the YAML and JSON configs use, or a table of the form +`{ config = , ... }` (which leaves room for hooks, see below). + +```lua filename="project/lithos.luau" +local environments = { "production", "staging", "dev" } + +local config = { + environments = {}, + target = { + experience = { + places = { + start = { file = "game.rbxl" }, + }, + }, + }, +} + +for _, label in ipairs(environments) do + table.insert(config.environments, { + label = label, + branches = { label == "production" and "main" or label }, + }) +end + +return config +``` + +Because the file is just Luau, you can `require` shared helpers, read +environment variables via `@lune/process`, and pull in JSON / TOML +snippets through `@lune/serde` — exactly like any other Lune script. + +### Hooks + +Return a table with named hook functions alongside `config` to react +to Lithos lifecycle events. Today Lithos invokes one hook synchronously +at load time; additional hooks are recorded so future deploy steps can +route into them. + +```lua filename="project/lithos.luau" +return { + config = { + environments = { { label = "production", branches = { "main" } } }, + target = { + experience = { + places = { start = { file = "game.rbxl" } }, + }, + }, + }, + + -- Runs immediately after Lithos evaluates the config. Return a new + -- table to replace the loaded config, or `nil` to leave it as-is. + onConfigLoaded = function(config) + if os.getenv("CI") then + config.environments[1].branches = { "main", "release/*" } + end + return config + end, + + -- Registered for future deploy lifecycle integration. + onBeforeDeploy = function() end, + onAfterDeploy = function() end, +} +``` + + + Hook functions must use the `on` naming convention so Lithos + can distinguish them from helpers that happen to live at the top + level. Function values found anywhere else are stripped from the + config before validation. + + +If Lune cannot start, the user script raises an error, or the returned +table does not match the Lithos schema, Lithos surfaces the failure +together with the offending config path and Lune's stderr. + ## Editor schemas The published JSON schema powers autocomplete and validation in VS Code, @@ -141,6 +236,39 @@ for YAML files and add the snippet below to your settings: +### What about Luau and Lua? + +The JSON schema validates the *shape* of a Lithos config — string vs. +number, required keys, allowed enum values. A Luau / Lua script is +code, not data, so editors cannot apply the JSON schema to it the way +they do to YAML or JSON. + +Validation still happens, just at a different point: + +1. Lune evaluates the script and returns a table. +2. Lithos converts the table to JSON internally and validates it + against the same schema used for YAML and JSON. +3. Any schema violation is reported with the offending config path and + the failing field, the same way YAML and JSON errors are reported. + +That means a `lithos.luau` file with a misspelled key or a wrong-typed +value will fail at config-load time with a normal Lithos error — not +silently produce a broken deploy. + +For editor support inside the `.luau` file itself, use +[luau-lsp](https://github.com/JohnnyMorganz/luau-lsp) the same way you +would for any other Lune script. It type-checks Luau syntax, function +calls, and `require`d modules, but it does not know about the Lithos +config shape — treat the returned table as a plain Luau table and rely +on Lithos's runtime validation for shape errors. + + + If you want schema-driven autocomplete while authoring, write the + body of your config in `lithos.json` (or `lithos.yml`) and only reach + for `lithos.luau` when you need real logic — environment fan-out, + conditional branches, computed asset lists, or hooks. + + ## Next steps PROJECT_CONFIG_KEYS.has(key)); - } catch { + const normalizedFilename = filename.toLowerCase().replace(/\\/g, '/'); + if (isExcludedFilename(normalizedFilename)) { return false; } + + return isLithosConfigFilename(normalizedFilename); } function isExcludedFilename(filename) { @@ -94,25 +100,383 @@ function isLithosConfigFilename(filename) { return /(^|\/)lithos\.ya?ml$/.test(filename); } -function convertYamlToJson(yamlSource) { +// --------------------------------------------------------------------------- +// YAML parsing with per-line path tracking. +// +// We walk the YAML AST (via `parseDocument`) and record, for each input line, +// the deepest key/value path that begins on that line. This lets us translate +// a user's `{4,6-7}` line-highlight meta into the equivalent lines in the +// generated JSON / Luau tabs. +// --------------------------------------------------------------------------- + +function parseYamlWithLineMap(source) { + let doc; try { - const parsed = parseYaml(yamlSource); - return JSON.stringify(parsed, null, 2); + doc = parseDocument(source); } catch { return undefined; } + if (doc.errors && doc.errors.length > 0) { + return undefined; + } + + const value = doc.toJS(); + if (value === undefined || value === null) { + return undefined; + } + + const lineStarts = [0]; + for (let i = 0; i < source.length; i += 1) { + if (source[i] === '\n') { + lineStarts.push(i + 1); + } + } + const offsetToLine = (offset) => { + let lo = 0; + let hi = lineStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >>> 1; + if (lineStarts[mid] <= offset) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo + 1; + }; + + // For each line, record the deepest path that starts there. Walking + // depth-first means deeper paths overwrite shallower ones at the same line. + const lineToPath = new Map(); + const setLine = (line, path) => { + const existing = lineToPath.get(line); + if (!existing || path.length > existing.length) { + lineToPath.set(line, path.slice()); + } + }; + + function walk(node, path) { + if (!node) return; + if (isMap(node)) { + for (const pair of node.items) { + if (!isPair(pair)) continue; + const key = pair.key && pair.key.value; + if (key == null) continue; + const childPath = [...path, String(key)]; + if (pair.key && pair.key.range) { + setLine(offsetToLine(pair.key.range[0]), childPath); + } + walk(pair.value, childPath); + } + } else if (isSeq(node)) { + node.items.forEach((item, idx) => { + const childPath = [...path, idx]; + if (item && item.range) { + setLine(offsetToLine(item.range[0]), childPath); + } + walk(item, childPath); + }); + } else if (isScalar(node)) { + if (node.range) { + setLine(offsetToLine(node.range[0]), path); + } + } + } + walk(doc.contents, []); + + return { value, lineToPath }; +} + +function pathKey(path) { + return path.map((segment) => String(segment)).join('\u0000'); +} + +// --------------------------------------------------------------------------- +// JSON / Luau emitters with per-path line tracking. +// +// Each emitter produces { text, pathToLine }. `pathToLine` maps a `pathKey` +// (the `\0`-joined path from the root) to the 1-indexed output line where +// that key / value pair starts. The line-highlight translator looks up the +// YAML path for each highlighted YAML line, then looks up the corresponding +// line in the target format. +// --------------------------------------------------------------------------- + +function buildJsonWithPathLines(rootValue) { + const builder = new LineBuilder(); + emitJsonValue(builder, rootValue, [], 0); + return builder.finish(); +} + +function emitJsonValue(builder, value, path, indent) { + if (Array.isArray(value)) { + if (value.length === 0) { + builder.write('[]'); + return; + } + builder.write('['); + builder.newline(); + value.forEach((item, i) => { + const childPath = [...path, i]; + builder.recordPathOnNextLine(childPath); + builder.write(' '.repeat(indent + 1)); + emitJsonValue(builder, item, childPath, indent + 1); + if (i < value.length - 1) builder.write(','); + builder.newline(); + }); + builder.write(' '.repeat(indent) + ']'); + return; + } + if (value && typeof value === 'object') { + const keys = Object.keys(value); + if (keys.length === 0) { + builder.write('{}'); + return; + } + builder.write('{'); + builder.newline(); + keys.forEach((key, i) => { + const childPath = [...path, key]; + builder.recordPathOnNextLine(childPath); + builder.write(' '.repeat(indent + 1) + JSON.stringify(key) + ': '); + emitJsonValue(builder, value[key], childPath, indent + 1); + if (i < keys.length - 1) builder.write(','); + builder.newline(); + }); + builder.write(' '.repeat(indent) + '}'); + return; + } + builder.write(formatJsonScalar(value)); +} + +function formatJsonScalar(value) { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'number' && !Number.isFinite(value)) return 'null'; + return JSON.stringify(value); } -function createTabsNode(codeNode, jsonValue) { +function buildLuauWithPathLines(rootValue) { + const builder = new LineBuilder(); + builder.write('return '); + emitLuauValue(builder, rootValue, [], 0); + builder.newline(); + return builder.finish(); +} + +function emitLuauValue(builder, value, path, indent) { + if (value === null || value === undefined) { + builder.write('nil'); + return; + } + if (typeof value === 'boolean') { + builder.write(value ? 'true' : 'false'); + return; + } + if (typeof value === 'number') { + builder.write(Number.isFinite(value) ? String(value) : 'nil'); + return; + } + if (typeof value === 'string') { + builder.write(formatLuauString(value)); + return; + } + if (Array.isArray(value)) { + if (value.length === 0) { + builder.write('{}'); + return; + } + builder.write('{'); + builder.newline(); + value.forEach((item, i) => { + const childPath = [...path, i]; + builder.recordPathOnNextLine(childPath); + builder.write(' '.repeat(indent + 1)); + emitLuauValue(builder, item, childPath, indent + 1); + builder.write(','); + builder.newline(); + }); + builder.write(' '.repeat(indent) + '}'); + return; + } + if (typeof value === 'object') { + const keys = Object.keys(value); + if (keys.length === 0) { + builder.write('{}'); + return; + } + builder.write('{'); + builder.newline(); + keys.forEach((key, i) => { + const childPath = [...path, key]; + builder.recordPathOnNextLine(childPath); + const formattedKey = isLuauIdentifier(key) + ? key + : `[${JSON.stringify(key)}]`; + builder.write(' '.repeat(indent + 1) + formattedKey + ' = '); + emitLuauValue(builder, value[key], childPath, indent + 1); + builder.write(','); + builder.newline(); + }); + builder.write(' '.repeat(indent) + '}'); + return; + } + builder.write('nil'); +} + +function formatLuauString(value) { + if (!value.includes('\n')) { + return JSON.stringify(value); + } + + // Use Lua long brackets for multi-line strings, picking an equals padding + // that does not appear in the body so we never accidentally close early. + let level = 0; + while (value.includes(`]${'='.repeat(level)}]`)) { + level += 1; + } + const padding = '='.repeat(level); + return `[${padding}[\n${value}]${padding}]`; +} + +function isLuauIdentifier(key) { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && !LUAU_RESERVED.has(key); +} + +class LineBuilder { + constructor() { + this.lines = []; + this.current = ''; + this.pathToLine = new Map(); + } + write(s) { + this.current += s; + } + newline() { + this.lines.push(this.current); + this.current = ''; + } + recordPathOnNextLine(path) { + const key = pathKey(path); + if (!this.pathToLine.has(key)) { + this.pathToLine.set(key, this.lines.length + 1); + } + } + finish() { + if (this.current.length > 0) { + this.lines.push(this.current); + this.current = ''; + } + return { text: this.lines.join('\n') + '\n', pathToLine: this.pathToLine }; + } +} + +// --------------------------------------------------------------------------- +// Highlight translation. Parses the `{N,M-O}` portion of the YAML meta, maps +// each highlighted YAML line through the path table to the equivalent line +// in the JSON / Luau output, and serializes the result back into the same +// `{...}` syntax that Nextra / shiki expects. +// --------------------------------------------------------------------------- + +function parseHighlightMeta(meta) { + if (!meta) return null; + const match = meta.match(/\{([^}]+)\}/); + if (!match) return null; + const lines = new Set(); + for (const part of match[1].split(',')) { + const trimmed = part.trim(); + const rangeMatch = trimmed.match(/^(\d+)\s*-\s*(\d+)$/); + if (rangeMatch) { + const start = parseInt(rangeMatch[1], 10); + const end = parseInt(rangeMatch[2], 10); + for (let i = start; i <= end; i += 1) { + lines.add(i); + } + } else if (/^\d+$/.test(trimmed)) { + lines.add(parseInt(trimmed, 10)); + } + } + return { raw: match[0], lines }; +} + +function formatHighlight(lineSet) { + if (!lineSet || lineSet.size === 0) return ''; + const sorted = [...lineSet].sort((a, b) => a - b); + const parts = []; + let i = 0; + while (i < sorted.length) { + let j = i; + while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1) { + j += 1; + } + parts.push(i === j ? `${sorted[i]}` : `${sorted[i]}-${sorted[j]}`); + i = j + 1; + } + return `{${parts.join(',')}}`; +} + +function translateHighlight(originalHighlight, yamlLineToPath, targetPathToLine) { + const result = new Set(); + if (!originalHighlight) return result; + for (const line of originalHighlight.lines) { + const path = yamlLineToPath.get(line); + if (!path) continue; + const targetLine = targetPathToLine.get(pathKey(path)); + if (targetLine !== undefined) { + result.add(targetLine); + } + } + return result; +} + +// --------------------------------------------------------------------------- +// Tab construction. +// --------------------------------------------------------------------------- + +function createTabsNode(codeNode, yamlInfo, jsonOutput, luauOutput) { + const originalHighlight = parseHighlightMeta(codeNode.meta); + + const jsonHighlight = originalHighlight + ? formatHighlight( + translateHighlight( + originalHighlight, + yamlInfo.lineToPath, + jsonOutput.pathToLine + ) + ) + : ''; + const luauHighlight = originalHighlight + ? formatHighlight( + translateHighlight( + originalHighlight, + yamlInfo.lineToPath, + luauOutput.pathToLine + ) + ) + : ''; + return { type: 'mdxJsxFlowElement', name: 'ConfigFormatTabs', attributes: [], children: [ - createTabNode('YAML', createCodeNode(codeNode, codeNode.value, codeNode.meta)), + createTabNode( + 'YAML', + createCodeNode('yaml', codeNode.value, codeNode.meta) + ), createTabNode( 'JSON', - createCodeNode(codeNode, jsonValue, buildJsonMeta(codeNode.meta)) + createCodeNode( + 'json', + jsonOutput.text, + rewriteMetaForFormat(codeNode.meta, 'lithos.json', jsonHighlight) + ) + ), + createTabNode( + 'Luau', + createCodeNode( + 'lua', + luauOutput.text, + rewriteMetaForFormat(codeNode.meta, 'lithos.luau', luauHighlight) + ) ), ], data: { _mdxExplicitJsx: true }, @@ -129,23 +493,39 @@ function createTabNode(label, codeNode) { }; } -function createCodeNode(codeNode, value, meta) { - return { - type: 'code', - lang: value === codeNode.value ? 'yaml' : 'json', - meta, - value, - }; +function createCodeNode(lang, value, meta) { + return { type: 'code', lang, meta, value }; } -function buildJsonMeta(meta) { +function rewriteMetaForFormat(meta, replacementName, newHighlightStr) { + if (!meta) { + return newHighlightStr || undefined; + } + + let result = meta; const filename = extractFilenameFromMeta(meta); - if (!filename) { - return undefined; + if (filename) { + const newFilename = filename.replace( + /lithos\.(ya?ml|json|luau|lua)$/i, + replacementName + ); + result = result.replace( + /(filename|title)="[^"]+"/, + `filename="${newFilename}"` + ); + } + + if (/\{[^}]+\}/.test(result)) { + result = result.replace( + /\s*\{[^}]+\}/, + newHighlightStr ? ` ${newHighlightStr}` : '' + ); + } else if (newHighlightStr) { + result = `${result.trim()} ${newHighlightStr}`; } - const jsonFilename = filename.replace(/lithos\.ya?ml$/i, 'lithos.json'); - return `filename="${jsonFilename}"`; + result = result.trim(); + return result.length > 0 ? result : undefined; } function extractFilenameFromMeta(meta) { @@ -155,4 +535,4 @@ function extractFilenameFromMeta(meta) { module.exports = { createTransformLithosConfigExamples, -}; \ No newline at end of file +}; diff --git a/examples/README.md b/examples/README.md index 560a342..fcc7b56 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,6 +5,7 @@ Runnable example projects for learning Lithos. | Project | What it shows | | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | [`getting-started`](projects/getting-started) | The smallest valid `lithos.yml`: one experience, one place, two environments. | +| [`luau-config`](projects/luau-config) | A Luau-based `lithos.luau` with helpers, a loop over environments, and an `onConfigLoaded` hook. | | [`pirate-wars`](projects/pirate-wars) | A near-complete project: multi-place, icon, thumbnails, products, passes, badges, social links, notifications, asset bundle, env overrides. | ## Usage diff --git a/examples/foreman.toml b/examples/foreman.toml index 60e3ad8..9ea67e9 100644 --- a/examples/foreman.toml +++ b/examples/foreman.toml @@ -1,4 +1,4 @@ [tools] # `lithos` is the CLI binary. Releases are published from # https://github.com/siriuslatte/lithos/releases. -lithos = { source = "siriuslatte/lithos", version = "0.4.0" } +lithos = { source = "siriuslatte/lithos", version = "0.4.0-beta.2" } diff --git a/examples/projects/luau-config/README.md b/examples/projects/luau-config/README.md new file mode 100644 index 0000000..87a47dd --- /dev/null +++ b/examples/projects/luau-config/README.md @@ -0,0 +1,19 @@ +# Luau Config Example + +A small project that mirrors `getting-started` but defines the Lithos +config in Luau, demonstrating loops, helpers, and hook callbacks. + +```sh +# From the repository root +lithos deploy --environment dev examples/projects/luau-config +``` + +Requires the `lune` binary on PATH. See the +[Configuration docs](../../../docs/site/pages/docs/configuration.mdx) for +installation pointers and the full hook contract. + +The `game.rbxlx` referenced by `lithos.luau` is intentionally not +checked in here; copy +[`../getting-started/game.rbxlx`](../getting-started/game.rbxlx) into +this directory (or point the `file` field at your own place file) +before running `lithos deploy`. diff --git a/examples/projects/luau-config/lithos.luau b/examples/projects/luau-config/lithos.luau new file mode 100644 index 0000000..2d30a4b --- /dev/null +++ b/examples/projects/luau-config/lithos.luau @@ -0,0 +1,67 @@ +-- Luau equivalent of `examples/projects/getting-started/lithos.yml`, +-- demonstrating two reasons to reach for a Luau config: +-- +-- 1. Reduce repetition with helpers and loops. +-- 2. React to Lithos lifecycle events with hooks. +-- +-- Run with: `lithos deploy --environment dev examples/projects/luau-config`. +-- Requires the `lune` binary on PATH (see the Configuration docs page). + +local function environment(label: string, opts: { public: boolean? }?): { [string]: any } + opts = opts or {} + return { + label = label, + targetNamePrefix = if opts.public then nil else "environmentLabel", + targetAccess = if opts.public then "public" else nil, + } +end + +local config = { + environments = { + environment("dev"), + environment("staging"), + environment("prod", { public = true }), + }, + + target = { + experience = { + configuration = { + genre = "building", + playableDevices = { "computer", "phone", "tablet" }, + }, + places = { + start = { + file = "game.rbxlx", + configuration = { + name = "Getting Started with Lithos (Luau)", + description = "Made with Lithos and Luau.", + maxPlayerCount = 20, + }, + }, + }, + }, + }, +} + +return { + config = config, + + -- Runs synchronously right after Lithos finishes decoding the config. + -- Return a new table to replace it, or `nil` to leave it untouched. + onConfigLoaded = function(config) + if os.getenv("CI") then + -- In CI, automatically allow the `release/*` branch family to + -- deploy to production as well. + for _, env in ipairs(config.environments) do + if env.label == "prod" then + env.branches = { "main", "release/*" } + end + end + end + return config + end, + + -- Hooks below are recorded for future deploy lifecycle integration. + onBeforeDeploy = function() end, + onAfterDeploy = function() end, +} diff --git a/src/lithos/CHANGELOG.md b/src/lithos/CHANGELOG.md index 898bd98..cdc1b88 100644 --- a/src/lithos/CHANGELOG.md +++ b/src/lithos/CHANGELOG.md @@ -1,5 +1,12 @@ # lithos +## 0.4.0-beta.2 + +### Minor Changes + +- Luau / Lua project configs are now supported alongside YAML and JSON via a bundled Lune evaluator, with optional `on*` lifecycle hooks (starting with `onConfigLoaded`). +- Docs site now renders every `lithos.yml` example as YAML / JSON / Luau tabs and translates line-highlight metadata across all three formats. + ## 0.4.0 ### Minor Changes diff --git a/src/lithos/Cargo.toml b/src/lithos/Cargo.toml index fd5c63a..17f7596 100644 --- a/src/lithos/Cargo.toml +++ b/src/lithos/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lithos" -version = "0.4.0" +version = "0.4.0-beta.2" edition = "2021" description = "Infra-as-code and deployment tool for Roblox" license = "MIT" diff --git a/src/rbx_lithos/Cargo.toml b/src/rbx_lithos/Cargo.toml index e51dbc1..6e618f9 100644 --- a/src/rbx_lithos/Cargo.toml +++ b/src/rbx_lithos/Cargo.toml @@ -15,6 +15,7 @@ logger = { path = "../logger" } dotenv = "0.15.0" serde_yaml = { version = "0.8" } +serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } clap = "2.33.0" glob = "0.3.0" diff --git a/src/rbx_lithos/src/config.rs b/src/rbx_lithos/src/config.rs index 19106a8..e665e3a 100644 --- a/src/rbx_lithos/src/config.rs +++ b/src/rbx_lithos/src/config.rs @@ -1,12 +1,14 @@ //! Project configuration types. //! //! This module defines the data shape of the user-facing `lithos.yml` and -//! `lithos.yaml`, and `lithos.json` files (plus the legacy `mantle.yml` / -//! `mantle.yaml` aliases). Two focused -//! submodules handle -//! adjacent concerns: +//! `lithos.yaml`, `lithos.json`, `lithos.luau`, and `lithos.lua` files (plus +//! the legacy `mantle.yml` / `mantle.yaml` aliases). Three focused +//! submodules handle adjacent concerns: //! -//! - [`loading`] – reads and parses the YAML/JSON file from disk. +//! - [`loading`] – reads and parses config files from disk, dispatching by +//! extension between the static (YAML / JSON) and executable (Luau / Lua) +//! formats. +//! - [`luau`] – evaluates `.luau` and `.lua` configs via the Lune runtime. //! - [`mapping`] – pure `From` impls converting these config types into the //! request models used by `rbx_api`. @@ -19,6 +21,7 @@ use serde::{Deserialize, Serialize}; use url::Url; mod loading; +mod luau; mod mapping; pub use loading::load_project_config; diff --git a/src/rbx_lithos/src/config/loading.rs b/src/rbx_lithos/src/config/loading.rs index 16f6f66..655c694 100644 --- a/src/rbx_lithos/src/config/loading.rs +++ b/src/rbx_lithos/src/config/loading.rs @@ -12,9 +12,15 @@ use dotenv::from_path; use log::info; use yansi::Paint; -use super::Config; - -const PRIMARY_CONFIG_FILENAMES: &[&str] = &["lithos.yml", "lithos.yaml", "lithos.json"]; +use super::{luau, Config}; + +const PRIMARY_CONFIG_FILENAMES: &[&str] = &[ + "lithos.yml", + "lithos.yaml", + "lithos.json", + "lithos.luau", + "lithos.lua", +]; const LEGACY_CONFIG_FILENAMES: &[&str] = &["mantle.yml", "mantle.yaml"]; fn config_candidates(project_path: &Path) -> Vec { @@ -94,6 +100,10 @@ fn parse_project_path(project: Option<&str>) -> Result<(PathBuf, PathBuf), Strin } fn load_config_file(config_file: &Path) -> Result { + if luau::is_lua_config_path(config_file) { + return luau::load_lua_config(config_file).map(|eval| eval.config); + } + let data = fs::read_to_string(config_file).map_err(|e| { format!( "Unable to read config file: {}\n\t{}", @@ -155,7 +165,9 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; - use super::{load_project_config, LEGACY_CONFIG_FILENAMES, PRIMARY_CONFIG_FILENAMES}; + use super::{ + load_project_config, parse_project_path, LEGACY_CONFIG_FILENAMES, PRIMARY_CONFIG_FILENAMES, + }; static NEXT_TEMP_DIR_ID: AtomicUsize = AtomicUsize::new(0); @@ -322,6 +334,46 @@ target: assert!(error.contains(LEGACY_CONFIG_FILENAMES[1])); } + #[test] + fn discovery_prefers_yaml_and_json_over_luau() { + // Selection should be a pure function of which files exist; verify + // precedence without actually evaluating any of them. + let project_dir = TempProjectDir::new(); + project_dir.write("lithos.json", JSON_CONFIG); + project_dir.write("lithos.luau", "return {}"); + project_dir.write("lithos.lua", "return {}"); + + let (_, config_path) = + parse_project_path(Some(project_dir.path().to_str().unwrap())).unwrap(); + + assert_eq!(config_path.file_name().unwrap(), "lithos.json"); + } + + #[test] + fn discovery_prefers_luau_over_lua_and_legacy_mantle() { + let project_dir = TempProjectDir::new(); + project_dir.write("lithos.luau", "return {}"); + project_dir.write("lithos.lua", "return {}"); + project_dir.write("mantle.yml", YML_CONFIG); + + let (_, config_path) = + parse_project_path(Some(project_dir.path().to_str().unwrap())).unwrap(); + + assert_eq!(config_path.file_name().unwrap(), "lithos.luau"); + } + + #[test] + fn missing_config_error_mentions_luau_in_search_path() { + let project_dir = TempProjectDir::new(); + + let error = load_project_config(Some(project_dir.path().to_str().unwrap())) + .err() + .unwrap(); + + assert!(error.contains("lithos.luau")); + assert!(error.contains("lithos.lua")); + } + #[test] fn load_project_config_loads_project_dotenv() { let env_key = "LITHOS_TEST_PROJECT_DOTENV_8740"; diff --git a/src/rbx_lithos/src/config/luau.rs b/src/rbx_lithos/src/config/luau.rs new file mode 100644 index 0000000..241c2f7 --- /dev/null +++ b/src/rbx_lithos/src/config/luau.rs @@ -0,0 +1,578 @@ +//! Luau / Lua project config evaluation. +//! +//! Lithos delegates execution to [Lune](https://lune-org.github.io/docs), an +//! external Luau runtime aimed at Roblox tooling. We do not interpret Luau +//! ourselves: a small wrapper script is handed to `lune run`, which `require`s +//! the user's config file and prints the resulting table as JSON between +//! sentinel markers. Rust then parses the markers and decodes the JSON +//! payload into [`Config`]. +//! +//! This keeps the surface area small while letting users write idiomatic +//! Luau (helper functions, loops, environment-variable branching, ...) just +//! like any other Lune script. Documented capabilities and limits live in the +//! `docs/site/pages/docs/configuration` pages. +//! +//! Side-effectful boundary: spawns a subprocess, writes a temp file, reads +//! environment variables, and prints to the logger on failure. + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Stdio}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use super::Config; + +/// Environment variable that overrides the `lune` binary used to evaluate +/// `.luau` / `.lua` configs. Useful for pinning a specific Lune build in CI +/// or pointing at a forked runtime. +pub const LUNE_BIN_ENV: &str = "LITHOS_LUNE"; + +const DEFAULT_LUNE_BIN: &str = "lune"; + +const CONFIG_BEGIN_MARKER: &str = "@@LITHOS_CONFIG_BEGIN@@"; +const CONFIG_END_MARKER: &str = "@@LITHOS_CONFIG_END@@"; + +/// Lune wrapper script. Receives the require-path to the user's config (no +/// extension, relative to this wrapper's location) as `process.args[1]` and +/// prints the resulting config table as JSON between sentinel markers. +/// +/// Errors are written to stderr and surfaced via the process exit code. +const WRAPPER_SCRIPT: &str = include_str!("luau_wrapper.luau"); + +/// Result of evaluating a Luau / Lua config file. +pub struct LuauEvaluation { + pub config: Config, + /// Names of hook functions the user defined at the top level of the + /// returned table (e.g. `onConfigLoaded`, `onBeforeDeploy`). Lithos uses + /// this list to log which hooks were registered and to know whether to + /// re-invoke Lune for lifecycle hooks in future deploy steps. + #[allow(dead_code)] // Reserved for upcoming deploy-lifecycle integration. + pub hooks: Vec, +} + +/// Returns `true` for file paths Lithos should evaluate via Lune. +pub fn is_lua_config_path(path: &Path) -> bool { + matches!( + path.extension().and_then(|s| s.to_str()), + Some("lua") | Some("luau") + ) +} + +/// Evaluate a Luau or Lua project config and decode its returned table as +/// a [`Config`]. +pub fn load_lua_config(config_file: &Path) -> Result { + let canonical_raw = config_file.canonicalize().map_err(|e| { + format!( + "Unable to resolve Luau config path {}: {}", + config_file.display(), + e + ) + })?; + // Windows `canonicalize()` returns paths with the `\\?\` verbatim + // prefix. Lune does not normalize that prefix when resolving relative + // `require()` calls from the running script, so we strip it before + // handing the wrapper path to Lune. + let canonical = strip_verbatim_prefix(&canonical_raw); + let user_dir = canonical.parent().ok_or_else(|| { + format!( + "Luau config path {} has no parent directory", + config_file.display() + ) + })?; + let user_stem = canonical + .file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| { + format!( + "Luau config path {} has no valid file stem", + config_file.display() + ) + })?; + + let lune_bin = env::var(LUNE_BIN_ENV).unwrap_or_else(|_| DEFAULT_LUNE_BIN.to_string()); + + // Place the wrapper inside the same directory as the user's config so the + // `require()` path is just `./`. Lune's require() resolves relative + // to the calling script and cross-directory traversal has proven brittle + // on Windows (canonical `\\?\` prefixes, separator normalization, etc.). + let wrapper = WrapperFile::new_in(user_dir)?; + let require_path = format!("./{}", user_stem); + + let output = Command::new(&lune_bin) + .arg("run") + .arg(wrapper.path()) + .arg(&require_path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|e| { + format!( + "Failed to launch Lune ('{}') to evaluate {}: {}\n\ + Hint: install Lune from https://lune-org.github.io/docs and ensure it is on PATH,\n\ + or set the {} environment variable to point at a Lune binary.", + lune_bin, + config_file.display(), + e, + LUNE_BIN_ENV, + ) + })?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if !output.status.success() { + return Err(format!( + "Lune failed to evaluate {} (exit code {}):\n{}", + config_file.display(), + output + .status + .code() + .map(|c| c.to_string()) + .unwrap_or_else(|| "".into()), + indent_block(stderr.trim_end()), + )); + } + + let json_payload = extract_marker_payload(&stdout, &stderr, config_file)?; + + #[derive(serde::Deserialize)] + struct Envelope { + config: serde_json::Value, + #[serde(default)] + hooks: Vec, + } + + let envelope: Envelope = serde_json::from_str(&json_payload).map_err(|e| { + format!( + "Luau config {} produced a payload Lithos could not decode:\n\t{}", + config_file.display(), + e + ) + })?; + + let config: Config = serde_json::from_value(envelope.config).map_err(|e| { + format!( + "Luau config {} returned data that does not match the Lithos config schema:\n\t{}", + config_file.display(), + e + ) + })?; + + Ok(LuauEvaluation { + config, + hooks: envelope.hooks, + }) +} + +fn extract_marker_payload( + stdout: &str, + stderr: &str, + config_file: &Path, +) -> Result { + let start = stdout.find(CONFIG_BEGIN_MARKER).ok_or_else(|| { + format!( + "Lune did not emit a config payload for {}. Make sure your script returns a table or \ + `{{ config =
}}`.\n\ + stdout:\n{}\n\ + stderr:\n{}", + config_file.display(), + indent_block(stdout.trim_end()), + indent_block(stderr.trim_end()), + ) + })?; + let after_begin = start + CONFIG_BEGIN_MARKER.len(); + let end_rel = stdout[after_begin..] + .find(CONFIG_END_MARKER) + .ok_or_else(|| { + format!( + "Lune emitted a truncated config payload for {} (missing end marker).", + config_file.display() + ) + })?; + Ok(stdout[after_begin..after_begin + end_rel] + .trim() + .to_string()) +} + +/// Strips the Windows verbatim `\\?\` prefix from a path if present. On +/// non-Windows platforms (and for paths without the prefix) the input is +/// returned unchanged. Lune chokes on verbatim-prefixed script paths when +/// resolving relative requires, so we hand it normal `C:\...` paths. +fn strip_verbatim_prefix(path: &Path) -> PathBuf { + let lossy = path.to_string_lossy(); + if let Some(stripped) = lossy.strip_prefix(r"\\?\") { + PathBuf::from(stripped) + } else { + path.to_path_buf() + } +} + +fn indent_block(text: &str) -> String { + if text.is_empty() { + return "\t".to_string(); + } + text.lines() + .map(|line| format!("\t{}", line)) + .collect::>() + .join("\n") +} + +/// Computes a `require()`-compatible path from `from_dir` to `target_file` +/// with the extension stripped. The result always starts with `./` or `../` +/// so Lune accepts it. Both paths must already be absolute. +/// +/// Kept available for tests and possible future cross-directory use; the +/// production loader places the wrapper inside the user's config directory +/// instead and uses the simpler `./` form. +#[cfg(test)] +fn relative_require_path(from_dir: &Path, target_file: &Path) -> String { + let stripped = target_file.with_extension(""); + let from = normalize_components(from_dir); + let to = normalize_components(&stripped); + + let mut common = 0; + while common < from.len() && common < to.len() && from[common] == to[common] { + common += 1; + } + + let ups = from.len() - common; + let mut parts: Vec = Vec::new(); + if ups == 0 { + parts.push(".".to_string()); + } else { + for _ in 0..ups { + parts.push("..".to_string()); + } + } + for c in &to[common..] { + parts.push(c.clone()); + } + parts.join("/") +} + +#[cfg(test)] +fn normalize_components(path: &Path) -> Vec { + use std::path::Component; + path.components() + .filter_map(|c| match c { + Component::Prefix(prefix) => Some(prefix.as_os_str().to_string_lossy().to_string()), + Component::RootDir => None, + Component::CurDir => None, + Component::ParentDir => Some("..".to_string()), + Component::Normal(s) => Some(s.to_string_lossy().to_string()), + }) + .collect() +} + +/// Self-cleaning temp file holding the Lune wrapper script. The wrapper is +/// written into `host_dir` with a unique hidden file name so the wrapper can +/// `require("./")` to reach the user's config. +struct WrapperFile { + path: PathBuf, +} + +impl WrapperFile { + fn new_in(host_dir: &Path) -> Result { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let name = format!( + ".lithos-luau-eval-{}-{}.luau", + std::process::id(), + timestamp + ); + let path = host_dir.join(name); + fs::write(&path, WRAPPER_SCRIPT).map_err(|e| { + format!( + "Unable to write Luau wrapper script to {}: {}", + path.display(), + e + ) + })?; + Ok(Self { path }) + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for WrapperFile { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn relative_require_path_within_same_subtree() { + let from = PathBuf::from(if cfg!(windows) { + r"C:\tmp\wrap" + } else { + "/tmp/wrap" + }); + let target = PathBuf::from(if cfg!(windows) { + r"C:\projects\demo\lithos.luau" + } else { + "/projects/demo/lithos.luau" + }); + let rel = relative_require_path(&from, &target); + // Always starts with ./ or ../ + assert!(rel.starts_with("./") || rel.starts_with("../")); + assert!(rel.ends_with("/lithos")); + } + + #[test] + fn is_lua_config_path_detects_both_extensions() { + assert!(is_lua_config_path(Path::new("lithos.lua"))); + assert!(is_lua_config_path(Path::new("lithos.luau"))); + assert!(!is_lua_config_path(Path::new("lithos.yml"))); + assert!(!is_lua_config_path(Path::new("lithos.json"))); + } + + // ----- Lune-gated end-to-end tests ---------------------------------- + // + // These tests shell out to a real `lune` binary. They are skipped (and + // print a clear message) when Lune is not on PATH so contributors who + // have not installed Lune locally can still run `cargo test`. + + fn lune_available() -> bool { + let bin = std::env::var(LUNE_BIN_ENV).unwrap_or_else(|_| DEFAULT_LUNE_BIN.to_string()); + Command::new(bin) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + + struct TempLuauDir(PathBuf); + + impl TempLuauDir { + fn new() -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let mut p = env::temp_dir(); + p.push(format!("lithos-luau-test-{}-{}", std::process::id(), nanos)); + fs::create_dir_all(&p).unwrap(); + Self(p) + } + fn write(&self, name: &str, content: &str) -> PathBuf { + let f = self.0.join(name); + fs::write(&f, content).unwrap(); + f + } + } + + impl Drop for TempLuauDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + const VALID_LUAU: &str = r#" +local config = { + environments = { + { label = "production", branches = { "main" } }, + }, + target = { + experience = { + places = { + start = { file = "place.rbxl" }, + }, + }, + }, +} +return config +"#; + + #[test] + fn evaluates_valid_luau_config_to_typed_config() { + if !lune_available() { + eprintln!("skipping: lune not available on PATH"); + return; + } + let dir = TempLuauDir::new(); + let file = dir.write("lithos.luau", VALID_LUAU); + + let eval = match load_lua_config(&file) { + Ok(eval) => eval, + Err(err) => panic!("luau evaluation should succeed: {}", err), + }; + + assert_eq!(eval.config.environments.len(), 1); + assert_eq!(eval.config.environments[0].label, "production"); + } + + #[test] + fn accepts_table_with_explicit_config_field() { + if !lune_available() { + eprintln!("skipping: lune not available on PATH"); + return; + } + let dir = TempLuauDir::new(); + let file = dir.write( + "lithos.luau", + r#" +return { + config = { + environments = { { label = "wrapped", branches = { "main" } } }, + target = { experience = { places = { start = { file = "p.rbxl" } } } }, + }, +} +"#, + ); + + let eval = match load_lua_config(&file) { + Ok(eval) => eval, + Err(err) => panic!("luau evaluation should succeed: {}", err), + }; + assert_eq!(eval.config.environments[0].label, "wrapped"); + } + + #[test] + fn surfaces_runtime_errors_with_config_path() { + if !lune_available() { + eprintln!("skipping: lune not available on PATH"); + return; + } + let dir = TempLuauDir::new(); + let file = dir.write( + "lithos.luau", + r#"error("boom from user config") +"#, + ); + + let err = match load_lua_config(&file) { + Ok(_) => panic!("runtime error should fail load"), + Err(e) => e, + }; + assert!( + err.contains(file.file_name().unwrap().to_string_lossy().as_ref()), + "error should mention the config file path; got: {}", + err + ); + assert!( + err.contains("boom from user config"), + "error should preserve the underlying message; got: {}", + err + ); + } + + #[test] + fn rejects_non_table_return_values() { + if !lune_available() { + eprintln!("skipping: lune not available on PATH"); + return; + } + let dir = TempLuauDir::new(); + let file = dir.write("lithos.luau", "return 42\n"); + + let err = match load_lua_config(&file) { + Ok(_) => panic!("scalar return should fail"), + Err(e) => e, + }; + assert!(err.to_lowercase().contains("table"), "got: {}", err); + } + + #[test] + fn rejects_invalid_config_shape_with_schema_error() { + if !lune_available() { + eprintln!("skipping: lune not available on PATH"); + return; + } + let dir = TempLuauDir::new(); + // `environments` and `target` are required. + let file = dir.write( + "lithos.luau", + "return { totallyUnknownTopLevelKey = true }\n", + ); + + let err = match load_lua_config(&file) { + Ok(_) => panic!("schema mismatch should fail"), + Err(e) => e, + }; + assert!( + err.contains("schema") || err.contains("missing") || err.contains("unknown"), + "expected a schema-style error; got: {}", + err + ); + } + + #[test] + fn on_config_loaded_hook_can_transform_config() { + if !lune_available() { + eprintln!("skipping: lune not available on PATH"); + return; + } + let dir = TempLuauDir::new(); + let file = dir.write( + "lithos.luau", + r#" +return { + config = { + environments = { { label = "production", branches = { "main" } } }, + target = { experience = { places = { start = { file = "p.rbxl" } } } }, + }, + onConfigLoaded = function(config) + table.insert(config.environments, { label = "staging", branches = { "develop" } }) + return config + end, + onBeforeDeploy = function() end, +} +"#, + ); + + let eval = match load_lua_config(&file) { + Ok(eval) => eval, + Err(err) => panic!("hook evaluation should succeed: {}", err), + }; + assert_eq!(eval.config.environments.len(), 2); + assert_eq!(eval.config.environments[1].label, "staging"); + let mut hook_names = eval.hooks.clone(); + hook_names.sort(); + assert_eq!(hook_names, vec!["onBeforeDeploy", "onConfigLoaded"]); + } + + #[test] + fn on_config_loaded_hook_failure_is_surfaced() { + if !lune_available() { + eprintln!("skipping: lune not available on PATH"); + return; + } + let dir = TempLuauDir::new(); + let file = dir.write( + "lithos.luau", + r#" +return { + config = { + environments = { { label = "production", branches = { "main" } } }, + target = { experience = { places = { start = { file = "p.rbxl" } } } }, + }, + onConfigLoaded = function() error("hook exploded") end, +} +"#, + ); + + let err = match load_lua_config(&file) { + Ok(_) => panic!("hook failure should propagate"), + Err(e) => e, + }; + assert!( + err.contains("onConfigLoaded") && err.contains("hook exploded"), + "expected hook error to surface; got: {}", + err + ); + } +} diff --git a/src/rbx_lithos/src/config/luau_wrapper.luau b/src/rbx_lithos/src/config/luau_wrapper.luau new file mode 100644 index 0000000..5a6503b --- /dev/null +++ b/src/rbx_lithos/src/config/luau_wrapper.luau @@ -0,0 +1,102 @@ +--!strict +-- Lithos Luau config wrapper. +-- +-- Lune invokes this script as `lune run wrapper.luau ` where +-- is a Lune-compatible relative path (no extension) to the +-- user's `lithos.luau` / `lithos.lua` file. +-- +-- Contract: +-- * The user script may return either: +-- - a config table directly, or +-- - a table shaped `{ config =
, ... }` with optional hook +-- functions (e.g. `onConfigLoaded`) alongside `config`. +-- * Hook functions discovered at the top level are reported back to Lithos +-- and (for `onConfigLoaded`) invoked here. Function-valued keys are +-- stripped from the config table before JSON encoding so users can mix +-- data and hook callbacks in a single return value. +-- * Anything written to stdout outside the sentinel markers is ignored by +-- Lithos, but the user is still free to `print` for debugging. +-- +-- Important: Lune's `require` resolves relative paths using `debug.getinfo` +-- on the calling stack frame. Wrapping it in `pcall` hides the source frame, +-- which breaks path resolution. Errors from `require` / the user script are +-- therefore left to propagate; Lune prints them to stderr and exits non-zero, +-- and the Rust caller surfaces both. + +local serde = require("@lune/serde") +local stdio = require("@lune/stdio") +local process = require("@lune/process") + +local userPath = process.args[1] +if type(userPath) ~= "string" or userPath == "" then + stdio.ewrite("[lithos] internal error: missing user config path argument\n") + process.exit(64) +end + +local mod = require(userPath) + +if type(mod) ~= "table" then + stdio.ewrite("[lithos] user config must return a table or `{ config =
, ... }`, got " .. type(mod) .. "\n") + process.exit(66) +end + +-- Collect hook functions defined at the top level of the returned table. +-- Hooks must use the `on` naming convention so we can distinguish +-- them from user helpers that happen to be defined at the top level. +local hooks = {} +for k, v in pairs(mod) do + if type(v) == "function" and type(k) == "string" and k:sub(1, 2) == "on" then + table.insert(hooks, k) + end +end + +-- Resolve the config table. +local config +if type(mod.config) == "table" then + config = mod.config +else + -- Treat the returned table itself as the config, stripping out any + -- function values so JSON encoding does not fail on hook callbacks. + config = {} + for k, v in pairs(mod) do + if type(v) ~= "function" then + config[k] = v + end + end +end + +-- Invoke the synchronous `onConfigLoaded` hook if present. It receives the +-- decoded config table and may return a replacement table. +if type(mod.onConfigLoaded) == "function" then + local okHook, hookResult = pcall(mod.onConfigLoaded, config) + if not okHook then + stdio.ewrite("[lithos] onConfigLoaded hook failed: " .. tostring(hookResult) .. "\n") + process.exit(68) + end + if type(hookResult) == "table" then + config = hookResult + elseif hookResult ~= nil then + stdio.ewrite("[lithos] onConfigLoaded must return a table or nil; got " .. type(hookResult) .. "\n") + process.exit(69) + end +end + +local envelope = { + config = config, +} +-- Lune's JSON encoder cannot tell an empty Lua table from an empty array, +-- so only attach `hooks` when at least one hook was registered. Rust treats +-- the missing field as an empty list. +if #hooks > 0 then + envelope.hooks = hooks +end + +local okEncode, encoded = pcall(serde.encode, "json", envelope) +if not okEncode then + stdio.ewrite("[lithos] failed to encode user config as JSON: " .. tostring(encoded) .. "\n") + process.exit(67) +end + +stdio.write("\n@@LITHOS_CONFIG_BEGIN@@\n") +stdio.write(encoded) +stdio.write("\n@@LITHOS_CONFIG_END@@\n")