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("program".to_owned(), program.into());
83 configuration.insert(
84 "args".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 "runtimeExecutable": {
286 "type": ["string", "null"],
287 "description": "Runtime to use, an absolute path or the name of a runtime available on PATH",
288 "default": "node"
289 },
290 "runtimeArgs": {
291 "type": ["array", "null"],
292 "description": "Arguments passed to the runtime executable",
293 "items": {
294 "type": "string"
295 },
296 "default": []
297 },
298 "outFiles": {
299 "type": "array",
300 "description": "Glob patterns for locating generated JavaScript files",
301 "items": {
302 "type": "string"
303 },
304 "default": ["${ZED_WORKTREE_ROOT}/**/*.js", "!**/node_modules/**"]
305 },
306 "sourceMaps": {
307 "type": "boolean",
308 "description": "Use JavaScript source maps if they exist",
309 "default": true
310 },
311 "pauseForSourceMap": {
312 "type": "boolean",
313 "description": "Wait for source maps to load before setting breakpoints.",
314 "default": true
315 },
316 "sourceMapRenames": {
317 "type": "boolean",
318 "description": "Whether to use the \"names\" mapping in sourcemaps.",
319 "default": true
320 },
321 "sourceMapPathOverrides": {
322 "type": "object",
323 "description": "Rewrites the locations of source files from what the sourcemap says to their locations on disk",
324 "default": {}
325 },
326 "restart": {
327 "type": ["boolean", "object"],
328 "description": "Restart session after Node.js has terminated",
329 "default": false
330 },
331 "trace": {
332 "type": ["boolean", "object"],
333 "description": "Enables logging of the Debug Adapter",
334 "default": false
335 },
336 "console": {
337 "type": "string",
338 "enum": ["internalConsole", "integratedTerminal"],
339 "description": "Where to launch the debug target",
340 "default": "internalConsole"
341 },
342 // Browser-specific
343 "url": {
344 "type": ["string", "null"],
345 "description": "Will navigate to this URL and attach to it (browser debugging)"
346 },
347 "webRoot": {
348 "type": "string",
349 "description": "Workspace absolute path to the webserver root",
350 "default": "${ZED_WORKTREE_ROOT}"
351 },
352 "userDataDir": {
353 "type": ["string", "boolean"],
354 "description": "Path to a custom Chrome user profile (browser debugging)",
355 "default": true
356 },
357 "skipFiles": {
358 "type": "array",
359 "description": "An array of glob patterns for files to skip when debugging",
360 "items": {
361 "type": "string"
362 },
363 "default": ["<node_internals>/**"]
364 },
365 "timeout": {
366 "type": "number",
367 "description": "Retry for this number of milliseconds to connect to the debug adapter",
368 "default": 10000
369 },
370 "resolveSourceMapLocations": {
371 "type": ["array", "null"],
372 "description": "A list of minimatch patterns for source map resolution",
373 "items": {
374 "type": "string"
375 }
376 }
377 },
378 "oneOf": [
379 { "required": ["program"] },
380 { "required": ["url"] }
381 ]
382 }
383 ]
384 },
385 {
386 "allOf": [
387 {
388 "type": "object",
389 "required": ["request"],
390 "properties": {
391 "request": {
392 "type": "string",
393 "enum": ["attach"],
394 "description": "Request to attach to an existing process"
395 }
396 }
397 },
398 {
399 "type": "object",
400 "properties": {
401 "type": {
402 "type": "string",
403 "enum": ["pwa-node", "node", "chrome", "pwa-chrome", "edge", "pwa-edge"],
404 "description": "The type of debug session",
405 "default": "pwa-node"
406 },
407 "processId": {
408 "type": ["string", "number"],
409 "description": "ID of process to attach to (Node.js debugging)"
410 },
411 "port": {
412 "type": "number",
413 "description": "Debug port to attach to",
414 "default": 9229
415 },
416 "address": {
417 "type": "string",
418 "description": "TCP/IP address of the process to be debugged",
419 "default": "localhost"
420 },
421 "restart": {
422 "type": ["boolean", "object"],
423 "description": "Restart session after Node.js has terminated",
424 "default": false
425 },
426 "sourceMaps": {
427 "type": "boolean",
428 "description": "Use JavaScript source maps if they exist",
429 "default": true
430 },
431 "sourceMapPathOverrides": {
432 "type": "object",
433 "description": "Rewrites the locations of source files from what the sourcemap says to their locations on disk",
434 "default": {}
435 },
436 "outFiles": {
437 "type": "array",
438 "description": "Glob patterns for locating generated JavaScript files",
439 "items": {
440 "type": "string"
441 },
442 "default": ["${ZED_WORKTREE_ROOT}/**/*.js", "!**/node_modules/**"]
443 },
444 "url": {
445 "type": "string",
446 "description": "Will search for a page with this URL and attach to it (browser debugging)"
447 },
448 "webRoot": {
449 "type": "string",
450 "description": "Workspace absolute path to the webserver root",
451 "default": "${ZED_WORKTREE_ROOT}"
452 },
453 "skipFiles": {
454 "type": "array",
455 "description": "An array of glob patterns for files to skip when debugging",
456 "items": {
457 "type": "string"
458 },
459 "default": ["<node_internals>/**"]
460 },
461 "timeout": {
462 "type": "number",
463 "description": "Retry for this number of milliseconds to connect to the debug adapter",
464 "default": 10000
465 },
466 "resolveSourceMapLocations": {
467 "type": ["array", "null"],
468 "description": "A list of minimatch patterns for source map resolution",
469 "items": {
470 "type": "string"
471 }
472 },
473 "remoteRoot": {
474 "type": ["string", "null"],
475 "description": "Path to the remote directory containing the program"
476 },
477 "localRoot": {
478 "type": ["string", "null"],
479 "description": "Path to the local directory containing the program"
480 }
481 },
482 "oneOf": [
483 { "required": ["processId"] },
484 { "required": ["port"] }
485 ]
486 }
487 ]
488 }
489 ]
490 })
491 }
492
493 async fn get_binary(
494 &self,
495 delegate: &Arc<dyn DapDelegate>,
496 config: &DebugTaskDefinition,
497 user_installed_path: Option<PathBuf>,
498 user_args: Option<Vec<String>>,
499 cx: &mut AsyncApp,
500 ) -> Result<DebugAdapterBinary> {
501 if self.checked.set(()).is_ok() {
502 delegate.output_to_console(format!("Checking latest version of {}...", self.name()));
503 if let Some(version) = self.fetch_latest_adapter_version(delegate).await.log_err() {
504 adapters::download_adapter_from_github(
505 self.name(),
506 version,
507 adapters::DownloadedFileType::GzipTar,
508 delegate.as_ref(),
509 )
510 .await?;
511 } else {
512 delegate.output_to_console(format!("{} debug adapter is up to date", self.name()));
513 }
514 }
515
516 self.get_installed_binary(delegate, &config, user_installed_path, user_args, cx)
517 .await
518 }
519
520 fn label_for_child_session(&self, args: &StartDebuggingRequestArguments) -> Option<String> {
521 let label = args.configuration.get("name")?.as_str()?;
522 Some(label.to_owned())
523 }
524}
525
526fn normalize_task_type(task_type: &mut Value) {
527 let Some(task_type_str) = task_type.as_str() else {
528 return;
529 };
530
531 let new_name = match task_type_str {
532 "node" | "pwa-node" | "node-terminal" => "pwa-node",
533 "chrome" | "pwa-chrome" => "pwa-chrome",
534 "edge" | "msedge" | "pwa-edge" | "pwa-msedge" => "pwa-msedge",
535 _ => task_type_str,
536 }
537 .to_owned();
538
539 *task_type = Value::String(new_name);
540}