1use anyhow::{Context as _, Result};
2use async_compression::futures::bufread::GzipDecoder;
3use async_tar::Archive;
4use async_trait::async_trait;
5use collections::HashMap;
6use gpui::AsyncApp;
7use http_client::github::{AssetKind, GitHubLspBinaryVersion, build_asset_url};
8use language::{LanguageToolchainStore, LspAdapter, LspAdapterDelegate};
9use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName};
10use node_runtime::NodeRuntime;
11use project::ContextProviderWithTasks;
12use project::{Fs, lsp_store::language_server_settings};
13use serde_json::{Value, json};
14use smol::{fs, io::BufReader, stream::StreamExt};
15use std::{
16 any::Any,
17 ffi::OsString,
18 path::{Path, PathBuf},
19 sync::Arc,
20};
21use task::{TaskTemplate, TaskTemplates, VariableName};
22use util::archive::extract_zip;
23use util::merge_json_value_into;
24use util::{ResultExt, fs::remove_matching, maybe};
25
26pub(super) fn typescript_task_context() -> ContextProviderWithTasks {
27 ContextProviderWithTasks::new(TaskTemplates(vec![
28 TaskTemplate {
29 label: "jest file test".to_owned(),
30 command: "npx jest".to_owned(),
31 args: vec![VariableName::File.template_value()],
32 ..TaskTemplate::default()
33 },
34 TaskTemplate {
35 label: "jest test $ZED_SYMBOL".to_owned(),
36 command: "npx jest".to_owned(),
37 args: vec![
38 "--testNamePattern".into(),
39 format!("\"{}\"", VariableName::Symbol.template_value()),
40 VariableName::File.template_value(),
41 ],
42 tags: vec!["ts-test".into(), "js-test".into(), "tsx-test".into()],
43 ..TaskTemplate::default()
44 },
45 TaskTemplate {
46 label: "execute selection $ZED_SELECTED_TEXT".to_owned(),
47 command: "node".to_owned(),
48 args: vec![
49 "-e".into(),
50 format!("\"{}\"", VariableName::SelectedText.template_value()),
51 ],
52 ..TaskTemplate::default()
53 },
54 ]))
55}
56
57fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
58 vec![server_path.into(), "--stdio".into()]
59}
60
61fn eslint_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
62 vec![
63 "--max-old-space-size=8192".into(),
64 server_path.into(),
65 "--stdio".into(),
66 ]
67}
68
69pub struct TypeScriptLspAdapter {
70 node: NodeRuntime,
71}
72
73impl TypeScriptLspAdapter {
74 const OLD_SERVER_PATH: &'static str = "node_modules/typescript-language-server/lib/cli.js";
75 const NEW_SERVER_PATH: &'static str = "node_modules/typescript-language-server/lib/cli.mjs";
76 const SERVER_NAME: LanguageServerName =
77 LanguageServerName::new_static("typescript-language-server");
78 const PACKAGE_NAME: &str = "typescript";
79 pub fn new(node: NodeRuntime) -> Self {
80 TypeScriptLspAdapter { node }
81 }
82 async fn tsdk_path(fs: &dyn Fs, adapter: &Arc<dyn LspAdapterDelegate>) -> Option<&'static str> {
83 let is_yarn = adapter
84 .read_text_file(PathBuf::from(".yarn/sdks/typescript/lib/typescript.js"))
85 .await
86 .is_ok();
87
88 let tsdk_path = if is_yarn {
89 ".yarn/sdks/typescript/lib"
90 } else {
91 "node_modules/typescript/lib"
92 };
93
94 if fs
95 .is_dir(&adapter.worktree_root_path().join(tsdk_path))
96 .await
97 {
98 Some(tsdk_path)
99 } else {
100 None
101 }
102 }
103}
104
105struct TypeScriptVersions {
106 typescript_version: String,
107 server_version: String,
108}
109
110#[async_trait(?Send)]
111impl LspAdapter for TypeScriptLspAdapter {
112 fn name(&self) -> LanguageServerName {
113 Self::SERVER_NAME.clone()
114 }
115
116 async fn fetch_latest_server_version(
117 &self,
118 _: &dyn LspAdapterDelegate,
119 ) -> Result<Box<dyn 'static + Send + Any>> {
120 Ok(Box::new(TypeScriptVersions {
121 typescript_version: self.node.npm_package_latest_version("typescript").await?,
122 server_version: self
123 .node
124 .npm_package_latest_version("typescript-language-server")
125 .await?,
126 }) as Box<_>)
127 }
128
129 async fn check_if_version_installed(
130 &self,
131 version: &(dyn 'static + Send + Any),
132 container_dir: &PathBuf,
133 _: &dyn LspAdapterDelegate,
134 ) -> Option<LanguageServerBinary> {
135 let version = version.downcast_ref::<TypeScriptVersions>().unwrap();
136 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
137
138 let should_install_language_server = self
139 .node
140 .should_install_npm_package(
141 Self::PACKAGE_NAME,
142 &server_path,
143 &container_dir,
144 version.typescript_version.as_str(),
145 )
146 .await;
147
148 if should_install_language_server {
149 None
150 } else {
151 Some(LanguageServerBinary {
152 path: self.node.binary_path().await.ok()?,
153 env: None,
154 arguments: typescript_server_binary_arguments(&server_path),
155 })
156 }
157 }
158
159 async fn fetch_server_binary(
160 &self,
161 latest_version: Box<dyn 'static + Send + Any>,
162 container_dir: PathBuf,
163 _: &dyn LspAdapterDelegate,
164 ) -> Result<LanguageServerBinary> {
165 let latest_version = latest_version.downcast::<TypeScriptVersions>().unwrap();
166 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
167
168 self.node
169 .npm_install_packages(
170 &container_dir,
171 &[
172 (
173 Self::PACKAGE_NAME,
174 latest_version.typescript_version.as_str(),
175 ),
176 (
177 "typescript-language-server",
178 latest_version.server_version.as_str(),
179 ),
180 ],
181 )
182 .await?;
183
184 Ok(LanguageServerBinary {
185 path: self.node.binary_path().await?,
186 env: None,
187 arguments: typescript_server_binary_arguments(&server_path),
188 })
189 }
190
191 async fn cached_server_binary(
192 &self,
193 container_dir: PathBuf,
194 _: &dyn LspAdapterDelegate,
195 ) -> Option<LanguageServerBinary> {
196 get_cached_ts_server_binary(container_dir, &self.node).await
197 }
198
199 fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
200 Some(vec![
201 CodeActionKind::QUICKFIX,
202 CodeActionKind::REFACTOR,
203 CodeActionKind::REFACTOR_EXTRACT,
204 CodeActionKind::SOURCE,
205 ])
206 }
207
208 async fn label_for_completion(
209 &self,
210 item: &lsp::CompletionItem,
211 language: &Arc<language::Language>,
212 ) -> Option<language::CodeLabel> {
213 use lsp::CompletionItemKind as Kind;
214 let len = item.label.len();
215 let grammar = language.grammar()?;
216 let highlight_id = match item.kind? {
217 Kind::CLASS | Kind::INTERFACE | Kind::ENUM => grammar.highlight_id_for_name("type"),
218 Kind::CONSTRUCTOR => grammar.highlight_id_for_name("type"),
219 Kind::CONSTANT => grammar.highlight_id_for_name("constant"),
220 Kind::FUNCTION | Kind::METHOD => grammar.highlight_id_for_name("function"),
221 Kind::PROPERTY | Kind::FIELD => grammar.highlight_id_for_name("property"),
222 Kind::VARIABLE => grammar.highlight_id_for_name("variable"),
223 _ => None,
224 }?;
225
226 let text = if let Some(description) = item
227 .label_details
228 .as_ref()
229 .and_then(|label_details| label_details.description.as_ref())
230 {
231 format!("{} {}", item.label, description)
232 } else if let Some(detail) = &item.detail {
233 format!("{} {}", item.label, detail)
234 } else {
235 item.label.clone()
236 };
237
238 Some(language::CodeLabel {
239 text,
240 runs: vec![(0..len, highlight_id)],
241 filter_range: 0..len,
242 })
243 }
244
245 async fn initialization_options(
246 self: Arc<Self>,
247 fs: &dyn Fs,
248 adapter: &Arc<dyn LspAdapterDelegate>,
249 ) -> Result<Option<serde_json::Value>> {
250 let tsdk_path = Self::tsdk_path(fs, adapter).await;
251 Ok(Some(json!({
252 "provideFormatter": true,
253 "hostInfo": "zed",
254 "tsserver": {
255 "path": tsdk_path,
256 },
257 "preferences": {
258 "includeInlayParameterNameHints": "all",
259 "includeInlayParameterNameHintsWhenArgumentMatchesName": true,
260 "includeInlayFunctionParameterTypeHints": true,
261 "includeInlayVariableTypeHints": true,
262 "includeInlayVariableTypeHintsWhenTypeMatchesName": true,
263 "includeInlayPropertyDeclarationTypeHints": true,
264 "includeInlayFunctionLikeReturnTypeHints": true,
265 "includeInlayEnumMemberValueHints": true,
266 }
267 })))
268 }
269
270 async fn workspace_configuration(
271 self: Arc<Self>,
272 _: &dyn Fs,
273 delegate: &Arc<dyn LspAdapterDelegate>,
274 _: Arc<dyn LanguageToolchainStore>,
275 cx: &mut AsyncApp,
276 ) -> Result<Value> {
277 let override_options = cx.update(|cx| {
278 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
279 .and_then(|s| s.settings.clone())
280 })?;
281 if let Some(options) = override_options {
282 return Ok(options);
283 }
284 Ok(json!({
285 "completions": {
286 "completeFunctionCalls": true
287 }
288 }))
289 }
290
291 fn language_ids(&self) -> HashMap<String, String> {
292 HashMap::from_iter([
293 ("TypeScript".into(), "typescript".into()),
294 ("JavaScript".into(), "javascript".into()),
295 ("TSX".into(), "typescriptreact".into()),
296 ])
297 }
298}
299
300async fn get_cached_ts_server_binary(
301 container_dir: PathBuf,
302 node: &NodeRuntime,
303) -> Option<LanguageServerBinary> {
304 maybe!(async {
305 let old_server_path = container_dir.join(TypeScriptLspAdapter::OLD_SERVER_PATH);
306 let new_server_path = container_dir.join(TypeScriptLspAdapter::NEW_SERVER_PATH);
307 if new_server_path.exists() {
308 Ok(LanguageServerBinary {
309 path: node.binary_path().await?,
310 env: None,
311 arguments: typescript_server_binary_arguments(&new_server_path),
312 })
313 } else if old_server_path.exists() {
314 Ok(LanguageServerBinary {
315 path: node.binary_path().await?,
316 env: None,
317 arguments: typescript_server_binary_arguments(&old_server_path),
318 })
319 } else {
320 anyhow::bail!("missing executable in directory {container_dir:?}")
321 }
322 })
323 .await
324 .log_err()
325}
326
327pub struct EsLintLspAdapter {
328 node: NodeRuntime,
329}
330
331impl EsLintLspAdapter {
332 const CURRENT_VERSION: &'static str = "2.4.4";
333 const CURRENT_VERSION_TAG_NAME: &'static str = "release/2.4.4";
334
335 #[cfg(not(windows))]
336 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
337 #[cfg(windows)]
338 const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
339
340 const SERVER_PATH: &'static str = "vscode-eslint/server/out/eslintServer.js";
341 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("eslint");
342
343 const FLAT_CONFIG_FILE_NAMES: &'static [&'static str] = &[
344 "eslint.config.js",
345 "eslint.config.mjs",
346 "eslint.config.cjs",
347 "eslint.config.ts",
348 "eslint.config.cts",
349 "eslint.config.mts",
350 ];
351
352 pub fn new(node: NodeRuntime) -> Self {
353 EsLintLspAdapter { node }
354 }
355
356 fn build_destination_path(container_dir: &Path) -> PathBuf {
357 container_dir.join(format!("vscode-eslint-{}", Self::CURRENT_VERSION))
358 }
359}
360
361#[async_trait(?Send)]
362impl LspAdapter for EsLintLspAdapter {
363 fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
364 Some(vec![
365 CodeActionKind::QUICKFIX,
366 CodeActionKind::new("source.fixAll.eslint"),
367 ])
368 }
369
370 async fn workspace_configuration(
371 self: Arc<Self>,
372 _: &dyn Fs,
373 delegate: &Arc<dyn LspAdapterDelegate>,
374 _: Arc<dyn LanguageToolchainStore>,
375 cx: &mut AsyncApp,
376 ) -> Result<Value> {
377 let workspace_root = delegate.worktree_root_path();
378 let use_flat_config = Self::FLAT_CONFIG_FILE_NAMES
379 .iter()
380 .any(|file| workspace_root.join(file).is_file());
381
382 let mut default_workspace_configuration = json!({
383 "validate": "on",
384 "rulesCustomizations": [],
385 "run": "onType",
386 "nodePath": null,
387 "workingDirectory": {
388 "mode": "auto"
389 },
390 "workspaceFolder": {
391 "uri": workspace_root,
392 "name": workspace_root.file_name()
393 .unwrap_or(workspace_root.as_os_str())
394 .to_string_lossy(),
395 },
396 "problems": {},
397 "codeActionOnSave": {
398 // We enable this, but without also configuring code_actions_on_format
399 // in the Zed configuration, it doesn't have an effect.
400 "enable": true,
401 },
402 "codeAction": {
403 "disableRuleComment": {
404 "enable": true,
405 "location": "separateLine",
406 },
407 "showDocumentation": {
408 "enable": true
409 }
410 },
411 "experimental": {
412 "useFlatConfig": use_flat_config,
413 },
414 });
415
416 let override_options = cx.update(|cx| {
417 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
418 .and_then(|s| s.settings.clone())
419 })?;
420
421 if let Some(override_options) = override_options {
422 merge_json_value_into(override_options, &mut default_workspace_configuration);
423 }
424
425 Ok(json!({
426 "": default_workspace_configuration
427 }))
428 }
429
430 fn name(&self) -> LanguageServerName {
431 Self::SERVER_NAME.clone()
432 }
433
434 async fn fetch_latest_server_version(
435 &self,
436 _delegate: &dyn LspAdapterDelegate,
437 ) -> Result<Box<dyn 'static + Send + Any>> {
438 let url = build_asset_url(
439 "zed-industries/vscode-eslint",
440 Self::CURRENT_VERSION_TAG_NAME,
441 Self::GITHUB_ASSET_KIND,
442 )?;
443
444 Ok(Box::new(GitHubLspBinaryVersion {
445 name: Self::CURRENT_VERSION.into(),
446 url,
447 }))
448 }
449
450 async fn fetch_server_binary(
451 &self,
452 version: Box<dyn 'static + Send + Any>,
453 container_dir: PathBuf,
454 delegate: &dyn LspAdapterDelegate,
455 ) -> Result<LanguageServerBinary> {
456 let version = version.downcast::<GitHubLspBinaryVersion>().unwrap();
457 let destination_path = Self::build_destination_path(&container_dir);
458 let server_path = destination_path.join(Self::SERVER_PATH);
459
460 if fs::metadata(&server_path).await.is_err() {
461 remove_matching(&container_dir, |entry| entry != destination_path).await;
462
463 let mut response = delegate
464 .http_client()
465 .get(&version.url, Default::default(), true)
466 .await
467 .context("downloading release")?;
468 match Self::GITHUB_ASSET_KIND {
469 AssetKind::TarGz => {
470 let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
471 let archive = Archive::new(decompressed_bytes);
472 archive.unpack(&destination_path).await.with_context(|| {
473 format!("extracting {} to {:?}", version.url, destination_path)
474 })?;
475 }
476 AssetKind::Gz => {
477 let mut decompressed_bytes =
478 GzipDecoder::new(BufReader::new(response.body_mut()));
479 let mut file =
480 fs::File::create(&destination_path).await.with_context(|| {
481 format!(
482 "creating a file {:?} for a download from {}",
483 destination_path, version.url,
484 )
485 })?;
486 futures::io::copy(&mut decompressed_bytes, &mut file)
487 .await
488 .with_context(|| {
489 format!("extracting {} to {:?}", version.url, destination_path)
490 })?;
491 }
492 AssetKind::Zip => {
493 extract_zip(&destination_path, response.body_mut())
494 .await
495 .with_context(|| {
496 format!("unzipping {} to {:?}", version.url, destination_path)
497 })?;
498 }
499 }
500
501 let mut dir = fs::read_dir(&destination_path).await?;
502 let first = dir.next().await.context("missing first file")??;
503 let repo_root = destination_path.join("vscode-eslint");
504 fs::rename(first.path(), &repo_root).await?;
505
506 #[cfg(target_os = "windows")]
507 {
508 handle_symlink(
509 repo_root.join("$shared"),
510 repo_root.join("client").join("src").join("shared"),
511 )
512 .await?;
513 handle_symlink(
514 repo_root.join("$shared"),
515 repo_root.join("server").join("src").join("shared"),
516 )
517 .await?;
518 }
519
520 self.node
521 .run_npm_subcommand(&repo_root, "install", &[])
522 .await?;
523
524 self.node
525 .run_npm_subcommand(&repo_root, "run-script", &["compile"])
526 .await?;
527 }
528
529 Ok(LanguageServerBinary {
530 path: self.node.binary_path().await?,
531 env: None,
532 arguments: eslint_server_binary_arguments(&server_path),
533 })
534 }
535
536 async fn cached_server_binary(
537 &self,
538 container_dir: PathBuf,
539 _: &dyn LspAdapterDelegate,
540 ) -> Option<LanguageServerBinary> {
541 let server_path =
542 Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH);
543 Some(LanguageServerBinary {
544 path: self.node.binary_path().await.ok()?,
545 env: None,
546 arguments: eslint_server_binary_arguments(&server_path),
547 })
548 }
549}
550
551#[cfg(target_os = "windows")]
552async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
553 anyhow::ensure!(
554 fs::metadata(&src_dir).await.is_ok(),
555 "Directory {src_dir:?} is not present"
556 );
557 if fs::metadata(&dest_dir).await.is_ok() {
558 fs::remove_file(&dest_dir).await?;
559 }
560 fs::create_dir_all(&dest_dir).await?;
561 let mut entries = fs::read_dir(&src_dir).await?;
562 while let Some(entry) = entries.try_next().await? {
563 let entry_path = entry.path();
564 let entry_name = entry.file_name();
565 let dest_path = dest_dir.join(&entry_name);
566 fs::copy(&entry_path, &dest_path).await?;
567 }
568 Ok(())
569}
570
571#[cfg(test)]
572mod tests {
573 use gpui::{AppContext as _, TestAppContext};
574 use unindent::Unindent;
575
576 #[gpui::test]
577 async fn test_outline(cx: &mut TestAppContext) {
578 let language = crate::language(
579 "typescript",
580 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
581 );
582
583 let text = r#"
584 function a() {
585 // local variables are omitted
586 let a1 = 1;
587 // all functions are included
588 async function a2() {}
589 }
590 // top-level variables are included
591 let b: C
592 function getB() {}
593 // exported variables are included
594 export const d = e;
595 "#
596 .unindent();
597
598 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
599 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None).unwrap());
600 assert_eq!(
601 outline
602 .items
603 .iter()
604 .map(|item| (item.text.as_str(), item.depth))
605 .collect::<Vec<_>>(),
606 &[
607 ("function a()", 0),
608 ("async function a2()", 1),
609 ("let b", 0),
610 ("function getB()", 0),
611 ("const d", 0),
612 ]
613 );
614 }
615}