typescript.rs

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