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 = "3.0.10";
840 const CURRENT_VERSION_TAG_NAME: &'static str = "release/3.0.10";
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 "useFlatConfig": use_flat_config,
919 });
920
921 let override_options = cx.update(|cx| {
922 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
923 .and_then(|s| s.settings.clone())
924 })?;
925
926 if let Some(override_options) = override_options {
927 merge_json_value_into(override_options, &mut default_workspace_configuration);
928 }
929
930 Ok(json!({
931 "": default_workspace_configuration
932 }))
933 }
934
935 fn name(&self) -> LanguageServerName {
936 Self::SERVER_NAME.clone()
937 }
938
939 async fn fetch_latest_server_version(
940 &self,
941 _delegate: &dyn LspAdapterDelegate,
942 ) -> Result<Box<dyn 'static + Send + Any>> {
943 let url = build_asset_url(
944 "zed-industries/vscode-eslint",
945 Self::CURRENT_VERSION_TAG_NAME,
946 Self::GITHUB_ASSET_KIND,
947 )?;
948
949 Ok(Box::new(GitHubLspBinaryVersion {
950 name: Self::CURRENT_VERSION.into(),
951 url,
952 }))
953 }
954
955 async fn fetch_server_binary(
956 &self,
957 version: Box<dyn 'static + Send + Any>,
958 container_dir: PathBuf,
959 delegate: &dyn LspAdapterDelegate,
960 ) -> Result<LanguageServerBinary> {
961 let version = version.downcast::<GitHubLspBinaryVersion>().unwrap();
962 let destination_path = Self::build_destination_path(&container_dir);
963 let server_path = destination_path.join(Self::SERVER_PATH);
964
965 if fs::metadata(&server_path).await.is_err() {
966 remove_matching(&container_dir, |entry| entry != destination_path).await;
967
968 let mut response = delegate
969 .http_client()
970 .get(&version.url, Default::default(), true)
971 .await
972 .context("downloading release")?;
973 match Self::GITHUB_ASSET_KIND {
974 AssetKind::TarGz => {
975 let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
976 let archive = Archive::new(decompressed_bytes);
977 archive.unpack(&destination_path).await.with_context(|| {
978 format!("extracting {} to {:?}", version.url, destination_path)
979 })?;
980 }
981 AssetKind::Gz => {
982 let mut decompressed_bytes =
983 GzipDecoder::new(BufReader::new(response.body_mut()));
984 let mut file =
985 fs::File::create(&destination_path).await.with_context(|| {
986 format!(
987 "creating a file {:?} for a download from {}",
988 destination_path, version.url,
989 )
990 })?;
991 futures::io::copy(&mut decompressed_bytes, &mut file)
992 .await
993 .with_context(|| {
994 format!("extracting {} to {:?}", version.url, destination_path)
995 })?;
996 }
997 AssetKind::Zip => {
998 extract_zip(&destination_path, response.body_mut())
999 .await
1000 .with_context(|| {
1001 format!("unzipping {} to {:?}", version.url, destination_path)
1002 })?;
1003 }
1004 }
1005
1006 let mut dir = fs::read_dir(&destination_path).await?;
1007 let first = dir.next().await.context("missing first file")??;
1008 let repo_root = destination_path.join("vscode-eslint");
1009 fs::rename(first.path(), &repo_root).await?;
1010
1011 #[cfg(target_os = "windows")]
1012 {
1013 handle_symlink(
1014 repo_root.join("$shared"),
1015 repo_root.join("client").join("src").join("shared"),
1016 )
1017 .await?;
1018 handle_symlink(
1019 repo_root.join("$shared"),
1020 repo_root.join("server").join("src").join("shared"),
1021 )
1022 .await?;
1023 }
1024
1025 self.node
1026 .run_npm_subcommand(&repo_root, "install", &[])
1027 .await?;
1028
1029 self.node
1030 .run_npm_subcommand(&repo_root, "run-script", &["compile"])
1031 .await?;
1032 }
1033
1034 Ok(LanguageServerBinary {
1035 path: self.node.binary_path().await?,
1036 env: None,
1037 arguments: eslint_server_binary_arguments(&server_path),
1038 })
1039 }
1040
1041 async fn cached_server_binary(
1042 &self,
1043 container_dir: PathBuf,
1044 _: &dyn LspAdapterDelegate,
1045 ) -> Option<LanguageServerBinary> {
1046 let server_path =
1047 Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH);
1048 Some(LanguageServerBinary {
1049 path: self.node.binary_path().await.ok()?,
1050 env: None,
1051 arguments: eslint_server_binary_arguments(&server_path),
1052 })
1053 }
1054}
1055
1056#[cfg(target_os = "windows")]
1057async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
1058 anyhow::ensure!(
1059 fs::metadata(&src_dir).await.is_ok(),
1060 "Directory {src_dir:?} is not present"
1061 );
1062 if fs::metadata(&dest_dir).await.is_ok() {
1063 fs::remove_file(&dest_dir).await?;
1064 }
1065 fs::create_dir_all(&dest_dir).await?;
1066 let mut entries = fs::read_dir(&src_dir).await?;
1067 while let Some(entry) = entries.try_next().await? {
1068 let entry_path = entry.path();
1069 let entry_name = entry.file_name();
1070 let dest_path = dest_dir.join(&entry_name);
1071 fs::copy(&entry_path, &dest_path).await?;
1072 }
1073 Ok(())
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078 use std::path::Path;
1079
1080 use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
1081 use language::language_settings;
1082 use project::{FakeFs, Project};
1083 use serde_json::json;
1084 use unindent::Unindent;
1085 use util::path;
1086
1087 use crate::typescript::{PackageJsonData, TypeScriptContextProvider};
1088
1089 #[gpui::test]
1090 async fn test_outline(cx: &mut TestAppContext) {
1091 let language = crate::language(
1092 "typescript",
1093 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1094 );
1095
1096 let text = r#"
1097 function a() {
1098 // local variables are omitted
1099 let a1 = 1;
1100 // all functions are included
1101 async function a2() {}
1102 }
1103 // top-level variables are included
1104 let b: C
1105 function getB() {}
1106 // exported variables are included
1107 export const d = e;
1108 "#
1109 .unindent();
1110
1111 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1112 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None).unwrap());
1113 assert_eq!(
1114 outline
1115 .items
1116 .iter()
1117 .map(|item| (item.text.as_str(), item.depth))
1118 .collect::<Vec<_>>(),
1119 &[
1120 ("function a()", 0),
1121 ("async function a2()", 1),
1122 ("let b", 0),
1123 ("function getB()", 0),
1124 ("const d", 0),
1125 ]
1126 );
1127 }
1128
1129 #[gpui::test]
1130 async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1131 cx.update(|cx| {
1132 settings::init(cx);
1133 Project::init_settings(cx);
1134 language_settings::init(cx);
1135 });
1136
1137 let package_json_1 = json!({
1138 "dependencies": {
1139 "mocha": "1.0.0",
1140 "vitest": "1.0.0"
1141 },
1142 "scripts": {
1143 "test": ""
1144 }
1145 })
1146 .to_string();
1147
1148 let package_json_2 = json!({
1149 "devDependencies": {
1150 "vitest": "2.0.0"
1151 },
1152 "scripts": {
1153 "test": ""
1154 }
1155 })
1156 .to_string();
1157
1158 let fs = FakeFs::new(executor);
1159 fs.insert_tree(
1160 path!("/root"),
1161 json!({
1162 "package.json": package_json_1,
1163 "sub": {
1164 "package.json": package_json_2,
1165 "file.js": "",
1166 }
1167 }),
1168 )
1169 .await;
1170
1171 let provider = TypeScriptContextProvider::new();
1172 let package_json_data = cx
1173 .update(|cx| {
1174 provider.combined_package_json_data(
1175 fs.clone(),
1176 path!("/root").as_ref(),
1177 "sub/file1.js".as_ref(),
1178 cx,
1179 )
1180 })
1181 .await
1182 .unwrap();
1183 pretty_assertions::assert_eq!(
1184 package_json_data,
1185 PackageJsonData {
1186 jest_package_path: None,
1187 mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1188 vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1189 jasmine_package_path: None,
1190 scripts: [
1191 (
1192 Path::new(path!("/root/package.json")).into(),
1193 "test".to_owned()
1194 ),
1195 (
1196 Path::new(path!("/root/sub/package.json")).into(),
1197 "test".to_owned()
1198 )
1199 ]
1200 .into_iter()
1201 .collect(),
1202 package_manager: None,
1203 }
1204 );
1205 }
1206}