1use anyhow::{Context as _, Result};
2use async_compression::futures::bufread::GzipDecoder;
3use async_tar::Archive;
4use async_trait::async_trait;
5use chrono::{DateTime, Local};
6use collections::HashMap;
7use futures::future::join_all;
8use gpui::{App, AppContext, AsyncApp, Task};
9use http_client::github::{AssetKind, GitHubLspBinaryVersion, build_asset_url};
10use language::{
11 ContextLocation, ContextProvider, File, LanguageToolchainStore, LspAdapter, LspAdapterDelegate,
12};
13use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName};
14use node_runtime::NodeRuntime;
15use project::{Fs, lsp_store::language_server_settings};
16use serde_json::{Value, json};
17use smol::{fs, io::BufReader, lock::RwLock, stream::StreamExt};
18use std::{
19 any::Any,
20 borrow::Cow,
21 collections::BTreeSet,
22 ffi::OsString,
23 path::{Path, PathBuf},
24 sync::Arc,
25};
26use task::{TaskTemplate, TaskTemplates, VariableName};
27use util::archive::extract_zip;
28use util::merge_json_value_into;
29use util::{ResultExt, fs::remove_matching, maybe};
30
31#[derive(Debug)]
32pub(crate) struct TypeScriptContextProvider {
33 last_package_json: PackageJsonContents,
34}
35
36const TYPESCRIPT_RUNNER_VARIABLE: VariableName =
37 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_RUNNER"));
38
39const TYPESCRIPT_JEST_TEST_NAME_VARIABLE: VariableName =
40 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_TEST_NAME"));
41
42const TYPESCRIPT_VITEST_TEST_NAME_VARIABLE: VariableName =
43 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_TEST_NAME"));
44
45const TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE: VariableName =
46 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_PACKAGE_PATH"));
47
48const TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE: VariableName =
49 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_MOCHA_PACKAGE_PATH"));
50
51const TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE: VariableName =
52 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_PACKAGE_PATH"));
53
54const TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE: VariableName =
55 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JASMINE_PACKAGE_PATH"));
56
57#[derive(Clone, Debug, Default)]
58struct PackageJsonContents(Arc<RwLock<HashMap<PathBuf, PackageJson>>>);
59
60#[derive(Clone, Debug)]
61struct PackageJson {
62 mtime: DateTime<Local>,
63 data: PackageJsonData,
64}
65
66#[derive(Clone, Debug, Default, PartialEq, Eq)]
67struct PackageJsonData {
68 jest_package_path: Option<Arc<Path>>,
69 mocha_package_path: Option<Arc<Path>>,
70 vitest_package_path: Option<Arc<Path>>,
71 jasmine_package_path: Option<Arc<Path>>,
72 scripts: BTreeSet<(Arc<Path>, String)>,
73 package_manager: Option<&'static str>,
74}
75
76impl PackageJsonData {
77 fn new(path: Arc<Path>, package_json: HashMap<String, Value>) -> Self {
78 let mut scripts = BTreeSet::new();
79 if let Some(serde_json::Value::Object(package_json_scripts)) = package_json.get("scripts") {
80 scripts.extend(
81 package_json_scripts
82 .keys()
83 .cloned()
84 .map(|name| (path.clone(), name)),
85 );
86 }
87
88 let mut jest_package_path = None;
89 let mut mocha_package_path = None;
90 let mut vitest_package_path = None;
91 let mut jasmine_package_path = None;
92 if let Some(serde_json::Value::Object(dependencies)) = package_json.get("devDependencies") {
93 if dependencies.contains_key("jest") {
94 jest_package_path.get_or_insert_with(|| path.clone());
95 }
96 if dependencies.contains_key("mocha") {
97 mocha_package_path.get_or_insert_with(|| path.clone());
98 }
99 if dependencies.contains_key("vitest") {
100 vitest_package_path.get_or_insert_with(|| path.clone());
101 }
102 if dependencies.contains_key("jasmine") {
103 jasmine_package_path.get_or_insert_with(|| path.clone());
104 }
105 }
106 if let Some(serde_json::Value::Object(dev_dependencies)) = package_json.get("dependencies")
107 {
108 if dev_dependencies.contains_key("jest") {
109 jest_package_path.get_or_insert_with(|| path.clone());
110 }
111 if dev_dependencies.contains_key("mocha") {
112 mocha_package_path.get_or_insert_with(|| path.clone());
113 }
114 if dev_dependencies.contains_key("vitest") {
115 vitest_package_path.get_or_insert_with(|| path.clone());
116 }
117 if dev_dependencies.contains_key("jasmine") {
118 jasmine_package_path.get_or_insert_with(|| path.clone());
119 }
120 }
121
122 let package_manager = package_json
123 .get("packageManager")
124 .and_then(|value| value.as_str())
125 .and_then(|value| {
126 if value.starts_with("pnpm") {
127 Some("pnpm")
128 } else if value.starts_with("yarn") {
129 Some("yarn")
130 } else if value.starts_with("npm") {
131 Some("npm")
132 } else {
133 None
134 }
135 });
136
137 Self {
138 jest_package_path,
139 mocha_package_path,
140 vitest_package_path,
141 jasmine_package_path,
142 scripts,
143 package_manager,
144 }
145 }
146
147 fn merge(&mut self, other: Self) {
148 self.jest_package_path = self.jest_package_path.take().or(other.jest_package_path);
149 self.mocha_package_path = self.mocha_package_path.take().or(other.mocha_package_path);
150 self.vitest_package_path = self
151 .vitest_package_path
152 .take()
153 .or(other.vitest_package_path);
154 self.jasmine_package_path = self
155 .jasmine_package_path
156 .take()
157 .or(other.jasmine_package_path);
158 self.scripts.extend(other.scripts);
159 self.package_manager = self.package_manager.or(other.package_manager);
160 }
161
162 fn fill_task_templates(&self, task_templates: &mut TaskTemplates) {
163 if self.jest_package_path.is_some() {
164 task_templates.0.push(TaskTemplate {
165 label: "jest file test".to_owned(),
166 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
167 args: vec!["jest".to_owned(), VariableName::Filename.template_value()],
168 cwd: Some(VariableName::Dirname.template_value()),
169 ..TaskTemplate::default()
170 });
171 task_templates.0.push(TaskTemplate {
172 label: format!("jest test {}", VariableName::Symbol.template_value()),
173 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
174 args: vec![
175 "jest".to_owned(),
176 "--testNamePattern".to_owned(),
177 format!(
178 "\"{}\"",
179 TYPESCRIPT_JEST_TEST_NAME_VARIABLE.template_value()
180 ),
181 VariableName::Filename.template_value(),
182 ],
183 tags: vec![
184 "ts-test".to_owned(),
185 "js-test".to_owned(),
186 "tsx-test".to_owned(),
187 ],
188 cwd: Some(VariableName::Dirname.template_value()),
189 ..TaskTemplate::default()
190 });
191 }
192
193 if self.vitest_package_path.is_some() {
194 task_templates.0.push(TaskTemplate {
195 label: format!("{} file test", "vitest".to_owned()),
196 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
197 args: vec![
198 "vitest".to_owned(),
199 "run".to_owned(),
200 VariableName::Filename.template_value(),
201 ],
202 cwd: Some(VariableName::Dirname.template_value()),
203 ..TaskTemplate::default()
204 });
205 task_templates.0.push(TaskTemplate {
206 label: format!(
207 "{} test {}",
208 "vitest".to_owned(),
209 VariableName::Symbol.template_value(),
210 ),
211 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
212 args: vec![
213 "vitest".to_owned(),
214 "run".to_owned(),
215 "--testNamePattern".to_owned(),
216 format!(
217 "\"{}\"",
218 TYPESCRIPT_VITEST_TEST_NAME_VARIABLE.template_value()
219 ),
220 VariableName::Filename.template_value(),
221 ],
222 tags: vec![
223 "ts-test".to_owned(),
224 "js-test".to_owned(),
225 "tsx-test".to_owned(),
226 ],
227 cwd: Some(VariableName::Dirname.template_value()),
228 ..TaskTemplate::default()
229 });
230 }
231
232 if self.mocha_package_path.is_some() {
233 task_templates.0.push(TaskTemplate {
234 label: format!("{} file test", "mocha".to_owned()),
235 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
236 args: vec!["mocha".to_owned(), VariableName::Filename.template_value()],
237 cwd: Some(VariableName::Dirname.template_value()),
238 ..TaskTemplate::default()
239 });
240 task_templates.0.push(TaskTemplate {
241 label: format!(
242 "{} test {}",
243 "mocha".to_owned(),
244 VariableName::Symbol.template_value(),
245 ),
246 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
247 args: vec![
248 "mocha".to_owned(),
249 "--grep".to_owned(),
250 format!("\"{}\"", VariableName::Symbol.template_value()),
251 VariableName::Filename.template_value(),
252 ],
253 tags: vec![
254 "ts-test".to_owned(),
255 "js-test".to_owned(),
256 "tsx-test".to_owned(),
257 ],
258 cwd: Some(VariableName::Dirname.template_value()),
259 ..TaskTemplate::default()
260 });
261 }
262
263 if self.jasmine_package_path.is_some() {
264 task_templates.0.push(TaskTemplate {
265 label: format!("{} file test", "jasmine".to_owned()),
266 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
267 args: vec![
268 "jasmine".to_owned(),
269 VariableName::Filename.template_value(),
270 ],
271 cwd: Some(VariableName::Dirname.template_value()),
272 ..TaskTemplate::default()
273 });
274 task_templates.0.push(TaskTemplate {
275 label: format!(
276 "{} test {}",
277 "jasmine".to_owned(),
278 VariableName::Symbol.template_value(),
279 ),
280 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
281 args: vec![
282 "jasmine".to_owned(),
283 format!("--filter={}", VariableName::Symbol.template_value()),
284 VariableName::Filename.template_value(),
285 ],
286 tags: vec![
287 "ts-test".to_owned(),
288 "js-test".to_owned(),
289 "tsx-test".to_owned(),
290 "jasmine-test".to_owned(),
291 ],
292 cwd: Some(VariableName::Dirname.template_value()),
293 ..TaskTemplate::default()
294 });
295 }
296
297 for (path, script) in &self.scripts {
298 task_templates.0.push(TaskTemplate {
299 label: format!("package.json > {script}",),
300 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
301 args: vec!["run".to_owned(), script.to_owned()],
302 tags: vec!["package-script".into()],
303 cwd: Some(
304 path.parent()
305 .unwrap_or(Path::new(""))
306 .to_string_lossy()
307 .to_string(),
308 ),
309 ..TaskTemplate::default()
310 });
311 }
312 }
313}
314
315impl TypeScriptContextProvider {
316 pub fn new() -> Self {
317 Self {
318 last_package_json: PackageJsonContents::default(),
319 }
320 }
321
322 fn combined_package_json_data(
323 &self,
324 fs: Arc<dyn Fs>,
325 worktree_root: &Path,
326 file_relative_path: &Path,
327 cx: &App,
328 ) -> Task<anyhow::Result<PackageJsonData>> {
329 let new_json_data = file_relative_path
330 .ancestors()
331 .map(|path| worktree_root.join(path))
332 .map(|parent_path| {
333 self.package_json_data(&parent_path, self.last_package_json.clone(), fs.clone(), cx)
334 })
335 .collect::<Vec<_>>();
336
337 cx.background_spawn(async move {
338 let mut package_json_data = PackageJsonData::default();
339 for new_data in join_all(new_json_data).await.into_iter().flatten() {
340 package_json_data.merge(new_data);
341 }
342 Ok(package_json_data)
343 })
344 }
345
346 fn package_json_data(
347 &self,
348 directory_path: &Path,
349 existing_package_json: PackageJsonContents,
350 fs: Arc<dyn Fs>,
351 cx: &App,
352 ) -> Task<anyhow::Result<PackageJsonData>> {
353 let package_json_path = directory_path.join("package.json");
354 let metadata_check_fs = fs.clone();
355 cx.background_spawn(async move {
356 let metadata = metadata_check_fs
357 .metadata(&package_json_path)
358 .await
359 .with_context(|| format!("getting metadata for {package_json_path:?}"))?
360 .with_context(|| format!("missing FS metadata for {package_json_path:?}"))?;
361 let mtime = DateTime::<Local>::from(metadata.mtime.timestamp_for_user());
362 let existing_data = {
363 let contents = existing_package_json.0.read().await;
364 contents
365 .get(&package_json_path)
366 .filter(|package_json| package_json.mtime == mtime)
367 .map(|package_json| package_json.data.clone())
368 };
369 match existing_data {
370 Some(existing_data) => Ok(existing_data),
371 None => {
372 let package_json_string =
373 fs.load(&package_json_path).await.with_context(|| {
374 format!("loading package.json from {package_json_path:?}")
375 })?;
376 let package_json: HashMap<String, serde_json::Value> =
377 serde_json::from_str(&package_json_string).with_context(|| {
378 format!("parsing package.json from {package_json_path:?}")
379 })?;
380 let new_data =
381 PackageJsonData::new(package_json_path.as_path().into(), package_json);
382 {
383 let mut contents = existing_package_json.0.write().await;
384 contents.insert(
385 package_json_path,
386 PackageJson {
387 mtime,
388 data: new_data.clone(),
389 },
390 );
391 }
392 Ok(new_data)
393 }
394 }
395 })
396 }
397}
398
399async fn detect_package_manager(
400 worktree_root: PathBuf,
401 fs: Arc<dyn Fs>,
402 package_json_data: Option<PackageJsonData>,
403) -> &'static str {
404 if let Some(package_json_data) = package_json_data {
405 if let Some(package_manager) = package_json_data.package_manager {
406 return package_manager;
407 }
408 }
409 if fs.is_file(&worktree_root.join("pnpm-lock.yaml")).await {
410 return "pnpm";
411 }
412 if fs.is_file(&worktree_root.join("yarn.lock")).await {
413 return "yarn";
414 }
415 "npm"
416}
417
418impl ContextProvider for TypeScriptContextProvider {
419 fn associated_tasks(
420 &self,
421 fs: Arc<dyn Fs>,
422 file: Option<Arc<dyn File>>,
423 cx: &App,
424 ) -> Task<Option<TaskTemplates>> {
425 let Some(file) = project::File::from_dyn(file.as_ref()).cloned() else {
426 return Task::ready(None);
427 };
428 let Some(worktree_root) = file.worktree.read(cx).root_dir() else {
429 return Task::ready(None);
430 };
431 let file_relative_path = file.path().clone();
432 let package_json_data =
433 self.combined_package_json_data(fs.clone(), &worktree_root, &file_relative_path, cx);
434
435 cx.background_spawn(async move {
436 let mut task_templates = TaskTemplates(Vec::new());
437 task_templates.0.push(TaskTemplate {
438 label: format!(
439 "execute selection {}",
440 VariableName::SelectedText.template_value()
441 ),
442 command: "node".to_owned(),
443 args: vec![
444 "-e".to_owned(),
445 format!("\"{}\"", VariableName::SelectedText.template_value()),
446 ],
447 ..TaskTemplate::default()
448 });
449
450 match package_json_data.await {
451 Ok(package_json) => {
452 package_json.fill_task_templates(&mut task_templates);
453 }
454 Err(e) => {
455 log::error!(
456 "Failed to read package.json for worktree {file_relative_path:?}: {e:#}"
457 );
458 }
459 }
460
461 Some(task_templates)
462 })
463 }
464
465 fn build_context(
466 &self,
467 current_vars: &task::TaskVariables,
468 location: ContextLocation<'_>,
469 _project_env: Option<HashMap<String, String>>,
470 _toolchains: Arc<dyn LanguageToolchainStore>,
471 cx: &mut App,
472 ) -> Task<Result<task::TaskVariables>> {
473 let mut vars = task::TaskVariables::default();
474
475 if let Some(symbol) = current_vars.get(&VariableName::Symbol) {
476 vars.insert(
477 TYPESCRIPT_JEST_TEST_NAME_VARIABLE,
478 replace_test_name_parameters(symbol),
479 );
480 vars.insert(
481 TYPESCRIPT_VITEST_TEST_NAME_VARIABLE,
482 replace_test_name_parameters(symbol),
483 );
484 }
485 let file_path = location
486 .file_location
487 .buffer
488 .read(cx)
489 .file()
490 .map(|file| file.path());
491
492 let args = location.worktree_root.zip(location.fs).zip(file_path).map(
493 |((worktree_root, fs), file_path)| {
494 (
495 self.combined_package_json_data(fs.clone(), &worktree_root, file_path, cx),
496 worktree_root,
497 fs,
498 )
499 },
500 );
501 cx.background_spawn(async move {
502 if let Some((task, worktree_root, fs)) = args {
503 let package_json_data = task.await.log_err();
504 vars.insert(
505 TYPESCRIPT_RUNNER_VARIABLE,
506 detect_package_manager(worktree_root, fs, package_json_data.clone())
507 .await
508 .to_owned(),
509 );
510
511 if let Some(package_json_data) = package_json_data {
512 if let Some(path) = package_json_data.jest_package_path {
513 vars.insert(
514 TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE,
515 path.parent()
516 .unwrap_or(Path::new(""))
517 .to_string_lossy()
518 .to_string(),
519 );
520 }
521
522 if let Some(path) = package_json_data.mocha_package_path {
523 vars.insert(
524 TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE,
525 path.parent()
526 .unwrap_or(Path::new(""))
527 .to_string_lossy()
528 .to_string(),
529 );
530 }
531
532 if let Some(path) = package_json_data.vitest_package_path {
533 vars.insert(
534 TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE,
535 path.parent()
536 .unwrap_or(Path::new(""))
537 .to_string_lossy()
538 .to_string(),
539 );
540 }
541
542 if let Some(path) = package_json_data.jasmine_package_path {
543 vars.insert(
544 TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE,
545 path.parent()
546 .unwrap_or(Path::new(""))
547 .to_string_lossy()
548 .to_string(),
549 );
550 }
551 }
552 }
553 Ok(vars)
554 })
555 }
556}
557
558fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
559 vec![server_path.into(), "--stdio".into()]
560}
561
562fn eslint_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
563 vec![
564 "--max-old-space-size=8192".into(),
565 server_path.into(),
566 "--stdio".into(),
567 ]
568}
569
570fn replace_test_name_parameters(test_name: &str) -> String {
571 let pattern = regex::Regex::new(r"(%|\$)[0-9a-zA-Z]+").unwrap();
572
573 pattern.replace_all(test_name, "(.+?)").to_string()
574}
575
576pub struct TypeScriptLspAdapter {
577 node: NodeRuntime,
578}
579
580impl TypeScriptLspAdapter {
581 const OLD_SERVER_PATH: &'static str = "node_modules/typescript-language-server/lib/cli.js";
582 const NEW_SERVER_PATH: &'static str = "node_modules/typescript-language-server/lib/cli.mjs";
583 const SERVER_NAME: LanguageServerName =
584 LanguageServerName::new_static("typescript-language-server");
585 const PACKAGE_NAME: &str = "typescript";
586 pub fn new(node: NodeRuntime) -> Self {
587 TypeScriptLspAdapter { node }
588 }
589 async fn tsdk_path(fs: &dyn Fs, adapter: &Arc<dyn LspAdapterDelegate>) -> Option<&'static str> {
590 let is_yarn = adapter
591 .read_text_file(PathBuf::from(".yarn/sdks/typescript/lib/typescript.js"))
592 .await
593 .is_ok();
594
595 let tsdk_path = if is_yarn {
596 ".yarn/sdks/typescript/lib"
597 } else {
598 "node_modules/typescript/lib"
599 };
600
601 if fs
602 .is_dir(&adapter.worktree_root_path().join(tsdk_path))
603 .await
604 {
605 Some(tsdk_path)
606 } else {
607 None
608 }
609 }
610}
611
612struct TypeScriptVersions {
613 typescript_version: String,
614 server_version: String,
615}
616
617#[async_trait(?Send)]
618impl LspAdapter for TypeScriptLspAdapter {
619 fn name(&self) -> LanguageServerName {
620 Self::SERVER_NAME.clone()
621 }
622
623 async fn fetch_latest_server_version(
624 &self,
625 _: &dyn LspAdapterDelegate,
626 ) -> Result<Box<dyn 'static + Send + Any>> {
627 Ok(Box::new(TypeScriptVersions {
628 typescript_version: self.node.npm_package_latest_version("typescript").await?,
629 server_version: self
630 .node
631 .npm_package_latest_version("typescript-language-server")
632 .await?,
633 }) as Box<_>)
634 }
635
636 async fn check_if_version_installed(
637 &self,
638 version: &(dyn 'static + Send + Any),
639 container_dir: &PathBuf,
640 _: &dyn LspAdapterDelegate,
641 ) -> Option<LanguageServerBinary> {
642 let version = version.downcast_ref::<TypeScriptVersions>().unwrap();
643 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
644
645 let should_install_language_server = self
646 .node
647 .should_install_npm_package(
648 Self::PACKAGE_NAME,
649 &server_path,
650 &container_dir,
651 version.typescript_version.as_str(),
652 )
653 .await;
654
655 if should_install_language_server {
656 None
657 } else {
658 Some(LanguageServerBinary {
659 path: self.node.binary_path().await.ok()?,
660 env: None,
661 arguments: typescript_server_binary_arguments(&server_path),
662 })
663 }
664 }
665
666 async fn fetch_server_binary(
667 &self,
668 latest_version: Box<dyn 'static + Send + Any>,
669 container_dir: PathBuf,
670 _: &dyn LspAdapterDelegate,
671 ) -> Result<LanguageServerBinary> {
672 let latest_version = latest_version.downcast::<TypeScriptVersions>().unwrap();
673 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
674
675 self.node
676 .npm_install_packages(
677 &container_dir,
678 &[
679 (
680 Self::PACKAGE_NAME,
681 latest_version.typescript_version.as_str(),
682 ),
683 (
684 "typescript-language-server",
685 latest_version.server_version.as_str(),
686 ),
687 ],
688 )
689 .await?;
690
691 Ok(LanguageServerBinary {
692 path: self.node.binary_path().await?,
693 env: None,
694 arguments: typescript_server_binary_arguments(&server_path),
695 })
696 }
697
698 async fn cached_server_binary(
699 &self,
700 container_dir: PathBuf,
701 _: &dyn LspAdapterDelegate,
702 ) -> Option<LanguageServerBinary> {
703 get_cached_ts_server_binary(container_dir, &self.node).await
704 }
705
706 fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
707 Some(vec![
708 CodeActionKind::QUICKFIX,
709 CodeActionKind::REFACTOR,
710 CodeActionKind::REFACTOR_EXTRACT,
711 CodeActionKind::SOURCE,
712 ])
713 }
714
715 async fn label_for_completion(
716 &self,
717 item: &lsp::CompletionItem,
718 language: &Arc<language::Language>,
719 ) -> Option<language::CodeLabel> {
720 use lsp::CompletionItemKind as Kind;
721 let len = item.label.len();
722 let grammar = language.grammar()?;
723 let highlight_id = match item.kind? {
724 Kind::CLASS | Kind::INTERFACE | Kind::ENUM => grammar.highlight_id_for_name("type"),
725 Kind::CONSTRUCTOR => grammar.highlight_id_for_name("type"),
726 Kind::CONSTANT => grammar.highlight_id_for_name("constant"),
727 Kind::FUNCTION | Kind::METHOD => grammar.highlight_id_for_name("function"),
728 Kind::PROPERTY | Kind::FIELD => grammar.highlight_id_for_name("property"),
729 Kind::VARIABLE => grammar.highlight_id_for_name("variable"),
730 _ => None,
731 }?;
732
733 let text = if let Some(description) = item
734 .label_details
735 .as_ref()
736 .and_then(|label_details| label_details.description.as_ref())
737 {
738 format!("{} {}", item.label, description)
739 } else if let Some(detail) = &item.detail {
740 format!("{} {}", item.label, detail)
741 } else {
742 item.label.clone()
743 };
744
745 Some(language::CodeLabel {
746 text,
747 runs: vec![(0..len, highlight_id)],
748 filter_range: 0..len,
749 })
750 }
751
752 async fn initialization_options(
753 self: Arc<Self>,
754 fs: &dyn Fs,
755 adapter: &Arc<dyn LspAdapterDelegate>,
756 ) -> Result<Option<serde_json::Value>> {
757 let tsdk_path = Self::tsdk_path(fs, adapter).await;
758 Ok(Some(json!({
759 "provideFormatter": true,
760 "hostInfo": "zed",
761 "tsserver": {
762 "path": tsdk_path,
763 },
764 "preferences": {
765 "includeInlayParameterNameHints": "all",
766 "includeInlayParameterNameHintsWhenArgumentMatchesName": true,
767 "includeInlayFunctionParameterTypeHints": true,
768 "includeInlayVariableTypeHints": true,
769 "includeInlayVariableTypeHintsWhenTypeMatchesName": true,
770 "includeInlayPropertyDeclarationTypeHints": true,
771 "includeInlayFunctionLikeReturnTypeHints": true,
772 "includeInlayEnumMemberValueHints": true,
773 }
774 })))
775 }
776
777 async fn workspace_configuration(
778 self: Arc<Self>,
779 _: &dyn Fs,
780 delegate: &Arc<dyn LspAdapterDelegate>,
781 _: Arc<dyn LanguageToolchainStore>,
782 cx: &mut AsyncApp,
783 ) -> Result<Value> {
784 let override_options = cx.update(|cx| {
785 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
786 .and_then(|s| s.settings.clone())
787 })?;
788 if let Some(options) = override_options {
789 return Ok(options);
790 }
791 Ok(json!({
792 "completions": {
793 "completeFunctionCalls": true
794 }
795 }))
796 }
797
798 fn language_ids(&self) -> HashMap<String, String> {
799 HashMap::from_iter([
800 ("TypeScript".into(), "typescript".into()),
801 ("JavaScript".into(), "javascript".into()),
802 ("TSX".into(), "typescriptreact".into()),
803 ])
804 }
805}
806
807async fn get_cached_ts_server_binary(
808 container_dir: PathBuf,
809 node: &NodeRuntime,
810) -> Option<LanguageServerBinary> {
811 maybe!(async {
812 let old_server_path = container_dir.join(TypeScriptLspAdapter::OLD_SERVER_PATH);
813 let new_server_path = container_dir.join(TypeScriptLspAdapter::NEW_SERVER_PATH);
814 if new_server_path.exists() {
815 Ok(LanguageServerBinary {
816 path: node.binary_path().await?,
817 env: None,
818 arguments: typescript_server_binary_arguments(&new_server_path),
819 })
820 } else if old_server_path.exists() {
821 Ok(LanguageServerBinary {
822 path: node.binary_path().await?,
823 env: None,
824 arguments: typescript_server_binary_arguments(&old_server_path),
825 })
826 } else {
827 anyhow::bail!("missing executable in directory {container_dir:?}")
828 }
829 })
830 .await
831 .log_err()
832}
833
834pub struct EsLintLspAdapter {
835 node: NodeRuntime,
836}
837
838impl EsLintLspAdapter {
839 const CURRENT_VERSION: &'static str = "2.4.4";
840 const CURRENT_VERSION_TAG_NAME: &'static str = "release/2.4.4";
841
842 #[cfg(not(windows))]
843 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
844 #[cfg(windows)]
845 const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
846
847 const SERVER_PATH: &'static str = "vscode-eslint/server/out/eslintServer.js";
848 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("eslint");
849
850 const FLAT_CONFIG_FILE_NAMES: &'static [&'static str] = &[
851 "eslint.config.js",
852 "eslint.config.mjs",
853 "eslint.config.cjs",
854 "eslint.config.ts",
855 "eslint.config.cts",
856 "eslint.config.mts",
857 ];
858
859 pub fn new(node: NodeRuntime) -> Self {
860 EsLintLspAdapter { node }
861 }
862
863 fn build_destination_path(container_dir: &Path) -> PathBuf {
864 container_dir.join(format!("vscode-eslint-{}", Self::CURRENT_VERSION))
865 }
866}
867
868#[async_trait(?Send)]
869impl LspAdapter for EsLintLspAdapter {
870 fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
871 Some(vec![
872 CodeActionKind::QUICKFIX,
873 CodeActionKind::new("source.fixAll.eslint"),
874 ])
875 }
876
877 async fn workspace_configuration(
878 self: Arc<Self>,
879 _: &dyn Fs,
880 delegate: &Arc<dyn LspAdapterDelegate>,
881 _: Arc<dyn LanguageToolchainStore>,
882 cx: &mut AsyncApp,
883 ) -> Result<Value> {
884 let workspace_root = delegate.worktree_root_path();
885 let use_flat_config = Self::FLAT_CONFIG_FILE_NAMES
886 .iter()
887 .any(|file| workspace_root.join(file).is_file());
888
889 let mut default_workspace_configuration = json!({
890 "validate": "on",
891 "rulesCustomizations": [],
892 "run": "onType",
893 "nodePath": null,
894 "workingDirectory": {
895 "mode": "auto"
896 },
897 "workspaceFolder": {
898 "uri": workspace_root,
899 "name": workspace_root.file_name()
900 .unwrap_or(workspace_root.as_os_str())
901 .to_string_lossy(),
902 },
903 "problems": {},
904 "codeActionOnSave": {
905 // We enable this, but without also configuring code_actions_on_format
906 // in the Zed configuration, it doesn't have an effect.
907 "enable": true,
908 },
909 "codeAction": {
910 "disableRuleComment": {
911 "enable": true,
912 "location": "separateLine",
913 },
914 "showDocumentation": {
915 "enable": true
916 }
917 },
918 "experimental": {
919 "useFlatConfig": use_flat_config,
920 },
921 });
922
923 let override_options = cx.update(|cx| {
924 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
925 .and_then(|s| s.settings.clone())
926 })?;
927
928 if let Some(override_options) = override_options {
929 merge_json_value_into(override_options, &mut default_workspace_configuration);
930 }
931
932 Ok(json!({
933 "": default_workspace_configuration
934 }))
935 }
936
937 fn name(&self) -> LanguageServerName {
938 Self::SERVER_NAME.clone()
939 }
940
941 async fn fetch_latest_server_version(
942 &self,
943 _delegate: &dyn LspAdapterDelegate,
944 ) -> Result<Box<dyn 'static + Send + Any>> {
945 let url = build_asset_url(
946 "zed-industries/vscode-eslint",
947 Self::CURRENT_VERSION_TAG_NAME,
948 Self::GITHUB_ASSET_KIND,
949 )?;
950
951 Ok(Box::new(GitHubLspBinaryVersion {
952 name: Self::CURRENT_VERSION.into(),
953 url,
954 }))
955 }
956
957 async fn fetch_server_binary(
958 &self,
959 version: Box<dyn 'static + Send + Any>,
960 container_dir: PathBuf,
961 delegate: &dyn LspAdapterDelegate,
962 ) -> Result<LanguageServerBinary> {
963 let version = version.downcast::<GitHubLspBinaryVersion>().unwrap();
964 let destination_path = Self::build_destination_path(&container_dir);
965 let server_path = destination_path.join(Self::SERVER_PATH);
966
967 if fs::metadata(&server_path).await.is_err() {
968 remove_matching(&container_dir, |entry| entry != destination_path).await;
969
970 let mut response = delegate
971 .http_client()
972 .get(&version.url, Default::default(), true)
973 .await
974 .context("downloading release")?;
975 match Self::GITHUB_ASSET_KIND {
976 AssetKind::TarGz => {
977 let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
978 let archive = Archive::new(decompressed_bytes);
979 archive.unpack(&destination_path).await.with_context(|| {
980 format!("extracting {} to {:?}", version.url, destination_path)
981 })?;
982 }
983 AssetKind::Gz => {
984 let mut decompressed_bytes =
985 GzipDecoder::new(BufReader::new(response.body_mut()));
986 let mut file =
987 fs::File::create(&destination_path).await.with_context(|| {
988 format!(
989 "creating a file {:?} for a download from {}",
990 destination_path, version.url,
991 )
992 })?;
993 futures::io::copy(&mut decompressed_bytes, &mut file)
994 .await
995 .with_context(|| {
996 format!("extracting {} to {:?}", version.url, destination_path)
997 })?;
998 }
999 AssetKind::Zip => {
1000 extract_zip(&destination_path, response.body_mut())
1001 .await
1002 .with_context(|| {
1003 format!("unzipping {} to {:?}", version.url, destination_path)
1004 })?;
1005 }
1006 }
1007
1008 let mut dir = fs::read_dir(&destination_path).await?;
1009 let first = dir.next().await.context("missing first file")??;
1010 let repo_root = destination_path.join("vscode-eslint");
1011 fs::rename(first.path(), &repo_root).await?;
1012
1013 #[cfg(target_os = "windows")]
1014 {
1015 handle_symlink(
1016 repo_root.join("$shared"),
1017 repo_root.join("client").join("src").join("shared"),
1018 )
1019 .await?;
1020 handle_symlink(
1021 repo_root.join("$shared"),
1022 repo_root.join("server").join("src").join("shared"),
1023 )
1024 .await?;
1025 }
1026
1027 self.node
1028 .run_npm_subcommand(&repo_root, "install", &[])
1029 .await?;
1030
1031 self.node
1032 .run_npm_subcommand(&repo_root, "run-script", &["compile"])
1033 .await?;
1034 }
1035
1036 Ok(LanguageServerBinary {
1037 path: self.node.binary_path().await?,
1038 env: None,
1039 arguments: eslint_server_binary_arguments(&server_path),
1040 })
1041 }
1042
1043 async fn cached_server_binary(
1044 &self,
1045 container_dir: PathBuf,
1046 _: &dyn LspAdapterDelegate,
1047 ) -> Option<LanguageServerBinary> {
1048 let server_path =
1049 Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH);
1050 Some(LanguageServerBinary {
1051 path: self.node.binary_path().await.ok()?,
1052 env: None,
1053 arguments: eslint_server_binary_arguments(&server_path),
1054 })
1055 }
1056}
1057
1058#[cfg(target_os = "windows")]
1059async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
1060 anyhow::ensure!(
1061 fs::metadata(&src_dir).await.is_ok(),
1062 "Directory {src_dir:?} is not present"
1063 );
1064 if fs::metadata(&dest_dir).await.is_ok() {
1065 fs::remove_file(&dest_dir).await?;
1066 }
1067 fs::create_dir_all(&dest_dir).await?;
1068 let mut entries = fs::read_dir(&src_dir).await?;
1069 while let Some(entry) = entries.try_next().await? {
1070 let entry_path = entry.path();
1071 let entry_name = entry.file_name();
1072 let dest_path = dest_dir.join(&entry_name);
1073 fs::copy(&entry_path, &dest_path).await?;
1074 }
1075 Ok(())
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use std::path::Path;
1081
1082 use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
1083 use language::language_settings;
1084 use project::{FakeFs, Project};
1085 use serde_json::json;
1086 use unindent::Unindent;
1087 use util::path;
1088
1089 use crate::typescript::{PackageJsonData, TypeScriptContextProvider};
1090
1091 #[gpui::test]
1092 async fn test_outline(cx: &mut TestAppContext) {
1093 let language = crate::language(
1094 "typescript",
1095 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1096 );
1097
1098 let text = r#"
1099 function a() {
1100 // local variables are omitted
1101 let a1 = 1;
1102 // all functions are included
1103 async function a2() {}
1104 }
1105 // top-level variables are included
1106 let b: C
1107 function getB() {}
1108 // exported variables are included
1109 export const d = e;
1110 "#
1111 .unindent();
1112
1113 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1114 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None).unwrap());
1115 assert_eq!(
1116 outline
1117 .items
1118 .iter()
1119 .map(|item| (item.text.as_str(), item.depth))
1120 .collect::<Vec<_>>(),
1121 &[
1122 ("function a()", 0),
1123 ("async function a2()", 1),
1124 ("let b", 0),
1125 ("function getB()", 0),
1126 ("const d", 0),
1127 ]
1128 );
1129 }
1130
1131 #[gpui::test]
1132 async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1133 cx.update(|cx| {
1134 settings::init(cx);
1135 Project::init_settings(cx);
1136 language_settings::init(cx);
1137 });
1138
1139 let package_json_1 = json!({
1140 "dependencies": {
1141 "mocha": "1.0.0",
1142 "vitest": "1.0.0"
1143 },
1144 "scripts": {
1145 "test": ""
1146 }
1147 })
1148 .to_string();
1149
1150 let package_json_2 = json!({
1151 "devDependencies": {
1152 "vitest": "2.0.0"
1153 },
1154 "scripts": {
1155 "test": ""
1156 }
1157 })
1158 .to_string();
1159
1160 let fs = FakeFs::new(executor);
1161 fs.insert_tree(
1162 path!("/root"),
1163 json!({
1164 "package.json": package_json_1,
1165 "sub": {
1166 "package.json": package_json_2,
1167 "file.js": "",
1168 }
1169 }),
1170 )
1171 .await;
1172
1173 let provider = TypeScriptContextProvider::new();
1174 let package_json_data = cx
1175 .update(|cx| {
1176 provider.combined_package_json_data(
1177 fs.clone(),
1178 path!("/root").as_ref(),
1179 "sub/file1.js".as_ref(),
1180 cx,
1181 )
1182 })
1183 .await
1184 .unwrap();
1185 pretty_assertions::assert_eq!(
1186 package_json_data,
1187 PackageJsonData {
1188 jest_package_path: None,
1189 mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1190 vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1191 jasmine_package_path: None,
1192 scripts: [
1193 (
1194 Path::new(path!("/root/package.json")).into(),
1195 "test".to_owned()
1196 ),
1197 (
1198 Path::new(path!("/root/sub/package.json")).into(),
1199 "test".to_owned()
1200 )
1201 ]
1202 .into_iter()
1203 .collect(),
1204 package_manager: None,
1205 }
1206 );
1207 }
1208}