1use adapters::latest_github_release;
2use anyhow::{Context as _, anyhow};
3use dap::{
4 StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest,
5 adapters::DebugTaskDefinition,
6};
7use gpui::AsyncApp;
8use std::{collections::HashMap, path::PathBuf, sync::OnceLock};
9use task::DebugRequest;
10use util::ResultExt;
11
12use crate::*;
13
14#[derive(Debug, Default)]
15pub(crate) struct JsDebugAdapter {
16 checked: OnceLock<()>,
17}
18
19impl JsDebugAdapter {
20 const ADAPTER_NAME: &'static str = "JavaScript";
21 const ADAPTER_NPM_NAME: &'static str = "vscode-js-debug";
22 const ADAPTER_PATH: &'static str = "js-debug/src/dapDebugServer.js";
23
24 async fn fetch_latest_adapter_version(
25 &self,
26 delegate: &Arc<dyn DapDelegate>,
27 ) -> Result<AdapterVersion> {
28 let release = latest_github_release(
29 &format!("{}/{}", "microsoft", Self::ADAPTER_NPM_NAME),
30 true,
31 false,
32 delegate.http_client(),
33 )
34 .await?;
35
36 let asset_name = format!("js-debug-dap-{}.tar.gz", release.tag_name);
37
38 Ok(AdapterVersion {
39 tag_name: release.tag_name,
40 url: release
41 .assets
42 .iter()
43 .find(|asset| asset.name == asset_name)
44 .with_context(|| format!("no asset found matching {asset_name:?}"))?
45 .browser_download_url
46 .clone(),
47 })
48 }
49
50 async fn get_installed_binary(
51 &self,
52 delegate: &Arc<dyn DapDelegate>,
53 task_definition: &DebugTaskDefinition,
54 user_installed_path: Option<PathBuf>,
55 _: &mut AsyncApp,
56 ) -> Result<DebugAdapterBinary> {
57 let adapter_path = if let Some(user_installed_path) = user_installed_path {
58 user_installed_path
59 } else {
60 let adapter_path = paths::debug_adapters_dir().join(self.name().as_ref());
61
62 let file_name_prefix = format!("{}_", self.name());
63
64 util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| {
65 file_name.starts_with(&file_name_prefix)
66 })
67 .await
68 .context("Couldn't find JavaScript dap directory")?
69 };
70
71 let tcp_connection = task_definition.tcp_connection.clone().unwrap_or_default();
72 let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
73
74 Ok(DebugAdapterBinary {
75 command: delegate
76 .node_runtime()
77 .binary_path()
78 .await?
79 .to_string_lossy()
80 .into_owned(),
81 arguments: vec![
82 adapter_path
83 .join(Self::ADAPTER_PATH)
84 .to_string_lossy()
85 .to_string(),
86 port.to_string(),
87 host.to_string(),
88 ],
89 cwd: Some(delegate.worktree_root_path().to_path_buf()),
90 envs: HashMap::default(),
91 connection: Some(adapters::TcpArguments {
92 host,
93 port,
94 timeout,
95 }),
96 request_args: StartDebuggingRequestArguments {
97 configuration: task_definition.config.clone(),
98 request: self.validate_config(&task_definition.config)?,
99 },
100 })
101 }
102}
103
104#[async_trait(?Send)]
105impl DebugAdapter for JsDebugAdapter {
106 fn name(&self) -> DebugAdapterName {
107 DebugAdapterName(Self::ADAPTER_NAME.into())
108 }
109
110 fn validate_config(
111 &self,
112 config: &serde_json::Value,
113 ) -> Result<dap::StartDebuggingRequestArgumentsRequest> {
114 match config.get("request") {
115 Some(val) if val == "launch" => {
116 if config.get("program").is_none() {
117 return Err(anyhow!("program is required"));
118 }
119 Ok(StartDebuggingRequestArgumentsRequest::Launch)
120 }
121 Some(val) if val == "attach" => {
122 if !config.get("processId").is_some_and(|val| val.is_u64()) {
123 return Err(anyhow!("processId must be a number"));
124 }
125 Ok(StartDebuggingRequestArgumentsRequest::Attach)
126 }
127 _ => Err(anyhow!("missing or invalid request field in config")),
128 }
129 }
130
131 fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
132 let mut args = json!({
133 "type": "pwa-node",
134 "request": match zed_scenario.request {
135 DebugRequest::Launch(_) => "launch",
136 DebugRequest::Attach(_) => "attach",
137 },
138 });
139
140 let map = args.as_object_mut().unwrap();
141 match &zed_scenario.request {
142 DebugRequest::Attach(attach) => {
143 map.insert("processId".into(), attach.process_id.into());
144 }
145 DebugRequest::Launch(launch) => {
146 map.insert("program".into(), launch.program.clone().into());
147
148 if !launch.args.is_empty() {
149 map.insert("args".into(), launch.args.clone().into());
150 }
151 if !launch.env.is_empty() {
152 map.insert("env".into(), launch.env_json());
153 }
154
155 if let Some(stop_on_entry) = zed_scenario.stop_on_entry {
156 map.insert("stopOnEntry".into(), stop_on_entry.into());
157 }
158 if let Some(cwd) = launch.cwd.as_ref() {
159 map.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
160 }
161 }
162 };
163
164 Ok(DebugScenario {
165 adapter: zed_scenario.adapter,
166 label: zed_scenario.label,
167 build: None,
168 config: args,
169 tcp_connection: None,
170 })
171 }
172
173 async fn dap_schema(&self) -> serde_json::Value {
174 json!({
175 "oneOf": [
176 {
177 "allOf": [
178 {
179 "type": "object",
180 "required": ["request"],
181 "properties": {
182 "request": {
183 "type": "string",
184 "enum": ["launch"],
185 "description": "Request to launch a new process"
186 }
187 }
188 },
189 {
190 "type": "object",
191 "properties": {
192 "type": {
193 "type": "string",
194 "enum": ["pwa-node", "node", "chrome", "pwa-chrome", "edge", "pwa-edge"],
195 "description": "The type of debug session",
196 "default": "pwa-node"
197 },
198 "program": {
199 "type": "string",
200 "description": "Path to the program or file to debug"
201 },
202 "cwd": {
203 "type": "string",
204 "description": "Absolute path to the working directory of the program being debugged"
205 },
206 "args": {
207 "type": ["array", "string"],
208 "description": "Command line arguments passed to the program",
209 "items": {
210 "type": "string"
211 },
212 "default": []
213 },
214 "env": {
215 "type": "object",
216 "description": "Environment variables passed to the program",
217 "default": {}
218 },
219 "envFile": {
220 "type": ["string", "array"],
221 "description": "Path to a file containing environment variable definitions",
222 "items": {
223 "type": "string"
224 }
225 },
226 "stopOnEntry": {
227 "type": "boolean",
228 "description": "Automatically stop program after launch",
229 "default": false
230 },
231 "runtimeExecutable": {
232 "type": ["string", "null"],
233 "description": "Runtime to use, an absolute path or the name of a runtime available on PATH",
234 "default": "node"
235 },
236 "runtimeArgs": {
237 "type": ["array", "null"],
238 "description": "Arguments passed to the runtime executable",
239 "items": {
240 "type": "string"
241 },
242 "default": []
243 },
244 "outFiles": {
245 "type": "array",
246 "description": "Glob patterns for locating generated JavaScript files",
247 "items": {
248 "type": "string"
249 },
250 "default": ["${ZED_WORKTREE_ROOT}/**/*.js", "!**/node_modules/**"]
251 },
252 "sourceMaps": {
253 "type": "boolean",
254 "description": "Use JavaScript source maps if they exist",
255 "default": true
256 },
257 "sourceMapPathOverrides": {
258 "type": "object",
259 "description": "Rewrites the locations of source files from what the sourcemap says to their locations on disk",
260 "default": {}
261 },
262 "restart": {
263 "type": ["boolean", "object"],
264 "description": "Restart session after Node.js has terminated",
265 "default": false
266 },
267 "trace": {
268 "type": ["boolean", "object"],
269 "description": "Enables logging of the Debug Adapter",
270 "default": false
271 },
272 "console": {
273 "type": "string",
274 "enum": ["internalConsole", "integratedTerminal"],
275 "description": "Where to launch the debug target",
276 "default": "internalConsole"
277 },
278 // Browser-specific
279 "url": {
280 "type": ["string", "null"],
281 "description": "Will navigate to this URL and attach to it (browser debugging)"
282 },
283 "webRoot": {
284 "type": "string",
285 "description": "Workspace absolute path to the webserver root",
286 "default": "${ZED_WORKTREE_ROOT}"
287 },
288 "userDataDir": {
289 "type": ["string", "boolean"],
290 "description": "Path to a custom Chrome user profile (browser debugging)",
291 "default": true
292 },
293 "skipFiles": {
294 "type": "array",
295 "description": "An array of glob patterns for files to skip when debugging",
296 "items": {
297 "type": "string"
298 },
299 "default": ["<node_internals>/**"]
300 },
301 "timeout": {
302 "type": "number",
303 "description": "Retry for this number of milliseconds to connect to the debug adapter",
304 "default": 10000
305 },
306 "resolveSourceMapLocations": {
307 "type": ["array", "null"],
308 "description": "A list of minimatch patterns for source map resolution",
309 "items": {
310 "type": "string"
311 }
312 }
313 },
314 "required": ["program"]
315 }
316 ]
317 },
318 {
319 "allOf": [
320 {
321 "type": "object",
322 "required": ["request"],
323 "properties": {
324 "request": {
325 "type": "string",
326 "enum": ["attach"],
327 "description": "Request to attach to an existing process"
328 }
329 }
330 },
331 {
332 "type": "object",
333 "properties": {
334 "type": {
335 "type": "string",
336 "enum": ["pwa-node", "node", "chrome", "pwa-chrome", "edge", "pwa-edge"],
337 "description": "The type of debug session",
338 "default": "pwa-node"
339 },
340 "processId": {
341 "type": ["string", "number"],
342 "description": "ID of process to attach to (Node.js debugging)"
343 },
344 "port": {
345 "type": "number",
346 "description": "Debug port to attach to",
347 "default": 9229
348 },
349 "address": {
350 "type": "string",
351 "description": "TCP/IP address of the process to be debugged",
352 "default": "localhost"
353 },
354 "restart": {
355 "type": ["boolean", "object"],
356 "description": "Restart session after Node.js has terminated",
357 "default": false
358 },
359 "sourceMaps": {
360 "type": "boolean",
361 "description": "Use JavaScript source maps if they exist",
362 "default": true
363 },
364 "sourceMapPathOverrides": {
365 "type": "object",
366 "description": "Rewrites the locations of source files from what the sourcemap says to their locations on disk",
367 "default": {}
368 },
369 "outFiles": {
370 "type": "array",
371 "description": "Glob patterns for locating generated JavaScript files",
372 "items": {
373 "type": "string"
374 },
375 "default": ["${ZED_WORKTREE_ROOT}/**/*.js", "!**/node_modules/**"]
376 },
377 "url": {
378 "type": "string",
379 "description": "Will search for a page with this URL and attach to it (browser debugging)"
380 },
381 "webRoot": {
382 "type": "string",
383 "description": "Workspace absolute path to the webserver root",
384 "default": "${ZED_WORKTREE_ROOT}"
385 },
386 "skipFiles": {
387 "type": "array",
388 "description": "An array of glob patterns for files to skip when debugging",
389 "items": {
390 "type": "string"
391 },
392 "default": ["<node_internals>/**"]
393 },
394 "timeout": {
395 "type": "number",
396 "description": "Retry for this number of milliseconds to connect to the debug adapter",
397 "default": 10000
398 },
399 "resolveSourceMapLocations": {
400 "type": ["array", "null"],
401 "description": "A list of minimatch patterns for source map resolution",
402 "items": {
403 "type": "string"
404 }
405 },
406 "remoteRoot": {
407 "type": ["string", "null"],
408 "description": "Path to the remote directory containing the program"
409 },
410 "localRoot": {
411 "type": ["string", "null"],
412 "description": "Path to the local directory containing the program"
413 }
414 },
415 "oneOf": [
416 { "required": ["processId"] },
417 { "required": ["port"] }
418 ]
419 }
420 ]
421 }
422 ]
423 })
424 }
425
426 async fn get_binary(
427 &self,
428 delegate: &Arc<dyn DapDelegate>,
429 config: &DebugTaskDefinition,
430 user_installed_path: Option<PathBuf>,
431 cx: &mut AsyncApp,
432 ) -> Result<DebugAdapterBinary> {
433 if self.checked.set(()).is_ok() {
434 delegate.output_to_console(format!("Checking latest version of {}...", self.name()));
435 if let Some(version) = self.fetch_latest_adapter_version(delegate).await.log_err() {
436 adapters::download_adapter_from_github(
437 self.name(),
438 version,
439 adapters::DownloadedFileType::GzipTar,
440 delegate.as_ref(),
441 )
442 .await?;
443 }
444 }
445
446 self.get_installed_binary(delegate, &config, user_installed_path, cx)
447 .await
448 }
449}