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}