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