typescript.rs

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