From 42e7f777f14fb800e14871c3ed98352a13b0de0f Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:30:56 +0800 Subject: [PATCH] feat(run): add .NET project run and debug configurations --- crates/ide-host/src/ops/run/commands.rs | 20 ++ crates/ide-run/src/detect.rs | 2 + crates/ide-run/src/detect/dotnet.rs | 121 +++++++++ crates/ide-run/src/dotnet.rs | 257 ++++++++++++++++++ crates/ide-run/src/lib.rs | 1 + crates/ide-run/tests/dotnet.rs | 138 ++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/run.rs | 11 + src/bindings.ts | 1 + .../debugger/__tests__/launch.test.ts | 20 ++ .../debugger/__tests__/missingAdapter.test.ts | 3 + src/features/debugger/actions.ts | 4 +- src/features/debugger/launch.ts | 9 + src/features/debugger/missingAdapter.ts | 1 + src/features/run/ConfigForm.tsx | 6 +- src/features/run/beforeLaunch.ts | 8 +- src/features/run/configs.ts | 5 +- src/features/run/dotnetTypes.test.tsx | 123 +++++++++ src/features/run/dotnetTypes.ts | 134 +++++++++ src/features/run/types.ts | 8 + src/platform/locales/text/zh.json | 22 +- src/platform/locales/textCatalog.test.ts | 19 +- 22 files changed, 908 insertions(+), 6 deletions(-) create mode 100644 crates/ide-run/src/detect/dotnet.rs create mode 100644 crates/ide-run/src/dotnet.rs create mode 100644 crates/ide-run/tests/dotnet.rs create mode 100644 src/features/run/dotnetTypes.test.tsx create mode 100644 src/features/run/dotnetTypes.ts diff --git a/crates/ide-host/src/ops/run/commands.rs b/crates/ide-host/src/ops/run/commands.rs index 7a916a519..c1b2acc73 100644 --- a/crates/ide-host/src/ops/run/commands.rs +++ b/crates/ide-host/src/ops/run/commands.rs @@ -5,6 +5,23 @@ use crate::rpc::Dispatcher; use ide_core::{IdeError, IdeResult}; use ide_run::RunConfig; +pub async fn run_dotnet_resolve(host: &Host, window: &str, config: RunConfig, debug: bool) -> IdeResult { + host.require_trust(window)?; + if !matches!(config.kind.as_str(), "dotnet" | "dotnet-test") + || config.extras.target.as_ref().is_some_and(|s| !s.is_empty()) + { + return Err(IdeError::invalid(".NET Project requires the local machine")); + } + let project = host.project(window)?; + let mut config = host.blocking(move || ide_run::dotnet::resolve(&project.root, config)).await?; + if debug { + let root = host.project(window)?.root.clone(); + let target = ide_run::dotnet::target_path(&root, &config).await?; + config.extras.params.insert("targetPath".into(), target); + } + Ok(config) +} + fn application_cache(project: &crate::project::Project, id: &str) -> std::path::PathBuf { use std::hash::{Hash, Hasher}; let mut hash = std::collections::hash_map::DefaultHasher::new(); @@ -206,6 +223,9 @@ pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mod /// Commands of this module the remote server answers. pub(crate) fn register(d: &mut Dispatcher) { + d.add("run_dotnet_resolve", |c| async move { + run_dotnet_resolve(&c.host, &c.window, c.arg("config")?, c.arg("debug")?).await + }); d.add("run_application_build", |c| async move { run_application_build(&c.host, &c.window, c.arg("config")?, c.arg("module")?).await }); diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 9f6e2c5fe..ca6d0cc4a 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -4,6 +4,7 @@ //! Detection never fails: unreadable or malformed files simply contribute nothing. pub(crate) mod cmake; +mod dotnet; use crate::RunConfig; use serde_json::Value as Json; @@ -25,6 +26,7 @@ pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec) { + let mut dirs = vec![root.to_path_buf()]; + let mut projects = BTreeSet::new(); + while let Some(dir) = dirs.pop() { + let Ok(entries) = fs::read_dir(&dir) else { continue }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + if entry.file_type().is_ok_and(|t| t.is_dir()) { + if !name.starts_with('.') + && !matches!( + name.as_str(), + "bin" | "obj" | "node_modules" | "target" | "build" | "dist" | "vendor" | "packages" + ) + { + dirs.push(path); + } + } else if entry.file_type().is_ok_and(|t| t.is_file()) { + match path.extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase().as_str() { + "csproj" => { + projects.insert(path); + } + "sln" | "slnx" => { + if let Ok(src) = fs::read_to_string(&path) { + for project in solution_projects( + &src, + path.extension().is_some_and(|e| e.eq_ignore_ascii_case("slnx")), + ) { + projects.insert(dir.join(project)); + } + } + } + _ => {} + } + } + } + } + let root = dunce::canonicalize(root).unwrap_or_else(|_| root.into()); + let projects: BTreeSet<_> = projects.into_iter().filter_map(|p| dunce::canonicalize(p).ok()).collect(); + for path in projects { + let Some(project) = fs::read_to_string(&path).ok().and_then(|s| parse_project(&s)) else { continue }; + let rel = path.strip_prefix(&root).unwrap_or(&path).to_string_lossy().replace('\\', "/"); + let name = path.file_stem().unwrap_or_default().to_string_lossy(); + let profiles = profiles(&path); + let mut params = BTreeMap::from([ + ("project".into(), rel.clone()), + ("framework".into(), project.frameworks.first().cloned().unwrap_or_default()), + ("frameworks".into(), project.frameworks.join(";")), + ("profiles".into(), serde_json::to_string(&profiles).unwrap_or_default()), + ("launchProfile".into(), String::new()), + ("programArgs".into(), String::new()), + ]); + if project.executable && !project.test { + out.push(detected(&rel, &name, "", params.clone(), false)); + for profile in profiles.keys() { + params.insert("launchProfile".into(), profile.clone()); + out.push(detected(&rel, &name, profile, params.clone(), false)); + } + } + if project.test { + params.insert("launchProfile".into(), String::new()); + out.push(detected(&rel, &name, "", params, true)); + } + } +} + +fn detected(project: &str, name: &str, profile: &str, params: BTreeMap, test: bool) -> RunConfig { + let op = if test { "test" } else { "run" }; + let mut config = super::config( + format!("dotnet:{project}:{op}:{profile}"), + if test { + format!("dotnet test ({name})") + } else if profile.is_empty() { + format!("{name} (.NET Project)") + } else { + format!("{name}: {profile}") + }, + if test { "dotnet-test" } else { "dotnet" }, + "dotnet", + &[], + ); + config.args = command_args(¶ms, test); + config.is_test = test; + config.debug_adapter = (!test).then(|| "netcoredbg".into()); + config.extras.params = params; + if !test { + config.extras.before_launch.push(crate::BeforeLaunchTask { kind: "dotnetBuild".into(), target: String::new() }); + } + config +} + +fn command_args(params: &BTreeMap, test: bool) -> Vec { + let mut args = if test { + vec!["test".into(), params.get("project").cloned().unwrap_or_default()] + } else { + vec!["run".into(), "--project".into(), params.get("project").cloned().unwrap_or_default()] + }; + if let Some(framework) = params.get("framework").filter(|s| !s.is_empty()) { + args.extend(["--framework".into(), framework.clone()]); + } + if !test { + if let Some(profile) = params.get("launchProfile").filter(|s| !s.is_empty()) { + args.extend(["--launch-profile".into(), profile.clone()]); + } else { + args.push("--no-launch-profile".into()); + } + } + args +} diff --git a/crates/ide-run/src/dotnet.rs b/crates/ide-run/src/dotnet.rs new file mode 100644 index 000000000..1ba990f60 --- /dev/null +++ b/crates/ide-run/src/dotnet.rs @@ -0,0 +1,257 @@ +//! Lightweight .NET project discovery; MSBuild remains the authority for build outputs. + +use crate::RunConfig; +use ide_core::{IdeError, IdeResult}; +use quick_xml::{Reader, XmlVersion, events::Event}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Default, PartialEq)] +pub struct Project { + pub executable: bool, + pub frameworks: Vec, + pub test: bool, +} + +#[derive(Default)] +struct Element { + name: String, + attrs: BTreeMap, + text: String, + conditional: bool, +} + +fn elements(xml: &str) -> Option> { + let mut reader = Reader::from_str(xml); + let mut out: Vec = Vec::new(); + let mut stack: Vec = Vec::new(); + loop { + let event = reader.read_event().ok()?; + let empty = matches!(&event, Event::Empty(_)); + match event { + Event::Start(tag) | Event::Empty(tag) => { + let attrs: BTreeMap<_, _> = tag + .attributes() + .map(|a| { + let a = a.ok()?; + Some(( + a.key.as_ref().to_owned(), + a.normalized_value(XmlVersion::Implicit1_0).ok()?.into_owned(), + )) + }) + .collect::>()?; + let conditional = attrs.contains_key("Condition") || stack.last().is_some_and(|i| out[*i].conditional); + out.push(Element { + name: tag.local_name().as_ref().to_owned(), + attrs, + conditional, + ..Default::default() + }); + // Empty elements have no corresponding End event. + if !empty { + stack.push(out.len() - 1); + } + } + Event::End(_) => { + stack.pop()?; + } + Event::Text(text) => { + if let Some(i) = stack.last() { + out[*i].text.push_str(&text.xml_content(XmlVersion::Implicit1_0)); + } + } + Event::CData(text) => { + if let Some(i) = stack.last() { + out[*i].text.push_str(text.as_ref()); + } + } + Event::GeneralRef(reference) => { + let name = reference.as_ref(); + let escaped = format!("&{name};"); + if let Some(i) = stack.last() { + out[*i].text.push_str(&quick_xml::escape::unescape(&escaped).ok()?); + } + } + Event::Eof => return stack.is_empty().then_some(out), + _ => {} + } + } +} + +pub fn parse_project(xml: &str) -> Option { + let tags = elements(xml)?; + if tags.first()?.name != "Project" { + return None; + } + let property = |name: &str| tags.iter().rev().find(|t| t.name == name && !t.conditional).map(|t| t.text.trim()); + let web = tags.iter().any(|t| { + t.attrs + .get("Sdk") + .or_else(|| (t.name == "Sdk").then(|| t.attrs.get("Name")).flatten()) + .is_some_and(|s| s.split(';').any(|sdk| sdk.split('/').next() == Some("Microsoft.NET.Sdk.Web"))) + }); + let executable = match property("OutputType") { + Some(output) => output.eq_ignore_ascii_case("exe") || output.eq_ignore_ascii_case("winexe"), + None => web, + }; + let frameworks = property("TargetFramework") + .filter(|v| !v.is_empty()) + .or_else(|| property("TargetFrameworks")) + .unwrap_or_default() + .split(';') + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_owned) + .collect(); + let test = tags.iter().any(|t| { + t.name == "PackageReference" + && t.attrs + .get("Include") + .or_else(|| t.attrs.get("Update")) + .is_some_and(|v| v.eq_ignore_ascii_case("Microsoft.NET.Test.Sdk")) + }); + Some(Project { executable, frameworks, test }) +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", default)] +pub struct Profile { + pub command_name: String, + pub command_line_args: String, + pub working_directory: String, + pub application_url: String, + pub environment_variables: BTreeMap, +} + +pub fn parse_profiles(json: &str) -> BTreeMap { + let Ok(json) = serde_json::from_str::(&ide_core::jsonc::strip(json)) else { + return BTreeMap::new(); + }; + json["profiles"] + .as_object() + .into_iter() + .flatten() + .filter_map(|(name, value)| { + let profile: Profile = serde_json::from_value(value.clone()).ok()?; + (profile.command_name == "Project").then(|| (name.clone(), profile)) + }) + .collect() +} + +pub fn solution_projects(src: &str, xml: bool) -> Vec { + let paths: Vec = if xml { + elements(src) + .unwrap_or_default() + .into_iter() + .filter(|e| e.name == "Project") + .filter_map(|mut e| e.attrs.remove("Path")) + .collect() + } else { + src.lines() + .filter_map(|line| { + let line = line.trim(); + if !line.starts_with("Project(") { + return None; + } + Some(line.split_once('=')?.1.split('"').nth(3)?.to_owned()) + }) + .collect() + }; + paths + .into_iter() + .filter(|p| p.to_ascii_lowercase().ends_with(".csproj")) + .map(|p| PathBuf::from(p.replace('\\', "/"))) + .collect() +} + +pub(crate) fn profiles(project: &Path) -> BTreeMap { + project + .parent() + .and_then(|p| fs::read_to_string(p.join("Properties/launchSettings.json")).ok()) + .map(|s| parse_profiles(&s)) + .unwrap_or_default() +} + +/// Resolves the selected profile afresh so changing project/profile never reuses stale values. +pub fn resolve(root: &Path, mut config: RunConfig) -> IdeResult { + let params = &config.extras.params; + let project = params + .get("project") + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| IdeError::invalid("Select a .NET project"))?; + let project = dunce::canonicalize(root.join(project))?; + let dir = project.parent().ok_or_else(|| IdeError::invalid("Invalid .NET project path"))?; + let profile_name = params.get("launchProfile").cloned().unwrap_or_default(); + let profile = if profile_name.is_empty() { + Profile::default() + } else { + profiles(&project).remove(&profile_name).ok_or_else(|| { + IdeError::invalid(format!(".NET launch profile '{profile_name}' is missing or is not a Project profile")) + })? + }; + let mut env = profile.environment_variables; + if !profile.application_url.is_empty() { + env.insert("ASPNETCORE_URLS".into(), profile.application_url); + } + if !profile_name.is_empty() { + env.insert("DOTNET_LAUNCH_PROFILE".into(), profile_name); + } + env.extend(config.env); + config.env = env; + config.cwd = Some( + config + .cwd + .map(|p| root.join(&*p)) + .unwrap_or_else(|| { + if profile.working_directory.is_empty() { dir.into() } else { dir.join(&profile.working_directory) } + }) + .into(), + ); + config.extras.params.insert("project".into(), project.to_string_lossy().into_owned()); + config.extras.params.insert("profileArgs".into(), profile.command_line_args); + Ok(config) +} + +/// Queries the evaluated output path, including AssemblyName, OutputPath and imported properties. +pub async fn target_path(root: &Path, config: &RunConfig) -> IdeResult { + let params = &config.extras.params; + let project = params.get("project").ok_or_else(|| IdeError::invalid("Select a .NET project"))?; + let mut args = vec![ + "msbuild".to_owned(), + root.join(project).to_string_lossy().into_owned(), + "-nologo".into(), + "-getProperty:TargetPath".into(), + "-p:Configuration=Debug".into(), + ]; + if let Some(framework) = params.get("framework").filter(|s| !s.is_empty()) { + args.push(format!("-p:TargetFramework={framework}")); + } + let mut command = std::process::Command::new(&config.command); + ide_core::process::host_env(&mut command); + command.args(args).current_dir(root); + if !config.extras.include_system_env { + command.env_clear(); + } else { + command.envs(ide_core::process::shell_env()); + } + command.envs(crate::resolved_env(root, config)?); + let mut command = tokio::process::Command::from(command); + command.kill_on_drop(true); + let output = tokio::time::timeout(std::time::Duration::from_secs(30), command.output()) + .await + .map_err(|_| IdeError::unavailable(".NET output resolution timed out"))??; + if !output.status.success() { + return Err(IdeError::invalid(format!( + ".NET output resolution failed: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ))); + } + let target = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + if target.is_empty() || !Path::new(&target).is_file() { + return Err(IdeError::invalid(".NET build output is missing; run dotnet build before debugging")); + } + Ok(target) +} diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index db94a4586..22c1e8cd0 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -2,6 +2,7 @@ mod config; mod detect; +pub mod dotnet; pub mod envfile; pub mod go; pub mod gradle_jvm; diff --git a/crates/ide-run/tests/dotnet.rs b/crates/ide-run/tests/dotnet.rs new file mode 100644 index 000000000..61befdd55 --- /dev/null +++ b/crates/ide-run/tests/dotnet.rs @@ -0,0 +1,138 @@ +use ide_run::{ + detect, + dotnet::{parse_profiles, parse_project, resolve, solution_projects}, +}; +use std::fs; +use std::path::PathBuf; + +const CONSOLE: &str = r#"Exenet10.0; net9.0"#; +const PROFILES: &str = r#"{ + // Only Project profiles can be run with dotnet. + "profiles": { + "http": {"commandName":"Project", "applicationUrl":"http://localhost:5165", "commandLineArgs":"--message \"hello world\"", "workingDirectory":"work", "environmentVariables":{"DEMO":"profile"}}, + "https": {"commandName":"Project", "environmentVariables":{"DEMO":"https"}}, + "IIS Express": {"commandName":"IISExpress"}, + "external": {"commandName":"Executable"}, + "broken": {"commandName":"Project", "environmentVariables":7} + } +}"#; + +fn write(root: &std::path::Path, file: &str, src: &str) { + let path = root.join(file); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, src).unwrap(); +} + +#[test] +fn project_properties_and_sdk_defaults() { + let p = parse_project(CONSOLE).unwrap(); + assert!(p.executable && !p.test); + assert_eq!(p.frameworks, ["net10.0", "net9.0"]); + for (sdk, output, executable) in [ + ("Microsoft.NET.Sdk", "", false), + ("Microsoft.NET.Sdk", "WinExe", true), + ("Microsoft.NET.Sdk.Web", "", true), + ("Microsoft.NET.Sdk.Web", "Library", false), + ] { + let xml = format!( + r#"{output}net10.0"# + ); + let p = parse_project(&xml).unwrap(); + assert_eq!(p.executable, executable, "{xml}"); + assert_eq!(p.frameworks, ["net10.0"]); + } + let p = parse_project(r#"net10.0Library"#).unwrap(); + assert!(p.executable && p.test); + assert!(parse_project("").is_none()); + assert!(parse_project("garbage").is_none()); + assert!( + parse_project( + r#""# + ) + .unwrap() + .test + ); + assert!( + !parse_project(r#""#) + .unwrap() + .test + ); +} + +#[test] +fn profiles_filter_and_preserve_launch_properties() { + let p = parse_profiles(PROFILES); + assert_eq!(p.keys().map(String::as_str).collect::>(), ["http", "https"]); + assert_eq!(p["http"].application_url, "http://localhost:5165"); + assert_eq!(p["http"].environment_variables["DEMO"], "profile"); + assert_eq!(p["http"].command_line_args, "--message \"hello world\""); + assert_eq!(p["http"].working_directory, "work"); + assert!(parse_profiles("invalid").is_empty()); +} + +#[test] +fn solution_paths_support_windows_separators_folders_and_entities() { + let sln = "Project(\"{guid}\") = \"App\", \"src\\App\\App.csproj\", \"{guid}\"\nEndProject\nProject(\"{folder}\") = \"Folder\", \"Folder\", \"{guid}\"\nProject(\"{guid}\") = \"External\", \"..\\other\\Other.csproj\", \"{guid}\""; + assert_eq!( + solution_projects(sln, false), + [PathBuf::from("src/App/App.csproj"), PathBuf::from("../other/Other.csproj")] + ); + let slnx = r#""#; + assert_eq!(solution_projects(slnx, true), [PathBuf::from("src/A&B/App.csproj")]); + assert!(solution_projects(""#, + ); + write( + root, + "src/Test/Test.csproj", + r#"net10.0"#, + ); + write(root, "bin/Ignored.csproj", CONSOLE); + write(root, "obj/Ignored.csproj", CONSOLE); + write(root, "node_modules/Ignored.csproj", CONSOLE); + write(root, "Lib.csproj", ""); + let configs = detect(root); + assert_eq!(configs.len(), 4); + let app = configs.iter().find(|c| c.extras.params.get("launchProfile").is_some_and(|p| p == "http")).unwrap(); + assert_eq!( + app.args, + ["run", "--project", "src/App/App.csproj", "--framework", "net10.0", "--launch-profile", "http"] + ); + assert_eq!(app.debug_adapter.as_deref(), Some("netcoredbg")); + assert_eq!(app.extras.before_launch[0].kind, "dotnetBuild"); + let test = configs.iter().find(|c| c.is_test).unwrap(); + assert_eq!(test.kind, "dotnet-test"); + assert_eq!(test.args, ["test", "src/Test/Test.csproj", "--framework", "net10.0"]); + assert!(test.debug_adapter.is_none()); + let mut edited = app.clone(); + edited.env.insert("DEMO".into(), "override".into()); + let resolved = resolve(root, edited.clone()).unwrap(); + assert_eq!(resolved.env["DEMO"], "override"); + assert_eq!(resolved.env["ASPNETCORE_URLS"], "http://localhost:5165"); + assert!(resolved.cwd.unwrap().ends_with("src/App/work")); + edited.extras.params.insert("launchProfile".into(), "https".into()); + let resolved = resolve(root, edited).unwrap(); + assert!(!resolved.env.contains_key("ASPNETCORE_URLS")); + assert!(resolved.cwd.unwrap().ends_with("src/App")); +} + +#[test] +fn solution_references_can_reach_projects_outside_the_open_directory() { + let temp = tempfile::tempdir().unwrap(); + write(temp.path(), "external/App.csproj", CONSOLE); + write(temp.path(), "open/demo.sln", "Project(\"{guid}\") = \"App\", \"..\\external\\App.csproj\", \"{guid}\""); + let configs = detect(&temp.path().join("open")); + assert_eq!(configs.len(), 1); + assert!(configs[0].args[2].ends_with("external/App.csproj")); +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c71ce8405..45a35f666 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -303,6 +303,7 @@ pub fn builder() -> tauri_specta::Builder { term::term_default_shell, term::term_available_shells, run::run_configs, + run::run_dotnet_resolve, run::run_save_configs, run::run_templates, run::run_save_templates, diff --git a/src-tauri/src/commands/run.rs b/src-tauri/src/commands/run.rs index 998cb5b9d..3bdbcecd2 100644 --- a/src-tauri/src/commands/run.rs +++ b/src-tauri/src/commands/run.rs @@ -8,6 +8,17 @@ use ide_run::{OutputChunk, RunConfig, RunInfo}; use tauri::ipc::Channel; use tauri::{State, Window}; +#[tauri::command] +#[specta::specta] +pub async fn run_dotnet_resolve( + window: Window, + state: State<'_, AppState>, + config: RunConfig, + debug: bool, +) -> IdeResult { + ide_host::ops::run::run_dotnet_resolve(&state.host, window.label(), config, debug).await +} + #[tauri::command] #[specta::specta] pub async fn run_application_build( diff --git a/src/bindings.ts b/src/bindings.ts index b65240187..cad8c86e3 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -564,6 +564,7 @@ export const commands = { termAvailableShells: () => __TAURI_INVOKE("term_available_shells"), /** Detected configs followed by user-defined ones (user ids win on conflict). */ runConfigs: () => typedError(__TAURI_INVOKE("run_configs")), + runDotnetResolve: (config: RunConfig_Deserialize, debug: boolean) => typedError(__TAURI_INVOKE("run_dotnet_resolve", { config, debug })), runSaveConfigs: (configs: RunConfig_Deserialize[], removedDetectedIds: string[] | null) => typedError(__TAURI_INVOKE("run_save_configs", { configs, removedDetectedIds })), /** Configuration templates: per-type defaults applied to new configurations. */ runTemplates: () => typedError(__TAURI_INVOKE("run_templates")), diff --git a/src/features/debugger/__tests__/launch.test.ts b/src/features/debugger/__tests__/launch.test.ts index 19fac437f..d52afa226 100644 --- a/src/features/debugger/__tests__/launch.test.ts +++ b/src/features/debugger/__tests__/launch.test.ts @@ -321,3 +321,23 @@ describe("Go build configuration fields", () => { }); }); }); + +it("netcoredbg launches the evaluated DLL with resolved profile arguments, env and cwd", () => { + const c = rc({ + kind: "dotnet", + command: "dotnet", + debugAdapter: "netcoredbg", + cwd: "/repo/work", + env: { ASPNETCORE_URLS: "http://localhost:5165", DEMO: "override" }, + params: { targetPath: "/repo/custom/Renamed.dll", programArgs: "--name 'hello world'", profileArgs: "--profile" }, + }); + expect(launchConfigFor(c, "/repo")).toMatchObject({ + adapter: "netcoredbg", + program: "/repo/custom/Renamed.dll", + args: ["--name", "hello world"], + cwd: "/repo/work", + env: c.env, + }); + expect(launchConfigFor({ ...c, params: { ...c.params, programArgs: "" } }, "/repo")?.args).toEqual(["--profile"]); + expect(launchConfigFor({ ...c, params: {} }, "/repo")).toBeNull(); +}); diff --git a/src/features/debugger/__tests__/missingAdapter.test.ts b/src/features/debugger/__tests__/missingAdapter.test.ts index 656ba9563..feb17d383 100644 --- a/src/features/debugger/__tests__/missingAdapter.test.ts +++ b/src/features/debugger/__tests__/missingAdapter.test.ts @@ -21,6 +21,9 @@ describe("missing debug adapters", () => { expect( missingAdapter("kotlin-debug-adapter", unavailable("debug adapter `kotlin-debug-adapter` not found (x)"))?.tool, ).toBe("kotlin-debug-adapter"); + expect(missingAdapter("netcoredbg", unavailable("debug adapter `netcoredbg` not found (x)"))?.tool).toBe( + "netcoredbg", + ); expect(missingAdapter("delve", unavailable("debug adapter `delve` not found (x)"))?.tool).toBe("delve"); }); diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 235b86beb..98cbde4bd 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -291,7 +291,9 @@ async function startConfig(arg: unknown) { await startJavaConfig(config, root, origin ?? config); return; } - const launch = launchConfigFor(config, root); + const dotnet = config.kind === "dotnet" && config.debugAdapter === "netcoredbg"; + const prepared = dotnet ? await call(commands.runDotnetResolve(config, true)) : config; + const launch = launchConfigFor(prepared, root); if (!launch) { notifications().notify({ group: "run", diff --git a/src/features/debugger/launch.ts b/src/features/debugger/launch.ts index 8f593baad..86b039a6d 100644 --- a/src/features/debugger/launch.ts +++ b/src/features/debugger/launch.ts @@ -313,6 +313,15 @@ export function launchConfigFor(rc: RunConfig, root: string): LaunchConfig | nul if (adapter === "js-debug") return jsDebugLaunch(rc, cwd); if (adapter === "delve") return delveLaunch(rc, cwd, root); if (adapter === "godot") return godotLaunch(rc, cwd); + if (adapter === "netcoredbg" && rc.kind === "dotnet") { + if (!rc.params?.targetPath) return null; + return { + ...base(rc, adapter, cwd), + program: rc.params.targetPath, + args: parseArgs(rc.params.programArgs?.trim() ? rc.params.programArgs : rc.params.profileArgs || ""), + extraJson: JSON.stringify({ justMyCode: workflowSettings().debugger.skipLibraryCode }), + }; + } // Adapters without a template (hidden in Edit Configurations): generic arguments. return { ...base(rc, adapter, cwd), diff --git a/src/features/debugger/missingAdapter.ts b/src/features/debugger/missingAdapter.ts index 8106ea519..dabfb4024 100644 --- a/src/features/debugger/missingAdapter.ts +++ b/src/features/debugger/missingAdapter.ts @@ -7,6 +7,7 @@ import { IpcError } from "@/platform/ipc"; /** Adapters the tools installer can provide (adapter id → tool name). */ const INSTALLABLE: Record = { "kotlin-debug-adapter": { tool: "kotlin-debug-adapter", title: "The Kotlin debugger is not installed" }, + netcoredbg: { tool: "netcoredbg", title: "netcoredbg is not installed" }, debugpy: { tool: "debugpy", title: "debugpy is not installed" }, "js-debug": { tool: "js-debug", title: "The JavaScript debugger (vscode-js-debug) is not installed" }, delve: { tool: "delve", title: "Delve is not installed" }, diff --git a/src/features/run/ConfigForm.tsx b/src/features/run/ConfigForm.tsx index 1d11ed2d4..3c5e8d30a 100644 --- a/src/features/run/ConfigForm.tsx +++ b/src/features/run/ConfigForm.tsx @@ -48,11 +48,12 @@ import styles from "./Run.module.css"; /** * Debug adapters with a real launch template (`debugger/launch.ts`, `debugger/java.ts`), - * verified against small programs. Others (netcoredbg, dart, php-debug, …) only got + * verified against small programs. Others (dart, php-debug, …) only got * generic launch arguments and are left out until they have one; a configuration that * already names one keeps it. */ const ADAPTERS: ReadonlyArray<{ id: string; label: string }> = [ + { id: "netcoredbg", label: "netcoredbg (.NET)" }, { id: "java-debug", label: "java-debug (Java)" }, { id: "kotlin-debug-adapter", label: "kotlin-debug-adapter (Kotlin)" }, { id: "debugpy", label: "debugpy (Python)" }, @@ -254,6 +255,9 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi })), }, { label: TASK_LABELS.build, icon: "build-project", run: () => addTask("build") }, + ...(config.kind === "dotnet" + ? [{ label: TASK_LABELS.dotnetBuild, icon: "build-project", run: () => addTask("dotnetBuild") }] + : []), { label: TASK_LABELS.tool, disabled: !externalTools.length, diff --git a/src/features/run/beforeLaunch.ts b/src/features/run/beforeLaunch.ts index fc0e632f8..8ce13fbd7 100644 --- a/src/features/run/beforeLaunch.ts +++ b/src/features/run/beforeLaunch.ts @@ -2,6 +2,7 @@ * Before launch tasks (IDEA's "Before launch" list): run in order before a * configuration starts; the first failure aborts the launch. */ +import { dotnetBuildConfig } from "./dotnetTypes"; import { executeAction, getAction } from "@/platform/actions"; import { getContext } from "@/platform/context"; import { tx, txTemplate } from "@/platform/i18n"; @@ -14,11 +15,12 @@ import { startRun, useRun } from "./store"; export const BUILD_ACTION = "build.buildProject"; const BUILD_STATUS = "build.lastStatus"; -export type BeforeLaunchKind = "run" | "build" | "tool" | "command" | "toolWindow"; +export type BeforeLaunchKind = "run" | "build" | "tool" | "command" | "toolWindow" | "dotnetBuild"; export const TASK_LABELS: Record = { run: "Run Another Configuration", build: "Build Project", + dotnetBuild: "Build .NET project", tool: "Run External Tool", command: "Run Shell Command", toolWindow: "Activate Tool Window", @@ -32,6 +34,8 @@ export function taskLabel(task: BeforeLaunchTask, configs: readonly RunConfig[] return `Run '${configs.find((c) => c.id === target)?.name ?? target}'`; case "build": return "Build Project"; + case "dotnetBuild": + return "Build .NET project"; case "tool": return `External tool '${workflowSettings().externalTools.find((t) => t.id === target)?.name ?? target}'`; case "command": @@ -58,6 +62,8 @@ async function runToEnd(config: RunConfig, chain: readonly string[]): Promise { const target = task.target ?? ""; switch (task.kind) { + case "dotnetBuild": + return (await runToEnd(dotnetBuildConfig(config), chain)) ? null : "Build failed"; case "build": { if (!getAction(BUILD_ACTION)) return "Building is not available"; if (!(await executeAction(BUILD_ACTION, { activate: false }))) return "A build is already running"; diff --git a/src/features/run/configs.ts b/src/features/run/configs.ts index 7669141b6..5d94cb2a8 100644 --- a/src/features/run/configs.ts +++ b/src/features/run/configs.ts @@ -67,6 +67,9 @@ export function configIcon(config: Pick): string { return "file-gradle"; case "maven": return "file-maven"; + case "dotnet": + case "dotnet-test": + return "file-csharp"; case "go": return "file-go"; case "node": @@ -220,7 +223,7 @@ export function validateConfig(config: RunConfig, all: readonly RunConfig[] = [] for (const t of config.beforeLaunch ?? []) { if (t.kind === "run" && (t.target === config.id || !all.some((c) => c.id === t.target))) errors.beforeLaunch = "A before launch task runs a missing configuration or this one"; - else if (t.kind !== "build" && t.kind !== "run" && !(t.target ?? "").trim()) + else if (t.kind !== "build" && t.kind !== "dotnetBuild" && t.kind !== "run" && !(t.target ?? "").trim()) errors.beforeLaunch = "A before launch task is incomplete"; } for (const key of Object.keys(config.env)) { diff --git a/src/features/run/dotnetTypes.test.tsx b/src/features/run/dotnetTypes.test.tsx new file mode 100644 index 000000000..93d5f16a9 --- /dev/null +++ b/src/features/run/dotnetTypes.test.tsx @@ -0,0 +1,123 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { commands } from "@/platform/ipc"; +import type { RunConfig } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { dotnetBuildConfig, dotnetProjectType, dotnetTestType } from "./dotnetTypes"; +import { ConfigForm } from "./ConfigForm"; +import { validateConfig } from "./configs"; +import { newConfigOfType, registerBuiltinTypes, withParams } from "./types"; + +const resolveDotnet = vi.hoisted(() => vi.fn()); +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, commands: { ...real.commands, runDotnetResolve: resolveDotnet } }; +}); + +const params = { + project: "src/My App/App.csproj", + framework: "net10.0", + frameworks: "net10.0;net9.0", + launchProfile: "local web", + programArgs: "--name 'hello world'", + profiles: JSON.stringify({ + "local web": { applicationUrl: "http://localhost:5165", environmentVariables: { DEMO: "profile" } }, + alternate: {}, + }), +}; +let dispose: () => void; +beforeEach(() => { + dispose = registerBuiltinTypes(); +}); +afterEach(() => dispose()); + +function config(): RunConfig { + return withParams({ ...newConfigOfType(dotnetProjectType, []), name: "demo" }, params, dotnetProjectType); +} + +describe(".NET configurations", () => { + it("generates run/test commands with spaces, framework/profile and separated program arguments", () => { + const c = config(); + expect(c.args).toEqual([ + "run", + "--project", + "src/My App/App.csproj", + "--framework", + "net10.0", + "--launch-profile", + "local web", + "--", + "--name", + "hello world", + ]); + expect(c.debugAdapter).toBe("netcoredbg"); + expect(c.beforeLaunch).toEqual([{ kind: "dotnetBuild", target: "" }]); + expect(validateConfig(c, [c])).toEqual({}); + const t = withParams( + newConfigOfType(dotnetTestType, []), + { project: params.project, framework: "net9.0", programArgs: '--filter "Category=Unit"' }, + dotnetTestType, + ); + expect(t.isTest).toBe(true); + expect(t.debugAdapter).toBeNull(); + expect(t.args).toEqual(["test", params.project, "--framework", "net9.0", "--filter", "Category=Unit"]); + expect(dotnetProjectType.commandLine({ project: "App.csproj" })?.args).toEqual([ + "run", + "--project", + "App.csproj", + "--no-launch-profile", + ]); + }); + + it("builds edited project/framework from the open root instead of a profile cwd", () => { + useProject.setState({ project: { root: "/repo" } as never }); + const c = withParams( + { ...config(), cwd: "profile/work" }, + { ...params, project: "other/App.csproj", framework: "net9.0" }, + ); + const build = dotnetBuildConfig(c); + expect(build.args).toEqual(["build", "other/App.csproj", "--configuration", "Debug", "--framework", "net9.0"]); + expect(build.cwd).toBe("/repo"); + expect(build.beforeLaunch).toEqual([]); + }); + + it("edits project, framework, profile, arguments and environment in the shared form", () => { + const c = config(); + const change = vi.fn(); + render( + , + ); + expect(screen.getByText("http://localhost:5165")).toBeTruthy(); + expect(screen.getByText("DEMO=profile")).toBeTruthy(); + for (const [label, value] of [ + [".NET project", "other/App.csproj"], + ["Target framework", "net9.0"], + ["Launch profile", "alternate"], + ["Program arguments", "--edited"], + ] as const) { + fireEvent.change(screen.getByLabelText(label), { target: { value } }); + expect(change).toHaveBeenCalled(); + } + fireEvent.change(screen.getByRole("textbox", { name: "Environment variables" }), { + target: { value: "DEMO=override" }, + }); + expect(change.mock.lastCall?.[0]).toMatchObject({ env: { DEMO: "override" } }); + }); + it("prepares Run from the freshly resolved profile while keeping explicit argument overrides", async () => { + const c = config(); + resolveDotnet.mockResolvedValue({ + status: "ok", + data: { + ...c, + cwd: "/repo/work", + env: { DEMO: "override" }, + params: { ...params, project: "/repo/App.csproj", profileArgs: "--from-profile" }, + }, + }); + const prepared = await dotnetProjectType.prepareLaunch!(c); + expect(commands.runDotnetResolve).toHaveBeenCalledWith(c, false); + expect(prepared).toMatchObject({ cwd: "/repo/work", env: { DEMO: "override" } }); + expect(prepared.args).toContain("--no-launch-profile"); + expect(prepared.args.slice(-3)).toEqual(["--", "--name", "hello world"]); + }); +}); diff --git a/src/features/run/dotnetTypes.ts b/src/features/run/dotnetTypes.ts new file mode 100644 index 000000000..534ed5733 --- /dev/null +++ b/src/features/run/dotnetTypes.ts @@ -0,0 +1,134 @@ +import { call, commands, type RunConfig } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import type { RunConfigTypeContribution } from "@/workbench/contributions"; +import { parseArgs } from "./configs"; + +function profile(p: Readonly>): { + applicationUrl?: string; + environmentVariables?: Record; +} { + try { + return JSON.parse(p.profiles || "{}")[p.launchProfile || ""] ?? {}; + } catch { + return {}; + } +} + +export function dotnetCommand(p: Readonly>, test = false) { + return { + command: p.executable || "dotnet", + args: [ + ...(test ? ["test", p.project || ""] : ["run", "--project", p.project || ""]), + ...(p.framework ? ["--framework", p.framework] : []), + ...(!test ? (p.launchProfile ? ["--launch-profile", p.launchProfile] : ["--no-launch-profile"]) : []), + ...(p.programArgs?.trim() ? ["--", ...parseArgs(p.programArgs)] : []), + ], + }; +} + +/** Build the current project/framework, independently of the run's profile working directory. */ +export function dotnetBuildConfig(config: RunConfig): RunConfig { + const p = config.params ?? {}; + return { + ...config, + id: `dotnet-build:${config.id}`, + name: `dotnet build (${p.project || ""})`, + kind: "shell", + args: ["build", p.project || "", "--configuration", "Debug", ...(p.framework ? ["--framework", p.framework] : [])], + cwd: useProject.getState().project?.root ?? null, + env: {}, + params: {}, + isTest: false, + debugAdapter: null, + beforeLaunch: [], + emulateTerminal: false, + stdinFile: undefined, + }; +} + +export const dotnetProjectType: RunConfigTypeContribution = { + id: "dotnet", + label: ".NET Project", + icon: "file-csharp", + description: "Run a .NET project with a launch profile", + debugAdapter: "netcoredbg", + fields: [ + { + key: "project", + label: ".NET project", + mono: true, + required: true, + suggestions: (configs) => [ + ...new Set( + configs.filter((c) => c.kind === "dotnet" || c.kind === "dotnet-test").map((c) => c.params?.project || ""), + ), + ], + }, + { + key: "framework", + label: "Target framework", + mono: true, + suggestions: (_configs, p) => (p.frameworks || "").split(";").filter(Boolean), + }, + { + key: "launchProfile", + label: "Launch profile", + mono: true, + hint: "Empty disables launch profiles", + suggestions: (_configs, p) => { + try { + return Object.keys(JSON.parse(p.profiles || "{}")); + } catch { + return []; + } + }, + }, + { key: "programArgs", label: "Program arguments", mono: true, hint: "Empty uses the launch profile's arguments" }, + { + key: "applicationUrl", + label: "Application URL", + control: "output", + value: (p) => profile(p).applicationUrl || "", + }, + { + key: "profileEnvironment", + label: "Profile environment variables", + control: "output", + value: (p) => + Object.entries(profile(p).environmentVariables ?? {}) + .map(([k, v]) => `${k}=${v}`) + .join("; "), + hint: "Environment variables below override the profile", + }, + ], + commandLine: (p) => dotnetCommand(p), + async prepareLaunch(config) { + const resolved = await call(commands.runDotnetResolve(config, false)); + const p = resolved.params ?? {}; + // Materialize the profile so explicit environment/cwd edits also apply on older SDKs. + const args = parseArgs(p.programArgs?.trim() ? p.programArgs : p.profileArgs || ""); + return { + ...resolved, + args: [ + ...dotnetCommand({ ...p, launchProfile: "", programArgs: "" }).args, + ...(args.length ? ["--", ...args] : []), + ], + }; + }, +}; + +export const dotnetTestType: RunConfigTypeContribution = { + id: "dotnet-test", + label: "dotnet test", + icon: "file-csharp", + description: "Run .NET tests", + fields: [...dotnetProjectType.fields.slice(0, 2), { key: "programArgs", label: "Test arguments", mono: true }], + commandLine(p) { + const line = dotnetCommand({ ...p, programArgs: "" }, true); + return { ...line, args: [...line.args, ...parseArgs(p.programArgs || "")] }; + }, + async prepareLaunch(config) { + const resolved = await call(commands.runDotnetResolve(config, false)); + return { ...resolved, ...dotnetTestType.commandLine(resolved.params ?? {}) }; + }, +}; diff --git a/src/features/run/types.ts b/src/features/run/types.ts index b0345cd25..1ad2edf87 100644 --- a/src/features/run/types.ts +++ b/src/features/run/types.ts @@ -22,6 +22,8 @@ import { useProject } from "@/platform/project"; import { tomcatType } from "./tomcat"; import { goBuildType, goTestType } from "./goTypes"; +import { dotnetProjectType, dotnetTestType } from "./dotnetTypes"; + export const COMPOUND = "compound"; const on = (v: string | undefined) => v === "true"; @@ -396,6 +398,8 @@ export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [ maven, godot, pytest, + dotnetProjectType, + dotnetTestType, goBuildType, goTestType, applicationType, @@ -444,6 +448,10 @@ export function withParams(config: RunConfig, params: Record, ty if (!line) return { ...rest, params, command: "", args: [] }; return { ...rest, + ...(config.kind === "dotnet" && config.beforeLaunch === undefined + ? { beforeLaunch: [{ kind: "dotnetBuild", target: "" }] } + : {}), + ...(config.kind === "dotnet-test" ? { isTest: true } : {}), params: params.goKind ? { ...params, programArgv: JSON.stringify(parseArgs(params.programArgs ?? "")) } : params, ...(config.kind === "go-test" && !config.id.startsWith("template:") ? { kind: "go" } : {}), ...(params.goKind ? { isTest: params.goKind === "test" } : {}), diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 5c81a726f..39c7b78f4 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5454,5 +5454,25 @@ "Cannot read Java formatter XML {path}: {error}": "无法读取 Java 格式化 XML {path}:{error}", "Java formatter configuration unavailable": "Java 格式化配置不可用", "Choose an Eclipse formatter XML file.": "请选择 Eclipse 格式化 XML 文件。", - "Not a formatter XML file: {path}": "不是格式化 XML 文件:{path}" + "Not a formatter XML file: {path}": "不是格式化 XML 文件:{path}", + ".NET Project": ".NET 项目", + ".NET project": ".NET 项目", + "Run a .NET project with a launch profile": "使用启动配置运行 .NET 项目", + "Target framework": "目标框架", + "Launch profile": "启动配置", + "Empty disables launch profiles": "留空则禁用启动配置", + "Empty uses the launch profile's arguments": "留空则使用启动配置中的参数", + "Application URL": "应用 URL", + "Profile environment variables": "启动配置中的环境变量", + "Environment variables below override the profile": "下方的环境变量会覆盖启动配置中的值", + "Build .NET project": "构建 .NET 项目", + "Run .NET tests": "运行 .NET 测试", + "Test arguments": "测试参数", + "netcoredbg is not installed": "未安装 netcoredbg", + ".NET Project requires the local machine": ".NET 项目需要在本机运行", + "Select a .NET project": "请选择 .NET 项目", + "Invalid .NET project path": ".NET 项目路径无效", + ".NET launch profile '{profile_name}' is missing or is not a Project profile": ".NET 启动配置“{profile_name}”不存在或不是 Project 类型", + ".NET output resolution timed out": ".NET 构建产物路径查询超时", + ".NET build output is missing; run dotnet build before debugging": ".NET 构建产物不存在,请在调试前运行 dotnet build" } diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index 7d74d60e9..90df1b520 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -79,6 +79,8 @@ const BUILT_AT_RUNTIME = new Set([]); /** Text that stays as written in every locale: names, code, formats, shortcuts. */ const UNTRANSLATED = new Set([ + "dotnet test", + "netcoredbg (.NET)", // Product names "Prettier", "IntelliJ IDEA", @@ -484,6 +486,21 @@ function scanTypeScript(): Scan { return { translated, literals, untranslatable, spliced }; } +/** Read strings without letting a Rust character literal open a string match. */ +function backendLiterals(source: string): string[] { + const out: string[] = []; + for (const m of source.matchAll(/'(?:\\.|[^'\\\r\n])'|"((?:[^"\\]|\\.)*)"/g)) { + if (m[1] !== undefined) out.push(m[1]); + } + return out; +} + +it("reads backend messages after quote character literals", () => { + expect( + backendLiterals(`let quote = '"'; let message = "Select a .NET project"; let detail = "Expected 'x'";`), + ).toEqual(["Select a .NET project", "Expected 'x'"]); +}); + /** String literals of the Rust crates and language definitions (backend messages). */ function scanBackend(literals: Set) { const files = [ @@ -492,7 +509,7 @@ function scanBackend(literals: Set) { ...walk(`${ROOT}languages`, (p) => p.endsWith(".toml")), ]; for (const file of files) - for (const m of readFileSync(file, "utf8").matchAll(/"((?:[^"\\]|\\.)*)"/g)) literals.add(shape(m[1]!)); + for (const literal of backendLiterals(readFileSync(file, "utf8"))) literals.add(shape(literal)); } /** Text that needs no translation: no words, a path / glob / option, or listed in `UNTRANSLATED`. */