From 6c893b3cb740e2ce0fe63322554efec6fd273284 Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Tue, 30 Dec 2025 11:26:03 -0500 Subject: [PATCH 1/5] claude: Add class-based engine selection with scoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for extracting the first class from code block attributes (e.g., {python .marimo}) and passing it to engine's claimsLanguage method. Changes: - Add languagesWithClasses() function returning Map - Update languagesInMarkdown() to use languagesWithClasses internally - Update claimsLanguage signature to accept optional firstClass parameter and return boolean | number for priority-based selection - Update markdownExecutionEngine to use scoring: highest score wins, first engine wins ties (backwards compatible) - Expose getLanguagesWithClasses in the markdownRegex API namespace Backwards compatibility: - Old engines returning true -> score 1 - Old engines returning false -> score 0 - New engines can return numbers > 1 to override 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/quarto-types/dist/index.d.ts | 13 ++- packages/quarto-types/src/execution-engine.ts | 6 +- packages/quarto-types/src/quarto-api.ts | 8 ++ src/core/api/markdown-regex.ts | 2 + src/core/api/types.ts | 3 + src/core/pandoc/pandoc-partition.ts | 24 +++-- src/execute/engine.ts | 28 ++++-- src/execute/types.ts | 9 +- .../_extensions/foo-engine/_extension.yml | 7 ++ .../_extensions/foo-engine/foo-engine.js | 92 +++++++++++++++++++ .../smoke-all/engine/class-override/test.qmd | 27 ++++++ 11 files changed, 202 insertions(+), 17 deletions(-) create mode 100644 tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/_extension.yml create mode 100644 tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/foo-engine.js create mode 100644 tests/docs/smoke-all/engine/class-override/test.qmd diff --git a/packages/quarto-types/dist/index.d.ts b/packages/quarto-types/dist/index.d.ts index bd99cdc1141..a104417616e 100644 --- a/packages/quarto-types/dist/index.d.ts +++ b/packages/quarto-types/dist/index.d.ts @@ -831,6 +831,13 @@ export interface QuartoAPI { * @returns Set of language identifiers found in fenced code blocks */ getLanguages: (markdown: string) => Set; + /** + * Extract programming languages and their first class from code blocks + * + * @param markdown - Markdown content to analyze + * @returns Map of language identifiers to their first class (or undefined) + */ + getLanguagesWithClasses: (markdown: string) => Map; /** * Break Quarto markdown into cells * @@ -1566,8 +1573,12 @@ export interface ExecutionEngineDiscovery { claimsFile: (file: string, ext: string) => boolean; /** * Whether this engine can handle the given language + * + * @param language - The language identifier (e.g., "python", "r", "julia") + * @param firstClass - Optional first class from code block attributes (e.g., "marimo" from {python .marimo}) + * @returns boolean for simple claim, or number for priority (higher wins, 0 = no claim, 1 = standard claim) */ - claimsLanguage: (language: string) => boolean; + claimsLanguage: (language: string, firstClass?: string) => boolean | number; /** * Whether this engine supports freezing */ diff --git a/packages/quarto-types/src/execution-engine.ts b/packages/quarto-types/src/execution-engine.ts index 3c20446140c..87d8b2821be 100644 --- a/packages/quarto-types/src/execution-engine.ts +++ b/packages/quarto-types/src/execution-engine.ts @@ -95,8 +95,12 @@ export interface ExecutionEngineDiscovery { /** * Whether this engine can handle the given language + * + * @param language - The language identifier (e.g., "python", "r", "julia") + * @param firstClass - Optional first class from code block attributes (e.g., "marimo" from {python .marimo}) + * @returns boolean for simple claim, or number for priority (higher wins, 0 = no claim, 1 = standard claim) */ - claimsLanguage: (language: string) => boolean; + claimsLanguage: (language: string, firstClass?: string) => boolean | number; /** * Whether this engine supports freezing diff --git a/packages/quarto-types/src/quarto-api.ts b/packages/quarto-types/src/quarto-api.ts index 0f8013e098e..d1c615ac722 100644 --- a/packages/quarto-types/src/quarto-api.ts +++ b/packages/quarto-types/src/quarto-api.ts @@ -66,6 +66,14 @@ export interface QuartoAPI { */ getLanguages: (markdown: string) => Set; + /** + * Extract programming languages and their first class from code blocks + * + * @param markdown - Markdown content to analyze + * @returns Map of language identifiers to their first class (or undefined) + */ + getLanguagesWithClasses: (markdown: string) => Map; + /** * Break Quarto markdown into cells * diff --git a/src/core/api/markdown-regex.ts b/src/core/api/markdown-regex.ts index 75f87c4ce22..d614f1623ab 100644 --- a/src/core/api/markdown-regex.ts +++ b/src/core/api/markdown-regex.ts @@ -7,6 +7,7 @@ import type { MarkdownRegexNamespace } from "./types.ts"; import { readYamlFromMarkdown } from "../yaml.ts"; import { languagesInMarkdown, + languagesWithClasses, partitionMarkdown, } from "../pandoc/pandoc-partition.ts"; import { breakQuartoMd } from "../lib/break-quarto-md.ts"; @@ -17,6 +18,7 @@ globalRegistry.register("markdownRegex", (): MarkdownRegexNamespace => { extractYaml: readYamlFromMarkdown, partition: partitionMarkdown, getLanguages: languagesInMarkdown, + getLanguagesWithClasses: languagesWithClasses, breakQuartoMd, }; }); diff --git a/src/core/api/types.ts b/src/core/api/types.ts index 3d503d4745b..91ef31a9def 100644 --- a/src/core/api/types.ts +++ b/src/core/api/types.ts @@ -38,6 +38,9 @@ export interface MarkdownRegexNamespace { extractYaml: (markdown: string) => Metadata; partition: (markdown: string) => PartitionedMarkdown; getLanguages: (markdown: string) => Set; + getLanguagesWithClasses: ( + markdown: string, + ) => Map; breakQuartoMd: ( src: string | MappedString, validate?: boolean, diff --git a/src/core/pandoc/pandoc-partition.ts b/src/core/pandoc/pandoc-partition.ts index 7271c96b9cc..24a23479fa2 100644 --- a/src/core/pandoc/pandoc-partition.ts +++ b/src/core/pandoc/pandoc-partition.ts @@ -114,19 +114,29 @@ export function languagesInMarkdownFile(file: string) { return languagesInMarkdown(Deno.readTextFileSync(file)); } -export function languagesInMarkdown(markdown: string) { - // see if there are any code chunks in the file - const languages = new Set(); - const kChunkRegex = /^[\t >]*```+\s*\{([a-zA-Z0-9_]+)( *[ ,].*)?\}\s*$/gm; +export function languagesWithClasses( + markdown: string, +): Map { + const result = new Map(); + // Capture language and everything after it (including dot-joined classes like {python.marimo}) + const kChunkRegex = /^[\t >]*```+\s*\{([a-zA-Z0-9_]+)([^}]*)?\}\s*$/gm; kChunkRegex.lastIndex = 0; let match = kChunkRegex.exec(markdown); while (match) { const language = match[1].toLowerCase(); - if (!languages.has(language)) { - languages.add(language); + if (!result.has(language)) { + // Extract first class from attrs (group 2) + // Handles {python.marimo}, {python .marimo}, {python #id .marimo}, etc. + const attrs = match[2]; + const firstClass = attrs?.match(/\.([a-zA-Z][a-zA-Z0-9_-]*)/)?.[1]; + result.set(language, firstClass); } match = kChunkRegex.exec(markdown); } kChunkRegex.lastIndex = 0; - return languages; + return result; +} + +export function languagesInMarkdown(markdown: string): Set { + return new Set(languagesWithClasses(markdown).keys()); } diff --git a/src/execute/engine.ts b/src/execute/engine.ts index 44b4b1fa7b6..ae4ff262178 100644 --- a/src/execute/engine.ts +++ b/src/execute/engine.ts @@ -27,7 +27,10 @@ import { ExecutionTarget, kQmdExtensions, } from "./types.ts"; -import { languagesInMarkdown } from "../core/pandoc/pandoc-partition.ts"; +import { + languagesInMarkdown, + languagesWithClasses, +} from "../core/pandoc/pandoc-partition.ts"; import { languages as handlerLanguages } from "../core/handlers/base.ts"; import { RenderContext, RenderFlags } from "../command/render/types.ts"; import { mergeConfigs } from "../core/config.ts"; @@ -168,20 +171,31 @@ export function markdownExecutionEngine( } // if there are languages see if any engines want to claim them - const languages = languagesInMarkdown(markdown); + const languagesWithClassesMap = languagesWithClasses(markdown); + + // see if there is an engine that claims this language (highest score wins) + for (const [language, firstClass] of languagesWithClassesMap) { + let bestEngine: ExecutionEngineDiscovery | undefined; + let bestScore = 0; - // see if there is an engine that claims this language - for (const language of languages) { for (const [_, engine] of reorderedEngines) { - if (engine.claimsLanguage(language)) { - return engine.launch(engineProjectContext(project)); + const claim = engine.claimsLanguage(language, firstClass); + // Convert boolean to number for backwards compatibility + const score = typeof claim === "boolean" ? (claim ? 1 : 0) : claim; + if (score > bestScore) { + bestScore = score; + bestEngine = engine; } } + + if (bestEngine) { + return bestEngine.launch(engineProjectContext(project)); + } } const handlerLanguagesVal = handlerLanguages(); // if there is a non-cell handler language then this must be jupyter - for (const language of languages) { + for (const language of languagesWithClassesMap.keys()) { if (language !== "ojs" && !handlerLanguagesVal.includes(language)) { return jupyterEngineDiscovery.launch(engineProjectContext(project)); } diff --git a/src/execute/types.ts b/src/execute/types.ts index 0b6c1802c73..5ff00d617a0 100644 --- a/src/execute/types.ts +++ b/src/execute/types.ts @@ -46,7 +46,14 @@ export interface ExecutionEngineDiscovery { defaultContent: (kernel?: string) => string[]; validExtensions: () => string[]; claimsFile: (file: string, ext: string) => boolean; - claimsLanguage: (language: string) => boolean; + /** + * Whether this engine can handle the given language + * + * @param language - The language identifier (e.g., "python", "r", "julia") + * @param firstClass - Optional first class from code block attributes (e.g., "marimo" from {python .marimo}) + * @returns boolean for simple claim, or number for priority (higher wins, 0 = no claim, 1 = standard claim) + */ + claimsLanguage: (language: string, firstClass?: string) => boolean | number; canFreeze: boolean; generatesFigures: boolean; ignoreDirs?: () => string[] | undefined; diff --git a/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/_extension.yml b/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/_extension.yml new file mode 100644 index 00000000000..c639eeb22a3 --- /dev/null +++ b/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/_extension.yml @@ -0,0 +1,7 @@ +title: Foo Engine +author: Quarto Dev Team +version: 1.0.0 +quarto-required: ">=1.9.17" +contributes: + engines: + - path: foo-engine.js diff --git a/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/foo-engine.js b/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/foo-engine.js new file mode 100644 index 00000000000..b70c507bd15 --- /dev/null +++ b/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/foo-engine.js @@ -0,0 +1,92 @@ +// Minimal engine to test class-based engine override +// Claims {python.foo} blocks with priority 2 (higher than Jupyter's 1) + +let quarto; + +const fooEngineDiscovery = { + init: (quartoAPI) => { + quarto = quartoAPI; + }, + + name: "foo", + defaultExt: ".qmd", + defaultYaml: () => ["engine: foo"], + defaultContent: () => ["# Foo Engine Document"], + validExtensions: () => [".qmd"], + + claimsFile: (_file, _ext) => false, + + claimsLanguage: (language, firstClass) => { + // Claim python.foo with priority 2 (overrides Jupyter's 1) + if (language === "python" && firstClass === "foo") { + return 2; + } + return 0; + }, + + canFreeze: false, + generatesFigures: false, + + launch: (context) => { + return { + name: "foo", + canFreeze: false, + + markdownForFile: async (file) => { + return quarto.mappedString.fromFile(file); + }, + + target: async (file, _quiet, markdown) => { + if (!markdown) { + markdown = quarto.mappedString.fromFile(file); + } + const metadata = quarto.markdownRegex.extractYaml(markdown.value); + return { + source: file, + input: file, + markdown, + metadata, + }; + }, + + partitionedMarkdown: async (file) => { + const markdown = quarto.mappedString.fromFile(file); + return quarto.markdownRegex.partition(markdown.value); + }, + + execute: async (options) => { + // Replace python.foo code blocks with a marker div showing we processed them + let markdown = options.target.markdown.value; + + // Find and replace {python.foo} blocks with our marker output + const codeBlockRegex = /```\{python\.foo[^}]*\}\n([\s\S]*?)```/g; + markdown = markdown.replace(codeBlockRegex, (match, code) => { + return ` +::: {#foo-engine-marker .foo-engine-output} +**FOO ENGINE PROCESSED THIS BLOCK** + +Original code: +\`\`\`python +${code.trim()} +\`\`\` +::: +`; + }); + + return { + markdown: markdown, + supporting: [], + filters: [], + }; + }, + + dependencies: async (_options) => { + return { includes: {} }; + }, + + postprocess: async (_options) => {}, + }; + }, +}; + +export default fooEngineDiscovery; diff --git a/tests/docs/smoke-all/engine/class-override/test.qmd b/tests/docs/smoke-all/engine/class-override/test.qmd new file mode 100644 index 00000000000..fc15b7559c7 --- /dev/null +++ b/tests/docs/smoke-all/engine/class-override/test.qmd @@ -0,0 +1,27 @@ +--- +title: Engine Class Override Test +_quarto: + tests: + html: + ensureHtmlElements: + - + - "#foo-engine-marker" + - ".foo-engine-output" + - [] + ensureFileRegexMatches: + - + - "FOO ENGINE PROCESSED THIS BLOCK" + - [] +--- + +This document tests that `{python.foo}` blocks are processed by the foo-engine +instead of Jupyter, because foo-engine claims `python` with `firstClass === "foo"` +at priority 2 (higher than Jupyter's default of 1). + +```{python.foo} +x = 1 + 1 +print(x) +``` + +The block above should show "FOO ENGINE PROCESSED THIS BLOCK" instead of +actually executing the Python code. From 1b0e3dc682269b186db0b31318249612619e7f09 Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Tue, 30 Dec 2025 12:14:07 -0500 Subject: [PATCH 2/5] claude: Update engine template with new claimsLanguage signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the `quarto create extension engine` template to reflect the new claimsLanguage signature that accepts an optional firstClass parameter and returns boolean | number for priority-based selection. No behavioral change - just updated signature and documentation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../engine/src/qstart-filesafename-qend.ejs.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/resources/create/extensions/engine/src/qstart-filesafename-qend.ejs.ts b/src/resources/create/extensions/engine/src/qstart-filesafename-qend.ejs.ts index 516124c357d..59e17012f20 100644 --- a/src/resources/create/extensions/engine/src/qstart-filesafename-qend.ejs.ts +++ b/src/resources/create/extensions/engine/src/qstart-filesafename-qend.ejs.ts @@ -52,8 +52,14 @@ const exampleEngineDiscovery: ExecutionEngineDiscovery = { return false; }, - claimsLanguage: (language: string) => { - // This engine claims cells with its own language name + claimsLanguage: ( + language: string, + _firstClass?: string, + ): boolean | number => { + // This engine claims cells with its own language name. + // The optional firstClass parameter allows claiming based on code block class + // (e.g., {python.myengine} would have firstClass="myengine"). + // Return a number > 1 to override other engines that also claim this language. return language.toLowerCase() === kCellLanguage.toLowerCase(); }, From 0dfc3c8d1bde0a180d82dc85f5b87628fef31579 Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Tue, 30 Dec 2025 13:07:08 -0500 Subject: [PATCH 3/5] claude: refactor foo-engine test to use Quarto API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use breakQuartoMd API for cell iteration instead of raw regex, making the code more idiomatic and fixing Windows CI failures caused by line ending differences. Also fix test syntax to use {python .foo} (space-separated) which is recognized by breakQuartoMd. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../_extensions/foo-engine/foo-engine.js | 34 ++++++++++++------- .../smoke-all/engine/class-override/test.qmd | 2 +- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/foo-engine.js b/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/foo-engine.js index b70c507bd15..985b9bd5196 100644 --- a/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/foo-engine.js +++ b/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/foo-engine.js @@ -55,26 +55,36 @@ const fooEngineDiscovery = { }, execute: async (options) => { - // Replace python.foo code blocks with a marker div showing we processed them - let markdown = options.target.markdown.value; - - // Find and replace {python.foo} blocks with our marker output - const codeBlockRegex = /```\{python\.foo[^}]*\}\n([\s\S]*?)```/g; - markdown = markdown.replace(codeBlockRegex, (match, code) => { - return ` -::: {#foo-engine-marker .foo-engine-output} + const chunks = await quarto.markdownRegex.breakQuartoMd( + options.target.markdown, + ); + + const processedCells = []; + for (const cell of chunks.cells) { + if ( + typeof cell.cell_type === "object" && + cell.cell_type.language === "python" + ) { + const header = cell.sourceVerbatim.value.split(/\r?\n/)[0]; + const hasClassFoo = /\.foo\b/.test(header); + if (hasClassFoo) { + processedCells.push(`::: {#foo-engine-marker .foo-engine-output} **FOO ENGINE PROCESSED THIS BLOCK** Original code: \`\`\`python -${code.trim()} +${cell.source.value.trim()} \`\`\` ::: -`; - }); +`); + continue; + } + } + processedCells.push(cell.sourceVerbatim.value); + } return { - markdown: markdown, + markdown: processedCells.join(""), supporting: [], filters: [], }; diff --git a/tests/docs/smoke-all/engine/class-override/test.qmd b/tests/docs/smoke-all/engine/class-override/test.qmd index fc15b7559c7..c04d54fcaa6 100644 --- a/tests/docs/smoke-all/engine/class-override/test.qmd +++ b/tests/docs/smoke-all/engine/class-override/test.qmd @@ -18,7 +18,7 @@ This document tests that `{python.foo}` blocks are processed by the foo-engine instead of Jupyter, because foo-engine claims `python` with `firstClass === "foo"` at priority 2 (higher than Jupyter's default of 1). -```{python.foo} +```{python .foo} x = 1 + 1 print(x) ``` From 021ecceae33594bc4395fe7afaab7caccb1141cd Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Fri, 30 Jan 2026 16:38:44 -0500 Subject: [PATCH 4/5] claude: Support dot-joined language syntax like {python.marimo} Update regexes in break-quarto-md.ts and pandoc-partition.ts to allow dots in language names. This is compatible with pampa executable cell syntax and also with the preferred class syntax {python .marimo}. Co-Authored-By: Claude Opus 4.5 --- src/core/lib/break-quarto-md.ts | 2 +- src/core/pandoc/pandoc-partition.ts | 3 ++- .../break-quarto-md/break-quarto-md.test.ts | 16 ++++++++++++++ tests/unit/pandoc-partition.test.ts | 21 ++++++++++++++++++- 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/core/lib/break-quarto-md.ts b/src/core/lib/break-quarto-md.ts index 669747de4b4..73b3e23a18a 100644 --- a/src/core/lib/break-quarto-md.ts +++ b/src/core/lib/break-quarto-md.ts @@ -44,7 +44,7 @@ export async function breakQuartoMd( // regexes const yamlRegEx = /^---\s*$/; const startCodeCellRegEx = startCodeCellRegex || new RegExp( - "^\\s*(```+)\\s*\\{([=A-Za-z]+)( *[ ,].*)?\\}\\s*$", + "^\\s*(```+)\\s*\\{([=A-Za-z][=A-Za-z0-9._]*)( *[ ,].*)?\\}\\s*$", ); const startCodeRegEx = /^```/; const endCodeRegEx = /^\s*(```+)\s*$/; diff --git a/src/core/pandoc/pandoc-partition.ts b/src/core/pandoc/pandoc-partition.ts index 24a23479fa2..08cbdf86572 100644 --- a/src/core/pandoc/pandoc-partition.ts +++ b/src/core/pandoc/pandoc-partition.ts @@ -119,7 +119,8 @@ export function languagesWithClasses( ): Map { const result = new Map(); // Capture language and everything after it (including dot-joined classes like {python.marimo}) - const kChunkRegex = /^[\t >]*```+\s*\{([a-zA-Z0-9_]+)([^}]*)?\}\s*$/gm; + const kChunkRegex = + /^[\t >]*```+\s*\{([a-zA-Z][a-zA-Z0-9_.]*)([^}]*)?\}\s*$/gm; kChunkRegex.lastIndex = 0; let match = kChunkRegex.exec(markdown); while (match) { diff --git a/tests/unit/break-quarto-md/break-quarto-md.test.ts b/tests/unit/break-quarto-md/break-quarto-md.test.ts index 2e2a8ee1db3..5de38c43125 100644 --- a/tests/unit/break-quarto-md/break-quarto-md.test.ts +++ b/tests/unit/break-quarto-md/break-quarto-md.test.ts @@ -133,3 +133,19 @@ And what about this? const cells = (await breakQuartoMd(qmd, false)).cells; assert(cells.length <= 2 || cells[2].cell_type === "markdown"); }); + +unitTest("break-quarto-md - dot-joined language", async () => { + await initYamlIntelligenceResourcesFromFilesystem(); + const qmd = `\`\`\`{python.marimo} +x = 1 +\`\`\` +`; + const cells = (await breakQuartoMd(qmd, false)).cells; + // First cell should be the code cell with language "python.marimo" + assert(cells.length >= 1, "Should have at least one cell"); + assert(typeof cells[0].cell_type === "object", "First cell should be a code cell"); + assert( + (cells[0].cell_type as { language: string }).language === "python.marimo", + "Language should be 'python.marimo'", + ); +}); diff --git a/tests/unit/pandoc-partition.test.ts b/tests/unit/pandoc-partition.test.ts index 5355e4fce52..44ddd6881db 100644 --- a/tests/unit/pandoc-partition.test.ts +++ b/tests/unit/pandoc-partition.test.ts @@ -6,7 +6,7 @@ */ import { assert } from "testing/asserts"; import { Metadata } from "../../src/config/types.ts"; -import { partitionMarkdown } from "../../src/core/pandoc/pandoc-partition.ts"; +import { languagesWithClasses, partitionMarkdown } from "../../src/core/pandoc/pandoc-partition.ts"; import { unitTest } from "../test.ts"; // deno-lint-ignore require-await @@ -54,3 +54,22 @@ unitTest("partitionYaml", async () => { "Heading missing attribute value", ); }); + +// deno-lint-ignore require-await +unitTest("languagesWithClasses - dot-joined syntax", async () => { + const md = `\`\`\`{python.marimo} +x = 1 +\`\`\` + +\`\`\`{python .foo} +y = 2 +\`\`\` +`; + const result = languagesWithClasses(md); + // {python.marimo} → language "python.marimo", no class + assert(result.has("python.marimo"), "Should have language 'python.marimo'"); + assert(result.get("python.marimo") === undefined, "python.marimo should have no class"); + // {python .foo} → language "python", class "foo" + assert(result.has("python"), "Should have language 'python'"); + assert(result.get("python") === "foo", "python should have class 'foo'"); +}); From a71b54ebf779abdc49e8d8de02855cccf8f22807 Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Fri, 30 Jan 2026 16:53:23 -0500 Subject: [PATCH 5/5] claude: Refine claimsLanguage semantics: false skips, any number claims - false means "don't claim" (skip engine entirely) - true means claim with priority 1 - any number means claim with that priority (higher wins) Changed bestScore from 0 to -Infinity so any number can win. Added class-override-twice test verifying priority 3 beats priority 2. Co-Authored-By: Claude Opus 4.5 --- packages/quarto-types/src/execution-engine.ts | 2 +- src/execute/engine.ts | 10 +- src/execute/types.ts | 2 +- .../src/qstart-filesafename-qend.ejs.ts | 4 +- .../_extensions/bar-engine/_extension.yml | 7 ++ .../_extensions/bar-engine/bar-engine.js | 102 ++++++++++++++++++ .../_extensions/foo-engine/_extension.yml | 7 ++ .../_extensions/foo-engine/foo-engine.js | 102 ++++++++++++++++++ .../engine/class-override-twice/test.qmd | 27 +++++ .../_extensions/foo-engine/foo-engine.js | 2 +- 10 files changed, 257 insertions(+), 8 deletions(-) create mode 100644 tests/docs/smoke-all/engine/class-override-twice/_extensions/bar-engine/_extension.yml create mode 100644 tests/docs/smoke-all/engine/class-override-twice/_extensions/bar-engine/bar-engine.js create mode 100644 tests/docs/smoke-all/engine/class-override-twice/_extensions/foo-engine/_extension.yml create mode 100644 tests/docs/smoke-all/engine/class-override-twice/_extensions/foo-engine/foo-engine.js create mode 100644 tests/docs/smoke-all/engine/class-override-twice/test.qmd diff --git a/packages/quarto-types/src/execution-engine.ts b/packages/quarto-types/src/execution-engine.ts index 87d8b2821be..11bc4229bc5 100644 --- a/packages/quarto-types/src/execution-engine.ts +++ b/packages/quarto-types/src/execution-engine.ts @@ -98,7 +98,7 @@ export interface ExecutionEngineDiscovery { * * @param language - The language identifier (e.g., "python", "r", "julia") * @param firstClass - Optional first class from code block attributes (e.g., "marimo" from {python .marimo}) - * @returns boolean for simple claim, or number for priority (higher wins, 0 = no claim, 1 = standard claim) + * @returns false to skip (don't claim), true to claim with priority 1, or any number for custom priority (higher wins) */ claimsLanguage: (language: string, firstClass?: string) => boolean | number; diff --git a/src/execute/engine.ts b/src/execute/engine.ts index ae4ff262178..0afab0a88f2 100644 --- a/src/execute/engine.ts +++ b/src/execute/engine.ts @@ -176,12 +176,16 @@ export function markdownExecutionEngine( // see if there is an engine that claims this language (highest score wins) for (const [language, firstClass] of languagesWithClassesMap) { let bestEngine: ExecutionEngineDiscovery | undefined; - let bestScore = 0; + let bestScore = -Infinity; for (const [_, engine] of reorderedEngines) { const claim = engine.claimsLanguage(language, firstClass); - // Convert boolean to number for backwards compatibility - const score = typeof claim === "boolean" ? (claim ? 1 : 0) : claim; + // false means "don't claim", skip this engine entirely + if (claim === false) { + continue; + } + // true -> score 1, number -> use as score + const score = claim === true ? 1 : claim; if (score > bestScore) { bestScore = score; bestEngine = engine; diff --git a/src/execute/types.ts b/src/execute/types.ts index 5ff00d617a0..6958f52a95a 100644 --- a/src/execute/types.ts +++ b/src/execute/types.ts @@ -51,7 +51,7 @@ export interface ExecutionEngineDiscovery { * * @param language - The language identifier (e.g., "python", "r", "julia") * @param firstClass - Optional first class from code block attributes (e.g., "marimo" from {python .marimo}) - * @returns boolean for simple claim, or number for priority (higher wins, 0 = no claim, 1 = standard claim) + * @returns false to skip (don't claim), true to claim with priority 1, or any number for custom priority (higher wins) */ claimsLanguage: (language: string, firstClass?: string) => boolean | number; canFreeze: boolean; diff --git a/src/resources/create/extensions/engine/src/qstart-filesafename-qend.ejs.ts b/src/resources/create/extensions/engine/src/qstart-filesafename-qend.ejs.ts index 59e17012f20..5997f476684 100644 --- a/src/resources/create/extensions/engine/src/qstart-filesafename-qend.ejs.ts +++ b/src/resources/create/extensions/engine/src/qstart-filesafename-qend.ejs.ts @@ -58,8 +58,8 @@ const exampleEngineDiscovery: ExecutionEngineDiscovery = { ): boolean | number => { // This engine claims cells with its own language name. // The optional firstClass parameter allows claiming based on code block class - // (e.g., {python.myengine} would have firstClass="myengine"). - // Return a number > 1 to override other engines that also claim this language. + // (e.g., {python .myengine} would have firstClass="myengine"). + // Return false to skip, true to claim with priority 1, or any number for custom priority. return language.toLowerCase() === kCellLanguage.toLowerCase(); }, diff --git a/tests/docs/smoke-all/engine/class-override-twice/_extensions/bar-engine/_extension.yml b/tests/docs/smoke-all/engine/class-override-twice/_extensions/bar-engine/_extension.yml new file mode 100644 index 00000000000..ab4481295ca --- /dev/null +++ b/tests/docs/smoke-all/engine/class-override-twice/_extensions/bar-engine/_extension.yml @@ -0,0 +1,7 @@ +title: Bar Engine +author: Quarto Dev Team +version: 1.0.0 +quarto-required: ">=1.9.17" +contributes: + engines: + - path: bar-engine.js diff --git a/tests/docs/smoke-all/engine/class-override-twice/_extensions/bar-engine/bar-engine.js b/tests/docs/smoke-all/engine/class-override-twice/_extensions/bar-engine/bar-engine.js new file mode 100644 index 00000000000..cfb8f3eb9db --- /dev/null +++ b/tests/docs/smoke-all/engine/class-override-twice/_extensions/bar-engine/bar-engine.js @@ -0,0 +1,102 @@ +// Engine to test priority-based engine override +// Claims {python.foo} blocks with priority 3 (higher than foo-engine's 2) + +let quarto; + +const barEngineDiscovery = { + init: (quartoAPI) => { + quarto = quartoAPI; + }, + + name: "bar", + defaultExt: ".qmd", + defaultYaml: () => ["engine: bar"], + defaultContent: () => ["# Bar Engine Document"], + validExtensions: () => [".qmd"], + + claimsFile: (_file, _ext) => false, + + claimsLanguage: (language, firstClass) => { + // Claim python.foo with priority 3 (overrides foo-engine's 2) + if (language === "python" && firstClass === "foo") { + return 3; + } + return false; // Don't claim + }, + + canFreeze: false, + generatesFigures: false, + + launch: (context) => { + return { + name: "bar", + canFreeze: false, + + markdownForFile: async (file) => { + return quarto.mappedString.fromFile(file); + }, + + target: async (file, _quiet, markdown) => { + if (!markdown) { + markdown = quarto.mappedString.fromFile(file); + } + const metadata = quarto.markdownRegex.extractYaml(markdown.value); + return { + source: file, + input: file, + markdown, + metadata, + }; + }, + + partitionedMarkdown: async (file) => { + const markdown = quarto.mappedString.fromFile(file); + return quarto.markdownRegex.partition(markdown.value); + }, + + execute: async (options) => { + const chunks = await quarto.markdownRegex.breakQuartoMd( + options.target.markdown, + ); + + const processedCells = []; + for (const cell of chunks.cells) { + if ( + typeof cell.cell_type === "object" && + cell.cell_type.language === "python" + ) { + const header = cell.sourceVerbatim.value.split(/\r?\n/)[0]; + const hasClassFoo = /\.foo\b/.test(header); + if (hasClassFoo) { + processedCells.push(`::: {#bar-engine-marker .bar-engine-output} +**BAR ENGINE PROCESSED THIS BLOCK** + +Original code: +\`\`\`python +${cell.source.value.trim()} +\`\`\` +::: +`); + continue; + } + } + processedCells.push(cell.sourceVerbatim.value); + } + + return { + markdown: processedCells.join(""), + supporting: [], + filters: [], + }; + }, + + dependencies: async (_options) => { + return { includes: {} }; + }, + + postprocess: async (_options) => {}, + }; + }, +}; + +export default barEngineDiscovery; diff --git a/tests/docs/smoke-all/engine/class-override-twice/_extensions/foo-engine/_extension.yml b/tests/docs/smoke-all/engine/class-override-twice/_extensions/foo-engine/_extension.yml new file mode 100644 index 00000000000..c639eeb22a3 --- /dev/null +++ b/tests/docs/smoke-all/engine/class-override-twice/_extensions/foo-engine/_extension.yml @@ -0,0 +1,7 @@ +title: Foo Engine +author: Quarto Dev Team +version: 1.0.0 +quarto-required: ">=1.9.17" +contributes: + engines: + - path: foo-engine.js diff --git a/tests/docs/smoke-all/engine/class-override-twice/_extensions/foo-engine/foo-engine.js b/tests/docs/smoke-all/engine/class-override-twice/_extensions/foo-engine/foo-engine.js new file mode 100644 index 00000000000..d4c7ff577fa --- /dev/null +++ b/tests/docs/smoke-all/engine/class-override-twice/_extensions/foo-engine/foo-engine.js @@ -0,0 +1,102 @@ +// Minimal engine to test class-based engine override +// Claims {python.foo} blocks with priority 2 (higher than Jupyter's 1) + +let quarto; + +const fooEngineDiscovery = { + init: (quartoAPI) => { + quarto = quartoAPI; + }, + + name: "foo", + defaultExt: ".qmd", + defaultYaml: () => ["engine: foo"], + defaultContent: () => ["# Foo Engine Document"], + validExtensions: () => [".qmd"], + + claimsFile: (_file, _ext) => false, + + claimsLanguage: (language, firstClass) => { + // Claim python.foo with priority 2 (overrides Jupyter's 1) + if (language === "python" && firstClass === "foo") { + return 2; + } + return false; // Don't claim + }, + + canFreeze: false, + generatesFigures: false, + + launch: (context) => { + return { + name: "foo", + canFreeze: false, + + markdownForFile: async (file) => { + return quarto.mappedString.fromFile(file); + }, + + target: async (file, _quiet, markdown) => { + if (!markdown) { + markdown = quarto.mappedString.fromFile(file); + } + const metadata = quarto.markdownRegex.extractYaml(markdown.value); + return { + source: file, + input: file, + markdown, + metadata, + }; + }, + + partitionedMarkdown: async (file) => { + const markdown = quarto.mappedString.fromFile(file); + return quarto.markdownRegex.partition(markdown.value); + }, + + execute: async (options) => { + const chunks = await quarto.markdownRegex.breakQuartoMd( + options.target.markdown, + ); + + const processedCells = []; + for (const cell of chunks.cells) { + if ( + typeof cell.cell_type === "object" && + cell.cell_type.language === "python" + ) { + const header = cell.sourceVerbatim.value.split(/\r?\n/)[0]; + const hasClassFoo = /\.foo\b/.test(header); + if (hasClassFoo) { + processedCells.push(`::: {#foo-engine-marker .foo-engine-output} +**FOO ENGINE PROCESSED THIS BLOCK** + +Original code: +\`\`\`python +${cell.source.value.trim()} +\`\`\` +::: +`); + continue; + } + } + processedCells.push(cell.sourceVerbatim.value); + } + + return { + markdown: processedCells.join(""), + supporting: [], + filters: [], + }; + }, + + dependencies: async (_options) => { + return { includes: {} }; + }, + + postprocess: async (_options) => {}, + }; + }, +}; + +export default fooEngineDiscovery; diff --git a/tests/docs/smoke-all/engine/class-override-twice/test.qmd b/tests/docs/smoke-all/engine/class-override-twice/test.qmd new file mode 100644 index 00000000000..baaa5745d65 --- /dev/null +++ b/tests/docs/smoke-all/engine/class-override-twice/test.qmd @@ -0,0 +1,27 @@ +--- +title: Engine Class Override Priority Test +_quarto: + tests: + html: + ensureHtmlElements: + - + - "#bar-engine-marker" + - ".bar-engine-output" + - [] + ensureFileRegexMatches: + - + - "BAR ENGINE PROCESSED THIS BLOCK" + - [] +--- + +This document tests that `{python .foo}` blocks are processed by bar-engine +(priority 3) instead of foo-engine (priority 2), verifying that higher +priority numbers win. + +```{python .foo} +x = 1 + 1 +print(x) +``` + +The block above should show "BAR ENGINE PROCESSED THIS BLOCK" because +bar-engine claims with priority 3, which is higher than foo-engine's priority 2. diff --git a/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/foo-engine.js b/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/foo-engine.js index 985b9bd5196..d4c7ff577fa 100644 --- a/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/foo-engine.js +++ b/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/foo-engine.js @@ -21,7 +21,7 @@ const fooEngineDiscovery = { if (language === "python" && firstClass === "foo") { return 2; } - return 0; + return false; // Don't claim }, canFreeze: false,