Skip to content

Commit dcf98e1

Browse files
Merge pull request #13838 from quarto-dev/feature/engine-claim-class
feature: engine claim by language and class
2 parents 50a7ea0 + 6941757 commit dcf98e1

20 files changed

Lines changed: 507 additions & 21 deletions

File tree

packages/quarto-types/dist/index.d.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,6 +831,13 @@ export interface QuartoAPI {
831831
* @returns Set of language identifiers found in fenced code blocks
832832
*/
833833
getLanguages: (markdown: string) => Set<string>;
834+
/**
835+
* Extract programming languages and their first class from code blocks
836+
*
837+
* @param markdown - Markdown content to analyze
838+
* @returns Map of language identifiers to their first class (or undefined)
839+
*/
840+
getLanguagesWithClasses: (markdown: string) => Map<string, string | undefined>;
834841
/**
835842
* Break Quarto markdown into cells
836843
*
@@ -1566,8 +1573,12 @@ export interface ExecutionEngineDiscovery {
15661573
claimsFile: (file: string, ext: string) => boolean;
15671574
/**
15681575
* Whether this engine can handle the given language
1576+
*
1577+
* @param language - The language identifier (e.g., "python", "r", "julia")
1578+
* @param firstClass - Optional first class from code block attributes (e.g., "marimo" from {python .marimo})
1579+
* @returns boolean for simple claim, or number for priority (higher wins, 0 = no claim, 1 = standard claim)
15691580
*/
1570-
claimsLanguage: (language: string) => boolean;
1581+
claimsLanguage: (language: string, firstClass?: string) => boolean | number;
15711582
/**
15721583
* Whether this engine supports freezing
15731584
*/

packages/quarto-types/src/execution-engine.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,12 @@ export interface ExecutionEngineDiscovery {
9595

9696
/**
9797
* Whether this engine can handle the given language
98+
*
99+
* @param language - The language identifier (e.g., "python", "r", "julia")
100+
* @param firstClass - Optional first class from code block attributes (e.g., "marimo" from {python .marimo})
101+
* @returns false to skip (don't claim), true to claim with priority 1, or any number for custom priority (higher wins)
98102
*/
99-
claimsLanguage: (language: string) => boolean;
103+
claimsLanguage: (language: string, firstClass?: string) => boolean | number;
100104

101105
/**
102106
* Whether this engine supports freezing

packages/quarto-types/src/quarto-api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ export interface QuartoAPI {
6666
*/
6767
getLanguages: (markdown: string) => Set<string>;
6868

69+
/**
70+
* Extract programming languages and their first class from code blocks
71+
*
72+
* @param markdown - Markdown content to analyze
73+
* @returns Map of language identifiers to their first class (or undefined)
74+
*/
75+
getLanguagesWithClasses: (markdown: string) => Map<string, string | undefined>;
76+
6977
/**
7078
* Break Quarto markdown into cells
7179
*

src/core/api/markdown-regex.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { MarkdownRegexNamespace } from "./types.ts";
77
import { readYamlFromMarkdown } from "../yaml.ts";
88
import {
99
languagesInMarkdown,
10+
languagesWithClasses,
1011
partitionMarkdown,
1112
} from "../pandoc/pandoc-partition.ts";
1213
import { breakQuartoMd } from "../lib/break-quarto-md.ts";
@@ -17,6 +18,7 @@ globalRegistry.register("markdownRegex", (): MarkdownRegexNamespace => {
1718
extractYaml: readYamlFromMarkdown,
1819
partition: partitionMarkdown,
1920
getLanguages: languagesInMarkdown,
21+
getLanguagesWithClasses: languagesWithClasses,
2022
breakQuartoMd,
2123
};
2224
});

src/core/api/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ export interface MarkdownRegexNamespace {
3838
extractYaml: (markdown: string) => Metadata;
3939
partition: (markdown: string) => PartitionedMarkdown;
4040
getLanguages: (markdown: string) => Set<string>;
41+
getLanguagesWithClasses: (
42+
markdown: string,
43+
) => Map<string, string | undefined>;
4144
breakQuartoMd: (
4245
src: string | MappedString,
4346
validate?: boolean,

src/core/lib/break-quarto-md.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export async function breakQuartoMd(
4444
// regexes
4545
const yamlRegEx = /^---\s*$/;
4646
const startCodeCellRegEx = startCodeCellRegex || new RegExp(
47-
"^\\s*(```+)\\s*\\{([=A-Za-z]+)( *[ ,].*)?\\}\\s*$",
47+
"^\\s*(```+)\\s*\\{([=A-Za-z][=A-Za-z0-9._]*)( *[ ,].*)?\\}\\s*$",
4848
);
4949
const startCodeRegEx = /^```/;
5050
const endCodeRegEx = /^\s*(```+)\s*$/;

src/core/pandoc/pandoc-partition.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -114,19 +114,30 @@ export function languagesInMarkdownFile(file: string) {
114114
return languagesInMarkdown(Deno.readTextFileSync(file));
115115
}
116116

117-
export function languagesInMarkdown(markdown: string) {
118-
// see if there are any code chunks in the file
119-
const languages = new Set<string>();
120-
const kChunkRegex = /^[\t >]*```+\s*\{([a-zA-Z0-9_]+)( *[ ,].*)?\}\s*$/gm;
117+
export function languagesWithClasses(
118+
markdown: string,
119+
): Map<string, string | undefined> {
120+
const result = new Map<string, string | undefined>();
121+
// Capture language and everything after it (including dot-joined classes like {python.marimo})
122+
const kChunkRegex =
123+
/^[\t >]*```+\s*\{([a-zA-Z][a-zA-Z0-9_.]*)([^}]*)?\}\s*$/gm;
121124
kChunkRegex.lastIndex = 0;
122125
let match = kChunkRegex.exec(markdown);
123126
while (match) {
124127
const language = match[1].toLowerCase();
125-
if (!languages.has(language)) {
126-
languages.add(language);
128+
if (!result.has(language)) {
129+
// Extract first class from attrs (group 2)
130+
// Handles {python.marimo}, {python .marimo}, {python #id .marimo}, etc.
131+
const attrs = match[2];
132+
const firstClass = attrs?.match(/\.([a-zA-Z][a-zA-Z0-9_-]*)/)?.[1];
133+
result.set(language, firstClass);
127134
}
128135
match = kChunkRegex.exec(markdown);
129136
}
130137
kChunkRegex.lastIndex = 0;
131-
return languages;
138+
return result;
139+
}
140+
141+
export function languagesInMarkdown(markdown: string): Set<string> {
142+
return new Set(languagesWithClasses(markdown).keys());
132143
}

src/execute/engine.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ import {
2727
ExecutionTarget,
2828
kQmdExtensions,
2929
} from "./types.ts";
30-
import { languagesInMarkdown } from "../core/pandoc/pandoc-partition.ts";
30+
import {
31+
languagesInMarkdown,
32+
languagesWithClasses,
33+
} from "../core/pandoc/pandoc-partition.ts";
3134
import { languages as handlerLanguages } from "../core/handlers/base.ts";
3235
import { RenderContext, RenderFlags } from "../command/render/types.ts";
3336
import { mergeConfigs } from "../core/config.ts";
@@ -168,20 +171,35 @@ export function markdownExecutionEngine(
168171
}
169172

170173
// if there are languages see if any engines want to claim them
171-
const languages = languagesInMarkdown(markdown);
174+
const languagesWithClassesMap = languagesWithClasses(markdown);
175+
176+
// see if there is an engine that claims this language (highest score wins)
177+
for (const [language, firstClass] of languagesWithClassesMap) {
178+
let bestEngine: ExecutionEngineDiscovery | undefined;
179+
let bestScore = -Infinity;
172180

173-
// see if there is an engine that claims this language
174-
for (const language of languages) {
175181
for (const [_, engine] of reorderedEngines) {
176-
if (engine.claimsLanguage(language)) {
177-
return engine.launch(engineProjectContext(project));
182+
const claim = engine.claimsLanguage(language, firstClass);
183+
// false means "don't claim", skip this engine entirely
184+
if (claim === false) {
185+
continue;
186+
}
187+
// true -> score 1, number -> use as score
188+
const score = claim === true ? 1 : claim;
189+
if (score > bestScore) {
190+
bestScore = score;
191+
bestEngine = engine;
178192
}
179193
}
194+
195+
if (bestEngine) {
196+
return bestEngine.launch(engineProjectContext(project));
197+
}
180198
}
181199

182200
const handlerLanguagesVal = handlerLanguages();
183201
// if there is a non-cell handler language then this must be jupyter
184-
for (const language of languages) {
202+
for (const language of languagesWithClassesMap.keys()) {
185203
if (language !== "ojs" && !handlerLanguagesVal.includes(language)) {
186204
return jupyterEngineDiscovery.launch(engineProjectContext(project));
187205
}

src/execute/types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,14 @@ export interface ExecutionEngineDiscovery {
4646
defaultContent: (kernel?: string) => string[];
4747
validExtensions: () => string[];
4848
claimsFile: (file: string, ext: string) => boolean;
49-
claimsLanguage: (language: string) => boolean;
49+
/**
50+
* Whether this engine can handle the given language
51+
*
52+
* @param language - The language identifier (e.g., "python", "r", "julia")
53+
* @param firstClass - Optional first class from code block attributes (e.g., "marimo" from {python .marimo})
54+
* @returns false to skip (don't claim), true to claim with priority 1, or any number for custom priority (higher wins)
55+
*/
56+
claimsLanguage: (language: string, firstClass?: string) => boolean | number;
5057
canFreeze: boolean;
5158
generatesFigures: boolean;
5259
ignoreDirs?: () => string[] | undefined;

src/resources/create/extensions/engine/src/qstart-filesafename-qend.ejs.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,14 @@ const exampleEngineDiscovery: ExecutionEngineDiscovery = {
5252
return false;
5353
},
5454

55-
claimsLanguage: (language: string) => {
56-
// This engine claims cells with its own language name
55+
claimsLanguage: (
56+
language: string,
57+
_firstClass?: string,
58+
): boolean | number => {
59+
// This engine claims cells with its own language name.
60+
// The optional firstClass parameter allows claiming based on code block class
61+
// (e.g., {python .myengine} would have firstClass="myengine").
62+
// Return false to skip, true to claim with priority 1, or any number for custom priority.
5763
return language.toLowerCase() === kCellLanguage.toLowerCase();
5864
},
5965

0 commit comments

Comments
 (0)