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