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