1use anyhow::{Context as _, Result};
2use async_trait::async_trait;
3use chrono::{DateTime, Local};
4use collections::HashMap;
5use futures::future::join_all;
6use gpui::{App, AppContext, AsyncApp, Task};
7use itertools::Itertools as _;
8use language::{
9 ContextLocation, ContextProvider, File, LanguageName, LanguageToolchainStore, LspAdapter,
10 LspAdapterDelegate, LspInstaller, Toolchain,
11};
12use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName, Uri};
13use node_runtime::{NodeRuntime, VersionStrategy};
14use project::{Fs, lsp_store::language_server_settings};
15use semver::Version;
16use serde_json::{Value, json};
17use smol::lock::RwLock;
18use std::{
19 borrow::Cow,
20 ffi::OsString,
21 path::{Path, PathBuf},
22 sync::{Arc, LazyLock},
23};
24use task::{TaskTemplate, TaskTemplates, VariableName};
25use util::rel_path::RelPath;
26use util::{ResultExt, maybe};
27
28use crate::{PackageJson, PackageJsonData};
29
30pub(crate) struct TypeScriptContextProvider {
31 fs: Arc<dyn Fs>,
32 last_package_json: PackageJsonContents,
33}
34
35const TYPESCRIPT_RUNNER_VARIABLE: VariableName =
36 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_RUNNER"));
37
38const TYPESCRIPT_JEST_TEST_NAME_VARIABLE: VariableName =
39 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_TEST_NAME"));
40
41const TYPESCRIPT_VITEST_TEST_NAME_VARIABLE: VariableName =
42 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_TEST_NAME"));
43
44const TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE: VariableName =
45 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_PACKAGE_PATH"));
46
47const TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE: VariableName =
48 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_MOCHA_PACKAGE_PATH"));
49
50const TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE: VariableName =
51 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_PACKAGE_PATH"));
52
53const TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE: VariableName =
54 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JASMINE_PACKAGE_PATH"));
55
56const TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE: VariableName =
57 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_BUN_PACKAGE_PATH"));
58
59const TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE: VariableName =
60 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_NODE_PACKAGE_PATH"));
61
62#[derive(Clone, Debug, Default)]
63struct PackageJsonContents(Arc<RwLock<HashMap<PathBuf, PackageJson>>>);
64
65impl PackageJsonData {
66 fn fill_task_templates(&self, task_templates: &mut TaskTemplates) {
67 if self.jest_package_path.is_some() {
68 task_templates.0.push(TaskTemplate {
69 label: "jest file test".to_owned(),
70 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
71 args: vec![
72 "exec".to_owned(),
73 "--".to_owned(),
74 "jest".to_owned(),
75 "--runInBand".to_owned(),
76 VariableName::File.template_value(),
77 ],
78 cwd: Some(TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE.template_value()),
79 ..TaskTemplate::default()
80 });
81 task_templates.0.push(TaskTemplate {
82 label: format!("jest test {}", VariableName::Symbol.template_value()),
83 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
84 args: vec![
85 "exec".to_owned(),
86 "--".to_owned(),
87 "jest".to_owned(),
88 "--runInBand".to_owned(),
89 "--testNamePattern".to_owned(),
90 format!(
91 "\"{}\"",
92 TYPESCRIPT_JEST_TEST_NAME_VARIABLE.template_value()
93 ),
94 VariableName::File.template_value(),
95 ],
96 tags: vec![
97 "ts-test".to_owned(),
98 "js-test".to_owned(),
99 "tsx-test".to_owned(),
100 ],
101 cwd: Some(TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE.template_value()),
102 ..TaskTemplate::default()
103 });
104 }
105
106 if self.vitest_package_path.is_some() {
107 task_templates.0.push(TaskTemplate {
108 label: format!("{} file test", "vitest".to_owned()),
109 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
110 args: vec![
111 "exec".to_owned(),
112 "--".to_owned(),
113 "vitest".to_owned(),
114 "run".to_owned(),
115 "--no-file-parallelism".to_owned(),
116 VariableName::File.template_value(),
117 ],
118 cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()),
119 ..TaskTemplate::default()
120 });
121 task_templates.0.push(TaskTemplate {
122 label: format!(
123 "{} test {}",
124 "vitest".to_owned(),
125 VariableName::Symbol.template_value(),
126 ),
127 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
128 args: vec![
129 "exec".to_owned(),
130 "--".to_owned(),
131 "vitest".to_owned(),
132 "run".to_owned(),
133 "--no-file-parallelism".to_owned(),
134 "--testNamePattern".to_owned(),
135 format!(
136 "\"{}\"",
137 TYPESCRIPT_VITEST_TEST_NAME_VARIABLE.template_value()
138 ),
139 VariableName::File.template_value(),
140 ],
141 tags: vec![
142 "ts-test".to_owned(),
143 "js-test".to_owned(),
144 "tsx-test".to_owned(),
145 ],
146 cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()),
147 ..TaskTemplate::default()
148 });
149 }
150
151 if self.mocha_package_path.is_some() {
152 task_templates.0.push(TaskTemplate {
153 label: format!("{} file test", "mocha".to_owned()),
154 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
155 args: vec![
156 "exec".to_owned(),
157 "--".to_owned(),
158 "mocha".to_owned(),
159 VariableName::File.template_value(),
160 ],
161 cwd: Some(TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE.template_value()),
162 ..TaskTemplate::default()
163 });
164 task_templates.0.push(TaskTemplate {
165 label: format!(
166 "{} test {}",
167 "mocha".to_owned(),
168 VariableName::Symbol.template_value(),
169 ),
170 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
171 args: vec![
172 "exec".to_owned(),
173 "--".to_owned(),
174 "mocha".to_owned(),
175 "--grep".to_owned(),
176 format!("\"{}\"", VariableName::Symbol.template_value()),
177 VariableName::File.template_value(),
178 ],
179 tags: vec![
180 "ts-test".to_owned(),
181 "js-test".to_owned(),
182 "tsx-test".to_owned(),
183 ],
184 cwd: Some(TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE.template_value()),
185 ..TaskTemplate::default()
186 });
187 }
188
189 if self.jasmine_package_path.is_some() {
190 task_templates.0.push(TaskTemplate {
191 label: format!("{} file test", "jasmine".to_owned()),
192 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
193 args: vec![
194 "exec".to_owned(),
195 "--".to_owned(),
196 "jasmine".to_owned(),
197 VariableName::File.template_value(),
198 ],
199 cwd: Some(TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE.template_value()),
200 ..TaskTemplate::default()
201 });
202 task_templates.0.push(TaskTemplate {
203 label: format!(
204 "{} test {}",
205 "jasmine".to_owned(),
206 VariableName::Symbol.template_value(),
207 ),
208 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
209 args: vec![
210 "exec".to_owned(),
211 "--".to_owned(),
212 "jasmine".to_owned(),
213 format!("--filter={}", VariableName::Symbol.template_value()),
214 VariableName::File.template_value(),
215 ],
216 tags: vec![
217 "ts-test".to_owned(),
218 "js-test".to_owned(),
219 "tsx-test".to_owned(),
220 ],
221 cwd: Some(TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE.template_value()),
222 ..TaskTemplate::default()
223 });
224 }
225
226 if self.bun_package_path.is_some() {
227 task_templates.0.push(TaskTemplate {
228 label: format!("{} file test", "bun test".to_owned()),
229 command: "bun".to_owned(),
230 args: vec!["test".to_owned(), VariableName::File.template_value()],
231 cwd: Some(TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE.template_value()),
232 ..TaskTemplate::default()
233 });
234 task_templates.0.push(TaskTemplate {
235 label: format!("bun test {}", VariableName::Symbol.template_value(),),
236 command: "bun".to_owned(),
237 args: vec![
238 "test".to_owned(),
239 "--test-name-pattern".to_owned(),
240 format!("\"{}\"", VariableName::Symbol.template_value()),
241 VariableName::File.template_value(),
242 ],
243 tags: vec![
244 "ts-test".to_owned(),
245 "js-test".to_owned(),
246 "tsx-test".to_owned(),
247 ],
248 cwd: Some(TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE.template_value()),
249 ..TaskTemplate::default()
250 });
251 }
252
253 if self.node_package_path.is_some() {
254 task_templates.0.push(TaskTemplate {
255 label: format!("{} file test", "node test".to_owned()),
256 command: "node".to_owned(),
257 args: vec!["--test".to_owned(), VariableName::File.template_value()],
258 tags: vec![
259 "ts-test".to_owned(),
260 "js-test".to_owned(),
261 "tsx-test".to_owned(),
262 ],
263 cwd: Some(TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE.template_value()),
264 ..TaskTemplate::default()
265 });
266 task_templates.0.push(TaskTemplate {
267 label: format!("node test {}", VariableName::Symbol.template_value()),
268 command: "node".to_owned(),
269 args: vec![
270 "--test".to_owned(),
271 "--test-name-pattern".to_owned(),
272 format!("\"{}\"", VariableName::Symbol.template_value()),
273 VariableName::File.template_value(),
274 ],
275 tags: vec![
276 "ts-test".to_owned(),
277 "js-test".to_owned(),
278 "tsx-test".to_owned(),
279 ],
280 cwd: Some(TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE.template_value()),
281 ..TaskTemplate::default()
282 });
283 }
284
285 let script_name_counts: HashMap<_, usize> =
286 self.scripts
287 .iter()
288 .fold(HashMap::default(), |mut acc, (_, script)| {
289 *acc.entry(script).or_default() += 1;
290 acc
291 });
292 for (path, script) in &self.scripts {
293 let label = if script_name_counts.get(script).copied().unwrap_or_default() > 1
294 && let Some(parent) = path.parent().and_then(|parent| parent.file_name())
295 {
296 let parent = parent.to_string_lossy();
297 format!("{parent}/package.json > {script}")
298 } else {
299 format!("package.json > {script}")
300 };
301 task_templates.0.push(TaskTemplate {
302 label,
303 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
304 args: vec!["run".to_owned(), script.to_owned()],
305 tags: vec!["package-script".into()],
306 cwd: Some(
307 path.parent()
308 .unwrap_or(Path::new("/"))
309 .to_string_lossy()
310 .to_string(),
311 ),
312 ..TaskTemplate::default()
313 });
314 }
315 }
316}
317
318impl TypeScriptContextProvider {
319 pub fn new(fs: Arc<dyn Fs>) -> Self {
320 Self {
321 fs,
322 last_package_json: PackageJsonContents::default(),
323 }
324 }
325
326 fn combined_package_json_data(
327 &self,
328 fs: Arc<dyn Fs>,
329 worktree_root: &Path,
330 file_relative_path: &RelPath,
331 cx: &App,
332 ) -> Task<anyhow::Result<PackageJsonData>> {
333 let new_json_data = file_relative_path
334 .ancestors()
335 .map(|path| worktree_root.join(path.as_std_path()))
336 .map(|parent_path| {
337 self.package_json_data(&parent_path, self.last_package_json.clone(), fs.clone(), cx)
338 })
339 .collect::<Vec<_>>();
340
341 cx.background_spawn(async move {
342 let mut package_json_data = PackageJsonData::default();
343 for new_data in join_all(new_json_data).await.into_iter().flatten() {
344 package_json_data.merge(new_data);
345 }
346 Ok(package_json_data)
347 })
348 }
349
350 fn package_json_data(
351 &self,
352 directory_path: &Path,
353 existing_package_json: PackageJsonContents,
354 fs: Arc<dyn Fs>,
355 cx: &App,
356 ) -> Task<anyhow::Result<PackageJsonData>> {
357 let package_json_path = directory_path.join("package.json");
358 let metadata_check_fs = fs.clone();
359 cx.background_spawn(async move {
360 let metadata = metadata_check_fs
361 .metadata(&package_json_path)
362 .await
363 .with_context(|| format!("getting metadata for {package_json_path:?}"))?
364 .with_context(|| format!("missing FS metadata for {package_json_path:?}"))?;
365 let mtime = DateTime::<Local>::from(metadata.mtime.timestamp_for_user());
366 let existing_data = {
367 let contents = existing_package_json.0.read().await;
368 contents
369 .get(&package_json_path)
370 .filter(|package_json| package_json.mtime == mtime)
371 .map(|package_json| package_json.data.clone())
372 };
373 match existing_data {
374 Some(existing_data) => Ok(existing_data),
375 None => {
376 let package_json_string =
377 fs.load(&package_json_path).await.with_context(|| {
378 format!("loading package.json from {package_json_path:?}")
379 })?;
380 let package_json: HashMap<String, serde_json_lenient::Value> =
381 serde_json_lenient::from_str(&package_json_string).with_context(|| {
382 format!("parsing package.json from {package_json_path:?}")
383 })?;
384 let new_data =
385 PackageJsonData::new(package_json_path.as_path().into(), package_json);
386 {
387 let mut contents = existing_package_json.0.write().await;
388 contents.insert(
389 package_json_path,
390 PackageJson {
391 mtime,
392 data: new_data.clone(),
393 },
394 );
395 }
396 Ok(new_data)
397 }
398 }
399 })
400 }
401}
402
403async fn detect_package_manager(
404 worktree_root: PathBuf,
405 fs: Arc<dyn Fs>,
406 package_json_data: Option<PackageJsonData>,
407) -> &'static str {
408 if let Some(package_json_data) = package_json_data
409 && let Some(package_manager) = package_json_data.package_manager
410 {
411 return package_manager;
412 }
413 if fs.is_file(&worktree_root.join("pnpm-lock.yaml")).await {
414 return "pnpm";
415 }
416 if fs.is_file(&worktree_root.join("yarn.lock")).await {
417 return "yarn";
418 }
419 "npm"
420}
421
422impl ContextProvider for TypeScriptContextProvider {
423 fn associated_tasks(
424 &self,
425 file: Option<Arc<dyn File>>,
426 cx: &App,
427 ) -> Task<Option<TaskTemplates>> {
428 let Some(file) = project::File::from_dyn(file.as_ref()).cloned() else {
429 return Task::ready(None);
430 };
431 let Some(worktree_root) = file.worktree.read(cx).root_dir() else {
432 return Task::ready(None);
433 };
434 let file_relative_path = file.path().clone();
435 let package_json_data = self.combined_package_json_data(
436 self.fs.clone(),
437 &worktree_root,
438 &file_relative_path,
439 cx,
440 );
441
442 cx.background_spawn(async move {
443 let mut task_templates = TaskTemplates(Vec::new());
444 task_templates.0.push(TaskTemplate {
445 label: format!(
446 "execute selection {}",
447 VariableName::SelectedText.template_value()
448 ),
449 command: "node".to_owned(),
450 args: vec![
451 "-e".to_owned(),
452 format!("\"{}\"", VariableName::SelectedText.template_value()),
453 ],
454 ..TaskTemplate::default()
455 });
456
457 match package_json_data.await {
458 Ok(package_json) => {
459 package_json.fill_task_templates(&mut task_templates);
460 }
461 Err(e) => {
462 log::error!(
463 "Failed to read package.json for worktree {file_relative_path:?}: {e:#}"
464 );
465 }
466 }
467
468 Some(task_templates)
469 })
470 }
471
472 fn build_context(
473 &self,
474 current_vars: &task::TaskVariables,
475 location: ContextLocation<'_>,
476 _project_env: Option<HashMap<String, String>>,
477 _toolchains: Arc<dyn LanguageToolchainStore>,
478 cx: &mut App,
479 ) -> Task<Result<task::TaskVariables>> {
480 let mut vars = task::TaskVariables::default();
481
482 if let Some(symbol) = current_vars.get(&VariableName::Symbol) {
483 vars.insert(
484 TYPESCRIPT_JEST_TEST_NAME_VARIABLE,
485 replace_test_name_parameters(symbol),
486 );
487 vars.insert(
488 TYPESCRIPT_VITEST_TEST_NAME_VARIABLE,
489 replace_test_name_parameters(symbol),
490 );
491 }
492 let file_path = location
493 .file_location
494 .buffer
495 .read(cx)
496 .file()
497 .map(|file| file.path());
498
499 let args = location.worktree_root.zip(location.fs).zip(file_path).map(
500 |((worktree_root, fs), file_path)| {
501 (
502 self.combined_package_json_data(fs.clone(), &worktree_root, file_path, cx),
503 worktree_root,
504 fs,
505 )
506 },
507 );
508 cx.background_spawn(async move {
509 if let Some((task, worktree_root, fs)) = args {
510 let package_json_data = task.await.log_err();
511 vars.insert(
512 TYPESCRIPT_RUNNER_VARIABLE,
513 detect_package_manager(worktree_root, fs, package_json_data.clone())
514 .await
515 .to_owned(),
516 );
517
518 if let Some(package_json_data) = package_json_data {
519 if let Some(path) = package_json_data.jest_package_path {
520 vars.insert(
521 TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE,
522 path.parent()
523 .unwrap_or(Path::new(""))
524 .to_string_lossy()
525 .to_string(),
526 );
527 }
528
529 if let Some(path) = package_json_data.mocha_package_path {
530 vars.insert(
531 TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE,
532 path.parent()
533 .unwrap_or(Path::new(""))
534 .to_string_lossy()
535 .to_string(),
536 );
537 }
538
539 if let Some(path) = package_json_data.vitest_package_path {
540 vars.insert(
541 TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE,
542 path.parent()
543 .unwrap_or(Path::new(""))
544 .to_string_lossy()
545 .to_string(),
546 );
547 }
548
549 if let Some(path) = package_json_data.jasmine_package_path {
550 vars.insert(
551 TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE,
552 path.parent()
553 .unwrap_or(Path::new(""))
554 .to_string_lossy()
555 .to_string(),
556 );
557 }
558
559 if let Some(path) = package_json_data.bun_package_path {
560 vars.insert(
561 TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE,
562 path.parent()
563 .unwrap_or(Path::new(""))
564 .to_string_lossy()
565 .to_string(),
566 );
567 }
568
569 if let Some(path) = package_json_data.node_package_path {
570 vars.insert(
571 TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE,
572 path.parent()
573 .unwrap_or(Path::new(""))
574 .to_string_lossy()
575 .to_string(),
576 );
577 }
578 }
579 }
580 Ok(vars)
581 })
582 }
583}
584
585fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
586 vec![server_path.into(), "--stdio".into()]
587}
588
589fn replace_test_name_parameters(test_name: &str) -> String {
590 static PATTERN: LazyLock<regex::Regex> =
591 LazyLock::new(|| regex::Regex::new(r"(\$([A-Za-z0-9_\.]+|[\#])|%[psdifjo#\$%])").unwrap());
592 PATTERN.split(test_name).map(regex::escape).join("(.+?)")
593}
594
595pub struct TypeScriptLspAdapter {
596 fs: Arc<dyn Fs>,
597 node: NodeRuntime,
598}
599
600impl TypeScriptLspAdapter {
601 const OLD_SERVER_PATH: &str = "node_modules/typescript-language-server/lib/cli.js";
602 const NEW_SERVER_PATH: &str = "node_modules/typescript-language-server/lib/cli.mjs";
603
604 const PACKAGE_NAME: &str = "typescript";
605 const SERVER_PACKAGE_NAME: &str = "typescript-language-server";
606
607 const SERVER_NAME: LanguageServerName =
608 LanguageServerName::new_static(Self::SERVER_PACKAGE_NAME);
609
610 pub fn new(node: NodeRuntime, fs: Arc<dyn Fs>) -> Self {
611 TypeScriptLspAdapter { fs, node }
612 }
613
614 async fn tsdk_path(&self, adapter: &Arc<dyn LspAdapterDelegate>) -> Option<&'static str> {
615 let is_yarn = adapter
616 .read_text_file(RelPath::unix(".yarn/sdks/typescript/lib/typescript.js").unwrap())
617 .await
618 .is_ok();
619
620 let tsdk_path = if is_yarn {
621 ".yarn/sdks/typescript/lib"
622 } else {
623 "node_modules/typescript/lib"
624 };
625
626 if self
627 .fs
628 .is_dir(&adapter.worktree_root_path().join(tsdk_path))
629 .await
630 {
631 Some(tsdk_path)
632 } else {
633 None
634 }
635 }
636}
637
638pub struct TypeScriptVersions {
639 typescript_version: Version,
640 server_version: Version,
641}
642
643impl LspInstaller for TypeScriptLspAdapter {
644 type BinaryVersion = TypeScriptVersions;
645
646 async fn fetch_latest_server_version(
647 &self,
648 _: &dyn LspAdapterDelegate,
649 _: bool,
650 _: &mut AsyncApp,
651 ) -> Result<Self::BinaryVersion> {
652 Ok(TypeScriptVersions {
653 typescript_version: self
654 .node
655 .npm_package_latest_version(Self::PACKAGE_NAME)
656 .await?,
657 server_version: self
658 .node
659 .npm_package_latest_version(Self::SERVER_PACKAGE_NAME)
660 .await?,
661 })
662 }
663
664 async fn check_if_version_installed(
665 &self,
666 version: &Self::BinaryVersion,
667 container_dir: &PathBuf,
668 _: &dyn LspAdapterDelegate,
669 ) -> Option<LanguageServerBinary> {
670 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
671
672 if self
673 .node
674 .should_install_npm_package(
675 Self::PACKAGE_NAME,
676 &server_path,
677 container_dir,
678 VersionStrategy::Latest(&version.typescript_version),
679 )
680 .await
681 {
682 return None;
683 }
684
685 if self
686 .node
687 .should_install_npm_package(
688 Self::SERVER_PACKAGE_NAME,
689 &server_path,
690 container_dir,
691 VersionStrategy::Latest(&version.server_version),
692 )
693 .await
694 {
695 return None;
696 }
697
698 Some(LanguageServerBinary {
699 path: self.node.binary_path().await.ok()?,
700 env: None,
701 arguments: typescript_server_binary_arguments(&server_path),
702 })
703 }
704
705 async fn fetch_server_binary(
706 &self,
707 latest_version: Self::BinaryVersion,
708 container_dir: PathBuf,
709 _: &dyn LspAdapterDelegate,
710 ) -> Result<LanguageServerBinary> {
711 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
712
713 self.node
714 .npm_install_packages(
715 &container_dir,
716 &[
717 (
718 Self::PACKAGE_NAME,
719 &latest_version.typescript_version.to_string(),
720 ),
721 (
722 Self::SERVER_PACKAGE_NAME,
723 &latest_version.server_version.to_string(),
724 ),
725 ],
726 )
727 .await?;
728
729 Ok(LanguageServerBinary {
730 path: self.node.binary_path().await?,
731 env: None,
732 arguments: typescript_server_binary_arguments(&server_path),
733 })
734 }
735
736 async fn cached_server_binary(
737 &self,
738 container_dir: PathBuf,
739 _: &dyn LspAdapterDelegate,
740 ) -> Option<LanguageServerBinary> {
741 get_cached_ts_server_binary(container_dir, &self.node).await
742 }
743}
744
745#[async_trait(?Send)]
746impl LspAdapter for TypeScriptLspAdapter {
747 fn name(&self) -> LanguageServerName {
748 Self::SERVER_NAME
749 }
750
751 fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
752 Some(vec![
753 CodeActionKind::QUICKFIX,
754 CodeActionKind::REFACTOR,
755 CodeActionKind::REFACTOR_EXTRACT,
756 CodeActionKind::SOURCE,
757 ])
758 }
759
760 async fn label_for_completion(
761 &self,
762 item: &lsp::CompletionItem,
763 language: &Arc<language::Language>,
764 ) -> Option<language::CodeLabel> {
765 use lsp::CompletionItemKind as Kind;
766 let label_len = item.label.len();
767 let grammar = language.grammar()?;
768 let highlight_id = match item.kind? {
769 Kind::CLASS | Kind::INTERFACE | Kind::ENUM => grammar.highlight_id_for_name("type"),
770 Kind::CONSTRUCTOR => grammar.highlight_id_for_name("type"),
771 Kind::CONSTANT => grammar.highlight_id_for_name("constant"),
772 Kind::FUNCTION | Kind::METHOD => grammar.highlight_id_for_name("function"),
773 Kind::PROPERTY | Kind::FIELD => grammar.highlight_id_for_name("property"),
774 Kind::VARIABLE => grammar.highlight_id_for_name("variable"),
775 _ => None,
776 }?;
777
778 let text = if let Some(description) = item
779 .label_details
780 .as_ref()
781 .and_then(|label_details| label_details.description.as_ref())
782 {
783 format!("{} {}", item.label, description)
784 } else if let Some(detail) = &item.detail {
785 format!("{} {}", item.label, detail)
786 } else {
787 item.label.clone()
788 };
789 Some(language::CodeLabel::filtered(
790 text,
791 label_len,
792 item.filter_text.as_deref(),
793 vec![(0..label_len, highlight_id)],
794 ))
795 }
796
797 async fn initialization_options(
798 self: Arc<Self>,
799 adapter: &Arc<dyn LspAdapterDelegate>,
800 ) -> Result<Option<serde_json::Value>> {
801 let tsdk_path = self.tsdk_path(adapter).await;
802 Ok(Some(json!({
803 "provideFormatter": true,
804 "hostInfo": "zed",
805 "tsserver": {
806 "path": tsdk_path,
807 },
808 "preferences": {
809 "includeInlayParameterNameHints": "all",
810 "includeInlayParameterNameHintsWhenArgumentMatchesName": true,
811 "includeInlayFunctionParameterTypeHints": true,
812 "includeInlayVariableTypeHints": true,
813 "includeInlayVariableTypeHintsWhenTypeMatchesName": true,
814 "includeInlayPropertyDeclarationTypeHints": true,
815 "includeInlayFunctionLikeReturnTypeHints": true,
816 "includeInlayEnumMemberValueHints": true,
817 }
818 })))
819 }
820
821 async fn workspace_configuration(
822 self: Arc<Self>,
823 delegate: &Arc<dyn LspAdapterDelegate>,
824 _: Option<Toolchain>,
825 _: Option<Uri>,
826 cx: &mut AsyncApp,
827 ) -> Result<Value> {
828 let override_options = cx.update(|cx| {
829 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
830 .and_then(|s| s.settings.clone())
831 })?;
832 if let Some(options) = override_options {
833 return Ok(options);
834 }
835 Ok(json!({
836 "completions": {
837 "completeFunctionCalls": true
838 }
839 }))
840 }
841
842 fn language_ids(&self) -> HashMap<LanguageName, String> {
843 HashMap::from_iter([
844 (LanguageName::new_static("TypeScript"), "typescript".into()),
845 (LanguageName::new_static("JavaScript"), "javascript".into()),
846 (LanguageName::new_static("TSX"), "typescriptreact".into()),
847 ])
848 }
849}
850
851async fn get_cached_ts_server_binary(
852 container_dir: PathBuf,
853 node: &NodeRuntime,
854) -> Option<LanguageServerBinary> {
855 maybe!(async {
856 let old_server_path = container_dir.join(TypeScriptLspAdapter::OLD_SERVER_PATH);
857 let new_server_path = container_dir.join(TypeScriptLspAdapter::NEW_SERVER_PATH);
858 if new_server_path.exists() {
859 Ok(LanguageServerBinary {
860 path: node.binary_path().await?,
861 env: None,
862 arguments: typescript_server_binary_arguments(&new_server_path),
863 })
864 } else if old_server_path.exists() {
865 Ok(LanguageServerBinary {
866 path: node.binary_path().await?,
867 env: None,
868 arguments: typescript_server_binary_arguments(&old_server_path),
869 })
870 } else {
871 anyhow::bail!("missing executable in directory {container_dir:?}")
872 }
873 })
874 .await
875 .log_err()
876}
877
878#[cfg(test)]
879mod tests {
880 use std::path::Path;
881
882 use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
883 use project::FakeFs;
884 use serde_json::json;
885 use task::TaskTemplates;
886 use unindent::Unindent;
887 use util::{path, rel_path::rel_path};
888
889 use crate::typescript::{
890 PackageJsonData, TypeScriptContextProvider, replace_test_name_parameters,
891 };
892
893 #[gpui::test]
894 async fn test_outline(cx: &mut TestAppContext) {
895 for language in [
896 crate::language(
897 "typescript",
898 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
899 ),
900 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
901 ] {
902 let text = r#"
903 function a() {
904 // local variables are included
905 let a1 = 1;
906 // all functions are included
907 async function a2() {}
908 }
909 // top-level variables are included
910 let b: C
911 function getB() {}
912 // exported variables are included
913 export const d = e;
914 "#
915 .unindent();
916
917 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
918 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
919 assert_eq!(
920 outline
921 .items
922 .iter()
923 .map(|item| (item.text.as_str(), item.depth))
924 .collect::<Vec<_>>(),
925 &[
926 ("function a()", 0),
927 ("let a1", 1),
928 ("async function a2()", 1),
929 ("let b", 0),
930 ("function getB()", 0),
931 ("const d", 0),
932 ]
933 );
934 }
935 }
936
937 #[gpui::test]
938 async fn test_outline_with_destructuring(cx: &mut TestAppContext) {
939 for language in [
940 crate::language(
941 "typescript",
942 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
943 ),
944 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
945 ] {
946 let text = r#"
947 // Top-level destructuring
948 const { a1, a2 } = a;
949 const [b1, b2] = b;
950
951 // Defaults and rest
952 const [c1 = 1, , c2, ...rest1] = c;
953 const { d1, d2: e1, f1 = 2, g1: h1 = 3, ...rest2 } = d;
954
955 function processData() {
956 // Nested object destructuring
957 const { c1, c2 } = c;
958 // Nested array destructuring
959 const [d1, d2, d3] = d;
960 // Destructuring with renaming
961 const { f1: g1 } = f;
962 // With defaults
963 const [x = 10, y] = xy;
964 }
965
966 class DataHandler {
967 method() {
968 // Destructuring in class method
969 const { a1, a2 } = a;
970 const [b1, ...b2] = b;
971 }
972 }
973 "#
974 .unindent();
975
976 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
977 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
978 assert_eq!(
979 outline
980 .items
981 .iter()
982 .map(|item| (item.text.as_str(), item.depth))
983 .collect::<Vec<_>>(),
984 &[
985 ("const a1", 0),
986 ("const a2", 0),
987 ("const b1", 0),
988 ("const b2", 0),
989 ("const c1", 0),
990 ("const c2", 0),
991 ("const rest1", 0),
992 ("const d1", 0),
993 ("const e1", 0),
994 ("const h1", 0),
995 ("const rest2", 0),
996 ("function processData()", 0),
997 ("const c1", 1),
998 ("const c2", 1),
999 ("const d1", 1),
1000 ("const d2", 1),
1001 ("const d3", 1),
1002 ("const g1", 1),
1003 ("const x", 1),
1004 ("const y", 1),
1005 ("class DataHandler", 0),
1006 ("method()", 1),
1007 ("const a1", 2),
1008 ("const a2", 2),
1009 ("const b1", 2),
1010 ("const b2", 2),
1011 ]
1012 );
1013 }
1014 }
1015
1016 #[gpui::test]
1017 async fn test_outline_with_object_properties(cx: &mut TestAppContext) {
1018 for language in [
1019 crate::language(
1020 "typescript",
1021 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1022 ),
1023 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1024 ] {
1025 let text = r#"
1026 // Object with function properties
1027 const o = { m() {}, async n() {}, g: function* () {}, h: () => {}, k: function () {} };
1028
1029 // Object with primitive properties
1030 const p = { p1: 1, p2: "hello", p3: true };
1031
1032 // Nested objects
1033 const q = {
1034 r: {
1035 // won't be included due to one-level depth limit
1036 s: 1
1037 },
1038 t: 2
1039 };
1040
1041 function getData() {
1042 const local = { x: 1, y: 2 };
1043 return local;
1044 }
1045 "#
1046 .unindent();
1047
1048 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1049 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1050 assert_eq!(
1051 outline
1052 .items
1053 .iter()
1054 .map(|item| (item.text.as_str(), item.depth))
1055 .collect::<Vec<_>>(),
1056 &[
1057 ("const o", 0),
1058 ("m()", 1),
1059 ("async n()", 1),
1060 ("g", 1),
1061 ("h", 1),
1062 ("k", 1),
1063 ("const p", 0),
1064 ("p1", 1),
1065 ("p2", 1),
1066 ("p3", 1),
1067 ("const q", 0),
1068 ("r", 1),
1069 ("s", 2),
1070 ("t", 1),
1071 ("function getData()", 0),
1072 ("const local", 1),
1073 ("x", 2),
1074 ("y", 2),
1075 ]
1076 );
1077 }
1078 }
1079
1080 #[gpui::test]
1081 async fn test_outline_with_computed_property_names(cx: &mut TestAppContext) {
1082 for language in [
1083 crate::language(
1084 "typescript",
1085 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1086 ),
1087 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1088 ] {
1089 let text = r#"
1090 // Symbols as object keys
1091 const sym = Symbol("test");
1092 const obj1 = {
1093 [sym]: 1,
1094 [Symbol("inline")]: 2,
1095 normalKey: 3
1096 };
1097
1098 // Enums as object keys
1099 enum Color { Red, Blue, Green }
1100
1101 const obj2 = {
1102 [Color.Red]: "red value",
1103 [Color.Blue]: "blue value",
1104 regularProp: "normal"
1105 };
1106
1107 // Mixed computed properties
1108 const key = "dynamic";
1109 const obj3 = {
1110 [key]: 1,
1111 ["string" + "concat"]: 2,
1112 [1 + 1]: 3,
1113 static: 4
1114 };
1115
1116 // Nested objects with computed properties
1117 const obj4 = {
1118 [sym]: {
1119 nested: 1
1120 },
1121 regular: {
1122 [key]: 2
1123 }
1124 };
1125 "#
1126 .unindent();
1127
1128 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1129 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1130 assert_eq!(
1131 outline
1132 .items
1133 .iter()
1134 .map(|item| (item.text.as_str(), item.depth))
1135 .collect::<Vec<_>>(),
1136 &[
1137 ("const sym", 0),
1138 ("const obj1", 0),
1139 ("[sym]", 1),
1140 ("[Symbol(\"inline\")]", 1),
1141 ("normalKey", 1),
1142 ("enum Color", 0),
1143 ("const obj2", 0),
1144 ("[Color.Red]", 1),
1145 ("[Color.Blue]", 1),
1146 ("regularProp", 1),
1147 ("const key", 0),
1148 ("const obj3", 0),
1149 ("[key]", 1),
1150 ("[\"string\" + \"concat\"]", 1),
1151 ("[1 + 1]", 1),
1152 ("static", 1),
1153 ("const obj4", 0),
1154 ("[sym]", 1),
1155 ("nested", 2),
1156 ("regular", 1),
1157 ("[key]", 2),
1158 ]
1159 );
1160 }
1161 }
1162
1163 #[gpui::test]
1164 async fn test_generator_function_outline(cx: &mut TestAppContext) {
1165 let language = crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into());
1166
1167 let text = r#"
1168 function normalFunction() {
1169 console.log("normal");
1170 }
1171
1172 function* simpleGenerator() {
1173 yield 1;
1174 yield 2;
1175 }
1176
1177 async function* asyncGenerator() {
1178 yield await Promise.resolve(1);
1179 }
1180
1181 function* generatorWithParams(start, end) {
1182 for (let i = start; i <= end; i++) {
1183 yield i;
1184 }
1185 }
1186
1187 class TestClass {
1188 *methodGenerator() {
1189 yield "method";
1190 }
1191
1192 async *asyncMethodGenerator() {
1193 yield "async method";
1194 }
1195 }
1196 "#
1197 .unindent();
1198
1199 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1200 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1201 assert_eq!(
1202 outline
1203 .items
1204 .iter()
1205 .map(|item| (item.text.as_str(), item.depth))
1206 .collect::<Vec<_>>(),
1207 &[
1208 ("function normalFunction()", 0),
1209 ("function* simpleGenerator()", 0),
1210 ("async function* asyncGenerator()", 0),
1211 ("function* generatorWithParams( )", 0),
1212 ("class TestClass", 0),
1213 ("*methodGenerator()", 1),
1214 ("async *asyncMethodGenerator()", 1),
1215 ]
1216 );
1217 }
1218
1219 #[gpui::test]
1220 async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1221 cx.update(|cx| {
1222 settings::init(cx);
1223 });
1224
1225 let package_json_1 = json!({
1226 "dependencies": {
1227 "mocha": "1.0.0",
1228 "vitest": "1.0.0"
1229 },
1230 "scripts": {
1231 "test": ""
1232 }
1233 })
1234 .to_string();
1235
1236 let package_json_2 = json!({
1237 "devDependencies": {
1238 "vitest": "2.0.0"
1239 },
1240 "scripts": {
1241 "test": ""
1242 }
1243 })
1244 .to_string();
1245
1246 let fs = FakeFs::new(executor);
1247 fs.insert_tree(
1248 path!("/root"),
1249 json!({
1250 "package.json": package_json_1,
1251 "sub": {
1252 "package.json": package_json_2,
1253 "file.js": "",
1254 }
1255 }),
1256 )
1257 .await;
1258
1259 let provider = TypeScriptContextProvider::new(fs.clone());
1260 let package_json_data = cx
1261 .update(|cx| {
1262 provider.combined_package_json_data(
1263 fs.clone(),
1264 path!("/root").as_ref(),
1265 rel_path("sub/file1.js"),
1266 cx,
1267 )
1268 })
1269 .await
1270 .unwrap();
1271 pretty_assertions::assert_eq!(
1272 package_json_data,
1273 PackageJsonData {
1274 jest_package_path: None,
1275 mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1276 vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1277 jasmine_package_path: None,
1278 bun_package_path: None,
1279 node_package_path: None,
1280 scripts: [
1281 (
1282 Path::new(path!("/root/package.json")).into(),
1283 "test".to_owned()
1284 ),
1285 (
1286 Path::new(path!("/root/sub/package.json")).into(),
1287 "test".to_owned()
1288 )
1289 ]
1290 .into_iter()
1291 .collect(),
1292 package_manager: None,
1293 }
1294 );
1295
1296 let mut task_templates = TaskTemplates::default();
1297 package_json_data.fill_task_templates(&mut task_templates);
1298 let task_templates = task_templates
1299 .0
1300 .into_iter()
1301 .map(|template| (template.label, template.cwd))
1302 .collect::<Vec<_>>();
1303 pretty_assertions::assert_eq!(
1304 task_templates,
1305 [
1306 (
1307 "vitest file test".into(),
1308 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1309 ),
1310 (
1311 "vitest test $ZED_SYMBOL".into(),
1312 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1313 ),
1314 (
1315 "mocha file test".into(),
1316 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1317 ),
1318 (
1319 "mocha test $ZED_SYMBOL".into(),
1320 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1321 ),
1322 (
1323 "root/package.json > test".into(),
1324 Some(path!("/root").into())
1325 ),
1326 (
1327 "sub/package.json > test".into(),
1328 Some(path!("/root/sub").into())
1329 ),
1330 ]
1331 );
1332 }
1333
1334 #[test]
1335 fn test_escaping_name() {
1336 let cases = [
1337 ("plain test name", "plain test name"),
1338 ("test name with $param_name", "test name with (.+?)"),
1339 ("test name with $nested.param.name", "test name with (.+?)"),
1340 ("test name with $#", "test name with (.+?)"),
1341 ("test name with $##", "test name with (.+?)\\#"),
1342 ("test name with %p", "test name with (.+?)"),
1343 ("test name with %s", "test name with (.+?)"),
1344 ("test name with %d", "test name with (.+?)"),
1345 ("test name with %i", "test name with (.+?)"),
1346 ("test name with %f", "test name with (.+?)"),
1347 ("test name with %j", "test name with (.+?)"),
1348 ("test name with %o", "test name with (.+?)"),
1349 ("test name with %#", "test name with (.+?)"),
1350 ("test name with %$", "test name with (.+?)"),
1351 ("test name with %%", "test name with (.+?)"),
1352 ("test name with %q", "test name with %q"),
1353 (
1354 "test name with regex chars .*+?^${}()|[]\\",
1355 "test name with regex chars \\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\",
1356 ),
1357 (
1358 "test name with multiple $params and %pretty and %b and (.+?)",
1359 "test name with multiple (.+?) and (.+?)retty and %b and \\(\\.\\+\\?\\)",
1360 ),
1361 ];
1362
1363 for (input, expected) in cases {
1364 assert_eq!(replace_test_name_parameters(input), expected);
1365 }
1366 }
1367
1368 // The order of test runner tasks is based on inferred user preference:
1369 // 1. Dedicated test runners (e.g., Jest, Vitest, Mocha, Jasmine) are prioritized.
1370 // 2. Bun's built-in test runner (`bun test`) comes next.
1371 // 3. Node.js's built-in test runner (`node --test`) is last.
1372 // This hierarchy assumes that if a dedicated test framework is installed, it is the
1373 // preferred testing mechanism. Between runtime-specific options, `bun test` is
1374 // typically preferred over `node --test` when @types/bun is present.
1375 #[gpui::test]
1376 async fn test_task_ordering_with_multiple_test_runners(
1377 executor: BackgroundExecutor,
1378 cx: &mut TestAppContext,
1379 ) {
1380 cx.update(|cx| {
1381 settings::init(cx);
1382 });
1383
1384 // Test case with all test runners present
1385 let package_json_all_runners = json!({
1386 "devDependencies": {
1387 "@types/bun": "1.0.0",
1388 "@types/node": "^20.0.0",
1389 "jest": "29.0.0",
1390 "mocha": "10.0.0",
1391 "vitest": "1.0.0",
1392 "jasmine": "5.0.0",
1393 },
1394 "scripts": {
1395 "test": "jest"
1396 }
1397 })
1398 .to_string();
1399
1400 let fs = FakeFs::new(executor);
1401 fs.insert_tree(
1402 path!("/root"),
1403 json!({
1404 "package.json": package_json_all_runners,
1405 "file.js": "",
1406 }),
1407 )
1408 .await;
1409
1410 let provider = TypeScriptContextProvider::new(fs.clone());
1411
1412 let package_json_data = cx
1413 .update(|cx| {
1414 provider.combined_package_json_data(
1415 fs.clone(),
1416 path!("/root").as_ref(),
1417 rel_path("file.js"),
1418 cx,
1419 )
1420 })
1421 .await
1422 .unwrap();
1423
1424 assert!(package_json_data.jest_package_path.is_some());
1425 assert!(package_json_data.mocha_package_path.is_some());
1426 assert!(package_json_data.vitest_package_path.is_some());
1427 assert!(package_json_data.jasmine_package_path.is_some());
1428 assert!(package_json_data.bun_package_path.is_some());
1429 assert!(package_json_data.node_package_path.is_some());
1430
1431 let mut task_templates = TaskTemplates::default();
1432 package_json_data.fill_task_templates(&mut task_templates);
1433
1434 let test_tasks: Vec<_> = task_templates
1435 .0
1436 .iter()
1437 .filter(|template| {
1438 template.tags.contains(&"ts-test".to_owned())
1439 || template.tags.contains(&"js-test".to_owned())
1440 })
1441 .map(|template| &template.label)
1442 .collect();
1443
1444 let node_test_index = test_tasks
1445 .iter()
1446 .position(|label| label.contains("node test"));
1447 let jest_test_index = test_tasks.iter().position(|label| label.contains("jest"));
1448 let bun_test_index = test_tasks
1449 .iter()
1450 .position(|label| label.contains("bun test"));
1451
1452 assert!(
1453 node_test_index.is_some(),
1454 "Node test tasks should be present"
1455 );
1456 assert!(
1457 jest_test_index.is_some(),
1458 "Jest test tasks should be present"
1459 );
1460 assert!(bun_test_index.is_some(), "Bun test tasks should be present");
1461
1462 assert!(
1463 jest_test_index.unwrap() < bun_test_index.unwrap(),
1464 "Jest should come before Bun"
1465 );
1466 assert!(
1467 bun_test_index.unwrap() < node_test_index.unwrap(),
1468 "Bun should come before Node"
1469 );
1470 }
1471}