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