about summary refs log tree commit diff
diff options
context:
space:
mode:
authorWilfred Hughes <wilfred@meta.com>2024-06-11 11:55:04 -0400
committerDavid Barsky <me@davidbarsky.com>2024-06-11 11:55:17 -0400
commit1e9e86c655884e87e136c867818269b7d7a0dffa (patch)
tree40c9dc273df11af606bffacdf8e37c40d948b4a0
parent8fd1b507ea7650a2e2670ebba542d9362e910147 (diff)
feature: add build system info; runnables to `rust-project.json`
-rw-r--r--src/tools/rust-analyzer/crates/project-model/src/lib.rs2
-rw-r--r--src/tools/rust-analyzer/crates/project-model/src/project_json.rs177
-rw-r--r--src/tools/rust-analyzer/crates/project-model/src/workspace.rs2
-rw-r--r--src/tools/rust-analyzer/crates/rust-analyzer/src/global_state.rs61
-rw-r--r--src/tools/rust-analyzer/crates/rust-analyzer/src/handlers/request.rs70
-rw-r--r--src/tools/rust-analyzer/crates/rust-analyzer/src/lib.rs2
-rw-r--r--src/tools/rust-analyzer/crates/rust-analyzer/src/lsp/ext.rs27
-rw-r--r--src/tools/rust-analyzer/crates/rust-analyzer/src/lsp/to_proto.rs184
-rw-r--r--src/tools/rust-analyzer/crates/rust-analyzer/src/target_spec.rs (renamed from src/tools/rust-analyzer/crates/rust-analyzer/src/cargo_target_spec.rs)110
-rw-r--r--src/tools/rust-analyzer/docs/dev/lsp-extensions.md15
-rw-r--r--src/tools/rust-analyzer/editors/code/src/commands.ts7
-rw-r--r--src/tools/rust-analyzer/editors/code/src/debug.ts40
-rw-r--r--src/tools/rust-analyzer/editors/code/src/lsp_ext.ts30
-rw-r--r--src/tools/rust-analyzer/editors/code/src/run.ts76
-rw-r--r--src/tools/rust-analyzer/editors/code/src/tasks.ts47
-rw-r--r--src/tools/rust-analyzer/editors/code/src/util.ts7
-rw-r--r--src/tools/rust-analyzer/editors/code/tests/unit/runnable_env.test.ts4
17 files changed, 631 insertions, 230 deletions
diff --git a/src/tools/rust-analyzer/crates/project-model/src/lib.rs b/src/tools/rust-analyzer/crates/project-model/src/lib.rs
index 181c07f46b2..35643dcc028 100644
--- a/src/tools/rust-analyzer/crates/project-model/src/lib.rs
+++ b/src/tools/rust-analyzer/crates/project-model/src/lib.rs
@@ -22,7 +22,7 @@ mod cargo_workspace;
 mod cfg;
 mod env;
 mod manifest_path;
-mod project_json;
+pub mod project_json;
 mod rustc_cfg;
 mod sysroot;
 pub mod target_data_layout;
diff --git a/src/tools/rust-analyzer/crates/project-model/src/project_json.rs b/src/tools/rust-analyzer/crates/project-model/src/project_json.rs
index 408593ea8a6..4a916e570be 100644
--- a/src/tools/rust-analyzer/crates/project-model/src/project_json.rs
+++ b/src/tools/rust-analyzer/crates/project-model/src/project_json.rs
@@ -33,7 +33,7 @@
 //!
 //! * file on disk
 //! * a field in the config (ie, you can send a JSON request with the contents
-//!   of rust-project.json to rust-analyzer, no need to write anything to disk)
+//!   of `rust-project.json` to rust-analyzer, no need to write anything to disk)
 //!
 //! Another possible thing we don't do today, but which would be totally valid,
 //! is to add an extension point to VS Code extension to register custom
@@ -55,8 +55,7 @@ use rustc_hash::FxHashMap;
 use serde::{de, Deserialize, Serialize};
 use span::Edition;
 
-use crate::cfg::CfgFlag;
-use crate::ManifestPath;
+use crate::{cfg::CfgFlag, ManifestPath, TargetKind};
 
 /// Roots and crates that compose this Rust project.
 #[derive(Clone, Debug, Eq, PartialEq)]
@@ -68,6 +67,10 @@ pub struct ProjectJson {
     project_root: AbsPathBuf,
     manifest: Option<ManifestPath>,
     crates: Vec<Crate>,
+    /// Configuration for CLI commands.
+    ///
+    /// Examples include a check build or a test run.
+    runnables: Vec<Runnable>,
 }
 
 /// A crate points to the root module of a crate and lists the dependencies of the crate. This is
@@ -88,6 +91,86 @@ pub struct Crate {
     pub(crate) exclude: Vec<AbsPathBuf>,
     pub(crate) is_proc_macro: bool,
     pub(crate) repository: Option<String>,
+    pub build: Option<Build>,
+}
+
+/// Additional, build-specific data about a crate.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Build {
+    /// The name associated with this crate.
+    ///
+    /// This is determined by the build system that produced
+    /// the `rust-project.json` in question. For instance, if buck were used,
+    /// the label might be something like `//ide/rust/rust-analyzer:rust-analyzer`.
+    ///
+    /// Do not attempt to parse the contents of this string; it is a build system-specific
+    /// identifier similar to [`Crate::display_name`].
+    pub label: String,
+    /// Path corresponding to the build system-specific file defining the crate.
+    ///
+    /// It is roughly analogous to [`ManifestPath`], but it should *not* be used with
+    /// [`crate::ProjectManifest::from_manifest_file`], as the build file may not be
+    /// be in the `rust-project.json`.
+    pub build_file: Utf8PathBuf,
+    /// The kind of target.
+    ///
+    /// Examples (non-exhaustively) include [`TargetKind::Bin`], [`TargetKind::Lib`],
+    /// and [`TargetKind::Test`]. This information is used to determine what sort
+    /// of runnable codelens to provide, if any.
+    pub target_kind: TargetKind,
+}
+
+/// A template-like structure for describing runnables.
+///
+/// These are used for running and debugging binaries and tests without encoding
+/// build system-specific knowledge into rust-analyzer.
+///
+/// # Example
+///
+/// Below is an example of a test runnable. `{label}` and `{test_id}`
+/// are explained in [`Runnable::args`]'s documentation.
+///
+/// ```json
+/// {
+///     "program": "buck",
+///     "args": [
+///         "test",
+///          "{label}",
+///          "--",
+///          "{test_id}",
+///          "--print-passing-details"
+///     ],
+///     "cwd": "/home/user/repo-root/",
+///     "kind": "testOne"
+/// }
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Runnable {
+    /// The program invoked by the runnable.
+    ///
+    /// For example, this might be `cargo`, `buck`, or `bazel`.
+    pub program: String,
+    /// The arguments passed to [`Runnable::program`].
+    ///
+    /// The args can contain two template strings: `{label}` and `{test_id}`.
+    /// rust-analyzer will find and replace `{label}` with [`Build::label`] and
+    /// `{test_id}` with the test name.
+    pub args: Vec<String>,
+    /// The current working directory of the runnable.
+    pub cwd: Utf8PathBuf,
+    pub kind: RunnableKind,
+}
+
+/// The kind of runnable.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RunnableKind {
+    Check,
+
+    /// Can run a binary.
+    Run,
+
+    /// Run a single test.
+    TestOne,
 }
 
 impl ProjectJson {
@@ -95,6 +178,7 @@ impl ProjectJson {
     ///
     /// # Arguments
     ///
+    /// * `manifest` - The path to the `rust-project.json`.
     /// * `base` - The path to the workspace root (i.e. the folder containing `rust-project.json`)
     /// * `data` - The parsed contents of `rust-project.json`, or project json that's passed via
     ///            configuration.
@@ -109,6 +193,7 @@ impl ProjectJson {
             sysroot_src: data.sysroot_src.map(absolutize_on_base),
             project_root: base.to_path_buf(),
             manifest,
+            runnables: data.runnables.into_iter().map(Runnable::from).collect(),
             crates: data
                 .crates
                 .into_iter()
@@ -127,6 +212,15 @@ impl ProjectJson {
                         None => (vec![root_module.parent().unwrap().to_path_buf()], Vec::new()),
                     };
 
+                    let build = match crate_data.build {
+                        Some(build) => Some(Build {
+                            label: build.label,
+                            build_file: build.build_file,
+                            target_kind: build.target_kind.into(),
+                        }),
+                        None => None,
+                    };
+
                     Crate {
                         display_name: crate_data
                             .display_name
@@ -146,6 +240,7 @@ impl ProjectJson {
                         exclude,
                         is_proc_macro: crate_data.is_proc_macro,
                         repository: crate_data.repository,
+                        build,
                     }
                 })
                 .collect(),
@@ -167,7 +262,15 @@ impl ProjectJson {
         &self.project_root
     }
 
-    /// Returns the path to the project's manifest file, if it exists.
+    pub fn crate_by_root(&self, root: &AbsPath) -> Option<Crate> {
+        self.crates
+            .iter()
+            .filter(|krate| krate.is_workspace_member)
+            .find(|krate| krate.root_module == root)
+            .cloned()
+    }
+
+    /// Returns the path to the project's manifest, if it exists.
     pub fn manifest(&self) -> Option<&ManifestPath> {
         self.manifest.as_ref()
     }
@@ -176,6 +279,10 @@ impl ProjectJson {
     pub fn manifest_or_root(&self) -> &AbsPath {
         self.manifest.as_ref().map_or(&self.project_root, |manifest| manifest.as_ref())
     }
+
+    pub fn runnables(&self) -> &[Runnable] {
+        &self.runnables
+    }
 }
 
 #[derive(Serialize, Deserialize, Debug, Clone)]
@@ -183,6 +290,8 @@ pub struct ProjectJsonData {
     sysroot: Option<Utf8PathBuf>,
     sysroot_src: Option<Utf8PathBuf>,
     crates: Vec<CrateData>,
+    #[serde(default)]
+    runnables: Vec<RunnableData>,
 }
 
 #[derive(Serialize, Deserialize, Debug, Clone)]
@@ -205,6 +314,8 @@ struct CrateData {
     is_proc_macro: bool,
     #[serde(default)]
     repository: Option<String>,
+    #[serde(default)]
+    build: Option<BuildData>,
 }
 
 #[derive(Serialize, Deserialize, Debug, Clone)]
@@ -220,6 +331,48 @@ enum EditionData {
     Edition2024,
 }
 
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct BuildData {
+    label: String,
+    build_file: Utf8PathBuf,
+    target_kind: TargetKindData,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct RunnableData {
+    pub program: String,
+    pub args: Vec<String>,
+    pub cwd: Utf8PathBuf,
+    pub kind: RunnableKindData,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub enum RunnableKindData {
+    Check,
+    Run,
+    TestOne,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub enum TargetKindData {
+    Bin,
+    /// Any kind of Cargo lib crate-type (dylib, rlib, proc-macro, ...).
+    Lib,
+    Test,
+}
+
+impl From<TargetKindData> for TargetKind {
+    fn from(data: TargetKindData) -> Self {
+        match data {
+            TargetKindData::Bin => TargetKind::Bin,
+            TargetKindData::Lib => TargetKind::Lib { is_proc_macro: false },
+            TargetKindData::Test => TargetKind::Test,
+        }
+    }
+}
+
 impl From<EditionData> for Edition {
     fn from(data: EditionData) -> Self {
         match data {
@@ -231,6 +384,22 @@ impl From<EditionData> for Edition {
     }
 }
 
+impl From<RunnableData> for Runnable {
+    fn from(data: RunnableData) -> Self {
+        Runnable { program: data.program, args: data.args, cwd: data.cwd, kind: data.kind.into() }
+    }
+}
+
+impl From<RunnableKindData> for RunnableKind {
+    fn from(data: RunnableKindData) -> Self {
+        match data {
+            RunnableKindData::Check => RunnableKind::Check,
+            RunnableKindData::Run => RunnableKind::Run,
+            RunnableKindData::TestOne => RunnableKind::TestOne,
+        }
+    }
+}
+
 /// Identifies a crate by position in the crates array.
 ///
 /// This will differ from `CrateId` when multiple `ProjectJson`
diff --git a/src/tools/rust-analyzer/crates/project-model/src/workspace.rs b/src/tools/rust-analyzer/crates/project-model/src/workspace.rs
index 4dba11eac3f..17e40e74de3 100644
--- a/src/tools/rust-analyzer/crates/project-model/src/workspace.rs
+++ b/src/tools/rust-analyzer/crates/project-model/src/workspace.rs
@@ -76,7 +76,7 @@ pub enum ProjectWorkspaceKind {
         /// Environment variables set in the `.cargo/config` file.
         cargo_config_extra_env: FxHashMap<String, String>,
     },
-    /// Project workspace was manually specified using a `rust-project.json` file.
+    /// Project workspace was specified using a `rust-project.json` file.
     Json(ProjectJson),
     // FIXME: The primary limitation of this approach is that the set of detached files needs to be fixed at the beginning.
     // That's not the end user experience we should strive for.
diff --git a/src/tools/rust-analyzer/crates/rust-analyzer/src/global_state.rs b/src/tools/rust-analyzer/crates/rust-analyzer/src/global_state.rs
index 3d5f525aaf9..717d8a632c3 100644
--- a/src/tools/rust-analyzer/crates/rust-analyzer/src/global_state.rs
+++ b/src/tools/rust-analyzer/crates/rust-analyzer/src/global_state.rs
@@ -18,10 +18,7 @@ use parking_lot::{
     RwLockWriteGuard,
 };
 use proc_macro_api::ProcMacroServer;
-use project_model::{
-    CargoWorkspace, ManifestPath, ProjectWorkspace, ProjectWorkspaceKind, Target,
-    WorkspaceBuildScripts,
-};
+use project_model::{ManifestPath, ProjectWorkspace, ProjectWorkspaceKind, WorkspaceBuildScripts};
 use rustc_hash::{FxHashMap, FxHashSet};
 use tracing::{span, Level};
 use triomphe::Arc;
@@ -40,6 +37,7 @@ use crate::{
     mem_docs::MemDocs,
     op_queue::OpQueue,
     reload,
+    target_spec::{CargoTargetSpec, ProjectJsonTargetSpec, TargetSpec},
     task_pool::{TaskPool, TaskQueue},
 };
 
@@ -556,21 +554,52 @@ impl GlobalStateSnapshot {
         self.vfs_read().file_path(file_id).clone()
     }
 
-    pub(crate) fn cargo_target_for_crate_root(
-        &self,
-        crate_id: CrateId,
-    ) -> Option<(&CargoWorkspace, Target)> {
+    pub(crate) fn target_spec_for_crate(&self, crate_id: CrateId) -> Option<TargetSpec> {
         let file_id = self.analysis.crate_root(crate_id).ok()?;
         let path = self.vfs_read().file_path(file_id).clone();
         let path = path.as_path()?;
-        self.workspaces.iter().find_map(|ws| match &ws.kind {
-            ProjectWorkspaceKind::Cargo { cargo, .. }
-            | ProjectWorkspaceKind::DetachedFile { cargo: Some((cargo, _)), .. } => {
-                cargo.target_by_root(path).map(|it| (cargo, it))
-            }
-            ProjectWorkspaceKind::Json { .. } => None,
-            ProjectWorkspaceKind::DetachedFile { .. } => None,
-        })
+
+        for workspace in self.workspaces.iter() {
+            match &workspace.kind {
+                ProjectWorkspaceKind::Cargo { cargo, .. }
+                | ProjectWorkspaceKind::DetachedFile { cargo: Some((cargo, _)), .. } => {
+                    let Some(target_idx) = cargo.target_by_root(path) else {
+                        continue;
+                    };
+
+                    let target_data = &cargo[target_idx];
+                    let package_data = &cargo[target_data.package];
+
+                    return Some(TargetSpec::Cargo(CargoTargetSpec {
+                        workspace_root: cargo.workspace_root().to_path_buf(),
+                        cargo_toml: package_data.manifest.clone(),
+                        crate_id,
+                        package: cargo.package_flag(package_data),
+                        target: target_data.name.clone(),
+                        target_kind: target_data.kind,
+                        required_features: target_data.required_features.clone(),
+                        features: package_data.features.keys().cloned().collect(),
+                    }));
+                }
+                ProjectWorkspaceKind::Json(project) => {
+                    let Some(krate) = project.crate_by_root(path) else {
+                        continue;
+                    };
+                    let Some(build) = krate.build else {
+                        continue;
+                    };
+
+                    return Some(TargetSpec::ProjectJson(ProjectJsonTargetSpec {
+                        label: build.label,
+                        target_kind: build.target_kind,
+                        shell_runnables: project.runnables().to_owned(),
+                    }));
+                }
+                ProjectWorkspaceKind::DetachedFile { .. } => {}
+            };
+        }
+
+        None
     }
 
     pub(crate) fn file_exists(&self, file_id: FileId) -> bool {
diff --git a/src/tools/rust-analyzer/crates/rust-analyzer/src/handlers/request.rs b/src/tools/rust-analyzer/crates/rust-analyzer/src/handlers/request.rs
index 0789dd64623..8e39b15da3d 100644
--- a/src/tools/rust-analyzer/crates/rust-analyzer/src/handlers/request.rs
+++ b/src/tools/rust-analyzer/crates/rust-analyzer/src/handlers/request.rs
@@ -35,7 +35,6 @@ use triomphe::Arc;
 use vfs::{AbsPath, AbsPathBuf, FileId, VfsPath};
 
 use crate::{
-    cargo_target_spec::CargoTargetSpec,
     config::{Config, RustfmtConfig, WorkspaceSymbolConfig},
     diff::diff,
     global_state::{GlobalState, GlobalStateSnapshot},
@@ -51,6 +50,7 @@ use crate::{
         self, CrateInfoResult, ExternalDocsPair, ExternalDocsResponse, FetchDependencyListParams,
         FetchDependencyListResult, PositionOrRange, ViewCrateGraphParams, WorkspaceSymbolParams,
     },
+    target_spec::TargetSpec,
 };
 
 pub(crate) fn handle_workspace_reload(state: &mut GlobalState, _: ()) -> anyhow::Result<()> {
@@ -790,9 +790,9 @@ pub(crate) fn handle_parent_module(
             Some(&crate_id) => crate_id,
             None => return Ok(None),
         };
-        let cargo_spec = match CargoTargetSpec::for_file(&snap, file_id)? {
-            Some(it) => it,
-            None => return Ok(None),
+        let cargo_spec = match TargetSpec::for_file(&snap, file_id)? {
+            Some(TargetSpec::Cargo(it)) => it,
+            Some(TargetSpec::ProjectJson(_)) | None => return Ok(None),
         };
 
         if snap.analysis.crate_root(crate_id)? == file_id {
@@ -823,7 +823,7 @@ pub(crate) fn handle_runnables(
     let file_id = from_proto::file_id(&snap, &params.text_document.uri)?;
     let line_index = snap.file_line_index(file_id)?;
     let offset = params.position.and_then(|it| from_proto::offset(&line_index, it).ok());
-    let cargo_spec = CargoTargetSpec::for_file(&snap, file_id)?;
+    let target_spec = TargetSpec::for_file(&snap, file_id)?;
 
     let expect_test = match offset {
         Some(offset) => {
@@ -840,21 +840,24 @@ pub(crate) fn handle_runnables(
         if should_skip_for_offset(&runnable, offset) {
             continue;
         }
-        if should_skip_target(&runnable, cargo_spec.as_ref()) {
+        if should_skip_target(&runnable, target_spec.as_ref()) {
             continue;
         }
-        let mut runnable = to_proto::runnable(&snap, runnable)?;
-        if expect_test {
-            runnable.label = format!("{} + expect", runnable.label);
-            runnable.args.expect_test = Some(true);
+        if let Some(mut runnable) = to_proto::runnable(&snap, runnable)? {
+            if expect_test {
+                if let lsp_ext::RunnableArgs::Cargo(r) = &mut runnable.args {
+                    runnable.label = format!("{} + expect", runnable.label);
+                    r.expect_test = Some(true);
+                }
+            }
+            res.push(runnable);
         }
-        res.push(runnable);
     }
 
     // Add `cargo check` and `cargo test` for all targets of the whole package
     let config = snap.config.runnables();
-    match cargo_spec {
-        Some(spec) => {
+    match target_spec {
+        Some(TargetSpec::Cargo(spec)) => {
             let is_crate_no_std = snap.analysis.is_crate_no_std(spec.crate_id)?;
             for cmd in ["check", "run", "test"] {
                 if cmd == "run" && spec.target_kind != TargetKind::Bin {
@@ -879,7 +882,7 @@ pub(crate) fn handle_runnables(
                     ),
                     location: None,
                     kind: lsp_ext::RunnableKind::Cargo,
-                    args: lsp_ext::CargoRunnable {
+                    args: lsp_ext::RunnableArgs::Cargo(lsp_ext::CargoRunnableArgs {
                         workspace_root: Some(spec.workspace_root.clone().into()),
                         cwd: Some(cwd.into()),
                         override_cargo: config.override_cargo.clone(),
@@ -887,17 +890,18 @@ pub(crate) fn handle_runnables(
                         cargo_extra_args: config.cargo_extra_args.clone(),
                         executable_args: Vec::new(),
                         expect_test: None,
-                    },
+                    }),
                 })
             }
         }
+        Some(TargetSpec::ProjectJson(_)) => {}
         None => {
             if !snap.config.linked_or_discovered_projects().is_empty() {
                 res.push(lsp_ext::Runnable {
                     label: "cargo check --workspace".to_owned(),
                     location: None,
                     kind: lsp_ext::RunnableKind::Cargo,
-                    args: lsp_ext::CargoRunnable {
+                    args: lsp_ext::RunnableArgs::Cargo(lsp_ext::CargoRunnableArgs {
                         workspace_root: None,
                         cwd: None,
                         override_cargo: config.override_cargo,
@@ -905,7 +909,7 @@ pub(crate) fn handle_runnables(
                         cargo_extra_args: config.cargo_extra_args,
                         executable_args: Vec::new(),
                         expect_test: None,
-                    },
+                    }),
                 });
             }
         }
@@ -931,7 +935,7 @@ pub(crate) fn handle_related_tests(
     let tests = snap.analysis.related_tests(position, None)?;
     let mut res = Vec::new();
     for it in tests {
-        if let Ok(runnable) = to_proto::runnable(&snap, it) {
+        if let Ok(Some(runnable)) = to_proto::runnable(&snap, it) {
             res.push(lsp_ext::TestInfo { runnable })
         }
     }
@@ -1397,14 +1401,14 @@ pub(crate) fn handle_code_lens(
     }
 
     let file_id = from_proto::file_id(&snap, &params.text_document.uri)?;
-    let cargo_target_spec = CargoTargetSpec::for_file(&snap, file_id)?;
+    let target_spec = TargetSpec::for_file(&snap, file_id)?;
 
     let annotations = snap.analysis.annotations(
         &AnnotationConfig {
-            binary_target: cargo_target_spec
+            binary_target: target_spec
                 .map(|spec| {
                     matches!(
-                        spec.target_kind,
+                        spec.target_kind(),
                         TargetKind::Bin | TargetKind::Example | TargetKind::Test
                     )
                 })
@@ -1824,9 +1828,9 @@ pub(crate) fn handle_open_cargo_toml(
     let _p = tracing::info_span!("handle_open_cargo_toml").entered();
     let file_id = from_proto::file_id(&snap, &params.text_document.uri)?;
 
-    let cargo_spec = match CargoTargetSpec::for_file(&snap, file_id)? {
-        Some(it) => it,
-        None => return Ok(None),
+    let cargo_spec = match TargetSpec::for_file(&snap, file_id)? {
+        Some(TargetSpec::Cargo(it)) => it,
+        Some(TargetSpec::ProjectJson(_)) | None => return Ok(None),
     };
 
     let cargo_toml_url = to_proto::url_from_abs_path(&cargo_spec.cargo_toml);
@@ -1954,8 +1958,8 @@ fn runnable_action_links(
         return None;
     }
 
-    let cargo_spec = CargoTargetSpec::for_file(snap, runnable.nav.file_id).ok()?;
-    if should_skip_target(&runnable, cargo_spec.as_ref()) {
+    let target_spec = TargetSpec::for_file(snap, runnable.nav.file_id).ok()?;
+    if should_skip_target(&runnable, target_spec.as_ref()) {
         return None;
     }
 
@@ -1965,7 +1969,7 @@ fn runnable_action_links(
     }
 
     let title = runnable.title();
-    let r = to_proto::runnable(snap, runnable).ok()?;
+    let r = to_proto::runnable(snap, runnable).ok()??;
 
     let mut group = lsp_ext::CommandLinkGroup::default();
 
@@ -2020,13 +2024,13 @@ fn prepare_hover_actions(
         .collect()
 }
 
-fn should_skip_target(runnable: &Runnable, cargo_spec: Option<&CargoTargetSpec>) -> bool {
+fn should_skip_target(runnable: &Runnable, cargo_spec: Option<&TargetSpec>) -> bool {
     match runnable.kind {
         RunnableKind::Bin => {
             // Do not suggest binary run on other target than binary
             match &cargo_spec {
                 Some(spec) => !matches!(
-                    spec.target_kind,
+                    spec.target_kind(),
                     TargetKind::Bin | TargetKind::Example | TargetKind::Test
                 ),
                 None => true,
@@ -2103,9 +2107,9 @@ fn run_rustfmt(
         }
         RustfmtConfig::CustomCommand { command, args } => {
             let cmd = Utf8PathBuf::from(&command);
-            let workspace = CargoTargetSpec::for_file(snap, file_id)?;
-            let mut cmd = match workspace {
-                Some(spec) => {
+            let target_spec = TargetSpec::for_file(snap, file_id)?;
+            let mut cmd = match target_spec {
+                Some(TargetSpec::Cargo(spec)) => {
                     // approach: if the command name contains a path separator, join it with the workspace root.
                     // however, if the path is absolute, joining will result in the absolute path being preserved.
                     // as a fallback, rely on $PATH-based discovery.
@@ -2118,7 +2122,7 @@ fn run_rustfmt(
                     };
                     process::Command::new(cmd_path)
                 }
-                None => process::Command::new(cmd),
+                _ => process::Command::new(cmd),
             };
 
             cmd.envs(snap.config.extra_env());
diff --git a/src/tools/rust-analyzer/crates/rust-analyzer/src/lib.rs b/src/tools/rust-analyzer/crates/rust-analyzer/src/lib.rs
index b3c11d0156e..a398e98f093 100644
--- a/src/tools/rust-analyzer/crates/rust-analyzer/src/lib.rs
+++ b/src/tools/rust-analyzer/crates/rust-analyzer/src/lib.rs
@@ -14,7 +14,6 @@
 pub mod cli;
 
 mod caps;
-mod cargo_target_spec;
 mod diagnostics;
 mod diff;
 mod dispatch;
@@ -24,6 +23,7 @@ mod main_loop;
 mod mem_docs;
 mod op_queue;
 mod reload;
+mod target_spec;
 mod task_pool;
 mod version;
 
diff --git a/src/tools/rust-analyzer/crates/rust-analyzer/src/lsp/ext.rs b/src/tools/rust-analyzer/crates/rust-analyzer/src/lsp/ext.rs
index 4da9054d13d..b82ba441904 100644
--- a/src/tools/rust-analyzer/crates/rust-analyzer/src/lsp/ext.rs
+++ b/src/tools/rust-analyzer/crates/rust-analyzer/src/lsp/ext.rs
@@ -3,7 +3,6 @@
 #![allow(clippy::disallowed_types)]
 
 use std::ops;
-use std::path::PathBuf;
 
 use ide_db::line_index::WideEncoding;
 use lsp_types::request::Request;
@@ -12,6 +11,7 @@ use lsp_types::{
     PartialResultParams, Position, Range, TextDocumentIdentifier, WorkDoneProgressParams,
 };
 use lsp_types::{PositionEncodingKind, Url};
+use paths::Utf8PathBuf;
 use rustc_hash::FxHashMap;
 use serde::{Deserialize, Serialize};
 
@@ -439,24 +439,33 @@ pub struct Runnable {
     #[serde(skip_serializing_if = "Option::is_none")]
     pub location: Option<lsp_types::LocationLink>,
     pub kind: RunnableKind,
-    pub args: CargoRunnable,
+    pub args: RunnableArgs,
+}
+
+#[derive(Deserialize, Serialize, Debug)]
+#[serde(rename_all = "camelCase")]
+#[serde(untagged)]
+pub enum RunnableArgs {
+    Cargo(CargoRunnableArgs),
+    Shell(ShellRunnableArgs),
 }
 
 #[derive(Serialize, Deserialize, Debug)]
 #[serde(rename_all = "lowercase")]
 pub enum RunnableKind {
     Cargo,
+    Shell,
 }
 
 #[derive(Deserialize, Serialize, Debug)]
 #[serde(rename_all = "camelCase")]
-pub struct CargoRunnable {
+pub struct CargoRunnableArgs {
     // command to be executed instead of cargo
     pub override_cargo: Option<String>,
     #[serde(skip_serializing_if = "Option::is_none")]
-    pub workspace_root: Option<PathBuf>,
+    pub workspace_root: Option<Utf8PathBuf>,
     #[serde(skip_serializing_if = "Option::is_none")]
-    pub cwd: Option<PathBuf>,
+    pub cwd: Option<Utf8PathBuf>,
     // command, --package and --lib stuff
     pub cargo_args: Vec<String>,
     // user-specified additional cargo args, like `--release`.
@@ -467,6 +476,14 @@ pub struct CargoRunnable {
     pub expect_test: Option<bool>,
 }
 
+#[derive(Deserialize, Serialize, Debug)]
+#[serde(rename_all = "camelCase")]
+pub struct ShellRunnableArgs {
+    pub program: String,
+    pub args: Vec<String>,
+    pub cwd: Utf8PathBuf,
+}
+
 pub enum RelatedTests {}
 
 impl Request for RelatedTests {
diff --git a/src/tools/rust-analyzer/crates/rust-analyzer/src/lsp/to_proto.rs b/src/tools/rust-analyzer/crates/rust-analyzer/src/lsp/to_proto.rs
index 86368c9eea8..db5f666a5b9 100644
--- a/src/tools/rust-analyzer/crates/rust-analyzer/src/lsp/to_proto.rs
+++ b/src/tools/rust-analyzer/crates/rust-analyzer/src/lsp/to_proto.rs
@@ -21,16 +21,17 @@ use serde_json::to_value;
 use vfs::AbsPath;
 
 use crate::{
-    cargo_target_spec::CargoTargetSpec,
     config::{CallInfoConfig, Config},
     global_state::GlobalStateSnapshot,
     line_index::{LineEndings, LineIndex, PositionEncoding},
     lsp::{
+        ext::ShellRunnableArgs,
         semantic_tokens::{self, standard_fallback_type},
         utils::invalid_params_error,
         LspError,
     },
     lsp_ext::{self, SnippetTextEdit},
+    target_spec::{CargoTargetSpec, TargetSpec},
 };
 
 pub(crate) fn position(line_index: &LineIndex, offset: TextSize) -> lsp_types::Position {
@@ -1356,34 +1357,90 @@ pub(crate) fn code_action(
 pub(crate) fn runnable(
     snap: &GlobalStateSnapshot,
     runnable: Runnable,
-) -> Cancellable<lsp_ext::Runnable> {
+) -> Cancellable<Option<lsp_ext::Runnable>> {
     let config = snap.config.runnables();
-    let spec = CargoTargetSpec::for_file(snap, runnable.nav.file_id)?;
-    let workspace_root = spec.as_ref().map(|it| it.workspace_root.clone());
-    let cwd = match runnable.kind {
-        ide::RunnableKind::Bin { .. } => workspace_root.clone().map(|it| it.into()),
-        _ => spec.as_ref().map(|it| it.cargo_toml.parent().into()),
-    };
-    let target = spec.as_ref().map(|s| s.target.as_str());
-    let label = runnable.label(target);
-    let (cargo_args, executable_args) =
-        CargoTargetSpec::runnable_args(snap, spec, &runnable.kind, &runnable.cfg);
-    let location = location_link(snap, None, runnable.nav)?;
+    let target_spec = TargetSpec::for_file(snap, runnable.nav.file_id)?;
 
-    Ok(lsp_ext::Runnable {
-        label,
-        location: Some(location),
-        kind: lsp_ext::RunnableKind::Cargo,
-        args: lsp_ext::CargoRunnable {
-            workspace_root: workspace_root.map(|it| it.into()),
-            cwd,
-            override_cargo: config.override_cargo,
-            cargo_args,
-            cargo_extra_args: config.cargo_extra_args,
-            executable_args,
-            expect_test: None,
-        },
-    })
+    match target_spec {
+        Some(TargetSpec::Cargo(spec)) => {
+            let workspace_root = spec.workspace_root.clone();
+
+            let target = spec.target.clone();
+
+            let (cargo_args, executable_args) = CargoTargetSpec::runnable_args(
+                snap,
+                Some(spec.clone()),
+                &runnable.kind,
+                &runnable.cfg,
+            );
+
+            let cwd = match runnable.kind {
+                ide::RunnableKind::Bin { .. } => workspace_root.clone(),
+                _ => spec.cargo_toml.parent().to_owned(),
+            };
+
+            let label = runnable.label(Some(&target));
+            let location = location_link(snap, None, runnable.nav)?;
+
+            Ok(Some(lsp_ext::Runnable {
+                label,
+                location: Some(location),
+                kind: lsp_ext::RunnableKind::Cargo,
+                args: lsp_ext::RunnableArgs::Cargo(lsp_ext::CargoRunnableArgs {
+                    workspace_root: Some(workspace_root.into()),
+                    override_cargo: config.override_cargo,
+                    cargo_args,
+                    cwd: Some(cwd.into()),
+                    cargo_extra_args: config.cargo_extra_args,
+                    executable_args,
+                    expect_test: None,
+                }),
+            }))
+        }
+        Some(TargetSpec::ProjectJson(spec)) => {
+            let label = runnable.label(Some(&spec.label));
+            let location = location_link(snap, None, runnable.nav)?;
+
+            match spec.runnable_args(&runnable.kind) {
+                Some(json_shell_runnable_args) => {
+                    let runnable_args = ShellRunnableArgs {
+                        program: json_shell_runnable_args.program,
+                        args: json_shell_runnable_args.args,
+                        cwd: json_shell_runnable_args.cwd,
+                    };
+                    Ok(Some(lsp_ext::Runnable {
+                        label,
+                        location: Some(location),
+                        kind: lsp_ext::RunnableKind::Shell,
+                        args: lsp_ext::RunnableArgs::Shell(runnable_args),
+                    }))
+                }
+                None => Ok(None),
+            }
+        }
+        None => {
+            let (cargo_args, executable_args) =
+                CargoTargetSpec::runnable_args(snap, None, &runnable.kind, &runnable.cfg);
+
+            let label = runnable.label(None);
+            let location = location_link(snap, None, runnable.nav)?;
+
+            Ok(Some(lsp_ext::Runnable {
+                label,
+                location: Some(location),
+                kind: lsp_ext::RunnableKind::Cargo,
+                args: lsp_ext::RunnableArgs::Cargo(lsp_ext::CargoRunnableArgs {
+                    workspace_root: None,
+                    override_cargo: config.override_cargo,
+                    cargo_args,
+                    cwd: None,
+                    cargo_extra_args: config.cargo_extra_args,
+                    executable_args,
+                    expect_test: None,
+                }),
+            }))
+        }
+    }
 }
 
 pub(crate) fn code_lens(
@@ -1407,33 +1464,37 @@ pub(crate) fn code_lens(
             };
             let r = runnable(snap, run)?;
 
-            let lens_config = snap.config.lens();
-            if lens_config.run
-                && client_commands_config.run_single
-                && r.args.workspace_root.is_some()
-            {
-                let command = command::run_single(&r, &title);
-                acc.push(lsp_types::CodeLens {
-                    range: annotation_range,
-                    command: Some(command),
-                    data: None,
-                })
-            }
-            if lens_config.debug && can_debug && client_commands_config.debug_single {
-                let command = command::debug_single(&r);
-                acc.push(lsp_types::CodeLens {
-                    range: annotation_range,
-                    command: Some(command),
-                    data: None,
-                })
-            }
-            if lens_config.interpret {
-                let command = command::interpret_single(&r);
-                acc.push(lsp_types::CodeLens {
-                    range: annotation_range,
-                    command: Some(command),
-                    data: None,
-                })
+            if let Some(r) = r {
+                let has_root = match &r.args {
+                    lsp_ext::RunnableArgs::Cargo(c) => c.workspace_root.is_some(),
+                    lsp_ext::RunnableArgs::Shell(_) => true,
+                };
+
+                let lens_config = snap.config.lens();
+                if lens_config.run && client_commands_config.run_single && has_root {
+                    let command = command::run_single(&r, &title);
+                    acc.push(lsp_types::CodeLens {
+                        range: annotation_range,
+                        command: Some(command),
+                        data: None,
+                    })
+                }
+                if lens_config.debug && can_debug && client_commands_config.debug_single {
+                    let command = command::debug_single(&r);
+                    acc.push(lsp_types::CodeLens {
+                        range: annotation_range,
+                        command: Some(command),
+                        data: None,
+                    })
+                }
+                if lens_config.interpret {
+                    let command = command::interpret_single(&r);
+                    acc.push(lsp_types::CodeLens {
+                        range: annotation_range,
+                        command: Some(command),
+                        data: None,
+                    })
+                }
             }
         }
         AnnotationKind::HasImpls { pos, data } => {
@@ -1538,12 +1599,8 @@ pub(crate) fn test_item(
         id: test_item.id,
         label: test_item.label,
         kind: match test_item.kind {
-            ide::TestItemKind::Crate(id) => 'b: {
-                let Some((cargo_ws, target)) = snap.cargo_target_for_crate_root(id) else {
-                    break 'b lsp_ext::TestItemKind::Package;
-                };
-                let target = &cargo_ws[target];
-                match target.kind {
+            ide::TestItemKind::Crate(id) => match snap.target_spec_for_crate(id) {
+                Some(target_spec) => match target_spec.target_kind() {
                     project_model::TargetKind::Bin
                     | project_model::TargetKind::Lib { .. }
                     | project_model::TargetKind::Example
@@ -1552,8 +1609,9 @@ pub(crate) fn test_item(
                     project_model::TargetKind::Test => lsp_ext::TestItemKind::Test,
                     // benches are not tests needed to be shown in the test explorer
                     project_model::TargetKind::Bench => return None,
-                }
-            }
+                },
+                None => lsp_ext::TestItemKind::Package,
+            },
             ide::TestItemKind::Module => lsp_ext::TestItemKind::Module,
             ide::TestItemKind::Function => lsp_ext::TestItemKind::Test,
         },
@@ -1566,7 +1624,7 @@ pub(crate) fn test_item(
             .file
             .map(|f| lsp_types::TextDocumentIdentifier { uri: url(snap, f) }),
         range: line_index.and_then(|l| Some(range(l, test_item.text_range?))),
-        runnable: test_item.runnable.and_then(|r| runnable(snap, r).ok()),
+        runnable: test_item.runnable.and_then(|r| runnable(snap, r).ok()).flatten(),
     })
 }
 
diff --git a/src/tools/rust-analyzer/crates/rust-analyzer/src/cargo_target_spec.rs b/src/tools/rust-analyzer/crates/rust-analyzer/src/target_spec.rs
index 693a35b91e6..6145f7e05f9 100644
--- a/src/tools/rust-analyzer/crates/rust-analyzer/src/cargo_target_spec.rs
+++ b/src/tools/rust-analyzer/crates/rust-analyzer/src/target_spec.rs
@@ -1,20 +1,52 @@
-//! See `CargoTargetSpec`
+//! See `TargetSpec`
 
 use std::mem;
 
 use cfg::{CfgAtom, CfgExpr};
 use ide::{Cancellable, CrateId, FileId, RunnableKind, TestId};
+use project_model::project_json::Runnable;
 use project_model::{CargoFeatures, ManifestPath, TargetKind};
 use rustc_hash::FxHashSet;
 use vfs::AbsPathBuf;
 
 use crate::global_state::GlobalStateSnapshot;
 
+/// A target represents a thing we can build or test.
+///
+/// We use it to calculate the CLI arguments required to build, run or
+/// test the target.
+#[derive(Clone, Debug)]
+pub(crate) enum TargetSpec {
+    Cargo(CargoTargetSpec),
+    ProjectJson(ProjectJsonTargetSpec),
+}
+
+impl TargetSpec {
+    pub(crate) fn for_file(
+        global_state_snapshot: &GlobalStateSnapshot,
+        file_id: FileId,
+    ) -> Cancellable<Option<Self>> {
+        let crate_id = match &*global_state_snapshot.analysis.crates_for(file_id)? {
+            &[crate_id, ..] => crate_id,
+            _ => return Ok(None),
+        };
+
+        Ok(global_state_snapshot.target_spec_for_crate(crate_id))
+    }
+
+    pub(crate) fn target_kind(&self) -> TargetKind {
+        match self {
+            TargetSpec::Cargo(cargo) => cargo.target_kind,
+            TargetSpec::ProjectJson(project_json) => project_json.target_kind,
+        }
+    }
+}
+
 /// Abstract representation of Cargo target.
 ///
 /// We use it to cook up the set of cli args we need to pass to Cargo to
 /// build/test/run the target.
-#[derive(Clone)]
+#[derive(Clone, Debug)]
 pub(crate) struct CargoTargetSpec {
     pub(crate) workspace_root: AbsPathBuf,
     pub(crate) cargo_toml: ManifestPath,
@@ -26,6 +58,51 @@ pub(crate) struct CargoTargetSpec {
     pub(crate) features: FxHashSet<String>,
 }
 
+#[derive(Clone, Debug)]
+pub(crate) struct ProjectJsonTargetSpec {
+    pub(crate) label: String,
+    pub(crate) target_kind: TargetKind,
+    pub(crate) shell_runnables: Vec<Runnable>,
+}
+
+impl ProjectJsonTargetSpec {
+    pub(crate) fn runnable_args(&self, kind: &RunnableKind) -> Option<Runnable> {
+        match kind {
+            RunnableKind::Bin => {
+                for runnable in &self.shell_runnables {
+                    if matches!(runnable.kind, project_model::project_json::RunnableKind::Run) {
+                        return Some(runnable.clone());
+                    }
+                }
+
+                None
+            }
+            RunnableKind::Test { test_id, .. } => {
+                for runnable in &self.shell_runnables {
+                    if matches!(runnable.kind, project_model::project_json::RunnableKind::TestOne) {
+                        let mut runnable = runnable.clone();
+
+                        let replaced_args: Vec<_> = runnable
+                            .args
+                            .iter()
+                            .map(|arg| arg.replace("{test_id}", &test_id.to_string()))
+                            .map(|arg| arg.replace("{label}", &self.label))
+                            .collect();
+                        runnable.args = replaced_args;
+
+                        return Some(runnable);
+                    }
+                }
+
+                None
+            }
+            RunnableKind::TestMod { .. } => None,
+            RunnableKind::Bench { .. } => None,
+            RunnableKind::DocTest { .. } => None,
+        }
+    }
+}
+
 impl CargoTargetSpec {
     pub(crate) fn runnable_args(
         snap: &GlobalStateSnapshot,
@@ -122,35 +199,6 @@ impl CargoTargetSpec {
         (cargo_args, executable_args)
     }
 
-    pub(crate) fn for_file(
-        global_state_snapshot: &GlobalStateSnapshot,
-        file_id: FileId,
-    ) -> Cancellable<Option<CargoTargetSpec>> {
-        let crate_id = match &*global_state_snapshot.analysis.crates_for(file_id)? {
-            &[crate_id, ..] => crate_id,
-            _ => return Ok(None),
-        };
-        let (cargo_ws, target) = match global_state_snapshot.cargo_target_for_crate_root(crate_id) {
-            Some(it) => it,
-            None => return Ok(None),
-        };
-
-        let target_data = &cargo_ws[target];
-        let package_data = &cargo_ws[target_data.package];
-        let res = CargoTargetSpec {
-            workspace_root: cargo_ws.workspace_root().to_path_buf(),
-            cargo_toml: package_data.manifest.clone(),
-            package: cargo_ws.package_flag(package_data),
-            target: target_data.name.clone(),
-            target_kind: target_data.kind,
-            required_features: target_data.required_features.clone(),
-            features: package_data.features.keys().cloned().collect(),
-            crate_id,
-        };
-
-        Ok(Some(res))
-    }
-
     pub(crate) fn push_to(self, buf: &mut Vec<String>, kind: &RunnableKind) {
         buf.push("--package".to_owned());
         buf.push(self.package);
diff --git a/src/tools/rust-analyzer/docs/dev/lsp-extensions.md b/src/tools/rust-analyzer/docs/dev/lsp-extensions.md
index 100662f4ceb..695fec7e8e0 100644
--- a/src/tools/rust-analyzer/docs/dev/lsp-extensions.md
+++ b/src/tools/rust-analyzer/docs/dev/lsp-extensions.md
@@ -1,5 +1,5 @@
 <!---
-lsp/ext.rs hash: a85ec97f07c6a2e3
+lsp/ext.rs hash: 8e6e340f2899b5e9
 
 If you need to change the above hash to make the test pass, please check if you
 need to adjust this doc as well and ping this issue:
@@ -372,7 +372,7 @@ interface Runnable {
 }
 ```
 
-rust-analyzer supports only one `kind`, `"cargo"`. The `args` for `"cargo"` look like this:
+rust-analyzer supports two `kind`s of runnables, `"cargo"` and `"shell"`. The `args` for `"cargo"` look like this:
 
 ```typescript
 {
@@ -386,6 +386,17 @@ rust-analyzer supports only one `kind`, `"cargo"`. The `args` for `"cargo"` look
 }
 ```
 
+The args for `"shell"` look like this:
+
+```typescript
+{
+    kind: string;
+    program: string;
+    args: string[];
+    cwd: string;
+}
+```
+
 ## Test explorer
 
 **Experimental Client Capability:** `{ "testExplorer": boolean }`
diff --git a/src/tools/rust-analyzer/editors/code/src/commands.ts b/src/tools/rust-analyzer/editors/code/src/commands.ts
index 9f4930c94a2..5cec2c61a5b 100644
--- a/src/tools/rust-analyzer/editors/code/src/commands.ts
+++ b/src/tools/rust-analyzer/editors/code/src/commands.ts
@@ -9,10 +9,11 @@ import {
     applySnippetTextEdits,
     type SnippetTextDocumentEdit,
 } from "./snippets";
-import { type RunnableQuickPick, selectRunnable, createTask, createArgs } from "./run";
+import { type RunnableQuickPick, selectRunnable, createTask, createCargoArgs } from "./run";
 import { AstInspector } from "./ast_inspector";
 import {
     isRustDocument,
+    isCargoRunnableArgs,
     isCargoTomlDocument,
     sleep,
     isRustEditor,
@@ -1154,8 +1155,8 @@ export function copyRunCommandLine(ctx: CtxInit) {
     let prevRunnable: RunnableQuickPick | undefined;
     return async () => {
         const item = await selectRunnable(ctx, prevRunnable);
-        if (!item) return;
-        const args = createArgs(item.runnable);
+        if (!item || !isCargoRunnableArgs(item.runnable.args)) return;
+        const args = createCargoArgs(item.runnable.args);
         const commandLine = ["cargo", ...args].join(" ");
         await vscode.env.clipboard.writeText(commandLine);
         await vscode.window.showInformationMessage("Cargo invocation copied to the clipboard.");
diff --git a/src/tools/rust-analyzer/editors/code/src/debug.ts b/src/tools/rust-analyzer/editors/code/src/debug.ts
index 4b96e4d5c8e..eef2f6f4ee2 100644
--- a/src/tools/rust-analyzer/editors/code/src/debug.ts
+++ b/src/tools/rust-analyzer/editors/code/src/debug.ts
@@ -7,10 +7,12 @@ import { Cargo, getRustcId, getSysroot } from "./toolchain";
 import type { Ctx } from "./ctx";
 import { prepareEnv } from "./run";
 import { unwrapUndefinable } from "./undefinable";
+import { isCargoRunnableArgs } from "./util";
 
 const debugOutput = vscode.window.createOutputChannel("Debug");
 type DebugConfigProvider = (
-    config: ra.Runnable,
+    runnable: ra.Runnable,
+    runnableArgs: ra.CargoRunnableArgs,
     executable: string,
     env: Record<string, string>,
     sourceFileMap?: Record<string, string>,
@@ -76,6 +78,11 @@ async function getDebugConfiguration(
     ctx: Ctx,
     runnable: ra.Runnable,
 ): Promise<vscode.DebugConfiguration | undefined> {
+    if (!isCargoRunnableArgs(runnable.args)) {
+        return;
+    }
+    const runnableArgs: ra.CargoRunnableArgs = runnable.args;
+
     const editor = ctx.activeRustEditor;
     if (!editor) return;
 
@@ -119,9 +126,9 @@ async function getDebugConfiguration(
     const isMultiFolderWorkspace = workspaceFolders.length > 1;
     const firstWorkspace = workspaceFolders[0];
     const maybeWorkspace =
-        !isMultiFolderWorkspace || !runnable.args.workspaceRoot
+        !isMultiFolderWorkspace || !runnableArgs.workspaceRoot
             ? firstWorkspace
-            : workspaceFolders.find((w) => runnable.args.workspaceRoot?.includes(w.uri.fsPath)) ||
+            : workspaceFolders.find((w) => runnableArgs.workspaceRoot?.includes(w.uri.fsPath)) ||
               firstWorkspace;
 
     const workspace = unwrapUndefinable(maybeWorkspace);
@@ -132,8 +139,8 @@ async function getDebugConfiguration(
         return path.normalize(p).replace(wsFolder, "${workspaceFolder" + workspaceQualifier + "}");
     }
 
-    const env = prepareEnv(runnable, ctx.config.runnablesExtraEnv);
-    const executable = await getDebugExecutable(runnable, env);
+    const env = prepareEnv(runnable.label, runnableArgs, ctx.config.runnablesExtraEnv);
+    const executable = await getDebugExecutable(runnableArgs, env);
     let sourceFileMap = debugOptions.sourceFileMap;
     if (sourceFileMap === "auto") {
         // let's try to use the default toolchain
@@ -147,7 +154,7 @@ async function getDebugConfiguration(
     }
 
     const provider = unwrapUndefinable(knownEngines[debugEngine.id]);
-    const debugConfig = provider(runnable, simplifyPath(executable), env, sourceFileMap);
+    const debugConfig = provider(runnable, runnableArgs, simplifyPath(executable), env);
     if (debugConfig.type in debugOptions.engineSettings) {
         const settingsMap = (debugOptions.engineSettings as any)[debugConfig.type];
         for (var key in settingsMap) {
@@ -170,11 +177,11 @@ async function getDebugConfiguration(
 }
 
 async function getDebugExecutable(
-    runnable: ra.Runnable,
+    runnableArgs: ra.CargoRunnableArgs,
     env: Record<string, string>,
 ): Promise<string> {
-    const cargo = new Cargo(runnable.args.workspaceRoot || ".", debugOutput, env);
-    const executable = await cargo.executableFromArgs(runnable.args.cargoArgs);
+    const cargo = new Cargo(runnableArgs.workspaceRoot || ".", debugOutput, env);
+    const executable = await cargo.executableFromArgs(runnableArgs.cargoArgs);
 
     // if we are here, there were no compilation errors.
     return executable;
@@ -182,6 +189,7 @@ async function getDebugExecutable(
 
 function getCCppDebugConfig(
     runnable: ra.Runnable,
+    runnableArgs: ra.CargoRunnableArgs,
     executable: string,
     env: Record<string, string>,
     sourceFileMap?: Record<string, string>,
@@ -191,8 +199,8 @@ function getCCppDebugConfig(
         request: "launch",
         name: runnable.label,
         program: executable,
-        args: runnable.args.executableArgs,
-        cwd: runnable.args.cwd || runnable.args.workspaceRoot || ".",
+        args: runnableArgs.executableArgs,
+        cwd: runnable.args.cwd || runnableArgs.workspaceRoot || ".",
         sourceFileMap,
         environment: Object.entries(env).map((entry) => ({
             name: entry[0],
@@ -207,6 +215,7 @@ function getCCppDebugConfig(
 
 function getCodeLldbDebugConfig(
     runnable: ra.Runnable,
+    runnableArgs: ra.CargoRunnableArgs,
     executable: string,
     env: Record<string, string>,
     sourceFileMap?: Record<string, string>,
@@ -216,8 +225,8 @@ function getCodeLldbDebugConfig(
         request: "launch",
         name: runnable.label,
         program: executable,
-        args: runnable.args.executableArgs,
-        cwd: runnable.args.cwd || runnable.args.workspaceRoot || ".",
+        args: runnableArgs.executableArgs,
+        cwd: runnable.args.cwd || runnableArgs.workspaceRoot || ".",
         sourceMap: sourceFileMap,
         sourceLanguages: ["rust"],
         env,
@@ -226,6 +235,7 @@ function getCodeLldbDebugConfig(
 
 function getNativeDebugConfig(
     runnable: ra.Runnable,
+    runnableArgs: ra.CargoRunnableArgs,
     executable: string,
     env: Record<string, string>,
     _sourceFileMap?: Record<string, string>,
@@ -236,8 +246,8 @@ function getNativeDebugConfig(
         name: runnable.label,
         target: executable,
         // See https://github.com/WebFreak001/code-debug/issues/359
-        arguments: quote(runnable.args.executableArgs),
-        cwd: runnable.args.cwd || runnable.args.workspaceRoot || ".",
+        arguments: quote(runnableArgs.executableArgs),
+        cwd: runnable.args.cwd || runnableArgs.workspaceRoot || ".",
         env,
         valuesFormatting: "prettyPrinters",
     };
diff --git a/src/tools/rust-analyzer/editors/code/src/lsp_ext.ts b/src/tools/rust-analyzer/editors/code/src/lsp_ext.ts
index 8e48aeef158..2462f06f18b 100644
--- a/src/tools/rust-analyzer/editors/code/src/lsp_ext.ts
+++ b/src/tools/rust-analyzer/editors/code/src/lsp_ext.ts
@@ -223,17 +223,27 @@ export type OpenCargoTomlParams = {
 export type Runnable = {
     label: string;
     location?: lc.LocationLink;
-    kind: "cargo";
-    args: {
-        workspaceRoot?: string;
-        cwd?: string;
-        cargoArgs: string[];
-        cargoExtraArgs: string[];
-        executableArgs: string[];
-        expectTest?: boolean;
-        overrideCargo?: string;
-    };
+    kind: "cargo" | "shell";
+    args: CargoRunnableArgs | ShellRunnableArgs;
 };
+
+export type ShellRunnableArgs = {
+    kind: string;
+    program: string;
+    args: string[];
+    cwd: string;
+};
+
+export type CargoRunnableArgs = {
+    workspaceRoot?: string;
+    cargoArgs: string[];
+    cwd: string;
+    cargoExtraArgs: string[];
+    executableArgs: string[];
+    expectTest?: boolean;
+    overrideCargo?: string;
+};
+
 export type RunnablesParams = {
     textDocument: lc.TextDocumentIdentifier;
     position: lc.Position | null;
diff --git a/src/tools/rust-analyzer/editors/code/src/run.ts b/src/tools/rust-analyzer/editors/code/src/run.ts
index 4470689cd8c..52117a442a1 100644
--- a/src/tools/rust-analyzer/editors/code/src/run.ts
+++ b/src/tools/rust-analyzer/editors/code/src/run.ts
@@ -9,6 +9,7 @@ import type { Config, RunnableEnvCfg, RunnableEnvCfgItem } from "./config";
 import { unwrapUndefinable } from "./undefinable";
 import type { LanguageClient } from "vscode-languageclient/node";
 import type { RustEditor } from "./util";
+import * as toolchain from "./toolchain";
 
 const quickPickButtons = [
     { iconPath: new vscode.ThemeIcon("save"), tooltip: "Save as a launch.json configuration." },
@@ -66,17 +67,23 @@ export class RunnableQuickPick implements vscode.QuickPickItem {
     }
 }
 
+export function prepareBaseEnv(): Record<string, string> {
+    const env: Record<string, string> = { RUST_BACKTRACE: "short" };
+    Object.assign(env, process.env as { [key: string]: string });
+    return env;
+}
+
 export function prepareEnv(
-    runnable: ra.Runnable,
+    label: string,
+    runnableArgs: ra.CargoRunnableArgs,
     runnableEnvCfg: RunnableEnvCfg,
 ): Record<string, string> {
-    const env: Record<string, string> = { RUST_BACKTRACE: "short" };
+    const env = prepareBaseEnv();
 
-    if (runnable.args.expectTest) {
+    if (runnableArgs.expectTest) {
         env["UPDATE_EXPECT"] = "1";
     }
 
-    Object.assign(env, process.env as { [key: string]: string });
     const platform = process.platform;
 
     const checkPlatform = (it: RunnableEnvCfgItem) => {
@@ -90,7 +97,7 @@ export function prepareEnv(
     if (runnableEnvCfg) {
         if (Array.isArray(runnableEnvCfg)) {
             for (const it of runnableEnvCfg) {
-                const masked = !it.mask || new RegExp(it.mask).test(runnable.label);
+                const masked = !it.mask || new RegExp(it.mask).test(label);
                 if (masked && checkPlatform(it)) {
                     Object.assign(env, it.env);
                 }
@@ -104,24 +111,41 @@ export function prepareEnv(
 }
 
 export async function createTask(runnable: ra.Runnable, config: Config): Promise<vscode.Task> {
-    if (runnable.kind !== "cargo") {
-        // rust-analyzer supports only one kind, "cargo"
-        // do not use tasks.TASK_TYPE here, these are completely different meanings.
+    let definition: tasks.RustTargetDefinition;
+    if (runnable.kind === "cargo") {
+        const runnableArgs = runnable.args as ra.CargoRunnableArgs;
+        let args = createCargoArgs(runnableArgs);
+
+        let program: string;
+        if (runnableArgs.overrideCargo) {
+            // Split on spaces to allow overrides like "wrapper cargo".
+            const cargoParts = runnableArgs.overrideCargo.split(" ");
+
+            program = unwrapUndefinable(cargoParts[0]);
+            args = [...cargoParts.slice(1), ...args];
+        } else {
+            program = await toolchain.cargoPath();
+        }
 
-        throw `Unexpected runnable kind: ${runnable.kind}`;
+        definition = {
+            type: tasks.TASK_TYPE,
+            command: program,
+            args,
+            cwd: runnableArgs.workspaceRoot || ".",
+            env: prepareEnv(runnable.label, runnableArgs, config.runnablesExtraEnv),
+        };
+    } else {
+        const runnableArgs = runnable.args as ra.ShellRunnableArgs;
+
+        definition = {
+            type: "shell",
+            command: runnableArgs.program,
+            args: runnableArgs.args,
+            cwd: runnableArgs.cwd,
+            env: prepareBaseEnv(),
+        };
     }
 
-    const args = createArgs(runnable);
-
-    const definition: tasks.CargoTaskDefinition = {
-        type: tasks.TASK_TYPE,
-        command: unwrapUndefinable(args[0]), // run, test, etc...
-        args: args.slice(1),
-        cwd: runnable.args.workspaceRoot || ".",
-        env: prepareEnv(runnable, config.runnablesExtraEnv),
-        overrideCargo: runnable.args.overrideCargo,
-    };
-
     // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
     const target = vscode.workspace.workspaceFolders![0]; // safe, see main activate()
     const task = await tasks.buildRustTask(
@@ -141,13 +165,13 @@ export async function createTask(runnable: ra.Runnable, config: Config): Promise
     return task;
 }
 
-export function createArgs(runnable: ra.Runnable): string[] {
-    const args = [...runnable.args.cargoArgs]; // should be a copy!
-    if (runnable.args.cargoExtraArgs) {
-        args.push(...runnable.args.cargoExtraArgs); // Append user-specified cargo options.
+export function createCargoArgs(runnableArgs: ra.CargoRunnableArgs): string[] {
+    const args = [...runnableArgs.cargoArgs]; // should be a copy!
+    if (runnableArgs.cargoExtraArgs) {
+        args.push(...runnableArgs.cargoExtraArgs); // Append user-specified cargo options.
     }
-    if (runnable.args.executableArgs.length > 0) {
-        args.push("--", ...runnable.args.executableArgs);
+    if (runnableArgs.executableArgs.length > 0) {
+        args.push("--", ...runnableArgs.executableArgs);
     }
     return args;
 }
diff --git a/src/tools/rust-analyzer/editors/code/src/tasks.ts b/src/tools/rust-analyzer/editors/code/src/tasks.ts
index 2b3abc5d65f..c28a919231b 100644
--- a/src/tools/rust-analyzer/editors/code/src/tasks.ts
+++ b/src/tools/rust-analyzer/editors/code/src/tasks.ts
@@ -10,7 +10,7 @@ export const TASK_TYPE = "cargo";
 
 export const TASK_SOURCE = "rust";
 
-export interface CargoTaskDefinition extends vscode.TaskDefinition {
+export interface RustTargetDefinition extends vscode.TaskDefinition {
     // The cargo command, such as "run" or "check".
     command: string;
     // Additional arguments passed to the cargo command.
@@ -69,7 +69,7 @@ class RustTaskProvider implements vscode.TaskProvider {
         // we need to inform VSCode how to execute that command by creating
         // a ShellExecution for it.
 
-        const definition = task.definition as CargoTaskDefinition;
+        const definition = task.definition as RustTargetDefinition;
 
         if (definition.type === TASK_TYPE) {
             return await buildRustTask(
@@ -87,7 +87,7 @@ class RustTaskProvider implements vscode.TaskProvider {
 
 export async function buildRustTask(
     scope: vscode.WorkspaceFolder | vscode.TaskScope | undefined,
-    definition: CargoTaskDefinition,
+    definition: RustTargetDefinition,
     name: string,
     problemMatcher: string[],
     customRunner?: string,
@@ -108,7 +108,7 @@ export async function buildRustTask(
 }
 
 async function cargoToExecution(
-    definition: CargoTaskDefinition,
+    definition: RustTargetDefinition,
     customRunner: string | undefined,
     throwOnError: boolean,
 ): Promise<vscode.ProcessExecution | vscode.ShellExecution> {
@@ -138,20 +138,31 @@ async function cargoToExecution(
         }
     }
 
-    // Check whether we must use a user-defined substitute for cargo.
-    // Split on spaces to allow overrides like "wrapper cargo".
-    const cargoPath = await toolchain.cargoPath();
-    const cargoCommand = definition.overrideCargo?.split(" ") ?? [cargoPath];
-
-    const args = [definition.command].concat(definition.args ?? []);
-    const fullCommand = [...cargoCommand, ...args];
-
-    const processName = unwrapUndefinable(fullCommand[0]);
-
-    return new vscode.ProcessExecution(processName, fullCommand.slice(1), {
-        cwd: definition.cwd,
-        env: definition.env,
-    });
+    // this is a cargo task; do Cargo-esque processing
+    if (definition.type === TASK_TYPE) {
+        // Check whether we must use a user-defined substitute for cargo.
+        // Split on spaces to allow overrides like "wrapper cargo".
+        const cargoPath = await toolchain.cargoPath();
+        const cargoCommand = definition.overrideCargo?.split(" ") ?? [cargoPath];
+
+        const args = [definition.command].concat(definition.args ?? []);
+        const fullCommand = [...cargoCommand, ...args];
+        const processName = unwrapUndefinable(fullCommand[0]);
+
+        return new vscode.ProcessExecution(processName, fullCommand.slice(1), {
+            cwd: definition.cwd,
+            env: definition.env,
+        });
+    } else {
+        // we've been handed a process definition by rust-analyzer, trust all its inputs
+        // and make a shell execution.
+        const args = unwrapUndefinable(definition.args);
+
+        return new vscode.ProcessExecution(definition.command, args, {
+            cwd: definition.cwd,
+            env: definition.env,
+        });
+    }
 }
 
 export function activateTaskProvider(config: Config): vscode.Disposable {
diff --git a/src/tools/rust-analyzer/editors/code/src/util.ts b/src/tools/rust-analyzer/editors/code/src/util.ts
index 51f921a2962..868cb2b7807 100644
--- a/src/tools/rust-analyzer/editors/code/src/util.ts
+++ b/src/tools/rust-analyzer/editors/code/src/util.ts
@@ -2,6 +2,7 @@ import * as vscode from "vscode";
 import { strict as nativeAssert } from "assert";
 import { exec, type ExecOptions, spawnSync } from "child_process";
 import { inspect } from "util";
+import type { CargoRunnableArgs, ShellRunnableArgs } from "./lsp_ext";
 import type { Env } from "./client";
 
 export function assert(condition: boolean, explanation: string): asserts condition {
@@ -77,6 +78,12 @@ export function isCargoTomlDocument(document: vscode.TextDocument): document is
     return document.uri.scheme === "file" && document.fileName.endsWith("Cargo.toml");
 }
 
+export function isCargoRunnableArgs(
+    args: CargoRunnableArgs | ShellRunnableArgs,
+): args is CargoRunnableArgs {
+    return (args as CargoRunnableArgs).executableArgs !== undefined;
+}
+
 export function isRustEditor(editor: vscode.TextEditor): editor is RustEditor {
     return isRustDocument(editor.document);
 }
diff --git a/src/tools/rust-analyzer/editors/code/tests/unit/runnable_env.test.ts b/src/tools/rust-analyzer/editors/code/tests/unit/runnable_env.test.ts
index b1407ce0193..21bdaf5384d 100644
--- a/src/tools/rust-analyzer/editors/code/tests/unit/runnable_env.test.ts
+++ b/src/tools/rust-analyzer/editors/code/tests/unit/runnable_env.test.ts
@@ -10,6 +10,7 @@ function makeRunnable(label: string): ra.Runnable {
         kind: "cargo",
         args: {
             cargoArgs: [],
+            cwd: ".",
             executableArgs: [],
             cargoExtraArgs: [],
         },
@@ -18,7 +19,8 @@ function makeRunnable(label: string): ra.Runnable {
 
 function fakePrepareEnv(runnableName: string, config: RunnableEnvCfg): Record<string, string> {
     const runnable = makeRunnable(runnableName);
-    return prepareEnv(runnable, config);
+    const runnableArgs = runnable.args as ra.CargoRunnableArgs;
+    return prepareEnv(runnable.label, runnableArgs, config);
 }
 
 export async function getTests(ctx: Context) {