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