Skip to content
Open
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
1 change: 1 addition & 0 deletions news/changelog-1.9.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,4 @@ All changes included in 1.9:
- ([#13890](https://github.com/quarto-dev/quarto-cli/issues/13890)): Fix render failure when using `embed-resources: true` with input path through a symlinked directory. The cleanup now resolves symlinks before comparing paths.
- ([#13907](https://github.com/quarto-dev/quarto-cli/issues/13907)): Ignore AI assistant configuration files (`CLAUDE.md`, `AGENTS.md`) when scanning for project input files and in extension templates, similar to how `README.md` is handled.
- ([#13935](https://github.com/quarto-dev/quarto-cli/issues/13935)): Fix `quarto install`, `quarto update`, and `quarto uninstall` interactive tool selection.
- ([#13997](https://github.com/quarto-dev/quarto-cli/issues/13997)): Fix Windows dart-sass theme compilation failing when Quarto is installed in a path with spaces (e.g., `C:\Program Files\`) and the project path also contains spaces.
89 changes: 63 additions & 26 deletions src/core/dart-sass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import { join } from "../deno_ral/path.ts";

import { architectureToolsPath } from "./resources.ts";
import { execProcess } from "./process.ts";
import { ProcessResult } from "./process-types.ts";
import { TempContext } from "./temp.ts";
import { lines } from "./text.ts";
import { debug, info } from "../deno_ral/log.ts";
import { existsSync } from "../deno_ral/fs.ts";
import { warnOnce } from "./log.ts";
import { isWindows } from "../deno_ral/platform.ts";
import { requireQuoting, safeWindowsExec } from "./windows.ts";

export function dartSassInstallDir() {
return architectureToolsPath("dart-sass");
Expand Down Expand Up @@ -53,7 +55,21 @@ export async function dartCompile(
return outputFilePath;
}

export async function dartCommand(args: string[]) {
/**
* Options for dartCommand
*/
export interface DartCommandOptions {
/**
* Override the sass executable path.
* Primarily used for testing with spaced paths.
*/
sassPath?: string;
}

export async function dartCommand(
args: string[],
options?: DartCommandOptions,
) {
const resolvePath = () => {
const dartOverrideCmd = Deno.env.get("QUARTO_DART_SASS");
if (dartOverrideCmd) {
Expand All @@ -69,34 +85,55 @@ export async function dartCommand(args: string[]) {
const command = isWindows ? "sass.bat" : "sass";
return architectureToolsPath(join("dart-sass", command));
};
const sass = resolvePath();
const sass = options?.sassPath ?? resolvePath();

const cmd = sass;
// Run the sass compiler
const result = await execProcess(
{
cmd,
args,
stdout: "piped",
stderr: "piped",
},
);
// Process result helper (shared by Windows and non-Windows paths)
const processResult = (result: ProcessResult): string | undefined => {
if (result.success) {
if (result.stderr) {
info(result.stderr);
}
return result.stdout;
} else {
debug(`[DART path] : ${sass}`);
debug(`[DART args] : ${args.join(" ")}`);
debug(`[DART stdout] : ${result.stdout}`);
debug(`[DART stderr] : ${result.stderr}`);

if (result.success) {
if (result.stderr) {
info(result.stderr);
const errLines = lines(result.stderr || "");
// truncate the last 2 lines (they include a pointer to the temp file containing
// all of the concatenated sass, which is more or less incomprehensible for users.
const errMsg = errLines.slice(0, errLines.length - 2).join("\n");
throw new Error("Theme file compilation failed:\n\n" + errMsg);
}
return result.stdout;
} else {
debug(`[DART path] : ${sass}`);
debug(`[DART args] : ${args.join(" ")}`);
debug(`[DART stdout] : ${result.stdout}`);
debug(`[DART stderr] : ${result.stderr}`);
};

const errLines = lines(result.stderr || "");
// truncate the last 2 lines (they include a pointer to the temp file containing
// all of the concatenated sass, which is more or less incomprehensible for users.
const errMsg = errLines.slice(0, errLines.length - 2).join("\n");
throw new Error("Theme file compilation failed:\n\n" + errMsg);
// On Windows, use safeWindowsExec to handle paths with spaces
// (e.g., when Quarto is installed in C:\Program Files\)
// See https://github.com/quarto-dev/quarto-cli/issues/13997
if (isWindows) {
const quoted = requireQuoting([sass, ...args]);
const result = await safeWindowsExec(
quoted.args[0],
quoted.args.slice(1),
(cmd: string[]) => {
return execProcess({
cmd: cmd[0],
args: cmd.slice(1),
stdout: "piped",
stderr: "piped",
});
},
);
return processResult(result);
}

// Non-Windows: direct execution
const result = await execProcess({
cmd: sass,
args,
stdout: "piped",
stderr: "piped",
});
return processResult(result);
}
78 changes: 78 additions & 0 deletions tests/unit/dart-sass.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* dart-sass.test.ts
*
* Tests for dart-sass functionality.
* Validates fix for https://github.com/quarto-dev/quarto-cli/issues/13997
*
* Copyright (C) 2020-2025 Posit Software, PBC
*/

import { unitTest } from "../test.ts";
import { assert } from "testing/asserts";
import { isWindows } from "../../src/deno_ral/platform.ts";
import { join } from "../../src/deno_ral/path.ts";
import { dartCommand, dartSassInstallDir } from "../../src/core/dart-sass.ts";

// Test that dartCommand handles spaced paths on Windows (issue #13997)
// The bug only triggers when BOTH the executable path AND arguments contain spaces.
unitTest(
"dartCommand - handles spaced paths on Windows (issue #13997)",
async () => {
// Create directories with spaces for both sass and file arguments
const tempBase = Deno.makeTempDirSync({ prefix: "quarto_test_" });
const spacedSassDir = join(tempBase, "Program Files", "dart-sass");
const spacedProjectDir = join(tempBase, "My Project");
const sassInstallDir = dartSassInstallDir();

try {
// Create directories
Deno.mkdirSync(join(tempBase, "Program Files"), { recursive: true });
Deno.mkdirSync(spacedProjectDir, { recursive: true });

// Create junction (Windows directory symlink) to actual dart-sass
const junctionResult = await new Deno.Command("cmd", {
args: ["/c", "mklink", "/J", spacedSassDir, sassInstallDir],
}).output();

if (!junctionResult.success) {
const stderr = new TextDecoder().decode(junctionResult.stderr);
throw new Error(`Failed to create junction: ${stderr}`);
}

// Create test SCSS file in spaced path (args with spaces)
const inputScss = join(spacedProjectDir, "test style.scss");
const outputCss = join(spacedProjectDir, "test style.css");
Deno.writeTextFileSync(inputScss, "body { color: red; }");

const spacedSassPath = join(spacedSassDir, "sass.bat");

// This is the exact bug scenario: spaced exe path + spaced args
// Without the fix, this fails with "C:\...\Program" not recognized
const result = await dartCommand([inputScss, outputCss], {
sassPath: spacedSassPath,
});

// Verify compilation succeeded (no stdout expected for file-to-file compilation)
assert(
result === undefined || result === "",
"Sass compile should succeed (no stdout for file-to-file compilation)",
);
assert(
Deno.statSync(outputCss).isFile,
"Output CSS file should be created",
);
} finally {
// Cleanup: remove junction first (rmdir for junctions), then temp directory
try {
await new Deno.Command("cmd", {
args: ["/c", "rmdir", spacedSassDir],
}).output();
await Deno.remove(tempBase, { recursive: true });
} catch (e) {
// Best effort cleanup - log for debugging if it fails
console.debug("Test cleanup failed:", e);
}
}
},
{ ignore: !isWindows },
);
157 changes: 157 additions & 0 deletions tests/unit/windows-exec.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/*
* windows-exec.test.ts
*
* Tests for Windows command execution utilities.
* Validates fix for https://github.com/quarto-dev/quarto-cli/issues/13997
*
* Copyright (C) 2020-2025 Posit Software, PBC
*/

import { unitTest } from "../test.ts";
import { assert, assertEquals } from "testing/asserts";
import { isWindows } from "../../src/deno_ral/platform.ts";
import { join } from "../../src/deno_ral/path.ts";
import { requireQuoting, safeWindowsExec } from "../../src/core/windows.ts";
import { execProcess } from "../../src/core/process.ts";

// Test that requireQuoting correctly quotes paths with spaces
unitTest(
"requireQuoting - quotes paths with spaces",
// deno-lint-ignore require-await
async () => {
const result = requireQuoting([
"C:\\Program Files\\dart-sass\\sass.bat",
"C:\\My Project\\style.scss",
"C:\\My Project\\style.css",
]);

assert(result.status === true, "Should indicate quoting was required");
assertEquals(result.args[0], '"C:\\Program Files\\dart-sass\\sass.bat"');
assertEquals(result.args[1], '"C:\\My Project\\style.scss"');
assertEquals(result.args[2], '"C:\\My Project\\style.css"');
},
{ ignore: !isWindows },
);

// Test that requireQuoting does not quote clean paths (no special chars)
// Note: Windows paths with drive letters (C:) ARE quoted due to the colon
unitTest(
"requireQuoting - no quoting for clean paths",
// deno-lint-ignore require-await
async () => {
const result = requireQuoting([
"sass.bat",
"input.scss",
"output.css",
]);

assert(result.status === false, "Should indicate no quoting needed");
assertEquals(result.args[0], "sass.bat");
assertEquals(result.args[1], "input.scss");
assertEquals(result.args[2], "output.css");
},
{ ignore: !isWindows },
);

// Test that safeWindowsExec passes arguments with spaces correctly
unitTest(
"safeWindowsExec - passes spaced args correctly",
async () => {
const tempDir = Deno.makeTempDirSync({ prefix: "quarto-test" });

try {
// Create batch file that echoes args (use %~1 to strip quotes)
const echoArgs = join(tempDir, "echo-args.bat");
Deno.writeTextFileSync(
echoArgs,
`@echo off
echo ARG1: %~1
echo ARG2: %~2
`,
);

const spaced1 = "C:\\My Project\\input.scss";
const spaced2 = "C:\\My Project\\output.css";
const quoted = requireQuoting([echoArgs, spaced1, spaced2]);

const result = await safeWindowsExec(
quoted.args[0],
quoted.args.slice(1),
(cmd) =>
execProcess({
cmd: cmd[0],
args: cmd.slice(1),
stdout: "piped",
stderr: "piped",
}),
);

assert(result.success, "Should execute successfully");
assert(
result.stdout?.includes(spaced1),
`Arg1 should be passed correctly. Got: ${result.stdout}`,
);
assert(
result.stdout?.includes(spaced2),
`Arg2 should be passed correctly. Got: ${result.stdout}`,
);
} finally {
Deno.removeSync(tempDir, { recursive: true });
}
},
{ ignore: !isWindows },
);

// Test that safeWindowsExec handles program paths with spaces (issue #13997)
// This is the core bug: Deno has issues executing .bat files when both the
// command path AND arguments contain spaces.
unitTest(
"safeWindowsExec - handles program path with spaces (issue #13997)",
async () => {
const tempDir = Deno.makeTempDirSync({ prefix: "quarto-test" });

try {
// Create directory with spaces (simulates C:\Program Files\)
const spacedDir = join(tempDir, "Program Files", "tool");
Deno.mkdirSync(spacedDir, { recursive: true });

// Create batch file in spaced path
const program = join(spacedDir, "echo-success.bat");
Deno.writeTextFileSync(
program,
`@echo off
echo SUCCESS
echo ARG: %~1
`,
);

const spacedArg = "C:\\My Project\\file.txt";
const quoted = requireQuoting([program, spacedArg]);

const result = await safeWindowsExec(
quoted.args[0],
quoted.args.slice(1),
(cmd) =>
execProcess({
cmd: cmd[0],
args: cmd.slice(1),
stdout: "piped",
stderr: "piped",
}),
);

assert(result.success, "Should execute program in spaced path");
assert(
result.stdout?.includes("SUCCESS"),
`Program should run. Got: ${result.stdout}`,
);
assert(
result.stdout?.includes(spacedArg),
`Arg should be passed correctly. Got: ${result.stdout}`,
);
} finally {
Deno.removeSync(tempDir, { recursive: true });
}
},
{ ignore: !isWindows },
);