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