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