дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
Ruby files, `rails server`, Rake tasks, RSpec and Minitest configurations can be debugged with rdbg, using the project's Ruby. In a Bundler project it runs through the bundle; if the bundle lacks the `debug` gem, debugging stops with an error asking to add `gem "debug"` to the Gemfile.
1335 рядки
51 KiB
Rust
1335 рядки
51 KiB
Rust
//! Run configuration detection from build files at the project root, or from the
|
|
//! builds nested in a folder that holds several repositories.
|
|
//!
|
|
//! Detection never fails: unreadable or malformed files simply contribute nothing.
|
|
|
|
pub(crate) mod cmake;
|
|
mod dotnet;
|
|
|
|
use crate::RunConfig;
|
|
use ide_core::project_roots::expand_members;
|
|
use serde_json::Value as Json;
|
|
use std::collections::{BTreeMap, HashSet};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
pub(crate) fn detect(root: &Path) -> Vec<RunConfig> {
|
|
detect_excluding(root, &[])
|
|
}
|
|
|
|
pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<RunConfig> {
|
|
detect_with_profiles(root, excluded_poms, None)
|
|
}
|
|
|
|
pub(crate) fn detect_with_profiles(
|
|
root: &Path,
|
|
excluded_poms: &[PathBuf],
|
|
profiles: Option<&[ide_core::settings::CMakeProfile]>,
|
|
) -> Vec<RunConfig> {
|
|
let mut out = Vec::new();
|
|
detect_node(root, &mut out);
|
|
detect_projects(root, &["deno.json", "deno.jsonc"], detect_deno, &mut out);
|
|
detect_projects(root, &["Cargo.toml"], detect_cargo, &mut out);
|
|
detect_gradle(root, &mut out);
|
|
detect_maven(root, excluded_poms, &mut out);
|
|
detect_go(root, &mut out);
|
|
detect_projects_matching(
|
|
root,
|
|
|dir| {
|
|
["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "pytest.ini", "tox.ini", "manage.py"]
|
|
.iter()
|
|
.any(|name| dir.join(name).is_file())
|
|
|| [".venv", "venv"]
|
|
.iter()
|
|
.any(|name| dir.join(name).join(PyEnv::bin_dir()).join(PyEnv::python_exe()).is_file())
|
|
},
|
|
detect_python,
|
|
&mut out,
|
|
);
|
|
detect_projects(
|
|
root,
|
|
&["composer.json", "phpunit.xml", "phpunit.xml.dist", "phpunit.dist.xml"],
|
|
detect_php,
|
|
&mut out,
|
|
);
|
|
detect_projects_matching(root, ruby_project, detect_ruby, &mut out);
|
|
for dir in ide_core::project_roots::discover(root, &["CMakeLists.txt"]) {
|
|
let start = out.len();
|
|
cmake::detect(&dir, if dir == root { profiles } else { None }, &mut out);
|
|
if dir != root {
|
|
let scope = Scope::new(root, &dir);
|
|
for config in &mut out[start..] {
|
|
config.id = scope.id(&config.id);
|
|
config.name = scope.name(&config.name);
|
|
for task in &mut config.extras.before_launch {
|
|
if matches!(task.kind.as_str(), "run" | "build") && !task.target.is_empty() {
|
|
task.target = scope.id(&task.target);
|
|
}
|
|
}
|
|
crate::scope::rebase(config, &dir, Some(root));
|
|
}
|
|
}
|
|
}
|
|
dotnet::detect(root, &mut out);
|
|
detect_projects(root, &["Makefile", "makefile", "GNUmakefile"], detect_make, &mut out);
|
|
detect_projects(root, &["justfile", "Justfile", ".justfile"], detect_just, &mut out);
|
|
detect_godot(root, &mut out);
|
|
crate::external::detect(root, &mut out);
|
|
|
|
let mut seen = HashSet::new();
|
|
out.retain(|c| seen.insert(c.id.clone()));
|
|
out
|
|
}
|
|
|
|
fn detect_projects(
|
|
root: &Path,
|
|
markers: &[&str],
|
|
detect: impl Fn(&Path, &mut Vec<RunConfig>),
|
|
out: &mut Vec<RunConfig>,
|
|
) {
|
|
detect_projects_matching(
|
|
root,
|
|
|dir| {
|
|
markers.iter().any(|marker| dir.join(marker).is_file())
|
|
&& (markers != ["Cargo.toml"] || !cargo_workspace_owns(root, dir))
|
|
},
|
|
detect,
|
|
out,
|
|
);
|
|
}
|
|
|
|
fn detect_projects_matching(
|
|
root: &Path,
|
|
marked: impl Fn(&Path) -> bool,
|
|
detect: impl Fn(&Path, &mut Vec<RunConfig>),
|
|
out: &mut Vec<RunConfig>,
|
|
) {
|
|
let mut covered = HashSet::new();
|
|
for dir in ide_core::project_roots::discover_matching(root, ide_core::project_roots::SEARCH_DEPTH, false, marked) {
|
|
if covered.contains(&dir) {
|
|
continue;
|
|
}
|
|
let mut configs = Vec::new();
|
|
detect(&dir, &mut configs);
|
|
for config in &configs {
|
|
if let Some(module) = &config.extras.module {
|
|
covered.insert(ide_core::project_roots::resolve(&dir, Path::new(module)));
|
|
}
|
|
}
|
|
if dir != root {
|
|
let scope = Scope::new(root, &dir);
|
|
for config in &mut configs {
|
|
config.id = scope.id(&config.id);
|
|
config.name = scope.name(&config.name);
|
|
for task in &mut config.extras.before_launch {
|
|
if task.kind == "run" {
|
|
task.target = scope.id(&task.target);
|
|
}
|
|
}
|
|
crate::scope::rebase(config, &dir, Some(root));
|
|
}
|
|
}
|
|
out.extend(configs);
|
|
}
|
|
}
|
|
|
|
fn cargo_workspace_owns(root: &Path, dir: &Path) -> bool {
|
|
let Some(manifest) = read_toml(&dir.join("Cargo.toml")) else { return false };
|
|
if manifest.contains_key("workspace") {
|
|
return false;
|
|
}
|
|
dir.ancestors()
|
|
.skip(1)
|
|
.take_while(|parent| parent.starts_with(root))
|
|
.find_map(|parent| {
|
|
let manifest = read_toml(&parent.join("Cargo.toml"))?;
|
|
let workspace = manifest.get("workspace")?;
|
|
let excluded = string_list(workspace.get("exclude"));
|
|
Some(
|
|
!excluded.iter().any(|excluded| dir.starts_with(parent.join(excluded)))
|
|
&& !expand_members(parent, &excluded, "Cargo.toml")
|
|
.iter()
|
|
.any(|excluded| dir.starts_with(excluded)),
|
|
)
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn config(id: impl Into<String>, name: impl Into<String>, kind: &str, command: &str, args: &[&str]) -> RunConfig {
|
|
RunConfig {
|
|
id: id.into(),
|
|
name: name.into(),
|
|
kind: kind.to_owned(),
|
|
command: command.to_owned(),
|
|
args: args.iter().map(|a| (*a).to_owned()).collect(),
|
|
cwd: None,
|
|
env: BTreeMap::new(),
|
|
detected: true,
|
|
is_test: false,
|
|
debug_adapter: None,
|
|
allow_parallel: false,
|
|
extras: Default::default(),
|
|
}
|
|
}
|
|
|
|
trait ConfigExt {
|
|
fn test(self) -> Self;
|
|
fn debug(self, adapter: &str) -> Self;
|
|
fn in_dir(self, dir: Option<PathBuf>) -> Self;
|
|
}
|
|
|
|
impl ConfigExt for RunConfig {
|
|
fn test(mut self) -> Self {
|
|
self.is_test = true;
|
|
self
|
|
}
|
|
fn debug(mut self, adapter: &str) -> Self {
|
|
self.debug_adapter = Some(adapter.to_owned());
|
|
self
|
|
}
|
|
fn in_dir(mut self, dir: Option<PathBuf>) -> Self {
|
|
self.cwd = dir.map(Into::into);
|
|
self
|
|
}
|
|
}
|
|
|
|
fn read(path: &Path) -> Option<String> {
|
|
fs::read_to_string(path).ok()
|
|
}
|
|
|
|
fn read_json(path: &Path) -> Option<Json> {
|
|
serde_json::from_str(&read(path)?).ok()
|
|
}
|
|
|
|
fn read_toml(path: &Path) -> Option<toml::Table> {
|
|
read(path)?.parse::<toml::Table>().ok()
|
|
}
|
|
|
|
fn first_existing(root: &Path, names: &[&str]) -> Option<PathBuf> {
|
|
names.iter().map(|n| root.join(n)).find(|p| p.is_file())
|
|
}
|
|
|
|
/// Sorted immediate subdirectories, skipping hidden and vendored ones.
|
|
fn subdirs(dir: &Path) -> Vec<PathBuf> {
|
|
ide_core::project_roots::children(dir)
|
|
}
|
|
|
|
fn relative(root: &Path, dir: &Path) -> PathBuf {
|
|
dir.strip_prefix(root).map(Path::to_path_buf).unwrap_or_else(|_| dir.to_path_buf())
|
|
}
|
|
|
|
// ---------------------------------------------------------------- node
|
|
|
|
pub(crate) fn package_manager(root: &Path, _pkg: &Json) -> &'static str {
|
|
ide_core::project_roots::package_manager(root)
|
|
}
|
|
|
|
pub(crate) fn is_test_script(name: &str) -> bool {
|
|
matches!(name, "test" | "vitest" | "jest") || name.starts_with("test:")
|
|
}
|
|
|
|
fn push_scripts(pkg: &Json, pm: &str, prefix: Option<&str>, cwd: Option<PathBuf>, out: &mut Vec<RunConfig>) {
|
|
let Some(scripts) = pkg.get("scripts").and_then(Json::as_object) else { return };
|
|
let mut names: Vec<&String> = scripts.keys().collect();
|
|
names.sort();
|
|
for script in names {
|
|
let (id, name) = match prefix {
|
|
Some(p) => (format!("npm:{p}:{script}"), format!("{p}: {pm} run {script}")),
|
|
None => (format!("npm:{script}"), format!("{pm} run {script}")),
|
|
};
|
|
let mut c = config(id, name, "npm", pm, &["run", script]).in_dir(cwd.clone()).debug("js-debug");
|
|
c.extras.module = cwd.as_deref().map(ide_core::ipc_path::to_ipc);
|
|
if is_test_script(script) {
|
|
c = c.test();
|
|
}
|
|
out.push(c);
|
|
}
|
|
}
|
|
|
|
fn detect_node(root: &Path, out: &mut Vec<RunConfig>) {
|
|
let mut covered = HashSet::new();
|
|
let projects =
|
|
ide_core::project_roots::discover_matching(root, ide_core::project_roots::SEARCH_DEPTH, false, |dir| {
|
|
dir.join("package.json").is_file()
|
|
});
|
|
for dir in projects {
|
|
if !covered.insert(dir.clone()) {
|
|
continue;
|
|
}
|
|
let Some(pkg) = read_json(&dir.join("package.json")) else { continue };
|
|
let pm = package_manager(&dir, &pkg);
|
|
let rel = relative(root, &dir);
|
|
let label = ide_core::ipc_path::to_ipc(&rel);
|
|
push_scripts(&pkg, pm, (dir != root).then_some(label.as_str()), (dir != root).then_some(rel), out);
|
|
for member_dir in ide_core::project_roots::node_workspace_members(&dir) {
|
|
if !covered.insert(member_dir.clone()) {
|
|
continue;
|
|
}
|
|
let Some(member) = read_json(&member_dir.join("package.json")) else { continue };
|
|
let rel = relative(root, &member_dir);
|
|
let name = member
|
|
.get("name")
|
|
.and_then(Json::as_str)
|
|
.map(str::to_owned)
|
|
.unwrap_or_else(|| ide_core::ipc_path::to_ipc(&rel));
|
|
let name = if dir == root { name } else { format!("{label}:{name}") };
|
|
push_scripts(&member, pm, Some(&name), Some(rel), out);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- deno
|
|
|
|
fn detect_deno(root: &Path, out: &mut Vec<RunConfig>) {
|
|
let Some(path) = first_existing(root, &["deno.json", "deno.jsonc"]) else { return };
|
|
let Some(json) = read(&path).and_then(|s| serde_json::from_str::<Json>(&ide_core::jsonc::strip(&s)).ok()) else {
|
|
return;
|
|
};
|
|
let Some(tasks) = json.get("tasks").and_then(Json::as_object) else { return };
|
|
let mut names: Vec<&String> = tasks.keys().collect();
|
|
names.sort();
|
|
for task in names {
|
|
let mut c = config(format!("deno:{task}"), format!("deno task {task}"), "deno", "deno", &["task", task]);
|
|
if is_test_script(task) {
|
|
c = c.test();
|
|
}
|
|
out.push(c);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- cargo
|
|
|
|
fn string_list(v: Option<&toml::Value>) -> Vec<String> {
|
|
v.and_then(toml::Value::as_array)
|
|
.map(|a| a.iter().filter_map(toml::Value::as_str).map(str::to_owned).collect())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn cargo_bins(dir: &Path, manifest: &toml::Table) -> Vec<String> {
|
|
let package = manifest.get("package").and_then(toml::Value::as_table);
|
|
let pkg_name = package.and_then(|p| p.get("name")).and_then(toml::Value::as_str).unwrap_or_default();
|
|
let autobins = package.and_then(|p| p.get("autobins")).and_then(toml::Value::as_bool).unwrap_or(true);
|
|
let mut bins = Vec::new();
|
|
let mut explicit_paths = HashSet::new();
|
|
if let Some(list) = manifest.get("bin").and_then(toml::Value::as_array) {
|
|
for bin in list.iter().filter_map(toml::Value::as_table) {
|
|
if let Some(name) = bin.get("name").and_then(toml::Value::as_str) {
|
|
bins.push(name.to_owned());
|
|
}
|
|
if let Some(path) = bin.get("path").and_then(toml::Value::as_str) {
|
|
explicit_paths.insert(path.trim_start_matches("./").replace('\\', "/"));
|
|
}
|
|
}
|
|
}
|
|
if autobins {
|
|
if dir.join("src/main.rs").is_file() && !explicit_paths.contains("src/main.rs") && !pkg_name.is_empty() {
|
|
bins.push(pkg_name.to_owned());
|
|
}
|
|
if let Ok(entries) = fs::read_dir(dir.join("src/bin")) {
|
|
let mut extra: Vec<String> = entries
|
|
.filter_map(Result::ok)
|
|
.filter_map(|e| {
|
|
let path = e.path();
|
|
let name = e.file_name().to_string_lossy().into_owned();
|
|
if path.is_file() {
|
|
name.strip_suffix(".rs").map(str::to_owned)
|
|
} else if path.join("main.rs").is_file() {
|
|
Some(name)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
extra.sort();
|
|
bins.extend(extra);
|
|
}
|
|
}
|
|
let mut seen = HashSet::new();
|
|
bins.retain(|b| seen.insert(b.clone()));
|
|
bins
|
|
}
|
|
|
|
fn detect_cargo(root: &Path, out: &mut Vec<RunConfig>) {
|
|
let Some(manifest) = read_toml(&root.join("Cargo.toml")) else { return };
|
|
let workspace = manifest.get("workspace").and_then(toml::Value::as_table);
|
|
let is_workspace = workspace.is_some();
|
|
|
|
let mut packages: Vec<(String, PathBuf, toml::Table)> = Vec::new();
|
|
if let Some(name) = manifest.get("package").and_then(|p| p.get("name")).and_then(toml::Value::as_str) {
|
|
packages.push((name.to_owned(), root.to_path_buf(), manifest.clone()));
|
|
}
|
|
if let Some(ws) = workspace {
|
|
let exclude: HashSet<PathBuf> =
|
|
string_list(ws.get("exclude")).iter().map(|e| root.join(e.trim_end_matches('/'))).collect();
|
|
for dir in expand_members(root, &string_list(ws.get("members")), "Cargo.toml") {
|
|
if exclude.contains(&dir) || dir == root {
|
|
continue;
|
|
}
|
|
let Some(m) = read_toml(&dir.join("Cargo.toml")) else { continue };
|
|
if let Some(name) = m.get("package").and_then(|p| p.get("name")).and_then(toml::Value::as_str) {
|
|
packages.push((name.to_owned(), dir, m));
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut used = HashSet::new();
|
|
for (pkg, dir, m) in &packages {
|
|
let bins = cargo_bins(dir, m);
|
|
for bin in &bins {
|
|
let mut args = vec!["run"];
|
|
if is_workspace {
|
|
args.extend(["-p", pkg.as_str()]);
|
|
}
|
|
if is_workspace || bins.len() > 1 {
|
|
args.extend(["--bin", bin.as_str()]);
|
|
}
|
|
let id = if used.insert(format!("cargo:run:{bin}")) {
|
|
format!("cargo:run:{bin}")
|
|
} else {
|
|
format!("cargo:run:{pkg}:{bin}")
|
|
};
|
|
let mut run = config(id, format!("cargo run {bin}"), "cargo", "cargo", &args).debug("codelldb");
|
|
if dir != root {
|
|
run.extras.module = Some(ide_core::ipc_path::to_ipc(&relative(root, dir)));
|
|
}
|
|
out.push(run);
|
|
}
|
|
}
|
|
if packages.is_empty() && !is_workspace {
|
|
return;
|
|
}
|
|
if is_workspace {
|
|
for (pkg, dir, _) in &packages {
|
|
let mut test = config(
|
|
format!("cargo:test:{pkg}"),
|
|
format!("cargo test {pkg}"),
|
|
"cargo",
|
|
"cargo",
|
|
&["test", "-p", pkg],
|
|
)
|
|
.test()
|
|
.debug("codelldb");
|
|
test.extras.module = Some(ide_core::ipc_path::to_ipc(&relative(root, dir)));
|
|
out.push(test);
|
|
}
|
|
}
|
|
let ws_flag: &[&str] = if is_workspace { &["--workspace"] } else { &[] };
|
|
let test_args: Vec<&str> = ["test"].iter().chain(ws_flag).copied().collect();
|
|
let build_args: Vec<&str> = ["build"].iter().chain(ws_flag).copied().collect();
|
|
out.push(config("cargo:test", "cargo test", "cargo", "cargo", &test_args).test().debug("codelldb"));
|
|
out.push(config("cargo:build", "cargo build", "cargo", "cargo", &build_args));
|
|
}
|
|
|
|
// ---------------------------------------------------------------- gradle / maven
|
|
|
|
fn wrapper(root: &Path, unix: &str, windows: &str, fallback: &str) -> String {
|
|
if cfg!(windows) {
|
|
if root.join(windows).is_file() {
|
|
return windows.to_owned();
|
|
}
|
|
} else if root.join(unix).is_file() {
|
|
return format!("./{unix}");
|
|
}
|
|
fallback.to_owned()
|
|
}
|
|
|
|
/// Root build file plus one level of subproject build files.
|
|
fn collect_texts(root: &Path, names: &[&str]) -> Option<String> {
|
|
let mut text = names.iter().find_map(|n| read(&root.join(n)))?;
|
|
for dir in subdirs(root) {
|
|
for name in names {
|
|
if let Some(t) = read(&dir.join(name)) {
|
|
text.push('\n');
|
|
text.push_str(&t);
|
|
}
|
|
}
|
|
}
|
|
Some(text)
|
|
}
|
|
|
|
fn applies_application_plugin(text: &str) -> bool {
|
|
text.lines().any(|line| {
|
|
let compact: String =
|
|
line.split("//").next().unwrap_or_default().chars().filter(|c| !c.is_whitespace()).collect();
|
|
compact == "application"
|
|
|| compact.starts_with("application{")
|
|
|| ["id'application'", "id\"application\"", "id(\"application\")", "id('application')"]
|
|
.iter()
|
|
.any(|p| compact.contains(p))
|
|
|| ["plugin:'application'", "plugin:\"application\"", "plugin=\"application\""]
|
|
.iter()
|
|
.any(|p| compact.contains(p))
|
|
})
|
|
}
|
|
|
|
fn build_dirs(root: &Path, markers: &[&str]) -> Vec<PathBuf> {
|
|
ide_core::project_roots::discover(root, markers)
|
|
}
|
|
|
|
/// Ids, names and working directory of the configurations of a build nested in
|
|
/// `rel` (`None`: the build at the project root).
|
|
struct Scope(Option<String>);
|
|
|
|
impl Scope {
|
|
fn new(root: &Path, dir: &Path) -> Self {
|
|
Self((dir != root).then(|| relative(root, dir).to_string_lossy().replace('\\', "/")))
|
|
}
|
|
fn id(&self, base: &str) -> String {
|
|
match &self.0 {
|
|
Some(l) => format!("{base}:{l}"),
|
|
None => base.to_owned(),
|
|
}
|
|
}
|
|
fn name(&self, base: &str) -> String {
|
|
match &self.0 {
|
|
Some(l) => format!("{l}: {base}"),
|
|
None => base.to_owned(),
|
|
}
|
|
}
|
|
fn cwd(&self) -> Option<PathBuf> {
|
|
self.0.as_ref().map(PathBuf::from)
|
|
}
|
|
}
|
|
|
|
const GRADLE_SETTINGS: &[&str] = &["settings.gradle.kts", "settings.gradle"];
|
|
const GRADLE_BUILDS: &[&str] = &["build.gradle.kts", "build.gradle"];
|
|
|
|
fn detect_gradle(root: &Path, out: &mut Vec<RunConfig>) {
|
|
let markers: Vec<&str> = GRADLE_SETTINGS.iter().chain(GRADLE_BUILDS).copied().collect();
|
|
for dir in build_dirs(root, &markers) {
|
|
detect_gradle_build(root, &dir, out);
|
|
}
|
|
}
|
|
|
|
fn detect_gradle_build(root: &Path, dir: &Path, out: &mut Vec<RunConfig>) {
|
|
let has_settings = first_existing(dir, GRADLE_SETTINGS).is_some();
|
|
let text = match collect_texts(dir, GRADLE_BUILDS) {
|
|
Some(t) => t,
|
|
None if has_settings => collect_texts_from_subdirs(dir, GRADLE_BUILDS),
|
|
None => return,
|
|
};
|
|
let scope = Scope::new(root, dir);
|
|
let cmd = wrapper(dir, "gradlew", "gradlew.bat", "gradle");
|
|
let gradle = |id: &str, task: &str| {
|
|
config(scope.id(id), scope.name(&format!("gradle {task}")), "gradle", &cmd, &[task]).in_dir(scope.cwd())
|
|
};
|
|
spring_boot_apps(root, dir, out);
|
|
out.push(gradle("gradle:build", "build"));
|
|
out.push(gradle("gradle:test", "test").test());
|
|
if applies_application_plugin(&text) {
|
|
out.push(gradle("gradle:run", "run"));
|
|
}
|
|
if text.contains("org.springframework.boot") {
|
|
out.push(gradle("gradle:bootRun", "bootRun"));
|
|
}
|
|
}
|
|
|
|
fn collect_texts_from_subdirs(root: &Path, names: &[&str]) -> String {
|
|
let mut text = String::new();
|
|
for dir in subdirs(root) {
|
|
for name in names {
|
|
if let Some(t) = read(&dir.join(name)) {
|
|
text.push_str(&t);
|
|
text.push('\n');
|
|
}
|
|
}
|
|
}
|
|
text
|
|
}
|
|
|
|
fn detect_maven(root: &Path, excluded_poms: &[PathBuf], out: &mut Vec<RunConfig>) {
|
|
for dir in build_dirs(root, &["pom.xml"]) {
|
|
let pom = dir.join("pom.xml");
|
|
let relative = pom.strip_prefix(root).unwrap_or(&pom);
|
|
if !excluded_poms.iter().any(|excluded| excluded == relative) {
|
|
detect_maven_build(root, &dir, out);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn detect_maven_build(root: &Path, dir: &Path, out: &mut Vec<RunConfig>) {
|
|
let Some(text) = collect_texts(dir, &["pom.xml"]) else { return };
|
|
let scope = Scope::new(root, dir);
|
|
let cmd = wrapper(dir, "mvnw", "mvnw.cmd", "mvn");
|
|
let maven = |id: &str, goal: &str| {
|
|
config(scope.id(id), scope.name(&format!("mvn {goal}")), "maven", &cmd, &[goal]).in_dir(scope.cwd())
|
|
};
|
|
spring_boot_apps(root, dir, out);
|
|
out.push(maven("maven:package", "package"));
|
|
out.push(maven("maven:test", "test").test());
|
|
if text.contains("spring-boot-maven-plugin") {
|
|
out.push(maven("maven:spring-boot:run", "spring-boot:run"));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- spring boot
|
|
|
|
/// Source files read per build when looking for `@SpringBootApplication` classes.
|
|
const SPRING_SCAN_FILES: usize = 400;
|
|
|
|
/// "Spring Boot" configurations for the `@SpringBootApplication` main classes of the
|
|
/// modules of the build in `build` that apply the Spring Boot plugin:
|
|
/// `java -cp ${classpath} <class>` in the module, the classpath resolved at launch.
|
|
fn spring_boot_apps(root: &Path, build: &Path, out: &mut Vec<RunConfig>) {
|
|
let mut modules = vec![build.to_path_buf()];
|
|
let mut level = vec![build.to_path_buf()];
|
|
for _ in 0..ide_core::project_roots::SEARCH_DEPTH {
|
|
level = level.iter().flat_map(|d| subdirs(d)).filter(|d| !d.ends_with("src")).collect();
|
|
modules.extend(level.iter().cloned());
|
|
}
|
|
for module in modules {
|
|
if !applies_spring_boot(&module) {
|
|
continue;
|
|
}
|
|
// A budget per module: a large common library must not starve later app modules.
|
|
let mut budget = SPRING_SCAN_FILES;
|
|
let module_rel = (module != root).then(|| relative(root, &module).to_string_lossy().replace('\\', "/"));
|
|
for class in spring_boot_classes(&module, &mut budget) {
|
|
let simple = class.rsplit(['.', '$']).next().unwrap_or(&class).to_owned();
|
|
let id = match &module_rel {
|
|
Some(m) => format!("application:{m}:{class}"),
|
|
None => format!("application:{class}"),
|
|
};
|
|
let mut app =
|
|
config(id, simple, "spring-boot", "java", &["-cp", "${classpath}", &class]).debug("java-debug");
|
|
app.extras.module = Some(module_rel.clone().unwrap_or_default());
|
|
out.push(app);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The module's own build applies the Spring Boot plugin, and it has main sources.
|
|
fn applies_spring_boot(module: &Path) -> bool {
|
|
if !["java", "kotlin"].iter().any(|l| module.join("src/main").join(l).is_dir()) {
|
|
return false;
|
|
}
|
|
if let Some(pom) = read(&module.join("pom.xml")) {
|
|
return pom.contains("spring-boot-maven-plugin") && !pom.contains("<packaging>pom</packaging>");
|
|
}
|
|
GRADLE_BUILDS.iter().filter_map(|n| read(&module.join(n))).any(|text| {
|
|
text.lines().any(|l| {
|
|
let l = l.split("//").next().unwrap_or_default();
|
|
// The plugin id (`id("org.springframework.boot")`), not a dependency (`org.springframework.boot:…`).
|
|
["org.springframework.boot\"", "org.springframework.boot'"].iter().any(|p| l.contains(p))
|
|
&& !l.contains("apply false")
|
|
})
|
|
})
|
|
}
|
|
|
|
/// Binary names of the `@SpringBootApplication` classes with a `main` under
|
|
/// `src/main/{java,kotlin}`, from the shallowest packages that declare one (the
|
|
/// application class sits in the root package of the code it scans).
|
|
fn spring_boot_classes(module: &Path, budget: &mut usize) -> Vec<String> {
|
|
let mut found = Vec::new();
|
|
let mut level: Vec<PathBuf> =
|
|
["java", "kotlin"].iter().map(|l| module.join("src/main").join(l)).filter(|d| d.is_dir()).collect();
|
|
while !level.is_empty() && found.is_empty() && *budget > 0 {
|
|
let mut next = Vec::new();
|
|
for dir in &level {
|
|
let Ok(entries) = fs::read_dir(dir) else { continue };
|
|
let mut files = Vec::new();
|
|
for e in entries.filter_map(Result::ok) {
|
|
let path = e.path();
|
|
match e.file_type() {
|
|
Ok(t) if t.is_dir() => next.push(path),
|
|
Ok(t) if t.is_file() && path.extension().is_some_and(|x| x == "java" || x == "kt") => {
|
|
files.push(path)
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
files.sort();
|
|
for file in files {
|
|
if *budget == 0 {
|
|
break;
|
|
}
|
|
*budget -= 1;
|
|
if fs::metadata(&file).is_ok_and(|m| m.len() > 256 * 1024) {
|
|
continue;
|
|
}
|
|
if let Some(class) = read(&file).and_then(|src| spring_boot_class(&src, &file)) {
|
|
found.push(class);
|
|
}
|
|
}
|
|
}
|
|
next.sort();
|
|
level = next;
|
|
}
|
|
found
|
|
}
|
|
|
|
/// The class to launch for a source file declaring a `@SpringBootApplication` with a `main`.
|
|
pub(crate) fn spring_boot_class(src: &str, file: &Path) -> Option<String> {
|
|
let code: Vec<&str> = src.lines().map(|l| l.split("//").next().unwrap_or_default()).collect();
|
|
let code = code.join("\n");
|
|
let at = code.find("@SpringBootApplication")?;
|
|
let package = code
|
|
.lines()
|
|
.map(str::trim)
|
|
.find_map(|l| l.strip_prefix("package "))
|
|
.map(|p| p.trim_end_matches(';').trim().to_owned())
|
|
.filter(|p| !p.is_empty());
|
|
let qualify = |name: &str| match &package {
|
|
Some(p) => format!("{p}.{name}"),
|
|
None => name.to_owned(),
|
|
};
|
|
if file.extension().is_some_and(|x| x == "kt") {
|
|
// A top-level `fun main` compiles into the file facade (`AppKt`, or `@file:JvmName`).
|
|
if code.lines().any(|l| l.starts_with("fun main(")) {
|
|
let jvm_name = code
|
|
.lines()
|
|
.find_map(|l| l.trim().strip_prefix("@file:JvmName(")?.split('"').nth(1).map(str::to_owned));
|
|
let facade = jvm_name.unwrap_or_else(|| {
|
|
let stem = file.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
|
|
let mut chars = stem.chars();
|
|
let upper: String = chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default();
|
|
format!("{upper}Kt")
|
|
});
|
|
return Some(qualify(&facade));
|
|
}
|
|
if !code.contains("fun main(") {
|
|
return None;
|
|
}
|
|
} else if !code.contains("void main(") {
|
|
return None;
|
|
}
|
|
let name: String = code[at..]
|
|
.split_whitespace()
|
|
.skip_while(|t| *t != "class")
|
|
.nth(1)?
|
|
.chars()
|
|
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
|
|
.collect();
|
|
(!name.is_empty()).then(|| qualify(&name))
|
|
}
|
|
|
|
// ---------------------------------------------------------------- go
|
|
|
|
fn is_go_main_package(dir: &Path) -> bool {
|
|
let Ok(entries) = fs::read_dir(dir) else { return false };
|
|
entries.filter_map(Result::ok).any(|e| {
|
|
let name = e.file_name().to_string_lossy().into_owned();
|
|
name.ends_with(".go")
|
|
&& !name.ends_with("_test.go")
|
|
&& read(&e.path()).is_some_and(|src| {
|
|
src.lines()
|
|
.map(str::trim)
|
|
.find(|l| l.starts_with("package "))
|
|
.is_some_and(|l| l.trim_start_matches("package ").split_whitespace().next() == Some("main"))
|
|
})
|
|
})
|
|
}
|
|
|
|
fn detect_go(root: &Path, out: &mut Vec<RunConfig>) {
|
|
if let Some(work) = read(&root.join("go.work")) {
|
|
let mut seen = HashSet::new();
|
|
for member in crate::go::work_modules(&work) {
|
|
let dir = root.join(member);
|
|
let dir = dunce::canonicalize(&dir).unwrap_or(dir);
|
|
if dir.join("go.mod").is_file() && seen.insert(dir.clone()) {
|
|
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
|
let rel = dir.strip_prefix(&canonical_root).unwrap_or(&dir).to_path_buf();
|
|
detect_go_module(&dir, (!rel.as_os_str().is_empty()).then_some(rel), out);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
for dir in ide_core::project_roots::discover_matching(root, ide_core::project_roots::SEARCH_DEPTH, false, |dir| {
|
|
dir.join("go.mod").is_file()
|
|
}) {
|
|
let rel = relative(root, &dir);
|
|
detect_go_module(&dir, (!rel.as_os_str().is_empty()).then_some(rel), out);
|
|
}
|
|
}
|
|
|
|
/// Configs for the module in `dir`; `rel` is its path from the project root when nested.
|
|
fn detect_go_module(dir: &Path, rel: Option<PathBuf>, out: &mut Vec<RunConfig>) {
|
|
let label = rel.as_ref().map(|r| r.to_string_lossy().replace('\\', "/"));
|
|
let id = |base: &str| match &label {
|
|
Some(l) => format!("{base}:{l}"),
|
|
None => base.to_owned(),
|
|
};
|
|
let name = |base: &str| match &label {
|
|
Some(l) => format!("{l}: {base}"),
|
|
None => base.to_owned(),
|
|
};
|
|
if is_go_main_package(dir) {
|
|
out.push(config(id("go:run"), name("go run ."), "go", "go", &["run", "."]).in_dir(rel.clone()).debug("delve"));
|
|
}
|
|
for sub in subdirs(&dir.join("cmd")) {
|
|
if is_go_main_package(&sub) {
|
|
let cmd = sub.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
|
let target = format!("./cmd/{cmd}");
|
|
out.push(
|
|
config(id(&format!("go:run:{cmd}")), name(&format!("go run {target}")), "go", "go", &["run", &target])
|
|
.in_dir(rel.clone())
|
|
.debug("delve"),
|
|
);
|
|
}
|
|
}
|
|
out.push(
|
|
config(id("go:test"), name("go test ./..."), "go", "go", &["test", "./..."]).in_dir(rel).test().debug("delve"),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- python
|
|
|
|
/// How to invoke tools inside the project's Python environment.
|
|
enum PyEnv {
|
|
/// `uv run …`, `poetry run …`, `pdm run …`
|
|
Runner(&'static str),
|
|
/// A project-local virtualenv directory (relative to root).
|
|
Venv(String),
|
|
/// A pixi environment directory `.pixi/envs/<env>` (relative to root); tools
|
|
/// also run as `pixi run …`, the interpreter directly for the debugger.
|
|
Pixi(String),
|
|
System,
|
|
}
|
|
|
|
impl PyEnv {
|
|
fn detect(root: &Path) -> Self {
|
|
if root.join("uv.lock").is_file() {
|
|
return Self::Runner("uv");
|
|
}
|
|
if root.join("poetry.lock").is_file() {
|
|
return Self::Runner("poetry");
|
|
}
|
|
if root.join("pdm.lock").is_file() {
|
|
return Self::Runner("pdm");
|
|
}
|
|
for venv in [".venv", "venv"] {
|
|
if root.join(venv).join(Self::bin_dir()).join(Self::python_exe()).is_file() {
|
|
return Self::Venv(venv.to_owned());
|
|
}
|
|
}
|
|
if let Some(env) = Self::pixi_env(root) {
|
|
return Self::Pixi(env);
|
|
}
|
|
Self::System
|
|
}
|
|
|
|
/// The first pixi environment `.pixi/envs/<env>` holding a Python, relative
|
|
/// to the root, in name order.
|
|
fn pixi_env(root: &Path) -> Option<String> {
|
|
let envs = root.join(".pixi/envs");
|
|
let mut dirs: Vec<_> =
|
|
std::fs::read_dir(&envs).into_iter().flatten().flatten().map(|e| e.path()).filter(|d| d.is_dir()).collect();
|
|
dirs.sort_by_key(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default());
|
|
dirs.iter()
|
|
.find(|d| d.join(Self::python_exe()).is_file() || d.join("bin").join(Self::python_exe()).is_file())
|
|
.and_then(|d| d.strip_prefix(root).ok())
|
|
.map(|rel| rel.to_string_lossy().replace('\\', "/"))
|
|
}
|
|
|
|
fn bin_dir() -> &'static str {
|
|
if cfg!(windows) { "Scripts" } else { "bin" }
|
|
}
|
|
|
|
fn python_exe() -> &'static str {
|
|
if cfg!(windows) { "python.exe" } else { "python" }
|
|
}
|
|
|
|
/// `(command, leading args)` to run `tool`, e.g. `("uv", ["run", "pytest"])`.
|
|
fn tool(&self, root: &Path, tool: &str) -> (String, Vec<String>) {
|
|
match self {
|
|
Self::Runner(r) => ((*r).to_owned(), vec!["run".into(), tool.into()]),
|
|
Self::Venv(v) => {
|
|
let exe = if cfg!(windows) { format!("{tool}.exe") } else { tool.to_owned() };
|
|
let rel = format!("{v}/{}/{exe}", Self::bin_dir());
|
|
if root.join(&rel).is_file() {
|
|
(rel, Vec::new())
|
|
} else {
|
|
let py = format!("{v}/{}/{}", Self::bin_dir(), Self::python_exe());
|
|
(py, vec!["-m".into(), tool.into()])
|
|
}
|
|
}
|
|
Self::System => (tool.to_owned(), Vec::new()),
|
|
// A pixi env's executables sit in `bin` (Unix) or `Scripts` (Windows)
|
|
// like a venv's; else `pixi run` finds the tool.
|
|
Self::Pixi(v) => {
|
|
let exe = if cfg!(windows) { format!("{tool}.exe") } else { tool.to_owned() };
|
|
for bin in ["Scripts", "bin"] {
|
|
let rel = format!("{v}/{bin}/{exe}");
|
|
if root.join(&rel).is_file() {
|
|
return (rel, Vec::new());
|
|
}
|
|
}
|
|
("pixi".to_owned(), vec!["run".into(), tool.into()])
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether a project virtualenv has `tool` installed.
|
|
fn has_tool(&self, root: &Path, tool: &str) -> bool {
|
|
let Self::Venv(v) = self else { return false };
|
|
let exe = if cfg!(windows) { format!("{tool}.exe") } else { tool.to_owned() };
|
|
root.join(v).join(Self::bin_dir()).join(exe).is_file()
|
|
}
|
|
|
|
fn python(&self) -> (String, Vec<String>) {
|
|
match self {
|
|
Self::Runner(r) => ((*r).to_owned(), vec!["run".into(), "python".into()]),
|
|
Self::Venv(v) => (format!("{v}/{}/{}", Self::bin_dir(), Self::python_exe()), Vec::new()),
|
|
Self::Pixi(v) => (Self::pixi_python(v), Vec::new()),
|
|
Self::System => ((if cfg!(windows) { "python" } else { "python3" }).to_owned(), Vec::new()),
|
|
}
|
|
}
|
|
|
|
/// `<env>/python.exe` (Windows) or `<env>/bin/python` of a pixi environment.
|
|
fn pixi_python(env: &str) -> String {
|
|
if cfg!(windows) {
|
|
format!("{env}/{}", Self::python_exe())
|
|
} else {
|
|
format!("{env}/bin/{}", Self::python_exe())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The interpreter an imported debugger configuration runs with, as `(command, leading args)`:
|
|
/// a project virtualenv's `python` (what the debugger launches directly), else the detected
|
|
/// environment's (`uv run python`, `python3`). The command is relative to the root.
|
|
pub(crate) fn project_python(root: &Path) -> (String, Vec<String>) {
|
|
for venv in [".venv", "venv"] {
|
|
let rel = format!("{venv}/{}/{}", PyEnv::bin_dir(), PyEnv::python_exe());
|
|
if root.join(&rel).is_file() {
|
|
return (rel, Vec::new());
|
|
}
|
|
}
|
|
if let Some(env) = PyEnv::pixi_env(root) {
|
|
return (PyEnv::pixi_python(&env), Vec::new());
|
|
}
|
|
PyEnv::detect(root).python()
|
|
}
|
|
|
|
fn py_config(
|
|
id: String,
|
|
name: String,
|
|
kind: &str,
|
|
(command, mut args): (String, Vec<String>),
|
|
extra: &[&str],
|
|
) -> RunConfig {
|
|
args.extend(extra.iter().map(|a| (*a).to_owned()));
|
|
let mut c = config(id, name, kind, &command, &[]);
|
|
c.args = args;
|
|
c.debug("debugpy")
|
|
}
|
|
|
|
fn uses_pytest(root: &Path) -> bool {
|
|
if ["pytest.ini", "tox.ini", "conftest.py"].iter().any(|f| root.join(f).is_file()) {
|
|
return true;
|
|
}
|
|
if read(&root.join("setup.cfg")).is_some_and(|s| s.contains("[tool:pytest]")) {
|
|
return true;
|
|
}
|
|
if read(&root.join("pyproject.toml")).is_some_and(|py| py.contains("pytest") || root.join("tests").is_dir()) {
|
|
return true;
|
|
}
|
|
["requirements.txt", "requirements-dev.txt", "requirements_dev.txt", "dev-requirements.txt"]
|
|
.iter()
|
|
.any(|f| read(&root.join(f)).is_some_and(|s| s.contains("pytest")))
|
|
}
|
|
|
|
fn detect_python(root: &Path, out: &mut Vec<RunConfig>) {
|
|
let markers = ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "pytest.ini", "tox.ini", "manage.py"];
|
|
let env = PyEnv::detect(root);
|
|
// A project virtualenv or pixi environment marks a Python project too
|
|
// (scripts + tests without packaging files).
|
|
let venv = matches!(env, PyEnv::Venv(_) | PyEnv::Pixi(_));
|
|
if !venv && !markers.iter().any(|m| root.join(m).is_file()) {
|
|
return;
|
|
}
|
|
if root.join("manage.py").is_file() {
|
|
out.push(py_config(
|
|
"django:runserver".into(),
|
|
"manage.py runserver".into(),
|
|
"python",
|
|
env.python(),
|
|
&["manage.py", "runserver"],
|
|
));
|
|
}
|
|
let mut modules = Vec::new();
|
|
for base in [root.to_path_buf(), root.join("src")] {
|
|
for dir in subdirs(&base) {
|
|
let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
|
let ident = !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_');
|
|
if ident && !matches!(name.as_str(), "venv" | "tests" | "test") && dir.join("__main__.py").is_file() {
|
|
modules.push(name);
|
|
}
|
|
}
|
|
}
|
|
modules.sort();
|
|
modules.dedup();
|
|
for m in modules {
|
|
out.push(py_config(
|
|
format!("python:module:{m}"),
|
|
format!("python -m {m}"),
|
|
"python",
|
|
env.python(),
|
|
&["-m", &m],
|
|
));
|
|
}
|
|
if uses_pytest(root) || env.has_tool(root, "pytest") {
|
|
out.push(py_config("pytest".into(), "pytest".into(), "pytest", env.tool(root, "pytest"), &[]).test());
|
|
}
|
|
// After pytest: a project set up for pytest (pytest-django) keeps it as the default test runner.
|
|
if root.join("manage.py").is_file() {
|
|
out.push(
|
|
py_config(
|
|
"django:test".into(),
|
|
"manage.py test".into(),
|
|
"django-test",
|
|
env.python(),
|
|
&["manage.py", "test"],
|
|
)
|
|
.test(),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- php
|
|
|
|
/// Composer script names that are lifecycle events Composer fires itself, not commands to run.
|
|
fn is_composer_event(name: &str) -> bool {
|
|
name.starts_with("pre-") || name.starts_with("post-") || name == "command"
|
|
}
|
|
|
|
/// The PHPUnit entry point: the project's `vendor/bin/pest` or `vendor/bin/phpunit`, else a global `phpunit`.
|
|
fn phpunit_command(root: &Path) -> (String, &'static str) {
|
|
for (runner, label) in [("pest", "Pest"), ("phpunit", "PHPUnit")] {
|
|
let bin = format!("vendor/bin/{runner}{}", if cfg!(windows) { ".bat" } else { "" });
|
|
if root.join(&bin).is_file() {
|
|
return (bin, label);
|
|
}
|
|
}
|
|
("phpunit".to_owned(), "PHPUnit")
|
|
}
|
|
|
|
/// Composer projects (`composer.json`) and PHPUnit suites (`phpunit.xml`, `phpunit.xml.dist`).
|
|
fn detect_php(root: &Path, out: &mut Vec<RunConfig>) {
|
|
let composer = read_json(&root.join("composer.json"));
|
|
let phpunit_xml = first_existing(root, &["phpunit.xml", "phpunit.xml.dist", "phpunit.dist.xml"]);
|
|
if composer.is_none() && phpunit_xml.is_none() {
|
|
return;
|
|
}
|
|
let requires = |name: &str| {
|
|
composer.as_ref().is_some_and(|c| {
|
|
["require", "require-dev"]
|
|
.iter()
|
|
.any(|k| c.get(k).and_then(Json::as_object).is_some_and(|m| m.contains_key(name)))
|
|
})
|
|
};
|
|
if root.join("artisan").is_file() {
|
|
out.push(config("php:artisan-serve", "artisan serve", "php", "php", &["artisan", "serve"]).debug("php-debug"));
|
|
}
|
|
let scripts = composer.as_ref().and_then(|c| c.get("scripts")).and_then(Json::as_object);
|
|
for name in scripts.into_iter().flat_map(|m| m.keys()) {
|
|
if is_composer_event(name) {
|
|
continue;
|
|
}
|
|
let c = config(
|
|
format!("composer:{name}"),
|
|
format!("composer {name}"),
|
|
"composer",
|
|
"composer",
|
|
&["run-script", name],
|
|
);
|
|
out.push(if name == "test" { c.test() } else { c });
|
|
}
|
|
if phpunit_xml.is_some()
|
|
|| requires("phpunit/phpunit")
|
|
|| requires("pestphp/pest")
|
|
|| root.join("vendor/bin/phpunit").is_file()
|
|
|| root.join("vendor/bin/pest").is_file()
|
|
{
|
|
let (command, label) = phpunit_command(root);
|
|
let mut c = config("phpunit", label, "phpunit", &command, &[]).test().debug("php-debug");
|
|
// A configuration file with a non-default name must be passed explicitly.
|
|
let named = phpunit_xml.as_deref().and_then(Path::file_name).map(|n| n.to_string_lossy().into_owned());
|
|
if let Some(file) = named.filter(|f| f == "phpunit.dist.xml") {
|
|
c.args = vec!["--configuration".into(), file];
|
|
}
|
|
// First test configuration is the default for context runs; PHPUnit beats a generic `composer test`.
|
|
let at = out.iter().position(|o| o.is_test).unwrap_or(out.len());
|
|
out.insert(at, c);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------- ruby
|
|
|
|
/// Tasks a Rakefile defines statically: `task :name`, `task "name" => deps`,
|
|
/// `task name: [...]`, qualified by enclosing `namespace` blocks. `desc` lines are
|
|
/// skipped; a `Rake::TestTask` contributes `test`. `load`/`import`ed files are not followed.
|
|
pub(crate) fn rake_tasks(src: &str) -> Vec<String> {
|
|
let mut tasks = Vec::new();
|
|
let mut namespaces: Vec<String> = Vec::new();
|
|
let mut depth: Vec<bool> = Vec::new(); // per open block: whether it is a namespace
|
|
let name_of = |rest: &str| -> Option<String> {
|
|
let rest = rest.trim_start();
|
|
let (name, _) = if let Some(r) = rest.strip_prefix(':') {
|
|
r.split(|c: char| !(c.is_alphanumeric() || c == '_')).next().map(|n| (n.to_owned(), ()))?
|
|
} else if let Some(r) = rest.strip_prefix(['"', '\'']) {
|
|
r.split(['"', '\'']).next().map(|n| (n.to_owned(), ()))?
|
|
} else {
|
|
let n: String = rest.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect();
|
|
(!n.is_empty() && rest[n.len()..].starts_with(':')).then_some((n, ()))?
|
|
};
|
|
(!name.is_empty()).then_some(name)
|
|
};
|
|
for raw in src.lines() {
|
|
let line = raw.trim();
|
|
if line.starts_with('#') {
|
|
continue;
|
|
}
|
|
let opens = line.ends_with(" do") || line.contains(" do |") || line.contains(" do ") && !line.contains(" end");
|
|
if let Some(rest) = line.strip_prefix("namespace ")
|
|
&& let Some(ns) = name_of(rest)
|
|
{
|
|
namespaces.push(ns);
|
|
depth.push(true);
|
|
continue;
|
|
}
|
|
if let Some(rest) = line.strip_prefix("task ").or_else(|| line.strip_prefix("task("))
|
|
&& let Some(name) = name_of(rest)
|
|
{
|
|
let full = namespaces.iter().chain([&name]).cloned().collect::<Vec<_>>().join(":");
|
|
if !tasks.contains(&full) {
|
|
tasks.push(full);
|
|
}
|
|
}
|
|
if line.contains("Rake::TestTask.new") || line.contains("RSpec::Core::RakeTask.new") {
|
|
let name = name_of(line.split_once(".new").map(|(_, r)| r.trim_start_matches(['(', ' '])).unwrap_or(""))
|
|
.unwrap_or_else(|| if line.contains("RSpec") { "spec".into() } else { "test".into() });
|
|
let full = namespaces.iter().chain([&name]).cloned().collect::<Vec<_>>().join(":");
|
|
if !tasks.contains(&full) {
|
|
tasks.push(full);
|
|
}
|
|
}
|
|
if opens && !line.starts_with("namespace ") {
|
|
depth.push(false);
|
|
}
|
|
if (line == "end" || line.starts_with("end ") || line.starts_with("end#")) && depth.pop() == Some(true) {
|
|
namespaces.pop();
|
|
}
|
|
}
|
|
tasks
|
|
}
|
|
|
|
/// `bundle exec <tool>` when the project has a Gemfile, else the bare tool.
|
|
fn ruby_tool(root: &Path, tool: &str) -> (String, Vec<String>) {
|
|
if root.join("Gemfile").is_file() {
|
|
("bundle".into(), vec!["exec".into(), tool.into()])
|
|
} else {
|
|
(tool.into(), Vec::new())
|
|
}
|
|
}
|
|
|
|
fn ruby_config(
|
|
id: String,
|
|
name: String,
|
|
kind: &str,
|
|
(command, mut args): (String, Vec<String>),
|
|
extra: &[&str],
|
|
) -> RunConfig {
|
|
args.extend(extra.iter().map(|a| (*a).to_owned()));
|
|
let mut c = config(id, name, kind, &command, &[]);
|
|
c.args = args;
|
|
c
|
|
}
|
|
|
|
/// Loads the test files of the directories (or files) named in `ARGV`, default `test`, the way `rake test`
|
|
/// does; options such as `-v` stay in `ARGV` for Minitest.
|
|
const MINITEST_RUNNER: &str = r#"paths, opts = ARGV.partition { |a| !a.start_with?("-") }; ARGV.replace(opts); (paths.empty? ? ["test"] : paths).flat_map { |d| File.file?(d) ? [d] : Dir[File.join(d, "**", "{*_test,test_*}.rb")] }.sort.each { |f| require File.expand_path(f) }"#;
|
|
|
|
fn uses_rspec(root: &Path) -> bool {
|
|
root.join("spec").is_dir()
|
|
|| root.join(".rspec").is_file()
|
|
|| read(&root.join("Gemfile")).is_some_and(|g| g.contains("rspec"))
|
|
}
|
|
|
|
fn ruby_project(root: &Path) -> bool {
|
|
let markers = ["Gemfile", "Rakefile", "rakefile", "Rakefile.rb", ".ruby-version", "config.ru"];
|
|
let gemspec = fs::read_dir(root)
|
|
.into_iter()
|
|
.flatten()
|
|
.flatten()
|
|
.any(|e| e.path().extension().is_some_and(|x| x == "gemspec"));
|
|
gemspec || markers.iter().any(|m| root.join(m).is_file())
|
|
}
|
|
|
|
fn detect_ruby(root: &Path, out: &mut Vec<RunConfig>) {
|
|
if !ruby_project(root) {
|
|
return;
|
|
}
|
|
if root.join("bin/rails").is_file() {
|
|
let mut c = ruby_config(
|
|
"rails:server".into(),
|
|
"rails server".into(),
|
|
"ruby",
|
|
("ruby".into(), Vec::new()),
|
|
&["bin/rails", "server"],
|
|
);
|
|
c = c.debug("rdbg");
|
|
c.extras.folder = Some("Rails".into());
|
|
out.push(c);
|
|
}
|
|
if let Some(rakefile) = first_existing(root, &["Rakefile", "rakefile", "Rakefile.rb"])
|
|
&& let Some(src) = read(&rakefile)
|
|
{
|
|
for task in rake_tasks(&src) {
|
|
let mut c =
|
|
ruby_config(format!("rake:{task}"), format!("rake {task}"), "rake", ruby_tool(root, "rake"), &[&task]);
|
|
if task == "test" || task == "spec" || task.ends_with(":test") || task.ends_with(":spec") {
|
|
c = c.test();
|
|
}
|
|
c = c.debug("rdbg");
|
|
c.extras.folder = Some("Rake".into());
|
|
out.push(c);
|
|
}
|
|
}
|
|
if uses_rspec(root) {
|
|
out.push(
|
|
ruby_config("rspec".into(), "rspec".into(), "rspec", ruby_tool(root, "rspec"), &[]).test().debug("rdbg"),
|
|
);
|
|
} else if root.join("test").is_dir() && !out.iter().any(|c| c.id == "rake:test") {
|
|
// Minitest / Test::Unit without a `test` Rake task: load every test file under `test/`.
|
|
let mut args = Vec::new();
|
|
for dir in ["lib", "test"] {
|
|
if root.join(dir).is_dir() {
|
|
args.push(format!("-I{dir}"));
|
|
}
|
|
}
|
|
args.extend(["-e".to_owned(), MINITEST_RUNNER.to_owned(), "test".to_owned()]);
|
|
let extra: Vec<&str> = args.iter().map(String::as_str).collect();
|
|
out.push(
|
|
ruby_config("minitest".into(), "minitest".into(), "ruby", ruby_tool(root, "ruby"), &extra)
|
|
.test()
|
|
.debug("rdbg"),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- make / just
|
|
|
|
pub(crate) fn make_targets(src: &str) -> Vec<String> {
|
|
let mut targets = Vec::new();
|
|
let mut in_define = false;
|
|
for line in src.lines() {
|
|
let trimmed = line.trim();
|
|
if in_define {
|
|
if trimmed.starts_with("endef") {
|
|
in_define = false;
|
|
}
|
|
continue;
|
|
}
|
|
if trimmed.starts_with("define ") || trimmed == "define" {
|
|
in_define = true;
|
|
continue;
|
|
}
|
|
if line.starts_with('\t') || line.starts_with(' ') || trimmed.starts_with('#') {
|
|
continue;
|
|
}
|
|
let Some(colon) = line.find(':') else { continue };
|
|
let head = &line[..colon];
|
|
let after = &line[colon + 1..];
|
|
// Variable assignments: `A := b`, `A ::= b`, `A = b:c`, `A ?= b`.
|
|
if after.starts_with('=') || after.starts_with(":=") || head.contains('=') {
|
|
continue;
|
|
}
|
|
if head.starts_with("export ") || head.starts_with("override ") || head.starts_with("include ") {
|
|
continue;
|
|
}
|
|
for target in head.split_whitespace() {
|
|
let special = target.starts_with('.') || target.contains(['%', '$', '/', '\\', '(', ')']);
|
|
if !special && !targets.iter().any(|t| t == target) {
|
|
targets.push(target.to_owned());
|
|
}
|
|
}
|
|
}
|
|
targets
|
|
}
|
|
|
|
fn detect_make(root: &Path, out: &mut Vec<RunConfig>) {
|
|
let Some(path) = first_existing(root, &["GNUmakefile", "Makefile", "makefile"]) else { return };
|
|
let Some(src) = read(&path) else { return };
|
|
for target in make_targets(&src) {
|
|
let mut c = config(format!("make:{target}"), format!("make {target}"), "make", "make", &[&target]);
|
|
if matches!(target.as_str(), "test" | "check") {
|
|
c = c.test();
|
|
}
|
|
out.push(c);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn just_recipes(src: &str) -> Vec<String> {
|
|
let mut recipes = Vec::new();
|
|
let mut private_attr = false;
|
|
for line in src.lines() {
|
|
if line.starts_with([' ', '\t']) {
|
|
continue;
|
|
}
|
|
let trimmed = line.trim();
|
|
if trimmed.is_empty() || trimmed.starts_with('#') {
|
|
continue;
|
|
}
|
|
if trimmed.starts_with('[') {
|
|
private_attr |= trimmed.contains("private");
|
|
continue;
|
|
}
|
|
let is_private = std::mem::take(&mut private_attr);
|
|
let keyword = trimmed.split_whitespace().next().unwrap_or_default();
|
|
if matches!(keyword, "set" | "alias" | "export" | "import" | "mod" | "import?" | "mod?") {
|
|
continue;
|
|
}
|
|
let body = trimmed.trim_start_matches('@');
|
|
let name_len = body.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-')).unwrap_or(body.len());
|
|
let name = &body[..name_len];
|
|
if name.is_empty() || !name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
|
|
continue;
|
|
}
|
|
let rest = &body[name_len..];
|
|
let Some(colon) = rest.find(':') else { continue };
|
|
if rest[colon + 1..].starts_with('=')
|
|
|| rest[..colon].contains(":=")
|
|
|| rest[..colon].trim_start().starts_with('=')
|
|
{
|
|
continue;
|
|
}
|
|
if !is_private && !name.starts_with('_') && !recipes.iter().any(|r| r == name) {
|
|
recipes.push(name.to_owned());
|
|
}
|
|
}
|
|
recipes
|
|
}
|
|
|
|
// ---------------------------------------------------------------- godot
|
|
|
|
/// Godot projects (`project.godot`) at the root or one level down: "Godot Project"
|
|
/// runs the main scene (`godot --path .`), debuggable through the editor's adapter.
|
|
fn detect_godot(root: &Path, out: &mut Vec<RunConfig>) {
|
|
let dirs = ide_core::project_roots::discover_matching(root, ide_core::project_roots::SEARCH_DEPTH, false, |dir| {
|
|
dir.join("project.godot").is_file()
|
|
});
|
|
for dir in dirs {
|
|
let rel = relative(root, &dir);
|
|
let (id, name, cwd) = if rel.as_os_str().is_empty() {
|
|
("godot:project".to_owned(), "Godot Project".to_owned(), None)
|
|
} else {
|
|
let r = rel.to_string_lossy();
|
|
(format!("godot:project:{r}"), format!("Godot Project ({r})"), Some(rel.clone()))
|
|
};
|
|
out.push(config(id, name, "godot", "godot", &["--path", "."]).debug("godot").in_dir(cwd));
|
|
}
|
|
}
|
|
|
|
fn detect_just(root: &Path, out: &mut Vec<RunConfig>) {
|
|
let Some(path) = first_existing(root, &["justfile", "Justfile", ".justfile"]) else { return };
|
|
let Some(src) = read(&path) else { return };
|
|
for recipe in just_recipes(&src) {
|
|
let mut c = config(format!("just:{recipe}"), format!("just {recipe}"), "just", "just", &[&recipe]);
|
|
if recipe == "test" {
|
|
c = c.test();
|
|
}
|
|
out.push(c);
|
|
}
|
|
}
|