import { error, info, warning } from "../deno_ral/log.ts";
import { existsSync } from "../deno_ral/fs.ts";
import { basename, extname } from "../deno_ral/path.ts";
import * as colors from "fmt/colors";
import type { QuartoAPI } from "../core/api/index.ts";
let quarto: QuartoAPI;
import { rBinaryPath } from "../core/resources.ts";
import { kCodeLink } from "../config/constants.ts";
import {
checkRBinary,
KnitrCapabilities,
knitrCapabilities,
knitrCapabilitiesMessage,
knitrInstallationMessage,
rInstallationMessage,
} from "../core/knitr.ts";
import {
DependenciesOptions,
DependenciesResult,
EngineProjectContext,
ExecuteOptions,
ExecuteResult,
ExecutionEngineDiscovery,
ExecutionEngineInstance,
ExecutionTarget,
kKnitrEngine,
PostProcessOptions,
RunOptions,
} from "./types.ts";
import type { CheckConfiguration } from "../command/check/check.ts";
import {
asMappedString,
mappedIndexToLineCol,
MappedString,
} from "../core/lib/mapped-text.ts";
const kRmdExtensions = [".rmd", ".rmarkdown"];
export const knitrEngineDiscovery: ExecutionEngineDiscovery = {
init: (quartoAPI) => {
quarto = quartoAPI;
},
name: kKnitrEngine,
defaultExt: ".qmd",
defaultYaml: () => [],
defaultContent: () => [
"```{r}",
"1 + 1",
"```",
],
validExtensions: () => kRmdExtensions.concat(kRmdExtensions),
claimsFile: (file: string, ext: string) => {
return kRmdExtensions.includes(ext.toLowerCase()) ||
isKnitrSpinScript(file);
},
claimsLanguage: (language: string) => {
return language.toLowerCase() === "r";
},
canFreeze: true,
generatesFigures: true,
ignoreDirs: () => {
return ["renv", "packrat", "rsconnect"];
},
checkInstallation: async (conf: CheckConfiguration) => {
const kIndent = " ";
const checkCompleteMessage = (message: string) => {
if (!conf.jsonResult) {
quarto.console.completeMessage(message);
}
};
const checkInfoMsg = (message: string) => {
if (!conf.jsonResult) {
info(message);
}
};
const checkKnitrRender = async () => {
const json: Record<string, unknown> = {};
if (conf.jsonResult) {
(conf.jsonResult.render as Record<string, unknown>).knitr = json;
}
const result = await quarto.system.checkRender({
content: `
---
title: "Title"
---
## Header
\`\`\`{r}
1 + 1
\`\`\`
`,
language: "r",
services: conf.services,
});
if (result.error) {
if (!conf.jsonResult) {
throw result.error;
} else {
json["error"] = result.error;
}
} else {
json["ok"] = true;
}
};
const kMessage = "Checking R installation...........";
let caps: KnitrCapabilities | undefined;
let rBin: string | undefined;
const json: Record<string, unknown> = {};
if (conf.jsonResult) {
(conf.jsonResult.tools as Record<string, unknown>).knitr = json;
}
const knitrCb = async () => {
rBin = await checkRBinary();
caps = await knitrCapabilities(rBin);
};
if (conf.jsonResult) {
await knitrCb();
} else {
await quarto.console.withSpinner({
message: kMessage,
doneMessage: false,
}, knitrCb);
}
if (rBin && caps) {
checkCompleteMessage(kMessage + "OK");
if (conf.jsonResult) {
json["capabilities"] = caps;
} else {
checkInfoMsg(knitrCapabilitiesMessage(caps, kIndent));
}
checkInfoMsg("");
if (caps.packages.rmarkdownVersOk && caps.packages.knitrVersOk) {
const kKnitrMessage = "Checking Knitr engine render......";
if (conf.jsonResult) {
await checkKnitrRender();
} else {
await quarto.console.withSpinner({
message: kKnitrMessage,
doneMessage: kKnitrMessage + "OK\n",
}, async () => {
await checkKnitrRender();
});
}
} else {
json["installed"] = false;
if (!caps.packages.knitr || !caps.packages.knitrVersOk) {
const msg = knitrInstallationMessage(
kIndent,
"knitr",
!!caps.packages.knitr && !caps.packages.knitrVersOk,
);
checkInfoMsg(msg);
json["how-to-install-knitr"] = msg;
}
if (!caps.packages.rmarkdown || !caps.packages.rmarkdownVersOk) {
const msg = knitrInstallationMessage(
kIndent,
"rmarkdown",
!!caps.packages.rmarkdown && !caps.packages.rmarkdownVersOk,
);
checkInfoMsg(msg);
json["how-to-install-rmarkdown"] = msg;
}
checkInfoMsg("");
}
} else if (rBin === undefined) {
checkCompleteMessage(kMessage + "(None)\n");
const msg = rInstallationMessage(kIndent);
checkInfoMsg(msg);
json["installed"] = false;
checkInfoMsg("");
} else if (caps === undefined) {
json["installed"] = false;
checkCompleteMessage(kMessage + "(None)\n");
const msgs = [
`R succesfully found at ${rBin}.`,
"However, a problem was encountered when checking configurations of packages.",
"Please check your installation of R.",
];
msgs.forEach((msg) => {
checkInfoMsg(msg);
});
json["error"] = msgs.join("\n");
checkInfoMsg("");
}
},
launch: (context: EngineProjectContext): ExecutionEngineInstance => {
return {
name: kKnitrEngine,
canFreeze: true,
async markdownForFile(file: string): Promise<MappedString> {
const isSpin = isKnitrSpinScript(file);
if (isSpin) {
return asMappedString(await markdownFromKnitrSpinScript(file));
}
return quarto.mappedString.fromFile(file);
},
target: async (
file: string,
_quiet?: boolean,
markdown?: MappedString,
): Promise<ExecutionTarget | undefined> => {
const resolvedMarkdown = await context.resolveFullMarkdownForFile(
knitrEngineDiscovery.launch(context),
file,
markdown,
);
let metadata;
try {
metadata = quarto.markdownRegex.extractYaml(resolvedMarkdown.value);
} catch (e) {
if (!(e instanceof Error)) throw e;
error(`Error reading metadata from ${file}.\n${e.message}`);
throw e;
}
const target: ExecutionTarget = {
source: file,
input: file,
markdown: resolvedMarkdown,
metadata,
};
return Promise.resolve(target);
},
partitionedMarkdown: async (file: string) => {
if (isKnitrSpinScript(file)) {
return quarto.markdownRegex.partition(
await markdownFromKnitrSpinScript(file),
);
} else {
return quarto.markdownRegex.partition(Deno.readTextFileSync(file));
}
},
execute: async (options: ExecuteOptions): Promise<ExecuteResult> => {
const inputBasename = basename(options.target.input);
const inputStem = basename(inputBasename, extname(inputBasename));
const result = await callR<ExecuteResult>(
"execute",
{
...options,
target: undefined,
input: options.target.input,
markdown: resolveInlineExecute(options.target.markdown.value),
},
options.tempDir,
options.project?.isSingleFile ? undefined : options.projectDir,
options.quiet,
(output) => {
output = output.replaceAll(
`${inputStem}.rmarkdown`,
() => inputBasename,
);
const m = output.match(/^Quitting from lines (\d+)-(\d+)/m);
if (m) {
const f1 = quarto.text.lineColToIndex(
options.target.markdown.value,
);
const f2 = mappedIndexToLineCol(options.target.markdown);
const newLine1 = f2(f1({ line: Number(m[1]) - 1, column: 0 }))
.line + 1;
const newLine2 = f2(f1({ line: Number(m[2]) - 1, column: 0 }))
.line + 1;
output = output.replace(
/^Quitting from lines (\d+)-(\d+)/m,
`\n\nQuitting from lines ${newLine1}-${newLine2}`,
);
}
output = filterAlwaysAllowHtml(output);
return output;
},
);
const includes = result.includes as unknown;
if (Array.isArray(includes) && includes.length === 0) {
result.includes = {};
}
return result;
},
dependencies: (options: DependenciesOptions) => {
return callR<DependenciesResult>(
"dependencies",
{ ...options, target: undefined, input: options.target.input },
options.tempDir,
options.projectDir,
options.quiet,
);
},
postprocess: async (options: PostProcessOptions) => {
quarto.text.postProcessRestorePreservedHtml(options);
if (options.format.render?.[kCodeLink]) {
if (quarto.format.isServerShiny(options.format)) {
warning(
`'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).`,
);
return Promise.resolve();
}
await callR<void>(
"postprocess",
{
...options,
target: undefined,
preserve: undefined,
input: options.target.input,
},
options.tempDir,
options.projectDir,
options.quiet,
undefined,
false,
).then(() => {
return Promise.resolve();
}, () => {
warning(
`Unable to perform code-link (code-link requires R packages rmarkdown, downlit, and xml2)`,
);
return Promise.resolve();
});
}
},
run: (options: RunOptions) => {
let running = false;
return callR<void>(
"run",
options,
options.tempDir,
options.projectDir,
undefined,
(output) => {
const kListeningPattern = /(Listening on) (https?:\/\/[^\n]*)/;
if (!running) {
const listeningMatch = output.match(kListeningPattern);
if (listeningMatch) {
running = true;
output = output.replace(kListeningPattern, "");
if (options.onReady) {
options.onReady();
}
}
}
return output;
},
);
},
};
},
};
async function callR<T>(
action: string,
params: unknown,
tempDir: string,
projectDir?: string,
quiet?: boolean,
outputFilter?: (output: string) => string,
reportError = true,
): Promise<T> {
const cwd = withinActiveRenv() ? Deno.cwd() : projectDir ?? Deno.cwd();
const resultsFile = Deno.makeTempFileSync(
{ dir: tempDir, prefix: "r-results", suffix: ".json" },
);
const input = JSON.stringify({
action,
params,
results: resultsFile,
wd: cwd,
});
const rscriptArgs = Deno.env.get("QUARTO_KNITR_RSCRIPT_ARGS") || "";
const rscriptArgsArray = rscriptArgs.split(",").filter((a) =>
a.trim() !== ""
);
try {
const result = await quarto.system.execProcess(
{
cmd: await rBinaryPath("Rscript"),
args: [
...rscriptArgsArray,
quarto.path.resource("rmd/rmd.R"),
],
cwd,
stderr: quiet ? "piped" : "inherit",
},
input,
"stdout>stderr",
(output) => {
if (outputFilter) {
output = outputFilter(output);
}
return colors.red(output);
},
);
if (result.success) {
const results = await Deno.readTextFile(resultsFile);
await Deno.remove(resultsFile);
const resultsJson = JSON.parse(results);
return resultsJson as T;
} else {
if (quiet) {
error(result.stderr || "");
}
if (reportError) {
await printCallRDiagnostics();
}
return Promise.reject();
}
} catch (e) {
if (!(e instanceof Error)) {
throw e;
}
if (reportError) {
if (e?.message) {
info("");
error(e.message);
}
await printCallRDiagnostics();
}
return Promise.reject();
}
}
function withinActiveRenv() {
const kRProfile = ".Rprofile";
const kREnvActivate = 'source("renv/activate.R")';
if (existsSync(".Rprofile")) {
const profile = Deno.readTextFileSync(kRProfile);
return profile.includes(kREnvActivate) &&
!profile.includes("# " + kREnvActivate);
} else {
return false;
}
}
async function printCallRDiagnostics() {
const rBin = await checkRBinary();
if (rBin === undefined) {
info("");
info(rInstallationMessage());
info("");
} else {
const caps = await knitrCapabilities(rBin);
if (caps === undefined) {
info(
`Problem with running R found at ${rBin} to check environment configurations.`,
);
info("Please check your installation of R.");
info("");
} else {
if (
!caps?.packages.rmarkdown || !caps?.packages.knitr ||
!caps?.packages.knitrVersOk || !caps?.packages.rmarkdownVersOk
) {
info("R installation:");
info(knitrCapabilitiesMessage(caps, " "));
if (!!!caps?.packages.knitr || !caps?.packages.knitrVersOk) {
info("");
info(
knitrInstallationMessage(
"",
"knitr",
!!caps.packages.knitr && !caps.packages.knitrVersOk,
),
);
}
if (!!!caps?.packages.rmarkdown || !caps?.packages.rmarkdownVersOk) {
info("");
info(
knitrInstallationMessage(
"",
"rmarkdown",
!!caps?.packages.rmarkdown && !caps?.packages.rmarkdownVersOk,
),
);
}
info("");
}
}
}
}
function filterAlwaysAllowHtml(s: string): string {
if (
s.indexOf(
"Functions that produce HTML output found in document targeting",
) !== -1
) {
s = s
.replace("your rmarkdown file", "your quarto file")
.replace("always_allow_html: true", "prefer-html: true");
}
return s;
}
function resolveInlineExecute(code: string) {
return quarto.text.executeInlineCodeHandler(
"r",
(expr) => `${"`"}r .QuartoInlineRender(${expr})${"`"}`,
)(code);
}
export function isKnitrSpinScript(file: string) {
const ext = extname(file).toLowerCase();
if (ext == ".r") {
const text = Deno.readTextFileSync(file);
return /^\s*#'\s*---[\s\S]+?\s*#'\s*---/.test(text);
} else {
return false;
}
}
export async function markdownFromKnitrSpinScript(file: string) {
const tempDir = quarto.system.tempContext().createDir();
const result = await callR<string>(
"spin",
{ input: file },
tempDir,
undefined,
true,
);
return result;
}