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