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..11bc4229bc5 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 false to skip (don't claim), true to claim with priority 1, or any number for custom priority (higher wins) */ - 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/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 7271c96b9cc..08cbdf86572 100644 --- a/src/core/pandoc/pandoc-partition.ts +++ b/src/core/pandoc/pandoc-partition.ts @@ -114,19 +114,30 @@ 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-Z][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..0afab0a88f2 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,35 @@ 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 = -Infinity; - // 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); + // 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; } } + + 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..6958f52a95a 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 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; generatesFigures: boolean; ignoreDirs?: () => string[] | undefined; 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..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 @@ -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 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/_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..d4c7ff577fa --- /dev/null +++ b/tests/docs/smoke-all/engine/class-override/_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/test.qmd b/tests/docs/smoke-all/engine/class-override/test.qmd new file mode 100644 index 00000000000..c04d54fcaa6 --- /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. 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'"); +});