Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/execute/rmd.ts
6458 views
1
/*
2
* rmd.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { error, info, warning } from "../deno_ral/log.ts";
8
import { existsSync } from "../deno_ral/fs.ts";
9
import { basename, extname } from "../deno_ral/path.ts";
10
11
import * as colors from "fmt/colors";
12
13
// Import quartoAPI directly since we're in core codebase
14
import type { QuartoAPI } from "../core/api/index.ts";
15
16
let quarto: QuartoAPI;
17
18
import { rBinaryPath } from "../core/resources.ts";
19
20
import { kCodeLink } from "../config/constants.ts";
21
22
import {
23
checkRBinary,
24
KnitrCapabilities,
25
knitrCapabilities,
26
knitrCapabilitiesMessage,
27
knitrInstallationMessage,
28
rInstallationMessage,
29
} from "../core/knitr.ts";
30
import {
31
DependenciesOptions,
32
DependenciesResult,
33
EngineProjectContext,
34
ExecuteOptions,
35
ExecuteResult,
36
ExecutionEngineDiscovery,
37
ExecutionEngineInstance,
38
ExecutionTarget,
39
kKnitrEngine,
40
PostProcessOptions,
41
RunOptions,
42
} from "./types.ts";
43
import type { CheckConfiguration } from "../command/check/check.ts";
44
import {
45
asMappedString,
46
mappedIndexToLineCol,
47
MappedString,
48
} from "../core/lib/mapped-text.ts";
49
50
const kRmdExtensions = [".rmd", ".rmarkdown"];
51
52
export const knitrEngineDiscovery: ExecutionEngineDiscovery = {
53
init: (quartoAPI) => {
54
quarto = quartoAPI;
55
},
56
57
// Discovery methods
58
name: kKnitrEngine,
59
60
defaultExt: ".qmd",
61
62
defaultYaml: () => [],
63
64
defaultContent: () => [
65
"```{r}",
66
"1 + 1",
67
"```",
68
],
69
70
validExtensions: () => kRmdExtensions.concat(kRmdExtensions),
71
72
claimsFile: (file: string, ext: string) => {
73
return kRmdExtensions.includes(ext.toLowerCase()) ||
74
isKnitrSpinScript(file);
75
},
76
77
claimsLanguage: (language: string) => {
78
return language.toLowerCase() === "r";
79
},
80
81
canFreeze: true,
82
83
generatesFigures: true,
84
85
ignoreDirs: () => {
86
return ["renv", "packrat", "rsconnect"];
87
},
88
89
checkInstallation: async (conf: CheckConfiguration) => {
90
const kIndent = " ";
91
92
// Helper functions (inline)
93
const checkCompleteMessage = (message: string) => {
94
if (!conf.jsonResult) {
95
quarto.console.completeMessage(message);
96
}
97
};
98
const checkInfoMsg = (message: string) => {
99
if (!conf.jsonResult) {
100
info(message);
101
}
102
};
103
104
// Render check helper (inline)
105
const checkKnitrRender = async () => {
106
const json: Record<string, unknown> = {};
107
if (conf.jsonResult) {
108
(conf.jsonResult.render as Record<string, unknown>).knitr = json;
109
}
110
111
const result = await quarto.system.checkRender({
112
content: `
113
---
114
title: "Title"
115
---
116
117
## Header
118
119
\`\`\`{r}
120
1 + 1
121
\`\`\`
122
`,
123
language: "r",
124
services: conf.services,
125
});
126
127
if (result.error) {
128
if (!conf.jsonResult) {
129
throw result.error;
130
} else {
131
json["error"] = result.error;
132
}
133
} else {
134
json["ok"] = true;
135
}
136
};
137
138
// Main check logic
139
const kMessage = "Checking R installation...........";
140
let caps: KnitrCapabilities | undefined;
141
let rBin: string | undefined;
142
const json: Record<string, unknown> = {};
143
if (conf.jsonResult) {
144
(conf.jsonResult.tools as Record<string, unknown>).knitr = json;
145
}
146
const knitrCb = async () => {
147
rBin = await checkRBinary();
148
caps = await knitrCapabilities(rBin);
149
};
150
if (conf.jsonResult) {
151
await knitrCb();
152
} else {
153
await quarto.console.withSpinner({
154
message: kMessage,
155
doneMessage: false,
156
}, knitrCb);
157
}
158
if (rBin && caps) {
159
checkCompleteMessage(kMessage + "OK");
160
if (conf.jsonResult) {
161
json["capabilities"] = caps;
162
} else {
163
checkInfoMsg(knitrCapabilitiesMessage(caps, kIndent));
164
}
165
checkInfoMsg("");
166
if (caps.packages.rmarkdownVersOk && caps.packages.knitrVersOk) {
167
const kKnitrMessage = "Checking Knitr engine render......";
168
if (conf.jsonResult) {
169
await checkKnitrRender();
170
} else {
171
await quarto.console.withSpinner({
172
message: kKnitrMessage,
173
doneMessage: kKnitrMessage + "OK\n",
174
}, async () => {
175
await checkKnitrRender();
176
});
177
}
178
} else {
179
// show install message if not available
180
// or update message if not up to date
181
json["installed"] = false;
182
if (!caps.packages.knitr || !caps.packages.knitrVersOk) {
183
const msg = knitrInstallationMessage(
184
kIndent,
185
"knitr",
186
!!caps.packages.knitr && !caps.packages.knitrVersOk,
187
);
188
checkInfoMsg(msg);
189
json["how-to-install-knitr"] = msg;
190
}
191
if (!caps.packages.rmarkdown || !caps.packages.rmarkdownVersOk) {
192
const msg = knitrInstallationMessage(
193
kIndent,
194
"rmarkdown",
195
!!caps.packages.rmarkdown && !caps.packages.rmarkdownVersOk,
196
);
197
checkInfoMsg(msg);
198
json["how-to-install-rmarkdown"] = msg;
199
}
200
checkInfoMsg("");
201
}
202
} else if (rBin === undefined) {
203
checkCompleteMessage(kMessage + "(None)\n");
204
const msg = rInstallationMessage(kIndent);
205
checkInfoMsg(msg);
206
json["installed"] = false;
207
checkInfoMsg("");
208
} else if (caps === undefined) {
209
json["installed"] = false;
210
checkCompleteMessage(kMessage + "(None)\n");
211
const msgs = [
212
`R succesfully found at ${rBin}.`,
213
"However, a problem was encountered when checking configurations of packages.",
214
"Please check your installation of R.",
215
];
216
msgs.forEach((msg) => {
217
checkInfoMsg(msg);
218
});
219
json["error"] = msgs.join("\n");
220
checkInfoMsg("");
221
}
222
},
223
224
// Launch method that returns an instance with context closure
225
launch: (context: EngineProjectContext): ExecutionEngineInstance => {
226
return {
227
// Instance properties (required by interface)
228
name: kKnitrEngine,
229
canFreeze: true,
230
231
// Instance methods
232
async markdownForFile(file: string): Promise<MappedString> {
233
const isSpin = isKnitrSpinScript(file);
234
if (isSpin) {
235
return asMappedString(await markdownFromKnitrSpinScript(file));
236
}
237
return quarto.mappedString.fromFile(file);
238
},
239
240
target: async (
241
file: string,
242
_quiet?: boolean,
243
markdown?: MappedString,
244
): Promise<ExecutionTarget | undefined> => {
245
const resolvedMarkdown = await context.resolveFullMarkdownForFile(
246
knitrEngineDiscovery.launch(context),
247
file,
248
markdown,
249
);
250
let metadata;
251
try {
252
metadata = quarto.markdownRegex.extractYaml(resolvedMarkdown.value);
253
} catch (e) {
254
if (!(e instanceof Error)) throw e;
255
error(`Error reading metadata from ${file}.\n${e.message}`);
256
throw e;
257
}
258
const target: ExecutionTarget = {
259
source: file,
260
input: file,
261
markdown: resolvedMarkdown,
262
metadata,
263
};
264
return Promise.resolve(target);
265
},
266
267
partitionedMarkdown: async (file: string) => {
268
if (isKnitrSpinScript(file)) {
269
return quarto.markdownRegex.partition(
270
await markdownFromKnitrSpinScript(file),
271
);
272
} else {
273
return quarto.markdownRegex.partition(Deno.readTextFileSync(file));
274
}
275
},
276
277
execute: async (options: ExecuteOptions): Promise<ExecuteResult> => {
278
const inputBasename = basename(options.target.input);
279
const inputStem = basename(inputBasename, extname(inputBasename));
280
281
const result = await callR<ExecuteResult>(
282
"execute",
283
{
284
...options,
285
target: undefined,
286
input: options.target.input,
287
markdown: resolveInlineExecute(options.target.markdown.value),
288
},
289
options.tempDir,
290
options.project?.isSingleFile ? undefined : options.projectDir,
291
options.quiet,
292
// fixup .rmarkdown file references
293
(output) => {
294
output = output.replaceAll(
295
`${inputStem}.rmarkdown`,
296
() => inputBasename,
297
);
298
299
const m = output.match(/^Quitting from lines (\d+)-(\d+)/m);
300
if (m) {
301
const f1 = quarto.text.lineColToIndex(
302
options.target.markdown.value,
303
);
304
const f2 = mappedIndexToLineCol(options.target.markdown);
305
306
const newLine1 = f2(f1({ line: Number(m[1]) - 1, column: 0 }))
307
.line + 1;
308
const newLine2 = f2(f1({ line: Number(m[2]) - 1, column: 0 }))
309
.line + 1;
310
output = output.replace(
311
/^Quitting from lines (\d+)-(\d+)/m,
312
`\n\nQuitting from lines ${newLine1}-${newLine2}`,
313
);
314
}
315
316
output = filterAlwaysAllowHtml(output);
317
318
return output;
319
},
320
);
321
const includes = result.includes as unknown;
322
// knitr appears to return [] instead of {} as the value for includes.
323
if (Array.isArray(includes) && includes.length === 0) {
324
result.includes = {};
325
}
326
return result;
327
},
328
329
dependencies: (options: DependenciesOptions) => {
330
return callR<DependenciesResult>(
331
"dependencies",
332
{ ...options, target: undefined, input: options.target.input },
333
options.tempDir,
334
options.projectDir,
335
options.quiet,
336
);
337
},
338
339
postprocess: async (options: PostProcessOptions) => {
340
// handle preserved html in js-land
341
quarto.text.postProcessRestorePreservedHtml(options);
342
343
// see if we can code link
344
if (options.format.render?.[kCodeLink]) {
345
// When using shiny document, code-link proceesing is not supported
346
// https://github.com/quarto-dev/quarto-cli/issues/9208
347
if (quarto.format.isServerShiny(options.format)) {
348
warning(
349
`'code-link' option will be ignored as it is not supported for 'server: shiny' due to 'downlit' R package limitation (https://github.com/quarto-dev/quarto-cli/issues/9208).`,
350
);
351
return Promise.resolve();
352
}
353
// Current knitr engine postprocess is all about applying downlit processing to the HTML output
354
await callR<void>(
355
"postprocess",
356
{
357
...options,
358
target: undefined,
359
preserve: undefined,
360
input: options.target.input,
361
},
362
options.tempDir,
363
options.projectDir,
364
options.quiet,
365
undefined,
366
false,
367
).then(() => {
368
return Promise.resolve();
369
}, () => {
370
warning(
371
`Unable to perform code-link (code-link requires R packages rmarkdown, downlit, and xml2)`,
372
);
373
return Promise.resolve();
374
});
375
}
376
},
377
378
run: (options: RunOptions) => {
379
let running = false;
380
return callR<void>(
381
"run",
382
options,
383
options.tempDir,
384
options.projectDir,
385
undefined,
386
// wait for 'listening' to call onReady
387
(output) => {
388
const kListeningPattern = /(Listening on) (https?:\/\/[^\n]*)/;
389
if (!running) {
390
const listeningMatch = output.match(kListeningPattern);
391
if (listeningMatch) {
392
running = true;
393
output = output.replace(kListeningPattern, "");
394
if (options.onReady) {
395
options.onReady();
396
}
397
}
398
}
399
return output;
400
},
401
);
402
},
403
};
404
},
405
};
406
407
async function callR<T>(
408
action: string,
409
params: unknown,
410
tempDir: string,
411
projectDir?: string,
412
quiet?: boolean,
413
outputFilter?: (output: string) => string,
414
reportError = true,
415
): Promise<T> {
416
// establish cwd for our R scripts (the current dir if there is an renv
417
// otherwise the project dir if specified)
418
const cwd = withinActiveRenv() ? Deno.cwd() : projectDir ?? Deno.cwd();
419
420
// create a temp file for writing the results
421
const resultsFile = Deno.makeTempFileSync(
422
{ dir: tempDir, prefix: "r-results", suffix: ".json" },
423
);
424
425
const input = JSON.stringify({
426
action,
427
params,
428
results: resultsFile,
429
wd: cwd,
430
});
431
432
// QUARTO_KNITR_RSCRIPT_ARGS allows to pass additional arguments to Rscript as comma separated values
433
// e.g. QUARTO_KNITR_RSCRIPT_ARGS="--vanilla,--no-init-file,--max-connections=258"
434
const rscriptArgs = Deno.env.get("QUARTO_KNITR_RSCRIPT_ARGS") || "";
435
const rscriptArgsArray = rscriptArgs.split(",").filter((a) =>
436
a.trim() !== ""
437
);
438
439
try {
440
const result = await quarto.system.execProcess(
441
{
442
cmd: await rBinaryPath("Rscript"),
443
args: [
444
...rscriptArgsArray,
445
quarto.path.resource("rmd/rmd.R"),
446
],
447
cwd,
448
stderr: quiet ? "piped" : "inherit",
449
},
450
input,
451
"stdout>stderr",
452
(output) => {
453
if (outputFilter) {
454
output = outputFilter(output);
455
}
456
return colors.red(output);
457
},
458
);
459
460
if (result.success) {
461
const results = await Deno.readTextFile(resultsFile);
462
await Deno.remove(resultsFile);
463
const resultsJson = JSON.parse(results);
464
return resultsJson as T;
465
} else {
466
// quiet means don't print in normal cases, but
467
// we still need to report errors
468
if (quiet) {
469
error(result.stderr || "");
470
}
471
if (reportError) {
472
await printCallRDiagnostics();
473
}
474
return Promise.reject();
475
}
476
} catch (e) {
477
if (!(e instanceof Error)) {
478
throw e;
479
}
480
if (reportError) {
481
if (e?.message) {
482
info("");
483
error(e.message);
484
}
485
await printCallRDiagnostics();
486
}
487
return Promise.reject();
488
}
489
}
490
491
function withinActiveRenv() {
492
const kRProfile = ".Rprofile";
493
const kREnvActivate = 'source("renv/activate.R")';
494
if (existsSync(".Rprofile")) {
495
const profile = Deno.readTextFileSync(kRProfile);
496
return profile.includes(kREnvActivate) &&
497
!profile.includes("# " + kREnvActivate);
498
} else {
499
return false;
500
}
501
}
502
503
async function printCallRDiagnostics() {
504
const rBin = await checkRBinary();
505
if (rBin === undefined) {
506
info("");
507
info(rInstallationMessage());
508
info("");
509
} else {
510
const caps = await knitrCapabilities(rBin);
511
if (caps === undefined) {
512
info(
513
`Problem with running R found at ${rBin} to check environment configurations.`,
514
);
515
info("Please check your installation of R.");
516
info("");
517
} else {
518
if (
519
!caps?.packages.rmarkdown || !caps?.packages.knitr ||
520
!caps?.packages.knitrVersOk || !caps?.packages.rmarkdownVersOk
521
) {
522
info("R installation:");
523
info(knitrCapabilitiesMessage(caps, " "));
524
if (!!!caps?.packages.knitr || !caps?.packages.knitrVersOk) {
525
info("");
526
info(
527
knitrInstallationMessage(
528
"",
529
"knitr",
530
!!caps.packages.knitr && !caps.packages.knitrVersOk,
531
),
532
);
533
}
534
if (!!!caps?.packages.rmarkdown || !caps?.packages.rmarkdownVersOk) {
535
info("");
536
info(
537
knitrInstallationMessage(
538
"",
539
"rmarkdown",
540
!!caps?.packages.rmarkdown && !caps?.packages.rmarkdownVersOk,
541
),
542
);
543
}
544
info("");
545
}
546
}
547
}
548
}
549
550
function filterAlwaysAllowHtml(s: string): string {
551
if (
552
s.indexOf(
553
"Functions that produce HTML output found in document targeting",
554
) !== -1
555
) {
556
s = s
557
.replace("your rmarkdown file", "your quarto file")
558
.replace("always_allow_html: true", "prefer-html: true");
559
}
560
return s;
561
}
562
563
function resolveInlineExecute(code: string) {
564
return quarto.text.executeInlineCodeHandler(
565
"r",
566
(expr) => `${"`"}r .QuartoInlineRender(${expr})${"`"}`,
567
)(code);
568
}
569
570
export function isKnitrSpinScript(file: string) {
571
const ext = extname(file).toLowerCase();
572
if (ext == ".r") {
573
const text = Deno.readTextFileSync(file);
574
// Consider a .R script that can be spinned if it contains a YAML header inside a special `#'` comment
575
return /^\s*#'\s*---[\s\S]+?\s*#'\s*---/.test(text);
576
} else {
577
return false;
578
}
579
}
580
581
export async function markdownFromKnitrSpinScript(file: string) {
582
// run spin to get .qmd and get markdown from .qmd
583
584
// TODO: implement a caching system because spin is slow and it seems we call this twice for each run
585
// 1. First as part of the target() call
586
// 2. Second as part of renderProject() call to get `partitioned` information to get `resourcesFrom` with `resourceFilesFromRenderedFile()`
587
588
// we need a temp dir for `CallR` to work but we don't have access to usual options.tempDir.
589
const tempDir = quarto.system.tempContext().createDir();
590
591
const result = await callR<string>(
592
"spin",
593
{ input: file },
594
tempDir,
595
undefined,
596
true,
597
);
598
599
return result;
600
}
601
602