Compare commits

...
Author SHA1 Message Date
l0ng-ai 79b73394d8 Merge remote-tracking branch 'origin/main' into feat/dotnet-run-configs
# Conflicts:
#	crates/ide-run/src/detect.rs
#	src/platform/locales/text/zh.json
2026-11-01 19:32:30 +08:00
l0ng-ai 42e7f777f1 feat(run): add .NET project run and debug configurations 2026-11-01 19:30:56 +08:00
22 changed files with 908 additions and 6 deletions

No files matched your search

+20
View File
@@ -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<RunConfig> {
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
});
+2
View File
@@ -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);
+121
View File
@@ -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<RunConfig>) {
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<String, String>, 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(&params, 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<String, String>, test: bool) -> Vec<String> {
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
}
+257
View File
@@ -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<String>,
pub test: bool,
}
#[derive(Default)]
struct Element {
name: String,
attrs: BTreeMap<String, String>,
text: String,
conditional: bool,
}
fn elements(xml: &str) -> Option<Vec<Element>> {
let mut reader = Reader::from_str(xml);
let mut out: Vec<Element> = Vec::new();
let mut stack: Vec<usize> = 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::<Option<_>>()?;
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<Project> {
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<String, String>,
}
pub fn parse_profiles(json: &str) -> BTreeMap<String, Profile> {
let Ok(json) = serde_json::from_str::<serde_json::Value>(&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<PathBuf> {
let paths: Vec<String> = 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<String, Profile> {
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<RunConfig> {
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<String> {
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)
}
+1
View File
@@ -2,6 +2,7 @@
mod config;
mod detect;
pub mod dotnet;
pub mod envfile;
pub mod go;
pub mod gradle_jvm;
+138
View File
@@ -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#"<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFrameworks>net10.0; net9.0</TargetFrameworks></PropertyGroup></Project>"#;
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", "<OutputType>WinExe</OutputType>", true),
("Microsoft.NET.Sdk.Web", "", true),
("Microsoft.NET.Sdk.Web", "<OutputType>Library</OutputType>", false),
] {
let xml = format!(
r#"<Project Sdk="{sdk}"><PropertyGroup>{output}<TargetFramework>net10.0</TargetFramework></PropertyGroup></Project>"#
);
let p = parse_project(&xml).unwrap();
assert_eq!(p.executable, executable, "{xml}");
assert_eq!(p.frameworks, ["net10.0"]);
}
let p = parse_project(r#"<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"><Sdk Name="Microsoft.NET.Sdk.Web"/><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup><PropertyGroup Condition="'$(Configuration)'=='Release'"><OutputType>Library</OutputType></PropertyGroup><ItemGroup><PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0" /></ItemGroup></Project>"#).unwrap();
assert!(p.executable && p.test);
assert!(parse_project("<Project><PropertyGroup>").is_none());
assert!(parse_project("garbage").is_none());
assert!(
parse_project(
r#"<Project><ItemGroup><PackageReference Update="microsoft.net.test.sdk" /></ItemGroup></Project>"#
)
.unwrap()
.test
);
assert!(
!parse_project(r#"<Project><!-- <PackageReference Include="Microsoft.NET.Test.Sdk"/> --></Project>"#)
.unwrap()
.test
);
}
#[test]
fn profiles_filter_and_preserve_launch_properties() {
let p = parse_profiles(PROFILES);
assert_eq!(p.keys().map(String::as_str).collect::<Vec<_>>(), ["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#"<Solution><Folder Name="/src/"><Project Path="src\A&amp;B\App.csproj" /></Folder><Project Path="other.vbproj" /></Solution>"#;
assert_eq!(solution_projects(slnx, true), [PathBuf::from("src/A&B/App.csproj")]);
assert!(solution_projects("<Solution><Project", true).is_empty());
}
#[test]
fn detection_deduplicates_projects_and_generates_commands() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
write(root, "src/App/App.csproj", CONSOLE);
write(root, "src/App/Properties/launchSettings.json", PROFILES);
write(
root,
"demo.slnx",
r#"<Solution><Project Path="src/App/App.csproj"/><Project Path="../external/Missing.csproj"/></Solution>"#,
);
write(
root,
"src/Test/Test.csproj",
r#"<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup><ItemGroup><PackageReference Include="Microsoft.NET.Test.Sdk"/></ItemGroup></Project>"#,
);
write(root, "bin/Ignored.csproj", CONSOLE);
write(root, "obj/Ignored.csproj", CONSOLE);
write(root, "node_modules/Ignored.csproj", CONSOLE);
write(root, "Lib.csproj", "<Project Sdk=\"Microsoft.NET.Sdk\" />");
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"));
}
+1
View File
@@ -305,6 +305,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+11
View File
@@ -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<RunConfig> {
ide_host::ops::run::run_dotnet_resolve(&state.host, window.label(), config, debug).await
}
#[tauri::command]
#[specta::specta]
pub async fn run_application_build(
+1
View File
@@ -565,6 +565,7 @@ export const commands = {
termAvailableShells: () => __TAURI_INVOKE<string[]>("term_available_shells"),
/** Detected configs followed by user-defined ones (user ids win on conflict). */
runConfigs: () => typedError<RunConfig_Serialize[], IdeError>(__TAURI_INVOKE("run_configs")),
runDotnetResolve: (config: RunConfig_Deserialize, debug: boolean) => typedError<RunConfig_Serialize, IdeError>(__TAURI_INVOKE("run_dotnet_resolve", { config, debug })),
runSaveConfigs: (configs: RunConfig_Deserialize[], removedDetectedIds: string[] | null) => typedError<null, IdeError>(__TAURI_INVOKE("run_save_configs", { configs, removedDetectedIds })),
/** Configuration templates: per-type defaults applied to new configurations. */
runTemplates: () => typedError<RunConfig_Serialize[], IdeError>(__TAURI_INVOKE("run_templates")),
@@ -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();
});
@@ -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");
});
+3 -1
View File
@@ -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",
+9
View File
@@ -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),
+1
View File
@@ -7,6 +7,7 @@ import { IpcError } from "@/platform/ipc";
/** Adapters the tools installer can provide (adapter id → tool name). */
const INSTALLABLE: Record<string, { tool: string; title: string }> = {
"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" },
+5 -1
View File
@@ -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,
+7 -1
View File
@@ -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<BeforeLaunchKind, string> = {
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<bo
async function runTask(task: BeforeLaunchTask, config: RunConfig, chain: readonly string[]): Promise<string | null> {
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";
+4 -1
View File
@@ -67,6 +67,9 @@ export function configIcon(config: Pick<RunConfig, "kind" | "isTest">): 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)) {
+123
View File
@@ -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<typeof import("@/platform/ipc")>();
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(
<ConfigForm config={c} all={[c]} type={dotnetProjectType} errors={{}} modifyRequest={0} onChange={change} />,
);
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"]);
});
});
+134
View File
@@ -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<Record<string, string>>): {
applicationUrl?: string;
environmentVariables?: Record<string, string>;
} {
try {
return JSON.parse(p.profiles || "{}")[p.launchProfile || ""] ?? {};
} catch {
return {};
}
}
export function dotnetCommand(p: Readonly<Record<string, string>>, 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 ?? {}) };
},
};
+8
View File
@@ -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<string, string>, 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" } : {}),
+21 -1
View File
@@ -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"
}
+18 -1
View File
@@ -79,6 +79,8 @@ const BUILT_AT_RUNTIME = new Set<string>([]);
/** Text that stays as written in every locale: names, code, formats, shortcuts. */
const UNTRANSLATED = new Set<string>([
"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<string>) {
const files = [
@@ -494,7 +511,7 @@ function scanBackend(literals: Set<string>) {
...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`. */