Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions extensions/vscode/src/inspect/detectors/quarto.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -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);
Expand Down
54 changes: 53 additions & 1 deletion extensions/vscode/src/inspect/detectors/quarto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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
Expand Down
Loading