Skip to main content

codebook_lsp/
lsp.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::str::FromStr as _;
4use std::sync::{Arc, Mutex, OnceLock, RwLock};
5use std::time::{Duration, Instant};
6
7use codebook::parser::get_word_from_string;
8use codebook::queries::LanguageType;
9use string_offsets::AllConfig;
10use string_offsets::Pos;
11use string_offsets::StringOffsets;
12
13use log::error;
14use serde_json::Value;
15use tokio::task;
16use tower_lsp::jsonrpc::Result as RpcResult;
17use tower_lsp::lsp_types::*;
18use tower_lsp::{Client, LanguageServer};
19
20use codebook::Codebook;
21use codebook_config::{CodebookConfig, CodebookConfigFile};
22use log::{debug, info};
23
24use crate::file_cache::TextDocumentCache;
25use crate::init_options::ClientInitializationOptions;
26use crate::lsp_logger;
27
28const SOURCE_NAME: &str = "Codebook";
29
30/// How often to poll the config files for external changes. spell_check runs
31/// on every keystroke with checkWhileTyping, so polling is debounced rather
32/// than done per call; changes made via code actions bypass this by calling
33/// recheck_all directly.
34const CONFIG_POLL_INTERVAL: Duration = Duration::from_secs(5);
35
36/// Computes the relative path of a file from a workspace directory.
37/// Returns the relative path if the file is within the workspace, otherwise returns the absolute path.
38/// If `workspace_dir_canonical` is provided, skips canonicalizing the workspace directory (optimization).
39fn compute_relative_path(
40    workspace_dir: &Path,
41    workspace_dir_canonical: Option<&Path>,
42    file_path: &Path,
43) -> String {
44    let workspace_canonical = match workspace_dir_canonical {
45        Some(dir) => dir.to_path_buf(),
46        None => match workspace_dir.canonicalize() {
47            Ok(dir) => dir,
48            Err(err) => {
49                info!("Could not canonicalize workspace directory. Error: {err}.");
50                return file_path.to_string_lossy().to_string();
51            }
52        },
53    };
54
55    match file_path.canonicalize() {
56        Ok(canon_file_path) => match canon_file_path.strip_prefix(&workspace_canonical) {
57            Ok(relative) => relative.to_string_lossy().to_string(),
58            Err(_) => file_path.to_string_lossy().to_string(),
59        },
60        Err(_) => file_path.to_string_lossy().to_string(),
61    }
62}
63
64pub struct Backend {
65    client: Client,
66    workspace_dir: PathBuf,
67    /// Cached canonicalized workspace directory for efficient relative path computation
68    workspace_dir_canonical: Option<PathBuf>,
69    codebook: OnceLock<Arc<Codebook>>,
70    config: OnceLock<Arc<CodebookConfigFile>>,
71    document_cache: TextDocumentCache,
72    initialize_options: RwLock<Arc<ClientInitializationOptions>>,
73    /// When the config files were last polled for changes (None = never)
74    last_config_poll: Mutex<Option<Instant>>,
75}
76
77enum CodebookCommand {
78    AddWord,
79    AddWordGlobal,
80    IgnoreFile,
81    Unknown,
82}
83
84impl From<&str> for CodebookCommand {
85    fn from(command: &str) -> Self {
86        match command {
87            "codebook.addWord" => CodebookCommand::AddWord,
88            "codebook.addWordGlobal" => CodebookCommand::AddWordGlobal,
89            "codebook.ignoreFile" => CodebookCommand::IgnoreFile,
90            _ => CodebookCommand::Unknown,
91        }
92    }
93}
94
95impl From<CodebookCommand> for String {
96    fn from(command: CodebookCommand) -> Self {
97        match command {
98            CodebookCommand::AddWord => "codebook.addWord".to_string(),
99            CodebookCommand::AddWordGlobal => "codebook.addWordGlobal".to_string(),
100            CodebookCommand::IgnoreFile => "codebook.ignoreFile".to_string(),
101            CodebookCommand::Unknown => "codebook.unknown".to_string(),
102        }
103    }
104}
105
106#[tower_lsp::async_trait]
107impl LanguageServer for Backend {
108    async fn initialize(&self, params: InitializeParams) -> RpcResult<InitializeResult> {
109        // info!("Capabilities: {:?}", params.capabilities);
110        let client_options = ClientInitializationOptions::from_value(params.initialization_options);
111        info!("Client options: {:?}", client_options);
112
113        // Attach the LSP client to the logger and flush buffered logs
114        lsp_logger::LspLogger::attach_client(self.client.clone(), client_options.log_level);
115        info!(
116            "LSP logger attached to client with log level: {}",
117            client_options.log_level
118        );
119
120        *self.initialize_options.write().unwrap() = Arc::new(client_options);
121
122        Ok(InitializeResult {
123            capabilities: ServerCapabilities {
124                position_encoding: Some(PositionEncodingKind::UTF16),
125                text_document_sync: Some(TextDocumentSyncCapability::Options(
126                    TextDocumentSyncOptions {
127                        open_close: Some(true),
128                        change: Some(TextDocumentSyncKind::FULL),
129                        save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
130                            include_text: Some(true),
131                        })),
132                        ..TextDocumentSyncOptions::default()
133                    },
134                )),
135                execute_command_provider: Some(ExecuteCommandOptions {
136                    commands: vec![
137                        CodebookCommand::AddWord.into(),
138                        CodebookCommand::AddWordGlobal.into(),
139                        CodebookCommand::IgnoreFile.into(),
140                    ],
141                    work_done_progress_options: Default::default(),
142                }),
143                code_action_provider: Some(CodeActionProviderCapability::Options(
144                    CodeActionOptions {
145                        code_action_kinds: Some(vec![CodeActionKind::QUICKFIX]),
146                        resolve_provider: None,
147                        work_done_progress_options: WorkDoneProgressOptions {
148                            work_done_progress: None,
149                        },
150                    },
151                )),
152                ..ServerCapabilities::default()
153            },
154            server_info: Some(ServerInfo {
155                name: format!("{SOURCE_NAME} Language Server"),
156                version: Some(env!("CARGO_PKG_VERSION").to_string()),
157            }),
158        })
159    }
160
161    async fn initialized(&self, _: InitializedParams) {
162        info!("Server ready!");
163        let config = self.config_handle();
164        match config.project_config_path() {
165            Some(path) => info!("Project config: {}", path.display()),
166            None => info!("Project config: <not set>"),
167        }
168        info!(
169            "Global config: {}",
170            config.global_config_path().unwrap_or_default().display()
171        );
172    }
173
174    async fn shutdown(&self) -> RpcResult<()> {
175        info!("Server shutting down");
176        Ok(())
177    }
178
179    async fn did_open(&self, params: DidOpenTextDocumentParams) {
180        debug!(
181            "Opened document: uri {:?}, language: {}, version: {}",
182            params.text_document.uri,
183            params.text_document.language_id,
184            params.text_document.version
185        );
186        self.document_cache.insert(&params.text_document);
187        if self.should_spellcheck_while_typing() {
188            self.spell_check(&params.text_document.uri).await;
189        }
190    }
191
192    async fn did_close(&self, params: DidCloseTextDocumentParams) {
193        self.document_cache.remove(&params.text_document.uri);
194        // Clear diagnostics when a file is closed.
195        self.client
196            .publish_diagnostics(params.text_document.uri, vec![], None)
197            .await;
198    }
199
200    async fn did_save(&self, params: DidSaveTextDocumentParams) {
201        debug!("Saved document: {}", params.text_document.uri);
202        if let Some(text) = params.text {
203            self.document_cache
204                .update(&params.text_document.uri, &text, None);
205        }
206        self.spell_check(&params.text_document.uri).await;
207    }
208
209    async fn did_change(&self, params: DidChangeTextDocumentParams) {
210        debug!(
211            "Changed document: uri={}, version={}",
212            params.text_document.uri, params.text_document.version
213        );
214        let uri = params.text_document.uri;
215        // With FULL sync each change is the whole document, so if a client
216        // batches several the last one is current.
217        if let Some(change) = params.content_changes.last() {
218            self.document_cache
219                .update(&uri, &change.text, Some(params.text_document.version));
220            if self.should_spellcheck_while_typing() {
221                self.spell_check(&uri).await;
222            }
223        }
224    }
225
226    async fn code_action(&self, params: CodeActionParams) -> RpcResult<Option<CodeActionResponse>> {
227        let mut actions: Vec<CodeActionOrCommand> = vec![];
228        let doc = match self.document_cache.get(params.text_document.uri.as_ref()) {
229            Some(doc) => doc,
230            None => return Ok(None),
231        };
232
233        let mut has_codebook_diagnostic = false;
234        for diag in params.context.diagnostics {
235            // Only process our own diagnostics
236            if diag.source.as_deref() != Some(SOURCE_NAME) {
237                continue;
238            }
239            has_codebook_diagnostic = true;
240            let line = doc
241                .text
242                .lines()
243                .nth(diag.range.start.line as usize)
244                .unwrap_or_default();
245            let start_char = diag.range.start.character as usize;
246            let end_char = diag.range.end.character as usize;
247            let word = get_word_from_string(start_char, end_char, line);
248            // info!("Word to suggest: {}", word);
249            if word.is_empty() || word.contains(" ") {
250                continue;
251            }
252            let cb = self.codebook_handle();
253            let inner_word = word.clone();
254            let suggestions = task::spawn_blocking(move || cb.get_suggestions(&inner_word)).await;
255
256            let suggestions = match suggestions {
257                Ok(suggestions) => suggestions,
258                Err(e) => {
259                    error!(
260                        "Error getting suggestions for word '{}' in file '{}'\n Error: {}",
261                        word,
262                        doc.uri.path(),
263                        e
264                    );
265                    continue;
266                }
267            };
268
269            if suggestions.is_none() {
270                continue;
271            }
272
273            suggestions.unwrap().iter().for_each(|suggestion| {
274                actions.push(CodeActionOrCommand::CodeAction(self.make_suggestion(
275                    suggestion,
276                    &diag.range,
277                    &params.text_document.uri,
278                )));
279            });
280            actions.push(CodeActionOrCommand::CodeAction(CodeAction {
281                title: format!("Add '{word}' to dictionary"),
282                kind: Some(CodeActionKind::QUICKFIX),
283                diagnostics: None,
284                edit: None,
285                command: Some(Command {
286                    title: format!("Add '{word}' to dictionary"),
287                    command: CodebookCommand::AddWord.into(),
288                    arguments: Some(vec![word.to_string().into()]),
289                }),
290                is_preferred: None,
291                disabled: None,
292                data: None,
293            }));
294            actions.push(CodeActionOrCommand::CodeAction(CodeAction {
295                title: format!("Add '{word}' to global dictionary"),
296                kind: Some(CodeActionKind::QUICKFIX),
297                diagnostics: None,
298                edit: None,
299                command: Some(Command {
300                    title: format!("Add '{word}' to global dictionary"),
301                    command: CodebookCommand::AddWordGlobal.into(),
302                    arguments: Some(vec![word.to_string().into()]),
303                }),
304                is_preferred: None,
305                disabled: None,
306                data: None,
307            }));
308        }
309        if has_codebook_diagnostic {
310            actions.push(CodeActionOrCommand::CodeAction(CodeAction {
311                title: "Add current file to ignore list".to_string(),
312                kind: Some(CodeActionKind::QUICKFIX),
313                diagnostics: None,
314                edit: None,
315                command: Some(Command {
316                    title: "Add current file to ignore list".to_string(),
317                    command: CodebookCommand::IgnoreFile.into(),
318                    arguments: Some(vec![params.text_document.uri.to_string().into()]),
319                }),
320                is_preferred: None,
321                disabled: None,
322                data: None,
323            }));
324        }
325        if actions.is_empty() {
326            return Ok(None);
327        }
328        Ok(Some(actions))
329    }
330
331    async fn execute_command(&self, params: ExecuteCommandParams) -> RpcResult<Option<Value>> {
332        match CodebookCommand::from(params.command.as_str()) {
333            CodebookCommand::AddWord => {
334                let config = self.config_handle();
335                let words = params
336                    .arguments
337                    .iter()
338                    .filter_map(|arg| arg.as_str().map(|s| s.to_string()));
339                info!(
340                    "Adding words to dictionary {}",
341                    words.clone().collect::<Vec<String>>().join(", ")
342                );
343                let updated = self.add_words(config.as_ref(), words);
344                if updated {
345                    if let Err(e) = config.save() {
346                        error!("Failed to save config: {e}");
347                    }
348                    self.recheck_all().await;
349                }
350                Ok(None)
351            }
352            CodebookCommand::AddWordGlobal => {
353                let config = self.config_handle();
354                let words = params
355                    .arguments
356                    .iter()
357                    .filter_map(|arg| arg.as_str().map(|s| s.to_string()));
358                let updated = self.add_words_global(config.as_ref(), words);
359                if updated {
360                    if let Err(e) = config.save_global() {
361                        error!("Failed to save global config: {e}");
362                    }
363                    self.recheck_all().await;
364                }
365                Ok(None)
366            }
367            CodebookCommand::IgnoreFile => {
368                let Some(file_uri) = params.arguments.first().and_then(|arg| arg.as_str()) else {
369                    error!("IgnoreFile command missing or invalid file URI argument");
370                    return Ok(None);
371                };
372                let config = self.config_handle();
373                let updated = self.add_ignore_file(config.as_ref(), file_uri);
374                if updated {
375                    if let Err(e) = config.save() {
376                        error!("Failed to save config: {e}");
377                    }
378                    self.recheck_all().await;
379                }
380                Ok(None)
381            }
382            CodebookCommand::Unknown => Ok(None),
383        }
384    }
385}
386
387impl Backend {
388    pub fn new(client: Client, workspace_dir: &Path) -> Self {
389        let workspace_dir_canonical = workspace_dir.canonicalize().ok();
390        Self {
391            client,
392            workspace_dir: workspace_dir.to_path_buf(),
393            workspace_dir_canonical,
394            codebook: OnceLock::new(),
395            config: OnceLock::new(),
396            document_cache: TextDocumentCache::default(),
397            initialize_options: RwLock::new(Arc::new(ClientInitializationOptions::default())),
398            last_config_poll: Mutex::new(None),
399        }
400    }
401
402    fn config_handle(&self) -> Arc<CodebookConfigFile> {
403        self.config
404            .get_or_init(|| {
405                let options = self.initialize_options.read().unwrap();
406                let global_config_path = options.global_config_path.clone();
407                let project_config_path = options
408                    .config_path
409                    .clone()
410                    .map(|p| self.resolve_workspace_path(&p));
411                drop(options);
412
413                // Strict at startup by design: an invalid config crashes the
414                // server with the parse error on stderr rather than silently
415                // running with defaults. Mid-session edits are lenient (the
416                // last good config is kept).
417                Arc::new(
418                    CodebookConfigFile::load_with_overrides(
419                        Some(self.workspace_dir.as_path()),
420                        global_config_path,
421                        project_config_path,
422                    )
423                    .unwrap_or_else(|e| panic!("Unable to load configuration: {e}")),
424                )
425            })
426            .clone()
427    }
428
429    /// Resolve a user-provided path against the workspace directory.
430    /// Absolute paths are returned unchanged; relative paths are joined onto `workspace_dir`.
431    fn resolve_workspace_path(&self, path: &Path) -> PathBuf {
432        if path.is_absolute() {
433            path.to_path_buf()
434        } else {
435            self.workspace_dir.join(path)
436        }
437    }
438
439    fn codebook_handle(&self) -> Arc<Codebook> {
440        self.codebook
441            .get_or_init(|| Arc::new(Codebook::new(self.config_handle())))
442            .clone()
443    }
444
445    fn should_spellcheck_while_typing(&self) -> bool {
446        self.initialize_options.read().unwrap().check_while_typing
447    }
448
449    fn add_words(&self, config: &CodebookConfigFile, words: impl Iterator<Item = String>) -> bool {
450        let mut should_save = false;
451        for word in words {
452            if config.add_word(&word) {
453                should_save = true;
454            } else {
455                info!("Word '{word}' already exists in dictionary.");
456            }
457        }
458        should_save
459    }
460
461    fn add_words_global(
462        &self,
463        config: &CodebookConfigFile,
464        words: impl Iterator<Item = String>,
465    ) -> bool {
466        let mut should_save = false;
467        for word in words {
468            if config.add_word_global(&word) {
469                should_save = true;
470            } else {
471                info!("Word '{word}' already exists in global dictionary.");
472            }
473        }
474        should_save
475    }
476
477    fn get_relative_path(&self, uri: &str) -> Option<String> {
478        let parsed_uri = match Url::parse(uri) {
479            Ok(u) => u,
480            Err(e) => {
481                error!("Failed to parse URI '{uri}': {e}");
482                return None;
483            }
484        };
485        let file_path = parsed_uri.to_file_path().unwrap_or_default();
486        Some(compute_relative_path(
487            &self.workspace_dir,
488            self.workspace_dir_canonical.as_deref(),
489            &file_path,
490        ))
491    }
492
493    fn add_ignore_file(&self, config: &CodebookConfigFile, file_uri: &str) -> bool {
494        let Some(relative_path) = self.get_relative_path(file_uri) else {
495            return false;
496        };
497        if config.add_ignore(&relative_path) {
498            true
499        } else {
500            info!("File {file_uri} already exists in the ignored files.");
501            false
502        }
503    }
504
505    fn make_suggestion(&self, suggestion: &str, range: &Range, uri: &Url) -> CodeAction {
506        let title = format!("Replace with '{suggestion}'");
507        let mut map = HashMap::new();
508        map.insert(
509            uri.clone(),
510            vec![TextEdit {
511                range: *range,
512                new_text: suggestion.to_string(),
513            }],
514        );
515        let edit = Some(WorkspaceEdit {
516            changes: Some(map),
517            document_changes: None,
518            change_annotations: None,
519        });
520        CodeAction {
521            title: title.to_string(),
522            kind: Some(CodeActionKind::QUICKFIX),
523            diagnostics: None,
524            edit,
525            command: None,
526            is_preferred: None,
527            disabled: None,
528            data: None,
529        }
530    }
531
532    async fn recheck_all(&self) {
533        let urls = self.document_cache.cached_urls();
534        debug!("Rechecking documents: {urls:?}");
535        for url in urls {
536            self.publish_spellcheck_diagnostics(&url).await;
537        }
538    }
539
540    /// Poll the config files for external changes, at most once per
541    /// CONFIG_POLL_INTERVAL. Returns true when the config actually changed.
542    fn reload_config_debounced(&self) -> bool {
543        {
544            let mut last_poll = self.last_config_poll.lock().unwrap();
545            if last_poll.is_some_and(|at| at.elapsed() < CONFIG_POLL_INTERVAL) {
546                return false;
547            }
548            *last_poll = Some(Instant::now());
549        }
550
551        self.config_handle().reload()
552    }
553
554    async fn spell_check(&self, uri: &Url) {
555        let did_reload = self.reload_config_debounced();
556
557        if did_reload {
558            debug!("Config reloaded, rechecking all files.");
559            self.recheck_all().await;
560        } else {
561            debug!("Checking file: {uri:?}");
562            self.publish_spellcheck_diagnostics(uri).await;
563        }
564    }
565
566    /// Helper method to publish diagnostics for spell-checking.
567    async fn publish_spellcheck_diagnostics(&self, uri: &Url) {
568        let doc = match self.document_cache.get(uri.as_ref()) {
569            Some(doc) => doc,
570            None => return,
571        };
572        // Convert the file URI to a local file path.
573        let file_path = doc.uri.to_file_path().unwrap_or_default();
574        debug!("Spell-checking file: {file_path:?}");
575
576        let lang = doc.language_id.as_deref();
577        let lang_type = lang.and_then(|lang| LanguageType::from_str(lang).ok());
578        debug!("Document identified as type {lang_type:?} from {lang:?}");
579
580        let severity = self.initialize_options.read().unwrap().diagnostic_severity;
581        let workspace_dir = self.workspace_dir.clone();
582        let workspace_dir_canonical = self.workspace_dir_canonical.clone();
583        let cb = self.codebook_handle();
584        let checked_version = doc.version;
585        let doc_uri = doc.uri.clone();
586
587        // Everything document-sized — canonicalization, the UTF-8→UTF-16
588        // offset table, the check itself, diagnostic conversion — runs on
589        // the blocking pool so large documents don't stall the event loop.
590        let diagnostics = task::spawn_blocking(move || {
591            let relative_path = compute_relative_path(
592                &workspace_dir,
593                workspace_dir_canonical.as_deref(),
594                &file_path,
595            );
596            let offsets = StringOffsets::<AllConfig>::new(&doc.text);
597            let spell_results = cb.spell_check(&doc.text, lang_type, Some(&relative_path));
598            spell_results
599                .into_iter()
600                .flat_map(|res| {
601                    // For each misspelling, create a diagnostic for each location.
602                    res.locations
603                        .iter()
604                        .map(|loc| {
605                            let start_pos = offsets.utf8_to_utf16_pos(loc.start_byte);
606                            let end_pos = offsets.utf8_to_utf16_pos(loc.end_byte);
607                            make_diagnostic(&res.word, &start_pos, &end_pos, severity)
608                        })
609                        .collect::<Vec<_>>()
610                })
611                .collect::<Vec<Diagnostic>>()
612        })
613        .await;
614
615        let diagnostics = match diagnostics {
616            Ok(diagnostics) => diagnostics,
617            Err(err) => {
618                error!("Spell-checking failed for '{uri}': {err}");
619                return;
620            }
621        };
622
623        // Handlers run concurrently, so a check of older text can finish
624        // after a newer one. Publish only if the document is still at the
625        // version we checked (and still open), and stamp the version so the
626        // client can discard anything stale that slips through.
627        match self.document_cache.get(uri.as_ref()) {
628            Some(current) if current.version == checked_version => {}
629            _ => {
630                debug!("Skipping stale diagnostics for {uri}");
631                return;
632            }
633        }
634        self.client
635            .publish_diagnostics(doc_uri, diagnostics, checked_version)
636            .await;
637    }
638}
639
640/// Build an LSP diagnostic for one misspelled-word location.
641fn make_diagnostic(
642    word: &str,
643    start_pos: &Pos,
644    end_pos: &Pos,
645    severity: DiagnosticSeverity,
646) -> Diagnostic {
647    let message = format!("Possible spelling issue '{word}'.");
648    Diagnostic {
649        range: Range {
650            start: Position {
651                line: start_pos.line as u32,
652                character: start_pos.col as u32,
653            },
654            end: Position {
655                line: end_pos.line as u32,
656                character: end_pos.col as u32,
657            },
658        },
659        severity: Some(severity),
660        code: None,
661        code_description: None,
662        source: Some(SOURCE_NAME.to_string()),
663        message,
664        related_information: None,
665        tags: None,
666        data: None,
667    }
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673    use std::fs;
674    use tempfile::tempdir;
675
676    #[test]
677    fn test_compute_relative_path_within_workspace() {
678        let workspace = tempdir().unwrap();
679        let workspace_path = workspace.path();
680
681        // Create a file inside the workspace
682        let subdir = workspace_path.join("src");
683        fs::create_dir_all(&subdir).unwrap();
684        let file_path = subdir.join("test.rs");
685        fs::write(&file_path, "test").unwrap();
686
687        let result = compute_relative_path(workspace_path, None, &file_path);
688        assert_eq!(result, "src/test.rs");
689    }
690
691    #[test]
692    fn test_compute_relative_path_with_cached_canonical() {
693        let workspace = tempdir().unwrap();
694        let workspace_path = workspace.path();
695        let workspace_canonical = workspace_path.canonicalize().unwrap();
696
697        // Create a file inside the workspace
698        let subdir = workspace_path.join("src");
699        fs::create_dir_all(&subdir).unwrap();
700        let file_path = subdir.join("test.rs");
701        fs::write(&file_path, "test").unwrap();
702
703        // Using cached canonical path should produce the same result
704        let result = compute_relative_path(workspace_path, Some(&workspace_canonical), &file_path);
705        assert_eq!(result, "src/test.rs");
706    }
707
708    #[test]
709    fn test_compute_relative_path_outside_workspace() {
710        let workspace = tempdir().unwrap();
711        let other_dir = tempdir().unwrap();
712
713        // Create a file outside the workspace
714        let file_path = other_dir.path().join("outside.rs");
715        fs::write(&file_path, "test").unwrap();
716
717        let result = compute_relative_path(workspace.path(), None, &file_path);
718        // Should return the original path since it's outside workspace
719        assert!(result.contains("outside.rs"));
720    }
721
722    #[test]
723    fn test_compute_relative_path_nonexistent_file() {
724        let workspace = tempdir().unwrap();
725        let file_path = workspace.path().join("nonexistent.rs");
726
727        let result = compute_relative_path(workspace.path(), None, &file_path);
728        // Should return the original path since file doesn't exist
729        assert!(result.contains("nonexistent.rs"));
730    }
731
732    #[test]
733    fn test_compute_relative_path_nested_directory() {
734        let workspace = tempdir().unwrap();
735        let workspace_path = workspace.path();
736
737        // Create a deeply nested file
738        let nested_dir = workspace_path.join("src").join("components").join("ui");
739        fs::create_dir_all(&nested_dir).unwrap();
740        let file_path = nested_dir.join("button.rs");
741        fs::write(&file_path, "test").unwrap();
742
743        let result = compute_relative_path(workspace_path, None, &file_path);
744        assert_eq!(result, "src/components/ui/button.rs");
745    }
746}