javascript.rs

  1use adapters::latest_github_release;
  2use anyhow::Context as _;
  3use collections::HashMap;
  4use dap::{StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
  5use gpui::AsyncApp;
  6use serde_json::Value;
  7use std::{path::PathBuf, sync::OnceLock};
  8use task::DebugRequest;
  9use util::{
 10    ResultExt, maybe,
 11    shell::{PosixShell, ShellKind},
 12};
 13
 14use crate::*;
 15
 16#[derive(Debug, Default)]
 17pub(crate) struct JsDebugAdapter {
 18    checked: OnceLock<()>,
 19}
 20
 21impl JsDebugAdapter {
 22    const ADAPTER_NAME: &'static str = "JavaScript";
 23    const ADAPTER_NPM_NAME: &'static str = "vscode-js-debug";
 24    const ADAPTER_PATH: &'static str = "js-debug/src/dapDebugServer.js";
 25
 26    async fn fetch_latest_adapter_version(
 27        &self,
 28        delegate: &Arc<dyn DapDelegate>,
 29    ) -> Result<AdapterVersion> {
 30        let release = latest_github_release(
 31            &format!("microsoft/{}", Self::ADAPTER_NPM_NAME),
 32            true,
 33            false,
 34            delegate.http_client(),
 35        )
 36        .await?;
 37
 38        let asset_name = format!("js-debug-dap-{}.tar.gz", release.tag_name);
 39
 40        Ok(AdapterVersion {
 41            tag_name: release.tag_name,
 42            url: release
 43                .assets
 44                .iter()
 45                .find(|asset| asset.name == asset_name)
 46                .with_context(|| format!("no asset found matching {asset_name:?}"))?
 47                .browser_download_url
 48                .clone(),
 49        })
 50    }
 51
 52    async fn get_installed_binary(
 53        &self,
 54        delegate: &Arc<dyn DapDelegate>,
 55        task_definition: &DebugTaskDefinition,
 56        user_installed_path: Option<PathBuf>,
 57        user_args: Option<Vec<String>>,
 58        user_env: Option<HashMap<String, String>>,
 59        _: &mut AsyncApp,
 60    ) -> Result<DebugAdapterBinary> {
 61        let tcp_connection = task_definition.tcp_connection.clone().unwrap_or_default();
 62        let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
 63
 64        let mut envs = user_env.unwrap_or_default();
 65
 66        let mut configuration = task_definition.config.clone();
 67        if let Some(configuration) = configuration.as_object_mut() {
 68            maybe!({
 69                configuration
 70                    .get("type")
 71                    .filter(|value| value == &"node-terminal")?;
 72                let command = configuration.get("command")?.as_str()?.to_owned();
 73                // TODO: Consider using the user's actual shell instead of hardcoding "sh"
 74                let mut args = ShellKind::Posix(PosixShell::Sh)
 75                    .split(&command)?
 76                    .into_iter();
 77                let program = args.next()?;
 78                configuration.insert("runtimeExecutable".to_owned(), program.into());
 79                configuration.insert(
 80                    "runtimeArgs".to_owned(),
 81                    args.map(Value::from).collect::<Vec<_>>().into(),
 82                );
 83                configuration.insert("console".to_owned(), "externalTerminal".into());
 84                Some(())
 85            });
 86
 87            configuration.entry("type").and_modify(normalize_task_type);
 88
 89            if let Some(program) = configuration
 90                .get("program")
 91                .cloned()
 92                .and_then(|value| value.as_str().map(str::to_owned))
 93            {
 94                match program.as_str() {
 95                    "npm" | "pnpm" | "yarn" | "bun"
 96                        if !configuration.contains_key("runtimeExecutable")
 97                            && !configuration.contains_key("runtimeArgs") =>
 98                    {
 99                        configuration.remove("program");
100                        configuration.insert("runtimeExecutable".to_owned(), program.into());
101                        if let Some(args) = configuration.remove("args") {
102                            configuration.insert("runtimeArgs".to_owned(), args);
103                        }
104                    }
105                    _ => {}
106                }
107            }
108
109            if let Some(env) = configuration.get("env").cloned()
110                && let Ok(env) = serde_json::from_value::<HashMap<String, String>>(env)
111            {
112                envs.extend(env.into_iter());
113            }
114
115            configuration
116                .entry("cwd")
117                .or_insert(delegate.worktree_root_path().to_string_lossy().into());
118
119            configuration
120                .entry("console")
121                .or_insert("externalTerminal".into());
122
123            configuration.entry("sourceMaps").or_insert(true.into());
124            configuration
125                .entry("pauseForSourceMap")
126                .or_insert(true.into());
127            configuration
128                .entry("sourceMapRenames")
129                .or_insert(true.into());
130
131            // Set up remote browser debugging
132            if delegate.is_headless() {
133                configuration
134                    .entry("browserLaunchLocation")
135                    .or_insert("ui".into());
136            }
137        }
138
139        let adapter_path = if let Some(user_installed_path) = user_installed_path {
140            user_installed_path
141        } else {
142            let adapter_path = paths::debug_adapters_dir().join(self.name().as_ref());
143
144            let file_name_prefix = format!("{}_", self.name());
145
146            util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| {
147                file_name.starts_with(&file_name_prefix)
148            })
149            .await
150            .context("Couldn't find JavaScript dap directory")?
151            .join(Self::ADAPTER_PATH)
152        };
153
154        let arguments = if let Some(mut args) = user_args {
155            args.insert(0, adapter_path.to_string_lossy().into_owned());
156            args
157        } else {
158            vec![
159                adapter_path.to_string_lossy().into_owned(),
160                port.to_string(),
161                host.to_string(),
162            ]
163        };
164
165        Ok(DebugAdapterBinary {
166            command: Some(
167                delegate
168                    .node_runtime()
169                    .binary_path()
170                    .await?
171                    .to_string_lossy()
172                    .into_owned(),
173            ),
174            arguments,
175            cwd: Some(delegate.worktree_root_path().to_path_buf()),
176            envs,
177            connection: Some(adapters::TcpArguments {
178                host,
179                port,
180                timeout,
181            }),
182            request_args: StartDebuggingRequestArguments {
183                configuration,
184                request: self.request_kind(&task_definition.config).await?,
185            },
186        })
187    }
188}
189
190#[async_trait(?Send)]
191impl DebugAdapter for JsDebugAdapter {
192    fn name(&self) -> DebugAdapterName {
193        DebugAdapterName(Self::ADAPTER_NAME.into())
194    }
195
196    async fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
197        let mut args = json!({
198            "type": "pwa-node",
199            "request": match zed_scenario.request {
200                DebugRequest::Launch(_) => "launch",
201                DebugRequest::Attach(_) => "attach",
202            },
203        });
204
205        let map = args.as_object_mut().unwrap();
206        match &zed_scenario.request {
207            DebugRequest::Attach(attach) => {
208                map.insert("processId".into(), attach.process_id.into());
209            }
210            DebugRequest::Launch(launch) => {
211                if launch.program.starts_with("http://") {
212                    map.insert("url".into(), launch.program.clone().into());
213                } else {
214                    map.insert("program".into(), launch.program.clone().into());
215                }
216
217                if !launch.args.is_empty() {
218                    map.insert("args".into(), launch.args.clone().into());
219                }
220                if !launch.env.is_empty() {
221                    map.insert("env".into(), launch.env_json());
222                }
223
224                if let Some(stop_on_entry) = zed_scenario.stop_on_entry {
225                    map.insert("stopOnEntry".into(), stop_on_entry.into());
226                }
227                if let Some(cwd) = launch.cwd.as_ref() {
228                    map.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
229                }
230            }
231        };
232
233        Ok(DebugScenario {
234            adapter: zed_scenario.adapter,
235            label: zed_scenario.label,
236            build: None,
237            config: args,
238            tcp_connection: None,
239        })
240    }
241
242    fn dap_schema(&self) -> serde_json::Value {
243        json!({
244            "oneOf": [
245                {
246                    "allOf": [
247                        {
248                            "type": "object",
249                            "required": ["request"],
250                            "properties": {
251                                "request": {
252                                    "type": "string",
253                                    "enum": ["launch"],
254                                    "description": "Request to launch a new process"
255                                }
256                            }
257                        },
258                        {
259                            "type": "object",
260                            "properties": {
261                                "type": {
262                                    "type": "string",
263                                    "enum": ["pwa-node", "node", "chrome", "pwa-chrome", "msedge", "pwa-msedge", "node-terminal"],
264                                    "description": "The type of debug session",
265                                    "default": "pwa-node"
266                                },
267                                "program": {
268                                    "type": "string",
269                                    "description": "Path to the program or file to debug"
270                                },
271                                "cwd": {
272                                    "type": "string",
273                                    "description": "Absolute path to the working directory of the program being debugged"
274                                },
275                                "args": {
276                                    "type": ["array", "string"],
277                                    "description": "Command line arguments passed to the program",
278                                    "items": {
279                                        "type": "string"
280                                    },
281                                    "default": []
282                                },
283                                "env": {
284                                    "type": "object",
285                                    "description": "Environment variables passed to the program",
286                                    "default": {}
287                                },
288                                "envFile": {
289                                    "type": ["string", "array"],
290                                    "description": "Path to a file containing environment variable definitions",
291                                    "items": {
292                                        "type": "string"
293                                    }
294                                },
295                                "stopOnEntry": {
296                                    "type": "boolean",
297                                    "description": "Automatically stop program after launch",
298                                    "default": false
299                                },
300                                "attachSimplePort": {
301                                    "type": "number",
302                                    "description": "If set, attaches to the process via the given port. This is generally no longer necessary for Node.js programs and loses the ability to debug child processes, but can be useful in more esoteric scenarios such as with Deno and Docker launches. If set to 0, a random port will be chosen and --inspect-brk added to the launch arguments automatically."
303                                },
304                                "runtimeExecutable": {
305                                    "type": ["string", "null"],
306                                    "description": "Runtime to use, an absolute path or the name of a runtime available on PATH",
307                                    "default": "node"
308                                },
309                                "runtimeArgs": {
310                                    "type": ["array", "null"],
311                                    "description": "Arguments passed to the runtime executable",
312                                    "items": {
313                                        "type": "string"
314                                    },
315                                    "default": []
316                                },
317                                "outFiles": {
318                                    "type": "array",
319                                    "description": "Glob patterns for locating generated JavaScript files",
320                                    "items": {
321                                        "type": "string"
322                                    },
323                                    "default": ["${ZED_WORKTREE_ROOT}/**/*.js", "!**/node_modules/**"]
324                                },
325                                "sourceMaps": {
326                                    "type": "boolean",
327                                    "description": "Use JavaScript source maps if they exist",
328                                    "default": true
329                                },
330                                "pauseForSourceMap": {
331                                    "type": "boolean",
332                                    "description": "Wait for source maps to load before setting breakpoints.",
333                                    "default": true
334                                },
335                                "sourceMapRenames": {
336                                    "type": "boolean",
337                                    "description": "Whether to use the \"names\" mapping in sourcemaps.",
338                                    "default": true
339                                },
340                                "sourceMapPathOverrides": {
341                                    "type": "object",
342                                    "description": "Rewrites the locations of source files from what the sourcemap says to their locations on disk",
343                                    "default": {}
344                                },
345                                "restart": {
346                                    "type": ["boolean", "object"],
347                                    "description": "Restart session after Node.js has terminated",
348                                    "default": false
349                                },
350                                "trace": {
351                                    "type": ["boolean", "object"],
352                                    "description": "Enables logging of the Debug Adapter",
353                                    "default": false
354                                },
355                                "console": {
356                                    "type": "string",
357                                    "enum": ["internalConsole", "integratedTerminal"],
358                                    "description": "Where to launch the debug target",
359                                    "default": "internalConsole"
360                                },
361                                // Browser-specific
362                                "url": {
363                                    "type": ["string", "null"],
364                                    "description": "Will navigate to this URL and attach to it (browser debugging)"
365                                },
366                                "webRoot": {
367                                    "type": "string",
368                                    "description": "Workspace absolute path to the webserver root",
369                                    "default": "${ZED_WORKTREE_ROOT}"
370                                },
371                                "userDataDir": {
372                                    "type": ["string", "boolean"],
373                                    "description": "Path to a custom Chrome user profile (browser debugging)",
374                                    "default": true
375                                },
376                                "skipFiles": {
377                                    "type": "array",
378                                    "description": "An array of glob patterns for files to skip when debugging",
379                                    "items": {
380                                        "type": "string"
381                                    },
382                                    "default": ["<node_internals>/**"]
383                                },
384                                "timeout": {
385                                    "type": "number",
386                                    "description": "Retry for this number of milliseconds to connect to the debug adapter",
387                                    "default": 10000
388                                },
389                                "resolveSourceMapLocations": {
390                                    "type": ["array", "null"],
391                                    "description": "A list of minimatch patterns for source map resolution",
392                                    "items": {
393                                        "type": "string"
394                                    }
395                                }
396                            },
397                        }
398                    ]
399                },
400                {
401                    "allOf": [
402                        {
403                            "type": "object",
404                            "required": ["request"],
405                            "properties": {
406                                "request": {
407                                    "type": "string",
408                                    "enum": ["attach"],
409                                    "description": "Request to attach to an existing process"
410                                }
411                            }
412                        },
413                        {
414                            "type": "object",
415                            "properties": {
416                                "type": {
417                                    "type": "string",
418                                    "enum": ["pwa-node", "node", "chrome", "pwa-chrome", "edge", "pwa-edge"],
419                                    "description": "The type of debug session",
420                                    "default": "pwa-node"
421                                },
422                                "processId": {
423                                    "type": ["string", "number"],
424                                    "description": "ID of process to attach to (Node.js debugging)"
425                                },
426                                "port": {
427                                    "type": "number",
428                                    "description": "Debug port to attach to",
429                                    "default": 9229
430                                },
431                                "address": {
432                                    "type": "string",
433                                    "description": "TCP/IP address of the process to be debugged",
434                                    "default": "localhost"
435                                },
436                                "restart": {
437                                    "type": ["boolean", "object"],
438                                    "description": "Restart session after Node.js has terminated",
439                                    "default": false
440                                },
441                                "sourceMaps": {
442                                    "type": "boolean",
443                                    "description": "Use JavaScript source maps if they exist",
444                                    "default": true
445                                },
446                                "sourceMapPathOverrides": {
447                                    "type": "object",
448                                    "description": "Rewrites the locations of source files from what the sourcemap says to their locations on disk",
449                                    "default": {}
450                                },
451                                "outFiles": {
452                                    "type": "array",
453                                    "description": "Glob patterns for locating generated JavaScript files",
454                                    "items": {
455                                        "type": "string"
456                                    },
457                                    "default": ["${ZED_WORKTREE_ROOT}/**/*.js", "!**/node_modules/**"]
458                                },
459                                "url": {
460                                    "type": "string",
461                                    "description": "Will search for a page with this URL and attach to it (browser debugging)"
462                                },
463                                "webRoot": {
464                                    "type": "string",
465                                    "description": "Workspace absolute path to the webserver root",
466                                    "default": "${ZED_WORKTREE_ROOT}"
467                                },
468                                "skipFiles": {
469                                    "type": "array",
470                                    "description": "An array of glob patterns for files to skip when debugging",
471                                    "items": {
472                                        "type": "string"
473                                    },
474                                    "default": ["<node_internals>/**"]
475                                },
476                                "timeout": {
477                                    "type": "number",
478                                    "description": "Retry for this number of milliseconds to connect to the debug adapter",
479                                    "default": 10000
480                                },
481                                "resolveSourceMapLocations": {
482                                    "type": ["array", "null"],
483                                    "description": "A list of minimatch patterns for source map resolution",
484                                    "items": {
485                                        "type": "string"
486                                    }
487                                },
488                                "remoteRoot": {
489                                    "type": ["string", "null"],
490                                    "description": "Path to the remote directory containing the program"
491                                },
492                                "localRoot": {
493                                    "type": ["string", "null"],
494                                    "description": "Path to the local directory containing the program"
495                                }
496                            },
497                            "oneOf": [
498                                { "required": ["processId"] },
499                                { "required": ["port"] }
500                            ]
501                        }
502                    ]
503                }
504            ]
505        })
506    }
507
508    async fn get_binary(
509        &self,
510        delegate: &Arc<dyn DapDelegate>,
511        config: &DebugTaskDefinition,
512        user_installed_path: Option<PathBuf>,
513        user_args: Option<Vec<String>>,
514        user_env: Option<HashMap<String, String>>,
515        cx: &mut AsyncApp,
516    ) -> Result<DebugAdapterBinary> {
517        if self.checked.set(()).is_ok() {
518            delegate.output_to_console(format!("Checking latest version of {}...", self.name()));
519            if let Some(version) = self.fetch_latest_adapter_version(delegate).await.log_err() {
520                adapters::download_adapter_from_github(
521                    self.name(),
522                    version,
523                    adapters::DownloadedFileType::GzipTar,
524                    delegate.as_ref(),
525                )
526                .await?;
527            } else {
528                delegate.output_to_console(format!("{} debug adapter is up to date", self.name()));
529            }
530        }
531
532        self.get_installed_binary(
533            delegate,
534            config,
535            user_installed_path,
536            user_args,
537            user_env,
538            cx,
539        )
540        .await
541    }
542
543    fn label_for_child_session(&self, args: &StartDebuggingRequestArguments) -> Option<String> {
544        let label = args
545            .configuration
546            .get("name")?
547            .as_str()
548            .filter(|name| !name.is_empty())?;
549        Some(label.to_owned())
550    }
551
552    fn compact_child_session(&self) -> bool {
553        true
554    }
555
556    fn prefer_thread_name(&self) -> bool {
557        true
558    }
559}
560
561fn normalize_task_type(task_type: &mut Value) {
562    let Some(task_type_str) = task_type.as_str() else {
563        return;
564    };
565
566    let new_name = match task_type_str {
567        "node" | "pwa-node" | "node-terminal" => "pwa-node",
568        "chrome" | "pwa-chrome" => "pwa-chrome",
569        "edge" | "msedge" | "pwa-edge" | "pwa-msedge" => "pwa-msedge",
570        _ => task_type_str,
571    }
572    .to_owned();
573
574    *task_type = Value::String(new_name);
575}