mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 17:22:55 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79b73394d8 | ||
|
|
42e7f777f1 |
No files matched your search
@@ -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
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(¶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<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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod config;
|
||||
mod detect;
|
||||
pub mod dotnet;
|
||||
pub mod envfile;
|
||||
pub mod go;
|
||||
pub mod gradle_jvm;
|
||||
|
||||
@@ -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&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"));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 ?? {}) };
|
||||
},
|
||||
};
|
||||
@@ -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" } : {}),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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`. */
|
||||
|
||||
Reference in new issue
Block a user