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