diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 066c539bf2e..e2a79b3b1e1 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -64,6 +64,10 @@ All changes included in 1.11: - ([PumasAI/quarto-julia-engine#11](https://github.com/PumasAI/quarto-julia-engine/pull/11)): Support `fig-format: retina`, normalized to `png` with doubled `fig-dpi` as in the `jupyter` and `knitr` engines. - ([PumasAI/quarto-julia-engine#7](https://github.com/PumasAI/quarto-julia-engine/pull/7)): Support `execute-dir`, shared worker processes across notebooks with matching configs (`share_worker_process: true`). +## Lua API + +- ([#14894](https://github.com/quarto-dev/quarto-cli/pull/14894)): Fix `quarto.version` and `quarto.config.version()` crashing filters with `table expected, got string` when the version string contains semver build metadata (such as a distro packager's revision suffix) or otherwise does not start with a digit. + ## Other fixes and improvements - ([#14775](https://github.com/quarto-dev/quarto-cli/issues/14775)): Fix a crash when the `QUARTO_R` environment variable is set to a malformed path. Quarto now warns and falls back to other R lookup methods. diff --git a/src/command/editor-support/crossref.ts b/src/command/editor-support/crossref.ts index e0f7d866d3d..b5a834515be 100644 --- a/src/command/editor-support/crossref.ts +++ b/src/command/editor-support/crossref.ts @@ -12,7 +12,11 @@ import { encodeBase64 } from "../../deno_ral/encoding.ts"; import { Command } from "cliffy/command/mod.ts"; import { execProcess } from "../../core/process.ts"; -import { pandocBinaryPath, resourcePath } from "../../core/resources.ts"; +import { + pandocBinaryPath, + pandocDataDirArgs, + resourcePath, +} from "../../core/resources.ts"; import { globalTempContext } from "../../core/temp.ts"; function parseCrossrefFlags(options: any, args: string[]): { @@ -99,8 +103,7 @@ const makeCrossrefCommand = () => { resourcePath("filters/qmd-reader.lua"), "--to", "native", - "--data-dir", - resourcePath("pandoc/datadir"), + ...pandocDataDirArgs(), "--lua-filter", resourcePath("filters/quarto-init/quarto-init.lua"), "--lua-filter", diff --git a/src/command/render/pandoc.ts b/src/command/render/pandoc.ts index 602b9b7be0b..bdd9bfb6443 100644 --- a/src/command/render/pandoc.ts +++ b/src/command/render/pandoc.ts @@ -58,7 +58,11 @@ import { isQuartoMetadata, metadataGetDeep, } from "../../config/metadata.ts"; -import { pandocBinaryPath, resourcePath } from "../../core/resources.ts"; +import { + pandocBinaryPath, + pandocDataDirArgs, + resourcePath, +} from "../../core/resources.ts"; import { getAvailableTypstFonts } from "../../core/typst.ts"; import { filterBundledSubtreeEngines } from "../../extension/extension.ts"; import { pandocAutoIdentifier } from "../../core/pandoc/pandoc-id.ts"; @@ -1071,7 +1075,7 @@ export async function runPandoc( pandocArgs, dataDirArgs, ); - pandocArgs.push("--data-dir", resourcePath("pandoc/datadir")); + pandocArgs.push(...pandocDataDirArgs()); // add any built-in syntax definition files allDefaults[kSyntaxDefinitions] = allDefaults[kSyntaxDefinitions] || []; diff --git a/src/core/resources.ts b/src/core/resources.ts index 1e79964547d..e5c77917b3f 100644 --- a/src/core/resources.ts +++ b/src/core/resources.ts @@ -80,6 +80,14 @@ export function pandocBinaryPath(): string { return isWindows ? toolsPath("pandoc") : architectureToolsPath("pandoc"); } +// Args selecting Quarto's own pandoc data-dir (init.lua and friends under +// src/resources/pandoc/datadir), never the user's default pandoc data +// directory. Not every pandoc invocation uses these -- e.g. `quarto run` +// (src/core/run/lua.ts) intentionally omits --data-dir. +export function pandocDataDirArgs(): string[] { + return ["--data-dir", resourcePath("pandoc/datadir")]; +} + const _r_binary_path: Map = new Map(); export async function rBinaryPath( binary: string, diff --git a/src/resources/pandoc/datadir/init.lua b/src/resources/pandoc/datadir/init.lua index bd19df840ff..d0f937d4882 100644 --- a/src/resources/pandoc/datadir/init.lua +++ b/src/resources/pandoc/datadir/init.lua @@ -675,13 +675,42 @@ local function outputFile() end end +-- Returns the leading sequence of dot-separated numeric components. +-- Lua patterns cannot match an arbitrary number of repeated groups. +local function leadingDottedNumber(s) + local parts = {} + local i = 1 + while true do + local numStart, numEnd = s:find('^%d+', i) + if not numStart then break end + table.insert(parts, s:sub(numStart, numEnd)) + i = numEnd + 1 + if s:sub(i, i) == '.' then + i = i + 1 + else + break + end + end + if #parts == 0 then + return nil + end + return table.concat(parts, '.') +end + local function version() local versionString = param('quarto-version', 'unknown') - local success, versionObject = pcall(pandoc.types.Version, versionString) + -- pandoc.types.Version's dotted-integer parser rejects semver build + -- metadata (e.g. "1.9.13+test.20260910") and any string without a + -- leading digit. Extract the leading dot-separated numeric component so + -- that construction always succeeds and this always returns a Version + -- object -- callers (e.g. table.concat(quarto.version, '.')) must not + -- have to handle a plain string fallback. + local numericVersion = leadingDottedNumber(versionString) or '0' + local success, versionObject = pcall(pandoc.types.Version, numericVersion) if success then return versionObject else - return versionString + return pandoc.types.Version('0') end end diff --git a/tests/smoke/lua-unit/quarto-version-repr.test.ts b/tests/smoke/lua-unit/quarto-version-repr.test.ts new file mode 100644 index 00000000000..25e9fe1acdf --- /dev/null +++ b/tests/smoke/lua-unit/quarto-version-repr.test.ts @@ -0,0 +1,101 @@ +/* + * quarto-version-repr.test.ts + * + * Regression test ensuring that quarto.version / quarto.config.version() + * always return a pandoc Version object, even when the `quarto-version` + * filter parameter contains semver build metadata or does not begin with a + * digit. Pandoc's dotted-integer Version parser rejects both forms. Such + * strings can reach quarto-version via QUARTO_FORCE_VERSION, a + * packager-appended revision suffix, or CI build-metadata stamping + * (test-smokes-built.yml). + * + * Unlike tests/smoke/lua-unit/lua-unit.test.ts, this does not go through + * `quarto run` -- that invokes pandoc without --data-dir, so init.lua from + * src/resources/pandoc/datadir/ (which defines quarto.version / + * quarto.config.version()) never loads. Instead this invokes pandoc + * directly with pandocDataDirArgs() pointing at that directory, forcing the + * quarto-version filter param via QUARTO_FILTER_PARAMS -- the same + * mechanism a real quarto render uses (src/command/render/filters.ts, + * src/command/render/pandoc.ts). + */ + +import { encodeBase64 } from "encoding/base64"; +import { fromFileUrl, join } from "../../../src/deno_ral/path.ts"; +import { assert } from "testing/asserts"; +import { execProcess } from "../../../src/core/process.ts"; +import { + pandocBinaryPath, + pandocDataDirArgs, + resourcePath, +} from "../../../src/core/resources.ts"; +import { unitTest } from "../../test.ts"; + +const testsDir = fromFileUrl(new URL("../../", import.meta.url)); +const unitLuaDir = join(testsDir, "unit-lua"); +const luaScript = join(unitLuaDir, "quarto-version-repr.test.lua"); + +// The `;;` at the end preserves the default search path. +const LUA_PATH = [join(unitLuaDir, "?.lua"), ""].join(";") + ";"; + +// A plain dotted-integer version parses fine under pandoc.types.Version -- +// this case must keep working (regression guard for the normal case). The +// four-component case guards against a fixed-arity extraction silently +// truncating a version with more components than it hard-codes. The other +// two are strings pandoc.types.Version's parser rejects: a build-metadata +// suffix (as CI's test-smokes-built.yml stamps via QUARTO_FORCE_VERSION, and +// as a distro packager might append) and a version with no leading digit at +// all. +const VERSION_CASES: Record = { + "plain numeric version": { input: "1.9.13", expected: "1.9.13" }, + "four-component version": { input: "1.2.3.4", expected: "1.2.3.4" }, + "build-metadata suffixed version": { + input: "1.9.13+test.20260910", + expected: "1.9.13", + }, + "non-numeric version": { input: "unknown", expected: "0" }, +}; + +for ( + const [label, { input: versionString, expected }] of Object.entries( + VERSION_CASES, + ) +) { + unitTest(`quarto-version-repr > ${label}`, async () => { + const filterParams = encodeBase64( + JSON.stringify({ + "quarto-version": versionString, + "expected-version": expected, + }), + ); + const result = await execProcess( + { + cmd: pandocBinaryPath(), + args: [ + ...pandocDataDirArgs(), + "--from", + "markdown", + "--to", + "plain", + "--lua-filter", + luaScript, + ], + env: { + LUA_PATH, + QUARTO_FILTER_PARAMS: filterParams, + QUARTO_SHARE_PATH: resourcePath(), + }, + }, + "test\n", + undefined, + undefined, + true, // forward child output directly to stdout/stderr + ); + assert( + result.success, + `quarto.version / quarto.config.version() were not a Version object ` + + `for quarto-version="${versionString}" (exit ${result.code}):\n` + + `--- stdout ---\n${result.stdout ?? ""}\n` + + `--- stderr ---\n${result.stderr ?? ""}`, + ); + }); +} diff --git a/tests/unit-lua/quarto-version-repr.test.lua b/tests/unit-lua/quarto-version-repr.test.lua new file mode 100644 index 00000000000..7a0d25c86df --- /dev/null +++ b/tests/unit-lua/quarto-version-repr.test.lua @@ -0,0 +1,53 @@ +-- Regression test for quarto.version / quarto.config.version() always +-- supporting table.concat(), even when the `quarto-version` filter param is +-- a string pandoc.types.Version's dotted-integer parser rejects (e.g. a +-- semver build-metadata suffix like "1.9.13+test.20260910", or an entirely +-- non-numeric string). Note: pandoc.types.Version is actually lenient about +-- a trailing "-suffix" (e.g. "1.9.13-1" parses fine, silently dropping the +-- suffix) -- the "+" character is what breaks its parser, along with any +-- string that doesn't start with a digit. +-- +-- On successful parsing, pandoc.types.Version returns a userdata object +-- (not a plain Lua table) that nonetheless supports table.concat() via its +-- metatable -- so this test checks table.concat() succeeds, not literal +-- Lua `type()`. +-- +-- Unlike the other tests in this directory, this one is not run through +-- `quarto run` (which invokes pandoc without --data-dir, so init.lua from +-- src/resources/pandoc/datadir/ never loads). It is invoked directly against +-- pandoc with --data-dir pointing at that directory, so the real init.lua +-- runs and populates the real `quarto` global -- see +-- tests/smoke/lua-unit/quarto-version-repr.test.ts, which sets +-- QUARTO_FILTER_PARAMS to force a specific `quarto-version` filter param. +-- +-- Reproduces the exact call made by the `{{< version >}}` shortcode +-- (src/resources/extensions/quarto/version/version.lua). +local lu = require('luaunit') + +TestQuartoVersionRepr = {} + +-- Optional: when the `expected-version` filter param is set, assert the +-- concatenated value exactly, not just that concat() didn't crash. Catches +-- silent truncation (e.g. a fixed-arity extraction pattern dropping a 4th +-- dotted component) that a crash-only check would miss. +local expectedVersion = param('expected-version', nil) + +function TestQuartoVersionRepr:testVersionConcatWorks() + local ok, result = pcall(table.concat, quarto.version, '.') + lu.assertTrue(ok, 'table.concat(quarto.version, ".") failed: ' .. tostring(result)) + if expectedVersion then + lu.assertEquals(result, expectedVersion) + end +end + +function TestQuartoVersionRepr:testConfigVersionConcatWorks() + local ok, result = pcall(table.concat, quarto.config.version(), '.') + lu.assertTrue(ok, 'table.concat(quarto.config.version(), ".") failed: ' .. tostring(result)) + if expectedVersion then + lu.assertEquals(result, expectedVersion) + end +end + +function Pandoc(doc) + os.exit(lu.LuaUnit.run()) +end