Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions extension.toml
Original file line number Diff line number Diff line change
Expand Up @@ -89,5 +89,10 @@ kind = "process:exec"
command = "ruby"
args = ["--version"]

[[capabilities]]
kind = "process:exec"
command = "sh"
args = ["-c", "*"]
Comment on lines +92 to +95
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MrSubidubi Just checking-in, after zed-industries/zed#52249 was closed, the Ruby extensions is a bit broken due to that. I found this solution to the problem described in the PR.

  • Since this extension must run some Ruby setup commands (bundle/ruby/gem) from the
    worktree root so version managers like mise/asdf resolve the project Ruby
    correctly.
  • AND The Zed extension process API does not expose a way to set the child process working directory

So I wonder if we can use sh -c as a minimal
cwd wrapper around those commands. I understand that sh -c * is quite broad pattern so I would like to hear your thoughts! Thanks!


[debug_adapters.rdbg]
[debug_locators.ruby]
96 changes: 77 additions & 19 deletions src/bundler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,35 @@ impl<E: CommandExecutor> Bundler<E> {
format!("Invalid path to Gemfile: {}", bundle_gemfile_path.display())
})?;

let working_dir_str = self.working_dir.to_str().with_context(|| {
format!(
"Invalid working directory path: {}",
self.working_dir.display()
)
})?;

let full_args: Vec<&str> = std::iter::once(cmd).chain(args.iter().copied()).collect();
let command_envs: Vec<(&str, &str)> = envs
let command_envs: Vec<(String, String)> = envs
.iter()
.cloned()
.chain(std::iter::once(("BUNDLE_GEMFILE", bundle_gemfile)))
.copied()
.filter(|(key, _)| *key != "BUNDLE_GEMFILE")
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect();
let command_envs = command_envs
.into_iter()
.chain(std::iter::once((
"BUNDLE_GEMFILE".to_string(),
bundle_gemfile.to_string(),
)))
.collect::<Vec<_>>();
let command_env_refs = command_envs
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect::<Vec<_>>();

let output = self
.command_executor
.execute("bundle", &full_args, &command_envs)
.execute_in_dir("bundle", &full_args, &command_env_refs, working_dir_str)
.map_err(|e| anyhow::anyhow!(e))?;

match output.status {
Expand Down Expand Up @@ -151,20 +170,34 @@ mod tests {
}
}

fn expected_envs(dir: &str) -> Vec<(String, String)> {
let gemfile_path = Path::new(dir)
.join("Gemfile")
.to_string_lossy()
.into_owned();

vec![("BUNDLE_GEMFILE".to_string(), gemfile_path)]
}

fn env_refs(envs: &[(String, String)]) -> Vec<(&str, &str)> {
envs.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect()
}

fn create_mock_executor_for_success(
version: &str,
dir: &str,
gem: &str,
) -> MockCommandExecutor {
let mock = MockCommandExecutor::new();
let gemfile_path = Path::new(dir)
.join("Gemfile")
.to_string_lossy()
.into_owned();
let expected_envs = expected_envs(dir);
let expected_envs = env_refs(&expected_envs);

mock.expect(
"bundle",
&["info", "--version", gem],
&[("BUNDLE_GEMFILE", &gemfile_path)],
&expected_envs,
Ok(Output {
status: Some(0),
stdout: version.as_bytes().to_vec(),
Expand All @@ -184,20 +217,47 @@ mod tests {
assert_eq!(version, "8.0.0", "Installed gem version should match");
}

#[test]
fn test_installed_gem_version_overrides_bundle_gemfile_env() {
let mock_executor = MockCommandExecutor::new();
let mut expected_command_envs = vec![("PATH".to_string(), "/usr/bin".to_string())];
expected_command_envs.extend(expected_envs("test_dir"));
let expected_command_envs = env_refs(&expected_command_envs);

mock_executor.expect(
"bundle",
&["info", "--version", "rails"],
&expected_command_envs,
Ok(Output {
status: Some(0),
stdout: b"8.0.0".to_vec(),
stderr: Vec::new(),
}),
);

let bundler = Bundler::new("test_dir".into(), mock_executor);
let version = bundler
.installed_gem_version(
"rails",
&[("BUNDLE_GEMFILE", "wrong/Gemfile"), ("PATH", "/usr/bin")],
)
.expect("Expected successful version");

assert_eq!(version, "8.0.0", "Installed gem version should match");
}

#[test]
fn test_installed_gem_version_command_error() {
let mock_executor = MockCommandExecutor::new();
let gem_name = "unknown_gem";
let error_output = "Could not find gem 'unknown_gem'.";
let gemfile_path = Path::new("test_dir")
.join("Gemfile")
.to_string_lossy()
.into_owned();
let expected_envs = expected_envs("test_dir");
let expected_envs = env_refs(&expected_envs);

mock_executor.expect(
"bundle",
&["info", "--version", gem_name],
&[("BUNDLE_GEMFILE", &gemfile_path)],
&expected_envs,
Ok(Output {
status: Some(1),
stdout: Vec::new(),
Expand Down Expand Up @@ -228,15 +288,13 @@ mod tests {
let mock_executor = MockCommandExecutor::new();
let gem_name = "critical_gem";
let specific_error_msg = "Mocked execution failure";
let gemfile_path = Path::new("test_dir")
.join("Gemfile")
.to_string_lossy()
.into_owned();
let expected_envs = expected_envs("test_dir");
let expected_envs = env_refs(&expected_envs);

mock_executor.expect(
"bundle",
&["info", "--version", gem_name],
&[("BUNDLE_GEMFILE", &gemfile_path)],
&expected_envs,
Err(specific_error_msg.to_string()),
);

Expand Down
43 changes: 43 additions & 0 deletions src/command_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ pub trait CommandExecutor {
args: &[&str],
envs: &[(&str, &str)],
) -> zed::Result<zed::process::Output>;

fn execute_in_dir(
&self,
cmd: &str,
args: &[&str],
envs: &[(&str, &str)],
working_dir: &str,
) -> zed::Result<zed::process::Output> {
let _ = working_dir;
self.execute(cmd, args, envs)
}
}

/// An implementation of `CommandExecutor` that executes commands
Expand All @@ -41,4 +52,36 @@ impl CommandExecutor for RealCommandExecutor {
.envs(envs.iter().copied())
.output()
}

fn execute_in_dir(
&self,
cmd: &str,
args: &[&str],
envs: &[(&str, &str)],
working_dir: &str,
) -> zed::Result<zed::process::Output> {
let script = sh_command_in_dir(working_dir, cmd, args);

eprintln!("Executing in dir via sh: {script}");

zed::Command::new("sh")
.args(["-c", script.as_str()])
.envs(envs.iter().copied())
.output()
}
}

fn sh_command_in_dir(working_dir: &str, cmd: &str, args: &[&str]) -> String {
format!(
"cd -- {} && exec {}{}",
sh_quote(working_dir),
sh_quote(cmd),
args.iter()
.map(|arg| format!(" {}", sh_quote(arg)))
.collect::<String>()
)
}

fn sh_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
25 changes: 23 additions & 2 deletions src/gemset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,17 @@ pub fn versioned_gem_home(
envs: &[(&str, &str)],
executor: &dyn CommandExecutor,
) -> Result<PathBuf> {
let working_dir = envs
.iter()
.find_map(|(key, value)| (*key == "PWD").then_some(*value));

let output = executor
.execute("ruby", &["--version"], envs)
.execute_in_dir(
"ruby",
&["--version"],
envs,
working_dir.unwrap_or(base_dir.to_string_lossy().as_ref()),
)
.map_err(|e| anyhow::anyhow!(e))
.context("Failed to detect Ruby version")?;

Expand All @@ -34,6 +43,7 @@ pub fn versioned_gem_home(
/// A simple wrapper around the `gem` command.
pub struct Gemset {
gem_home: PathBuf,
working_dir: Option<String>,
envs: Vec<(String, String)>,
cached_env: OnceLock<Vec<(String, String)>>,
command_executor: Box<dyn CommandExecutor>,
Expand All @@ -47,6 +57,10 @@ impl Gemset {
) -> Self {
Self {
gem_home,
working_dir: envs.and_then(|envs| {
envs.iter()
.find_map(|(key, value)| (*key == "PWD").then_some((*value).to_string()))
}),
envs: envs.map_or(Vec::new(), |envs| {
envs.iter()
.map(|&(k, v)| (k.to_string(), v.to_string()))
Expand Down Expand Up @@ -181,7 +195,14 @@ impl Gemset {

let output = self
.command_executor
.execute("gem", &full_args, &merged_envs)
.execute_in_dir(
"gem",
&full_args,
&merged_envs,
self.working_dir
.as_deref()
.unwrap_or(self.gem_home.to_string_lossy().as_ref()),
)
.map_err(|e| anyhow!(e))?;

match output.status {
Expand Down
28 changes: 23 additions & 5 deletions src/language_servers/language_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,15 @@ pub trait LanguageServer {
let lsp_settings =
zed::settings::LspSettings::for_worktree(language_server_id.as_ref(), worktree)?;

let worktree_root = worktree.root_path();
let shell_env = worktree.shell_env();

if let Some(binary_settings) = &lsp_settings.binary {
if let Some(path) = &binary_settings.path {
return Ok(LanguageServerBinary {
path: path.clone(),
args: binary_settings.arguments.clone(),
env: Some(worktree.shell_env()),
env: Some(shell_env),
});
}
}
Expand All @@ -169,8 +172,8 @@ pub trait LanguageServer {
return self.try_find_on_path_or_extension_gemset(language_server_id, worktree);
}

let bundler = Bundler::new(PathBuf::from(worktree.root_path()), RealCommandExecutor);
let shell_env = worktree.shell_env();
let bundler = Bundler::new(PathBuf::from(&worktree_root), RealCommandExecutor);

let env_vars: Vec<(&str, &str)> = shell_env
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
Expand All @@ -193,7 +196,14 @@ pub trait LanguageServer {
env: Some(shell_env),
})
}
Err(_e) => self.try_find_on_path_or_extension_gemset(language_server_id, worktree),
Err(e) => {
eprintln!(
"Bundler probe failed for {} in {}: {e:#}",
Self::GEM_NAME,
worktree_root
);
self.try_find_on_path_or_extension_gemset(language_server_id, worktree)
}
}
}

Expand All @@ -202,11 +212,13 @@ pub trait LanguageServer {
language_server_id: &zed::LanguageServerId,
worktree: &zed::Worktree,
) -> zed::Result<LanguageServerBinary> {
let shell_env = worktree.shell_env();

if let Some(path) = worktree.which(Self::EXECUTABLE_NAME) {
Ok(LanguageServerBinary {
path,
args: Some(self.get_executable_args(worktree)),
env: Some(worktree.shell_env()),
env: Some(shell_env),
})
} else {
self.extension_gemset_language_server_binary(language_server_id, worktree)
Expand All @@ -231,6 +243,12 @@ pub trait LanguageServer {
versioned_gem_home(&base_dir, &worktree_shell_env_vars, &RealCommandExecutor)
.map_err(|e| format!("{:#}", e))?;

eprintln!(
"Using extension gemset for {} with gem home {}",
Self::GEM_NAME,
gem_home.display()
);

let gemset = Gemset::new(
gem_home,
Some(&worktree_shell_env_vars),
Expand Down
7 changes: 4 additions & 3 deletions src/ruby.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,14 +134,15 @@ impl zed::Extension for RubyExtension {
_: Option<String>,
worktree: &Worktree,
) -> Result<DebugAdapterBinary, String> {
let worktree_root = worktree.root_path();
let shell_env = worktree.shell_env();
let env_vars: Vec<(&str, &str)> = shell_env
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();

let (command, mut arguments) = {
let bundler = Bundler::new(PathBuf::from(worktree.root_path()), RealCommandExecutor);
let bundler = Bundler::new(PathBuf::from(&worktree_root), RealCommandExecutor);
if bundler.installed_gem_version("debug", &env_vars).is_ok() {
let bundle = worktree.which("bundle").ok_or_else(|| {
"debug gem present, but unable to find 'bundle' command".to_string()
Expand Down Expand Up @@ -176,7 +177,7 @@ impl zed::Extension for RubyExtension {
if let Some(configuration) = configuration.as_object_mut() {
configuration
.entry("cwd")
.or_insert_with(|| worktree.root_path().into());
.or_insert_with(|| worktree_root.clone().into());
}

let ruby_config: RubyDebugConfig = serde_json::from_value(configuration.clone())
Expand Down Expand Up @@ -239,7 +240,7 @@ impl zed::Extension for RubyExtension {
command: Some(command),
arguments,
connection: Some(connection),
cwd: ruby_config.cwd.or(Some(worktree.root_path())),
cwd: ruby_config.cwd.or(Some(worktree_root)),
envs: ruby_config.env.into_iter().collect(),
request_args: StartDebuggingRequestArguments {
configuration: configuration.to_string(),
Expand Down
Loading