Skip to main content

solidity_language_server/
lsp.rs

1use crate::completion;
2use crate::config::{self, FoundryConfig, LintConfig};
3use crate::goto;
4use crate::hover;
5use crate::inlay_hints;
6use crate::links;
7use crate::references;
8use crate::rename;
9use crate::runner::{ForgeRunner, Runner};
10use crate::semantic_tokens;
11use crate::symbols;
12use crate::utils;
13use std::collections::HashMap;
14use std::sync::Arc;
15use tokio::sync::RwLock;
16use tower_lsp::{Client, LanguageServer, lsp_types::*};
17
18pub struct ForgeLsp {
19    client: Client,
20    compiler: Arc<dyn Runner>,
21    ast_cache: Arc<RwLock<HashMap<String, Arc<goto::CachedBuild>>>>,
22    /// Text cache for opened documents
23    ///
24    /// The key is the file's URI converted to string, and the value is a tuple of (version, content).
25    text_cache: Arc<RwLock<HashMap<String, (i32, String)>>>,
26    completion_cache: Arc<RwLock<HashMap<String, Arc<completion::CompletionCache>>>>,
27    /// Cached lint configuration from `foundry.toml`.
28    lint_config: Arc<RwLock<LintConfig>>,
29    /// Cached project configuration from `foundry.toml`.
30    foundry_config: Arc<RwLock<FoundryConfig>>,
31    /// Client capabilities received during initialization.
32    client_capabilities: Arc<RwLock<Option<ClientCapabilities>>>,
33    /// Whether to use solc directly for AST generation (with forge fallback).
34    use_solc: bool,
35}
36
37impl ForgeLsp {
38    pub fn new(client: Client, use_solar: bool, use_solc: bool) -> Self {
39        let compiler: Arc<dyn Runner> = if use_solar {
40            Arc::new(crate::solar_runner::SolarRunner)
41        } else {
42            Arc::new(ForgeRunner)
43        };
44        let ast_cache = Arc::new(RwLock::new(HashMap::new()));
45        let text_cache = Arc::new(RwLock::new(HashMap::new()));
46        let completion_cache = Arc::new(RwLock::new(HashMap::new()));
47        let lint_config = Arc::new(RwLock::new(LintConfig::default()));
48        let foundry_config = Arc::new(RwLock::new(FoundryConfig::default()));
49        let client_capabilities = Arc::new(RwLock::new(None));
50        Self {
51            client,
52            compiler,
53            ast_cache,
54            text_cache,
55            completion_cache,
56            lint_config,
57            foundry_config,
58            client_capabilities,
59            use_solc,
60        }
61    }
62
63    async fn on_change(&self, params: TextDocumentItem) {
64        let uri = params.uri.clone();
65        let version = params.version;
66
67        let file_path = match uri.to_file_path() {
68            Ok(path) => path,
69            Err(_) => {
70                self.client
71                    .log_message(MessageType::ERROR, "Invalid file URI")
72                    .await;
73                return;
74            }
75        };
76
77        let path_str = match file_path.to_str() {
78            Some(s) => s,
79            None => {
80                self.client
81                    .log_message(MessageType::ERROR, "Invalid file path")
82                    .await;
83                return;
84            }
85        };
86
87        // Check if linting should be skipped based on foundry.toml config.
88        let should_lint = {
89            let lint_cfg = self.lint_config.read().await;
90            lint_cfg.should_lint(&file_path)
91        };
92
93        // When use_solc is enabled, run solc once for both AST and diagnostics.
94        // This avoids running `forge build` separately (~27s on large projects).
95        // On solc failure, fall back to the forge-based pipeline.
96        let (lint_result, build_result, ast_result) = if self.use_solc {
97            let foundry_cfg = self.foundry_config.read().await.clone();
98            let solc_future = crate::solc::solc_ast(path_str, &foundry_cfg, Some(&self.client));
99
100            if should_lint {
101                let (lint, solc) =
102                    tokio::join!(self.compiler.get_lint_diagnostics(&uri), solc_future);
103                match solc {
104                    Ok(data) => {
105                        self.client
106                            .log_message(
107                                MessageType::INFO,
108                                "solc: AST + diagnostics from single run",
109                            )
110                            .await;
111                        // Extract diagnostics from the same solc output
112                        let content = tokio::fs::read_to_string(&file_path)
113                            .await
114                            .unwrap_or_default();
115                        let build_diags = crate::build::build_output_to_diagnostics(
116                            &data,
117                            &file_path,
118                            &content,
119                            &foundry_cfg.ignored_error_codes,
120                        );
121                        (Some(lint), Ok(build_diags), Ok(data))
122                    }
123                    Err(e) => {
124                        self.client
125                            .log_message(
126                                MessageType::WARNING,
127                                format!("solc failed, falling back to forge: {e}"),
128                            )
129                            .await;
130                        let (build, ast) = tokio::join!(
131                            self.compiler.get_build_diagnostics(&uri),
132                            self.compiler.ast(path_str)
133                        );
134                        (Some(lint), build, ast)
135                    }
136                }
137            } else {
138                self.client
139                    .log_message(
140                        MessageType::INFO,
141                        format!("skipping lint for ignored file: {path_str}"),
142                    )
143                    .await;
144                match solc_future.await {
145                    Ok(data) => {
146                        self.client
147                            .log_message(
148                                MessageType::INFO,
149                                "solc: AST + diagnostics from single run",
150                            )
151                            .await;
152                        let content = tokio::fs::read_to_string(&file_path)
153                            .await
154                            .unwrap_or_default();
155                        let build_diags = crate::build::build_output_to_diagnostics(
156                            &data,
157                            &file_path,
158                            &content,
159                            &foundry_cfg.ignored_error_codes,
160                        );
161                        (None, Ok(build_diags), Ok(data))
162                    }
163                    Err(e) => {
164                        self.client
165                            .log_message(
166                                MessageType::WARNING,
167                                format!("solc failed, falling back to forge: {e}"),
168                            )
169                            .await;
170                        let (build, ast) = tokio::join!(
171                            self.compiler.get_build_diagnostics(&uri),
172                            self.compiler.ast(path_str)
173                        );
174                        (None, build, ast)
175                    }
176                }
177            }
178        } else {
179            // forge-only pipeline (--use-forge)
180            if should_lint {
181                let (lint, build, ast) = tokio::join!(
182                    self.compiler.get_lint_diagnostics(&uri),
183                    self.compiler.get_build_diagnostics(&uri),
184                    self.compiler.ast(path_str)
185                );
186                (Some(lint), build, ast)
187            } else {
188                self.client
189                    .log_message(
190                        MessageType::INFO,
191                        format!("skipping lint for ignored file: {path_str}"),
192                    )
193                    .await;
194                let (build, ast) = tokio::join!(
195                    self.compiler.get_build_diagnostics(&uri),
196                    self.compiler.ast(path_str)
197                );
198                (None, build, ast)
199            }
200        };
201
202        // Only replace cache with new AST if build succeeded (no errors; warnings are OK)
203        let build_succeeded = matches!(&build_result, Ok(diagnostics) if diagnostics.iter().all(|d| d.severity != Some(DiagnosticSeverity::ERROR)));
204
205        if build_succeeded {
206            if let Ok(ast_data) = ast_result {
207                let cached_build = Arc::new(goto::CachedBuild::new(ast_data, version));
208                let mut cache = self.ast_cache.write().await;
209                cache.insert(uri.to_string(), cached_build.clone());
210                drop(cache);
211
212                // Rebuild completion cache in the background; old cache stays usable until replaced
213                let completion_cache = self.completion_cache.clone();
214                let uri_string = uri.to_string();
215                tokio::spawn(async move {
216                    if let Some(sources) = cached_build.ast.get("sources") {
217                        let contracts = cached_build.ast.get("contracts");
218                        let cc = completion::build_completion_cache(sources, contracts);
219                        completion_cache
220                            .write()
221                            .await
222                            .insert(uri_string, Arc::new(cc));
223                    }
224                });
225                self.client
226                    .log_message(MessageType::INFO, "Build successful, AST cache updated")
227                    .await;
228            } else if let Err(e) = ast_result {
229                self.client
230                    .log_message(
231                        MessageType::INFO,
232                        format!("Build succeeded but failed to get AST: {e}"),
233                    )
234                    .await;
235            }
236        } else {
237            // Build has errors - keep the existing cache (don't invalidate)
238            self.client
239                .log_message(
240                    MessageType::INFO,
241                    "Build errors detected, keeping existing AST cache",
242                )
243                .await;
244        }
245
246        // cache text — only if no newer version exists (e.g. from formatting/did_change)
247        {
248            let mut text_cache = self.text_cache.write().await;
249            let uri_str = uri.to_string();
250            let existing_version = text_cache.get(&uri_str).map(|(v, _)| *v).unwrap_or(-1);
251            if version >= existing_version {
252                text_cache.insert(uri_str, (version, params.text));
253            }
254        }
255
256        let mut all_diagnostics = vec![];
257
258        if let Some(lint_result) = lint_result {
259            match lint_result {
260                Ok(mut lints) => {
261                    self.client
262                        .log_message(
263                            MessageType::INFO,
264                            format!("found {} lint diagnostics", lints.len()),
265                        )
266                        .await;
267                    all_diagnostics.append(&mut lints);
268                }
269                Err(e) => {
270                    self.client
271                        .log_message(
272                            MessageType::ERROR,
273                            format!("Forge lint diagnostics failed: {e}"),
274                        )
275                        .await;
276                }
277            }
278        }
279
280        match build_result {
281            Ok(mut builds) => {
282                self.client
283                    .log_message(
284                        MessageType::INFO,
285                        format!("found {} build diagnostics", builds.len()),
286                    )
287                    .await;
288                all_diagnostics.append(&mut builds);
289            }
290            Err(e) => {
291                self.client
292                    .log_message(
293                        MessageType::WARNING,
294                        format!("Forge build diagnostics failed: {e}"),
295                    )
296                    .await;
297            }
298        }
299
300        // publish diags with no version, so we are sure they get displayed
301        self.client
302            .publish_diagnostics(uri, all_diagnostics, None)
303            .await;
304
305        // Refresh inlay hints after everything is updated
306        if build_succeeded {
307            let client = self.client.clone();
308            tokio::spawn(async move {
309                let _ = client.inlay_hint_refresh().await;
310            });
311        }
312    }
313
314    /// Get a CachedBuild from the cache, or fetch and build one on demand.
315    /// If `insert_on_miss` is true, the freshly-built entry is inserted into the cache
316    /// (used by references handler so cross-file lookups can find it later).
317    ///
318    /// When the entry is in the cache but marked stale (text_cache changed
319    /// since the last build), the text_cache content is flushed to disk and
320    /// the AST is rebuilt so that rename / references work correctly on
321    /// unsaved buffers.
322    async fn get_or_fetch_build(
323        &self,
324        uri: &Url,
325        file_path: &std::path::Path,
326        insert_on_miss: bool,
327    ) -> Option<Arc<goto::CachedBuild>> {
328        let uri_str = uri.to_string();
329
330        // Return cached entry if it exists (stale or not — stale entries are
331        // still usable, positions may be slightly off like goto-definition).
332        {
333            let cache = self.ast_cache.read().await;
334            if let Some(cached) = cache.get(&uri_str) {
335                return Some(cached.clone());
336            }
337        }
338
339        // Cache miss — if caller doesn't want to trigger a build, return None.
340        // This prevents inlay hints, code lens, etc. from blocking on a full
341        // solc/forge build. The cache will be populated by on_change (did_open/did_save).
342        if !insert_on_miss {
343            return None;
344        }
345
346        // Cache miss — build the AST from disk.
347        let path_str = file_path.to_str()?;
348        let ast_result = if self.use_solc {
349            let foundry_cfg = self.foundry_config.read().await.clone();
350            match crate::solc::solc_ast(path_str, &foundry_cfg, Some(&self.client)).await {
351                Ok(data) => Ok(data),
352                Err(_) => self.compiler.ast(path_str).await,
353            }
354        } else {
355            self.compiler.ast(path_str).await
356        };
357        match ast_result {
358            Ok(data) => {
359                // Built from disk (cache miss) — use version 0; the next
360                // didSave/on_change will stamp the correct version.
361                let build = Arc::new(goto::CachedBuild::new(data, 0));
362                let mut cache = self.ast_cache.write().await;
363                cache.insert(uri_str.clone(), build.clone());
364                Some(build)
365            }
366            Err(e) => {
367                self.client
368                    .log_message(MessageType::ERROR, format!("failed to get AST: {e}"))
369                    .await;
370                None
371            }
372        }
373    }
374
375    /// Get the source bytes for a file, preferring the in-memory text cache
376    /// (which reflects unsaved editor changes) over reading from disk.
377    async fn get_source_bytes(&self, uri: &Url, file_path: &std::path::Path) -> Option<Vec<u8>> {
378        {
379            let text_cache = self.text_cache.read().await;
380            if let Some((_, content)) = text_cache.get(&uri.to_string()) {
381                return Some(content.as_bytes().to_vec());
382            }
383        }
384        match std::fs::read(file_path) {
385            Ok(bytes) => Some(bytes),
386            Err(e) => {
387                self.client
388                    .log_message(MessageType::ERROR, format!("failed to read file: {e}"))
389                    .await;
390                None
391            }
392        }
393    }
394}
395
396#[tower_lsp::async_trait]
397impl LanguageServer for ForgeLsp {
398    async fn initialize(
399        &self,
400        params: InitializeParams,
401    ) -> tower_lsp::jsonrpc::Result<InitializeResult> {
402        // Store client capabilities for use during `initialized()`.
403        {
404            let mut caps = self.client_capabilities.write().await;
405            *caps = Some(params.capabilities.clone());
406        }
407
408        // Load config from the workspace root's foundry.toml.
409        if let Some(root_uri) = params
410            .root_uri
411            .as_ref()
412            .and_then(|uri| uri.to_file_path().ok())
413        {
414            let lint_cfg = config::load_lint_config(&root_uri);
415            self.client
416                .log_message(
417                    MessageType::INFO,
418                    format!(
419                        "loaded foundry.toml lint config: lint_on_build={}, ignore_patterns={}",
420                        lint_cfg.lint_on_build,
421                        lint_cfg.ignore_patterns.len()
422                    ),
423                )
424                .await;
425            let mut config = self.lint_config.write().await;
426            *config = lint_cfg;
427
428            let foundry_cfg = config::load_foundry_config(&root_uri);
429            self.client
430                .log_message(
431                    MessageType::INFO,
432                    format!(
433                        "loaded foundry.toml project config: solc_version={:?}, remappings={}",
434                        foundry_cfg.solc_version,
435                        foundry_cfg.remappings.len()
436                    ),
437                )
438                .await;
439            let mut fc = self.foundry_config.write().await;
440            *fc = foundry_cfg;
441        }
442
443        // Negotiate position encoding with the client (once, for the session).
444        let client_encodings = params
445            .capabilities
446            .general
447            .as_ref()
448            .and_then(|g| g.position_encodings.as_deref());
449        let encoding = utils::PositionEncoding::negotiate(client_encodings);
450        utils::set_encoding(encoding);
451
452        Ok(InitializeResult {
453            server_info: Some(ServerInfo {
454                name: "Solidity Language Server".to_string(),
455                version: Some(env!("LONG_VERSION").to_string()),
456            }),
457            capabilities: ServerCapabilities {
458                position_encoding: Some(encoding.into()),
459                completion_provider: Some(CompletionOptions {
460                    trigger_characters: Some(vec![".".to_string()]),
461                    resolve_provider: Some(false),
462                    ..Default::default()
463                }),
464                definition_provider: Some(OneOf::Left(true)),
465                declaration_provider: Some(DeclarationCapability::Simple(true)),
466                references_provider: Some(OneOf::Left(true)),
467                rename_provider: Some(OneOf::Right(RenameOptions {
468                    prepare_provider: Some(true),
469                    work_done_progress_options: WorkDoneProgressOptions {
470                        work_done_progress: Some(true),
471                    },
472                })),
473                workspace_symbol_provider: Some(OneOf::Left(true)),
474                document_symbol_provider: Some(OneOf::Left(true)),
475                hover_provider: Some(HoverProviderCapability::Simple(true)),
476                document_link_provider: Some(DocumentLinkOptions {
477                    resolve_provider: Some(false),
478                    work_done_progress_options: WorkDoneProgressOptions {
479                        work_done_progress: None,
480                    },
481                }),
482                document_formatting_provider: Some(OneOf::Left(true)),
483                code_lens_provider: None,
484                inlay_hint_provider: Some(OneOf::Right(InlayHintServerCapabilities::Options(
485                    InlayHintOptions {
486                        resolve_provider: Some(false),
487                        work_done_progress_options: WorkDoneProgressOptions {
488                            work_done_progress: None,
489                        },
490                    },
491                ))),
492                semantic_tokens_provider: Some(
493                    SemanticTokensServerCapabilities::SemanticTokensOptions(
494                        SemanticTokensOptions {
495                            legend: semantic_tokens::legend(),
496                            full: Some(SemanticTokensFullOptions::Bool(true)),
497                            range: None,
498                            work_done_progress_options: WorkDoneProgressOptions {
499                                work_done_progress: None,
500                            },
501                        },
502                    ),
503                ),
504                text_document_sync: Some(TextDocumentSyncCapability::Options(
505                    TextDocumentSyncOptions {
506                        will_save: Some(true),
507                        will_save_wait_until: None,
508                        open_close: Some(true),
509                        save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
510                            include_text: Some(true),
511                        })),
512                        change: Some(TextDocumentSyncKind::FULL),
513                    },
514                )),
515                ..ServerCapabilities::default()
516            },
517        })
518    }
519
520    async fn initialized(&self, _: InitializedParams) {
521        self.client
522            .log_message(MessageType::INFO, "lsp server initialized.")
523            .await;
524
525        // Dynamically register a file watcher for foundry.toml changes.
526        let supports_dynamic = self
527            .client_capabilities
528            .read()
529            .await
530            .as_ref()
531            .and_then(|caps| caps.workspace.as_ref())
532            .and_then(|ws| ws.did_change_watched_files.as_ref())
533            .and_then(|dcwf| dcwf.dynamic_registration)
534            .unwrap_or(false);
535
536        if supports_dynamic {
537            let registration = Registration {
538                id: "foundry-toml-watcher".to_string(),
539                method: "workspace/didChangeWatchedFiles".to_string(),
540                register_options: Some(
541                    serde_json::to_value(DidChangeWatchedFilesRegistrationOptions {
542                        watchers: vec![
543                            FileSystemWatcher {
544                                glob_pattern: GlobPattern::String("**/foundry.toml".to_string()),
545                                kind: Some(WatchKind::all()),
546                            },
547                            FileSystemWatcher {
548                                glob_pattern: GlobPattern::String("**/remappings.txt".to_string()),
549                                kind: Some(WatchKind::all()),
550                            },
551                        ],
552                    })
553                    .unwrap(),
554                ),
555            };
556
557            if let Err(e) = self.client.register_capability(vec![registration]).await {
558                self.client
559                    .log_message(
560                        MessageType::WARNING,
561                        format!("failed to register foundry.toml watcher: {e}"),
562                    )
563                    .await;
564            } else {
565                self.client
566                    .log_message(MessageType::INFO, "registered foundry.toml file watcher")
567                    .await;
568            }
569        }
570    }
571
572    async fn shutdown(&self) -> tower_lsp::jsonrpc::Result<()> {
573        self.client
574            .log_message(MessageType::INFO, "lsp server shutting down.")
575            .await;
576        Ok(())
577    }
578
579    async fn did_open(&self, params: DidOpenTextDocumentParams) {
580        self.client
581            .log_message(MessageType::INFO, "file opened")
582            .await;
583
584        self.on_change(params.text_document).await
585    }
586
587    async fn did_change(&self, params: DidChangeTextDocumentParams) {
588        self.client
589            .log_message(MessageType::INFO, "file changed")
590            .await;
591
592        // update text cache
593        if let Some(change) = params.content_changes.into_iter().next() {
594            let mut text_cache = self.text_cache.write().await;
595            text_cache.insert(
596                params.text_document.uri.to_string(),
597                (params.text_document.version, change.text),
598            );
599        }
600    }
601
602    async fn did_save(&self, params: DidSaveTextDocumentParams) {
603        self.client
604            .log_message(MessageType::INFO, "file saved")
605            .await;
606
607        let text_content = if let Some(text) = params.text {
608            text
609        } else {
610            // Prefer text_cache (reflects unsaved changes), fall back to disk
611            let cached = {
612                let text_cache = self.text_cache.read().await;
613                text_cache
614                    .get(params.text_document.uri.as_str())
615                    .map(|(_, content)| content.clone())
616            };
617            if let Some(content) = cached {
618                content
619            } else {
620                match std::fs::read_to_string(params.text_document.uri.path()) {
621                    Ok(content) => content,
622                    Err(e) => {
623                        self.client
624                            .log_message(
625                                MessageType::ERROR,
626                                format!("Failed to read file on save: {e}"),
627                            )
628                            .await;
629                        return;
630                    }
631                }
632            }
633        };
634
635        let version = self
636            .text_cache
637            .read()
638            .await
639            .get(params.text_document.uri.as_str())
640            .map(|(version, _)| *version)
641            .unwrap_or_default();
642
643        self.on_change(TextDocumentItem {
644            uri: params.text_document.uri,
645            text: text_content,
646            version,
647            language_id: "".to_string(),
648        })
649        .await;
650    }
651
652    async fn will_save(&self, params: WillSaveTextDocumentParams) {
653        self.client
654            .log_message(
655                MessageType::INFO,
656                format!(
657                    "file will save reason:{:?} {}",
658                    params.reason, params.text_document.uri
659                ),
660            )
661            .await;
662    }
663
664    async fn formatting(
665        &self,
666        params: DocumentFormattingParams,
667    ) -> tower_lsp::jsonrpc::Result<Option<Vec<TextEdit>>> {
668        self.client
669            .log_message(MessageType::INFO, "formatting request")
670            .await;
671
672        let uri = params.text_document.uri;
673        let file_path = match uri.to_file_path() {
674            Ok(path) => path,
675            Err(_) => {
676                self.client
677                    .log_message(MessageType::ERROR, "Invalid file URI for formatting")
678                    .await;
679                return Ok(None);
680            }
681        };
682        let path_str = match file_path.to_str() {
683            Some(s) => s,
684            None => {
685                self.client
686                    .log_message(MessageType::ERROR, "Invalid file path for formatting")
687                    .await;
688                return Ok(None);
689            }
690        };
691
692        // Get original content
693        let original_content = {
694            let text_cache = self.text_cache.read().await;
695            if let Some((_, content)) = text_cache.get(&uri.to_string()) {
696                content.clone()
697            } else {
698                // Fallback to reading file
699                match std::fs::read_to_string(&file_path) {
700                    Ok(content) => content,
701                    Err(_) => {
702                        self.client
703                            .log_message(MessageType::ERROR, "Failed to read file for formatting")
704                            .await;
705                        return Ok(None);
706                    }
707                }
708            }
709        };
710
711        // Get formatted content
712        let formatted_content = match self.compiler.format(path_str).await {
713            Ok(content) => content,
714            Err(e) => {
715                self.client
716                    .log_message(MessageType::WARNING, format!("Formatting failed: {e}"))
717                    .await;
718                return Ok(None);
719            }
720        };
721
722        // If changed, update text_cache with formatted content and return edit
723        if original_content != formatted_content {
724            let end = utils::byte_offset_to_position(&original_content, original_content.len());
725
726            // Update text_cache immediately so goto/hover use the formatted text
727            {
728                let mut text_cache = self.text_cache.write().await;
729                let version = text_cache
730                    .get(&uri.to_string())
731                    .map(|(v, _)| *v)
732                    .unwrap_or(0);
733                text_cache.insert(uri.to_string(), (version, formatted_content.clone()));
734            }
735
736            let edit = TextEdit {
737                range: Range {
738                    start: Position::default(),
739                    end,
740                },
741                new_text: formatted_content,
742            };
743            Ok(Some(vec![edit]))
744        } else {
745            Ok(None)
746        }
747    }
748
749    async fn did_close(&self, params: DidCloseTextDocumentParams) {
750        let uri = params.text_document.uri.to_string();
751        self.ast_cache.write().await.remove(&uri);
752        self.text_cache.write().await.remove(&uri);
753        self.completion_cache.write().await.remove(&uri);
754        self.client
755            .log_message(MessageType::INFO, "file closed, caches cleared.")
756            .await;
757    }
758
759    async fn did_change_configuration(&self, _: DidChangeConfigurationParams) {
760        self.client
761            .log_message(MessageType::INFO, "configuration changed.")
762            .await;
763    }
764    async fn did_change_workspace_folders(&self, _: DidChangeWorkspaceFoldersParams) {
765        self.client
766            .log_message(MessageType::INFO, "workdspace folders changed.")
767            .await;
768    }
769
770    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
771        self.client
772            .log_message(MessageType::INFO, "watched files have changed.")
773            .await;
774
775        // Reload configs if foundry.toml or remappings.txt changed.
776        for change in &params.changes {
777            let path = match change.uri.to_file_path() {
778                Ok(p) => p,
779                Err(_) => continue,
780            };
781
782            let filename = path.file_name().and_then(|n| n.to_str());
783
784            if filename == Some("foundry.toml") {
785                let lint_cfg = config::load_lint_config_from_toml(&path);
786                self.client
787                    .log_message(
788                        MessageType::INFO,
789                        format!(
790                            "reloaded foundry.toml lint config: lint_on_build={}, ignore_patterns={}",
791                            lint_cfg.lint_on_build,
792                            lint_cfg.ignore_patterns.len()
793                        ),
794                    )
795                    .await;
796                let mut lc = self.lint_config.write().await;
797                *lc = lint_cfg;
798
799                let foundry_cfg = config::load_foundry_config_from_toml(&path);
800                self.client
801                    .log_message(
802                        MessageType::INFO,
803                        format!(
804                            "reloaded foundry.toml project config: solc_version={:?}, remappings={}",
805                            foundry_cfg.solc_version,
806                            foundry_cfg.remappings.len()
807                        ),
808                    )
809                    .await;
810                let mut fc = self.foundry_config.write().await;
811                *fc = foundry_cfg;
812                break;
813            }
814
815            if filename == Some("remappings.txt") {
816                self.client
817                    .log_message(
818                        MessageType::INFO,
819                        "remappings.txt changed, config may need refresh",
820                    )
821                    .await;
822                // Remappings from remappings.txt are resolved at solc invocation time
823                // via `forge remappings`, so no cached state to update here.
824            }
825        }
826    }
827
828    async fn completion(
829        &self,
830        params: CompletionParams,
831    ) -> tower_lsp::jsonrpc::Result<Option<CompletionResponse>> {
832        let uri = params.text_document_position.text_document.uri;
833        let position = params.text_document_position.position;
834
835        let trigger_char = params
836            .context
837            .as_ref()
838            .and_then(|ctx| ctx.trigger_character.as_deref());
839
840        // Get source text — only needed for dot completions (to parse the line)
841        let source_text = {
842            let text_cache = self.text_cache.read().await;
843            if let Some((_, text)) = text_cache.get(&uri.to_string()) {
844                text.clone()
845            } else {
846                match uri.to_file_path() {
847                    Ok(path) => std::fs::read_to_string(&path).unwrap_or_default(),
848                    Err(_) => return Ok(None),
849                }
850            }
851        };
852
853        // Clone the Arc (pointer copy, instant) and drop the lock immediately.
854        let cached: Option<Arc<completion::CompletionCache>> = {
855            let comp_cache = self.completion_cache.read().await;
856            comp_cache.get(&uri.to_string()).cloned()
857        };
858
859        if cached.is_none() {
860            // Spawn background cache build so the next request will have full completions
861            let ast_cache = self.ast_cache.clone();
862            let completion_cache = self.completion_cache.clone();
863            let uri_string = uri.to_string();
864            tokio::spawn(async move {
865                let cached_build = {
866                    let cache = ast_cache.read().await;
867                    match cache.get(&uri_string) {
868                        Some(v) => v.clone(),
869                        None => return,
870                    }
871                };
872                if let Some(sources) = cached_build.ast.get("sources") {
873                    let contracts = cached_build.ast.get("contracts");
874                    let cc = completion::build_completion_cache(sources, contracts);
875                    completion_cache
876                        .write()
877                        .await
878                        .insert(uri_string, Arc::new(cc));
879                }
880            });
881        }
882
883        let cache_ref = cached.as_deref();
884
885        // Look up the AST file_id for scope-aware resolution
886        let file_id = {
887            let uri_path = uri.to_file_path().ok();
888            cache_ref.and_then(|c| {
889                uri_path.as_ref().and_then(|p| {
890                    let path_str = p.to_str()?;
891                    c.path_to_file_id.get(path_str).copied()
892                })
893            })
894        };
895
896        let result =
897            completion::handle_completion(cache_ref, &source_text, position, trigger_char, file_id);
898        Ok(result)
899    }
900
901    async fn goto_definition(
902        &self,
903        params: GotoDefinitionParams,
904    ) -> tower_lsp::jsonrpc::Result<Option<GotoDefinitionResponse>> {
905        self.client
906            .log_message(MessageType::INFO, "got textDocument/definition request")
907            .await;
908
909        let uri = params.text_document_position_params.text_document.uri;
910        let position = params.text_document_position_params.position;
911
912        let file_path = match uri.to_file_path() {
913            Ok(path) => path,
914            Err(_) => {
915                self.client
916                    .log_message(MessageType::ERROR, "Invalid file uri")
917                    .await;
918                return Ok(None);
919            }
920        };
921
922        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
923            Some(bytes) => bytes,
924            None => return Ok(None),
925        };
926
927        let source_text = String::from_utf8_lossy(&source_bytes).to_string();
928
929        // Extract the identifier name under the cursor for tree-sitter validation.
930        let cursor_name = goto::cursor_context(&source_text, position).map(|ctx| ctx.name);
931
932        // Determine if the file is dirty (unsaved edits since last build).
933        // When dirty, AST byte offsets are stale so we prefer tree-sitter.
934        // When clean, AST has proper semantic resolution (scoping, types).
935        let (is_dirty, cached_build) = {
936            let text_version = self
937                .text_cache
938                .read()
939                .await
940                .get(&uri.to_string())
941                .map(|(v, _)| *v)
942                .unwrap_or(0);
943            let cb = self.get_or_fetch_build(&uri, &file_path, false).await;
944            let build_version = cb.as_ref().map(|b| b.build_version).unwrap_or(0);
945            (text_version > build_version, cb)
946        };
947
948        // Validate a tree-sitter result: read the target source and check that
949        // the text at the location matches the cursor identifier. Tree-sitter
950        // resolves by name so a mismatch means it landed on the wrong node.
951        // AST results are NOT validated — the AST can legitimately resolve to a
952        // different name (e.g. `.selector` → error declaration).
953        let validate_ts = |loc: &Location| -> bool {
954            let Some(ref name) = cursor_name else {
955                return true; // can't validate, trust it
956            };
957            let target_src = if loc.uri == uri {
958                Some(source_text.clone())
959            } else {
960                loc.uri
961                    .to_file_path()
962                    .ok()
963                    .and_then(|p| std::fs::read_to_string(&p).ok())
964            };
965            match target_src {
966                Some(src) => goto::validate_goto_target(&src, loc, name),
967                None => true, // can't read target, trust it
968            }
969        };
970
971        if is_dirty {
972            self.client
973                .log_message(MessageType::INFO, "file is dirty, trying tree-sitter first")
974                .await;
975
976            // DIRTY: tree-sitter first (validated) → AST fallback
977            let ts_result = {
978                let comp_cache = self.completion_cache.read().await;
979                let text_cache = self.text_cache.read().await;
980                if let Some(cc) = comp_cache.get(&uri.to_string()) {
981                    goto::goto_definition_ts(&source_text, position, &uri, cc, &text_cache)
982                } else {
983                    None
984                }
985            };
986
987            if let Some(location) = ts_result {
988                if validate_ts(&location) {
989                    self.client
990                        .log_message(
991                            MessageType::INFO,
992                            format!(
993                                "found definition (tree-sitter) at {}:{}",
994                                location.uri, location.range.start.line
995                            ),
996                        )
997                        .await;
998                    return Ok(Some(GotoDefinitionResponse::from(location)));
999                }
1000                self.client
1001                    .log_message(
1002                        MessageType::INFO,
1003                        "tree-sitter result failed validation, trying AST fallback",
1004                    )
1005                    .await;
1006            }
1007
1008            // Tree-sitter failed or didn't validate — try name-based AST lookup.
1009            // Instead of matching by byte offset (which is stale on dirty files),
1010            // search cached AST nodes whose source text matches the cursor name
1011            // and follow their referencedDeclaration.
1012            if let Some(ref cb) = cached_build
1013                && let Some(ref name) = cursor_name
1014            {
1015                let byte_hint = goto::pos_to_bytes(&source_bytes, position);
1016                if let Some(location) = goto::goto_declaration_by_name(cb, &uri, name, byte_hint) {
1017                    self.client
1018                        .log_message(
1019                            MessageType::INFO,
1020                            format!(
1021                                "found definition (AST by name) at {}:{}",
1022                                location.uri, location.range.start.line
1023                            ),
1024                        )
1025                        .await;
1026                    return Ok(Some(GotoDefinitionResponse::from(location)));
1027                }
1028            }
1029        } else {
1030            // CLEAN: AST first → tree-sitter fallback (validated)
1031            if let Some(ref cb) = cached_build
1032                && let Some(location) =
1033                    goto::goto_declaration(&cb.ast, &uri, position, &source_bytes)
1034            {
1035                self.client
1036                    .log_message(
1037                        MessageType::INFO,
1038                        format!(
1039                            "found definition (AST) at {}:{}",
1040                            location.uri, location.range.start.line
1041                        ),
1042                    )
1043                    .await;
1044                return Ok(Some(GotoDefinitionResponse::from(location)));
1045            }
1046
1047            // AST couldn't resolve — try tree-sitter fallback (validated)
1048            let ts_result = {
1049                let comp_cache = self.completion_cache.read().await;
1050                let text_cache = self.text_cache.read().await;
1051                if let Some(cc) = comp_cache.get(&uri.to_string()) {
1052                    goto::goto_definition_ts(&source_text, position, &uri, cc, &text_cache)
1053                } else {
1054                    None
1055                }
1056            };
1057
1058            if let Some(location) = ts_result {
1059                if validate_ts(&location) {
1060                    self.client
1061                        .log_message(
1062                            MessageType::INFO,
1063                            format!(
1064                                "found definition (tree-sitter fallback) at {}:{}",
1065                                location.uri, location.range.start.line
1066                            ),
1067                        )
1068                        .await;
1069                    return Ok(Some(GotoDefinitionResponse::from(location)));
1070                }
1071                self.client
1072                    .log_message(MessageType::INFO, "tree-sitter fallback failed validation")
1073                    .await;
1074            }
1075        }
1076
1077        self.client
1078            .log_message(MessageType::INFO, "no definition found")
1079            .await;
1080        Ok(None)
1081    }
1082
1083    async fn goto_declaration(
1084        &self,
1085        params: request::GotoDeclarationParams,
1086    ) -> tower_lsp::jsonrpc::Result<Option<request::GotoDeclarationResponse>> {
1087        self.client
1088            .log_message(MessageType::INFO, "got textDocument/declaration request")
1089            .await;
1090
1091        let uri = params.text_document_position_params.text_document.uri;
1092        let position = params.text_document_position_params.position;
1093
1094        let file_path = match uri.to_file_path() {
1095            Ok(path) => path,
1096            Err(_) => {
1097                self.client
1098                    .log_message(MessageType::ERROR, "invalid file uri")
1099                    .await;
1100                return Ok(None);
1101            }
1102        };
1103
1104        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
1105            Some(bytes) => bytes,
1106            None => return Ok(None),
1107        };
1108
1109        let cached_build = self.get_or_fetch_build(&uri, &file_path, false).await;
1110        let cached_build = match cached_build {
1111            Some(cb) => cb,
1112            None => return Ok(None),
1113        };
1114
1115        if let Some(location) =
1116            goto::goto_declaration(&cached_build.ast, &uri, position, &source_bytes)
1117        {
1118            self.client
1119                .log_message(
1120                    MessageType::INFO,
1121                    format!(
1122                        "found declaration at {}:{}",
1123                        location.uri, location.range.start.line
1124                    ),
1125                )
1126                .await;
1127            Ok(Some(request::GotoDeclarationResponse::from(location)))
1128        } else {
1129            self.client
1130                .log_message(MessageType::INFO, "no declaration found")
1131                .await;
1132            Ok(None)
1133        }
1134    }
1135
1136    async fn references(
1137        &self,
1138        params: ReferenceParams,
1139    ) -> tower_lsp::jsonrpc::Result<Option<Vec<Location>>> {
1140        self.client
1141            .log_message(MessageType::INFO, "Got a textDocument/references request")
1142            .await;
1143
1144        let uri = params.text_document_position.text_document.uri;
1145        let position = params.text_document_position.position;
1146        let file_path = match uri.to_file_path() {
1147            Ok(path) => path,
1148            Err(_) => {
1149                self.client
1150                    .log_message(MessageType::ERROR, "Invalid file URI")
1151                    .await;
1152                return Ok(None);
1153            }
1154        };
1155        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
1156            Some(bytes) => bytes,
1157            None => return Ok(None),
1158        };
1159        let cached_build = self.get_or_fetch_build(&uri, &file_path, true).await;
1160        let cached_build = match cached_build {
1161            Some(cb) => cb,
1162            None => return Ok(None),
1163        };
1164
1165        // Get references from the current file's AST
1166        let mut locations = references::goto_references(
1167            &cached_build.ast,
1168            &uri,
1169            position,
1170            &source_bytes,
1171            params.context.include_declaration,
1172        );
1173
1174        // Cross-file: resolve target definition location, then scan other cached ASTs
1175        if let Some((def_abs_path, def_byte_offset)) =
1176            references::resolve_target_location(&cached_build, &uri, position, &source_bytes)
1177        {
1178            let cache = self.ast_cache.read().await;
1179            for (cached_uri, other_build) in cache.iter() {
1180                if *cached_uri == uri.to_string() {
1181                    continue;
1182                }
1183                let other_locations = references::goto_references_for_target(
1184                    other_build,
1185                    &def_abs_path,
1186                    def_byte_offset,
1187                    None,
1188                    params.context.include_declaration,
1189                );
1190                locations.extend(other_locations);
1191            }
1192        }
1193
1194        // Deduplicate across all caches
1195        let mut seen = std::collections::HashSet::new();
1196        locations.retain(|loc| {
1197            seen.insert((
1198                loc.uri.clone(),
1199                loc.range.start.line,
1200                loc.range.start.character,
1201                loc.range.end.line,
1202                loc.range.end.character,
1203            ))
1204        });
1205
1206        if locations.is_empty() {
1207            self.client
1208                .log_message(MessageType::INFO, "No references found")
1209                .await;
1210            Ok(None)
1211        } else {
1212            self.client
1213                .log_message(
1214                    MessageType::INFO,
1215                    format!("Found {} references", locations.len()),
1216                )
1217                .await;
1218            Ok(Some(locations))
1219        }
1220    }
1221
1222    async fn prepare_rename(
1223        &self,
1224        params: TextDocumentPositionParams,
1225    ) -> tower_lsp::jsonrpc::Result<Option<PrepareRenameResponse>> {
1226        self.client
1227            .log_message(MessageType::INFO, "got textDocument/prepareRename request")
1228            .await;
1229
1230        let uri = params.text_document.uri;
1231        let position = params.position;
1232
1233        let file_path = match uri.to_file_path() {
1234            Ok(path) => path,
1235            Err(_) => {
1236                self.client
1237                    .log_message(MessageType::ERROR, "invalid file uri")
1238                    .await;
1239                return Ok(None);
1240            }
1241        };
1242
1243        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
1244            Some(bytes) => bytes,
1245            None => return Ok(None),
1246        };
1247
1248        if let Some(range) = rename::get_identifier_range(&source_bytes, position) {
1249            self.client
1250                .log_message(
1251                    MessageType::INFO,
1252                    format!(
1253                        "prepare rename range: {}:{}",
1254                        range.start.line, range.start.character
1255                    ),
1256                )
1257                .await;
1258            Ok(Some(PrepareRenameResponse::Range(range)))
1259        } else {
1260            self.client
1261                .log_message(MessageType::INFO, "no identifier found for prepare rename")
1262                .await;
1263            Ok(None)
1264        }
1265    }
1266
1267    async fn rename(
1268        &self,
1269        params: RenameParams,
1270    ) -> tower_lsp::jsonrpc::Result<Option<WorkspaceEdit>> {
1271        self.client
1272            .log_message(MessageType::INFO, "got textDocument/rename request")
1273            .await;
1274
1275        let uri = params.text_document_position.text_document.uri;
1276        let position = params.text_document_position.position;
1277        let new_name = params.new_name;
1278        let file_path = match uri.to_file_path() {
1279            Ok(p) => p,
1280            Err(_) => {
1281                self.client
1282                    .log_message(MessageType::ERROR, "invalid file uri")
1283                    .await;
1284                return Ok(None);
1285            }
1286        };
1287        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
1288            Some(bytes) => bytes,
1289            None => return Ok(None),
1290        };
1291
1292        let current_identifier = match rename::get_identifier_at_position(&source_bytes, position) {
1293            Some(id) => id,
1294            None => {
1295                self.client
1296                    .log_message(MessageType::ERROR, "No identifier found at position")
1297                    .await;
1298                return Ok(None);
1299            }
1300        };
1301
1302        if !utils::is_valid_solidity_identifier(&new_name) {
1303            return Err(tower_lsp::jsonrpc::Error::invalid_params(
1304                "new name is not a valid solidity identifier",
1305            ));
1306        }
1307
1308        if new_name == current_identifier {
1309            self.client
1310                .log_message(
1311                    MessageType::INFO,
1312                    "new name is the same as current identifier",
1313                )
1314                .await;
1315            return Ok(None);
1316        }
1317
1318        let cached_build = self.get_or_fetch_build(&uri, &file_path, false).await;
1319        let cached_build = match cached_build {
1320            Some(cb) => cb,
1321            None => return Ok(None),
1322        };
1323        let other_builds: Vec<Arc<goto::CachedBuild>> = {
1324            let cache = self.ast_cache.read().await;
1325            cache
1326                .iter()
1327                .filter(|(key, _)| **key != uri.to_string())
1328                .map(|(_, v)| v.clone())
1329                .collect()
1330        };
1331        let other_refs: Vec<&goto::CachedBuild> = other_builds.iter().map(|v| v.as_ref()).collect();
1332
1333        // Build a map of URI → file content from the text_cache so rename
1334        // verification reads from in-memory buffers (unsaved edits) instead
1335        // of from disk.
1336        let text_buffers: HashMap<String, Vec<u8>> = {
1337            let text_cache = self.text_cache.read().await;
1338            text_cache
1339                .iter()
1340                .map(|(uri, (_, content))| (uri.clone(), content.as_bytes().to_vec()))
1341                .collect()
1342        };
1343
1344        match rename::rename_symbol(
1345            &cached_build,
1346            &uri,
1347            position,
1348            &source_bytes,
1349            new_name,
1350            &other_refs,
1351            &text_buffers,
1352        ) {
1353            Some(workspace_edit) => {
1354                self.client
1355                    .log_message(
1356                        MessageType::INFO,
1357                        format!(
1358                            "created rename edit with {} file(s), {} total change(s)",
1359                            workspace_edit
1360                                .changes
1361                                .as_ref()
1362                                .map(|c| c.len())
1363                                .unwrap_or(0),
1364                            workspace_edit
1365                                .changes
1366                                .as_ref()
1367                                .map(|c| c.values().map(|v| v.len()).sum::<usize>())
1368                                .unwrap_or(0)
1369                        ),
1370                    )
1371                    .await;
1372
1373                // Return the full WorkspaceEdit to the client so the editor
1374                // applies all changes (including cross-file renames) via the
1375                // LSP protocol. This keeps undo working and avoids writing
1376                // files behind the editor's back.
1377                Ok(Some(workspace_edit))
1378            }
1379
1380            None => {
1381                self.client
1382                    .log_message(MessageType::INFO, "No locations found for renaming")
1383                    .await;
1384                Ok(None)
1385            }
1386        }
1387    }
1388
1389    async fn symbol(
1390        &self,
1391        params: WorkspaceSymbolParams,
1392    ) -> tower_lsp::jsonrpc::Result<Option<Vec<SymbolInformation>>> {
1393        self.client
1394            .log_message(MessageType::INFO, "got workspace/symbol request")
1395            .await;
1396
1397        // Collect sources from open files in text_cache
1398        let files: Vec<(Url, String)> = {
1399            let cache = self.text_cache.read().await;
1400            cache
1401                .iter()
1402                .filter(|(uri_str, _)| uri_str.ends_with(".sol"))
1403                .filter_map(|(uri_str, (_, content))| {
1404                    Url::parse(uri_str).ok().map(|uri| (uri, content.clone()))
1405                })
1406                .collect()
1407        };
1408
1409        let mut all_symbols = symbols::extract_workspace_symbols(&files);
1410        if !params.query.is_empty() {
1411            let query = params.query.to_lowercase();
1412            all_symbols.retain(|symbol| symbol.name.to_lowercase().contains(&query));
1413        }
1414        if all_symbols.is_empty() {
1415            self.client
1416                .log_message(MessageType::INFO, "No symbols found")
1417                .await;
1418            Ok(None)
1419        } else {
1420            self.client
1421                .log_message(
1422                    MessageType::INFO,
1423                    format!("found {} symbols", all_symbols.len()),
1424                )
1425                .await;
1426            Ok(Some(all_symbols))
1427        }
1428    }
1429
1430    async fn document_symbol(
1431        &self,
1432        params: DocumentSymbolParams,
1433    ) -> tower_lsp::jsonrpc::Result<Option<DocumentSymbolResponse>> {
1434        self.client
1435            .log_message(MessageType::INFO, "got textDocument/documentSymbol request")
1436            .await;
1437        let uri = params.text_document.uri;
1438        let file_path = match uri.to_file_path() {
1439            Ok(path) => path,
1440            Err(_) => {
1441                self.client
1442                    .log_message(MessageType::ERROR, "invalid file uri")
1443                    .await;
1444                return Ok(None);
1445            }
1446        };
1447
1448        // Read source from text_cache (open files) or disk
1449        let source = {
1450            let cache = self.text_cache.read().await;
1451            cache
1452                .get(&uri.to_string())
1453                .map(|(_, content)| content.clone())
1454        };
1455        let source = match source {
1456            Some(s) => s,
1457            None => match std::fs::read_to_string(&file_path) {
1458                Ok(s) => s,
1459                Err(_) => return Ok(None),
1460            },
1461        };
1462
1463        let symbols = symbols::extract_document_symbols(&source);
1464        if symbols.is_empty() {
1465            self.client
1466                .log_message(MessageType::INFO, "no document symbols found")
1467                .await;
1468            Ok(None)
1469        } else {
1470            self.client
1471                .log_message(
1472                    MessageType::INFO,
1473                    format!("found {} document symbols", symbols.len()),
1474                )
1475                .await;
1476            Ok(Some(DocumentSymbolResponse::Nested(symbols)))
1477        }
1478    }
1479
1480    async fn hover(&self, params: HoverParams) -> tower_lsp::jsonrpc::Result<Option<Hover>> {
1481        self.client
1482            .log_message(MessageType::INFO, "got textDocument/hover request")
1483            .await;
1484
1485        let uri = params.text_document_position_params.text_document.uri;
1486        let position = params.text_document_position_params.position;
1487
1488        let file_path = match uri.to_file_path() {
1489            Ok(path) => path,
1490            Err(_) => {
1491                self.client
1492                    .log_message(MessageType::ERROR, "invalid file uri")
1493                    .await;
1494                return Ok(None);
1495            }
1496        };
1497
1498        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
1499            Some(bytes) => bytes,
1500            None => return Ok(None),
1501        };
1502
1503        let cached_build = self.get_or_fetch_build(&uri, &file_path, false).await;
1504        let cached_build = match cached_build {
1505            Some(cb) => cb,
1506            None => return Ok(None),
1507        };
1508
1509        let result = hover::hover_info(
1510            &cached_build.ast,
1511            &uri,
1512            position,
1513            &source_bytes,
1514            &cached_build.gas_index,
1515            &cached_build.doc_index,
1516            &cached_build.hint_index,
1517        );
1518
1519        if result.is_some() {
1520            self.client
1521                .log_message(MessageType::INFO, "hover info found")
1522                .await;
1523        } else {
1524            self.client
1525                .log_message(MessageType::INFO, "no hover info found")
1526                .await;
1527        }
1528
1529        Ok(result)
1530    }
1531
1532    async fn document_link(
1533        &self,
1534        params: DocumentLinkParams,
1535    ) -> tower_lsp::jsonrpc::Result<Option<Vec<DocumentLink>>> {
1536        self.client
1537            .log_message(MessageType::INFO, "got textDocument/documentLink request")
1538            .await;
1539
1540        let uri = params.text_document.uri;
1541        let file_path = match uri.to_file_path() {
1542            Ok(path) => path,
1543            Err(_) => {
1544                self.client
1545                    .log_message(MessageType::ERROR, "invalid file uri")
1546                    .await;
1547                return Ok(None);
1548            }
1549        };
1550
1551        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
1552            Some(bytes) => bytes,
1553            None => return Ok(None),
1554        };
1555
1556        let cached_build = self.get_or_fetch_build(&uri, &file_path, false).await;
1557        let cached_build = match cached_build {
1558            Some(cb) => cb,
1559            None => return Ok(None),
1560        };
1561
1562        let result = links::document_links(&cached_build, &uri, &source_bytes);
1563
1564        if result.is_empty() {
1565            self.client
1566                .log_message(MessageType::INFO, "no document links found")
1567                .await;
1568            Ok(None)
1569        } else {
1570            self.client
1571                .log_message(
1572                    MessageType::INFO,
1573                    format!("found {} document links", result.len()),
1574                )
1575                .await;
1576            Ok(Some(result))
1577        }
1578    }
1579
1580    async fn semantic_tokens_full(
1581        &self,
1582        params: SemanticTokensParams,
1583    ) -> tower_lsp::jsonrpc::Result<Option<SemanticTokensResult>> {
1584        self.client
1585            .log_message(
1586                MessageType::INFO,
1587                "got textDocument/semanticTokens/full request",
1588            )
1589            .await;
1590
1591        let uri = params.text_document.uri;
1592        let source = {
1593            let cache = self.text_cache.read().await;
1594            cache.get(&uri.to_string()).map(|(_, s)| s.clone())
1595        };
1596
1597        let source = match source {
1598            Some(s) => s,
1599            None => {
1600                // File not open in editor — try reading from disk
1601                let file_path = match uri.to_file_path() {
1602                    Ok(p) => p,
1603                    Err(_) => return Ok(None),
1604                };
1605                match std::fs::read_to_string(&file_path) {
1606                    Ok(s) => s,
1607                    Err(_) => return Ok(None),
1608                }
1609            }
1610        };
1611
1612        let tokens = semantic_tokens::semantic_tokens_full(&source);
1613
1614        Ok(Some(SemanticTokensResult::Tokens(tokens)))
1615    }
1616
1617    async fn inlay_hint(
1618        &self,
1619        params: InlayHintParams,
1620    ) -> tower_lsp::jsonrpc::Result<Option<Vec<InlayHint>>> {
1621        self.client
1622            .log_message(MessageType::INFO, "got textDocument/inlayHint request")
1623            .await;
1624
1625        let uri = params.text_document.uri;
1626        let range = params.range;
1627
1628        let file_path = match uri.to_file_path() {
1629            Ok(path) => path,
1630            Err(_) => {
1631                self.client
1632                    .log_message(MessageType::ERROR, "invalid file uri")
1633                    .await;
1634                return Ok(None);
1635            }
1636        };
1637
1638        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
1639            Some(bytes) => bytes,
1640            None => return Ok(None),
1641        };
1642
1643        let cached_build = self.get_or_fetch_build(&uri, &file_path, false).await;
1644        let cached_build = match cached_build {
1645            Some(cb) => cb,
1646            None => return Ok(None),
1647        };
1648
1649        let hints = inlay_hints::inlay_hints(&cached_build, &uri, range, &source_bytes);
1650
1651        if hints.is_empty() {
1652            self.client
1653                .log_message(MessageType::INFO, "no inlay hints found")
1654                .await;
1655            Ok(None)
1656        } else {
1657            self.client
1658                .log_message(
1659                    MessageType::INFO,
1660                    format!("found {} inlay hints", hints.len()),
1661                )
1662                .await;
1663            Ok(Some(hints))
1664        }
1665    }
1666}