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
2 changes: 2 additions & 0 deletions apps/vscode/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## 1.139.0 (Unreleased)

- Fixed "Quarto: Preview" and render in the terminal when the document path contains shell special characters, such as parentheses, `;`, `@`, `$`, or quotes (<https://github.com/quarto-dev/quarto/pull/1197>).

- The extension now requires VS Code 1.101 or later. Positron is unaffected (<https://github.com/quarto-dev/quarto/pull/1157>).
- In the visual editor, citations inserted into a `.bib` bibliography for software, graphics, songs, films, legislation, legal cases, interviews, personal communications, hearings, maps, figures and classics are now written as `@misc` entries instead of `@article` (<https://github.com/quarto-dev/quarto/pull/1160>).
- The extension now reads YAML the way Quarto CLI does: merge keys (`<<`) in front matter and `_quarto.yml` are no longer resolved, and dates are read as strings (<https://github.com/quarto-dev/quarto/pull/1158>).
Expand Down
7 changes: 4 additions & 3 deletions apps/vscode/src/core/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ import child_process from "node:child_process";
import { Uri, extensions } from "vscode";
import { activeWorkspaceFolder } from "./workspace";

import { shQuote } from "core";

export async function activePythonInterpreter(uri?: Uri) {

const workspaceFolder = activeWorkspaceFolder(uri);
Expand Down Expand Up @@ -85,7 +83,10 @@ export function pythonIsCondaEnv(python: string) {
"import sys, os; print(os.path.exists(os.path.join(sys.prefix, 'conda-meta')))",
];
const output = (
child_process.execFileSync(shQuote(python), args, {
// Note: execFileSync does not spawn a shell, so the executable path must
// not be shell-quoted here (shQuote is only for command strings typed
// into a terminal).
child_process.execFileSync(python, args, {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this fix! My earlier review also asked for a test of this change, and I don't see one quite yet.

The vscode import stops this module from running under plain node:test, but the VS Code extension tests in apps/vscode/src/test/ run inside the extension host, where vscode is available. Can you add a test there with npm run test-vscode?

The test must fail with the old shell-quoted path and pass with the raw path. A false result does not show this, because the catch returns false for both. One way to get a true result without conda:

  1. Make a temporary directory with special characters in its name, for example dir (x) $y.
  2. In that directory, write an executable script named python that contains #!/bin/sh and echo True.
  3. Make sure that pythonIsCondaEnv() returns true for the script path.

With the old code, the quoted path does not exist, so the function returns false and the test fails. A shebang script does not run on Windows, so skip this test on win32.

encoding: "utf-8",
}) as unknown as string
).trim();
Expand Down
65 changes: 54 additions & 11 deletions apps/vscode/src/core/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import * as os from "node:os";
import fs from "node:fs";


import { Uri, workspace, extensions, window } from "vscode";
import { pathWithForwardSlashes, shQuote, sleep, winShEscape } from "core";
import { Uri, workspace, extensions, window, env } from "vscode";
import { pathWithForwardSlashes, shQuote, shQuoteUnknownShell, sleep, TerminalShell } from "core";
import { Terminal } from "vscode";
import { QuartoContext, fileCrossrefIndexStorage } from "quarto-core";
import { TerminalOptions } from "vscode";
Expand Down Expand Up @@ -94,21 +94,64 @@ export function terminalOptions(name: string, target: Uri, env: TerminalEnv) {
}

export function terminalCommand(command: string, context: QuartoContext, target?: Uri) {
const shell = targetShell(context);
const quarto = "quarto"; // binPath prepended to PATH so we don't need the full form
const cmd: string[] = [
context.useCmd ? winShEscape(quarto) : shQuote(quarto),
command
];
// Note: the executable name must NOT be shell-quoted. PowerShell does not
// run a line that starts with a quoted string as a command (it needs `&`),
// so quoting it broke every Windows preview. `quarto` is a fixed literal
// with no shell-special characters, so it is safe bare in all shells.
const cmd: string[] = [quarto, command];
if (target) {
cmd.push(shQuote(
context.useCmd
? target.fsPath
: pathWithForwardSlashes(target.fsPath)
));
const targetPath =
context.useCmd ? target.fsPath : pathWithForwardSlashes(target.fsPath);
cmd.push(
shell === undefined ? shQuoteUnknownShell(targetPath) : shQuote(targetPath, shell)
);
}
return cmd;
}

// Map a shell executable path to the quoting rules it needs. Exported for
// unit testing.
export function terminalShellForPath(
shellPath: string | undefined
): TerminalShell | undefined {
if (!shellPath) {
return undefined;
}
const base = shellPath
.split(/[\\/]/)
.pop()!
.toLowerCase()
.replace(/\.(exe|cmd|bat)$/, "");
if (base === "pwsh" || base === "powershell") {
return "powershell";
}
if (base === "cmd") {
return "cmd";
}
if (base === "bash" || base === "zsh" || base === "sh") {
return "posix";
}
return undefined;
}

// The shell that will interpret a generated terminal command: `useCmd`
// means the command targets cmd.exe (old Quarto on Windows shipped a .cmd
// shim); otherwise the quoting follows the shell the integrated terminal
// will actually start, read from vscode.env.shell. The OS platform alone
// cannot tell us — `terminalOptions()` does not set shellPath, so the
// terminal starts the user's default profile, which on Windows can be
// PowerShell, cmd.exe, Git Bash, or WSL, and on macOS/Linux pwsh or fish.
// Returns undefined when the shell is not recognized; the caller then falls
// back to the pre-shell-aware quoting.
function targetShell(context: QuartoContext): TerminalShell | undefined {
if (context.useCmd) {
return "cmd";
}
return terminalShellForPath(env.shell);
}

export async function sendTerminalCommand(
terminal: Terminal,
terminalEnv: TerminalEnv,
Expand Down
2 changes: 1 addition & 1 deletion apps/vscode/src/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export async function run(): Promise<void> {
timeout: 60000,
});

const testFiles = glob.sync(path.join(__dirname, 'symbols.test.js'));
const testFiles = glob.sync(path.join(__dirname, '{symbols,terminal,python}.test.js'));

for (const file of testFiles) {
mocha.addFile(file);
Expand Down
33 changes: 33 additions & 0 deletions apps/vscode/src/test/python.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2026 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

import * as assert from "assert";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";

import { pythonIsCondaEnv } from "../core/python";

suite("pythonIsCondaEnv", function () {
test("accepts a raw interpreter path with shell special characters", function () {
if (process.platform === "win32") {
// A shebang script does not run on Windows.
this.skip();
}
// execFileSync does not spawn a shell, so the interpreter path must be
// passed raw: with the old shell-quoted path the file does not exist and
// the function returns false (quarto-dev/quarto#1197). A shebang script
// named `python` stands in for a real interpreter without needing conda.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "quarto-test-dir (x) $y-"));
const script = path.join(dir, "python");
try {
fs.writeFileSync(script, "#!/bin/sh\necho True\n");
fs.chmodSync(script, 0o755);
assert.strictEqual(pythonIsCondaEnv(script), true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
64 changes: 64 additions & 0 deletions apps/vscode/src/test/terminal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2026 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

import * as assert from "assert";
import * as vscode from "vscode";
import { QuartoContext } from "quarto-core";

import { terminalCommand, terminalShellForPath } from "../core/terminal";

suite("terminalCommand", function () {
test("does not quote the quarto executable name", function () {
// PowerShell does not run a line that starts with a quoted string as a
// command (it needs `&`), so the executable must stay a bare word —
// quoting it broke every Windows preview (quarto-dev/quarto#1197).
const context = { useCmd: false } as unknown as QuartoContext;
const cmd = terminalCommand("preview", context);
assert.deepStrictEqual(cmd, ["quarto", "preview"]);
assert.ok(!/^['"]/.test(cmd[0]), "executable name must not be quoted");
});

test("passes the document path as one literal argument", function () {
// The #1194 fix itself: a path with parentheses must survive as one
// literal argument. Quoting shape is shell-dependent (and the test
// host's default shell varies), so assert singularity and literal
// survival; exact serializer output is covered by the shQuote tests.
const context = { useCmd: false } as unknown as QuartoContext;
const target = vscode.Uri.file("C:/Users/me/Article(md)/01_introduction.md");
const cmd = terminalCommand("preview", context, target);
assert.strictEqual(cmd[0], "quarto");
assert.strictEqual(cmd[1], "preview");
assert.strictEqual(cmd.length, 3, "document path must be a single argument");
assert.ok(
cmd[2].includes("Article(md)"),
`document path must survive literally, got: ${cmd[2]}`
);
});
});

suite("terminalShellForPath", function () {
test("maps the terminal's actual shell to quoting rules", function () {
// terminalCommand() quotes for the shell the terminal will actually
// start (vscode.env.shell), not the OS platform (quarto-dev/quarto#1197).
const cases: Array<[string | undefined, string | undefined]> = [
["C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", "powershell"],
["/usr/local/bin/pwsh", "powershell"],
["C:\\Windows\\System32\\cmd.exe", "cmd"],
["/bin/bash", "posix"],
["/bin/zsh", "posix"],
["/bin/sh", "posix"],
["/usr/bin/fish", undefined],
["", undefined],
[undefined, undefined],
];
for (const [shellPath, expected] of cases) {
assert.strictEqual(
terminalShellForPath(shellPath),
expected,
`shell path: ${shellPath}`
);
}
});
});
51 changes: 44 additions & 7 deletions packages/core/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,51 @@ export function escapeRegExpCharacters(value: string): string {
return value.replace(/[\\{}*+?|^$.[\]()]/g, "\\$&");
}

export function shQuote(value: string): string {
if (/\s/g.test(value)) {
return `"${value}"`;
} else {
return value;
// The shell that will interpret a generated terminal command.
export type TerminalShell = "posix" | "powershell" | "cmd";

// Serialize one command-line argument so the target shell passes it to the
// child process as a single literal argument: no word splitting, globbing,
// or expansion. Always quotes, because each shell has its own literal-quoting
// rules and a generic double-quote wrapper is not enough (POSIX shells and
// PowerShell both still expand $HOME inside double quotes; cmd.exe still
// expands %NAME% inside them).
export function shQuote(value: string, shell: TerminalShell): string {
switch (shell) {
case "posix":
// Single quotes are fully literal; an embedded single quote is
// written as '\'' (close, escaped quote, reopen).
return `'${value.replace(/'/g, `'\\''`)}'`;
case "powershell":
// Single-quoted strings are fully literal; an embedded single quote
// is doubled.
return `'${value.replace(/'/g, "''")}'`;
case "cmd": {
// Double quotes group words for CommandLineToArgvW, but cmd.exe still
// expands %NAME% inside them (and !NAME! with delayed expansion).
// Note that cmd expands %NAME% before it processes carets, so ^%
// does not itself stop the expansion: the output is safe because the
// " and ^ characters break the variable name — cmd sees %"PATH"^%,
// no such variable is defined, and cmd keeps the text. Emit each %
// and ! outside the quotes, caret-escaped; a literal " is ""
// per CommandLineToArgvW. A part ending in a backslash needs its
// trailing backslashes doubled, otherwise CommandLineToArgvW reads
// \" as a literal quote instead of the closing quote.
return value
.split(/([%!])/)
.map((part) =>
part === "%" || part === "!"
? `^${part}`
: `"${part.replace(/"/g, `""`).replace(/(\\+)$/, "$1$1")}"`
)
.join("");
}
}
}

export function winShEscape(value: string): string {
return value.replace(" ", "^ ");
// Quoting for when the terminal's shell cannot be determined: the
// pre-shell-aware behavior (quote only on whitespace). Never worse than
// main for paths that already worked there.
export function shQuoteUnknownShell(value: string): string {
return /\s/.test(value) ? `"${value}"` : value;
}
Loading