eslint.rs

  1use anyhow::{Context as _, Result};
  2use async_trait::async_trait;
  3use gpui::AsyncApp;
  4use http_client::{
  5    github::{AssetKind, GitHubLspBinaryVersion, build_asset_url},
  6    github_download::download_server_binary,
  7};
  8use language::{LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain};
  9use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName, Uri};
 10use node_runtime::NodeRuntime;
 11use project::lsp_store::language_server_settings_for;
 12use serde::{Deserialize, Serialize};
 13use serde_json::{Value, json};
 14use settings::SettingsLocation;
 15use smol::{fs, stream::StreamExt};
 16use std::{
 17    ffi::OsString,
 18    path::{Path, PathBuf},
 19    sync::Arc,
 20};
 21use util::merge_json_value_into;
 22use util::{fs::remove_matching, rel_path::RelPath};
 23
 24fn eslint_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
 25    vec![
 26        "--max-old-space-size=8192".into(),
 27        server_path.into(),
 28        "--stdio".into(),
 29    ]
 30}
 31
 32pub struct EsLintLspAdapter {
 33    node: NodeRuntime,
 34}
 35
 36impl EsLintLspAdapter {
 37    const CURRENT_VERSION: &'static str = "2.4.4";
 38    const CURRENT_VERSION_TAG_NAME: &'static str = "release/2.4.4";
 39
 40    #[cfg(not(windows))]
 41    const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
 42    #[cfg(windows)]
 43    const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
 44
 45    const SERVER_PATH: &'static str = "vscode-eslint/server/out/eslintServer.js";
 46    const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("eslint");
 47
 48    const FLAT_CONFIG_FILE_NAMES: &'static [&'static str] = &[
 49        "eslint.config.js",
 50        "eslint.config.mjs",
 51        "eslint.config.cjs",
 52        "eslint.config.ts",
 53        "eslint.config.cts",
 54        "eslint.config.mts",
 55    ];
 56
 57    pub fn new(node: NodeRuntime) -> Self {
 58        EsLintLspAdapter { node }
 59    }
 60
 61    fn build_destination_path(container_dir: &Path) -> PathBuf {
 62        container_dir.join(format!("vscode-eslint-{}", Self::CURRENT_VERSION))
 63    }
 64}
 65
 66impl LspInstaller for EsLintLspAdapter {
 67    type BinaryVersion = GitHubLspBinaryVersion;
 68
 69    async fn fetch_latest_server_version(
 70        &self,
 71        _delegate: &dyn LspAdapterDelegate,
 72        _: bool,
 73        _: &mut AsyncApp,
 74    ) -> Result<GitHubLspBinaryVersion> {
 75        let url = build_asset_url(
 76            "zed-industries/vscode-eslint",
 77            Self::CURRENT_VERSION_TAG_NAME,
 78            Self::GITHUB_ASSET_KIND,
 79        )?;
 80
 81        Ok(GitHubLspBinaryVersion {
 82            name: Self::CURRENT_VERSION.into(),
 83            digest: None,
 84            url,
 85        })
 86    }
 87
 88    async fn fetch_server_binary(
 89        &self,
 90        version: GitHubLspBinaryVersion,
 91        container_dir: PathBuf,
 92        delegate: &dyn LspAdapterDelegate,
 93    ) -> Result<LanguageServerBinary> {
 94        let destination_path = Self::build_destination_path(&container_dir);
 95        let server_path = destination_path.join(Self::SERVER_PATH);
 96
 97        if fs::metadata(&server_path).await.is_err() {
 98            remove_matching(&container_dir, |_| true).await;
 99
100            download_server_binary(
101                &*delegate.http_client(),
102                &version.url,
103                None,
104                &destination_path,
105                Self::GITHUB_ASSET_KIND,
106            )
107            .await?;
108
109            let mut dir = fs::read_dir(&destination_path).await?;
110            let first = dir.next().await.context("missing first file")??;
111            let repo_root = destination_path.join("vscode-eslint");
112            fs::rename(first.path(), &repo_root).await?;
113
114            #[cfg(target_os = "windows")]
115            {
116                handle_symlink(
117                    repo_root.join("$shared"),
118                    repo_root.join("client").join("src").join("shared"),
119                )
120                .await?;
121                handle_symlink(
122                    repo_root.join("$shared"),
123                    repo_root.join("server").join("src").join("shared"),
124                )
125                .await?;
126            }
127
128            self.node
129                .run_npm_subcommand(Some(&repo_root), "install", &[])
130                .await?;
131
132            self.node
133                .run_npm_subcommand(Some(&repo_root), "run-script", &["compile"])
134                .await?;
135        }
136
137        Ok(LanguageServerBinary {
138            path: self.node.binary_path().await?,
139            env: None,
140            arguments: eslint_server_binary_arguments(&server_path),
141        })
142    }
143
144    async fn cached_server_binary(
145        &self,
146        container_dir: PathBuf,
147        _: &dyn LspAdapterDelegate,
148    ) -> Option<LanguageServerBinary> {
149        let server_path =
150            Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH);
151        fs::metadata(&server_path).await.ok()?;
152        Some(LanguageServerBinary {
153            path: self.node.binary_path().await.ok()?,
154            env: None,
155            arguments: eslint_server_binary_arguments(&server_path),
156        })
157    }
158}
159
160#[async_trait(?Send)]
161impl LspAdapter for EsLintLspAdapter {
162    fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
163        Some(vec![
164            CodeActionKind::QUICKFIX,
165            CodeActionKind::new("source.fixAll.eslint"),
166        ])
167    }
168
169    async fn workspace_configuration(
170        self: Arc<Self>,
171        delegate: &Arc<dyn LspAdapterDelegate>,
172        _: Option<Toolchain>,
173        requested_uri: Option<Uri>,
174        cx: &mut AsyncApp,
175    ) -> Result<Value> {
176        let worktree_root = delegate.worktree_root_path();
177        let use_flat_config = Self::FLAT_CONFIG_FILE_NAMES
178            .iter()
179            .any(|file| worktree_root.join(file).is_file());
180
181        let mut default_workspace_configuration = json!({
182            "validate": "on",
183            "rulesCustomizations": [],
184            "run": "onType",
185            "nodePath": null,
186            "workingDirectory": {
187                "mode": "auto"
188            },
189            "workspaceFolder": {
190                "uri": worktree_root,
191                "name": worktree_root.file_name()
192                    .unwrap_or(worktree_root.as_os_str())
193                    .to_string_lossy(),
194            },
195            "problems": {},
196            "codeActionOnSave": {
197                // We enable this, but without also configuring code_actions_on_format
198                // in the Zed configuration, it doesn't have an effect.
199                "enable": true,
200            },
201            "codeAction": {
202                "disableRuleComment": {
203                    "enable": true,
204                    "location": "separateLine",
205                },
206                "showDocumentation": {
207                    "enable": true
208                }
209            },
210            "experimental": {
211                "useFlatConfig": use_flat_config,
212            }
213        });
214
215        let file_path = requested_uri
216            .as_ref()
217            .and_then(|uri| {
218                (uri.scheme() == "file")
219                    .then(|| uri.to_file_path().ok())
220                    .flatten()
221            })
222            .and_then(|abs_path| {
223                abs_path
224                    .strip_prefix(&worktree_root)
225                    .ok()
226                    .map(ToOwned::to_owned)
227            });
228        let file_path = file_path
229            .and_then(|p| RelPath::unix(&p).ok().map(ToOwned::to_owned))
230            .unwrap_or_else(|| RelPath::empty().to_owned());
231        let override_options = cx.update(|cx| {
232            language_server_settings_for(
233                SettingsLocation {
234                    worktree_id: delegate.worktree_id(),
235                    path: &file_path,
236                },
237                &Self::SERVER_NAME,
238                cx,
239            )
240            .and_then(|s| s.settings.clone())
241        });
242
243        if let Some(override_options) = override_options {
244            let working_directories = override_options.get("workingDirectories").and_then(|wd| {
245                serde_json::from_value::<WorkingDirectories>(wd.clone())
246                    .ok()
247                    .and_then(|wd| wd.0)
248            });
249
250            merge_json_value_into(override_options, &mut default_workspace_configuration);
251
252            let working_directory = working_directories
253                .zip(requested_uri)
254                .and_then(|(wd, uri)| {
255                    determine_working_directory(uri, wd, worktree_root.to_owned())
256                });
257
258            if let Some(working_directory) = working_directory
259                && let Some(wd) = default_workspace_configuration.get_mut("workingDirectory")
260            {
261                *wd = serde_json::to_value(working_directory)?;
262            }
263        }
264
265        Ok(json!({
266            "": default_workspace_configuration
267        }))
268    }
269
270    fn name(&self) -> LanguageServerName {
271        Self::SERVER_NAME
272    }
273}
274
275/// On Windows, converts Unix-style separators (/) to Windows-style (\).
276/// On Unix, returns the path unchanged
277fn normalize_path_separators(path: &str) -> String {
278    #[cfg(windows)]
279    {
280        path.replace('/', "\\")
281    }
282    #[cfg(not(windows))]
283    {
284        path.to_string()
285    }
286}
287
288fn determine_working_directory(
289    uri: Uri,
290    working_directories: Vec<WorkingDirectory>,
291    workspace_folder_path: PathBuf,
292) -> Option<ResultWorkingDirectory> {
293    let mut working_directory = None;
294
295    for item in working_directories {
296        let mut directory: Option<String> = None;
297        let mut pattern: Option<String> = None;
298        let mut no_cwd = false;
299        match item {
300            WorkingDirectory::String(contents) => {
301                directory = Some(normalize_path_separators(&contents));
302            }
303            WorkingDirectory::LegacyDirectoryItem(legacy_directory_item) => {
304                directory = Some(normalize_path_separators(&legacy_directory_item.directory));
305                no_cwd = !legacy_directory_item.change_process_cwd;
306            }
307            WorkingDirectory::DirectoryItem(directory_item) => {
308                directory = Some(normalize_path_separators(&directory_item.directory));
309                if let Some(not_cwd) = directory_item.not_cwd {
310                    no_cwd = not_cwd;
311                }
312            }
313            WorkingDirectory::PatternItem(pattern_item) => {
314                pattern = Some(normalize_path_separators(&pattern_item.pattern));
315                if let Some(not_cwd) = pattern_item.not_cwd {
316                    no_cwd = not_cwd;
317                }
318            }
319            WorkingDirectory::ModeItem(mode_item) => {
320                working_directory = Some(ResultWorkingDirectory::ModeItem(mode_item));
321                continue;
322            }
323        }
324
325        let mut item_value: Option<String> = None;
326        if directory.is_some() || pattern.is_some() {
327            let file_path: Option<PathBuf> = (uri.scheme() == "file")
328                .then(|| uri.to_file_path().ok())
329                .flatten();
330            if let Some(file_path) = file_path {
331                if let Some(mut directory) = directory {
332                    if Path::new(&directory).is_relative() {
333                        directory = workspace_folder_path
334                            .join(directory)
335                            .to_string_lossy()
336                            .to_string();
337                    }
338                    if !directory.ends_with(std::path::MAIN_SEPARATOR) {
339                        directory.push(std::path::MAIN_SEPARATOR);
340                    }
341                    if file_path.starts_with(&directory) {
342                        item_value = Some(directory);
343                    }
344                } else if let Some(mut pattern) = pattern
345                    && !pattern.is_empty()
346                {
347                    if Path::new(&pattern).is_relative() {
348                        pattern = workspace_folder_path
349                            .join(pattern)
350                            .to_string_lossy()
351                            .to_string();
352                    }
353                    if !pattern.ends_with(std::path::MAIN_SEPARATOR) {
354                        pattern.push(std::path::MAIN_SEPARATOR);
355                    }
356                    if let Some(matched) = match_glob_pattern(&pattern, &file_path) {
357                        item_value = Some(matched);
358                    }
359                }
360            }
361        }
362        if let Some(item_value) = item_value {
363            if working_directory
364                .as_ref()
365                .is_none_or(|wd| matches!(wd, ResultWorkingDirectory::ModeItem(_)))
366            {
367                working_directory = Some(ResultWorkingDirectory::DirectoryItem(DirectoryItem {
368                    directory: item_value,
369                    not_cwd: Some(no_cwd),
370                }));
371            } else if let Some(ResultWorkingDirectory::DirectoryItem(item)) = &mut working_directory
372                && item.directory.len() < item_value.len()
373            {
374                item.directory = item_value;
375                item.not_cwd = Some(no_cwd);
376            }
377        }
378    }
379
380    working_directory
381}
382
383fn match_glob_pattern(pattern: &str, file_path: &Path) -> Option<String> {
384    use globset::GlobBuilder;
385
386    let glob = GlobBuilder::new(pattern)
387        .literal_separator(true)
388        .build()
389        .ok()?
390        .compile_matcher();
391
392    let mut current = file_path.to_path_buf();
393    let mut matched: Option<String> = None;
394
395    while let Some(parent) = current.parent() {
396        let mut prefix = parent.to_string_lossy().to_string();
397        if !prefix.ends_with(std::path::MAIN_SEPARATOR) {
398            prefix.push(std::path::MAIN_SEPARATOR);
399        }
400        if glob.is_match(&prefix) {
401            matched = Some(prefix);
402        }
403        current = parent.to_path_buf();
404    }
405
406    matched
407}
408
409#[cfg(target_os = "windows")]
410async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
411    anyhow::ensure!(
412        fs::metadata(&src_dir).await.is_ok(),
413        "Directory {src_dir:?} is not present"
414    );
415    if fs::metadata(&dest_dir).await.is_ok() {
416        fs::remove_file(&dest_dir).await?;
417    }
418    fs::create_dir_all(&dest_dir).await?;
419    let mut entries = fs::read_dir(&src_dir).await?;
420    while let Some(entry) = entries.try_next().await? {
421        let entry_path = entry.path();
422        let entry_name = entry.file_name();
423        let dest_path = dest_dir.join(&entry_name);
424        fs::copy(&entry_path, &dest_path).await?;
425    }
426    Ok(())
427}
428
429#[derive(Serialize, Deserialize, Debug)]
430struct LegacyDirectoryItem {
431    directory: String,
432    #[serde(rename = "changeProcessCWD")]
433    change_process_cwd: bool,
434}
435
436#[derive(Serialize, Deserialize, Debug)]
437struct DirectoryItem {
438    directory: String,
439    #[serde(rename = "!cwd")]
440    not_cwd: Option<bool>,
441}
442
443#[derive(Serialize, Deserialize, Debug)]
444struct PatternItem {
445    pattern: String,
446    #[serde(rename = "!cwd")]
447    not_cwd: Option<bool>,
448}
449
450#[derive(Serialize, Deserialize, Debug)]
451struct ModeItem {
452    mode: ModeEnum,
453}
454
455#[derive(Serialize, Deserialize, Debug)]
456#[serde(rename_all = "lowercase")]
457enum ModeEnum {
458    Auto,
459    Location,
460}
461
462#[derive(Serialize, Deserialize, Debug)]
463#[serde(untagged)]
464enum WorkingDirectory {
465    String(String),
466    LegacyDirectoryItem(LegacyDirectoryItem),
467    DirectoryItem(DirectoryItem),
468    PatternItem(PatternItem),
469    ModeItem(ModeItem),
470}
471#[derive(Serialize, Deserialize)]
472struct WorkingDirectories(Option<Vec<WorkingDirectory>>);
473
474#[derive(Serialize, Deserialize, Debug)]
475#[serde(untagged)]
476enum ResultWorkingDirectory {
477    ModeItem(ModeItem),
478    DirectoryItem(DirectoryItem),
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    mod glob_patterns {
486        use super::*;
487
488        #[test]
489        fn test_match_glob_pattern() {
490            let pattern = unix_path_to_platform("/test/*/");
491            let file_path = PathBuf::from(unix_path_to_platform("/test/foo/bar/file.txt"));
492            let matched = match_glob_pattern(&pattern, &file_path);
493            assert_eq!(matched, Some(unix_path_to_platform("/test/foo/")));
494        }
495
496        #[test]
497        fn test_match_glob_pattern_globstar() {
498            let pattern = unix_path_to_platform("/workspace/**/src/");
499            let file_path = PathBuf::from(unix_path_to_platform(
500                "/workspace/packages/core/src/index.ts",
501            ));
502            let matched = match_glob_pattern(&pattern, &file_path);
503            assert_eq!(
504                matched,
505                Some(unix_path_to_platform("/workspace/packages/core/src/"))
506            );
507        }
508
509        #[test]
510        fn test_match_glob_pattern_no_match() {
511            let pattern = unix_path_to_platform("/other/*/");
512            let file_path = PathBuf::from(unix_path_to_platform("/test/foo/bar/file.txt"));
513            let matched = match_glob_pattern(&pattern, &file_path);
514            assert_eq!(matched, None);
515        }
516    }
517
518    mod unix_style_paths {
519        use super::*;
520
521        #[test]
522        fn test_working_directory_string() {
523            let uri = make_uri("/workspace/packages/foo/src/file.ts");
524            let working_directories = vec![WorkingDirectory::String("packages/foo".to_string())];
525            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
526
527            let result = determine_working_directory(uri, working_directories, workspace_folder);
528            assert_directory_result(
529                result,
530                &unix_path_to_platform("/workspace/packages/foo/"),
531                false,
532            );
533        }
534
535        #[test]
536        fn test_working_directory_absolute_path() {
537            let uri = make_uri("/workspace/packages/foo/src/file.ts");
538            let working_directories = vec![WorkingDirectory::String(unix_path_to_platform(
539                "/workspace/packages/foo",
540            ))];
541            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
542
543            let result = determine_working_directory(uri, working_directories, workspace_folder);
544            assert_directory_result(
545                result,
546                &unix_path_to_platform("/workspace/packages/foo/"),
547                false,
548            );
549        }
550
551        #[test]
552        fn test_working_directory_directory_item() {
553            let uri = make_uri("/workspace/packages/foo/src/file.ts");
554            let working_directories = vec![WorkingDirectory::DirectoryItem(DirectoryItem {
555                directory: "packages/foo".to_string(),
556                not_cwd: Some(true),
557            })];
558            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
559
560            let result = determine_working_directory(uri, working_directories, workspace_folder);
561            assert_directory_result(
562                result,
563                &unix_path_to_platform("/workspace/packages/foo/"),
564                true,
565            );
566        }
567
568        #[test]
569        fn test_working_directory_legacy_item() {
570            let uri = make_uri("/workspace/packages/foo/src/file.ts");
571            let working_directories =
572                vec![WorkingDirectory::LegacyDirectoryItem(LegacyDirectoryItem {
573                    directory: "packages/foo".to_string(),
574                    change_process_cwd: false,
575                })];
576            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
577
578            let result = determine_working_directory(uri, working_directories, workspace_folder);
579            assert_directory_result(
580                result,
581                &unix_path_to_platform("/workspace/packages/foo/"),
582                true,
583            );
584        }
585
586        #[test]
587        fn test_working_directory_pattern_item() {
588            let uri = make_uri("/workspace/packages/foo/src/file.ts");
589            let working_directories = vec![WorkingDirectory::PatternItem(PatternItem {
590                pattern: "packages/*/".to_string(),
591                not_cwd: Some(false),
592            })];
593            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
594
595            let result = determine_working_directory(uri, working_directories, workspace_folder);
596            assert_directory_result(
597                result,
598                &unix_path_to_platform("/workspace/packages/foo/"),
599                false,
600            );
601        }
602
603        #[test]
604        fn test_working_directory_multiple_patterns() {
605            let uri = make_uri("/workspace/apps/web/src/file.ts");
606            let working_directories = vec![
607                WorkingDirectory::PatternItem(PatternItem {
608                    pattern: "packages/*/".to_string(),
609                    not_cwd: None,
610                }),
611                WorkingDirectory::PatternItem(PatternItem {
612                    pattern: "apps/*/".to_string(),
613                    not_cwd: None,
614                }),
615            ];
616            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
617
618            let result = determine_working_directory(uri, working_directories, workspace_folder);
619            assert_directory_result(
620                result,
621                &unix_path_to_platform("/workspace/apps/web/"),
622                false,
623            );
624        }
625    }
626
627    #[cfg(windows)]
628    mod windows_style_paths {
629        use super::*;
630
631        #[test]
632        fn test_working_directory_string() {
633            let uri = make_uri("/workspace/packages/foo/src/file.ts");
634            let working_directories = vec![WorkingDirectory::String("packages\\foo".to_string())];
635            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
636
637            let result = determine_working_directory(uri, working_directories, workspace_folder);
638            assert_directory_result(
639                result,
640                &unix_path_to_platform("/workspace/packages/foo/"),
641                false,
642            );
643        }
644
645        #[test]
646        fn test_working_directory_absolute_path() {
647            let uri = make_uri("/workspace/packages/foo/src/file.ts");
648            let working_directories = vec![WorkingDirectory::String(
649                unix_path_to_platform("/workspace/packages/foo").replace('/', "\\"),
650            )];
651            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
652
653            let result = determine_working_directory(uri, working_directories, workspace_folder);
654            assert_directory_result(
655                result,
656                &unix_path_to_platform("/workspace/packages/foo/"),
657                false,
658            );
659        }
660
661        #[test]
662        fn test_working_directory_directory_item() {
663            let uri = make_uri("/workspace/packages/foo/src/file.ts");
664            let working_directories = vec![WorkingDirectory::DirectoryItem(DirectoryItem {
665                directory: "packages\\foo".to_string(),
666                not_cwd: Some(true),
667            })];
668            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
669
670            let result = determine_working_directory(uri, working_directories, workspace_folder);
671            assert_directory_result(
672                result,
673                &unix_path_to_platform("/workspace/packages/foo/"),
674                true,
675            );
676        }
677
678        #[test]
679        fn test_working_directory_legacy_item() {
680            let uri = make_uri("/workspace/packages/foo/src/file.ts");
681            let working_directories =
682                vec![WorkingDirectory::LegacyDirectoryItem(LegacyDirectoryItem {
683                    directory: "packages\\foo".to_string(),
684                    change_process_cwd: false,
685                })];
686            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
687
688            let result = determine_working_directory(uri, working_directories, workspace_folder);
689            assert_directory_result(
690                result,
691                &unix_path_to_platform("/workspace/packages/foo/"),
692                true,
693            );
694        }
695
696        #[test]
697        fn test_working_directory_pattern_item() {
698            let uri = make_uri("/workspace/packages/foo/src/file.ts");
699            let working_directories = vec![WorkingDirectory::PatternItem(PatternItem {
700                pattern: "packages\\*\\".to_string(),
701                not_cwd: Some(false),
702            })];
703            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
704
705            let result = determine_working_directory(uri, working_directories, workspace_folder);
706            assert_directory_result(
707                result,
708                &unix_path_to_platform("/workspace/packages/foo/"),
709                false,
710            );
711        }
712
713        #[test]
714        fn test_working_directory_multiple_patterns() {
715            let uri = make_uri("/workspace/apps/web/src/file.ts");
716            let working_directories = vec![
717                WorkingDirectory::PatternItem(PatternItem {
718                    pattern: "packages\\*\\".to_string(),
719                    not_cwd: None,
720                }),
721                WorkingDirectory::PatternItem(PatternItem {
722                    pattern: "apps\\*\\".to_string(),
723                    not_cwd: None,
724                }),
725            ];
726            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
727
728            let result = determine_working_directory(uri, working_directories, workspace_folder);
729            assert_directory_result(
730                result,
731                &unix_path_to_platform("/workspace/apps/web/"),
732                false,
733            );
734        }
735    }
736
737    /// Converts a Unix-style path to a platform-specific path.
738    /// On Windows, converts "/workspace/foo/bar" to "C:\workspace\foo\bar"
739    /// On Unix, returns the path unchanged.
740    fn unix_path_to_platform(path: &str) -> String {
741        #[cfg(windows)]
742        {
743            if path.starts_with('/') {
744                format!("C:{}", path.replace('/', "\\"))
745            } else {
746                path.replace('/', "\\")
747            }
748        }
749        #[cfg(not(windows))]
750        {
751            path.to_string()
752        }
753    }
754
755    fn make_uri(path: &str) -> Uri {
756        let platform_path = unix_path_to_platform(path);
757        Uri::from_file_path(&platform_path).unwrap()
758    }
759
760    fn assert_directory_result(
761        result: Option<ResultWorkingDirectory>,
762        expected_directory: &str,
763        expected_not_cwd: bool,
764    ) {
765        match result {
766            Some(ResultWorkingDirectory::DirectoryItem(item)) => {
767                assert_eq!(item.directory, expected_directory);
768                assert_eq!(item.not_cwd, Some(expected_not_cwd));
769            }
770            other => panic!("Expected DirectoryItem, got {:?}", other),
771        }
772    }
773}