From a49297168b4cfe7ad63a01c757a105c7e11b7c76 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 5 May 2026 13:02:19 -0400 Subject: [PATCH] fix: detect Quarto engines from file content when quarto inspect is unavailable (#3993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `quarto inspect` fails (e.g. quarto not in PATH) or returns empty engines for a directory inspection, the fallback path for .qmd files did not detect R/Python code chunks — resulting in an empty engines field in the manifest. Connect uses this field to decide whether to restore renv, so empty engines caused renv restore to be skipped. Now the fallback scans .qmd file content for `{r}` and `{python}` code chunks and sets engines to ["knitr"] / ["jupyter"] accordingly. Co-Authored-By: Claude Opus 4.6 --- .../src/inspect/detectors/quarto.test.ts | 61 +++++++++++++++++++ .../vscode/src/inspect/detectors/quarto.ts | 54 +++++++++++++++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/extensions/vscode/src/inspect/detectors/quarto.test.ts b/extensions/vscode/src/inspect/detectors/quarto.test.ts index f1de85b9f..ae3867466 100644 --- a/extensions/vscode/src/inspect/detectors/quarto.test.ts +++ b/extensions/vscode/src/inspect/detectors/quarto.test.ts @@ -348,6 +348,46 @@ describe("QuartoDetector", () => { expect(configs[0]?.files).toContain("/doc.qmd"); }); + test("fallback when quarto binary missing: .qmd with R chunks detects knitr engine (#3993)", async () => { + setupGlobDir(["report.qmd"]); + mockAccess.mockRejectedValue(new Error("ENOENT")); + mockReadFile.mockResolvedValue( + '---\ntitle: "Report"\n---\n\n```{r}\nsource("helpers.R")\n```\n', + ); + + // quarto inspect fails (not installed) + mockExecFile.mockRejectedValue( + Object.assign(new Error("spawn quarto ENOENT"), { code: "ENOENT" }), + ); + + const configs = await detector.inferType("/project", "report.qmd"); + expect(configs).toHaveLength(1); + expect(configs[0]?.type).toBe(ContentType.QUARTO_STATIC); + expect(configs[0]?.r).toEqual({}); + expect(configs[0]?.quarto).toEqual({ + version: "1.7.34", + engines: ["knitr"], + }); + }); + + test("fallback when quarto binary missing: .qmd with Python chunks detects jupyter engine", async () => { + setupGlobDir(["analysis.qmd"]); + mockAccess.mockRejectedValue(new Error("ENOENT")); + mockReadFile.mockResolvedValue("```{python}\nimport pandas as pd\n```\n"); + + mockExecFile.mockRejectedValue( + Object.assign(new Error("spawn quarto ENOENT"), { code: "ENOENT" }), + ); + + const configs = await detector.inferType("/project", "analysis.qmd"); + expect(configs).toHaveLength(1); + expect(configs[0]?.python).toEqual({}); + expect(configs[0]?.quarto).toEqual({ + version: "1.7.34", + engines: ["jupyter"], + }); + }); + test("fallback when quarto binary missing: .ipynb", async () => { setupGlobDir(["notebook.ipynb"]); mockAccess.mockRejectedValue(new Error("ENOENT")); @@ -386,6 +426,27 @@ describe("QuartoDetector", () => { }); }); + test("directory inspection with empty engines falls back to file scanning (#3993)", async () => { + setupGlobDir([]); + mockAccess.mockRejectedValue(new Error("ENOENT")); + mockReadFile.mockResolvedValue('```{r}\nsource("script.R")\n```\n'); + + // quarto inspect returns empty engines (simulating older quarto or edge case) + const inspectJson = makeInspectOutput({ + engines: [], + files: { input: ["/project/report.qmd"], configResources: [] }, + formats: { + html: { metadata: { title: "Report" }, pandoc: {} }, + }, + }); + mockExecFile.mockResolvedValue({ stdout: inspectJson }); + + const configs = await detector.inferType("/project", "_quarto.yml"); + expect(configs).toHaveLength(1); + expect(configs[0]?.r).toEqual({}); + expect(configs[0]?.quarto?.engines).toContain("knitr"); + }); + test("skips non-quarto entrypoints", async () => { const configs = await detector.inferType("/project", "index.html"); expect(configs).toHaveLength(0); diff --git a/extensions/vscode/src/inspect/detectors/quarto.ts b/extensions/vscode/src/inspect/detectors/quarto.ts index f69f7791a..32a4690e8 100644 --- a/extensions/vscode/src/inspect/detectors/quarto.ts +++ b/extensions/vscode/src/inspect/detectors/quarto.ts @@ -180,6 +180,28 @@ export class QuartoDetector implements ContentTypeDetector { ); } } + } else if (inspectOutput.engines.length === 0) { + // Directory inspection returned no engines — scan input files to detect + // languages so we can populate engines as a fallback. + for (const inputFile of inspectOutput.inputFiles()) { + if (needR && needPython) break; + const abs = path.isAbsolute(inputFile) + ? inputFile + : path.join(baseDir, inputFile); + const ext = path.extname(abs).toLowerCase(); + if (ext === ".ipynb") { + needPython = true; + } else if (quartoSuffixesLower.includes(ext)) { + try { + const content = await fs.readFile(abs, "utf-8"); + const langs = detectMarkdownLanguagesInContent(content); + needR = needR || langs.needsR; + needPython = needPython || langs.needsPython; + } catch { + // Cannot read file — skip + } + } + } } const engines = [...inspectOutput.engines]; @@ -508,11 +530,41 @@ export class QuartoDetector implements ContentTypeDetector { return cfg; } - // Include .qmd files + // Include .qmd files and detect languages from their content const qmdFiles = await globDir(baseDir, "*.qmd"); + let needR = false; + let needPython = false; + for (const qmdPath of qmdFiles) { const relPath = path.basename(qmdPath); files.push(`/${relPath}`); + + // Detect language needs from file content + if (!needR || !needPython) { + try { + const content = await fs.readFile(qmdPath, "utf-8"); + const langs = detectMarkdownLanguagesInContent(content); + needR = needR || langs.needsR; + needPython = needPython || langs.needsPython; + } catch { + // Cannot read file — skip language detection for this file + } + } + } + + // Set engines based on detected languages + const engines: string[] = []; + if (needR) { + cfg.r = {}; + engines.push("knitr"); + } + if (needPython) { + cfg.python = {}; + engines.push("jupyter"); + } + if (engines.length > 0) { + engines.sort(); + cfg.quarto = { version: defaultQuartoVersion, engines }; } // Include special yml files