Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/execute/engine.ts
6458 views
1
/*
2
* engine.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { extname, join, toFileUrl } from "../deno_ral/path.ts";
8
9
import * as ld from "../core/lodash.ts";
10
11
import {
12
partitionYamlFrontMatter,
13
readYamlFromMarkdown,
14
} from "../core/yaml.ts";
15
import { dirAndStem } from "../core/path.ts";
16
17
import { metadataAsFormat } from "../config/metadata.ts";
18
import { kBaseFormat, kEngine } from "../config/constants.ts";
19
20
import { knitrEngineDiscovery } from "./rmd.ts";
21
import { jupyterEngineDiscovery } from "./jupyter/jupyter.ts";
22
import { ExternalEngine } from "../resources/types/schema-types.ts";
23
import { kMdExtensions, markdownEngineDiscovery } from "./markdown.ts";
24
import {
25
ExecutionEngineDiscovery,
26
ExecutionEngineInstance,
27
ExecutionTarget,
28
kQmdExtensions,
29
} from "./types.ts";
30
import {
31
languagesInMarkdown,
32
languagesWithClasses,
33
} from "../core/pandoc/pandoc-partition.ts";
34
import { languages as handlerLanguages } from "../core/handlers/base.ts";
35
import { RenderContext, RenderFlags } from "../command/render/types.ts";
36
import { mergeConfigs } from "../core/config.ts";
37
import { ProjectContext } from "../project/types.ts";
38
import { pandocBuiltInFormats } from "../core/pandoc/pandoc-formats.ts";
39
import { gitignoreEntries } from "../project/project-gitignore.ts";
40
import { ensureFileInformationCache } from "../project/project-shared.ts";
41
import { engineProjectContext } from "../project/engine-project-context.ts";
42
import { getQuartoAPI } from "../core/api/index.ts";
43
import { satisfies } from "semver/mod.ts";
44
import { quartoConfig } from "../core/quarto.ts";
45
46
const kEngines: Map<string, ExecutionEngineDiscovery> = new Map();
47
48
// Standard engines to register on first resolveEngines() call
49
const kStandardEngines: ExecutionEngineDiscovery[] = [
50
knitrEngineDiscovery,
51
jupyterEngineDiscovery,
52
markdownEngineDiscovery,
53
];
54
55
let enginesRegistered = false;
56
57
/**
58
* Check if an engine's Quarto version requirement is satisfied
59
* @param engine The engine to check
60
* @throws Error if the version requirement is not met or is invalid
61
*/
62
function checkEngineVersionRequirement(engine: ExecutionEngineDiscovery): void {
63
if (engine.quartoRequired) {
64
const ourVersion = quartoConfig.version();
65
try {
66
if (!satisfies(ourVersion, engine.quartoRequired)) {
67
throw new Error(
68
`Execution engine '${engine.name}' requires Quarto ${engine.quartoRequired}, ` +
69
`but you have ${ourVersion}. Please upgrade Quarto to use this engine.`,
70
);
71
}
72
} catch (e) {
73
if (e instanceof Error && e.message.includes("Invalid")) {
74
throw new Error(
75
`Execution engine '${engine.name}' has invalid version constraint: ${engine.quartoRequired}`,
76
);
77
}
78
throw e;
79
}
80
}
81
}
82
83
export function executionEngines(): ExecutionEngineDiscovery[] {
84
return [...kEngines.values()];
85
}
86
87
export function executionEngine(name: string) {
88
return kEngines.get(name);
89
}
90
91
export function registerExecutionEngine(engine: ExecutionEngineDiscovery) {
92
if (kEngines.has(engine.name)) {
93
throw new Error(`Execution engine ${engine.name} already registered`);
94
}
95
96
// Check if engine's Quarto version requirement is satisfied
97
checkEngineVersionRequirement(engine);
98
99
kEngines.set(engine.name, engine);
100
if (engine.init) {
101
engine.init(getQuartoAPI());
102
}
103
}
104
105
export function executionEngineKeepMd(context: RenderContext) {
106
const { input } = context.target;
107
const baseFormat = context.format.identifier[kBaseFormat] || "html";
108
const keepSuffix = `.${baseFormat}.md`;
109
if (!input.endsWith(keepSuffix)) {
110
const [dir, stem] = dirAndStem(input);
111
return join(dir, stem + keepSuffix);
112
}
113
}
114
115
// for the project crawl
116
export function executionEngineIntermediateFiles(
117
engine: ExecutionEngineInstance,
118
input: string,
119
) {
120
// all files of the form e.g. .html.md or -html.md are interemediate
121
const files: string[] = [];
122
const [dir, stem] = dirAndStem(input);
123
files.push(
124
...pandocBuiltInFormats()
125
.flatMap((format) => [`-${format}.md`, `.${format}.md`])
126
.map((suffix) => join(dir, stem + suffix)),
127
);
128
129
// additional engine-specific intermediates (e.g. .ipynb for jupyter)
130
const engineKeepFiles = engine.intermediateFiles
131
? engine.intermediateFiles(input)
132
: undefined;
133
if (engineKeepFiles) {
134
return files.concat(engineKeepFiles);
135
} else {
136
return files;
137
}
138
}
139
140
export function engineValidExtensions(): string[] {
141
return ld.uniq(
142
executionEngines().flatMap((engine) => engine.validExtensions()),
143
);
144
}
145
146
export function markdownExecutionEngine(
147
project: ProjectContext,
148
markdown: string,
149
reorderedEngines: Map<string, ExecutionEngineDiscovery>,
150
flags?: RenderFlags,
151
): ExecutionEngineInstance {
152
// read yaml and see if the engine is declared in yaml
153
// (note that if the file were a non text-file like ipynb
154
// it would have already been claimed via extension)
155
const result = partitionYamlFrontMatter(markdown);
156
if (result) {
157
let yaml = readYamlFromMarkdown(result.yaml);
158
if (yaml) {
159
// merge in command line fags
160
yaml = mergeConfigs(yaml, flags?.metadata);
161
for (const [_, engine] of reorderedEngines) {
162
if (yaml[engine.name]) {
163
return engine.launch(engineProjectContext(project));
164
}
165
const format = metadataAsFormat(yaml);
166
if (format.execute?.[kEngine] === engine.name) {
167
return engine.launch(engineProjectContext(project));
168
}
169
}
170
}
171
}
172
173
// if there are languages see if any engines want to claim them
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;
180
181
for (const [_, engine] of reorderedEngines) {
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;
192
}
193
}
194
195
if (bestEngine) {
196
return bestEngine.launch(engineProjectContext(project));
197
}
198
}
199
200
const handlerLanguagesVal = handlerLanguages();
201
// if there is a non-cell handler language then this must be jupyter
202
for (const language of languagesWithClassesMap.keys()) {
203
if (language !== "ojs" && !handlerLanguagesVal.includes(language)) {
204
return jupyterEngineDiscovery.launch(engineProjectContext(project));
205
}
206
}
207
208
// if there is no computational engine discovered then bind
209
// to the markdown engine;
210
return markdownEngineDiscovery.launch(engineProjectContext(project));
211
}
212
213
export async function resolveEngines(project: ProjectContext) {
214
// Register standard engines on first call
215
if (!enginesRegistered) {
216
enginesRegistered = true;
217
for (const engine of kStandardEngines) {
218
registerExecutionEngine(engine);
219
}
220
}
221
222
const userSpecifiedOrder: string[] = [];
223
const projectEngines = project.config?.engines as
224
| (string | ExternalEngine)[]
225
| undefined;
226
227
for (const engine of projectEngines ?? []) {
228
if (typeof engine === "object") {
229
try {
230
const extEngine = (await import(toFileUrl(engine.path).href))
231
.default as ExecutionEngineDiscovery;
232
233
// Validate that the module exports an ExecutionEngineDiscovery object
234
if (!extEngine) {
235
throw new Error(
236
`Engine module must export a default ExecutionEngineDiscovery object. ` +
237
`Check that your engine file exports 'export default yourEngineDiscovery;'`,
238
);
239
}
240
241
// Validate required properties
242
const missing: string[] = [];
243
if (!extEngine.name) missing.push("name");
244
if (!extEngine.launch) missing.push("launch");
245
if (!extEngine.claimsLanguage) missing.push("claimsLanguage");
246
247
if (missing.length > 0) {
248
throw new Error(
249
`Engine is missing required properties: ${missing.join(", ")}. ` +
250
`Ensure your engine implements the ExecutionEngineDiscovery interface.`,
251
);
252
}
253
254
// Check if engine's Quarto version requirement is satisfied
255
checkEngineVersionRequirement(extEngine);
256
257
userSpecifiedOrder.push(extEngine.name);
258
kEngines.set(extEngine.name, extEngine);
259
if (extEngine.init) {
260
extEngine.init(getQuartoAPI());
261
}
262
} catch (err: any) {
263
// Throw error for engine import failures as this is a serious configuration issue
264
throw new Error(
265
`Failed to import engine from ${engine.path}: ${
266
err.message || "Unknown error"
267
}`,
268
);
269
}
270
} else {
271
userSpecifiedOrder.push(engine);
272
}
273
}
274
275
for (const key of userSpecifiedOrder) {
276
if (!kEngines.has(key)) {
277
throw new Error(
278
`'${key}' was specified in the list of engines in the project settings but it is not a valid engine. Available engines are ${
279
Array.from(kEngines.keys()).join(", ")
280
}`,
281
);
282
}
283
}
284
285
const reorderedEngines = new Map<string, ExecutionEngineDiscovery>();
286
287
// Add keys in the order of userSpecifiedOrder first
288
for (const key of userSpecifiedOrder) {
289
reorderedEngines.set(key, kEngines.get(key)!); // Non-null assertion since we verified the keys are in the map
290
}
291
292
// Add the rest of the keys from the original map
293
for (const [key, value] of kEngines) {
294
if (!reorderedEngines.has(key)) {
295
reorderedEngines.set(key, value);
296
}
297
}
298
299
return reorderedEngines;
300
}
301
302
export async function fileExecutionEngine(
303
file: string,
304
flags: RenderFlags | undefined,
305
project: ProjectContext,
306
): Promise<ExecutionEngineInstance | undefined> {
307
// Resolve engines first (registers standard engines on first call)
308
const engines = await resolveEngines(project);
309
310
// get the extension and validate that it can be handled by at least one of our engines
311
const ext = extname(file).toLowerCase();
312
if (
313
!(executionEngines().some((engine) =>
314
engine.validExtensions().includes(ext)
315
))
316
) {
317
return undefined;
318
}
319
320
// try to find an engine that claims this extension outright
321
for (const [_, engine] of engines) {
322
if (engine.claimsFile(file, ext)) {
323
return engine.launch(engineProjectContext(project));
324
}
325
}
326
327
// if we were passed a transformed markdown, use that for the text instead
328
// of the contents of the file.
329
if (kMdExtensions.includes(ext) || kQmdExtensions.includes(ext)) {
330
const markdown = await project.resolveFullMarkdownForFile(undefined, file);
331
// https://github.com/quarto-dev/quarto-cli/issues/6825
332
// In case the YAML _parsing_ fails, we need to annotate the error
333
// with the filename so that the user knows which file is the problem.
334
try {
335
return markdownExecutionEngine(
336
project,
337
markdown ? markdown.value : Deno.readTextFileSync(file),
338
engines,
339
flags,
340
);
341
} catch (error) {
342
if (!(error instanceof Error)) throw error;
343
if (error.name === "YAMLError") {
344
error.message = `${file}:\n${error.message}`;
345
}
346
throw error;
347
}
348
} else {
349
return undefined;
350
}
351
}
352
353
export async function fileExecutionEngineAndTarget(
354
file: string,
355
flags: RenderFlags | undefined,
356
project: ProjectContext,
357
): Promise<{ engine: ExecutionEngineInstance; target: ExecutionTarget }> {
358
const cached = ensureFileInformationCache(project, file);
359
if (cached && cached.engine && cached.target) {
360
return { engine: cached.engine, target: cached.target };
361
}
362
363
// Get the launched engine
364
const engine = await fileExecutionEngine(file, flags, project);
365
if (!engine) {
366
throw new Error("Can't determine execution engine for " + file);
367
}
368
369
const markdown = await project.resolveFullMarkdownForFile(engine, file);
370
const target = await engine.target(file, flags?.quiet, markdown);
371
if (!target) {
372
throw new Error("Can't determine execution target for " + file);
373
}
374
375
// Cache the ExecutionEngineInstance
376
cached.engine = engine;
377
cached.target = target;
378
379
return { engine, target };
380
}
381
382
export function engineIgnoreDirs() {
383
const ignoreDirs: string[] = ["node_modules"];
384
executionEngines().forEach((engine) => {
385
if (engine && engine.ignoreDirs) {
386
const ignores = engine.ignoreDirs();
387
if (ignores) {
388
ignoreDirs.push(...ignores);
389
}
390
}
391
});
392
return ignoreDirs;
393
}
394
395
export function engineIgnoreGlobs() {
396
return engineIgnoreDirs().map((ignore) => `**/${ignore}/**`);
397
}
398
399
export function projectIgnoreGlobs(dir: string) {
400
return engineIgnoreGlobs().concat(
401
gitignoreEntries(dir).map((ignore) => `**/${ignore}**`),
402
);
403
}
404
405