From afa1c21316d45e5cfe0265e6e8e9016dab13fd75 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 5 Feb 2026 14:18:52 +0100 Subject: [PATCH 1/2] Fix Windows dart-sass path quoting with spaces (#13997) Deno has a bug when executing .bat files where both the command path and arguments contain spaces. This causes dart-sass to fail when Quarto is installed in C:\Program Files\ and project paths have spaces. Use safeWindowsExec on Windows to write the command to a temp batch file with proper quoting, bypassing Deno's buggy command-line handling. Closes #13997 Co-Authored-By: Claude --- src/core/dart-sass.ts | 89 ++++++++++++------ tests/unit/dart-sass.test.ts | 78 ++++++++++++++++ tests/unit/windows-exec.test.ts | 157 ++++++++++++++++++++++++++++++++ 3 files changed, 298 insertions(+), 26 deletions(-) create mode 100644 tests/unit/dart-sass.test.ts create mode 100644 tests/unit/windows-exec.test.ts diff --git a/src/core/dart-sass.ts b/src/core/dart-sass.ts index f9b859149a3..0c8e39c9795 100644 --- a/src/core/dart-sass.ts +++ b/src/core/dart-sass.ts @@ -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"); @@ -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) { @@ -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); } diff --git a/tests/unit/dart-sass.test.ts b/tests/unit/dart-sass.test.ts new file mode 100644 index 00000000000..0e37241353c --- /dev/null +++ b/tests/unit/dart-sass.test.ts @@ -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 }, +); diff --git a/tests/unit/windows-exec.test.ts b/tests/unit/windows-exec.test.ts new file mode 100644 index 00000000000..2422a4c1ffe --- /dev/null +++ b/tests/unit/windows-exec.test.ts @@ -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 }, +); From 1da3ac74694541e45ff8649cb572c82802ff7367 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 5 Feb 2026 18:10:46 +0100 Subject: [PATCH 2/2] Add changelog entry --- news/changelog-1.9.md | 1 + 1 file changed, 1 insertion(+) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 1d9fe8db3ba..8e18542df1b 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -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.