diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 453b1433f..38b676ca8 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -548,6 +548,7 @@ pub struct LanguagesSettings { pub typescript: TypeScriptSettings, pub prettier: PrettierSettings, pub java: JavaSettings, + pub ruby: RubySettings, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] @@ -605,6 +606,13 @@ impl Default for PythonSettings { } } +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct RubySettings { + /// Project interpreter executable; empty = auto-detect (`.ruby-version`, mise / asdf, else `ruby` on PATH). + pub interpreter: String, +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct NodeSettings { diff --git a/crates/ide-core/src/version_managers.rs b/crates/ide-core/src/version_managers.rs index 19e7cd4f8..30eeae097 100644 --- a/crates/ide-core/src/version_managers.rs +++ b/crates/ide-core/src/version_managers.rs @@ -18,6 +18,7 @@ pub enum Tool { Go, Maven, Gradle, + Ruby, } impl Tool { @@ -30,6 +31,7 @@ impl Tool { Tool::Go => &["go", "golang"], Tool::Maven => &["maven"], Tool::Gradle => &["gradle"], + Tool::Ruby => &["ruby"], } } } diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index ee48ab612..984c082a3 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -22,6 +22,7 @@ const BUILTIN: &[(&str, &str)] = &[ ("dockerfile", include_str!("../../../languages/dockerfile.toml")), ("dotenv", include_str!("../../../languages/dotenv.toml")), ("elixir", include_str!("../../../languages/elixir.toml")), + ("erb", include_str!("../../../languages/erb.toml")), ("erlang", include_str!("../../../languages/erlang.toml")), ("fsharp", include_str!("../../../languages/fsharp.toml")), ("gitattributes", include_str!("../../../languages/gitattributes.toml")), @@ -520,6 +521,7 @@ mod tests { "MsGenny", "Vue", "Angular Template", + "ERB", ]; fn reg() -> LanguageRegistry { @@ -776,6 +778,7 @@ mod tests { ("AppDelegate.m", "objective-c"), ("a.rb", "ruby"), ("Gemfile", "ruby"), + ("show.html.erb", "erb"), ("index.php", "php"), ("init.lua", "lua"), ("main.dart", "dart"), diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 0f56ad500..7e60b2fdd 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -33,6 +33,7 @@ pub(crate) fn detect_with_profiles( detect_maven(root, excluded_poms, &mut out); detect_go(root, &mut out); detect_python(root, &mut out); + detect_ruby(root, &mut out); cmake::detect(root, profiles, &mut out); dotnet::detect(root, &mut out); detect_make(root, &mut out); @@ -905,6 +906,133 @@ fn detect_python(root: &Path, out: &mut Vec) { } } +// ---------------------------------------------------------------------- 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 { + let mut tasks = Vec::new(); + let mut namespaces: Vec = Vec::new(); + let mut depth: Vec = Vec::new(); // per open block: whether it is a namespace + let name_of = |rest: &str| -> Option { + 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::>().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::>().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 ` when the project has a Gemfile, else the bare tool. +fn ruby_tool(root: &Path, tool: &str) -> (String, Vec) { + 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), + extra: &[&str], +) -> RunConfig { + args.extend(extra.iter().map(|a| (*a).to_owned())); + let mut c = config(id, name, kind, &command, &[]); + c.args = args; + c +} + +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 detect_ruby(root: &Path, out: &mut Vec) { + 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")); + if !gemspec && !markers.iter().any(|m| root.join(m).is_file()) { + 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.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.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()); + } +} + // ---------------------------------------------------------------- make / just pub(crate) fn make_targets(src: &str) -> Vec { diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 48db7c561..e207f2e35 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -728,3 +728,69 @@ fn cmake_profile_build_configures_only_without_a_cache() { } } } + +#[test] +fn ruby_projects_list_rake_tasks_rspec_and_rails() { + let rakefile = "\ +require 'rake/testtask' + +desc \"Build it\" +task :build do + sh 'make' +end +task \"lint\" => [:build] +task deploy: :build do |t| + puts t +end +namespace :db do + desc 'Migrate' + task :migrate, [:version] do |t, args| + puts args + end + namespace :schema do + task :load + end +end +Rake::TestTask.new do |t| + t.libs << 'test' +end +task default: :build +"; + let dir = project(&[ + ("Gemfile", "source 'https://rubygems.org'\ngem 'rspec'\n"), + ("Rakefile", rakefile), + ("bin/rails", "#!/usr/bin/env ruby\n"), + ("spec/a_spec.rb", ""), + ]); + let c = detect(dir.path()); + assert_eq!( + ids(&c), + [ + "rails:server", + "rake:build", + "rake:lint", + "rake:deploy", + "rake:db:migrate", + "rake:db:schema:load", + "rake:test", + "rake:default", + "rspec" + ] + ); + assert_eq!(cmdline(find(&c, "rails:server")), "ruby bin/rails server"); + assert_eq!(cmdline(find(&c, "rake:db:migrate")), "bundle exec rake db:migrate"); + assert_eq!(find(&c, "rake:db:migrate").kind, "rake"); + assert!(find(&c, "rake:test").is_test); + assert_eq!(cmdline(find(&c, "rspec")), "bundle exec rspec"); + assert!(find(&c, "rspec").is_test); + assert_eq!(find(&c, "rake:build").extras.folder.as_deref(), Some("Rake")); +} + +#[test] +fn ruby_without_bundler_runs_bare_tools() { + let dir = project(&[("Rakefile", "task :test\n")]); + let c = detect(dir.path()); + assert_eq!(ids(&c), ["rake:test"]); + assert_eq!(cmdline(&c[0]), "rake test"); + assert!(detect(project(&[("a.rb", "puts 1\n")]).path()).is_empty()); +} diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 3f368d469..0f9a937b2 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -36,6 +36,12 @@ pub fn python_interpreter(root: &Path, s: &Settings) -> Option { .or_else(|| pinned::pinned_python(root)) } +/// The project Ruby: the configured one, else the `.ruby-version` / mise / asdf +/// install, else `ruby` on PATH. +pub fn ruby_interpreter(root: &Path, s: &Settings) -> Option { + setting_path(root, &s.languages.ruby.interpreter).or_else(|| crate::ruby::project_ruby(root)) +} + /// The configured `node` executable's directory, else the `bin` of the Node.js the /// project pins for mise / asdf. pub(crate) fn node_dir(root: &Path, s: &Settings) -> Option { @@ -624,6 +630,7 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings let jdk = project_jdk(root, s); let mvn = maven_command(root, s); let mvn_options = maven_options(root, s); + let ruby = ruby_interpreter(root, s); for c in configs.iter_mut() { resolve_run_command(c, root, s); @@ -709,6 +716,26 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings c.env.entry(key).or_insert(value); } } + "ruby" | "rake" | "rspec" => { + if let Some(ruby) = &ruby + && let Some(bin) = crate::ruby::bin_dir_of(ruby) + { + // `ruby`, `bundle`, `rake`, `rspec` of the chosen interpreter when installed + // alongside it; the rest (a project `bin/rspec` binstub) stays as is. + let name = Path::new(&c.command).file_name().map(|n| n.to_string_lossy().into_owned()); + if let Some(name) = name + && matches!(name.as_str(), "ruby" | "bundle" | "rake" | "rspec" | "gem" | "irb") + && !c.command.contains('/') + && !c.command.contains('\\') + { + let exe = bin.join(exe(&name)); + if exe.is_file() { + c.command = exe.to_string_lossy().into_owned(); + } + } + path_dirs.push(bin); + } + } "maven" | "tomcat" => { if let Some(jdk) = &jdk { c.env.entry("JAVA_HOME".into()).or_insert_with(|| jdk.home.to_string_lossy().into_owned()); diff --git a/crates/ide-sdk/src/discover.rs b/crates/ide-sdk/src/discover.rs index a9dec42e5..425795bdb 100644 --- a/crates/ide-sdk/src/discover.rs +++ b/crates/ide-sdk/src/discover.rs @@ -21,6 +21,7 @@ pub fn detect(kind: SdkKind, root: Option<&Path>) -> Vec { SdkKind::TypeScript => typescript(root), SdkKind::Jdk => jdk(), SdkKind::Maven => maven(), + SdkKind::Ruby => crate::ruby::detect(root), }; if let Some(root) = root { pinned_first(&mut out, kind, root); @@ -35,6 +36,7 @@ fn tool_of(kind: SdkKind) -> Option { SdkKind::Go => Some(Tool::Go), SdkKind::Jdk => Some(Tool::Java), SdkKind::Maven => Some(Tool::Maven), + SdkKind::Ruby => Some(Tool::Ruby), SdkKind::PackageManager | SdkKind::Rust | SdkKind::TypeScript => None, } } @@ -50,7 +52,7 @@ fn pinned_first(sdks: &mut Vec, kind: SdkKind, root: &Path) { } /// Runs `f` over `items` on scoped threads (version queries spawn processes). -fn par_map(items: &[T], f: impl Fn(&T) -> Option + Sync) -> Vec { +pub(crate) fn par_map(items: &[T], f: impl Fn(&T) -> Option + Sync) -> Vec { std::thread::scope(|s| { let handles: Vec<_> = items.iter().map(|item| s.spawn(|| f(item))).collect(); handles.into_iter().filter_map(|h| h.join().ok().flatten()).collect() @@ -227,7 +229,7 @@ fn pyvenv_version(python: &Path) -> Option { }) } -fn subdirs(dir: &Path) -> Vec { +pub(crate) fn subdirs(dir: &Path) -> Vec { let mut out: Vec = std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect(); out.sort(); diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs index ac98b5716..79ccfda67 100644 --- a/crates/ide-sdk/src/lib.rs +++ b/crates/ide-sdk/src/lib.rs @@ -19,6 +19,7 @@ pub mod lombok; mod npm; pub mod pinned; pub mod python; +pub mod ruby; mod vue; use serde::{Deserialize, Serialize}; @@ -42,6 +43,7 @@ pub enum SdkKind { Jdk, /// Maven homes. Maven, + Ruby, } /// One installed interpreter / toolchain. diff --git a/crates/ide-sdk/src/libraries.rs b/crates/ide-sdk/src/libraries.rs index bdc450b7e..827e42ec2 100644 --- a/crates/ide-sdk/src/libraries.rs +++ b/crates/ide-sdk/src/libraries.rs @@ -54,6 +54,7 @@ const JAVA_MANIFESTS: &[&str] = const NODE_MANIFESTS: &[&str] = &["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb"]; const CARGO_MANIFESTS: &[&str] = &["Cargo.toml", "Cargo.lock", "rust-toolchain", "rust-toolchain.toml"]; const GO_MANIFESTS: &[&str] = &["go.mod", "go.sum", "go.work"]; +const RUBY_MANIFESTS: &[&str] = &["Gemfile", "Gemfile.lock", ".ruby-version", ".tool-versions", "mise.toml"]; /// A Maven / Gradle dependency of the project, from the build model. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -70,7 +71,7 @@ pub struct BuildDependency { /// `dependencies` (from the build model) are listed when their jars are in the local repositories. pub fn project_libraries(root: &Path, settings: &Settings, dependencies: &[BuildDependency]) -> Vec { type Detect = fn(&Path, &Settings) -> Vec; - let detectors: [Detect; 5] = [python_groups, jdk_groups, node_groups, rust_groups, go_groups]; + let detectors: [Detect; 6] = [python_groups, jdk_groups, node_groups, rust_groups, go_groups, ruby_groups]; std::thread::scope(|s| { let handles: Vec<_> = detectors.iter().map(|d| s.spawn(move || d(root, settings))).collect(); let deps = s.spawn(|| dependency_groups(root, settings, dependencies)); @@ -193,6 +194,24 @@ pub(crate) fn named_roots(roots: Vec<(String, PathBuf)>) -> Vec { .collect() } +// ------------------------------------------------------------------ ruby + +fn ruby_groups(root: &Path, s: &Settings) -> Vec { + let Some(files) = manifests(root, RUBY_MANIFESTS) else { return Vec::new() }; + if !root.join("Gemfile").is_file() && !root.join(".ruby-version").is_file() { + return Vec::new(); + } + let Some(ruby) = crate::apply::ruby_interpreter(root, s) else { return Vec::new() }; + cached(root, "ruby", ruby.display().to_string(), &files, || { + let name = crate::ruby::ruby_sdk(&ruby, "system").map(|s| s.name).unwrap_or_else(|| "Ruby".into()); + let (stdlib, gems) = crate::ruby::library_roots(&ruby, Some(root)); + let mut out = Vec::new(); + out.extend(group(name, "ruby", named_roots(stdlib))); + out.extend(group("Gems", "gems", named_roots(gems))); + out + }) +} + // ------------------------------------------------------------------ jdk fn jdk_groups(root: &Path, s: &Settings) -> Vec { diff --git a/crates/ide-sdk/src/ruby.rs b/crates/ide-sdk/src/ruby.rs new file mode 100644 index 000000000..b7ce8df4b --- /dev/null +++ b/crates/ide-sdk/src/ruby.rs @@ -0,0 +1,237 @@ +//! Ruby interpreters: the ones a version manager installed (rbenv, rvm, chruby, +//! mise, asdf), Homebrew's, and the ones on `PATH`; the project's `.ruby-version`; +//! and the interpreter's standard library and Bundler gems for External Libraries. + +use crate::discover::{par_map, subdirs}; +use crate::exec::{executables_in, query, query_in, search_path}; +use crate::{Sdk, exe, home_dir, pinned}; +use ide_core::version_managers::{self, Tool}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const VERSION_TIMEOUT: Duration = Duration::from_secs(4); +const QUERY_TIMEOUT: Duration = Duration::from_secs(20); + +fn var_path(key: &str) -> Option { + ide_core::process::host_var_os(key).filter(|v| !v.is_empty()).map(PathBuf::from) +} + +/// Where version managers keep one directory per Ruby, with the manager's name. +fn manager_roots() -> Vec<(PathBuf, &'static str)> { + let mut out = Vec::new(); + let Some(home) = home_dir() else { return out }; + out.push((var_path("RBENV_ROOT").unwrap_or_else(|| home.join(".rbenv")).join("versions"), "rbenv")); + out.push((var_path("rvm_path").unwrap_or_else(|| home.join(".rvm")).join("rubies"), "rvm")); + out.push((PathBuf::from("/usr/local/rvm/rubies"), "rvm")); + out.push((home.join(".rubies"), "chruby")); + out.push((PathBuf::from("/opt/rubies"), "chruby")); + out +} + +/// `bin/ruby` of a Ruby install directory. +pub(crate) fn ruby_in_install(dir: &Path) -> Option { + let p = dir.join("bin").join(exe("ruby")); + p.is_file().then_some(p) +} + +/// Installed Rubies: version-manager installs, Homebrew, then `PATH` (shims excluded). +pub(crate) fn detect(root: Option<&Path>) -> Vec { + let mut found: Vec<(PathBuf, &'static str)> = Vec::new(); + for (dir, manager) in manager_roots() { + found.extend(subdirs(&dir).into_iter().filter_map(|v| ruby_in_install(&v)).map(|p| (p, manager))); + } + for (dir, manager) in version_managers::installs(Tool::Ruby) { + found.extend(ruby_in_install(&dir).map(|p| (p, manager))); + } + for brew in ["/opt/homebrew/opt/ruby", "/usr/local/opt/ruby", "/home/linuxbrew/.linuxbrew/opt/ruby"] { + found.extend(ruby_in_install(Path::new(brew)).map(|p| (p, "homebrew"))); + } + let dirs = std::env::split_paths(&search_path()) + .filter(|d| !is_shims_dir(d) && !version_managers::is_shims_dir(d)) + .collect::>(); + found.extend(executables_in("ruby", dirs.into_iter()).into_iter().map(|p| (p, "system"))); + + let mut seen = HashSet::new(); + let candidates: Vec<(PathBuf, &'static str)> = found + .into_iter() + .filter(|(p, _)| p.is_file() && seen.insert(p.canonicalize().unwrap_or_else(|_| p.clone()))) + .collect(); + let mut out = par_map(&candidates, |(path, source)| ruby_sdk(path, source)); + if let Some(root) = root + && let Some(pinned) = pinned_ruby_version(root) + { + // The `.ruby-version` install first, like the project virtualenv for Python. + if let Some(i) = out.iter().position(|s| version_matches(s.version.as_deref().unwrap_or(""), &pinned)) { + let sdk = out.remove(i); + out.insert(0, sdk); + } + } + out +} + +/// rbenv's and rvm's shim directories, which only forward to an install. +fn is_shims_dir(dir: &Path) -> bool { + dir.ends_with(".rbenv/shims") || dir.ends_with(".rvm/bin") +} + +/// `ruby 3.3.1 (2024-04-23 revision …) [arm64-darwin23]` → `3.3.1`. +pub(crate) fn parse_version(out: &str) -> Option { + out.strip_prefix("ruby ")? + .split_whitespace() + .next() + .map(|v| v.trim_end_matches(|c: char| c.is_ascii_alphabetic() || c == '-').to_owned()) +} + +pub(crate) fn ruby_sdk(path: &Path, source: &str) -> Option { + let version = query(path, &["--version"], VERSION_TIMEOUT).and_then(|v| parse_version(&v))?; + let short = version.split('.').take(2).collect::>().join("."); + let name = match source { + "system" => format!("Ruby {short}"), + tool => format!("Ruby {short} ({tool})"), + }; + Some(Sdk { + path: path.into(), + name, + version: Some(version), + source: source.to_owned(), + home: path.parent().and_then(Path::parent).map(Path::to_path_buf).map(Into::into), + extra: None, + }) +} + +/// The version `.ruby-version` pins (rbenv / rvm / chruby read it; `ruby-3.3.1` → `3.3.1`), +/// from `root` up, else the mise / asdf pin. +pub fn pinned_ruby_version(root: &Path) -> Option { + let from_file = root.ancestors().find_map(|dir| { + let text = std::fs::read_to_string(dir.join(".ruby-version")).ok()?; + let v = text.lines().map(str::trim).find(|l| !l.is_empty() && !l.starts_with('#'))?; + Some(v.strip_prefix("ruby-").unwrap_or(v).to_owned()) + }); + from_file.or_else(|| pinned::pinned_version(root, Tool::Ruby)) +} + +/// Whether `version` (`3.3.1`) satisfies a pin (`3.3.1`, `3.3`, `3`). +pub(crate) fn version_matches(version: &str, pin: &str) -> bool { + version == pin || version.strip_prefix(pin).is_some_and(|rest| rest.starts_with('.')) +} + +/// The Ruby for `root` without an explicit setting: the install matching +/// `.ruby-version` (version managers, then mise / asdf), else `ruby` on PATH. +pub fn project_ruby(root: &Path) -> Option { + if let Some(pin) = pinned_ruby_version(root) { + let install = manager_roots() + .into_iter() + .flat_map(|(dir, _)| subdirs(&dir)) + .chain(version_managers::installs(Tool::Ruby).into_iter().map(|(d, _)| d)) + .find(|dir| { + let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + version_matches(name.strip_prefix("ruby-").unwrap_or(&name), &pin) + }); + if let Some(ruby) = install.and_then(|d| ruby_in_install(&d)) { + return Some(ruby); + } + } + crate::exec::which("ruby") +} + +/// `bin` directory of `ruby` and the gems it was installed with (`gem` and `bundle` next to it). +pub fn bin_dir_of(ruby: &Path) -> Option { + ruby.parent().map(Path::to_path_buf) +} + +type NamedRoots = Vec<(String, PathBuf)>; + +/// Standard-library / site directories (`ruby/3.3.0`) and the gems Bundler resolves +/// for `root` (`rails-7.1.3`), each as `(name, path)`. +pub fn library_roots(ruby: &Path, root: Option<&Path>) -> (NamedRoots, NamedRoots) { + const PATHS: &str = "require 'rbconfig'; puts RbConfig::CONFIG['rubylibdir'], RbConfig::CONFIG['sitelibdir'], RbConfig::CONFIG['vendorlibdir']"; + let mut stdlib = Vec::new(); + let mut gems = Vec::new(); + let file_name = |p: &Path| p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + if let Some(text) = query(ruby, &["-e", PATHS], QUERY_TIMEOUT) { + for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) { + let path = PathBuf::from(line); + if path.is_dir() && !stdlib.iter().any(|(_, p)| *p == path) { + let parent = path.parent().map(file_name).unwrap_or_default(); + stdlib.push((format!("{parent}/{}", file_name(&path)), path)); + } + } + } + if let Some(root) = root.filter(|r| r.join("Gemfile").is_file()) { + let bin = bin_dir_of(ruby); + let bundle = bin.as_ref().map(|b| b.join(exe("bundle"))).filter(|b| b.is_file()); + let path = bin.as_ref().map(|b| crate::exec::path_with(&[b.as_path()])).unwrap_or_default(); + let env: &[(&str, &str)] = &[("PATH", &path)]; + let text = match &bundle { + Some(b) => query_in(b, &["list", "--paths"], Some(root), env, QUERY_TIMEOUT), + None => query_in(ruby, &["-S", "bundle", "list", "--paths"], Some(root), env, QUERY_TIMEOUT), + }; + for line in text.unwrap_or_default().lines().map(str::trim).filter(|l| !l.is_empty()) { + let path = PathBuf::from(line); + if path.is_dir() && path != root { + gems.push((file_name(&path), path)); + } + } + } + (stdlib, gems) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn includes_bundled_gems_installed_inside_the_project() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("project"); + let gem = root.join("vendor/bundle/ruby/4.0.0/gems/rake-13.4.0"); + let external = dir.path().join("gems/rspec-3.13.2"); + let bin = dir.path().join("bin"); + for path in [&gem, &external, &bin] { + std::fs::create_dir_all(path).unwrap(); + } + std::fs::write(root.join("Gemfile"), "gem 'rake'\ngem 'rspec'\n").unwrap(); + for (name, output) in [ + ("ruby", String::new()), + ("bundle", format!("{}\n{}\n{}", gem.display(), external.display(), root.display())), + ] { + let path = bin.join(name); + std::fs::write(&path, format!("#!/bin/sh\nprintf '%s\\n' '{output}'\n")).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + let (_, gems) = library_roots(&bin.join("ruby"), Some(&root)); + assert_eq!(gems, vec![("rake-13.4.0".into(), gem), ("rspec-3.13.2".into(), external)]); + } + + #[test] + fn parses_ruby_version_output() { + assert_eq!( + parse_version("ruby 3.3.1 (2024-04-23 revision c56cd86388) [arm64-darwin23]").as_deref(), + Some("3.3.1") + ); + assert_eq!(parse_version("ruby 3.4.0dev (2024-10-01) +PRISM [x86_64-linux]").as_deref(), Some("3.4.0")); + assert_eq!(parse_version("jruby 9.4.5.0"), None); + } + + #[test] + fn version_pins_match_prefixes() { + assert!(version_matches("3.3.1", "3.3.1")); + assert!(version_matches("3.3.1", "3.3")); + assert!(!version_matches("3.31.1", "3.3")); + assert!(!version_matches("3.3.1", "3.3.10")); + } + + #[test] + fn ruby_version_file_wins_over_managers() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("app/sub"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(dir.path().join("app/.ruby-version"), "ruby-3.2.2\n").unwrap(); + assert_eq!(pinned_ruby_version(&project).as_deref(), Some("3.2.2")); + } +} diff --git a/languages/erb.toml b/languages/erb.toml new file mode 100644 index 000000000..50cfd6d0e --- /dev/null +++ b/languages/erb.toml @@ -0,0 +1,6 @@ +id = "erb" +name = "ERB" +extensions = ["erb", "html.erb", "rhtml", "js.erb", "text.erb", "xml.erb", "json.erb"] +block-comment = ["<%#", "%>"] +indent = { tab-size = 2, insert-spaces = true } +ui-mode = "ERB" diff --git a/src/bindings.ts b/src/bindings.ts index 13367ac86..7f3e69b7c 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -4098,6 +4098,7 @@ export type LanguagesSettings = { typescript?: TypeScriptSettings, prettier?: PrettierSettings, java?: JavaSettings, + ruby?: RubySettings, }; /** What a freshly opened window should do once its frontend is up. */ @@ -5325,6 +5326,11 @@ export type RevisionChange = { export type RootKind = "source" | "test" | "resources" | "testResources" | "excluded"; +export type RubySettings = { + /** Project interpreter executable; empty = auto-detect (`.ruby-version`, mise / asdf, else `ruby` on PATH). */ + interpreter?: string, +}; + export type RouteSource = { path: string, text: string, @@ -5538,7 +5544,7 @@ export type SdkKind = "python" | "node" | /** JDK homes. */ "jdk" | /** Maven homes. */ -"maven"; +"maven" | "ruby"; /** * IDEA's search context: matches anywhere, only in, or outside comments and diff --git a/src/editor/core/cm/erb.test.ts b/src/editor/core/cm/erb.test.ts new file mode 100644 index 000000000..2d70528e5 --- /dev/null +++ b/src/editor/core/cm/erb.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { ensureSyntaxTree, syntaxTree } from "@codemirror/language"; +import { classHighlighter, highlightTree } from "@lezer/highlight"; +import { findLanguageDescription, loadSupport } from "./languages"; + +async function tokens(source: string) { + const desc = findLanguageDescription("ERB", "show.html.erb")!; + const support = await loadSupport(desc); + const state = EditorState.create({ doc: source, extensions: [support!] }); + ensureSyntaxTree(state, source.length, 1000); + const result: { text: string; style: string }[] = []; + highlightTree(syntaxTree(state), classHighlighter, (from, to, style) => + result.push({ text: source.slice(from, to), style }), + ); + return result; +} + +describe("ERB mode", () => { + it("highlights HTML outside and Ruby inside the tags", async () => { + const out = await tokens('
<%= user.name if user %>
\n<%# note %>\n<% 42 %>'); + expect(out).toContainEqual(expect.objectContaining({ text: "div", style: "tok-typeName" })); + expect(out).toContainEqual(expect.objectContaining({ text: "if", style: "tok-keyword" })); + expect(out).toContainEqual(expect.objectContaining({ text: "42", style: "tok-number" })); + expect(out).toContainEqual(expect.objectContaining({ text: "<%# note %>", style: "tok-comment" })); + expect(out.filter((t) => t.text === "<%=" || t.text === "%>")).toHaveLength(3); + }); + + it("keeps Ruby state across lines", async () => { + const out = await tokens('<% s = "a\n

" %>

'); + expect(out.filter((t) => t.text === "p")).toHaveLength(1); + }); +}); diff --git a/src/editor/core/cm/erb.ts b/src/editor/core/cm/erb.ts new file mode 100644 index 000000000..8e8c3f3cf --- /dev/null +++ b/src/editor/core/cm/erb.ts @@ -0,0 +1,78 @@ +/** + * ERB (Embedded Ruby) editor mode: HTML outside `<% … %>`, Ruby inside. Each + * region is tokenized by its own legacy stream mode on a sub-stream bounded at + * the next delimiter, so neither mode sees the other's text. + */ +import { LanguageSupport, StreamLanguage, StringStream, type StreamParser } from "@codemirror/language"; +import { tags } from "@lezer/highlight"; + +type State = { html: H; ruby: R; inRuby: boolean; indentUnit: number }; + +const OPEN = /<%(?:==|=|-|#)?/; +const CLOSE = /-?%>/; + +export function erbParser(html: StreamParser, ruby: StreamParser): StreamParser> { + const copy = (base: StreamParser, state: S): S => + base.copyState + ? base.copyState(state) + : (Object.fromEntries( + Object.entries(state as object).map(([k, v]) => [k, Array.isArray(v) ? v.slice() : v]), + ) as S); + + /** Runs `base` on `stream` cut at `end`, then advances `stream` to where it stopped. */ + const within = (stream: StringStream, end: number, base: StreamParser, state: S): string | null => { + const sub = new StringStream(stream.string.slice(0, end), 4, stream.indentUnit); + sub.pos = stream.pos; + sub.start = stream.start; + const style = base.token(sub, state); + stream.pos = Math.max(sub.pos, stream.pos + (sub.pos === stream.pos ? 1 : 0)); + return style; + }; + + return { + name: "erb", + languageData: { ...html.languageData, commentTokens: { block: { open: "<%#", close: "%>" } } }, + tokenTable: { ...html.tokenTable, ...ruby.tokenTable, erbDelimiter: tags.special(tags.brace) }, + startState: (indentUnit) => ({ + html: html.startState!(indentUnit), + ruby: ruby.startState!(indentUnit), + inRuby: false, + indentUnit, + }), + copyState: (s) => ({ ...s, html: copy(html, s.html), ruby: copy(ruby, s.ruby) }), + blankLine: (s, unit) => (s.inRuby ? ruby.blankLine?.(s.ruby, unit) : html.blankLine?.(s.html, unit)), + indent: (s, text, cx) => (s.inRuby ? ruby.indent?.(s.ruby, text, cx) : html.indent?.(s.html, text, cx)) ?? null, + token(stream, state) { + const rest = stream.string.slice(stream.pos); + if (!state.inRuby) { + const open = OPEN.exec(rest); + if (open?.index === 0) { + stream.pos += open[0].length; + if (open[0] === "<%#") { + const close = CLOSE.exec(stream.string.slice(stream.pos)); + stream.pos = close ? stream.pos + close.index + close[0].length : stream.string.length; + return "comment"; + } + state.inRuby = true; + return "erbDelimiter"; + } + return within(stream, open ? stream.pos + open.index : stream.string.length, html, state.html); + } + const close = CLOSE.exec(rest); + if (close?.index === 0) { + stream.pos += close[0].length; + state.inRuby = false; + return "erbDelimiter"; + } + return within(stream, close ? stream.pos + close.index : stream.string.length, ruby, state.ruby); + }, + }; +} + +export async function erb(): Promise { + const [{ html }, { ruby }] = await Promise.all([ + import("@codemirror/legacy-modes/mode/xml"), + import("@codemirror/legacy-modes/mode/ruby"), + ]); + return new LanguageSupport(StreamLanguage.define(erbParser(html, ruby))); +} diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index 9be9fbe34..f6c01d560 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -96,6 +96,12 @@ const OWN_MODES = [ }), ), LanguageDescription.of({ name: "Angular Template", load: () => import("./angular").then((m) => m.angular()) }), + LanguageDescription.of({ + name: "ERB", + alias: ["erb", "rhtml"], + extensions: ["erb", "rhtml"], + load: () => import("./erb").then((m) => m.erb()), + }), ]; const BATCH_KEYWORDS = new Set([ diff --git a/src/features/libraries/index.ts b/src/features/libraries/index.ts index 622f88cca..350507ba9 100644 --- a/src/features/libraries/index.ts +++ b/src/features/libraries/index.ts @@ -26,6 +26,8 @@ const GROUP_ICONS: Record = { goModules: "file-gomod", maven: "maven-project", gradle: "file-gradle", + ruby: "file-ruby", + gems: "file-ruby", }; /** An archive's root (`/jdk/lib/src.zip!`): its entries live below `!/`. */ diff --git a/src/features/run/configs.test.ts b/src/features/run/configs.test.ts index 162e0adf8..d27eb2655 100644 --- a/src/features/run/configs.test.ts +++ b/src/features/run/configs.test.ts @@ -152,6 +152,8 @@ describe("context configuration", () => { cfg({ id: "pytest", kind: "pytest", command: "uv", args: ["run", "pytest"], isTest: true }), cfg({ id: "python:module:app", kind: "python", command: ".venv/bin/python", args: ["-m", "app"] }), cfg({ id: "gradle:test", kind: "gradle", command: "./gradlew", args: ["test"], isTest: true }), + cfg({ id: "rspec", kind: "rspec", command: "bundle", args: ["exec", "rspec"], isTest: true }), + cfg({ id: "rake:test", kind: "rake", command: "bundle", args: ["exec", "rake", "test"], isTest: true }), ]; it("classifies files", () => { @@ -163,6 +165,9 @@ describe("context configuration", () => { expect(fileRole("polls/tests.py")).toBe("script"); expect(fileRole("src/test/kotlin/OrderControllerTest.kt")).toBe("test"); expect(fileRole("tests/integration.rs")).toBe("test"); + expect(fileRole("spec/models/user_spec.rb")).toBe("test"); + expect(fileRole("test/user_test.rb")).toBe("test"); + expect(fileRole("lib/tasks/gen.rb")).toBe("script"); expect(fileRole("src/main.rs")).toBe("main"); expect(fileRole("cmd/main.go", "package main\nfunc main() {}")).toBe("main"); expect(fileRole("tools/gen.py")).toBe("script"); @@ -184,6 +189,20 @@ describe("context configuration", () => { expect(contextConfig("src/a.spec.js", configs)!.args).toEqual(["run", "test", "--", "src/a.spec.js"]); }); + it("narrows rspec and rake test runs to the file", () => { + expect(contextConfig("spec/models/user_spec.rb", configs)!.args).toEqual([ + "exec", + "rspec", + "spec/models/user_spec.rb", + ]); + expect(contextConfig("test/user_test.rb", configs)!.args).toEqual([ + "exec", + "rake", + "test", + "TEST=test/user_test.rb", + ]); + }); + it("narrows go, cargo, pytest and gradle runs", () => { expect(contextConfig("internal/calc/calc_test.go", configs)!.args).toEqual(["test", "./internal/calc"]); expect(contextConfig("tests/api.rs", configs)!.args).toEqual(["test", "--test", "api"]); diff --git a/src/features/run/context.ts b/src/features/run/context.ts index c71bf6e6d..2e7d9e554 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -19,6 +19,7 @@ export function fileRole(relPath: string, content?: string, windows = isWindows( /^test_.*\.py$|_test\.py$/.test(name) || (django && name === "tests.py") || /_test\.go$/.test(name) || + /_(spec|test)\.rb$/.test(name) || /(Test|Tests|Spec|IT)\.(kt|java|scala|groovy)$/.test(name) || (/(^|\/)tests\/[^/]+\.rs$/.test(relPath) && name !== "mod.rs") ) @@ -27,7 +28,7 @@ export function fileRole(relPath: string, content?: string, windows = isWindows( if (/\.go$/.test(name) && content !== undefined && /^\s*package\s+main\b/m.test(content)) return "main"; if (/\.(kt|java)$/.test(name) && content !== undefined && /\bfun\s+main\s*\(|static\s+void\s+main\s*\(/.test(content)) return "main"; - if (/\.(py|sh|bash|zsh|[cm]?js|ts)$/.test(name)) return "script"; + if (/\.(py|rb|sh|bash|zsh|[cm]?js|ts)$/.test(name)) return "script"; if (windows && /\.(bat|cmd)$/i.test(name)) return "script"; return null; } @@ -39,6 +40,8 @@ const stem = (p: string) => (p.split("/").pop() ?? p).replace(/\.[^.]+$/, ""); function testKinds(relPath: string): string[] { if (/\.[cm]?[jt]sx?$/.test(relPath)) return ["npm", "deno", "bun"]; if (/\.py$/.test(relPath)) return ["pytest", "python", "django-test"]; + if (/_spec\.rb$/.test(relPath)) return ["rspec", "rake"]; + if (/_test\.rb$/.test(relPath)) return ["rake", "rspec"]; if (/\.go$/.test(relPath)) return ["go"]; if (/\.rs$/.test(relPath)) return ["cargo"]; if (/\.(kt|java|scala|groovy)$/.test(relPath)) return ["gradle", "maven"]; @@ -136,7 +139,9 @@ function implicitPytest(relPath: string, configs: RunConfig[]): RunConfig | null export function testBasesFor(relPath: string, configs: RunConfig[]): RunConfig[] { const kinds = testKinds(relPath); // Configurations produced from a test or file are results of narrowing, never bases for it. - const candidates = configs.filter((c) => c.isTest && kinds.includes(c.kind) && !c.id.startsWith("context:")); + const candidates = configs + .filter((c) => c.isTest && kinds.includes(c.kind) && !c.id.startsWith("context:")) + .sort((a, b) => kinds.indexOf(a.kind) - kinds.indexOf(b.kind)); const best = allClosest(candidates, relPath); if (best.length) return best; const fallback = candidates[0] ?? implicitPytest(relPath, configs); @@ -162,7 +167,14 @@ export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunC return derived(base, relPath, { args: [...base.args, file] }); case "pytest": case "python": + case "rspec": return derived(base, relPath, { args: [...base.args, file] }); + case "rake": { + // `rake test` (minitest) takes the file as TEST=; `rake spec` as SPEC=. + const task = base.args.at(-1) ?? ""; + const key = /(^|:)spec$/.test(task) ? "SPEC" : /(^|:)test$/.test(task) ? "TEST" : null; + return key ? derived(base, relPath, { args: [...base.args, `${key}=${file}`] }) : base; + } case "django-test": { const tests = djangoTestsOf(base, [djangoLabel(file)]); return tests ? derived(base, relPath, tests) : null; diff --git a/src/features/run/rubyTypes.ts b/src/features/run/rubyTypes.ts new file mode 100644 index 000000000..f7f3580d8 --- /dev/null +++ b/src/features/run/rubyTypes.ts @@ -0,0 +1,199 @@ +/** + * Ruby run configuration types: a script (`ruby [options] script [args]`), a Rake + * task and RSpec, the latter two through `bundle exec` when the project uses Bundler. + * The interpreter itself comes from Settings › Ruby (`apply_to_run_configs`). + */ +import type { RunConfig } from "@/platform/ipc"; +import type { RunConfigTypeContribution } from "@/workbench/contributions"; +import { formatArgs, parseArgs } from "./configs"; +import { commandName } from "./commandName"; + +const on = (v: string | undefined) => v === "true"; + +/** `bundle exec ` or ``, launched through `params.command` when one is set. */ +function tool(p: Readonly>, name: string): { command: string; args: string[] } { + const command = p.command?.trim(); + return on(p.bundler) + ? { command: command || "bundle", args: ["exec", name] } + : { command: command || name, args: [] }; +} + +/** Splits `bundle exec rest…` / ` rest…` into the launcher, the Bundler flag and `rest`. */ +function unwrap(c: RunConfig, name: string): { command: string; bundler: string; rest: string[] } | null { + const cmd = commandName(c.command); + const bare = c.command === cmd; + if (cmd === "bundle" && c.args[0] === "exec" && c.args[1] === name) + return { command: bare ? "" : c.command, bundler: "true", rest: c.args.slice(2) }; + if (cmd === name) return { command: bare ? "" : c.command, bundler: "", rest: c.args }; + return null; +} + +/** + * Index of the first operand in `args` (the script, spec target or Rake task): the + * first argument that is not an option, nor the value of the option before it. A + * token after an option without `=` is that option's value when the option is known + * to take one (`-I lib`, `--format documentation`) or, with `operand`, when the token + * does not look like an operand (`--foo bar spec/a_spec.rb`); `--` ends the options. + */ +function operandIndex( + args: readonly string[], + valueOptions: ReadonlySet, + operand: (a: string) => boolean = () => true, +): number { + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + if (a === "--") return i + 1 < args.length ? i + 1 : -1; + if (a.startsWith("-")) continue; + const prev = i > 0 ? args[i - 1]! : ""; + const value = prev.startsWith("-") && !prev.includes("=") && (valueOptions.has(prev) || !operand(a)); + if (!value) return i; + } + return -1; +} + +const RUBY_VALUE_OPTIONS = new Set( + "-I -r -C -E -e -F --encoding --external-encoding --internal-encoding --enable --disable --dump".split(" "), +); +const RSPEC_VALUE_OPTIONS = new Set( + ( + "-I -r --require -O --options -P --pattern --exclude-pattern --default-path -o --out --deprecation-out " + + "-f --format -e --example -E --example-matches -t --tag --seed --order" + ).split(" "), +); +const RAKE_VALUE_OPTIONS = new Set( + ( + "-f --rakefile -I --libdir -r --require -R --rakelibdir -j --jobs -C --directory " + + "-e --execute -E --execute-continue -p --execute-print" + ).split(" "), +); + +const looksLikeScript = (a: string) => /[\\/]|\.rb$/.test(a); +const looksLikeSpecTarget = (a: string) => /[\\/]|\.rb(:\d+|\[[\d:]+\])?$|^(spec|\.)$/.test(a); + +const bundlerField = { + key: "bundler", + label: "Run with Bundler (bundle exec)", + control: "checkbox", +} as const; + +const commandField = (name: string) => + ({ + key: "command", + label: "Launcher", + mono: true, + placeholder: name, + hint: "Path of the tool (bundle when run with Bundler); empty uses the interpreter from Settings › Ruby", + }) as const; + +export const rubyScriptType: RunConfigTypeContribution = { + id: "ruby", + label: "Ruby", + icon: "file-ruby", + description: "Run a Ruby script", + defaults: {}, + fields: [ + { key: "script", label: "Ruby script", mono: true, required: true, placeholder: "e.g. bin/rails or lib/app.rb" }, + { key: "scriptArgs", label: "Script arguments", mono: true, placeholder: "e.g. server -p 3000" }, + { key: "rubyArgs", label: "Ruby arguments", mono: true, placeholder: "e.g. -Ilib -w" }, + bundlerField, + commandField("ruby"), + ], + commandLine(p) { + const t = tool(p, "ruby"); + return { + command: t.command, + args: [ + ...t.args, + ...parseArgs(p.rubyArgs ?? ""), + ...parseArgs(p.script?.trim() ?? ""), + ...parseArgs(p.scriptArgs ?? ""), + ], + }; + }, + paramsFrom(c) { + if (c.kind !== "ruby") return null; + const u = unwrap(c, "ruby"); + if (!u) return null; + const at = operandIndex(u.rest, RUBY_VALUE_OPTIONS, looksLikeScript); + // A new configuration (`ruby`, nothing else) opens in the form with the script still to fill in; + // options without a script (`ruby -e …`, `ruby -v`) stay a plain command. + if (at < 0) + return u.rest.length + ? null + : { command: u.command, bundler: u.bundler, rubyArgs: "", script: "", scriptArgs: "" }; + return { + command: u.command, + bundler: u.bundler, + rubyArgs: formatArgs(u.rest.slice(0, at)), + script: formatArgs([u.rest[at]!]), + scriptArgs: formatArgs(u.rest.slice(at + 1)), + }; + }, +}; + +export const rakeType: RunConfigTypeContribution = { + id: "rake", + label: "Rake", + icon: "file-ruby", + description: "Run a Rake task", + defaults: { bundler: "true" }, + fields: [ + { + key: "task", + label: "Task", + mono: true, + placeholder: "default", + hint: "Task name with arguments, e.g. db:migrate or test TEST=test/a_test.rb; empty runs the default task", + suggestions: (configs) => [ + ...new Set(configs.filter((c) => c.kind === "rake" && c.detected).map((c) => c.args.at(-1) ?? "")), + ], + }, + { key: "options", label: "Rake options", mono: true, placeholder: "e.g. --trace" }, + bundlerField, + commandField("rake"), + ], + commandLine(p) { + const t = tool(p, "rake"); + return { command: t.command, args: [...t.args, ...parseArgs(p.options ?? ""), ...parseArgs(p.task ?? "")] }; + }, + paramsFrom(c) { + if (c.kind !== "rake") return null; + const u = unwrap(c, "rake"); + if (!u) return null; + const at = operandIndex(u.rest, RAKE_VALUE_OPTIONS); + const [options, task] = at < 0 ? [u.rest, []] : [u.rest.slice(0, at), u.rest.slice(at)]; + return { command: u.command, bundler: u.bundler, options: formatArgs(options), task: formatArgs(task) }; + }, +}; + +export const rspecType: RunConfigTypeContribution = { + id: "rspec", + label: "RSpec", + icon: "file-ruby", + description: "Run RSpec examples", + defaults: { bundler: "true" }, + fields: [ + { + key: "target", + label: "Target", + mono: true, + placeholder: "e.g. spec", + hint: "Directory, file or file:line; empty runs the whole suite", + }, + { key: "options", label: "RSpec options", mono: true, placeholder: "e.g. --format documentation --fail-fast" }, + bundlerField, + commandField("rspec"), + ], + commandLine(p) { + const t = tool(p, "rspec"); + return { command: t.command, args: [...t.args, ...parseArgs(p.options ?? ""), ...parseArgs(p.target ?? "")] }; + }, + paramsFrom(c) { + if (c.kind !== "rspec") return null; + const u = unwrap(c, "rspec"); + if (!u) return null; + const at = operandIndex(u.rest, RSPEC_VALUE_OPTIONS, looksLikeSpecTarget); + const [options, target] = at < 0 ? [u.rest, []] : [u.rest.slice(0, at), u.rest.slice(at)]; + return { command: u.command, bundler: u.bundler, options: formatArgs(options), target: formatArgs(target) }; + }, +}; diff --git a/src/features/run/runConfigTypes.test.tsx b/src/features/run/runConfigTypes.test.tsx index fd207b13f..1fb73c57b 100644 --- a/src/features/run/runConfigTypes.test.tsx +++ b/src/features/run/runConfigTypes.test.tsx @@ -103,6 +103,12 @@ describe("configuration types", () => { ["pytest", "pytest.exe", ["tests/test_app.py", "-q"]], ["pytest", String.raw`C:\project\.venv\Scripts\PYTEST.EXE`, ["-q"]], ["pytest", "PYTHON.EXE", ["-m", "pytest", "tests"]], + ["ruby", String.raw`C:\Ruby33\bin\ruby.exe`, ["-Ilib", "bin/app", "--port", "3000"]], + ["rake", "bundle", ["exec", "rake", "--trace", "db:migrate"]], + ["rspec", "/home/me/.rbenv/versions/3.3.1/bin/rspec", ["--fail-fast", "spec/a_spec.rb:12"]], + ["rspec", "bundle", ["exec", "rspec", "--format", "documentation", "--fail-fast", "spec/foo_spec.rb"]], + ["ruby", "ruby", ["-I", "lib", "-w", "bin/app", "--port", "3000"]], + ["rake", "bundle", ["exec", "rake", "-f", "lib/tasks.rake", "--trace", "db:migrate"]], ["npm", String.raw`C:\Program Files\nodejs\NPM.CMD`, ["run", "test", "--", "two words"]], ["npm", "pnpm.COM", ["test"]], ["application", String.raw`C:\jdk\bin\JAVA.EXE`, ["-cp", "lib/*", "a.Main"]], @@ -172,6 +178,75 @@ describe("configuration types", () => { expect(godot.paramsFrom!(cfg("x", { command: "cargo" }))).toBeNull(); }); + it("ruby types default to Bundler for rake and rspec and mark rspec as tests", () => { + expect(newConfigOfType(typeFor("rake")!, [])).toMatchObject({ command: "bundle", args: ["exec", "rake"] }); + const ruby = newConfigOfType(typeFor("ruby")!, []); + expect(ruby).toMatchObject({ command: "ruby", args: [] }); + expect(formParams(ruby)).toEqual({ command: "", bundler: "", rubyArgs: "", script: "", scriptArgs: "" }); + const rspec = newConfigOfType(typeFor("rspec")!, []); + expect(rspec).toMatchObject({ command: "bundle", args: ["exec", "rspec"], isTest: true }); + expect(withParams(rspec, { bundler: "", target: "spec/a_spec.rb", options: "-fd" })).toMatchObject({ + command: "rspec", + args: ["-fd", "spec/a_spec.rb"], + isTest: true, + }); + expect(formParams(cfg("r", { kind: "ruby", command: "ruby", args: ["app.rb", "-v"] }))).toEqual({ + command: "", + bundler: "", + rubyArgs: "", + script: "app.rb", + scriptArgs: "-v", + }); + }); + + it("ruby types keep option values with their options and the argument order as written", () => { + const ruby = (args: string[]) => formParams(cfg("r", { kind: "ruby", command: "ruby", args })); + expect(ruby(["-I", "lib", "-w", "bin/app", "--port", "3000"])).toEqual({ + command: "", + bundler: "", + rubyArgs: "-I lib -w", + script: "bin/app", + scriptArgs: "--port 3000", + }); + expect(ruby(["-W0", "--", "-weird.rb", "x"])).toMatchObject({ + rubyArgs: "-W0 --", + script: "-weird.rb", + scriptArgs: "x", + }); + expect(ruby(["-e", "puts 1"])).toBeNull(); + expect(ruby(["-v"])).toBeNull(); + + const rspec = (args: string[]) => + formParams(cfg("s", { kind: "rspec", command: "bundle", args: ["exec", "rspec", ...args] })); + expect(rspec(["--format", "documentation", "--fail-fast", "spec/foo_spec.rb"])).toEqual({ + command: "", + bundler: "true", + options: "--format documentation --fail-fast", + target: "spec/foo_spec.rb", + }); + expect(rspec(["--tag", "~slow", "-fd", "spec/a_spec.rb:12", "spec/b_spec.rb"])).toMatchObject({ + options: "--tag ~slow -fd", + target: "spec/a_spec.rb:12 spec/b_spec.rb", + }); + expect(rspec(["--format", "documentation"])).toMatchObject({ options: "--format documentation", target: "" }); + expect(rspec(["--new-option", "value", "spec"])).toMatchObject({ options: "--new-option value", target: "spec" }); + expect(rspec([])).toMatchObject({ options: "", target: "" }); + + const rake = (args: string[]) => + formParams(cfg("k", { kind: "rake", command: "bundle", args: ["exec", "rake", ...args] })); + expect(rake(["-f", "lib/tasks.rake", "--trace", "db:migrate"])).toEqual({ + command: "", + bundler: "true", + options: "-f lib/tasks.rake --trace", + task: "db:migrate", + }); + expect(rake(["--trace", "test", "TEST=test/a_test.rb"])).toMatchObject({ + options: "--trace", + task: "test TEST=test/a_test.rb", + }); + expect(rake(["-T"])).toMatchObject({ options: "-T", task: "" }); + }); + it("Django tests round-trip commandLine / paramsFrom and always report to the test tree", () => { const django = typeFor("django-test")!; expect(django.debugAdapter).toBe("debugpy"); diff --git a/src/features/run/types.ts b/src/features/run/types.ts index a5b51f4fa..172b07443 100644 --- a/src/features/run/types.ts +++ b/src/features/run/types.ts @@ -23,6 +23,7 @@ import { tomcatType } from "./tomcat"; import { goBuildType, goTestType } from "./goTypes"; import { dotnetProjectType, dotnetTestType } from "./dotnetTypes"; +import { rakeType, rspecType, rubyScriptType } from "./rubyTypes"; import { djangoTestType } from "./djangoTypes"; export const COMPOUND = "compound"; @@ -399,6 +400,9 @@ export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [ maven, godot, pytest, + rubyScriptType, + rakeType, + rspecType, djangoTestType, dotnetProjectType, dotnetTestType, @@ -453,7 +457,7 @@ export function withParams(config: RunConfig, params: Record, ty ...(config.kind === "dotnet" && config.beforeLaunch === undefined ? { beforeLaunch: [{ kind: "dotnetBuild", target: "" }] } : {}), - ...(config.kind === "dotnet-test" || config.kind === "django-test" ? { isTest: true } : {}), + ...(config.kind === "dotnet-test" || config.kind === "django-test" || config.kind === "rspec" ? { isTest: true } : {}), params: params.goKind ? { ...params, programArgv: JSON.stringify(parseArgs(params.programArgs ?? "")) } : params, ...(config.kind === "go-test" && !config.id.startsWith("template:") ? { kind: "go" } : {}), ...(params.goKind ? { isTest: params.goKind === "test" } : {}), diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index 5864b0586..0f78d2950 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -35,6 +35,14 @@ describe("languages settings", () => { expect(validateSettings({}).languages).toEqual(DEFAULT_LANGUAGES); }); + it("keeps the Ruby interpreter through settings normalization and defaults to Auto", () => { + expect(validateSettings({}).languages.ruby.interpreter).toBe(""); + expect(validateLanguages({ ruby: { interpreter: 42 } }).ruby.interpreter).toBe(""); + const settings = validateSettings({ languages: { ruby: { interpreter: " /opt/ruby/bin/ruby " } } }); + expect(settings.languages.ruby.interpreter).toBe("/opt/ruby/bin/ruby"); + expect(validateSettings(settings).languages.ruby.interpreter).toBe("/opt/ruby/bin/ruby"); + }); + it("keeps Prettier opt-in on save and normalizes its enabled languages", () => { expect(validateLanguages({}).prettier).toMatchObject({ prefer: false, runOnSave: false, path: "" }); expect( @@ -69,6 +77,7 @@ describe("language pages", () => { "Node.js", "Python Interpreter", "Prettier", + "Ruby", "Rust", "TypeScript", ]); diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index e507588a1..ca3a14f75 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -109,6 +109,28 @@ export function NodePage() { ); } +// --------------------------------------------------------------------------- +// Ruby + +export function RubyPage() { + return ( +

+
+ +
+
+ ); +} + // --------------------------------------------------------------------------- // Rust diff --git a/src/features/settings/pages/languages.tsx b/src/features/settings/pages/languages.tsx index 181afc052..8f217c377 100644 --- a/src/features/settings/pages/languages.tsx +++ b/src/features/settings/pages/languages.tsx @@ -3,7 +3,7 @@ * Languages & Frameworks › Go / Java / Node.js / Rust / TypeScript. Pages live in * `../languages/`; settings are `languages.*` / `projectStructure.*`. */ -import { GoPage, JavaPage, NodePage, RustPage, TypeScriptPage } from "../languages/LanguagePages"; +import { GoPage, JavaPage, NodePage, RubyPage, RustPage, TypeScriptPage } from "../languages/LanguagePages"; import { PrettierPage } from "../languages/PrettierPage"; import { ProjectStructurePage } from "../languages/ProjectStructurePage"; import { PythonInterpreterPage } from "../languages/PythonInterpreterPage"; @@ -118,6 +118,16 @@ export const LANGUAGE_PAGES: PageSpec[] = [ keywords: ["formatter", "prettier", "markdown", "yaml", "css", "format on save", "node_modules"], component: PrettierPage, }, + { + id: "languages.ruby", + projectScope: true, + defaultScope: "project", + title: "Ruby", + group: LANGUAGES_GROUP, + order: 403.8, + keywords: ["ruby interpreter", "rbenv", "rvm", "chruby", "bundler", "gems", "rake", "rspec"], + component: RubyPage, + }, { id: "languages.rust", projectScope: true, diff --git a/src/platform/languageSettings.ts b/src/platform/languageSettings.ts index 055ff9d47..01032a2a7 100644 --- a/src/platform/languageSettings.ts +++ b/src/platform/languageSettings.ts @@ -27,6 +27,7 @@ const DEFAULT_GO_BUILD_TAGS: GoBuildTags = { export interface LanguagesValid { python: { interpreter: string; activateVirtualenv: boolean }; + ruby: { interpreter: string }; node: { interpreter: string; packageManager: "" | "npm" | "pnpm" | "yarn" | "bun" }; rust: { toolchain: string; @@ -77,6 +78,7 @@ export type FolderListKey = export const DEFAULT_LANGUAGES: LanguagesValid = { python: { interpreter: "", activateVirtualenv: true }, + ruby: { interpreter: "" }, node: { interpreter: "", packageManager: "" }, rust: { toolchain: "", stdlib: "", checkOnSave: true, checkCommand: "check", rustfmtArgs: "" }, go: { goroot: "", gopath: "", buildTags: DEFAULT_GO_BUILD_TAGS, modules: { env: {}, autoVendor: true } }, @@ -158,6 +160,7 @@ export function validateLanguages(raw: unknown): LanguagesValid { const d = DEFAULT_LANGUAGES; const l = obj(raw); const py = obj(l.python); + const ruby = obj(l.ruby); const node = obj(l.node); const rust = obj(l.rust); const go = obj(l.go); @@ -173,6 +176,7 @@ export function validateLanguages(raw: unknown): LanguagesValid { interpreter: text(py.interpreter, d.python.interpreter), activateVirtualenv: bool(py.activateVirtualenv, d.python.activateVirtualenv), }, + ruby: { interpreter: text(ruby.interpreter, d.ruby.interpreter) }, node: { interpreter: text(node.interpreter, d.node.interpreter), packageManager: oneOf(node.packageManager, ["", "npm", "pnpm", "yarn", "bun"] as const, d.node.packageManager), diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 3a2d099ae..5891763c5 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4876,6 +4876,30 @@ "Directory, file or test id; empty runs the whole suite": "目录、文件或测试 ID;留空则运行整个测试套件", "pytest options": "pytest 选项", "e.g. -x -q --tb=short": "例如 -x -q --tb=short", + "Ruby": "Ruby 语言", + "Run a Ruby script": "运行 Ruby 脚本", + "Ruby script": "Ruby 脚本", + "e.g. bin/rails or lib/app.rb": "例如 bin/rails 或 lib/app.rb", + "Script arguments": "脚本实参", + "e.g. server -p 3000": "例如 server -p 3000", + "Ruby arguments": "Ruby 实参", + "e.g. -Ilib -w": "例如 -Ilib -w", + "Run with Bundler (bundle exec)": "通过 Bundler 运行(bundle exec)", + "Launcher": "启动器", + "Path of the tool (bundle when run with Bundler); empty uses the interpreter from Settings › Ruby": "工具的路径(通过 Bundler 运行时为 bundle);留空则使用“设置 › Ruby”中的解释器", + "Rake": "Rake 任务", + "Run a Rake task": "运行 Rake 任务", + "Task name with arguments, e.g. db:migrate or test TEST=test/a_test.rb; empty runs the default task": "任务名及其实参,例如 db:migrate 或 test TEST=test/a_test.rb;留空则运行默认任务", + "Rake options": "Rake 选项", + "e.g. --trace": "例如 --trace", + "RSpec": "RSpec 测试", + "Run RSpec examples": "运行 RSpec 示例", + "e.g. spec": "例如 spec", + "Directory, file or file:line; empty runs the whole suite": "目录、文件或 文件:行号;留空则运行整个测试套件", + "RSpec options": "RSpec 选项", + "e.g. --format documentation --fail-fast": "例如 --format documentation --fail-fast", + "Ruby interpreter": "Ruby 解释器", + "Runs the ruby, rake and rspec configurations and lists the standard library and gems under External Libraries. Installs from rbenv, rvm, chruby, mise and asdf are detected.": "用于运行 ruby、rake 和 rspec 配置,并在“外部库”下列出标准库和 gem。会检测 rbenv、rvm、chruby、mise 和 asdf 安装的版本。", "Monospaced": "等宽", "Proportional": "非等宽", "Loading system fonts…": "正在加载系统字体…", diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index bcaf888bf..047091b3b 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -874,7 +874,7 @@ const EXT_ICONS = table({ "file-swift": "swift swiftinterface", "file-go": "go", "file-rust": "rs", - "file-ruby": "rb rake gemspec ru rbi jbuilder podspec", + "file-ruby": "rb rake gemspec ru rbi jbuilder podspec erb rhtml", "file-lua": "lua luau rockspec", "file-zig": "zig zon", "file-gdscript": "gd",