Unnamed repository; edit this file 'description' to name the repository.
Merge pull request #22103 from joshka/joshka/new-project-command
Add VS Code create new project command
| -rw-r--r-- | editors/code/package.json | 40 | ||||
| -rw-r--r-- | editors/code/src/commands.ts | 1 | ||||
| -rw-r--r-- | editors/code/src/config.ts | 4 | ||||
| -rw-r--r-- | editors/code/src/main.ts | 6 | ||||
| -rw-r--r-- | editors/code/src/new_project.ts | 311 | ||||
| -rw-r--r-- | editors/code/src/toolchain.ts | 74 | ||||
| -rw-r--r-- | editors/code/src/util.ts | 6 | ||||
| -rw-r--r-- | editors/code/tests/unit/commands.test.ts | 80 | ||||
| -rw-r--r-- | editors/code/tests/unit/launch_config.test.ts | 49 | ||||
| -rw-r--r-- | editors/code/walkthrough-create-project.md | 9 |
10 files changed, 569 insertions, 11 deletions
diff --git a/editors/code/package.json b/editors/code/package.json index 7bc8fda659..13d32283d2 100644 --- a/editors/code/package.json +++ b/editors/code/package.json @@ -215,6 +215,11 @@ "category": "rust-analyzer" }, { + "command": "rust-analyzer.newProject", + "title": "Create New Project...", + "category": "rust-analyzer" + }, + { "command": "rust-analyzer.rebuildProcMacros", "title": "Rebuild proc macros and build scripts", "category": "rust-analyzer" @@ -491,6 +496,23 @@ "markdownDescription": "Do not start rust-analyzer server when the extension is activated.", "default": false, "type": "boolean" + }, + "rust-analyzer.projectCreation.openAfterCreate": { + "markdownDescription": "Control what happens after `rust-analyzer: Create New Project...` finishes creating a Cargo project.", + "default": "ask", + "enum": [ + "ask", + "open", + "openNewWindow", + "addToWorkspace" + ], + "enumDescriptions": [ + "Prompt for how to open the new project.", + "Open the new project in the current window.", + "Open the new project in a new window.", + "Add the new project to the current workspace, or open it if no workspace is open." + ], + "type": "string" } } }, @@ -3813,6 +3835,9 @@ "when": "inRustProject" }, { + "command": "rust-analyzer.newProject" + }, + { "command": "rust-analyzer.reloadWorkspace", "when": "inRustProject" }, @@ -3931,6 +3956,13 @@ } ] }, + "viewsWelcome": [ + { + "view": "explorer", + "contents": "Create a new Rust project.\n[Create Rust Project](command:rust-analyzer.newProject)", + "when": "workspaceFolderCount == 0" + } + ], "viewsContainers": { "activitybar": [ { @@ -3957,6 +3989,14 @@ "description": "A brief introduction to get started with rust-analyzer. Learn about key features and resources to help you get the most out of the extension.", "steps": [ { + "id": "create-project", + "title": "Create a Rust project", + "description": "Start a new Cargo binary or library project from VS Code.\n\n[Create a Rust Project](command:rust-analyzer.newProject)", + "media": { + "markdown": "./walkthrough-create-project.md" + } + }, + { "id": "setup", "title": "Useful Setup Tips", "description": "There are a couple of things you might want to configure upfront to your tastes. We'll name a few here but be sure to check out the docs linked below!\n\n**Marking library sources as readonly**\n\nAdding the snippet on the right to your settings.json will mark all Rust library sources as readonly.\n\n**Check on Save**\n\nBy default, rust-analyzer will run ``cargo check`` on your codebase when you save a file, rendering diagnostics emitted by ``cargo check`` within your code. This can potentially collide with other ``cargo`` commands running concurrently, blocking them from running for a certain amount of time. In these cases it is recommended to disable the ``rust-analyzer.checkOnSave`` configuration and running the ``rust-analyzer: Run flycheck`` command on-demand instead.", diff --git a/editors/code/src/commands.ts b/editors/code/src/commands.ts index 9c8b7071b3..5514319c69 100644 --- a/editors/code/src/commands.ts +++ b/editors/code/src/commands.ts @@ -33,6 +33,7 @@ import { log } from "./util"; import type { SyntaxElement } from "./syntax_tree_provider"; export * from "./run"; +export { newProject } from "./new_project"; export function analyzerStatus(ctx: CtxInit): Cmd { const tdcp = new (class implements vscode.TextDocumentContentProvider { diff --git a/editors/code/src/config.ts b/editors/code/src/config.ts index d65f011c75..0f783c71b9 100644 --- a/editors/code/src/config.ts +++ b/editors/code/src/config.ts @@ -447,6 +447,10 @@ export class Config { return this.get<boolean>("initializeStopped"); } + get projectCreationOpenAfterCreate() { + return this.get<string>("projectCreation.openAfterCreate"); + } + get askBeforeUpdateTest() { return this.get<boolean>("runnables.askBeforeUpdateTest"); } diff --git a/editors/code/src/main.ts b/editors/code/src/main.ts index 6567bcd2f1..7136314e7c 100644 --- a/editors/code/src/main.ts +++ b/editors/code/src/main.ts @@ -161,6 +161,12 @@ function createCommands(): Record<string, CommandFactory> { memoryUsage: { enabled: commands.memoryUsage }, reloadWorkspace: { enabled: commands.reloadWorkspace }, rebuildProcMacros: { enabled: commands.rebuildProcMacros }, + newProject: { + // Project creation is a pure VS Code-side workflow and should stay available even in + // empty windows before rust-analyzer has started or a Rust workspace exists. + enabled: commands.newProject, + disabled: commands.newProject, + }, matchingBrace: { enabled: commands.matchingBrace }, joinLines: { enabled: commands.joinLines }, parentModule: { enabled: commands.parentModule }, diff --git a/editors/code/src/new_project.ts b/editors/code/src/new_project.ts new file mode 100644 index 0000000000..f5f6d86da0 --- /dev/null +++ b/editors/code/src/new_project.ts @@ -0,0 +1,311 @@ +import * as vscode from "vscode"; + +import type { Ctx, Cmd } from "./ctx"; +import * as ra from "./lsp_ext"; +import { cargoPath } from "./toolchain"; +import { log, spawnAsync } from "./util"; + +type NewProjectKind = "bin" | "lib"; +type NewProjectOpenAction = "open" | "openNewWindow" | "addToWorkspace"; + +type NewProjectTemplate = { + detail: string; + id: NewProjectKind; + label: string; +}; + +type NewProjectCargo = { + cargo: string; + cargoEnv: NodeJS.ProcessEnv; +}; + +const NEW_PROJECT_TEMPLATES: readonly NewProjectTemplate[] = [ + { + id: "bin", + label: "Binary Application", + detail: "Create a Cargo binary package (`cargo new --bin`)", + }, + { + id: "lib", + label: "Library", + detail: "Create a Cargo library package (`cargo new --lib`)", + }, +] as const; + +export function newProject(ctx: Ctx): Cmd { + return async () => { + const cargo = await resolveNewProjectCargo(ctx); + + const selectedKind = await promptForNewProjectTemplate(); + if (!selectedKind) { + return; + } + + const parentFolder = await promptForNewProjectParentFolder(); + if (!parentFolder) { + return; + } + + const projectName = await promptForNewProjectName(parentFolder); + if (!projectName) { + return; + } + + if (!(await createNewProject(cargo, parentFolder, selectedKind, projectName))) { + return; + } + + const projectUri = vscode.Uri.joinPath(parentFolder, projectName); + const defaultAction = determineNewProjectOpenAction( + ctx.config.projectCreationOpenAfterCreate, + Boolean(vscode.workspace.workspaceFolders?.length), + ); + const action = + defaultAction === "ask" + ? await promptForNewProjectOpenAction( + projectName, + Boolean(vscode.workspace.workspaceFolders?.length), + ) + : defaultAction; + + if (action) { + await executeNewProjectOpenAction(ctx, action, projectUri); + } + }; +} + +async function resolveNewProjectCargo(ctx: Ctx): Promise<NewProjectCargo> { + // Use the same effective environment rust-analyzer uses elsewhere so project creation sees + // toolchain wrappers, PATH overrides, and CARGO_HOME changes from configuration. + const cargoEnv = { ...process.env, ...ctx.config.serverExtraEnv }; + return { cargo: await cargoPath(cargoEnv), cargoEnv }; +} + +async function promptForNewProjectTemplate(): Promise<NewProjectKind | undefined> { + const selected = await vscode.window.showQuickPick(NEW_PROJECT_TEMPLATES, { + placeHolder: "Select a Rust project kind", + }); + return selected?.id; +} + +async function promptForNewProjectParentFolder(): Promise<vscode.Uri | undefined> { + const selectedFolder = await vscode.window.showOpenDialog({ + title: "Select the parent folder for the new Rust project", + openLabel: "Select parent folder", + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + }); + if (!selectedFolder?.length) { + return undefined; + } + return selectedFolder[0]; +} + +const CARGO_MANIFEST_NAME_PATTERN = /^[\p{Alphabetic}\p{Number}_-]+$/u; + +// Keep local validation focused on stable checks that can be reported in the input box, then let +// `cargo new` remain the source of truth for package-name-specific identifier and keyword rules. +export function validateNewProjectName( + value: string, + existingNames: readonly string[], +): string | undefined { + const trimmedValue = value.trim(); + if (trimmedValue.length === 0) { + return "Project name cannot be empty."; + } + if (trimmedValue.includes("/") || trimmedValue.includes("\\")) { + return "Project name cannot contain '/' or '\\' characters."; + } + if (trimmedValue === "." || trimmedValue === "..") { + return "Project name cannot be '.' or '..'."; + } + if (!CARGO_MANIFEST_NAME_PATTERN.test(trimmedValue)) { + return "Project name can contain only alphanumeric characters, '-' or '_'."; + } + if (existingNames.includes(trimmedValue)) { + return "A file or folder with this name already exists."; + } + return undefined; +} + +async function promptForNewProjectName(parentFolder: vscode.Uri): Promise<string | undefined> { + let existingNames: string[] = []; + try { + const entries = await vscode.workspace.fs.readDirectory(parentFolder); + existingNames = entries.map(([name]) => name); + } catch (error) { + log.error("Failed to read project parent folder", error); + void vscode.window.showErrorMessage("Failed to read the selected parent folder."); + return undefined; + } + + const projectName = await vscode.window.showInputBox({ + prompt: `Enter the new project name to create inside ${parentFolder.fsPath}`, + validateInput: async (value) => validateNewProjectName(value, existingNames), + }); + return projectName?.trim(); +} + +export function cargoNewArgs(kind: NewProjectKind, name: string): string[] { + return ["new", kind === "bin" ? "--bin" : "--lib", name]; +} + +async function createNewProject( + cargo: NewProjectCargo, + parentFolder: vscode.Uri, + kind: NewProjectKind, + projectName: string, +): Promise<boolean> { + const args = cargoNewArgs(kind, projectName); + const createResult = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Creating Rust project ${projectName}`, + }, + async () => + spawnAsync(cargo.cargo, args, { + cwd: parentFolder.fsPath, + env: cargo.cargoEnv, + }), + ); + + if (createResult.error || createResult.status !== 0) { + const details = formatProcessDetails(createResult); + await showNewProjectError("Failed to create Rust project.", details || undefined, { + cargo: cargo.cargo, + args, + cwd: parentFolder.fsPath, + error: createResult.error?.message, + status: createResult.status, + stderr: createResult.stderr || undefined, + stdout: createResult.stdout || undefined, + }); + return false; + } + + return true; +} + +function formatProcessDetails(result: { error?: Error; stderr: string; stdout: string }): string { + return [result.stderr, result.stdout, result.error?.message] + .filter((value): value is string => Boolean(value && value.trim().length > 0)) + .join("\n") + .trim(); +} + +async function showNewProjectError( + message: string, + details: string | undefined, + logContext: { + cargo: string; + args: string[]; + cwd?: string; + error?: string; + status: number | null; + stderr?: string; + stdout?: string; + }, +): Promise<void> { + // Keep command-failure logging focused on the invocation and process output. Environment + // variables may contain secrets such as API keys, tokens, and credentials, so failure logs + // must not dump the merged env here. + const commandLine = [logContext.cargo, ...logContext.args].join(" "); + log.error(message); + log.error(`command: ${commandLine}`); + if (logContext.cwd) { + log.error(`cwd: ${logContext.cwd}`); + } + log.error(`exit status: ${String(logContext.status)}`); + if (logContext.error) { + log.error(`error: ${logContext.error}`); + } + if (logContext.stderr) { + log.error(`stderr:\n${logContext.stderr}`); + } + if (logContext.stdout) { + log.error(`stdout:\n${logContext.stdout}`); + } + const selection = await vscode.window.showErrorMessage( + details ? `${message}\n${details}` : message, + "Open Extension Logs", + ); + if (selection === "Open Extension Logs") { + log.show(); + } +} + +export function determineNewProjectOpenAction( + configuredAction: string | undefined, + hasWorkspaceFolders: boolean, +): "ask" | NewProjectOpenAction { + switch (configuredAction) { + case "open": + case "openNewWindow": + return configuredAction; + case "addToWorkspace": + // Adding to a workspace only makes sense when one is already open. Falling back to + // "open" keeps the setting usable in empty windows without adding another prompt path. + return hasWorkspaceFolders ? configuredAction : "open"; + default: + return "ask"; + } +} + +async function promptForNewProjectOpenAction( + projectName: string, + hasWorkspaceFolders: boolean, +): Promise<NewProjectOpenAction | undefined> { + let message = `Would you like to open ${projectName}?`; + const open = "Open"; + const openNewWindow = "Open in New Window"; + const choices = [open, openNewWindow]; + + const addToWorkspace = "Add to VS Code Workspace"; + if (hasWorkspaceFolders) { + message = `Would you like to open ${projectName}, or add it to the current VS Code workspace?`; + choices.push(addToWorkspace); + } + + const result = await vscode.window.showInformationMessage( + message, + { modal: true, detail: "The default action can be configured in settings." }, + ...choices, + ); + + const actionMap: Record<string, NewProjectOpenAction> = { + [open]: "open", + [openNewWindow]: "openNewWindow", + [addToWorkspace]: "addToWorkspace", + }; + return result ? actionMap[result] : undefined; +} + +async function executeNewProjectOpenAction( + ctx: Ctx, + action: NewProjectOpenAction, + projectUri: vscode.Uri, +): Promise<void> { + if (action === "open") { + await vscode.commands.executeCommand("vscode.openFolder", projectUri, { + forceReuseWindow: true, + }); + return; + } + + if (action === "openNewWindow") { + await vscode.commands.executeCommand("vscode.openFolder", projectUri, { + forceNewWindow: true, + }); + return; + } + + const index = vscode.workspace.workspaceFolders?.length ?? 0; + vscode.workspace.updateWorkspaceFolders(index, 0, { uri: projectUri }); + // Reuse the existing workspace window when requested, but nudge rust-analyzer afterwards so + // the newly added Cargo project is discovered immediately instead of waiting for a later + // background refresh. + if (ctx.client?.isRunning()) { + await ctx.client.sendRequest(ra.reloadWorkspace); + } +} diff --git a/editors/code/src/toolchain.ts b/editors/code/src/toolchain.ts index 76946d1510..6ae365c71c 100644 --- a/editors/code/src/toolchain.ts +++ b/editors/code/src/toolchain.ts @@ -3,7 +3,7 @@ import * as os from "os"; import * as path from "path"; import * as readline from "readline"; import * as vscode from "vscode"; -import { Env, log, memoizeAsync, unwrapUndefinable } from "./util"; +import { Env, isWindows, log, memoizeAsync, unwrapUndefinable } from "./util"; import type { CargoRunnableArgs } from "./lsp_ext"; interface CompilationArtifact { @@ -160,9 +160,46 @@ export function cargoPath(env?: Env): Promise<string> { if (env?.["RUSTC_TOOLCHAIN"]) { return Promise.resolve("cargo"); } + if (env) { + return getPathForExecutableWithEnv("cargo", env); + } return getPathForExecutable("cargo"); } +/** + * Resolves an executable using an explicitly supplied environment instead of the VS Code host + * process environment. + * + * Some extension call sites already construct the exact env they will use for spawning, so path + * resolution needs to honor that same `PATH`/`CARGO_HOME` view to avoid launching a different + * toolchain than the one that was resolved. + */ +async function getPathForExecutableWithEnv( + executableName: "cargo" | "rustc" | "rustup", + env: Env, +): Promise<string> { + const envVar = getEnvVar(env, executableName.toUpperCase()); + if (envVar) { + return envVar; + } + + if (await lookupInPath(executableName, getEnvVar(env, "PATH") ?? "")) { + return executableName; + } + + const cargoHome = getCargoHomeFromPath(getEnvVar(env, "CARGO_HOME")); + if (cargoHome) { + for (const candidate of executableCandidates(executableName)) { + const standardPath = vscode.Uri.joinPath(cargoHome, "bin", candidate); + if (await isFileAtUri(standardPath)) { + return standardPath.fsPath; + } + } + } + + return executableName; +} + /** Mirrors `toolchain::get_path_for_executable()` implementation */ const getPathForExecutable = memoizeAsync( // We apply caching to decrease file-system interactions @@ -172,23 +209,22 @@ const getPathForExecutable = memoizeAsync( if (envVar) return envVar; } - if (await lookupInPath(executableName)) return executableName; + if (await lookupInPath(executableName, process.env["PATH"] ?? "")) return executableName; const cargoHome = getCargoHome(); if (cargoHome) { - const standardPath = vscode.Uri.joinPath(cargoHome, "bin", executableName); - if (await isFileAtUri(standardPath)) return standardPath.fsPath; + for (const candidate of executableCandidates(executableName)) { + const standardPath = vscode.Uri.joinPath(cargoHome, "bin", candidate); + if (await isFileAtUri(standardPath)) return standardPath.fsPath; + } } return executableName; }, ); -async function lookupInPath(exec: string): Promise<boolean> { - const paths = process.env["PATH"] ?? ""; - +async function lookupInPath(exec: string, paths: string): Promise<boolean> { const candidates = paths.split(path.delimiter).flatMap((dirInPath) => { - const candidate = path.join(dirInPath, exec); - return os.type() === "Windows_NT" ? [candidate, `${candidate}.exe`] : [candidate]; + return executableCandidates(exec).map((candidate) => path.join(dirInPath, candidate)); }); for await (const isFile of candidates.map(isFileAtPath)) { @@ -199,8 +235,28 @@ async function lookupInPath(exec: string): Promise<boolean> { return false; } +function executableCandidates(executableName: string): string[] { + // Keep the extension-side probe aligned with `crates/toolchain::probe_for_binary()`, which + // checks both the bare executable name and the platform suffix such as `.exe` on Windows. + // That matters for both PATH lookups and `$CARGO_HOME/bin/<tool>` fallbacks. + return isWindows ? [executableName, `${executableName}.exe`] : [executableName]; +} + +function getEnvVar(env: Env, name: string): string | undefined { + if (!isWindows) { + return env[name]; + } + + const foldedName = name.toLowerCase(); + return Object.entries(env).find(([key]) => key.toLowerCase() === foldedName)?.[1]; +} + function getCargoHome(): vscode.Uri | null { const envVar = process.env["CARGO_HOME"]; + return getCargoHomeFromPath(envVar); +} + +function getCargoHomeFromPath(envVar: string | undefined): vscode.Uri | null { if (envVar) return vscode.Uri.file(envVar); try { diff --git a/editors/code/src/util.ts b/editors/code/src/util.ts index 05b475080c..ddeec814b4 100644 --- a/editors/code/src/util.ts +++ b/editors/code/src/util.ts @@ -22,6 +22,10 @@ class Log { log: true, }); + show(): void { + this.output.show(true); + } + trace(...messages: [unknown, ...unknown[]]): void { this.output.trace(this.stringify(messages)); } @@ -40,7 +44,7 @@ class Log { error(...messages: [unknown, ...unknown[]]): void { this.output.error(this.stringify(messages)); - this.output.show(true); + this.show(); } private stringify(messages: unknown[]): string { diff --git a/editors/code/tests/unit/commands.test.ts b/editors/code/tests/unit/commands.test.ts new file mode 100644 index 0000000000..900bb2779d --- /dev/null +++ b/editors/code/tests/unit/commands.test.ts @@ -0,0 +1,80 @@ +import * as assert from "node:assert/strict"; + +import { + cargoNewArgs, + determineNewProjectOpenAction, + validateNewProjectName, +} from "../../src/new_project"; +import type { Context } from "."; + +export async function getTests(ctx: Context) { + await ctx.suite("New project command", (suite) => { + suite.addTest("rejects empty project name", async () => { + assert.equal(validateNewProjectName("", []), "Project name cannot be empty."); + assert.equal(validateNewProjectName(" ", []), "Project name cannot be empty."); + }); + + suite.addTest("rejects dot project names", async () => { + assert.equal(validateNewProjectName(".", []), "Project name cannot be '.' or '..'."); + assert.equal(validateNewProjectName("..", []), "Project name cannot be '.' or '..'."); + }); + + suite.addTest("rejects path separators", async () => { + assert.equal( + validateNewProjectName("foo/bar", []), + "Project name cannot contain '/' or '\\' characters.", + ); + assert.equal( + validateNewProjectName("foo\\bar", []), + "Project name cannot contain '/' or '\\' characters.", + ); + }); + + suite.addTest("rejects invalid Cargo package name characters", async () => { + assert.equal( + validateNewProjectName("foo.bar", []), + "Project name can contain only alphanumeric characters, '-' or '_'.", + ); + assert.equal( + validateNewProjectName("foo bar", []), + "Project name can contain only alphanumeric characters, '-' or '_'.", + ); + assert.equal( + validateNewProjectName("foo+bar", []), + "Project name can contain only alphanumeric characters, '-' or '_'.", + ); + }); + + suite.addTest("rejects existing child folder collisions", async () => { + assert.equal( + validateNewProjectName("demo", ["demo"]), + "A file or folder with this name already exists.", + ); + }); + + suite.addTest("accepts a normal project name", async () => { + assert.equal(validateNewProjectName("demo-project", []), undefined); + }); + + suite.addTest("resolves addToWorkspace fallback without workspace", async () => { + assert.equal(determineNewProjectOpenAction("addToWorkspace", false), "open"); + }); + + suite.addTest("keeps addToWorkspace when workspace exists", async () => { + assert.equal(determineNewProjectOpenAction("addToWorkspace", true), "addToWorkspace"); + }); + + suite.addTest("defaults to ask for unknown values", async () => { + assert.equal(determineNewProjectOpenAction(undefined, true), "ask"); + assert.equal(determineNewProjectOpenAction("ask", true), "ask"); + }); + + suite.addTest("builds binary cargo args", async () => { + assert.deepEqual(cargoNewArgs("bin", "demo"), ["new", "--bin", "demo"]); + }); + + suite.addTest("builds library cargo args", async () => { + assert.deepEqual(cargoNewArgs("lib", "demo"), ["new", "--lib", "demo"]); + }); + }); +} diff --git a/editors/code/tests/unit/launch_config.test.ts b/editors/code/tests/unit/launch_config.test.ts index eeb8608a42..8da62cd241 100644 --- a/editors/code/tests/unit/launch_config.test.ts +++ b/editors/code/tests/unit/launch_config.test.ts @@ -1,5 +1,9 @@ import * as assert from "assert"; -import { Cargo } from "../../src/toolchain"; +import * as os from "os"; +import * as path from "path"; +import { mkdtemp, mkdir, rm, writeFile } from "fs/promises"; +import { Cargo, cargoPath } from "../../src/toolchain"; +import { isWindows, normalizeDriveLetter } from "../../src/util"; import type { Context } from "."; export async function getTests(ctx: Context) { @@ -96,4 +100,47 @@ export async function getTests(ctx: Context) { assert.notDeepStrictEqual(args.filter, undefined); }); }); + + await ctx.suite("Toolchain resolution", (suite) => { + suite.addTest("prefers explicit CARGO from provided env", async () => { + const explicitCargo = path.join(os.tmpdir(), "custom-cargo"); + assert.strictEqual(await cargoPath({ CARGO: explicitCargo }), explicitCargo); + }); + + suite.addTest("resolves cargo from provided PATH", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "ra-cargo-path-")); + try { + const cargoBinary = path.join(tempDir, isWindows ? "cargo.exe" : "cargo"); + await writeFile(cargoBinary, ""); + + const pathKey = isWindows ? "Path" : "PATH"; + assert.strictEqual(await cargoPath({ [pathKey]: tempDir }), "cargo"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + suite.addTest("resolves cargo from provided CARGO_HOME", async () => { + const cargoHome = await mkdtemp(path.join(os.tmpdir(), "ra-cargo-home-")); + try { + const binDir = path.join(cargoHome, "bin"); + await mkdir(binDir); + const cargoBinary = path.join(binDir, isWindows ? "cargo.exe" : "cargo"); + await writeFile(cargoBinary, ""); + + assert.strictEqual( + normalizeComparablePath(await cargoPath({ PATH: "", CARGO_HOME: cargoHome })), + normalizeComparablePath(cargoBinary), + ); + } finally { + await rm(cargoHome, { recursive: true, force: true }); + } + }); + }); +} + +function normalizeComparablePath(filePath: string): string { + // Windows path comparisons should ignore drive-letter casing because `fsPath` + // may normalize it differently while still pointing to the same file. + return normalizeDriveLetter(filePath, isWindows); } diff --git a/editors/code/walkthrough-create-project.md b/editors/code/walkthrough-create-project.md new file mode 100644 index 0000000000..34c04cdd1d --- /dev/null +++ b/editors/code/walkthrough-create-project.md @@ -0,0 +1,9 @@ +# Create a New Cargo Project + +Create a new Cargo project without leaving VS Code. + +The command lets you choose the package kind, parent folder, project name, and +how the new project should be opened afterward. + +The project is created with Cargo, so the generated files and defaults match +the normal `cargo new` experience. |