From 5c642819e2a76a6121c23d0a86a39848620c2766 Mon Sep 17 00:00:00 2001 From: Andrea Basile Date: Tue, 3 Nov 2026 17:09:38 +0100 Subject: [PATCH] feat(run): import run configurations from .vscode/launch.json (#1030) Run/Debug Configurations offers Import from .vscode/launch.json when the project has one (also a Find Action command). debugpy/python launches become Python, Django test, module or pytest configurations with their interpreter; debugpy attach becomes Python Remote Debug; node/pwa-node launches become node or npm configurations, attaches become Attach to Node.js, and node-terminal commands map to npm, a program or a shell line. cwd, env, envFile and the per-OS blocks are applied. Imported configurations join the dialog like new ones and are saved on OK/Apply; names already present are skipped, and a notification lists skipped entries with the reason and keys with no equivalent. Refs #991 Co-authored-by: andreabasile <7715018+andreabasile@users.noreply.github.com> --- crates/ide-host/src/ops/run/commands.rs | 24 ++ crates/ide-run/src/config.rs | 2 +- crates/ide-run/src/detect.rs | 13 + crates/ide-run/src/import/mod.rs | 238 +++++++++++ crates/ide-run/src/import/vscode.rs | 541 ++++++++++++++++++++++++ crates/ide-run/src/lib.rs | 12 + crates/ide-run/src/process.rs | 17 +- crates/ide-run/tests/import_vscode.rs | 521 +++++++++++++++++++++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/run.rs | 22 + src/bindings.ts | 67 +++ src/features/run/EditConfigsDialog.tsx | 52 ++- src/features/run/Run.module.css | 4 + src/features/run/importAction.test.tsx | 80 ++++ src/features/run/importConfigs.test.tsx | 180 ++++++++ src/features/run/importConfigs.tsx | 94 ++++ src/features/run/index.ts | 23 +- src/features/run/removal.test.tsx | 1 + src/features/run/store.ts | 5 + src/platform/locales/text/zh.json | 19 + 20 files changed, 1908 insertions(+), 9 deletions(-) create mode 100644 crates/ide-run/src/import/mod.rs create mode 100644 crates/ide-run/src/import/vscode.rs create mode 100644 crates/ide-run/tests/import_vscode.rs create mode 100644 src/features/run/importAction.test.tsx create mode 100644 src/features/run/importConfigs.test.tsx create mode 100644 src/features/run/importConfigs.tsx diff --git a/crates/ide-host/src/ops/run/commands.rs b/crates/ide-host/src/ops/run/commands.rs index c1b2acc73..6bd695895 100644 --- a/crates/ide-host/src/ops/run/commands.rs +++ b/crates/ide-host/src/ops/run/commands.rs @@ -4,6 +4,7 @@ use crate::Host; use crate::rpc::Dispatcher; use ide_core::{IdeError, IdeResult}; use ide_run::RunConfig; +use ide_run::import::{RunImport, RunImportSource}; pub async fn run_dotnet_resolve(host: &Host, window: &str, config: RunConfig, debug: bool) -> IdeResult { host.require_trust(window)?; @@ -105,6 +106,25 @@ pub fn run_save_templates(host: &Host, window: &str, templates: Vec) ide_run::save_templates(&root, &templates) } +/// Project files run configurations can be imported from (`.vscode/launch.json`). +pub fn run_import_sources(host: &Host, window: &str) -> IdeResult> { + let root = host.project(window)?.root.clone(); + Ok(ide_run::import::available(&root)) +} + +/// Configurations read from `source`, not saved; names already in `existing` (the +/// configurations being edited) are skipped. +pub fn run_import_configs( + host: &Host, + window: &str, + source: RunImportSource, + existing: Vec, +) -> IdeResult { + host.require_trust(window)?; + let root = host.project(window)?.root.clone(); + ide_run::import_configs(&root, source, &existing) +} + /// The environment runs inherit when "Include system environment variables" is on. pub fn run_system_env(_host: &Host, _window: &str) -> std::collections::BTreeMap { ide_run::system_env() @@ -240,6 +260,10 @@ pub(crate) fn register(d: &mut Dispatcher) { }); d.add("run_templates", |c| async move { run_templates(&c.host, &c.window) }); d.add("run_save_templates", |c| async move { run_save_templates(&c.host, &c.window, c.arg("templates")?) }); + d.add("run_import_sources", |c| async move { run_import_sources(&c.host, &c.window) }); + d.add("run_import_configs", |c| async move { + run_import_configs(&c.host, &c.window, c.arg("source")?, c.arg("existing")?) + }); d.add("run_system_env", |c| async move { Ok(run_system_env(&c.host, &c.window)) }); d.add("run_gradle_jvm_args_script", |c| async move { run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await diff --git a/crates/ide-run/src/config.rs b/crates/ide-run/src/config.rs index 63c6a35f3..60616c60a 100644 --- a/crates/ide-run/src/config.rs +++ b/crates/ide-run/src/config.rs @@ -117,7 +117,7 @@ struct FileConfig { target: Option, } -fn slug(s: &str) -> String { +pub(crate) fn slug(s: &str) -> String { let mut out = String::new(); for c in s.chars() { if c.is_alphanumeric() { diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 50bce3967..24cf12f64 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -823,6 +823,19 @@ impl PyEnv { } } +/// The interpreter an imported debugger configuration runs with, as `(command, leading args)`: +/// a project virtualenv's `python` (what the debugger launches directly), else the detected +/// environment's (`uv run python`, `python3`). The command is relative to the root. +pub(crate) fn project_python(root: &Path) -> (String, Vec) { + for venv in [".venv", "venv"] { + let rel = format!("{venv}/{}/{}", PyEnv::bin_dir(), PyEnv::python_exe()); + if root.join(&rel).is_file() { + return (rel, Vec::new()); + } + } + PyEnv::detect(root).python() +} + fn py_config( id: String, name: String, diff --git a/crates/ide-run/src/import/mod.rs b/crates/ide-run/src/import/mod.rs new file mode 100644 index 000000000..2961e2134 --- /dev/null +++ b/crates/ide-run/src/import/mod.rs @@ -0,0 +1,238 @@ +//! Run configurations imported on request from another IDE's project files +//! (`.vscode/launch.json`). Each source turns its entries into [`Entry`]s; [`finish`] +//! gives them ids, skips names that already exist (so importing again adds nothing +//! twice) and resolves compound members by name. + +mod vscode; + +use crate::RunConfig; +use ide_core::{IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::{Component, Path, PathBuf}; + +/// A project file run configurations can be imported from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum RunImportSource { + /// VS Code's `.vscode/launch.json`. + Vscode, +} + +impl RunImportSource { + pub const ALL: [Self; 1] = [Self::Vscode]; + + /// The file read, relative to the project root. + pub fn file(self) -> &'static str { + match self { + Self::Vscode => ".vscode/launch.json", + } + } +} + +/// What an import found: configurations to add and entries left out, with the reason. +#[derive(Clone, Debug, PartialEq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RunImport { + pub source: RunImportSource, + /// The file read, relative to the project root. + pub file: String, + pub imported: Vec, + pub skipped: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ImportedRunConfig { + pub config: RunConfig, + /// Keys of the source entry with no equivalent here (`preLaunchTask`, `justMyCode`…). + pub ignored: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SkippedRunConfig { + pub name: String, + pub reason: SkipReason, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum SkipReason { + /// A configuration with this name already exists. + NameTaken, + /// The debugger type (`type`) has no equivalent here, e.g. `cppdbg`. + UnsupportedType { + #[serde(rename = "type")] + type_: String, + }, + /// A `request` other than `launch` and `attach`. + UnsupportedRequest { request: String }, + /// An attach other than to a host and port (to a process id, or listening for it). + UnsupportedAttach, + /// Uses a variable only known when VS Code starts it (`${file}`, `${input:…}`, `${command:…}`). + Variable { variable: String }, + /// No program, module or command. + NothingToRun, + /// A compound member that is neither imported nor an existing configuration. + MissingMember { member: String }, + /// Not an object with a name. + Invalid, +} + +/// One entry of the source file, before ids and names are settled. +pub(crate) struct Entry { + pub name: String, + pub result: Result, +} + +pub(crate) struct Found { + /// `id` is assigned by [`finish`]. + pub config: RunConfig, + /// Names of the configurations a compound starts. + pub members: Vec, + pub ignored: Vec, +} + +/// Configurations from `source` under `root`, given the ones the project has now (`existing`, +/// detected included). `env` resolves `${env:NAME}` and `${userHome}`. +pub fn import( + root: &Path, + source: RunImportSource, + existing: &[RunConfig], + env: &BTreeMap, +) -> IdeResult { + let file = source.file(); + let text = match std::fs::read_to_string(root.join(file)) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(IdeError::not_found(format!("{file} not found"))); + } + Err(e) => return Err(e.into()), + }; + let entries = match source { + RunImportSource::Vscode => vscode::entries(&text, root, env)?, + }; + Ok(finish(source, entries, existing)) +} + +/// The sources whose file exists under `root`. +pub fn available(root: &Path) -> Vec { + RunImportSource::ALL.into_iter().filter(|s| root.join(s.file()).is_file()).collect() +} + +fn unique_id(name: &str, ids: &mut HashSet) -> String { + let slug = crate::config::slug(name); + let base = format!("user:{}", if slug.is_empty() { "imported" } else { &slug }); + let mut id = base.clone(); + let mut n = 2; + while ids.contains(&id) { + id = format!("{base}-{n}"); + n += 1; + } + ids.insert(id.clone()); + id +} + +pub(crate) fn finish(source: RunImportSource, entries: Vec, existing: &[RunConfig]) -> RunImport { + let mut ids: HashSet = existing.iter().map(|c| c.id.clone()).collect(); + let mut by_name: HashMap = HashMap::new(); + for c in existing { + // Imported names are trimmed: compare existing ones the same way. + by_name.entry(c.name.trim().to_owned()).or_insert_with(|| c.id.clone()); + } + let mut imported: Vec<(usize, ImportedRunConfig)> = Vec::new(); + let mut skipped: Vec<(usize, SkippedRunConfig)> = Vec::new(); + let mut compounds = Vec::new(); + let skip = |name: String, reason| SkippedRunConfig { name, reason }; + + // Compounds last: their members may be any configuration of the file. + for (i, e) in entries.into_iter().enumerate() { + match e.result { + Err(reason) => skipped.push((i, skip(e.name, reason))), + Ok(_) if by_name.contains_key(&e.name) => skipped.push((i, skip(e.name, SkipReason::NameTaken))), + Ok(found) if found.config.kind == "compound" => { + // Reserve the name so a later configuration can't take it. + by_name.insert(e.name.clone(), String::new()); + compounds.push((i, e.name, found)); + } + Ok(mut found) => { + found.config.id = unique_id(&e.name, &mut ids); + by_name.insert(e.name.clone(), found.config.id.clone()); + imported.push((i, ImportedRunConfig { config: found.config, ignored: found.ignored })); + } + } + } + for (i, name, mut found) in compounds { + let members: Result, String> = found + .members + .iter() + .map(|m| by_name.get(m).filter(|id| !id.is_empty()).cloned().ok_or_else(|| m.clone())) + .collect(); + match members { + Ok(members) => { + found.config.id = unique_id(&name, &mut ids); + found.config.extras.compound = members; + imported.push((i, ImportedRunConfig { config: found.config, ignored: found.ignored })); + } + Err(member) => skipped.push((i, skip(name, SkipReason::MissingMember { member }))), + } + } + imported.sort_by_key(|(i, _)| *i); + skipped.sort_by_key(|(i, _)| *i); + RunImport { + source, + file: source.file().to_owned(), + imported: imported.into_iter().map(|(_, c)| c).collect(), + skipped: skipped.into_iter().map(|(_, s)| s).collect(), + } +} + +/// `path` as written relative to `base` (both absolute, without `..`), with `/` separators; +/// `None` when they don't share a prefix (another drive). +pub(crate) fn relative_to(path: &Path, base: &Path) -> Option { + let path: Vec = path.components().collect(); + let base: Vec = base.components().collect(); + let common = path.iter().zip(&base).take_while(|(a, b)| a == b).count(); + if common == 0 { + return None; + } + let mut parts: Vec = base[common..].iter().map(|_| "..".to_owned()).collect(); + parts.extend(path[common..].iter().map(|c| c.as_os_str().to_string_lossy().into_owned())); + Some(if parts.is_empty() { ".".to_owned() } else { parts.join("/") }) +} + +/// Drops `.` components and folds `..` into the parent, without touching the disk. +pub(crate) fn normalize(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for c in path.components() { + match c { + Component::CurDir => {} + Component::ParentDir => { + if !out.pop() { + out.push(c); + } + } + other => out.push(other), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_paths_climb_out_of_the_base() { + let root = Path::new("/p"); + assert_eq!(relative_to(Path::new("/p/manage.py"), root).as_deref(), Some("manage.py")); + assert_eq!(relative_to(Path::new("/p"), root).as_deref(), Some(".")); + assert_eq!( + relative_to(Path::new("/p/.venv/bin/python"), Path::new("/p/api")).as_deref(), + Some("../.venv/bin/python") + ); + assert_eq!(normalize(Path::new("/p/./api/../web")), PathBuf::from("/p/web")); + } +} diff --git a/crates/ide-run/src/import/vscode.rs b/crates/ide-run/src/import/vscode.rs new file mode 100644 index 000000000..254f3b2d4 --- /dev/null +++ b/crates/ide-run/src/import/vscode.rs @@ -0,0 +1,541 @@ +//! `.vscode/launch.json`: JSON with comments and trailing commas, `configurations` and +//! `compounds`. Python (`debugpy`, the older `python`) and Node.js (`node`, `pwa-node`, +//! `node-terminal`) launches become run configurations of the matching kind, their attach +//! requests Python Remote Debug / Attach to Node.js configurations. Variables with a fixed +//! meaning (`${workspaceFolder}`, `${userHome}`, `${env:NAME}`…) are replaced when importing; +//! an entry using one that depends on the moment VS Code launches it (`${file}`, +//! `${input:…}`, `${command:…}`) is skipped. + +use super::{Entry, Found, SkipReason, normalize, relative_to}; +use crate::{RunConfig, RunExtras}; +use ide_core::{IdeError, IdeResult}; +use serde_json::{Map, Value}; +use std::cell::Cell; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Keys this IDE covers on its own (console, source maps, Django detection, child +/// processes) or that only shape VS Code's UI: left out without a note. +const SILENT: &[&str] = &[ + "name", + "type", + "request", + "presentation", + "internalConsoleOptions", + "console", + "outputCapture", + "redirectOutput", + "django", + "sourceMaps", + "outFiles", + "resolveSourceMapLocations", + "autoAttachChildProcesses", + "showAsyncStacks", + "smartStep", + "windows", + "osx", + "linux", +]; + +const COMMON: &[&str] = &["cwd", "env", "envFile"]; +const PYTHON_LAUNCH: &[&str] = &["program", "module", "args", "python", "pythonArgs"]; +const PYTHON_ATTACH: &[&str] = &["connect", "host", "port", "pathMappings"]; +const NODE_LAUNCH: &[&str] = &["program", "args", "runtimeExecutable", "runtimeArgs"]; +const NODE_ATTACH: &[&str] = &["address", "port", "localRoot", "remoteRoot"]; +const NODE_TERMINAL: &[&str] = &["command"]; +const COMPOUND: &[&str] = &["configurations"]; + +const PACKAGE_MANAGERS: &[&str] = &["npm", "pnpm", "yarn", "bun"]; +/// `npm ` lines the npm configuration type edits (`src/features/run/types.ts`). +const NPM_COMMANDS: &[&str] = &["run", "test", "start", "install", "ci", "publish", "pack"]; + +/// Characters that make a `node-terminal` command a shell line rather than a program with arguments. +const SHELL_CHARS: &[char] = &['|', '&', ';', '<', '>', '(', ')', '$', '`', '*', '?', '\n']; + +const OS_KEY: &str = if cfg!(windows) { + "windows" +} else if cfg!(target_os = "macos") { + "osx" +} else { + "linux" +}; + +pub(crate) fn entries(text: &str, root: &Path, env: &BTreeMap) -> IdeResult> { + let json: Value = serde_json::from_str(&ide_core::jsonc::strip(text)) + .map_err(|e| IdeError::invalid(format!(".vscode/launch.json: {e}")))?; + let windows = cfg!(windows); + let homes = if windows { ["USERPROFILE", "HOME"] } else { ["HOME", "USERPROFILE"] }; + let home = homes.into_iter().find_map(|k| env_var(env, k, windows)); + let cx = Cx { root: &normalize(root), env, home, machine_values: Cell::new(false) }; + let list = |key: &str| json.get(key).and_then(Value::as_array).cloned().unwrap_or_default(); + let mut out: Vec = list("configurations").iter().map(|v| cx.entry(v)).collect(); + out.extend(list("compounds").iter().map(|v| cx.compound(v))); + Ok(out) +} + +struct Cx<'a> { + root: &'a Path, + env: &'a BTreeMap, + home: Option, + /// The entry being converted used `${env:…}` or `${userHome}`: its values belong to this machine. + machine_values: Cell, +} + +type Res = Result; + +fn name_of(v: &Value) -> String { + v.get("name").and_then(Value::as_str).unwrap_or_default().trim().to_owned() +} + +/// The variable `name` of `env`; Windows names ignore case (`${env:PATH}` finds `Path`). +fn env_var(env: &BTreeMap, name: &str, windows: bool) -> Option { + match env.get(name) { + Some(v) => Some(v.clone()), + None if windows => env.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.clone()), + None => None, + } +} + +/// String form of a scalar (`"args": [8000]`, `"env": { "DEBUG": true }`). +fn scalar(v: &Value) -> Option { + match v { + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + _ => None, + } +} + +/// The program's file name without a Windows extension (`npm.cmd` → `npm`). +fn command_name(cmd: &str) -> &str { + let name = cmd.rsplit(['/', '\\']).next().unwrap_or(cmd); + name.strip_suffix(".cmd").or_else(|| name.strip_suffix(".exe")).unwrap_or(name) +} + +fn config(name: &str, kind: &str, command: String, args: Vec, debug: Option<&str>) -> RunConfig { + RunConfig { + id: String::new(), + name: name.to_owned(), + kind: kind.to_owned(), + command, + args, + cwd: None, + env: BTreeMap::new(), + detected: false, + is_test: false, + debug_adapter: debug.map(str::to_owned), + allow_parallel: false, + extras: RunExtras::default(), + } +} + +/// `npm run dev`-style words: an npm configuration's `(command, args)`, `run-script` read as `run`. +fn npm_line(words: &[String]) -> Option<(String, Vec)> { + let (exe, rest) = words.split_first()?; + if !PACKAGE_MANAGERS.contains(&command_name(exe)) { + return None; + } + let mut args = rest.to_vec(); + if args.first().is_some_and(|a| a == "run-script") { + args[0] = "run".into(); + } + NPM_COMMANDS.contains(&args.first()?.as_str()).then(|| (exe.clone(), args)) +} + +/// Splits a command line into words: whitespace, `'…'` and `"…"` quoting, backslash escapes +/// (like `parseArgs` in `src/platform/commandLine.ts`). +fn split_args(line: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut has = false; + let mut quote: Option = None; + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + match quote { + Some(q) if c == q => quote = None, + Some('"') if c == '\\' && chars.peek().is_some_and(|n| "\"\\$`".contains(*n)) => { + cur.extend(chars.next()); + } + Some(_) => cur.push(c), + None if c == '\'' || c == '"' => { + quote = Some(c); + has = true; + } + None if c == '\\' && chars.peek().is_some() => { + cur.extend(chars.next()); + has = true; + } + None if c.is_whitespace() => { + if has || !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + has = false; + } + None => cur.push(c), + } + } + if has || !cur.is_empty() { + out.push(cur); + } + out +} + +impl Cx<'_> { + /// Replaces the variables in `s`. + fn expand(&self, s: &str) -> Res { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(start) = rest.find("${") { + let Some(len) = rest[start..].find('}') else { + break; + }; + out.push_str(&rest[..start]); + let name = &rest[start + 2..start + len]; + let value = match name { + "workspaceFolder" | "workspaceRoot" => self.root.to_string_lossy().into_owned(), + "workspaceFolderBasename" => { + self.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default() + } + "userHome" => { + self.machine_values.set(true); + self.home.clone().ok_or(SkipReason::Variable { variable: format!("${{{name}}}") })? + } + "pathSeparator" | "/" => std::path::MAIN_SEPARATOR.to_string(), + _ => match name.strip_prefix("env:") { + Some(var) => { + self.machine_values.set(true); + env_var(self.env, var, cfg!(windows)).unwrap_or_default() + } + None => return Err(SkipReason::Variable { variable: format!("${{{name}}}") }), + }, + }; + out.push_str(&value); + rest = &rest[start + len + 1..]; + } + out.push_str(rest); + Ok(out) + } + + fn string(&self, obj: &Map, key: &str) -> Res> { + match obj.get(key).and_then(scalar) { + Some(s) if !s.trim().is_empty() => self.expand(&s).map(Some), + _ => Ok(None), + } + } + + /// An array of words, or a string split like a command line. + fn words(&self, obj: &Map, key: &str) -> Res> { + match obj.get(key) { + Some(Value::Array(items)) => items.iter().filter_map(scalar).map(|s| self.expand(&s)).collect(), + Some(Value::String(s)) => Ok(split_args(&self.expand(s)?)), + _ => Ok(Vec::new()), + } + } + + /// An absolute path inside the project, written relative to `dir` (the run's working + /// directory, where the process resolves it); anything else as it is. + fn path_from(&self, value: String, dir: &Path) -> String { + let path = Path::new(&value); + if !path.is_absolute() { + return value; + } + let path = normalize(path); + if path.starts_with(self.root) && dir.starts_with(self.root) { + relative_to(&path, dir).unwrap_or(value) + } else { + value + } + } + + /// The working directory (absolute) and the configuration's `cwd` (relative to the root). + fn cwd(&self, obj: &Map) -> Res<(PathBuf, Option)> { + let Some(cwd) = self.string(obj, "cwd")? else { + return Ok((self.root.to_path_buf(), None)); + }; + let abs = normalize(&self.root.join(cwd)); + let rel = match abs.strip_prefix(self.root) { + Ok(r) if r.as_os_str().is_empty() => None, + Ok(r) => Some(PathBuf::from(relative_to(&abs, self.root).unwrap_or_else(|| r.to_string_lossy().into()))), + Err(_) => Some(abs.clone()), + }; + Ok((abs, rel)) + } + + /// `env` (a variable set to itself is inherited anyway) and `envFile`. + fn environment(&self, obj: &Map, c: &mut RunConfig) -> Res<()> { + if let Some(Value::Object(env)) = obj.get("env") { + for (k, v) in env { + let Some(v) = scalar(v) else { continue }; + let own = format!("${{env:{k}}}"); + if v == own || (cfg!(windows) && v.eq_ignore_ascii_case(&own)) { + continue; + } + c.env.insert(k.clone(), self.expand(&v)?); + } + } + if let Some(file) = self.string(obj, "envFile")? { + c.extras.env_files.push(self.path_from(file, self.root)); + } + Ok(()) + } + + fn entry(&self, v: &Value) -> Entry { + let name = name_of(v); + self.machine_values.set(false); + let result = match v.as_object() { + Some(obj) if !name.is_empty() => self.convert(&name, &overlay(obj)), + _ => Err(SkipReason::Invalid), + }; + let result = result.map(|mut found| { + // Values of this machine (its environment, its home) stay out of the shared run.toml. + found.config.extras.local = self.machine_values.get(); + found + }); + Entry { name, result } + } + + fn convert(&self, name: &str, obj: &Map) -> Res { + let ty = obj.get("type").and_then(Value::as_str).unwrap_or_default(); + let request = obj.get("request").and_then(Value::as_str).unwrap_or("launch"); + let (mut config, handled) = match (ty, request) { + ("debugpy" | "python", "launch") => (self.python_launch(name, obj)?, PYTHON_LAUNCH), + ("debugpy" | "python", "attach") => (self.python_attach(name, obj)?, PYTHON_ATTACH), + ("node" | "pwa-node", "launch") => (self.node_launch(name, obj)?, NODE_LAUNCH), + ("node" | "pwa-node", "attach") => (self.node_attach(name, obj)?, NODE_ATTACH), + ("node-terminal", "launch") => (self.node_terminal(name, obj)?, NODE_TERMINAL), + ("debugpy" | "python" | "node" | "pwa-node" | "node-terminal", other) => { + return Err(SkipReason::UnsupportedRequest { request: other.to_owned() }); + } + (other, _) => return Err(SkipReason::UnsupportedType { type_: other.to_owned() }), + }; + if request == "launch" { + config.cwd = self.cwd(obj)?.1.map(Into::into); + self.environment(obj, &mut config)?; + } + let mut ignored: Vec = obj + .keys() + .filter(|k| { + let k = k.as_str(); + !SILENT.contains(&k) && !handled.contains(&k) && (request != "launch" || !COMMON.contains(&k)) + }) + .cloned() + .collect(); + if obj.get("pathMappings").and_then(Value::as_array).is_some_and(|m| m.len() > 1) { + ignored.push("pathMappings".into()); + } + ignored.sort(); + Ok(Found { config, members: Vec::new(), ignored }) + } + + fn python_launch(&self, name: &str, obj: &Map) -> Res { + let (dir, _) = self.cwd(obj)?; + // `${command:python.interpreterPath}` / `${config:python.defaultInterpreterPath}` name the + // interpreter VS Code has selected: the project's here. Options after it (`[exe, "-X", "dev"]`) stay. + let selected = |s: &str| s.starts_with("${command:python.") || s.starts_with("${config:python."); + let python: Vec = match obj.get("python") { + Some(Value::Array(items)) => items.iter().filter_map(scalar).collect(), + Some(v) => scalar(v).into_iter().collect(), + None => Vec::new(), + }; + let (command, mut args) = match python.split_first() { + Some((exe, opts)) => { + let opts = opts.iter().map(|o| self.expand(o)).collect::>>()?; + if exe.trim().is_empty() || selected(exe) { + let (exe, mut lead) = self.project_python(&dir); + lead.extend(opts); + (exe, lead) + } else { + (self.path_from(self.expand(exe)?, &dir), opts) + } + } + None => self.project_python(&dir), + }; + args.extend(self.words(obj, "pythonArgs")?); + let rest = self.words(obj, "args")?; + let mut kind = "python"; + if let Some(module) = self.string(obj, "module")? { + if module == "pytest" { + kind = "pytest"; + } + args.extend(["-m".to_owned(), module]); + } else if let Some(program) = self.string(obj, "program")? { + let mut program = self.path_from(program, &dir); + // Only `manage.py` in the working directory (`./manage.py` too): the Django tests form writes `manage.py`. + if normalize(Path::new(&program)) == Path::new("manage.py") && rest.first().is_some_and(|a| a == "test") { + kind = "django-test"; + program = "manage.py".into(); + } + args.push(program); + } else { + return Err(SkipReason::NothingToRun); + } + args.extend(rest.into_iter().map(|a| self.path_from(a, &dir))); + let mut c = config(name, kind, command, args, Some("debugpy")); + c.is_test = kind != "python"; + Ok(c) + } + + /// The project's interpreter (see [`crate::detect::project_python`]), relative to `dir`. + fn project_python(&self, dir: &Path) -> (String, Vec) { + let (exe, lead) = crate::detect::project_python(self.root); + let exe = + if exe.contains('/') { self.path_from(self.root.join(&exe).to_string_lossy().into(), dir) } else { exe }; + (exe, lead) + } + + fn python_attach(&self, name: &str, obj: &Map) -> Res { + if obj.contains_key("processId") || obj.contains_key("listen") { + return Err(SkipReason::UnsupportedAttach); + } + let connect = obj.get("connect").and_then(Value::as_object).unwrap_or(obj); + let host = self.string(connect, "host")?.unwrap_or_else(|| "localhost".into()); + let Some(port) = self.string(connect, "port")? else { + return Err(SkipReason::UnsupportedAttach); + }; + let mut c = config(name, "python-remote", String::new(), Vec::new(), Some("debugpy")); + c.extras.params = BTreeMap::from([("host".into(), host), ("port".into(), port)]); + if let Some(Value::Object(m)) = obj.get("pathMappings").and_then(|m| m.get(0)) { + self.roots(m, &mut c)?; + } + Ok(c) + } + + fn node_launch(&self, name: &str, obj: &Map) -> Res { + let (dir, _) = self.cwd(obj)?; + let exe = self.string(obj, "runtimeExecutable")?.map(|e| self.path_from(e, &dir)); + let mut args = self.words(obj, "runtimeArgs")?; + let program = self.string(obj, "program")?.map(|p| self.path_from(p, &dir)); + if exe.is_none() && program.is_none() { + return Err(SkipReason::NothingToRun); + } + args.extend(program); + args.extend(self.words(obj, "args")?.into_iter().map(|a| self.path_from(a, &dir))); + let exe = exe.unwrap_or_else(|| "node".into()); + let words: Vec = std::iter::once(exe.clone()).chain(args.iter().cloned()).collect(); + Ok(match npm_line(&words) { + Some((command, args)) => npm_config(name, command, args), + None => config(name, "node", exe, args, Some("js-debug")), + }) + } + + fn node_attach(&self, name: &str, obj: &Map) -> Res { + if obj.contains_key("processId") { + return Err(SkipReason::UnsupportedAttach); + } + let host = self.string(obj, "address")?.unwrap_or_else(|| "localhost".into()); + let port = self.string(obj, "port")?.unwrap_or_else(|| "9229".into()); + let mut c = config(name, "node-remote", String::new(), Vec::new(), Some("js-debug")); + c.extras.params = BTreeMap::from([("host".into(), host), ("port".into(), port)]); + self.roots(obj, &mut c)?; + Ok(c) + } + + /// `localRoot` (relative to the project root, empty for the root itself) and `remoteRoot`. + fn roots(&self, obj: &Map, c: &mut RunConfig) -> Res<()> { + if let Some(local) = self.string(obj, "localRoot")? { + let local = self.path_from(local, self.root); + if local != "." { + c.extras.params.insert("localRoot".into(), local); + } + } + if let Some(remote) = self.string(obj, "remoteRoot")? { + c.extras.params.insert("remoteRoot".into(), remote); + } + Ok(()) + } + + /// A command typed into a terminal VS Code debugs: an npm script, a program with + /// arguments, or a shell line (run by the shell, without the debugger). + fn node_terminal(&self, name: &str, obj: &Map) -> Res { + let Some(line) = self.string(obj, "command")? else { + return Err(SkipReason::NothingToRun); + }; + let words = split_args(&line); + let shell = line.contains(SHELL_CHARS) || words.first().is_some_and(|w| w.contains('=')); + if shell { + return Ok(config(name, "shell", line.trim().to_owned(), Vec::new(), None)); + } + if let Some((command, args)) = npm_line(&words) { + return Ok(npm_config(name, command, args)); + } + let Some((exe, args)) = words.split_first() else { + return Err(SkipReason::NothingToRun); + }; + Ok(config(name, "shell", exe.clone(), args.to_vec(), Some("js-debug"))) + } + + fn compound(&self, v: &Value) -> Entry { + let name = name_of(v); + let result = match (v.as_object(), v.get("configurations").and_then(Value::as_array)) { + (Some(obj), Some(list)) if !name.is_empty() => { + // Members are names, or `{ "name", "folder" }` in multi-root workspaces. + let members: Vec = list + .iter() + .filter_map(|m| m.as_str().or_else(|| m.get("name").and_then(Value::as_str))) + .map(str::to_owned) + .collect(); + let mut ignored: Vec = obj + .keys() + .filter(|k| !SILENT.contains(&k.as_str()) && !COMPOUND.contains(&k.as_str())) + .cloned() + .collect(); + ignored.sort(); + Ok(Found { config: config(&name, "compound", String::new(), Vec::new(), None), members, ignored }) + } + _ => Err(SkipReason::Invalid), + }; + Entry { name, result } + } +} + +fn npm_config(name: &str, command: String, args: Vec) -> RunConfig { + config(name, "npm", command, args, Some("js-debug")) +} + +/// The entry with its block for this OS (`"linux": { … }`) merged over it, as VS Code does. +fn overlay(obj: &Map) -> Map { + let mut out = obj.clone(); + if let Some(Value::Object(os)) = obj.get(OS_KEY) { + for (k, v) in os { + out.insert(k.clone(), v.clone()); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splits_like_a_shell() { + assert_eq!( + split_args(r#"next dev --port "3 000" 'a b' c\ d"#), + ["next", "dev", "--port", "3 000", "a b", "c d"] + ); + assert_eq!(split_args(r#"x "" y"#), ["x", "", "y"]); + } + + fn cx_expand(env: &[(&str, &str)], text: &str) -> String { + let env: BTreeMap = env.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect(); + let cx = Cx { root: Path::new("/p"), env: &env, home: None, machine_values: Cell::new(false) }; + let out = cx.expand(text).unwrap(); + assert!(cx.machine_values.get() == text.contains("${env:")); + out + } + + #[test] + fn an_unclosed_variable_is_kept_as_written() { + assert_eq!(cx_expand(&[], "abc${foo"), "abc${foo"); + assert_eq!(cx_expand(&[], "${workspaceFolder}/x ${y"), "/p/x ${y"); + } + + #[test] + fn windows_variable_names_ignore_case() { + let env = BTreeMap::from([("Path".to_owned(), r"C:\bin".to_owned())]); + assert_eq!(env_var(&env, "PATH", true).as_deref(), Some(r"C:\bin")); + assert_eq!(env_var(&env, "PATH", false), None); + assert_eq!(env_var(&env, "Path", false).as_deref(), Some(r"C:\bin")); + assert_eq!(cx_expand(&[("A", "1")], "${env:A}-${env:B}"), "1-"); + } +} diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index f35036b1b..1ca1a0a18 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -6,6 +6,7 @@ pub mod dotnet; pub mod envfile; pub mod go; pub mod gradle_jvm; +pub mod import; pub mod java_cmdline; pub mod junit; mod process; @@ -256,6 +257,17 @@ pub fn resolved_env(root: &Path, config: &RunConfig) -> IdeResult IdeResult { + // `${env:NAME}` reads what a run inherits, the login shell's variables included. + import::import(root, source, existing, &process::inherited_env()) +} + /// Path of the user run configuration file for `root`. pub fn user_configs_path(root: &Path) -> PathBuf { config::config_path(root) diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index 19ceefaa7..ce900ecaa 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -784,14 +784,19 @@ fn launch_env(root: &Path, config: &RunConfig) -> IdeResult { Ok(LaunchEnv { clear: !extras.include_system_env, vars }) } +/// What a run inherits with "Include system environment variables" on: this process's +/// variables and the login shell's ones it lacks. +pub(crate) fn inherited_env() -> BTreeMap { + let mut vars = crate::system_env(); + vars.extend( + ide_core::process::shell_env().filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?))), + ); + vars +} + pub(crate) fn resolved_env(root: &Path, config: &RunConfig) -> IdeResult> { let env = launch_env(root, config)?; - let mut vars = if env.clear { BTreeMap::new() } else { crate::system_env() }; - if !env.clear { - vars.extend( - ide_core::process::shell_env().filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?))), - ); - } + let mut vars = if env.clear { BTreeMap::new() } else { inherited_env() }; vars.extend(env.vars); Ok(vars) } diff --git a/crates/ide-run/tests/import_vscode.rs b/crates/ide-run/tests/import_vscode.rs new file mode 100644 index 000000000..947a61a98 --- /dev/null +++ b/crates/ide-run/tests/import_vscode.rs @@ -0,0 +1,521 @@ +//! `.vscode/launch.json` import, from launch files written the way the VS Code docs and +//! framework guides (Django, Flask, FastAPI, Next.js) suggest. + +use ide_run::RunConfig; +use ide_run::import::{RunImport, RunImportSource, SkipReason, import}; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +const VENV_PYTHON: &str = if cfg!(windows) { ".venv/Scripts/python.exe" } else { ".venv/bin/python" }; + +fn env() -> BTreeMap { + BTreeMap::from([("HOME".into(), "/home/me".into()), ("API_TOKEN".into(), "s3cret".into())]) +} + +fn project(launch: &str) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".vscode")).unwrap(); + fs::write(dir.path().join(".vscode/launch.json"), launch).unwrap(); + dir +} + +fn import_from(root: &Path, existing: &[RunConfig]) -> RunImport { + import(root, RunImportSource::Vscode, existing, &env()).unwrap() +} + +fn run(launch: &str) -> (tempfile::TempDir, RunImport) { + let dir = project(launch); + let result = import_from(dir.path(), &[]); + (dir, result) +} + +fn get<'a>(r: &'a RunImport, name: &str) -> &'a RunConfig { + &r.imported.iter().find(|c| c.config.name == name).unwrap_or_else(|| panic!("{name} not imported: {r:#?}")).config +} + +fn ignored<'a>(r: &'a RunImport, name: &str) -> &'a [String] { + &r.imported.iter().find(|c| c.config.name == name).unwrap().ignored +} + +fn reason<'a>(r: &'a RunImport, name: &str) -> &'a SkipReason { + &r.skipped.iter().find(|s| s.name == name).unwrap_or_else(|| panic!("{name} not skipped: {r:#?}")).reason +} + +fn s(v: &[&str]) -> Vec { + v.iter().map(|a| (*a).to_owned()).collect() +} + +fn cwd(c: &RunConfig) -> Option { + c.cwd.as_ref().map(|p| p.as_ref().to_path_buf()) +} + +const DJANGO: &str = r#"{ + // Use IntelliSense to learn about possible attributes. + "version": "0.2.0", + "configurations": [ + { + "name": "Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": ["runserver", "0.0.0.0:8000", "--noreload"], + "django": true, + "justMyCode": false, + "env": { "DJANGO_SETTINGS_MODULE": "mysite.settings.dev", "DEBUG": true, "PATH": "${env:PATH}" }, + }, + /* the test runner */ + { + "name": "Django tests", + "type": "python", + "request": "launch", + "python": "${workspaceFolder}/.venv/bin/python", + "program": "${workspaceFolder}/manage.py", + "args": ["test", "polls", "--keepdb"], + "django": true, + }, + ], +}"#; + +#[test] +fn django_runserver_and_tests_with_comments_and_trailing_commas() { + let dir = project(DJANGO); + let venv = dir.path().join(VENV_PYTHON); + fs::create_dir_all(venv.parent().unwrap()).unwrap(); + fs::write(&venv, "").unwrap(); + let r = import_from(dir.path(), &[]); + assert!(r.skipped.is_empty(), "{r:#?}"); + assert_eq!(r.file, ".vscode/launch.json"); + + let server = get(&r, "Django"); + assert_eq!(server.id, "user:django"); + assert_eq!(server.kind, "python"); + // No `python`: the project's virtualenv, which the debugger launches directly. + assert_eq!(server.command, VENV_PYTHON); + assert_eq!(server.args, s(&["manage.py", "runserver", "0.0.0.0:8000", "--noreload"])); + assert_eq!(server.debug_adapter.as_deref(), Some("debugpy")); + assert_eq!(cwd(server), None); + // A variable set to its own value is inherited anyway; scalars become strings. + assert_eq!( + server.env, + BTreeMap::from([ + ("DEBUG".into(), "true".into()), + ("DJANGO_SETTINGS_MODULE".into(), "mysite.settings.dev".into()) + ]) + ); + assert!(!server.extras.local && !server.detected && !server.is_test); + assert_eq!(ignored(&r, "Django"), ["justMyCode"]); + + let tests = get(&r, "Django tests"); + assert_eq!(tests.kind, "django-test"); + assert!(tests.is_test); + assert_eq!(tests.command, ".venv/bin/python"); + assert_eq!(tests.args, s(&["manage.py", "test", "polls", "--keepdb"])); + assert!(ignored(&r, "Django tests").is_empty()); +} + +#[test] +fn flask_and_fastapi_run_as_modules() { + let (_dir, r) = run(r#"{ + "configurations": [ + { + "name": "Python Debugger: Flask", + "type": "debugpy", + "request": "launch", + "module": "flask", + "python": "/usr/bin/python3.12", + "env": { "FLASK_APP": "app.py", "FLASK_DEBUG": "1" }, + "args": ["run", "--no-debugger", "--no-reload"], + "jinja": true, + "autoStartBrowser": false + }, + { + "name": "FastAPI", + "type": "debugpy", + "request": "launch", + "module": "uvicorn", + "python": "${workspaceFolder}/.venv/bin/python", + "args": "app.main:app --reload --port 8001", + "envFile": "${workspaceFolder}/.env.local" + } + ] + }"#); + let flask = get(&r, "Python Debugger: Flask"); + assert_eq!(flask.kind, "python"); + assert_eq!(flask.command, "/usr/bin/python3.12"); + assert_eq!(flask.args, s(&["-m", "flask", "run", "--no-debugger", "--no-reload"])); + assert_eq!(flask.env["FLASK_APP"], "app.py"); + assert_eq!(ignored(&r, "Python Debugger: Flask"), ["autoStartBrowser", "jinja"]); + + let api = get(&r, "FastAPI"); + // A string `args` is split like a command line. + assert_eq!(api.args, s(&["-m", "uvicorn", "app.main:app", "--reload", "--port", "8001"])); + assert_eq!(api.extras.env_files, s(&[".env.local"])); +} + +#[test] +fn pytest_module_in_a_subdirectory_is_a_test_configuration() { + let (dir, r) = run(r#"{ + "configurations": [{ + "name": "Backend tests", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "cwd": "${workspaceFolder}/backend", + "python": "${command:python.interpreterPath}", + "pythonArgs": ["-X", "dev"], + "args": ["-x", "${workspaceFolder}/backend/tests", "--ds=${workspaceFolderBasename}.settings"] + }] + }"#); + let c = get(&r, "Backend tests"); + assert_eq!(c.kind, "pytest"); + assert!(c.is_test); + assert_eq!(cwd(c), Some(PathBuf::from("backend"))); + // The interpreter VS Code has selected is the project's here: no virtualenv, so python3. + assert_eq!(c.command, if cfg!(windows) { "python" } else { "python3" }); + let base = dir.path().file_name().unwrap().to_string_lossy().into_owned(); + // Paths inside the project are written relative to the working directory. + assert_eq!(c.args, s(&["-X", "dev", "-m", "pytest", "-x", "tests", &format!("--ds={base}.settings")])); +} + +#[test] +fn interpreter_from_the_root_virtualenv_is_relative_to_the_working_directory() { + let dir = project( + r#"{ "configurations": [{ "name": "Worker", "type": "debugpy", "request": "launch", + "program": "${workspaceFolder}/api/worker.py", "cwd": "${workspaceFolder}/api" }] }"#, + ); + let venv = dir.path().join(VENV_PYTHON); + fs::create_dir_all(venv.parent().unwrap()).unwrap(); + fs::write(&venv, "").unwrap(); + let r = import_from(dir.path(), &[]); + let c = get(&r, "Worker"); + assert_eq!(c.command, format!("../{VENV_PYTHON}")); + assert_eq!(c.args, s(&["worker.py"])); + assert_eq!(cwd(c), Some(PathBuf::from("api"))); +} + +const NEXT: &str = r#"{ + "version": "0.2.0", + "configurations": [ + { + "name": "Next.js: debug server-side", + "type": "node-terminal", + "request": "launch", + "command": "npm run dev" + }, + { + "name": "Next.js: debug client-side", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000" + }, + { + "name": "Next.js: debug full stack", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/node_modules/next/dist/bin/next", + "runtimeArgs": ["--inspect"], + "skipFiles": ["/**"], + "serverReadyAction": { "action": "debugWithEdge", "killOnServerStop": true, "pattern": "- Local:.+(https?://.+)" } + } + ] +}"#; + +#[test] +fn nextjs_recipes() { + let (_dir, r) = run(NEXT); + let server = get(&r, "Next.js: debug server-side"); + assert_eq!((server.kind.as_str(), server.command.as_str()), ("npm", "npm")); + assert_eq!(server.args, s(&["run", "dev"])); + assert_eq!(server.debug_adapter.as_deref(), Some("js-debug")); + + assert_eq!(reason(&r, "Next.js: debug client-side"), &SkipReason::UnsupportedType { type_: "chrome".into() }); + + let full = get(&r, "Next.js: debug full stack"); + assert_eq!((full.kind.as_str(), full.command.as_str()), ("node", "node")); + assert_eq!(full.args, s(&["--inspect", "node_modules/next/dist/bin/next"])); + assert_eq!(ignored(&r, "Next.js: debug full stack"), ["serverReadyAction", "skipFiles"]); +} + +#[test] +fn node_runtimes_npm_scripts_and_terminal_commands() { + let (_dir, r) = run(r#"{ + "configurations": [ + { "name": "Start", "type": "node", "request": "launch", "runtimeExecutable": "npm", + "runtimeArgs": ["run-script", "start"], "cwd": "${workspaceFolder}/web", "env": { "PORT": 3001 } }, + { "name": "Vite", "type": "pwa-node", "request": "launch", "cwd": "${workspaceFolder}/web", + "runtimeExecutable": "${workspaceFolder}/web/node_modules/.bin/vite", "runtimeArgs": ["--port", "5173"] }, + { "name": "Script", "type": "node", "request": "launch", "program": "${workspaceFolder}/scripts/seed.js", + "args": ["--count", 10], "envFile": "${workspaceFolder}/.env", "preLaunchTask": "tsc: build" }, + { "name": "Storybook", "type": "node-terminal", "request": "launch", "command": "pnpm exec storybook dev -p 6006" }, + { "name": "Migrate and serve", "type": "node-terminal", "request": "launch", "command": "npm run migrate && npm start" }, + { "name": "Nothing", "type": "node", "request": "launch" } + ] + }"#); + let start = get(&r, "Start"); + assert_eq!((start.kind.as_str(), start.command.as_str()), ("npm", "npm")); + assert_eq!(start.args, s(&["run", "start"])); + assert_eq!(cwd(start), Some(PathBuf::from("web"))); + assert_eq!(start.env["PORT"], "3001"); + + let vite = get(&r, "Vite"); + assert_eq!((vite.kind.as_str(), vite.command.as_str()), ("node", "node_modules/.bin/vite")); + assert_eq!(vite.args, s(&["--port", "5173"])); + + let script = get(&r, "Script"); + assert_eq!(script.args, s(&["scripts/seed.js", "--count", "10"])); + assert_eq!(script.extras.env_files, s(&[".env"])); + assert_eq!(ignored(&r, "Script"), ["preLaunchTask"]); + + let storybook = get(&r, "Storybook"); + assert_eq!((storybook.kind.as_str(), storybook.command.as_str()), ("shell", "pnpm")); + assert_eq!(storybook.args, s(&["exec", "storybook", "dev", "-p", "6006"])); + assert_eq!(storybook.debug_adapter.as_deref(), Some("js-debug")); + + // A shell line runs through the shell, without the debugger. + let line = get(&r, "Migrate and serve"); + assert_eq!((line.kind.as_str(), line.command.as_str()), ("shell", "npm run migrate && npm start")); + assert!(line.args.is_empty() && line.debug_adapter.is_none()); + + assert_eq!(reason(&r, "Nothing"), &SkipReason::NothingToRun); +} + +#[test] +fn compounds_resolve_members_by_name() { + let launch = r#"{ + "configurations": [ + { "name": "Django", "type": "debugpy", "request": "launch", "program": "${workspaceFolder}/manage.py", "args": ["runserver"] }, + { "name": "Next.js: debug server-side", "type": "node-terminal", "request": "launch", "command": "npm run dev" }, + ], + "compounds": [ + { "name": "Full stack", "configurations": ["Django", "Next.js: debug server-side"], "stopAll": true }, + { "name": "With the worker", "configurations": ["Django", { "name": "Celery", "folder": "api" }] }, + ], + }"#; + let (dir, r) = run(launch); + let full = get(&r, "Full stack"); + assert_eq!(full.kind, "compound"); + assert_eq!(full.extras.compound, s(&["user:django", "user:next-js-debug-server-side"])); + assert_eq!(ignored(&r, "Full stack"), ["stopAll"]); + assert_eq!(reason(&r, "With the worker"), &SkipReason::MissingMember { member: "Celery".into() }); + + // Importing again adds nothing; a compound still finds members that already exist. + let mut existing: Vec = r.imported.iter().map(|c| c.config.clone()).collect(); + existing.retain(|c| c.name != "Full stack"); + let again = import_from(dir.path(), &existing); + assert_eq!(again.imported.iter().map(|c| c.config.name.as_str()).collect::>(), ["Full stack"]); + assert_eq!(again.imported[0].config.extras.compound, s(&["user:django", "user:next-js-debug-server-side"])); + assert_eq!(reason(&again, "Django"), &SkipReason::NameTaken); + assert_eq!(reason(&again, "Next.js: debug server-side"), &SkipReason::NameTaken); +} + +#[test] +fn existing_names_are_skipped_and_ids_stay_unique() { + let dir = project( + r#"{ "configurations": [ + { "name": "manage.py runserver", "type": "debugpy", "request": "launch", "program": "manage.py", "args": ["runserver"] }, + { "name": "Server", "type": "debugpy", "request": "launch", "program": "server.py" }, + { "name": "Server", "type": "debugpy", "request": "launch", "program": "other.py" } + ] }"#, + ); + let detected = RunConfig { + id: "django:runserver".into(), + name: "manage.py runserver".into(), + kind: "python".into(), + command: "python3".into(), + args: s(&["manage.py", "runserver"]), + cwd: None, + env: BTreeMap::new(), + detected: true, + is_test: false, + debug_adapter: Some("debugpy".into()), + allow_parallel: false, + extras: Default::default(), + }; + let user = RunConfig { id: "user:server".into(), name: "Old server".into(), detected: false, ..detected.clone() }; + let r = import_from(dir.path(), &[detected, user]); + assert_eq!(reason(&r, "manage.py runserver"), &SkipReason::NameTaken); + // Two entries with the same name: the first wins. + assert_eq!(r.imported.len(), 1); + assert_eq!(r.imported[0].config.id, "user:server-2"); + assert_eq!(r.imported[0].config.args, s(&["server.py"])); + assert_eq!(r.skipped.len(), 2); +} + +#[test] +fn variables_known_only_at_launch_skip_the_entry() { + let (_dir, r) = run(r#"{ + "configurations": [ + { "name": "Current file", "type": "debugpy", "request": "launch", "program": "${file}", "console": "integratedTerminal" }, + { "name": "Port", "type": "debugpy", "request": "launch", "module": "app", "args": ["--port", "${input:port}"] }, + { "name": "Home", "type": "debugpy", "request": "launch", "program": "${userHome}/bin/tool.py" }, + { "name": "Token", "type": "debugpy", "request": "launch", "program": "app.py", + "env": { "TOKEN": "${env:API_TOKEN}", "MISSING": "${env:NOT_SET}x" } } + ], + "inputs": [{ "id": "port", "type": "promptString", "description": "Port" }] + }"#); + assert_eq!(reason(&r, "Current file"), &SkipReason::Variable { variable: "${file}".into() }); + assert_eq!(reason(&r, "Port"), &SkipReason::Variable { variable: "${input:port}".into() }); + assert_eq!(get(&r, "Home").args, s(&["/home/me/bin/tool.py"])); + // The home directory is this machine's too: the configuration stays out of run.toml. + assert!(get(&r, "Home").extras.local); + let token = get(&r, "Token"); + assert_eq!(token.env["TOKEN"], "s3cret"); + assert_eq!(token.env["MISSING"], "x"); + // Values from this machine's environment go to the workspace-local file, not run.toml. + assert!(token.extras.local); +} + +#[test] +fn attach_requests_become_remote_debug_configurations() { + let (_dir, r) = run(r#"{ + "configurations": [ + { "name": "Attach (container)", "type": "debugpy", "request": "attach", + "connect": { "host": "localhost", "port": 5678 }, "justMyCode": true, + "pathMappings": [{ "localRoot": "${workspaceFolder}", "remoteRoot": "/app" }] }, + { "name": "Attach to Node", "type": "node", "request": "attach", "port": 9230, + "localRoot": "${workspaceFolder}/web", "remoteRoot": "/usr/src/app", "restart": true }, + { "name": "Attach to PID", "type": "debugpy", "request": "attach", "processId": "${command:pickProcess}" }, + { "name": "Odd", "type": "node", "request": "test" } + ] + }"#); + let py = get(&r, "Attach (container)"); + assert_eq!((py.kind.as_str(), py.command.as_str()), ("python-remote", "")); + assert_eq!(py.debug_adapter.as_deref(), Some("debugpy")); + assert_eq!( + py.extras.params, + BTreeMap::from([ + ("host".into(), "localhost".into()), + ("port".into(), "5678".into()), + ("remoteRoot".into(), "/app".into()) + ]) + ); + assert_eq!(ignored(&r, "Attach (container)"), ["justMyCode"]); + + let node = get(&r, "Attach to Node"); + assert_eq!(node.kind, "node-remote"); + assert_eq!(node.extras.params["port"], "9230"); + assert_eq!(node.extras.params["localRoot"], "web"); + assert_eq!(node.extras.params["remoteRoot"], "/usr/src/app"); + assert_eq!(ignored(&r, "Attach to Node"), ["restart"]); + + assert_eq!(reason(&r, "Attach to PID"), &SkipReason::UnsupportedAttach); + assert_eq!(reason(&r, "Odd"), &SkipReason::UnsupportedRequest { request: "test".into() }); +} + +#[test] +fn the_block_for_this_os_overrides_the_entry() { + let os = if cfg!(windows) { + "windows" + } else if cfg!(target_os = "macos") { + "osx" + } else { + "linux" + }; + let (_dir, r) = run(&format!( + r#"{{ "configurations": [{{ "name": "App", "type": "debugpy", "request": "launch", "program": "app.py", + "python": "python3", "{os}": {{ "python": "/opt/py/bin/python" }} }}] }}"# + )); + assert_eq!(get(&r, "App").command, "/opt/py/bin/python"); + assert!(ignored(&r, "App").is_empty()); +} + +#[test] +fn unnamed_entries_are_invalid_and_bad_json_is_an_error() { + let (_dir, r) = run(r#"{ "configurations": [{ "type": "debugpy", "request": "launch", "program": "a.py" }, 3] }"#); + assert!(r.imported.is_empty()); + assert!(r.skipped.iter().all(|s| s.reason == SkipReason::Invalid && s.name.is_empty())); + + let dir = project("{ \"configurations\": [ "); + assert!(import(dir.path(), RunImportSource::Vscode, &[], &env()).is_err()); + let empty = tempfile::tempdir().unwrap(); + assert!(import(empty.path(), RunImportSource::Vscode, &[], &env()).is_err()); + assert!(ide_run::import::available(empty.path()).is_empty()); + assert_eq!(ide_run::import::available(dir.path()), [RunImportSource::Vscode]); +} + +#[test] +fn imported_configurations_round_trip_through_run_toml() { + let (dir, r) = run(DJANGO); + let configs: Vec = r.imported.iter().map(|c| c.config.clone()).collect(); + ide_run::save_user_configs(dir.path(), &configs).unwrap(); + assert_eq!(ide_run::load_user_configs(dir.path()).unwrap(), configs); +} + +/// Run configurations pass `env` values to the process as written (only `.env` files expand +/// `${VAR}`), so `${env:…}` is resolved when importing; the value stays out of the shared run.toml. +#[test] +fn resolved_environment_values_are_saved_in_the_local_file_only() { + let (dir, r) = run(r#"{ "configurations": [ + { "name": "Token", "type": "debugpy", "request": "launch", "program": "app.py", "env": { "TOKEN": "${env:API_TOKEN}" } }, + { "name": "Plain", "type": "debugpy", "request": "launch", "program": "app.py" } + ] }"#); + let configs: Vec = r.imported.iter().map(|c| c.config.clone()).collect(); + ide_run::save_user_configs(dir.path(), &configs).unwrap(); + let shared = fs::read_to_string(ide_run::user_configs_path(dir.path())).unwrap(); + assert!(shared.contains("Plain") && !shared.contains("Token") && !shared.contains("s3cret")); + let local = fs::read_to_string(dir.path().join(".openidea/run.local.toml")).unwrap(); + assert!(local.contains("TOKEN = \"s3cret\""), "{local}"); + let loaded = ide_run::load_user_configs(dir.path()).unwrap(); + assert!(loaded.iter().any(|c| c.name == "Token" && c.extras.local && c.env["TOKEN"] == "s3cret")); +} + +#[test] +fn environment_variables_come_from_what_runs_inherit_login_shell_included() { + // An app started from the Dock lacks what ~/.zshrc exports; runs get it from the login shell. + ide_core::process::set_shell_env(vec![("OPENIDEA_IMPORT_SHELL_ONLY".into(), "from-shell".into())]); + let dir = project( + r#"{ "configurations": [{ "name": "App", "type": "debugpy", "request": "launch", "program": "app.py", + "env": { "DATABASE_URL": "${env:OPENIDEA_IMPORT_SHELL_ONLY}" } }] }"#, + ); + let r = ide_run::import_configs(dir.path(), RunImportSource::Vscode, &[]).unwrap(); + assert_eq!(get(&r, "App").env["DATABASE_URL"], "from-shell"); +} + +#[test] +fn selected_interpreter_keeps_its_options_and_subdirectory_manage_py_stays_python() { + let (_dir, r) = run(r#"{ "configurations": [ + { "name": "Dev mode", "type": "debugpy", "request": "launch", "program": "app.py", + "python": ["${command:python.interpreterPath}", "-X", "dev"] }, + { "name": "Default", "type": "debugpy", "request": "launch", "program": "app.py", + "python": "${config:python.defaultInterpreterPath}" }, + { "name": "Spaces", "type": "debugpy", "request": "launch", "program": "app.py", + "python": "/opt/My Python/bin/python" }, + { "name": "Dot tests", "type": "debugpy", "request": "launch", "program": "./manage.py", "args": ["test", "polls"] }, + { "name": "Nested tests", "type": "debugpy", "request": "launch", + "program": "${workspaceFolder}/backend/manage.py", "args": ["test"] } + ] }"#); + let py = if cfg!(windows) { "python" } else { "python3" }; + assert_eq!(get(&r, "Dev mode").command, py); + assert_eq!(get(&r, "Dev mode").args, s(&["-X", "dev", "app.py"])); + assert_eq!(get(&r, "Default").command, py); + assert_eq!(get(&r, "Spaces").command, "/opt/My Python/bin/python"); + let dot = get(&r, "Dot tests"); + assert_eq!((dot.kind.as_str(), dot.args.clone()), ("django-test", s(&["manage.py", "test", "polls"]))); + // The Django tests form runs `manage.py` from the working directory: a nested one stays a Python run. + let nested = get(&r, "Nested tests"); + assert_eq!((nested.kind.as_str(), nested.args.clone()), ("python", s(&["backend/manage.py", "test"]))); +} + +#[test] +fn existing_names_match_without_surrounding_spaces() { + let dir = project( + r#"{ "configurations": [{ "name": " Django ", "type": "debugpy", "request": "launch", "program": "manage.py" }] }"#, + ); + let existing = RunConfig { + id: "user:django".into(), + name: "Django ".into(), + kind: "python".into(), + command: "python3".into(), + args: s(&["manage.py"]), + cwd: None, + env: BTreeMap::new(), + detected: false, + is_test: false, + debug_adapter: None, + allow_parallel: false, + extras: Default::default(), + }; + let r = import_from(dir.path(), &[existing]); + assert_eq!(reason(&r, "Django"), &SkipReason::NameTaken); +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f0066101e..07101df69 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -317,6 +317,8 @@ pub fn builder() -> tauri_specta::Builder { run::run_save_configs, run::run_templates, run::run_save_templates, + run::run_import_sources, + run::run_import_configs, run::run_system_env, run::run_start, run::run_stop, diff --git a/src-tauri/src/commands/run.rs b/src-tauri/src/commands/run.rs index 3bdbcecd2..b5e0a9025 100644 --- a/src-tauri/src/commands/run.rs +++ b/src-tauri/src/commands/run.rs @@ -121,6 +121,28 @@ pub fn run_save_templates(window: Window, state: State<'_, AppState>, templates: ide_host::ops::run::run_save_templates(&state.host, window.label(), templates) } +/// Project files run configurations can be imported from (`.vscode/launch.json`). +#[tauri::command] +#[specta::specta] +pub fn run_import_sources( + window: Window, + state: State<'_, AppState>, +) -> IdeResult> { + ide_host::ops::run::run_import_sources(&state.host, window.label()) +} + +/// Configurations read from `source`, not saved; names already in `existing` are skipped. +#[tauri::command] +#[specta::specta] +pub fn run_import_configs( + window: Window, + state: State<'_, AppState>, + source: ide_run::import::RunImportSource, + existing: Vec, +) -> IdeResult { + ide_host::ops::run::run_import_configs(&state.host, window.label(), source, existing) +} + /// The environment runs inherit when "Include system environment variables" is on. #[tauri::command] #[specta::specta] diff --git a/src/bindings.ts b/src/bindings.ts index 02fc09f73..879951d43 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -585,6 +585,10 @@ export const commands = { /** Configuration templates: per-type defaults applied to new configurations. */ runTemplates: () => typedError(__TAURI_INVOKE("run_templates")), runSaveTemplates: (templates: RunConfig_Deserialize[]) => typedError(__TAURI_INVOKE("run_save_templates", { templates })), + /** Project files run configurations can be imported from (`.vscode/launch.json`). */ + runImportSources: () => typedError(__TAURI_INVOKE("run_import_sources")), + /** Configurations read from `source`, not saved; names already in `existing` are skipped. */ + runImportConfigs: (source: RunImportSource, existing: RunConfig_Deserialize[]) => typedError(__TAURI_INVOKE("run_import_configs", { source, existing })), /** The environment runs inherit when "Include system environment variables" is on. */ runSystemEnv: () => __TAURI_INVOKE<{ [key in string]: string }>("run_system_env"), runStart: (config: RunConfig_Deserialize, onOutput: Channel) => typedError(__TAURI_INVOKE("run_start", { config, onOutput })), @@ -3702,6 +3706,20 @@ export type IdentifierOccurrence = { export type IgnoreDestination = "gitignore" | "localExclude"; +export type ImportedRunConfig = ImportedRunConfig_Serialize | ImportedRunConfig_Deserialize; + +export type ImportedRunConfig_Deserialize = { + config: RunConfig_Deserialize, + /** Keys of the source entry with no equivalent here (`preLaunchTask`, `justMyCode`…). */ + ignored: string[], +}; + +export type ImportedRunConfig_Serialize = { + config: RunConfig_Serialize, + /** Keys of the source entry with no equivalent here (`preLaunchTask`, `justMyCode`…). */ + ignored: string[], +}; + export type IndentConfig = IndentConfig_Serialize | IndentConfig_Deserialize; export type IndentConfig_Deserialize = { @@ -5490,6 +5508,32 @@ export type RunExtras_Serialize = { /** A process started from a run configuration. */ export type RunId = number; +/** What an import found: configurations to add and entries left out, with the reason. */ +export type RunImport = RunImport_Serialize | RunImport_Deserialize; + +/** A project file run configurations can be imported from. */ +export type RunImportSource = +/** VS Code's `.vscode/launch.json`. */ +"vscode"; + +/** What an import found: configurations to add and entries left out, with the reason. */ +export type RunImport_Deserialize = { + source: RunImportSource, + /** The file read, relative to the project root. */ + file: string, + imported: ImportedRunConfig_Deserialize[], + skipped: SkippedRunConfig[], +}; + +/** What an import found: configurations to add and entries left out, with the reason. */ +export type RunImport_Serialize = { + source: RunImportSource, + /** The file read, relative to the project root. */ + file: string, + imported: ImportedRunConfig_Serialize[], + skipped: SkippedRunConfig[], +}; + export type RunInfo = { id: RunId, configId: string, @@ -5841,6 +5885,29 @@ export type SignatureHelp = { activeParameter: number, }; +export type SkipReason = +/** A configuration with this name already exists. */ +{ kind: "nameTaken" } | +/** The debugger type (`type`) has no equivalent here, e.g. `cppdbg`. */ +{ kind: "unsupportedType"; type: string } | +/** A `request` other than `launch` and `attach`. */ +{ kind: "unsupportedRequest"; request: string } | +/** An attach other than to a host and port (to a process id, or listening for it). */ +{ kind: "unsupportedAttach" } | +/** Uses a variable only known when VS Code starts it (`${file}`, `${input:…}`, `${command:…}`). */ +{ kind: "variable"; variable: string } | +/** No program, module or command. */ +{ kind: "nothingToRun" } | +/** A compound member that is neither imported nor an existing configuration. */ +{ kind: "missingMember"; member: string } | +/** Not an object with a name. */ +{ kind: "invalid" }; + +export type SkippedRunConfig = { + name: string, + reason: SkipReason, +}; + /** Editor › General › Smart Keys. */ export type SmartKeysSettings = { /** Home moves caret to first non-whitespace character. */ diff --git a/src/features/run/EditConfigsDialog.tsx b/src/features/run/EditConfigsDialog.tsx index 50767c5e3..56223ab15 100644 --- a/src/features/run/EditConfigsDialog.tsx +++ b/src/features/run/EditConfigsDialog.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from "react"; import clsx from "clsx"; import { executeAction } from "@/platform/actions"; -import { call, commands, isTauri, type RunConfig } from "@/platform/ipc"; +import { call, commands, isTauri, type RunConfig, type RunImportSource } from "@/platform/ipc"; import { letterOf } from "@/platform/keymap"; import { useWorkflowSettings } from "@/platform/workflowSettings"; import { Button, Dialog, Icon, IconButton, Input, T } from "@/ui"; @@ -26,6 +26,7 @@ import { validateConfig, type ConfigErrors, } from "./configs"; +import { importConfigs, importLabel } from "./importConfigs"; import { targetName } from "./remote"; import { saveUserConfigs, selectConfig, useRun } from "./store"; import { blankTemplate, COMPOUND, missingParams, newConfigOfType, templateId, typeFor } from "./types"; @@ -149,6 +150,44 @@ function EditConfigsDialog() { }; }, []); + // "Import from .vscode/launch.json": offered only when the project has the file. + const [importSources, setImportSources] = useState([]); + const [importing, setImporting] = useState(false); + const runImport = async (source: RunImportSource, from: readonly RunConfig[]) => { + setImporting(true); + try { + const added = await importConfigs(source, from); + if (!added?.length) return; + setDraft((d) => [...d, ...added]); + setMode("configs"); + setSelection({ type: "config", id: added[0]!.id }); + setDirty(true); + } finally { + setImporting(false); + } + }; + // "Import Run Configurations from…" (Find Action): on opening, or while the dialog is open. + const requested = useRun((s) => s.editImport); + useEffect(() => { + // Consumed from the store, not the render's value: StrictMode runs this twice on mount. + const pending = useRun.getState().editImport; + if (!pending) return; + useRun.setState({ editImport: null }); + void runImport(pending, draft); + // eslint-disable-next-line react-hooks/exhaustive-deps -- runs per request, with the draft of that moment + }, [requested]); + useEffect(() => { + if (!isTauri()) return; + let live = true; + call(commands.runImportSources()) + .then((s) => live && setImportSources(s)) + .catch(() => {}); + return () => { + live = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- once, when the dialog opens + }, []); + const close = () => useRun.setState({ editorOpen: false, editTarget: null }); const current = selection?.type === "config" ? (draft.find((c) => c.id === selection.id) ?? null) : null; const templateFor = (kind: string): RunConfig | null => { @@ -534,6 +573,17 @@ function EditConfigsDialog() { > Edit configuration templates… + {importSources.map((source) => ( + + ))} ) : ( <> diff --git a/src/features/run/Run.module.css b/src/features/run/Run.module.css index 64b8da79a..2a3ec6690 100644 --- a/src/features/run/Run.module.css +++ b/src/features/run/Run.module.css @@ -504,6 +504,10 @@ align-self: flex-start; margin: 8px 12px; } +/* Below "Edit configuration templates…". */ +.editImportLink { + margin-top: 0; +} .footerRun { display: inline-flex; align-items: center; diff --git a/src/features/run/importAction.test.tsx b/src/features/run/importAction.test.tsx new file mode 100644 index 000000000..645e73a20 --- /dev/null +++ b/src/features/run/importAction.test.tsx @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { StrictMode } from "react"; +import { act, cleanup, render, screen } from "@testing-library/react"; + +const ipc = vi.hoisted(() => ({ runImportSources: vi.fn(), runImportConfigs: vi.fn() })); +vi.mock("@/platform/ipc", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, commands: { ...original.commands, ...ipc } }; +}); + +import { executeAction } from "@/platform/actions"; +import { setContext } from "@/platform/context"; +import type { RunConfig } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { provideNotificationService, type NotificationService } from "@/workbench/contributions"; +import { EditConfigsOverlay } from "./EditConfigsDialog"; +import { activate } from "./index"; +import { resetRuns, useRun } from "./store"; + +const django: RunConfig = { + id: "user:django", + name: "Django", + kind: "python", + command: ".venv/bin/python", + args: ["manage.py", "runserver"], + cwd: null, + env: {}, + detected: false, + isTest: false, + debugAdapter: "debugpy", +}; +const ok = (data: T) => ({ status: "ok", data }); +const result = () => + ok({ source: "vscode", file: ".vscode/launch.json", imported: [{ config: django, ignored: [] }], skipped: [] }); +let notes: Array[0]>; + +describe("Find Action › Import Run Configurations from .vscode/launch.json", () => { + let dispose: () => void; + beforeEach(() => { + resetRuns(); + notes = []; + provideNotificationService({ notify: (n) => void notes.push(n) }); + useProject.setState({ project: { root: "/test-import" } as never }); + ipc.runImportConfigs.mockReset(); + ipc.runImportConfigs.mockImplementation(async () => result()); + useRun.setState({ configs: [], loaded: true }); + setContext("projectOpen", true); + dispose = activate(); + }); + afterEach(() => { + cleanup(); + dispose(); + useProject.setState({ project: null }); + }); + + it("opens the dialog and imports once", async () => { + ipc.runImportSources.mockImplementation(async () => ok(["vscode"])); + // As in the app (main.tsx): StrictMode runs the dialog's effects twice on mount. + render( + + + , + ); + await act(() => executeAction("run.importVscodeLaunch")); + expect(useRun.getState().editorOpen).toBe(true); + expect(await screen.findByRole("treeitem", { name: /^Django/ })).toBeInTheDocument(); + expect(ipc.runImportConfigs).toHaveBeenCalledTimes(1); + expect(useRun.getState().editImport).toBeNull(); + // No duplicate ids in the draft. + expect(screen.getAllByRole("treeitem", { name: /^Django/ })).toHaveLength(1); + }); + + it("says so without opening the dialog when the project has no launch.json", async () => { + ipc.runImportSources.mockImplementation(async () => ok([])); + await act(() => executeAction("run.importVscodeLaunch")); + expect(useRun.getState().editorOpen).toBe(false); + expect(ipc.runImportConfigs).not.toHaveBeenCalled(); + expect(notes.at(-1)).toMatchObject({ level: "info", title: "This project has no .vscode/launch.json" }); + }); +}); diff --git a/src/features/run/importConfigs.test.tsx b/src/features/run/importConfigs.test.tsx new file mode 100644 index 000000000..a2fb79c0a --- /dev/null +++ b/src/features/run/importConfigs.test.tsx @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; + +const ipc = vi.hoisted(() => ({ + runSaveConfigs: vi.fn(), + runConfigs: vi.fn(), + runTemplates: vi.fn(async () => ({ status: "ok", data: [] })), + runImportSources: vi.fn(), + runImportConfigs: vi.fn(), +})); +vi.mock("@/platform/ipc", async (importOriginal) => ({ + ...(await importOriginal()), + isTauri: () => true, + commands: ipc, +})); + +import { _resetI18nForTests, setLocale } from "@/platform/i18n"; +import type { RunConfig, RunImport } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { provideNotificationService, type NotificationService } from "@/workbench/contributions"; +import { EditConfigsOverlay } from "./EditConfigsDialog"; +import { importSummary } from "./importConfigs"; +import { resetRuns, useRun } from "./store"; +import { registerBuiltinTypes } from "./types"; + +const cfg = (over: Partial): RunConfig => ({ + id: "user:django", + name: "Django", + kind: "python", + command: ".venv/bin/python", + args: ["manage.py", "runserver"], + cwd: null, + env: {}, + detected: false, + isTest: false, + debugAdapter: "debugpy", + ...over, +}); + +const result = (over: Partial = {}): RunImport => ({ + source: "vscode", + file: ".vscode/launch.json", + imported: [{ config: cfg({}), ignored: ["justMyCode"] }], + skipped: [ + { name: "Current file", reason: { kind: "variable", variable: "${file}" } }, + { name: "Chrome", reason: { kind: "unsupportedType", type: "chrome" } }, + { name: "", reason: { kind: "invalid" } }, + ], + ...over, +}); + +const ok = (data: T) => ({ status: "ok", data }); +type Note = Parameters[0]; +let notes: Note[]; + +describe("import summary", () => { + afterEach(() => _resetI18nForTests()); + + it("counts the imported configurations and says why the others were left out", () => { + const s = importSummary(result()); + expect(s.title).toBe("Imported 1 run configuration from .vscode/launch.json"); + expect(s.level).toBe("info"); + expect(s.sticky).toBe(true); + render(<>{s.body}); + expect( + screen.getByText("Skipped 'Current file': Uses ${file}, which is known only when VS Code starts it"), + ).toBeInTheDocument(); + expect(screen.getByText("Skipped 'Chrome': The chrome debugger has no equivalent here")).toBeInTheDocument(); + expect(screen.getByText("Skipped 'Unnamed': Not a configuration with a name")).toBeInTheDocument(); + expect(screen.getByText("Left out of 'Django': justMyCode")).toBeInTheDocument(); + expect(screen.getByText("Click OK or Apply to save them.")).toBeInTheDocument(); + cleanup(); + }); + + it("warns when nothing came in, and translates counts and reasons", async () => { + const none = importSummary(result({ imported: [], skipped: [{ name: "Django", reason: { kind: "nameTaken" } }] })); + expect(none).toMatchObject({ level: "warning", title: "No run configurations imported from .vscode/launch.json" }); + expect(importSummary(result({ imported: [{ config: cfg({}), ignored: [] }], skipped: [] }))).toMatchObject({ + sticky: false, + }); + const two = result({ + imported: [ + { config: cfg({}), ignored: [] }, + { config: cfg({ id: "b", name: "B" }), ignored: [] }, + ], + }); + expect(importSummary(two).title).toBe("Imported 2 run configurations from .vscode/launch.json"); + await setLocale("zh"); + expect(importSummary(two).title).toBe("已从 .vscode/launch.json 导入 2 个运行配置"); + render(<>{importSummary(result({ imported: [] })).body}); + expect(screen.getByText("已跳过 'Chrome':此处没有与 chrome 调试器对应的配置类型")).toBeInTheDocument(); + cleanup(); + }); +}); + +describe("Run/Debug Configurations › Import from .vscode/launch.json", () => { + let dispose: () => void; + const detected = cfg({ id: "django:runserver", name: "manage.py runserver", command: "python3", detected: true }); + + beforeEach(() => { + resetRuns(); + localStorage.clear(); + dispose = registerBuiltinTypes(); + notes = []; + provideNotificationService({ notify: (n) => void notes.push(n) }); + useProject.setState({ project: { root: "/test-import" } as never }); + ipc.runImportSources.mockImplementation(async () => ok(["vscode"])); + ipc.runImportConfigs.mockReset(); + ipc.runImportConfigs.mockImplementation(async () => ok(result())); + ipc.runSaveConfigs.mockReset(); + ipc.runSaveConfigs.mockImplementation(async () => ok(null)); + useRun.setState({ configs: [detected], loaded: true, selectedId: detected.id, editorOpen: true }); + }); + afterEach(() => { + cleanup(); + dispose(); + useProject.setState({ project: null }); + }); + + it("adds the imported configurations to the dialog, saved on Apply", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: "Import from .vscode/launch.json" })); + // The backend matches names against everything in the dialog, detected configurations included. + await waitFor(() => expect(ipc.runImportConfigs).toHaveBeenCalledWith("vscode", [detected])); + expect(await screen.findByRole("treeitem", { name: /^Django/ })).toHaveAttribute("aria-selected", "true"); + expect(notes.at(-1)).toMatchObject({ title: "Imported 1 run configuration from .vscode/launch.json" }); + expect(ipc.runSaveConfigs).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + await waitFor(() => expect(ipc.runSaveConfigs).toHaveBeenCalledWith([cfg({})], [])); + }); + + it("offers no import without the file, and reports a file it can't read", async () => { + ipc.runImportSources.mockImplementation(async () => ok([])); + render(); + await waitFor(() => expect(ipc.runImportSources).toHaveBeenCalled()); + expect(screen.queryByRole("button", { name: "Import from .vscode/launch.json" })).not.toBeInTheDocument(); + cleanup(); + + ipc.runImportSources.mockImplementation(async () => ok(["vscode"])); + ipc.runImportConfigs.mockImplementation(async () => ({ + status: "error", + error: { kind: "invalid", message: ".vscode/launch.json: EOF while parsing", data: null }, + })); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Import from .vscode/launch.json" })); + await waitFor(() => + expect(notes.at(-1)).toMatchObject({ + level: "error", + title: "Cannot import run configurations", + body: ".vscode/launch.json: EOF while parsing", + }), + ); + expect(screen.queryByRole("treeitem", { name: /^Django/ })).not.toBeInTheDocument(); + }); + + it("imports again when asked while the dialog is open", async () => { + render(); + await screen.findByRole("button", { name: "Import from .vscode/launch.json" }); + act(() => useRun.setState({ editImport: "vscode" })); + expect(await screen.findByRole("treeitem", { name: /^Django/ })).toBeInTheDocument(); + expect(useRun.getState().editImport).toBeNull(); + expect(ipc.runImportConfigs).toHaveBeenCalledTimes(1); + }); + + it("keeps a configuration with this machine's values local, and says so", async () => { + const local = cfg({ id: "user:token", name: "Token", local: true }); + ipc.runImportConfigs.mockImplementation(async () => + ok(result({ imported: [{ config: local, ignored: [] }], skipped: [] })), + ); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Import from .vscode/launch.json" })); + await screen.findByRole("treeitem", { name: /^Token/ }); + render(<>{notes.at(-1)!.body}); + expect( + screen.getByText("'Token' is saved in run.local.toml because it uses values from this machine"), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + await waitFor(() => expect(ipc.runSaveConfigs).toHaveBeenCalledWith([local], [])); + }); +}); diff --git a/src/features/run/importConfigs.tsx b/src/features/run/importConfigs.tsx new file mode 100644 index 000000000..2ac30f42c --- /dev/null +++ b/src/features/run/importConfigs.tsx @@ -0,0 +1,94 @@ +/** + * "Import from .vscode/launch.json" in Run/Debug Configurations: the backend maps the file's + * entries (`ide-run` `import`), the configurations join the dialog's draft and are saved with + * it on OK / Apply. A notification says how many came in and why the others were left out. + */ +import type { ReactNode } from "react"; +import { tx } from "@/platform/i18n"; +import { call, commands, type RunConfig, type RunImport, type RunImportSource, type SkipReason } from "@/platform/ipc"; +import { notifications } from "@/workbench/contributions"; + +/** The dialog's link for `source`. */ +export function importLabel(source: RunImportSource): string { + switch (source) { + case "vscode": + return tx("Import from .vscode/launch.json"); + } +} + +/** Why an entry was not imported. */ +export function skipReasonText(reason: SkipReason): string { + switch (reason.kind) { + case "nameTaken": + return tx("A configuration with this name already exists"); + case "unsupportedType": + return tx`The ${reason.type} debugger has no equivalent here`; + case "unsupportedRequest": + return tx`Request '${reason.request}' isn't supported`; + case "unsupportedAttach": + return tx("Only attaching to a host and port is supported"); + case "variable": + return tx`Uses ${reason.variable}, which is known only when VS Code starts it`; + case "nothingToRun": + return tx("No program, module or command to run"); + case "missingMember": + return tx`Its configuration '${reason.member}' isn't imported`; + case "invalid": + return tx("Not a configuration with a name"); + } +} + +/** The notification telling what an import brought in and what it left out. */ +export function importSummary(result: RunImport): { + level: "info" | "warning"; + title: string; + body?: ReactNode; + sticky: boolean; +} { + const n = result.imported.length; + const file = result.file; + const title = + n === 0 + ? tx`No run configurations imported from ${file}` + : n === 1 + ? tx`Imported ${n} run configuration from ${file}` + : tx`Imported ${n} run configurations from ${file}`; + const notes = [ + ...result.skipped.map((s) => tx`Skipped '${s.name || tx("Unnamed")}': ${skipReasonText(s.reason)}`), + ...result.imported + .filter((c) => c.ignored.length) + .map((c) => tx`Left out of '${c.config.name}': ${c.ignored.join(", ")}`), + ...result.imported + .filter((c) => c.config.local) + .map((c) => tx`'${c.config.name}' is saved in run.local.toml because it uses values from this machine`), + ]; + const lines = n ? [...notes, tx("Click OK or Apply to save them.")] : notes; + return { + level: n ? "info" : "warning", + title, + ...(lines.length ? { body: lines.map((line, i) =>
{line}
) } : {}), + // The reasons stay readable until dismissed. + sticky: notes.length > 0, + }; +} + +/** + * Configurations from `source` that `draft` (the dialog's configurations, detected ones + * included) doesn't have by name; null when the file can't be read. + */ +export async function importConfigs(source: RunImportSource, draft: readonly RunConfig[]): Promise { + try { + const result = await call(commands.runImportConfigs(source, [...draft])); + notifications().notify({ group: "run", key: "run-import", ...importSummary(result) }); + return result.imported.map((c) => c.config); + } catch (e) { + notifications().notify({ + group: "run", + level: "error", + title: "Cannot import run configurations", + body: e instanceof Error ? e.message : String(e), + key: "run-import", + }); + return null; + } +} diff --git a/src/features/run/index.ts b/src/features/run/index.ts index cd2fcd0d3..71de5b36e 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -5,7 +5,7 @@ import { registerEditorContribution } from "@/editor/api"; import { executeAction, getAction, registerActions } from "@/platform/actions"; import { getContext, setContext } from "@/platform/context"; -import { onIdeEvent, type RunConfig } from "@/platform/ipc"; +import { call, commands, onIdeEvent, type RunConfig, type RunImportSource } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { onWorkflowSettingsChange } from "@/platform/workflowSettings"; import { showContextMenu, lazyComponent, type MenuItem } from "@/ui"; @@ -407,6 +407,27 @@ export function activate(): () => void { }); }, }, + { + id: "run.importVscodeLaunch", + title: "Import Run Configurations from .vscode/launch.json", + category: "Run", + when: "projectOpen", + keywords: ["launch.json", "vscode", "visual studio code", "debug configuration"], + run: async () => { + const sources = await call(commands.runImportSources()).catch((): RunImportSource[] => []); + if (!sources.includes("vscode")) { + notifications().notify({ + group: "run", + level: "info", + title: "This project has no .vscode/launch.json", + key: "run-import", + }); + return; + } + await ensureConfigs(); + useRun.setState({ editorOpen: true, chooserOpen: false, editTarget: null, editImport: "vscode" }); + }, + }, { id: "run.debugConfig", title: "Debug Configuration", diff --git a/src/features/run/removal.test.tsx b/src/features/run/removal.test.tsx index 442ad07f3..29a35c2e5 100644 --- a/src/features/run/removal.test.tsx +++ b/src/features/run/removal.test.tsx @@ -5,6 +5,7 @@ const ipc = vi.hoisted(() => ({ runSaveConfigs: vi.fn(), runConfigs: vi.fn(), runTemplates: vi.fn(async () => ({ status: "ok", data: [] })), + runImportSources: vi.fn(async () => ({ status: "ok", data: [] })), })); vi.mock("@/platform/ipc", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/src/features/run/store.ts b/src/features/run/store.ts index dfa664bcc..b01914472 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -15,6 +15,7 @@ import { type OutputChunk, type RunConfig, type RunId, + type RunImportSource, type TestCaseReport, } from "@/platform/ipc"; import { @@ -125,6 +126,8 @@ interface RunState { editTarget: string | null; /** The edit dialog opens with unsaved changes (a temporary configuration to make permanent on OK). */ editDirty: boolean; + /** The edit dialog opens by importing from this project file ("Import Run Configurations from…"). */ + editImport: RunImportSource | null; } export const useRun = create(() => ({ @@ -141,6 +144,7 @@ export const useRun = create(() => ({ editorOpen: false, editTarget: null, editDirty: false, + editImport: null, })); const runtimes = new Map(); @@ -1093,6 +1097,7 @@ export function resetRuns() { editorOpen: false, editTarget: null, editDirty: false, + editImport: null, }); } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 21be8bac7..4f4a9081c 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -2946,6 +2946,25 @@ "No run configurations added.": "未添加运行配置。", "Add new…": "新增…", "Edit configuration templates…": "编辑配置模板…", + "Import from .vscode/launch.json": "从 .vscode/launch.json 导入", + "Import Run Configurations from .vscode/launch.json": "从 .vscode/launch.json 导入运行配置", + "Cannot import run configurations": "无法导入运行配置", + "No run configurations imported from {file}": "未从 {file} 导入任何运行配置", + "Imported {n} run configuration from {file}": "已从 {file} 导入 {n} 个运行配置", + "Imported {n} run configurations from {file}": "已从 {file} 导入 {n} 个运行配置", + "Skipped '{name}': {reason}": "已跳过 '{name}':{reason}", + "Left out of '{name}': {keys}": "'{name}' 中未导入的设置:{keys}", + "Click OK or Apply to save them.": "点击“确定”或“应用”以保存。", + "A configuration with this name already exists": "已存在同名配置", + "The {type} debugger has no equivalent here": "此处没有与 {type} 调试器对应的配置类型", + "Request '{request}' isn't supported": "不支持请求 '{request}'", + "Only attaching to a host and port is supported": "仅支持附加到主机和端口", + "Uses {variable}, which is known only when VS Code starts it": "使用了 {variable},其值只有在 VS Code 启动时才能确定", + "No program, module or command to run": "没有要运行的程序、模块或命令", + "Its configuration '{member}' isn't imported": "其中的配置 '{member}' 未导入", + "Not a configuration with a name": "不是带名称的配置", + "'{name}' is saved in run.local.toml because it uses values from this machine": "'{name}' 使用了本机的值,因此保存在 run.local.toml 中", + "This project has no .vscode/launch.json": "此项目没有 .vscode/launch.json", "Templates": "模板", "Configuration templates": "配置模板", "Back to configurations": "返回配置",