From 245fe3cdf40c0605429abb2398cdea6020c6f464 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:34:31 +0800 Subject: [PATCH] feat(run): .NET project run and debug configurations (#686) .NET projects had no run configurations. Projects from .csproj files (and those listed in .sln/.slnx) now get configurations: executable projects (OutputType Exe/WinExe, or the Web SDK) get a .NET Project configuration running dotnet run, one per Project launch profile in Properties/launchSettings.json with its application URL and environment shown; test projects (Microsoft.NET.Test.Sdk) get dotnet test. The form edits project, target framework, launch profile, program arguments and environment, which override the profile's values. Debug builds the project, resolves the output assembly through MSBuild and launches it under netcoredbg, offering to install netcoredbg when missing. The source-text catalog test now ignores Rust character literals when collecting backend strings. Part of #615 --- 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 13a1f5d5c..82f365949 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; @@ -33,6 +34,7 @@ pub(crate) fn detect_with_profiles( detect_go(root, &mut out); detect_python(root, &mut out); cmake::detect(root, profiles, &mut out); + dotnet::detect(root, &mut out); detect_make(root, &mut out); detect_just(root, &mut out); detect_godot(root, &mut out); diff --git a/crates/ide-run/src/detect/dotnet.rs b/crates/ide-run/src/detect/dotnet.rs new file mode 100644 index 000000000..ce1f433e0 --- /dev/null +++ b/crates/ide-run/src/detect/dotnet.rs @@ -0,0 +1,121 @@ +//! Runnable .NET projects and launch profiles, without a solution model. + +use crate::{ + RunConfig, + dotnet::{parse_project, profiles, solution_projects}, +}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::Path, +}; + +pub(crate) fn detect(root: &Path, out: &mut 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 95bf00bd3..f35036b1b 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 2d37e23ea..ba2822494 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -305,6 +305,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 e2a005bb4..f4603e362 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -565,6 +565,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 098395a0e..2db2c3e62 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -297,7 +297,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 b92b21660..43edfc7fe 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5470,5 +5470,25 @@ "Build arguments": "构建参数", "CMake default": "CMake 默认", "Quote arguments containing spaces. Build arguments go to cmake --build; use -- before native build tool options.": "包含空格的参数请加引号。构建参数传给 cmake --build;原生构建工具参数前请加 --。", - "CMake profile {0}": "CMake 配置 {0}" + "CMake profile {0}": "CMake 配置 {0}", + ".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 2079d23aa..827d795fb 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 "Cargo", "CMake", @@ -486,6 +488,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 = [ @@ -494,7 +511,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`. */