Skip to main content

solidity_language_server/
lsp.rs

1use crate::completion;
2use crate::config::{self, FoundryConfig, LintConfig, Settings};
3use crate::file_operations;
4use crate::folding;
5use crate::goto;
6use crate::highlight;
7use crate::hover;
8use crate::inlay_hints;
9use crate::links;
10use crate::references;
11use crate::rename;
12use crate::runner::{ForgeRunner, Runner};
13use crate::selection;
14use crate::semantic_tokens;
15use crate::symbols;
16use crate::utils;
17use std::collections::{HashMap, HashSet};
18use std::sync::Arc;
19use std::sync::atomic::{AtomicU64, Ordering};
20use tokio::sync::RwLock;
21use tower_lsp::{Client, LanguageServer, lsp_types::*};
22
23/// Per-document semantic token cache: `result_id` + token list.
24type SemanticTokenCache = HashMap<String, (String, Vec<SemanticToken>)>;
25
26pub struct ForgeLsp {
27    client: Client,
28    compiler: Arc<dyn Runner>,
29    ast_cache: Arc<RwLock<HashMap<String, Arc<goto::CachedBuild>>>>,
30    /// Text cache for opened documents
31    ///
32    /// The key is the file's URI converted to string, and the value is a tuple of (version, content).
33    text_cache: Arc<RwLock<HashMap<String, (i32, String)>>>,
34    completion_cache: Arc<RwLock<HashMap<String, Arc<completion::CompletionCache>>>>,
35    /// Cached lint configuration from `foundry.toml`.
36    lint_config: Arc<RwLock<LintConfig>>,
37    /// Cached project configuration from `foundry.toml`.
38    foundry_config: Arc<RwLock<FoundryConfig>>,
39    /// Client capabilities received during initialization.
40    client_capabilities: Arc<RwLock<Option<ClientCapabilities>>>,
41    /// Editor-provided settings (from `initializationOptions` / `didChangeConfiguration`).
42    settings: Arc<RwLock<Settings>>,
43    /// Whether to use solc directly for AST generation (with forge fallback).
44    use_solc: bool,
45    /// Cache of semantic tokens per document for delta support.
46    semantic_token_cache: Arc<RwLock<SemanticTokenCache>>,
47    /// Monotonic counter for generating unique result_ids.
48    semantic_token_id: Arc<AtomicU64>,
49    /// Workspace root URI from `initialize`. Used for project-wide file discovery.
50    root_uri: Arc<RwLock<Option<Url>>>,
51    /// Whether background project indexing has already been triggered.
52    project_indexed: Arc<std::sync::atomic::AtomicBool>,
53    /// URIs recently scaffolded in willCreateFiles (used to avoid re-applying
54    /// edits again in didCreateFiles for the same create operation).
55    pending_create_scaffold: Arc<RwLock<HashSet<String>>>,
56}
57
58impl ForgeLsp {
59    pub fn new(client: Client, use_solar: bool, use_solc: bool) -> Self {
60        let compiler: Arc<dyn Runner> = if use_solar {
61            Arc::new(crate::solar_runner::SolarRunner)
62        } else {
63            Arc::new(ForgeRunner)
64        };
65        let ast_cache = Arc::new(RwLock::new(HashMap::new()));
66        let text_cache = Arc::new(RwLock::new(HashMap::new()));
67        let completion_cache = Arc::new(RwLock::new(HashMap::new()));
68        let lint_config = Arc::new(RwLock::new(LintConfig::default()));
69        let foundry_config = Arc::new(RwLock::new(FoundryConfig::default()));
70        let client_capabilities = Arc::new(RwLock::new(None));
71        let settings = Arc::new(RwLock::new(Settings::default()));
72        Self {
73            client,
74            compiler,
75            ast_cache,
76            text_cache,
77            completion_cache,
78            lint_config,
79            foundry_config,
80            client_capabilities,
81            settings,
82            use_solc,
83            semantic_token_cache: Arc::new(RwLock::new(HashMap::new())),
84            semantic_token_id: Arc::new(AtomicU64::new(0)),
85            root_uri: Arc::new(RwLock::new(None)),
86            project_indexed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
87            pending_create_scaffold: Arc::new(RwLock::new(HashSet::new())),
88        }
89    }
90
91    /// Resolve the foundry configuration for a specific file.
92    ///
93    /// Looks for `foundry.toml` starting from the file's own directory, which
94    /// handles files in nested projects (e.g. `lib/`, `example/`,
95    /// `node_modules/`).  When no `foundry.toml` exists at all (Hardhat, bare
96    /// projects), the file's git root or parent directory is used as the
97    /// project root so solc can still resolve imports.
98    async fn foundry_config_for_file(&self, file_path: &std::path::Path) -> FoundryConfig {
99        config::load_foundry_config(file_path)
100    }
101
102    async fn on_change(&self, params: TextDocumentItem) {
103        let uri = params.uri.clone();
104        let version = params.version;
105
106        let file_path = match uri.to_file_path() {
107            Ok(path) => path,
108            Err(_) => {
109                self.client
110                    .log_message(MessageType::ERROR, "Invalid file URI")
111                    .await;
112                return;
113            }
114        };
115
116        let path_str = match file_path.to_str() {
117            Some(s) => s,
118            None => {
119                self.client
120                    .log_message(MessageType::ERROR, "Invalid file path")
121                    .await;
122                return;
123            }
124        };
125
126        // Check if linting should be skipped based on foundry.toml + editor settings.
127        let (should_lint, lint_settings) = {
128            let lint_cfg = self.lint_config.read().await;
129            let settings = self.settings.read().await;
130            let enabled = lint_cfg.should_lint(&file_path) && settings.lint.enabled;
131            let ls = settings.lint.clone();
132            (enabled, ls)
133        };
134
135        // When use_solc is enabled, run solc once for both AST and diagnostics.
136        // This avoids running `forge build` separately (~27s on large projects).
137        // On solc failure, fall back to the forge-based pipeline.
138        let (lint_result, build_result, ast_result) = if self.use_solc {
139            let foundry_cfg = self.foundry_config_for_file(&file_path).await;
140            let solc_future = crate::solc::solc_ast(path_str, &foundry_cfg, Some(&self.client));
141
142            if should_lint {
143                let (lint, solc) = tokio::join!(
144                    self.compiler.get_lint_diagnostics(&uri, &lint_settings),
145                    solc_future
146                );
147                match solc {
148                    Ok(data) => {
149                        self.client
150                            .log_message(
151                                MessageType::INFO,
152                                "solc: AST + diagnostics from single run",
153                            )
154                            .await;
155                        // Extract diagnostics from the same solc output
156                        let content = tokio::fs::read_to_string(&file_path)
157                            .await
158                            .unwrap_or_default();
159                        let build_diags = crate::build::build_output_to_diagnostics(
160                            &data,
161                            &file_path,
162                            &content,
163                            &foundry_cfg.ignored_error_codes,
164                        );
165                        (Some(lint), Ok(build_diags), Ok(data))
166                    }
167                    Err(e) => {
168                        self.client
169                            .log_message(
170                                MessageType::WARNING,
171                                format!("solc failed, falling back to forge: {e}"),
172                            )
173                            .await;
174                        let (build, ast) = tokio::join!(
175                            self.compiler.get_build_diagnostics(&uri),
176                            self.compiler.ast(path_str)
177                        );
178                        (Some(lint), build, ast)
179                    }
180                }
181            } else {
182                self.client
183                    .log_message(
184                        MessageType::INFO,
185                        format!("skipping lint for ignored file: {path_str}"),
186                    )
187                    .await;
188                match solc_future.await {
189                    Ok(data) => {
190                        self.client
191                            .log_message(
192                                MessageType::INFO,
193                                "solc: AST + diagnostics from single run",
194                            )
195                            .await;
196                        let content = tokio::fs::read_to_string(&file_path)
197                            .await
198                            .unwrap_or_default();
199                        let build_diags = crate::build::build_output_to_diagnostics(
200                            &data,
201                            &file_path,
202                            &content,
203                            &foundry_cfg.ignored_error_codes,
204                        );
205                        (None, Ok(build_diags), Ok(data))
206                    }
207                    Err(e) => {
208                        self.client
209                            .log_message(
210                                MessageType::WARNING,
211                                format!("solc failed, falling back to forge: {e}"),
212                            )
213                            .await;
214                        let (build, ast) = tokio::join!(
215                            self.compiler.get_build_diagnostics(&uri),
216                            self.compiler.ast(path_str)
217                        );
218                        (None, build, ast)
219                    }
220                }
221            }
222        } else {
223            // forge-only pipeline (--use-forge)
224            if should_lint {
225                let (lint, build, ast) = tokio::join!(
226                    self.compiler.get_lint_diagnostics(&uri, &lint_settings),
227                    self.compiler.get_build_diagnostics(&uri),
228                    self.compiler.ast(path_str)
229                );
230                (Some(lint), build, ast)
231            } else {
232                self.client
233                    .log_message(
234                        MessageType::INFO,
235                        format!("skipping lint for ignored file: {path_str}"),
236                    )
237                    .await;
238                let (build, ast) = tokio::join!(
239                    self.compiler.get_build_diagnostics(&uri),
240                    self.compiler.ast(path_str)
241                );
242                (None, build, ast)
243            }
244        };
245
246        // Only replace cache with new AST if build succeeded (no errors; warnings are OK)
247        let build_succeeded = matches!(&build_result, Ok(diagnostics) if diagnostics.iter().all(|d| d.severity != Some(DiagnosticSeverity::ERROR)));
248
249        if build_succeeded {
250            if let Ok(ast_data) = ast_result {
251                let cached_build = Arc::new(goto::CachedBuild::new(ast_data, version));
252                let mut cache = self.ast_cache.write().await;
253                cache.insert(uri.to_string(), cached_build.clone());
254                drop(cache);
255
256                // Insert pre-built completion cache (built during CachedBuild::new)
257                {
258                    let mut cc = self.completion_cache.write().await;
259                    cc.insert(uri.to_string(), cached_build.completion_cache.clone());
260                }
261                self.client
262                    .log_message(MessageType::INFO, "Build successful, AST cache updated")
263                    .await;
264            } else if let Err(e) = ast_result {
265                self.client
266                    .log_message(
267                        MessageType::INFO,
268                        format!("Build succeeded but failed to get AST: {e}"),
269                    )
270                    .await;
271            }
272        } else {
273            // Build has errors - keep the existing cache (don't invalidate)
274            self.client
275                .log_message(
276                    MessageType::INFO,
277                    "Build errors detected, keeping existing AST cache",
278                )
279                .await;
280        }
281
282        // cache text — only if no newer version exists (e.g. from formatting/did_change)
283        {
284            let mut text_cache = self.text_cache.write().await;
285            let uri_str = uri.to_string();
286            let existing_version = text_cache.get(&uri_str).map(|(v, _)| *v).unwrap_or(-1);
287            if version >= existing_version {
288                text_cache.insert(uri_str, (version, params.text));
289            }
290        }
291
292        let mut all_diagnostics = vec![];
293
294        if let Some(lint_result) = lint_result {
295            match lint_result {
296                Ok(mut lints) => {
297                    // Filter out excluded lint rules from editor settings.
298                    if !lint_settings.exclude.is_empty() {
299                        lints.retain(|d| {
300                            if let Some(NumberOrString::String(code)) = &d.code {
301                                !lint_settings.exclude.iter().any(|ex| ex == code)
302                            } else {
303                                true
304                            }
305                        });
306                    }
307                    self.client
308                        .log_message(
309                            MessageType::INFO,
310                            format!("found {} lint diagnostics", lints.len()),
311                        )
312                        .await;
313                    all_diagnostics.append(&mut lints);
314                }
315                Err(e) => {
316                    self.client
317                        .log_message(
318                            MessageType::ERROR,
319                            format!("Forge lint diagnostics failed: {e}"),
320                        )
321                        .await;
322                }
323            }
324        }
325
326        match build_result {
327            Ok(mut builds) => {
328                self.client
329                    .log_message(
330                        MessageType::INFO,
331                        format!("found {} build diagnostics", builds.len()),
332                    )
333                    .await;
334                all_diagnostics.append(&mut builds);
335            }
336            Err(e) => {
337                self.client
338                    .log_message(
339                        MessageType::WARNING,
340                        format!("Forge build diagnostics failed: {e}"),
341                    )
342                    .await;
343            }
344        }
345
346        // Sanitize: some LSP clients (e.g. trunk.io) crash on diagnostics with
347        // empty message fields. Replace any empty message with a safe fallback
348        // before publishing regardless of which diagnostic source produced it.
349        for diag in &mut all_diagnostics {
350            if diag.message.is_empty() {
351                diag.message = "Unknown issue".to_string();
352            }
353        }
354
355        // Publish diagnostics immediately — don't block on project indexing.
356        self.client
357            .publish_diagnostics(uri, all_diagnostics, None)
358            .await;
359
360        // Refresh inlay hints after everything is updated
361        if build_succeeded {
362            let client = self.client.clone();
363            tokio::spawn(async move {
364                let _ = client.inlay_hint_refresh().await;
365            });
366        }
367
368        // Trigger project index in the background on first successful build.
369        // This compiles all project files (src, test, script) in a single solc
370        // invocation so that cross-file features (references, rename) discover
371        // the full project. Runs asynchronously after diagnostics are published
372        // so the user sees diagnostics immediately without waiting for the index.
373        if build_succeeded
374            && self.use_solc
375            && !self
376                .project_indexed
377                .load(std::sync::atomic::Ordering::Relaxed)
378        {
379            self.project_indexed
380                .store(true, std::sync::atomic::Ordering::Relaxed);
381            let foundry_config = self.foundry_config.read().await.clone();
382            let root_uri = self.root_uri.read().await.clone();
383            let cache_key = root_uri.as_ref().map(|u| u.to_string());
384            let ast_cache = self.ast_cache.clone();
385            let client = self.client.clone();
386
387            tokio::spawn(async move {
388                let Some(cache_key) = cache_key else {
389                    return;
390                };
391                if !foundry_config.root.is_dir() {
392                    client
393                        .log_message(
394                            MessageType::INFO,
395                            format!(
396                                "project index: {} not found, skipping",
397                                foundry_config.root.display(),
398                            ),
399                        )
400                        .await;
401                    return;
402                }
403
404                // Create a progress token to show indexing status in the editor.
405                let token = NumberOrString::String("solidity/projectIndex".to_string());
406                let _ = client
407                    .send_request::<request::WorkDoneProgressCreate>(WorkDoneProgressCreateParams {
408                        token: token.clone(),
409                    })
410                    .await;
411
412                // Begin progress: show spinner in the status bar.
413                client
414                    .send_notification::<notification::Progress>(ProgressParams {
415                        token: token.clone(),
416                        value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(
417                            WorkDoneProgressBegin {
418                                title: "Indexing project".to_string(),
419                                message: Some("Discovering source files...".to_string()),
420                                cancellable: Some(false),
421                                percentage: None,
422                            },
423                        )),
424                    })
425                    .await;
426
427                match crate::solc::solc_project_index(&foundry_config, Some(&client), None).await {
428                    Ok(ast_data) => {
429                        let cached_build = Arc::new(crate::goto::CachedBuild::new(ast_data, 0));
430                        let source_count = cached_build.nodes.len();
431                        ast_cache.write().await.insert(cache_key, cached_build);
432                        client
433                            .log_message(
434                                MessageType::INFO,
435                                format!("project index: cached {} source files", source_count),
436                            )
437                            .await;
438
439                        // End progress: indexing complete.
440                        client
441                            .send_notification::<notification::Progress>(ProgressParams {
442                                token: token.clone(),
443                                value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(
444                                    WorkDoneProgressEnd {
445                                        message: Some(format!(
446                                            "Indexed {} source files",
447                                            source_count
448                                        )),
449                                    },
450                                )),
451                            })
452                            .await;
453                    }
454                    Err(e) => {
455                        client
456                            .log_message(MessageType::WARNING, format!("project index failed: {e}"))
457                            .await;
458
459                        // End progress on failure too.
460                        client
461                            .send_notification::<notification::Progress>(ProgressParams {
462                                token: token.clone(),
463                                value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(
464                                    WorkDoneProgressEnd {
465                                        message: Some("Indexing failed".to_string()),
466                                    },
467                                )),
468                            })
469                            .await;
470                    }
471                }
472            });
473        }
474    }
475
476    /// Get a CachedBuild from the cache, or fetch and build one on demand.
477    /// If `insert_on_miss` is true, the freshly-built entry is inserted into the cache
478    /// (used by references handler so cross-file lookups can find it later).
479    ///
480    /// When the entry is in the cache but marked stale (text_cache changed
481    /// since the last build), the text_cache content is flushed to disk and
482    /// the AST is rebuilt so that rename / references work correctly on
483    /// unsaved buffers.
484    async fn get_or_fetch_build(
485        &self,
486        uri: &Url,
487        file_path: &std::path::Path,
488        insert_on_miss: bool,
489    ) -> Option<Arc<goto::CachedBuild>> {
490        let uri_str = uri.to_string();
491
492        // Return cached entry if it exists (stale or not — stale entries are
493        // still usable, positions may be slightly off like goto-definition).
494        {
495            let cache = self.ast_cache.read().await;
496            if let Some(cached) = cache.get(&uri_str) {
497                return Some(cached.clone());
498            }
499        }
500
501        // Cache miss — if caller doesn't want to trigger a build, return None.
502        // This prevents inlay hints, code lens, etc. from blocking on a full
503        // solc/forge build. The cache will be populated by on_change (did_open/did_save).
504        if !insert_on_miss {
505            return None;
506        }
507
508        // Cache miss — build the AST from disk.
509        let path_str = file_path.to_str()?;
510        let ast_result = if self.use_solc {
511            let foundry_cfg = self.foundry_config_for_file(&file_path).await;
512            match crate::solc::solc_ast(path_str, &foundry_cfg, Some(&self.client)).await {
513                Ok(data) => Ok(data),
514                Err(_) => self.compiler.ast(path_str).await,
515            }
516        } else {
517            self.compiler.ast(path_str).await
518        };
519        match ast_result {
520            Ok(data) => {
521                // Built from disk (cache miss) — use version 0; the next
522                // didSave/on_change will stamp the correct version.
523                let build = Arc::new(goto::CachedBuild::new(data, 0));
524                let mut cache = self.ast_cache.write().await;
525                cache.insert(uri_str.clone(), build.clone());
526                Some(build)
527            }
528            Err(e) => {
529                self.client
530                    .log_message(MessageType::ERROR, format!("failed to get AST: {e}"))
531                    .await;
532                None
533            }
534        }
535    }
536
537    /// Get the source bytes for a file, preferring the in-memory text cache
538    /// (which reflects unsaved editor changes) over reading from disk.
539    async fn get_source_bytes(&self, uri: &Url, file_path: &std::path::Path) -> Option<Vec<u8>> {
540        {
541            let text_cache = self.text_cache.read().await;
542            if let Some((_, content)) = text_cache.get(&uri.to_string()) {
543                return Some(content.as_bytes().to_vec());
544            }
545        }
546        match std::fs::read(file_path) {
547            Ok(bytes) => Some(bytes),
548            Err(e) => {
549                if e.kind() == std::io::ErrorKind::NotFound {
550                    // Benign during create/delete races when the editor emits
551                    // didOpen/didChange before the file is materialized on disk.
552                    self.client
553                        .log_message(
554                            MessageType::INFO,
555                            format!("file not found yet (transient): {e}"),
556                        )
557                        .await;
558                } else {
559                    self.client
560                        .log_message(MessageType::ERROR, format!("failed to read file: {e}"))
561                        .await;
562                }
563                None
564            }
565        }
566    }
567}
568
569fn update_imports_on_delete_enabled(settings: &crate::config::Settings) -> bool {
570    settings.file_operations.update_imports_on_delete
571}
572
573#[tower_lsp::async_trait]
574impl LanguageServer for ForgeLsp {
575    async fn initialize(
576        &self,
577        params: InitializeParams,
578    ) -> tower_lsp::jsonrpc::Result<InitializeResult> {
579        // Store client capabilities for use during `initialized()`.
580        {
581            let mut caps = self.client_capabilities.write().await;
582            *caps = Some(params.capabilities.clone());
583        }
584
585        // Read editor settings from initializationOptions.
586        if let Some(init_opts) = &params.initialization_options {
587            let s = config::parse_settings(init_opts);
588            self.client
589                .log_message(
590                    MessageType::INFO,
591                    format!(
592                        "settings: inlayHints.parameters={}, inlayHints.gasEstimates={}, lint.enabled={}, lint.severity={:?}, lint.only={:?}, lint.exclude={:?}, fileOperations.templateOnCreate={}, fileOperations.updateImportsOnRename={}, fileOperations.updateImportsOnDelete={}",
593                        s.inlay_hints.parameters, s.inlay_hints.gas_estimates, s.lint.enabled, s.lint.severity, s.lint.only, s.lint.exclude, s.file_operations.template_on_create, s.file_operations.update_imports_on_rename, s.file_operations.update_imports_on_delete,
594                    ),
595                )
596                .await;
597            let mut settings = self.settings.write().await;
598            *settings = s;
599        }
600
601        // Store root URI for project-wide file discovery.
602        if let Some(uri) = params.root_uri.as_ref() {
603            let mut root = self.root_uri.write().await;
604            *root = Some(uri.clone());
605        }
606
607        // Load config from the workspace root's foundry.toml.
608        if let Some(root_uri) = params
609            .root_uri
610            .as_ref()
611            .and_then(|uri| uri.to_file_path().ok())
612        {
613            let lint_cfg = config::load_lint_config(&root_uri);
614            self.client
615                .log_message(
616                    MessageType::INFO,
617                    format!(
618                        "loaded foundry.toml lint config: lint_on_build={}, ignore_patterns={}",
619                        lint_cfg.lint_on_build,
620                        lint_cfg.ignore_patterns.len()
621                    ),
622                )
623                .await;
624            let mut config = self.lint_config.write().await;
625            *config = lint_cfg;
626
627            let foundry_cfg = config::load_foundry_config(&root_uri);
628            self.client
629                .log_message(
630                    MessageType::INFO,
631                    format!(
632                        "loaded foundry.toml project config: solc_version={:?}, remappings={}",
633                        foundry_cfg.solc_version,
634                        foundry_cfg.remappings.len()
635                    ),
636                )
637                .await;
638            if foundry_cfg.via_ir {
639                self.client
640                    .log_message(
641                        MessageType::WARNING,
642                        "via_ir is enabled in foundry.toml — gas estimate inlay hints are disabled to avoid slow compilation",
643                    )
644                    .await;
645            }
646            let mut fc = self.foundry_config.write().await;
647            *fc = foundry_cfg;
648        }
649
650        // Negotiate position encoding with the client (once, for the session).
651        let client_encodings = params
652            .capabilities
653            .general
654            .as_ref()
655            .and_then(|g| g.position_encodings.as_deref());
656        let encoding = utils::PositionEncoding::negotiate(client_encodings);
657        utils::set_encoding(encoding);
658
659        Ok(InitializeResult {
660            server_info: Some(ServerInfo {
661                name: "Solidity Language Server".to_string(),
662                version: Some(env!("LONG_VERSION").to_string()),
663            }),
664            capabilities: ServerCapabilities {
665                position_encoding: Some(encoding.into()),
666                completion_provider: Some(CompletionOptions {
667                    trigger_characters: Some(vec![".".to_string()]),
668                    resolve_provider: Some(false),
669                    ..Default::default()
670                }),
671                signature_help_provider: Some(SignatureHelpOptions {
672                    trigger_characters: Some(vec![
673                        "(".to_string(),
674                        ",".to_string(),
675                        "[".to_string(),
676                    ]),
677                    retrigger_characters: None,
678                    work_done_progress_options: WorkDoneProgressOptions {
679                        work_done_progress: None,
680                    },
681                }),
682                definition_provider: Some(OneOf::Left(true)),
683                declaration_provider: Some(DeclarationCapability::Simple(true)),
684                references_provider: Some(OneOf::Left(true)),
685                rename_provider: Some(OneOf::Right(RenameOptions {
686                    prepare_provider: Some(true),
687                    work_done_progress_options: WorkDoneProgressOptions {
688                        work_done_progress: Some(true),
689                    },
690                })),
691                workspace_symbol_provider: Some(OneOf::Left(true)),
692                document_symbol_provider: Some(OneOf::Left(true)),
693                document_highlight_provider: Some(OneOf::Left(true)),
694                hover_provider: Some(HoverProviderCapability::Simple(true)),
695                document_link_provider: Some(DocumentLinkOptions {
696                    resolve_provider: Some(false),
697                    work_done_progress_options: WorkDoneProgressOptions {
698                        work_done_progress: None,
699                    },
700                }),
701                document_formatting_provider: Some(OneOf::Left(true)),
702                code_lens_provider: None,
703                folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
704                selection_range_provider: Some(SelectionRangeProviderCapability::Simple(true)),
705                inlay_hint_provider: Some(OneOf::Right(InlayHintServerCapabilities::Options(
706                    InlayHintOptions {
707                        resolve_provider: Some(false),
708                        work_done_progress_options: WorkDoneProgressOptions {
709                            work_done_progress: None,
710                        },
711                    },
712                ))),
713                semantic_tokens_provider: Some(
714                    SemanticTokensServerCapabilities::SemanticTokensOptions(
715                        SemanticTokensOptions {
716                            legend: semantic_tokens::legend(),
717                            full: Some(SemanticTokensFullOptions::Delta { delta: Some(true) }),
718                            range: Some(true),
719                            work_done_progress_options: WorkDoneProgressOptions {
720                                work_done_progress: None,
721                            },
722                        },
723                    ),
724                ),
725                text_document_sync: Some(TextDocumentSyncCapability::Options(
726                    TextDocumentSyncOptions {
727                        will_save: Some(true),
728                        will_save_wait_until: None,
729                        open_close: Some(true),
730                        save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
731                            include_text: Some(true),
732                        })),
733                        change: Some(TextDocumentSyncKind::FULL),
734                    },
735                )),
736                workspace: Some(WorkspaceServerCapabilities {
737                    workspace_folders: None,
738                    file_operations: Some(WorkspaceFileOperationsServerCapabilities {
739                        will_rename: Some(FileOperationRegistrationOptions {
740                            filters: vec![
741                                // Match .sol files
742                                FileOperationFilter {
743                                    scheme: Some("file".to_string()),
744                                    pattern: FileOperationPattern {
745                                        glob: "**/*.sol".to_string(),
746                                        matches: Some(FileOperationPatternKind::File),
747                                        options: None,
748                                    },
749                                },
750                                // Match folders (moving a directory moves all .sol files within)
751                                FileOperationFilter {
752                                    scheme: Some("file".to_string()),
753                                    pattern: FileOperationPattern {
754                                        glob: "**".to_string(),
755                                        matches: Some(FileOperationPatternKind::Folder),
756                                        options: None,
757                                    },
758                                },
759                            ],
760                        }),
761                        did_rename: Some(FileOperationRegistrationOptions {
762                            filters: vec![
763                                FileOperationFilter {
764                                    scheme: Some("file".to_string()),
765                                    pattern: FileOperationPattern {
766                                        glob: "**/*.sol".to_string(),
767                                        matches: Some(FileOperationPatternKind::File),
768                                        options: None,
769                                    },
770                                },
771                                FileOperationFilter {
772                                    scheme: Some("file".to_string()),
773                                    pattern: FileOperationPattern {
774                                        glob: "**".to_string(),
775                                        matches: Some(FileOperationPatternKind::Folder),
776                                        options: None,
777                                    },
778                                },
779                            ],
780                        }),
781                        will_delete: Some(FileOperationRegistrationOptions {
782                            filters: vec![
783                                FileOperationFilter {
784                                    scheme: Some("file".to_string()),
785                                    pattern: FileOperationPattern {
786                                        glob: "**/*.sol".to_string(),
787                                        matches: Some(FileOperationPatternKind::File),
788                                        options: None,
789                                    },
790                                },
791                                FileOperationFilter {
792                                    scheme: Some("file".to_string()),
793                                    pattern: FileOperationPattern {
794                                        glob: "**".to_string(),
795                                        matches: Some(FileOperationPatternKind::Folder),
796                                        options: None,
797                                    },
798                                },
799                            ],
800                        }),
801                        did_delete: Some(FileOperationRegistrationOptions {
802                            filters: vec![
803                                FileOperationFilter {
804                                    scheme: Some("file".to_string()),
805                                    pattern: FileOperationPattern {
806                                        glob: "**/*.sol".to_string(),
807                                        matches: Some(FileOperationPatternKind::File),
808                                        options: None,
809                                    },
810                                },
811                                FileOperationFilter {
812                                    scheme: Some("file".to_string()),
813                                    pattern: FileOperationPattern {
814                                        glob: "**".to_string(),
815                                        matches: Some(FileOperationPatternKind::Folder),
816                                        options: None,
817                                    },
818                                },
819                            ],
820                        }),
821                        will_create: Some(FileOperationRegistrationOptions {
822                            filters: vec![FileOperationFilter {
823                                scheme: Some("file".to_string()),
824                                pattern: FileOperationPattern {
825                                    glob: "**/*.sol".to_string(),
826                                    matches: Some(FileOperationPatternKind::File),
827                                    options: None,
828                                },
829                            }],
830                        }),
831                        did_create: Some(FileOperationRegistrationOptions {
832                            filters: vec![FileOperationFilter {
833                                scheme: Some("file".to_string()),
834                                pattern: FileOperationPattern {
835                                    glob: "**/*.sol".to_string(),
836                                    matches: Some(FileOperationPatternKind::File),
837                                    options: None,
838                                },
839                            }],
840                        }),
841                        ..Default::default()
842                    }),
843                }),
844                ..ServerCapabilities::default()
845            },
846        })
847    }
848
849    async fn initialized(&self, _: InitializedParams) {
850        self.client
851            .log_message(MessageType::INFO, "lsp server initialized.")
852            .await;
853
854        // Dynamically register a file watcher for foundry.toml changes.
855        let supports_dynamic = self
856            .client_capabilities
857            .read()
858            .await
859            .as_ref()
860            .and_then(|caps| caps.workspace.as_ref())
861            .and_then(|ws| ws.did_change_watched_files.as_ref())
862            .and_then(|dcwf| dcwf.dynamic_registration)
863            .unwrap_or(false);
864
865        if supports_dynamic {
866            let registration = Registration {
867                id: "foundry-toml-watcher".to_string(),
868                method: "workspace/didChangeWatchedFiles".to_string(),
869                register_options: Some(
870                    serde_json::to_value(DidChangeWatchedFilesRegistrationOptions {
871                        watchers: vec![
872                            FileSystemWatcher {
873                                glob_pattern: GlobPattern::String("**/foundry.toml".to_string()),
874                                kind: Some(WatchKind::all()),
875                            },
876                            FileSystemWatcher {
877                                glob_pattern: GlobPattern::String("**/remappings.txt".to_string()),
878                                kind: Some(WatchKind::all()),
879                            },
880                        ],
881                    })
882                    .unwrap(),
883                ),
884            };
885
886            if let Err(e) = self.client.register_capability(vec![registration]).await {
887                self.client
888                    .log_message(
889                        MessageType::WARNING,
890                        format!("failed to register foundry.toml watcher: {e}"),
891                    )
892                    .await;
893            } else {
894                self.client
895                    .log_message(MessageType::INFO, "registered foundry.toml file watcher")
896                    .await;
897            }
898        }
899
900        // Eagerly build the project index on startup so cross-file features
901        // (willRenameFiles, references, goto) work immediately — even before
902        // the user opens any .sol file.
903        if self.use_solc {
904            self.project_indexed
905                .store(true, std::sync::atomic::Ordering::Relaxed);
906            let foundry_config = self.foundry_config.read().await.clone();
907            let root_uri = self.root_uri.read().await.clone();
908            let cache_key = root_uri.as_ref().map(|u| u.to_string());
909            let ast_cache = self.ast_cache.clone();
910            let client = self.client.clone();
911
912            tokio::spawn(async move {
913                let Some(cache_key) = cache_key else {
914                    return;
915                };
916                if !foundry_config.root.is_dir() {
917                    client
918                        .log_message(
919                            MessageType::INFO,
920                            format!(
921                                "project index: {} not found, skipping eager index",
922                                foundry_config.root.display(),
923                            ),
924                        )
925                        .await;
926                    return;
927                }
928
929                let token = NumberOrString::String("solidity/projectIndex".to_string());
930                let _ = client
931                    .send_request::<request::WorkDoneProgressCreate>(WorkDoneProgressCreateParams {
932                        token: token.clone(),
933                    })
934                    .await;
935
936                client
937                    .send_notification::<notification::Progress>(ProgressParams {
938                        token: token.clone(),
939                        value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(
940                            WorkDoneProgressBegin {
941                                title: "Indexing project".to_string(),
942                                message: Some("Discovering source files...".to_string()),
943                                cancellable: Some(false),
944                                percentage: None,
945                            },
946                        )),
947                    })
948                    .await;
949
950                match crate::solc::solc_project_index(&foundry_config, Some(&client), None).await {
951                    Ok(ast_data) => {
952                        let cached_build = Arc::new(crate::goto::CachedBuild::new(ast_data, 0));
953                        let source_count = cached_build.nodes.len();
954                        ast_cache.write().await.insert(cache_key, cached_build);
955                        client
956                            .log_message(
957                                MessageType::INFO,
958                                format!(
959                                    "project index (eager): cached {} source files",
960                                    source_count
961                                ),
962                            )
963                            .await;
964
965                        client
966                            .send_notification::<notification::Progress>(ProgressParams {
967                                token: token.clone(),
968                                value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(
969                                    WorkDoneProgressEnd {
970                                        message: Some(format!(
971                                            "Indexed {} source files",
972                                            source_count
973                                        )),
974                                    },
975                                )),
976                            })
977                            .await;
978                    }
979                    Err(e) => {
980                        client
981                            .log_message(
982                                MessageType::WARNING,
983                                format!("project index (eager): failed: {e}"),
984                            )
985                            .await;
986
987                        client
988                            .send_notification::<notification::Progress>(ProgressParams {
989                                token,
990                                value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(
991                                    WorkDoneProgressEnd {
992                                        message: Some(format!("Index failed: {e}")),
993                                    },
994                                )),
995                            })
996                            .await;
997                    }
998                }
999            });
1000        }
1001    }
1002
1003    async fn shutdown(&self) -> tower_lsp::jsonrpc::Result<()> {
1004        self.client
1005            .log_message(MessageType::INFO, "lsp server shutting down.")
1006            .await;
1007        Ok(())
1008    }
1009
1010    async fn did_open(&self, params: DidOpenTextDocumentParams) {
1011        self.client
1012            .log_message(MessageType::INFO, "file opened")
1013            .await;
1014
1015        let mut td = params.text_document;
1016        let template_on_create = self
1017            .settings
1018            .read()
1019            .await
1020            .file_operations
1021            .template_on_create;
1022
1023        // Fallback path for clients/flows that don't emit file-operation
1024        // create events reliably: scaffold an empty newly-opened `.sol` file.
1025        let should_attempt_scaffold = template_on_create
1026            && td.text.chars().all(|ch| ch.is_whitespace())
1027            && td.uri.scheme() == "file"
1028            && td
1029                .uri
1030                .to_file_path()
1031                .ok()
1032                .and_then(|p| p.extension().map(|e| e == "sol"))
1033                .unwrap_or(false);
1034
1035        if should_attempt_scaffold {
1036            let uri_str = td.uri.to_string();
1037            let create_flow_pending = {
1038                let pending = self.pending_create_scaffold.read().await;
1039                pending.contains(&uri_str)
1040            };
1041            if create_flow_pending {
1042                self.client
1043                    .log_message(
1044                        MessageType::INFO,
1045                        format!(
1046                            "didOpen: skip scaffold for {} (didCreateFiles scaffold pending)",
1047                            uri_str
1048                        ),
1049                    )
1050                    .await;
1051            } else {
1052                let cache_has_content = {
1053                    let tc = self.text_cache.read().await;
1054                    tc.get(&uri_str)
1055                        .map_or(false, |(_, c)| c.chars().any(|ch| !ch.is_whitespace()))
1056                };
1057
1058                if !cache_has_content {
1059                    let file_has_content = td.uri.to_file_path().ok().is_some_and(|p| {
1060                        std::fs::read_to_string(&p)
1061                            .map_or(false, |c| c.chars().any(|ch| !ch.is_whitespace()))
1062                    });
1063
1064                    if !file_has_content {
1065                        let solc_version = self.foundry_config.read().await.solc_version.clone();
1066                        if let Some(scaffold) =
1067                            file_operations::generate_scaffold(&td.uri, solc_version.as_deref())
1068                        {
1069                            let end = utils::byte_offset_to_position(&td.text, td.text.len());
1070                            let edit = WorkspaceEdit {
1071                                changes: Some(HashMap::from([(
1072                                    td.uri.clone(),
1073                                    vec![TextEdit {
1074                                        range: Range {
1075                                            start: Position::default(),
1076                                            end,
1077                                        },
1078                                        new_text: scaffold.clone(),
1079                                    }],
1080                                )])),
1081                                document_changes: None,
1082                                change_annotations: None,
1083                            };
1084                            if self
1085                                .client
1086                                .apply_edit(edit)
1087                                .await
1088                                .as_ref()
1089                                .is_ok_and(|r| r.applied)
1090                            {
1091                                td.text = scaffold;
1092                                self.client
1093                                    .log_message(
1094                                        MessageType::INFO,
1095                                        format!("didOpen: scaffolded empty file {}", uri_str),
1096                                    )
1097                                    .await;
1098                            }
1099                        }
1100                    }
1101                }
1102            }
1103        }
1104
1105        self.on_change(td).await
1106    }
1107
1108    async fn did_change(&self, params: DidChangeTextDocumentParams) {
1109        self.client
1110            .log_message(MessageType::INFO, "file changed")
1111            .await;
1112
1113        // update text cache
1114        if let Some(change) = params.content_changes.into_iter().next() {
1115            let has_substantive_content = change.text.chars().any(|ch| !ch.is_whitespace());
1116            let mut text_cache = self.text_cache.write().await;
1117            text_cache.insert(
1118                params.text_document.uri.to_string(),
1119                (params.text_document.version, change.text),
1120            );
1121            drop(text_cache);
1122
1123            if has_substantive_content {
1124                self.pending_create_scaffold
1125                    .write()
1126                    .await
1127                    .remove(params.text_document.uri.as_str());
1128            }
1129        }
1130    }
1131
1132    async fn did_save(&self, params: DidSaveTextDocumentParams) {
1133        self.client
1134            .log_message(MessageType::INFO, "file saved")
1135            .await;
1136
1137        let mut text_content = if let Some(text) = params.text {
1138            text
1139        } else {
1140            // Prefer text_cache (reflects unsaved changes), fall back to disk
1141            let cached = {
1142                let text_cache = self.text_cache.read().await;
1143                text_cache
1144                    .get(params.text_document.uri.as_str())
1145                    .map(|(_, content)| content.clone())
1146            };
1147            if let Some(content) = cached {
1148                content
1149            } else {
1150                match std::fs::read_to_string(params.text_document.uri.path()) {
1151                    Ok(content) => content,
1152                    Err(e) => {
1153                        self.client
1154                            .log_message(
1155                                MessageType::ERROR,
1156                                format!("Failed to read file on save: {e}"),
1157                            )
1158                            .await;
1159                        return;
1160                    }
1161                }
1162            }
1163        };
1164
1165        // Recovery path for create-file races:
1166        // if a newly-created file is still whitespace-only at first save,
1167        // regenerate scaffold and apply it to the open buffer.
1168        let uri_str = params.text_document.uri.to_string();
1169        let template_on_create = self
1170            .settings
1171            .read()
1172            .await
1173            .file_operations
1174            .template_on_create;
1175        let needs_recover_scaffold = {
1176            let pending = self.pending_create_scaffold.read().await;
1177            template_on_create
1178                && pending.contains(&uri_str)
1179                && !text_content.chars().any(|ch| !ch.is_whitespace())
1180        };
1181        if needs_recover_scaffold {
1182            let solc_version = self.foundry_config.read().await.solc_version.clone();
1183            if let Some(scaffold) = file_operations::generate_scaffold(
1184                &params.text_document.uri,
1185                solc_version.as_deref(),
1186            ) {
1187                let end = utils::byte_offset_to_position(&text_content, text_content.len());
1188                let edit = WorkspaceEdit {
1189                    changes: Some(HashMap::from([(
1190                        params.text_document.uri.clone(),
1191                        vec![TextEdit {
1192                            range: Range {
1193                                start: Position::default(),
1194                                end,
1195                            },
1196                            new_text: scaffold.clone(),
1197                        }],
1198                    )])),
1199                    document_changes: None,
1200                    change_annotations: None,
1201                };
1202                if self
1203                    .client
1204                    .apply_edit(edit)
1205                    .await
1206                    .as_ref()
1207                    .is_ok_and(|r| r.applied)
1208                {
1209                    text_content = scaffold.clone();
1210                    let version = self
1211                        .text_cache
1212                        .read()
1213                        .await
1214                        .get(params.text_document.uri.as_str())
1215                        .map(|(v, _)| *v)
1216                        .unwrap_or_default();
1217                    self.text_cache
1218                        .write()
1219                        .await
1220                        .insert(uri_str.clone(), (version, scaffold));
1221                    self.pending_create_scaffold.write().await.remove(&uri_str);
1222                    self.client
1223                        .log_message(
1224                            MessageType::INFO,
1225                            format!("didSave: recovered scaffold for {}", uri_str),
1226                        )
1227                        .await;
1228                }
1229            }
1230        }
1231
1232        let version = self
1233            .text_cache
1234            .read()
1235            .await
1236            .get(params.text_document.uri.as_str())
1237            .map(|(version, _)| *version)
1238            .unwrap_or_default();
1239
1240        self.on_change(TextDocumentItem {
1241            uri: params.text_document.uri,
1242            text: text_content,
1243            version,
1244            language_id: "".to_string(),
1245        })
1246        .await;
1247    }
1248
1249    async fn will_save(&self, params: WillSaveTextDocumentParams) {
1250        self.client
1251            .log_message(
1252                MessageType::INFO,
1253                format!(
1254                    "file will save reason:{:?} {}",
1255                    params.reason, params.text_document.uri
1256                ),
1257            )
1258            .await;
1259    }
1260
1261    async fn formatting(
1262        &self,
1263        params: DocumentFormattingParams,
1264    ) -> tower_lsp::jsonrpc::Result<Option<Vec<TextEdit>>> {
1265        self.client
1266            .log_message(MessageType::INFO, "formatting request")
1267            .await;
1268
1269        let uri = params.text_document.uri;
1270        let file_path = match uri.to_file_path() {
1271            Ok(path) => path,
1272            Err(_) => {
1273                self.client
1274                    .log_message(MessageType::ERROR, "Invalid file URI for formatting")
1275                    .await;
1276                return Ok(None);
1277            }
1278        };
1279        let path_str = match file_path.to_str() {
1280            Some(s) => s,
1281            None => {
1282                self.client
1283                    .log_message(MessageType::ERROR, "Invalid file path for formatting")
1284                    .await;
1285                return Ok(None);
1286            }
1287        };
1288
1289        // Get original content
1290        let original_content = {
1291            let text_cache = self.text_cache.read().await;
1292            if let Some((_, content)) = text_cache.get(&uri.to_string()) {
1293                content.clone()
1294            } else {
1295                // Fallback to reading file
1296                match std::fs::read_to_string(&file_path) {
1297                    Ok(content) => content,
1298                    Err(_) => {
1299                        self.client
1300                            .log_message(MessageType::ERROR, "Failed to read file for formatting")
1301                            .await;
1302                        return Ok(None);
1303                    }
1304                }
1305            }
1306        };
1307
1308        // Get formatted content
1309        let formatted_content = match self.compiler.format(path_str).await {
1310            Ok(content) => content,
1311            Err(e) => {
1312                self.client
1313                    .log_message(MessageType::WARNING, format!("Formatting failed: {e}"))
1314                    .await;
1315                return Ok(None);
1316            }
1317        };
1318
1319        // If changed, update text_cache with formatted content and return edit
1320        if original_content != formatted_content {
1321            let end = utils::byte_offset_to_position(&original_content, original_content.len());
1322
1323            // Update text_cache immediately so goto/hover use the formatted text
1324            {
1325                let mut text_cache = self.text_cache.write().await;
1326                let version = text_cache
1327                    .get(&uri.to_string())
1328                    .map(|(v, _)| *v)
1329                    .unwrap_or(0);
1330                text_cache.insert(uri.to_string(), (version, formatted_content.clone()));
1331            }
1332
1333            let edit = TextEdit {
1334                range: Range {
1335                    start: Position::default(),
1336                    end,
1337                },
1338                new_text: formatted_content,
1339            };
1340            Ok(Some(vec![edit]))
1341        } else {
1342            Ok(None)
1343        }
1344    }
1345
1346    async fn did_close(&self, params: DidCloseTextDocumentParams) {
1347        let uri = params.text_document.uri.to_string();
1348        self.ast_cache.write().await.remove(&uri);
1349        self.text_cache.write().await.remove(&uri);
1350        self.completion_cache.write().await.remove(&uri);
1351        self.client
1352            .log_message(MessageType::INFO, "file closed, caches cleared.")
1353            .await;
1354    }
1355
1356    async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
1357        let s = config::parse_settings(&params.settings);
1358        self.client
1359                .log_message(
1360                    MessageType::INFO,
1361                    format!(
1362                    "settings updated: inlayHints.parameters={}, inlayHints.gasEstimates={}, lint.enabled={}, lint.severity={:?}, lint.only={:?}, lint.exclude={:?}, fileOperations.templateOnCreate={}, fileOperations.updateImportsOnRename={}, fileOperations.updateImportsOnDelete={}",
1363                    s.inlay_hints.parameters, s.inlay_hints.gas_estimates, s.lint.enabled, s.lint.severity, s.lint.only, s.lint.exclude, s.file_operations.template_on_create, s.file_operations.update_imports_on_rename, s.file_operations.update_imports_on_delete,
1364                ),
1365            )
1366            .await;
1367        let mut settings = self.settings.write().await;
1368        *settings = s;
1369
1370        // Refresh inlay hints so the editor re-requests them with new settings.
1371        let client = self.client.clone();
1372        tokio::spawn(async move {
1373            let _ = client.inlay_hint_refresh().await;
1374        });
1375    }
1376    async fn did_change_workspace_folders(&self, _: DidChangeWorkspaceFoldersParams) {
1377        self.client
1378            .log_message(MessageType::INFO, "workdspace folders changed.")
1379            .await;
1380    }
1381
1382    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1383        self.client
1384            .log_message(MessageType::INFO, "watched files have changed.")
1385            .await;
1386
1387        // Reload configs if foundry.toml or remappings.txt changed.
1388        for change in &params.changes {
1389            let path = match change.uri.to_file_path() {
1390                Ok(p) => p,
1391                Err(_) => continue,
1392            };
1393
1394            let filename = path.file_name().and_then(|n| n.to_str());
1395
1396            if filename == Some("foundry.toml") {
1397                let lint_cfg = config::load_lint_config_from_toml(&path);
1398                self.client
1399                    .log_message(
1400                        MessageType::INFO,
1401                        format!(
1402                            "reloaded foundry.toml lint config: lint_on_build={}, ignore_patterns={}",
1403                            lint_cfg.lint_on_build,
1404                            lint_cfg.ignore_patterns.len()
1405                        ),
1406                    )
1407                    .await;
1408                let mut lc = self.lint_config.write().await;
1409                *lc = lint_cfg;
1410
1411                let foundry_cfg = config::load_foundry_config_from_toml(&path);
1412                self.client
1413                    .log_message(
1414                        MessageType::INFO,
1415                        format!(
1416                            "reloaded foundry.toml project config: solc_version={:?}, remappings={}",
1417                            foundry_cfg.solc_version,
1418                            foundry_cfg.remappings.len()
1419                        ),
1420                    )
1421                    .await;
1422                if foundry_cfg.via_ir {
1423                    self.client
1424                        .log_message(
1425                            MessageType::WARNING,
1426                            "via_ir is enabled in foundry.toml — gas estimate inlay hints are disabled to avoid slow compilation",
1427                        )
1428                        .await;
1429                }
1430                let mut fc = self.foundry_config.write().await;
1431                *fc = foundry_cfg;
1432                break;
1433            }
1434
1435            if filename == Some("remappings.txt") {
1436                self.client
1437                    .log_message(
1438                        MessageType::INFO,
1439                        "remappings.txt changed, config may need refresh",
1440                    )
1441                    .await;
1442                // Remappings from remappings.txt are resolved at solc invocation time
1443                // via `forge remappings`, so no cached state to update here.
1444            }
1445        }
1446    }
1447
1448    async fn completion(
1449        &self,
1450        params: CompletionParams,
1451    ) -> tower_lsp::jsonrpc::Result<Option<CompletionResponse>> {
1452        let uri = params.text_document_position.text_document.uri;
1453        let position = params.text_document_position.position;
1454
1455        let trigger_char = params
1456            .context
1457            .as_ref()
1458            .and_then(|ctx| ctx.trigger_character.as_deref());
1459
1460        // Get source text — only needed for dot completions (to parse the line)
1461        let source_text = {
1462            let text_cache = self.text_cache.read().await;
1463            if let Some((_, text)) = text_cache.get(&uri.to_string()) {
1464                text.clone()
1465            } else {
1466                match uri.to_file_path() {
1467                    Ok(path) => std::fs::read_to_string(&path).unwrap_or_default(),
1468                    Err(_) => return Ok(None),
1469                }
1470            }
1471        };
1472
1473        // Clone URI-specific cache (pointer copy, instant) and drop the lock immediately.
1474        let local_cached: Option<Arc<completion::CompletionCache>> = {
1475            let comp_cache = self.completion_cache.read().await;
1476            comp_cache.get(&uri.to_string()).cloned()
1477        };
1478
1479        // Project-wide cache for global top-level symbol tail candidates.
1480        let root_cached: Option<Arc<completion::CompletionCache>> = {
1481            let root_key = self.root_uri.read().await.as_ref().map(|u| u.to_string());
1482            match root_key {
1483                Some(root_key) => {
1484                    let ast_cache = self.ast_cache.read().await;
1485                    ast_cache
1486                        .get(&root_key)
1487                        .map(|root_build| root_build.completion_cache.clone())
1488                }
1489                None => None,
1490            }
1491        };
1492
1493        // Base cache remains per-file first; root cache is only a fallback.
1494        let cached = local_cached.or(root_cached.clone());
1495
1496        if cached.is_none() {
1497            // Use pre-built completion cache from CachedBuild
1498            let ast_cache = self.ast_cache.clone();
1499            let completion_cache = self.completion_cache.clone();
1500            let uri_string = uri.to_string();
1501            tokio::spawn(async move {
1502                let cached_build = {
1503                    let cache = ast_cache.read().await;
1504                    match cache.get(&uri_string) {
1505                        Some(v) => v.clone(),
1506                        None => return,
1507                    }
1508                };
1509                completion_cache
1510                    .write()
1511                    .await
1512                    .insert(uri_string, cached_build.completion_cache.clone());
1513            });
1514        }
1515
1516        let cache_ref = cached.as_deref();
1517
1518        // Look up the AST file_id for scope-aware resolution
1519        let file_id = {
1520            let uri_path = uri.to_file_path().ok();
1521            cache_ref.and_then(|c| {
1522                uri_path.as_ref().and_then(|p| {
1523                    let path_str = p.to_str()?;
1524                    c.path_to_file_id.get(path_str).copied()
1525                })
1526            })
1527        };
1528
1529        let current_file_path = uri
1530            .to_file_path()
1531            .ok()
1532            .and_then(|p| p.to_str().map(|s| s.to_string()));
1533
1534        let tail_candidates = if trigger_char == Some(".") {
1535            vec![]
1536        } else {
1537            root_cached.as_deref().map_or_else(Vec::new, |c| {
1538                completion::top_level_importable_completion_candidates(
1539                    c,
1540                    current_file_path.as_deref(),
1541                    &source_text,
1542                )
1543            })
1544        };
1545
1546        let result = completion::handle_completion_with_tail_candidates(
1547            cache_ref,
1548            &source_text,
1549            position,
1550            trigger_char,
1551            file_id,
1552            tail_candidates,
1553        );
1554        Ok(result)
1555    }
1556
1557    async fn goto_definition(
1558        &self,
1559        params: GotoDefinitionParams,
1560    ) -> tower_lsp::jsonrpc::Result<Option<GotoDefinitionResponse>> {
1561        self.client
1562            .log_message(MessageType::INFO, "got textDocument/definition request")
1563            .await;
1564
1565        let uri = params.text_document_position_params.text_document.uri;
1566        let position = params.text_document_position_params.position;
1567
1568        let file_path = match uri.to_file_path() {
1569            Ok(path) => path,
1570            Err(_) => {
1571                self.client
1572                    .log_message(MessageType::ERROR, "Invalid file uri")
1573                    .await;
1574                return Ok(None);
1575            }
1576        };
1577
1578        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
1579            Some(bytes) => bytes,
1580            None => return Ok(None),
1581        };
1582
1583        let source_text = String::from_utf8_lossy(&source_bytes).to_string();
1584
1585        // Extract the identifier name under the cursor for tree-sitter validation.
1586        let cursor_name = goto::cursor_context(&source_text, position).map(|ctx| ctx.name);
1587
1588        // Determine if the file is dirty (unsaved edits since last build).
1589        // When dirty, AST byte offsets are stale so we prefer tree-sitter.
1590        // When clean, AST has proper semantic resolution (scoping, types).
1591        let (is_dirty, cached_build) = {
1592            let text_version = self
1593                .text_cache
1594                .read()
1595                .await
1596                .get(&uri.to_string())
1597                .map(|(v, _)| *v)
1598                .unwrap_or(0);
1599            let cb = self.get_or_fetch_build(&uri, &file_path, false).await;
1600            let build_version = cb.as_ref().map(|b| b.build_version).unwrap_or(0);
1601            (text_version > build_version, cb)
1602        };
1603
1604        // Validate a tree-sitter result: read the target source and check that
1605        // the text at the location matches the cursor identifier. Tree-sitter
1606        // resolves by name so a mismatch means it landed on the wrong node.
1607        // AST results are NOT validated — the AST can legitimately resolve to a
1608        // different name (e.g. `.selector` → error declaration).
1609        let validate_ts = |loc: &Location| -> bool {
1610            let Some(ref name) = cursor_name else {
1611                return true; // can't validate, trust it
1612            };
1613            let target_src = if loc.uri == uri {
1614                Some(source_text.clone())
1615            } else {
1616                loc.uri
1617                    .to_file_path()
1618                    .ok()
1619                    .and_then(|p| std::fs::read_to_string(&p).ok())
1620            };
1621            match target_src {
1622                Some(src) => goto::validate_goto_target(&src, loc, name),
1623                None => true, // can't read target, trust it
1624            }
1625        };
1626
1627        if is_dirty {
1628            self.client
1629                .log_message(MessageType::INFO, "file is dirty, trying tree-sitter first")
1630                .await;
1631
1632            // DIRTY: tree-sitter first (validated) → AST fallback
1633            let ts_result = {
1634                let comp_cache = self.completion_cache.read().await;
1635                let text_cache = self.text_cache.read().await;
1636                if let Some(cc) = comp_cache.get(&uri.to_string()) {
1637                    goto::goto_definition_ts(&source_text, position, &uri, cc, &text_cache)
1638                } else {
1639                    None
1640                }
1641            };
1642
1643            if let Some(location) = ts_result {
1644                if validate_ts(&location) {
1645                    self.client
1646                        .log_message(
1647                            MessageType::INFO,
1648                            format!(
1649                                "found definition (tree-sitter) at {}:{}",
1650                                location.uri, location.range.start.line
1651                            ),
1652                        )
1653                        .await;
1654                    return Ok(Some(GotoDefinitionResponse::from(location)));
1655                }
1656                self.client
1657                    .log_message(
1658                        MessageType::INFO,
1659                        "tree-sitter result failed validation, trying AST fallback",
1660                    )
1661                    .await;
1662            }
1663
1664            // Tree-sitter failed or didn't validate — try name-based AST lookup.
1665            // Instead of matching by byte offset (which is stale on dirty files),
1666            // search cached AST nodes whose source text matches the cursor name
1667            // and follow their referencedDeclaration.
1668            if let Some(ref cb) = cached_build
1669                && let Some(ref name) = cursor_name
1670            {
1671                let byte_hint = goto::pos_to_bytes(&source_bytes, position);
1672                if let Some(location) = goto::goto_declaration_by_name(cb, &uri, name, byte_hint) {
1673                    self.client
1674                        .log_message(
1675                            MessageType::INFO,
1676                            format!(
1677                                "found definition (AST by name) at {}:{}",
1678                                location.uri, location.range.start.line
1679                            ),
1680                        )
1681                        .await;
1682                    return Ok(Some(GotoDefinitionResponse::from(location)));
1683                }
1684            }
1685        } else {
1686            // CLEAN: AST first → tree-sitter fallback (validated)
1687            if let Some(ref cb) = cached_build
1688                && let Some(location) =
1689                    goto::goto_declaration_cached(cb, &uri, position, &source_bytes)
1690            {
1691                self.client
1692                    .log_message(
1693                        MessageType::INFO,
1694                        format!(
1695                            "found definition (AST) at {}:{}",
1696                            location.uri, location.range.start.line
1697                        ),
1698                    )
1699                    .await;
1700                return Ok(Some(GotoDefinitionResponse::from(location)));
1701            }
1702
1703            // AST couldn't resolve — try tree-sitter fallback (validated)
1704            let ts_result = {
1705                let comp_cache = self.completion_cache.read().await;
1706                let text_cache = self.text_cache.read().await;
1707                if let Some(cc) = comp_cache.get(&uri.to_string()) {
1708                    goto::goto_definition_ts(&source_text, position, &uri, cc, &text_cache)
1709                } else {
1710                    None
1711                }
1712            };
1713
1714            if let Some(location) = ts_result {
1715                if validate_ts(&location) {
1716                    self.client
1717                        .log_message(
1718                            MessageType::INFO,
1719                            format!(
1720                                "found definition (tree-sitter fallback) at {}:{}",
1721                                location.uri, location.range.start.line
1722                            ),
1723                        )
1724                        .await;
1725                    return Ok(Some(GotoDefinitionResponse::from(location)));
1726                }
1727                self.client
1728                    .log_message(MessageType::INFO, "tree-sitter fallback failed validation")
1729                    .await;
1730            }
1731        }
1732
1733        self.client
1734            .log_message(MessageType::INFO, "no definition found")
1735            .await;
1736        Ok(None)
1737    }
1738
1739    async fn goto_declaration(
1740        &self,
1741        params: request::GotoDeclarationParams,
1742    ) -> tower_lsp::jsonrpc::Result<Option<request::GotoDeclarationResponse>> {
1743        self.client
1744            .log_message(MessageType::INFO, "got textDocument/declaration request")
1745            .await;
1746
1747        let uri = params.text_document_position_params.text_document.uri;
1748        let position = params.text_document_position_params.position;
1749
1750        let file_path = match uri.to_file_path() {
1751            Ok(path) => path,
1752            Err(_) => {
1753                self.client
1754                    .log_message(MessageType::ERROR, "invalid file uri")
1755                    .await;
1756                return Ok(None);
1757            }
1758        };
1759
1760        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
1761            Some(bytes) => bytes,
1762            None => return Ok(None),
1763        };
1764
1765        let cached_build = self.get_or_fetch_build(&uri, &file_path, false).await;
1766        let cached_build = match cached_build {
1767            Some(cb) => cb,
1768            None => return Ok(None),
1769        };
1770
1771        if let Some(location) =
1772            goto::goto_declaration_cached(&cached_build, &uri, position, &source_bytes)
1773        {
1774            self.client
1775                .log_message(
1776                    MessageType::INFO,
1777                    format!(
1778                        "found declaration at {}:{}",
1779                        location.uri, location.range.start.line
1780                    ),
1781                )
1782                .await;
1783            Ok(Some(request::GotoDeclarationResponse::from(location)))
1784        } else {
1785            self.client
1786                .log_message(MessageType::INFO, "no declaration found")
1787                .await;
1788            Ok(None)
1789        }
1790    }
1791
1792    async fn references(
1793        &self,
1794        params: ReferenceParams,
1795    ) -> tower_lsp::jsonrpc::Result<Option<Vec<Location>>> {
1796        self.client
1797            .log_message(MessageType::INFO, "Got a textDocument/references request")
1798            .await;
1799
1800        let uri = params.text_document_position.text_document.uri;
1801        let position = params.text_document_position.position;
1802        let file_path = match uri.to_file_path() {
1803            Ok(path) => path,
1804            Err(_) => {
1805                self.client
1806                    .log_message(MessageType::ERROR, "Invalid file URI")
1807                    .await;
1808                return Ok(None);
1809            }
1810        };
1811        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
1812            Some(bytes) => bytes,
1813            None => return Ok(None),
1814        };
1815        let cached_build = self.get_or_fetch_build(&uri, &file_path, true).await;
1816        let cached_build = match cached_build {
1817            Some(cb) => cb,
1818            None => return Ok(None),
1819        };
1820
1821        // Get references from the current file's AST — uses pre-built indices
1822        let mut locations = references::goto_references_cached(
1823            &cached_build,
1824            &uri,
1825            position,
1826            &source_bytes,
1827            None,
1828            params.context.include_declaration,
1829        );
1830
1831        // Cross-file: resolve target definition location, then scan other cached ASTs
1832        if let Some((def_abs_path, def_byte_offset)) =
1833            references::resolve_target_location(&cached_build, &uri, position, &source_bytes)
1834        {
1835            let cache = self.ast_cache.read().await;
1836            for (cached_uri, other_build) in cache.iter() {
1837                if *cached_uri == uri.to_string() {
1838                    continue;
1839                }
1840                let other_locations = references::goto_references_for_target(
1841                    other_build,
1842                    &def_abs_path,
1843                    def_byte_offset,
1844                    None,
1845                    params.context.include_declaration,
1846                );
1847                locations.extend(other_locations);
1848            }
1849        }
1850
1851        // Deduplicate across all caches
1852        let mut seen = std::collections::HashSet::new();
1853        locations.retain(|loc| {
1854            seen.insert((
1855                loc.uri.clone(),
1856                loc.range.start.line,
1857                loc.range.start.character,
1858                loc.range.end.line,
1859                loc.range.end.character,
1860            ))
1861        });
1862
1863        if locations.is_empty() {
1864            self.client
1865                .log_message(MessageType::INFO, "No references found")
1866                .await;
1867            Ok(None)
1868        } else {
1869            self.client
1870                .log_message(
1871                    MessageType::INFO,
1872                    format!("Found {} references", locations.len()),
1873                )
1874                .await;
1875            Ok(Some(locations))
1876        }
1877    }
1878
1879    async fn prepare_rename(
1880        &self,
1881        params: TextDocumentPositionParams,
1882    ) -> tower_lsp::jsonrpc::Result<Option<PrepareRenameResponse>> {
1883        self.client
1884            .log_message(MessageType::INFO, "got textDocument/prepareRename request")
1885            .await;
1886
1887        let uri = params.text_document.uri;
1888        let position = params.position;
1889
1890        let file_path = match uri.to_file_path() {
1891            Ok(path) => path,
1892            Err(_) => {
1893                self.client
1894                    .log_message(MessageType::ERROR, "invalid file uri")
1895                    .await;
1896                return Ok(None);
1897            }
1898        };
1899
1900        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
1901            Some(bytes) => bytes,
1902            None => return Ok(None),
1903        };
1904
1905        if let Some(range) = rename::get_identifier_range(&source_bytes, position) {
1906            self.client
1907                .log_message(
1908                    MessageType::INFO,
1909                    format!(
1910                        "prepare rename range: {}:{}",
1911                        range.start.line, range.start.character
1912                    ),
1913                )
1914                .await;
1915            Ok(Some(PrepareRenameResponse::Range(range)))
1916        } else {
1917            self.client
1918                .log_message(MessageType::INFO, "no identifier found for prepare rename")
1919                .await;
1920            Ok(None)
1921        }
1922    }
1923
1924    async fn rename(
1925        &self,
1926        params: RenameParams,
1927    ) -> tower_lsp::jsonrpc::Result<Option<WorkspaceEdit>> {
1928        self.client
1929            .log_message(MessageType::INFO, "got textDocument/rename request")
1930            .await;
1931
1932        let uri = params.text_document_position.text_document.uri;
1933        let position = params.text_document_position.position;
1934        let new_name = params.new_name;
1935        let file_path = match uri.to_file_path() {
1936            Ok(p) => p,
1937            Err(_) => {
1938                self.client
1939                    .log_message(MessageType::ERROR, "invalid file uri")
1940                    .await;
1941                return Ok(None);
1942            }
1943        };
1944        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
1945            Some(bytes) => bytes,
1946            None => return Ok(None),
1947        };
1948
1949        let current_identifier = match rename::get_identifier_at_position(&source_bytes, position) {
1950            Some(id) => id,
1951            None => {
1952                self.client
1953                    .log_message(MessageType::ERROR, "No identifier found at position")
1954                    .await;
1955                return Ok(None);
1956            }
1957        };
1958
1959        if !utils::is_valid_solidity_identifier(&new_name) {
1960            return Err(tower_lsp::jsonrpc::Error::invalid_params(
1961                "new name is not a valid solidity identifier",
1962            ));
1963        }
1964
1965        if new_name == current_identifier {
1966            self.client
1967                .log_message(
1968                    MessageType::INFO,
1969                    "new name is the same as current identifier",
1970                )
1971                .await;
1972            return Ok(None);
1973        }
1974
1975        let cached_build = self.get_or_fetch_build(&uri, &file_path, false).await;
1976        let cached_build = match cached_build {
1977            Some(cb) => cb,
1978            None => return Ok(None),
1979        };
1980        let other_builds: Vec<Arc<goto::CachedBuild>> = {
1981            let cache = self.ast_cache.read().await;
1982            cache
1983                .iter()
1984                .filter(|(key, _)| **key != uri.to_string())
1985                .map(|(_, v)| v.clone())
1986                .collect()
1987        };
1988        let other_refs: Vec<&goto::CachedBuild> = other_builds.iter().map(|v| v.as_ref()).collect();
1989
1990        // Build a map of URI → file content from the text_cache so rename
1991        // verification reads from in-memory buffers (unsaved edits) instead
1992        // of from disk.
1993        let text_buffers: HashMap<String, Vec<u8>> = {
1994            let text_cache = self.text_cache.read().await;
1995            text_cache
1996                .iter()
1997                .map(|(uri, (_, content))| (uri.clone(), content.as_bytes().to_vec()))
1998                .collect()
1999        };
2000
2001        match rename::rename_symbol(
2002            &cached_build,
2003            &uri,
2004            position,
2005            &source_bytes,
2006            new_name,
2007            &other_refs,
2008            &text_buffers,
2009        ) {
2010            Some(workspace_edit) => {
2011                self.client
2012                    .log_message(
2013                        MessageType::INFO,
2014                        format!(
2015                            "created rename edit with {} file(s), {} total change(s)",
2016                            workspace_edit
2017                                .changes
2018                                .as_ref()
2019                                .map(|c| c.len())
2020                                .unwrap_or(0),
2021                            workspace_edit
2022                                .changes
2023                                .as_ref()
2024                                .map(|c| c.values().map(|v| v.len()).sum::<usize>())
2025                                .unwrap_or(0)
2026                        ),
2027                    )
2028                    .await;
2029
2030                // Return the full WorkspaceEdit to the client so the editor
2031                // applies all changes (including cross-file renames) via the
2032                // LSP protocol. This keeps undo working and avoids writing
2033                // files behind the editor's back.
2034                Ok(Some(workspace_edit))
2035            }
2036
2037            None => {
2038                self.client
2039                    .log_message(MessageType::INFO, "No locations found for renaming")
2040                    .await;
2041                Ok(None)
2042            }
2043        }
2044    }
2045
2046    async fn symbol(
2047        &self,
2048        params: WorkspaceSymbolParams,
2049    ) -> tower_lsp::jsonrpc::Result<Option<Vec<SymbolInformation>>> {
2050        self.client
2051            .log_message(MessageType::INFO, "got workspace/symbol request")
2052            .await;
2053
2054        // Collect sources from open files in text_cache
2055        let files: Vec<(Url, String)> = {
2056            let cache = self.text_cache.read().await;
2057            cache
2058                .iter()
2059                .filter(|(uri_str, _)| uri_str.ends_with(".sol"))
2060                .filter_map(|(uri_str, (_, content))| {
2061                    Url::parse(uri_str).ok().map(|uri| (uri, content.clone()))
2062                })
2063                .collect()
2064        };
2065
2066        let mut all_symbols = symbols::extract_workspace_symbols(&files);
2067        if !params.query.is_empty() {
2068            let query = params.query.to_lowercase();
2069            all_symbols.retain(|symbol| symbol.name.to_lowercase().contains(&query));
2070        }
2071        if all_symbols.is_empty() {
2072            self.client
2073                .log_message(MessageType::INFO, "No symbols found")
2074                .await;
2075            Ok(None)
2076        } else {
2077            self.client
2078                .log_message(
2079                    MessageType::INFO,
2080                    format!("found {} symbols", all_symbols.len()),
2081                )
2082                .await;
2083            Ok(Some(all_symbols))
2084        }
2085    }
2086
2087    async fn document_symbol(
2088        &self,
2089        params: DocumentSymbolParams,
2090    ) -> tower_lsp::jsonrpc::Result<Option<DocumentSymbolResponse>> {
2091        self.client
2092            .log_message(MessageType::INFO, "got textDocument/documentSymbol request")
2093            .await;
2094        let uri = params.text_document.uri;
2095        let file_path = match uri.to_file_path() {
2096            Ok(path) => path,
2097            Err(_) => {
2098                self.client
2099                    .log_message(MessageType::ERROR, "invalid file uri")
2100                    .await;
2101                return Ok(None);
2102            }
2103        };
2104
2105        // Read source from text_cache (open files) or disk
2106        let source = {
2107            let cache = self.text_cache.read().await;
2108            cache
2109                .get(&uri.to_string())
2110                .map(|(_, content)| content.clone())
2111        };
2112        let source = match source {
2113            Some(s) => s,
2114            None => match std::fs::read_to_string(&file_path) {
2115                Ok(s) => s,
2116                Err(_) => return Ok(None),
2117            },
2118        };
2119
2120        let symbols = symbols::extract_document_symbols(&source);
2121        if symbols.is_empty() {
2122            self.client
2123                .log_message(MessageType::INFO, "no document symbols found")
2124                .await;
2125            Ok(None)
2126        } else {
2127            self.client
2128                .log_message(
2129                    MessageType::INFO,
2130                    format!("found {} document symbols", symbols.len()),
2131                )
2132                .await;
2133            Ok(Some(DocumentSymbolResponse::Nested(symbols)))
2134        }
2135    }
2136
2137    async fn document_highlight(
2138        &self,
2139        params: DocumentHighlightParams,
2140    ) -> tower_lsp::jsonrpc::Result<Option<Vec<DocumentHighlight>>> {
2141        self.client
2142            .log_message(
2143                MessageType::INFO,
2144                "got textDocument/documentHighlight request",
2145            )
2146            .await;
2147
2148        let uri = params.text_document_position_params.text_document.uri;
2149        let position = params.text_document_position_params.position;
2150
2151        let source = {
2152            let cache = self.text_cache.read().await;
2153            cache.get(&uri.to_string()).map(|(_, s)| s.clone())
2154        };
2155
2156        let source = match source {
2157            Some(s) => s,
2158            None => {
2159                let file_path = match uri.to_file_path() {
2160                    Ok(p) => p,
2161                    Err(_) => return Ok(None),
2162                };
2163                match std::fs::read_to_string(&file_path) {
2164                    Ok(s) => s,
2165                    Err(_) => return Ok(None),
2166                }
2167            }
2168        };
2169
2170        let highlights = highlight::document_highlights(&source, position);
2171
2172        if highlights.is_empty() {
2173            self.client
2174                .log_message(MessageType::INFO, "no document highlights found")
2175                .await;
2176            Ok(None)
2177        } else {
2178            self.client
2179                .log_message(
2180                    MessageType::INFO,
2181                    format!("found {} document highlights", highlights.len()),
2182                )
2183                .await;
2184            Ok(Some(highlights))
2185        }
2186    }
2187
2188    async fn hover(&self, params: HoverParams) -> tower_lsp::jsonrpc::Result<Option<Hover>> {
2189        self.client
2190            .log_message(MessageType::INFO, "got textDocument/hover request")
2191            .await;
2192
2193        let uri = params.text_document_position_params.text_document.uri;
2194        let position = params.text_document_position_params.position;
2195
2196        let file_path = match uri.to_file_path() {
2197            Ok(path) => path,
2198            Err(_) => {
2199                self.client
2200                    .log_message(MessageType::ERROR, "invalid file uri")
2201                    .await;
2202                return Ok(None);
2203            }
2204        };
2205
2206        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
2207            Some(bytes) => bytes,
2208            None => return Ok(None),
2209        };
2210
2211        let cached_build = self.get_or_fetch_build(&uri, &file_path, false).await;
2212        let cached_build = match cached_build {
2213            Some(cb) => cb,
2214            None => return Ok(None),
2215        };
2216
2217        let result = hover::hover_info(&cached_build, &uri, position, &source_bytes);
2218
2219        if result.is_some() {
2220            self.client
2221                .log_message(MessageType::INFO, "hover info found")
2222                .await;
2223        } else {
2224            self.client
2225                .log_message(MessageType::INFO, "no hover info found")
2226                .await;
2227        }
2228
2229        Ok(result)
2230    }
2231
2232    async fn signature_help(
2233        &self,
2234        params: SignatureHelpParams,
2235    ) -> tower_lsp::jsonrpc::Result<Option<SignatureHelp>> {
2236        self.client
2237            .log_message(MessageType::INFO, "got textDocument/signatureHelp request")
2238            .await;
2239
2240        let uri = params.text_document_position_params.text_document.uri;
2241        let position = params.text_document_position_params.position;
2242
2243        let file_path = match uri.to_file_path() {
2244            Ok(path) => path,
2245            Err(_) => {
2246                self.client
2247                    .log_message(MessageType::ERROR, "invalid file uri")
2248                    .await;
2249                return Ok(None);
2250            }
2251        };
2252
2253        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
2254            Some(bytes) => bytes,
2255            None => return Ok(None),
2256        };
2257
2258        let cached_build = self.get_or_fetch_build(&uri, &file_path, false).await;
2259        let cached_build = match cached_build {
2260            Some(cb) => cb,
2261            None => return Ok(None),
2262        };
2263
2264        let result = hover::signature_help(&cached_build, &source_bytes, position);
2265
2266        Ok(result)
2267    }
2268
2269    async fn document_link(
2270        &self,
2271        params: DocumentLinkParams,
2272    ) -> tower_lsp::jsonrpc::Result<Option<Vec<DocumentLink>>> {
2273        self.client
2274            .log_message(MessageType::INFO, "got textDocument/documentLink request")
2275            .await;
2276
2277        let uri = params.text_document.uri;
2278        let file_path = match uri.to_file_path() {
2279            Ok(path) => path,
2280            Err(_) => {
2281                self.client
2282                    .log_message(MessageType::ERROR, "invalid file uri")
2283                    .await;
2284                return Ok(None);
2285            }
2286        };
2287
2288        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
2289            Some(bytes) => bytes,
2290            None => return Ok(None),
2291        };
2292
2293        let cached_build = self.get_or_fetch_build(&uri, &file_path, false).await;
2294        let cached_build = match cached_build {
2295            Some(cb) => cb,
2296            None => return Ok(None),
2297        };
2298
2299        let result = links::document_links(&cached_build, &uri, &source_bytes);
2300
2301        if result.is_empty() {
2302            self.client
2303                .log_message(MessageType::INFO, "no document links found")
2304                .await;
2305            Ok(None)
2306        } else {
2307            self.client
2308                .log_message(
2309                    MessageType::INFO,
2310                    format!("found {} document links", result.len()),
2311                )
2312                .await;
2313            Ok(Some(result))
2314        }
2315    }
2316
2317    async fn semantic_tokens_full(
2318        &self,
2319        params: SemanticTokensParams,
2320    ) -> tower_lsp::jsonrpc::Result<Option<SemanticTokensResult>> {
2321        self.client
2322            .log_message(
2323                MessageType::INFO,
2324                "got textDocument/semanticTokens/full request",
2325            )
2326            .await;
2327
2328        let uri = params.text_document.uri;
2329        let source = {
2330            let cache = self.text_cache.read().await;
2331            cache.get(&uri.to_string()).map(|(_, s)| s.clone())
2332        };
2333
2334        let source = match source {
2335            Some(s) => s,
2336            None => {
2337                // File not open in editor — try reading from disk
2338                let file_path = match uri.to_file_path() {
2339                    Ok(p) => p,
2340                    Err(_) => return Ok(None),
2341                };
2342                match std::fs::read_to_string(&file_path) {
2343                    Ok(s) => s,
2344                    Err(_) => return Ok(None),
2345                }
2346            }
2347        };
2348
2349        let mut tokens = semantic_tokens::semantic_tokens_full(&source);
2350
2351        // Generate a unique result_id and cache the tokens for delta requests
2352        let id = self.semantic_token_id.fetch_add(1, Ordering::Relaxed);
2353        let result_id = id.to_string();
2354        tokens.result_id = Some(result_id.clone());
2355
2356        {
2357            let mut cache = self.semantic_token_cache.write().await;
2358            cache.insert(uri.to_string(), (result_id, tokens.data.clone()));
2359        }
2360
2361        Ok(Some(SemanticTokensResult::Tokens(tokens)))
2362    }
2363
2364    async fn semantic_tokens_range(
2365        &self,
2366        params: SemanticTokensRangeParams,
2367    ) -> tower_lsp::jsonrpc::Result<Option<SemanticTokensRangeResult>> {
2368        self.client
2369            .log_message(
2370                MessageType::INFO,
2371                "got textDocument/semanticTokens/range request",
2372            )
2373            .await;
2374
2375        let uri = params.text_document.uri;
2376        let range = params.range;
2377        let source = {
2378            let cache = self.text_cache.read().await;
2379            cache.get(&uri.to_string()).map(|(_, s)| s.clone())
2380        };
2381
2382        let source = match source {
2383            Some(s) => s,
2384            None => {
2385                let file_path = match uri.to_file_path() {
2386                    Ok(p) => p,
2387                    Err(_) => return Ok(None),
2388                };
2389                match std::fs::read_to_string(&file_path) {
2390                    Ok(s) => s,
2391                    Err(_) => return Ok(None),
2392                }
2393            }
2394        };
2395
2396        let tokens =
2397            semantic_tokens::semantic_tokens_range(&source, range.start.line, range.end.line);
2398
2399        Ok(Some(SemanticTokensRangeResult::Tokens(tokens)))
2400    }
2401
2402    async fn semantic_tokens_full_delta(
2403        &self,
2404        params: SemanticTokensDeltaParams,
2405    ) -> tower_lsp::jsonrpc::Result<Option<SemanticTokensFullDeltaResult>> {
2406        self.client
2407            .log_message(
2408                MessageType::INFO,
2409                "got textDocument/semanticTokens/full/delta request",
2410            )
2411            .await;
2412
2413        let uri = params.text_document.uri;
2414        let previous_result_id = params.previous_result_id;
2415
2416        let source = {
2417            let cache = self.text_cache.read().await;
2418            cache.get(&uri.to_string()).map(|(_, s)| s.clone())
2419        };
2420
2421        let source = match source {
2422            Some(s) => s,
2423            None => {
2424                let file_path = match uri.to_file_path() {
2425                    Ok(p) => p,
2426                    Err(_) => return Ok(None),
2427                };
2428                match std::fs::read_to_string(&file_path) {
2429                    Ok(s) => s,
2430                    Err(_) => return Ok(None),
2431                }
2432            }
2433        };
2434
2435        let mut new_tokens = semantic_tokens::semantic_tokens_full(&source);
2436
2437        // Generate a new result_id
2438        let id = self.semantic_token_id.fetch_add(1, Ordering::Relaxed);
2439        let new_result_id = id.to_string();
2440        new_tokens.result_id = Some(new_result_id.clone());
2441
2442        let uri_str = uri.to_string();
2443
2444        // Look up the previous tokens by result_id
2445        let old_tokens = {
2446            let cache = self.semantic_token_cache.read().await;
2447            cache
2448                .get(&uri_str)
2449                .filter(|(rid, _)| *rid == previous_result_id)
2450                .map(|(_, tokens)| tokens.clone())
2451        };
2452
2453        // Update the cache with the new tokens
2454        {
2455            let mut cache = self.semantic_token_cache.write().await;
2456            cache.insert(uri_str, (new_result_id.clone(), new_tokens.data.clone()));
2457        }
2458
2459        match old_tokens {
2460            Some(old) => {
2461                // Compute delta
2462                let edits = semantic_tokens::compute_delta(&old, &new_tokens.data);
2463                Ok(Some(SemanticTokensFullDeltaResult::TokensDelta(
2464                    SemanticTokensDelta {
2465                        result_id: Some(new_result_id),
2466                        edits,
2467                    },
2468                )))
2469            }
2470            None => {
2471                // No cached previous — fall back to full response
2472                Ok(Some(SemanticTokensFullDeltaResult::Tokens(new_tokens)))
2473            }
2474        }
2475    }
2476
2477    async fn folding_range(
2478        &self,
2479        params: FoldingRangeParams,
2480    ) -> tower_lsp::jsonrpc::Result<Option<Vec<FoldingRange>>> {
2481        self.client
2482            .log_message(MessageType::INFO, "got textDocument/foldingRange request")
2483            .await;
2484
2485        let uri = params.text_document.uri;
2486
2487        let source = {
2488            let cache = self.text_cache.read().await;
2489            cache.get(&uri.to_string()).map(|(_, s)| s.clone())
2490        };
2491
2492        let source = match source {
2493            Some(s) => s,
2494            None => {
2495                let file_path = match uri.to_file_path() {
2496                    Ok(p) => p,
2497                    Err(_) => return Ok(None),
2498                };
2499                match std::fs::read_to_string(&file_path) {
2500                    Ok(s) => s,
2501                    Err(_) => return Ok(None),
2502                }
2503            }
2504        };
2505
2506        let ranges = folding::folding_ranges(&source);
2507
2508        if ranges.is_empty() {
2509            self.client
2510                .log_message(MessageType::INFO, "no folding ranges found")
2511                .await;
2512            Ok(None)
2513        } else {
2514            self.client
2515                .log_message(
2516                    MessageType::INFO,
2517                    format!("found {} folding ranges", ranges.len()),
2518                )
2519                .await;
2520            Ok(Some(ranges))
2521        }
2522    }
2523
2524    async fn selection_range(
2525        &self,
2526        params: SelectionRangeParams,
2527    ) -> tower_lsp::jsonrpc::Result<Option<Vec<SelectionRange>>> {
2528        self.client
2529            .log_message(MessageType::INFO, "got textDocument/selectionRange request")
2530            .await;
2531
2532        let uri = params.text_document.uri;
2533
2534        let source = {
2535            let cache = self.text_cache.read().await;
2536            cache.get(&uri.to_string()).map(|(_, s)| s.clone())
2537        };
2538
2539        let source = match source {
2540            Some(s) => s,
2541            None => {
2542                let file_path = match uri.to_file_path() {
2543                    Ok(p) => p,
2544                    Err(_) => return Ok(None),
2545                };
2546                match std::fs::read_to_string(&file_path) {
2547                    Ok(s) => s,
2548                    Err(_) => return Ok(None),
2549                }
2550            }
2551        };
2552
2553        let ranges = selection::selection_ranges(&source, &params.positions);
2554
2555        if ranges.is_empty() {
2556            self.client
2557                .log_message(MessageType::INFO, "no selection ranges found")
2558                .await;
2559            Ok(None)
2560        } else {
2561            self.client
2562                .log_message(
2563                    MessageType::INFO,
2564                    format!("found {} selection ranges", ranges.len()),
2565                )
2566                .await;
2567            Ok(Some(ranges))
2568        }
2569    }
2570
2571    async fn inlay_hint(
2572        &self,
2573        params: InlayHintParams,
2574    ) -> tower_lsp::jsonrpc::Result<Option<Vec<InlayHint>>> {
2575        self.client
2576            .log_message(MessageType::INFO, "got textDocument/inlayHint request")
2577            .await;
2578
2579        let uri = params.text_document.uri;
2580        let range = params.range;
2581
2582        let file_path = match uri.to_file_path() {
2583            Ok(path) => path,
2584            Err(_) => {
2585                self.client
2586                    .log_message(MessageType::ERROR, "invalid file uri")
2587                    .await;
2588                return Ok(None);
2589            }
2590        };
2591
2592        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
2593            Some(bytes) => bytes,
2594            None => return Ok(None),
2595        };
2596
2597        let cached_build = self.get_or_fetch_build(&uri, &file_path, false).await;
2598        let cached_build = match cached_build {
2599            Some(cb) => cb,
2600            None => return Ok(None),
2601        };
2602
2603        let mut hints = inlay_hints::inlay_hints(&cached_build, &uri, range, &source_bytes);
2604
2605        // Filter hints based on settings.
2606        let settings = self.settings.read().await;
2607        if !settings.inlay_hints.parameters {
2608            hints.retain(|h| h.kind != Some(InlayHintKind::PARAMETER));
2609        }
2610        if !settings.inlay_hints.gas_estimates {
2611            hints.retain(|h| h.kind != Some(InlayHintKind::TYPE));
2612        }
2613
2614        if hints.is_empty() {
2615            self.client
2616                .log_message(MessageType::INFO, "no inlay hints found")
2617                .await;
2618            Ok(None)
2619        } else {
2620            self.client
2621                .log_message(
2622                    MessageType::INFO,
2623                    format!("found {} inlay hints", hints.len()),
2624                )
2625                .await;
2626            Ok(Some(hints))
2627        }
2628    }
2629
2630    async fn will_rename_files(
2631        &self,
2632        params: RenameFilesParams,
2633    ) -> tower_lsp::jsonrpc::Result<Option<WorkspaceEdit>> {
2634        self.client
2635            .log_message(
2636                MessageType::INFO,
2637                format!("workspace/willRenameFiles: {} file(s)", params.files.len()),
2638            )
2639            .await;
2640        if !self
2641            .settings
2642            .read()
2643            .await
2644            .file_operations
2645            .update_imports_on_rename
2646        {
2647            self.client
2648                .log_message(
2649                    MessageType::INFO,
2650                    "willRenameFiles: updateImportsOnRename disabled",
2651                )
2652                .await;
2653            return Ok(None);
2654        }
2655
2656        // ── Phase 1: discover source files (blocking I/O) ──────────────
2657        let config = self.foundry_config.read().await.clone();
2658        let project_root = config.root.clone();
2659        let source_files: Vec<String> = tokio::task::spawn_blocking(move || {
2660            crate::solc::discover_source_files(&config)
2661                .into_iter()
2662                .filter_map(|p| p.to_str().map(String::from))
2663                .collect()
2664        })
2665        .await
2666        .unwrap_or_default();
2667
2668        if source_files.is_empty() {
2669            self.client
2670                .log_message(
2671                    MessageType::WARNING,
2672                    "willRenameFiles: no source files found",
2673                )
2674                .await;
2675            return Ok(None);
2676        }
2677
2678        // ── Phase 2: parse rename params & expand folders ──────────────
2679        let raw_renames: Vec<(std::path::PathBuf, std::path::PathBuf)> = params
2680            .files
2681            .iter()
2682            .filter_map(|fr| {
2683                let old_uri = Url::parse(&fr.old_uri).ok()?;
2684                let new_uri = Url::parse(&fr.new_uri).ok()?;
2685                let old_path = old_uri.to_file_path().ok()?;
2686                let new_path = new_uri.to_file_path().ok()?;
2687                Some((old_path, new_path))
2688            })
2689            .collect();
2690
2691        let renames = file_operations::expand_folder_renames(&raw_renames, &source_files);
2692
2693        if renames.is_empty() {
2694            return Ok(None);
2695        }
2696
2697        self.client
2698            .log_message(
2699                MessageType::INFO,
2700                format!(
2701                    "willRenameFiles: {} rename(s) after folder expansion",
2702                    renames.len()
2703                ),
2704            )
2705            .await;
2706
2707        // ── Phase 3: hydrate text_cache (blocking I/O) ─────────────────
2708        // Collect which files need reading from disk (not already in cache).
2709        let files_to_read: Vec<(String, String)> = {
2710            let tc = self.text_cache.read().await;
2711            source_files
2712                .iter()
2713                .filter_map(|fs_path| {
2714                    let uri = Url::from_file_path(fs_path).ok()?;
2715                    let uri_str = uri.to_string();
2716                    if tc.contains_key(&uri_str) {
2717                        None
2718                    } else {
2719                        Some((uri_str, fs_path.clone()))
2720                    }
2721                })
2722                .collect()
2723        };
2724
2725        if !files_to_read.is_empty() {
2726            let loaded: Vec<(String, String)> = tokio::task::spawn_blocking(move || {
2727                files_to_read
2728                    .into_iter()
2729                    .filter_map(|(uri_str, fs_path)| {
2730                        let content = std::fs::read_to_string(&fs_path).ok()?;
2731                        Some((uri_str, content))
2732                    })
2733                    .collect()
2734            })
2735            .await
2736            .unwrap_or_default();
2737
2738            let mut tc = self.text_cache.write().await;
2739            for (uri_str, content) in loaded {
2740                tc.entry(uri_str).or_insert((0, content));
2741            }
2742        }
2743
2744        // ── Phase 4: compute edits (pure, no I/O) ──────────────────────
2745        // Build source-bytes provider that reads from the cache held behind
2746        // the Arc<RwLock>.  We hold a read guard only for the duration of
2747        // each lookup, not for the full computation.
2748        let text_cache = self.text_cache.clone();
2749        let result = {
2750            let tc = text_cache.read().await;
2751            let get_source_bytes = |fs_path: &str| -> Option<Vec<u8>> {
2752                let uri = Url::from_file_path(fs_path).ok()?;
2753                let (_, content) = tc.get(&uri.to_string())?;
2754                Some(content.as_bytes().to_vec())
2755            };
2756
2757            file_operations::rename_imports(
2758                &source_files,
2759                &renames,
2760                &project_root,
2761                &get_source_bytes,
2762            )
2763        };
2764
2765        // ── Phase 5: log diagnostics ───────────────────────────────────
2766        let stats = &result.stats;
2767        if stats.read_failures > 0 || stats.pathdiff_failures > 0 || stats.duplicate_renames > 0 {
2768            self.client
2769                .log_message(
2770                    MessageType::WARNING,
2771                    format!(
2772                        "willRenameFiles stats: read_failures={}, pathdiff_failures={}, \
2773                         duplicate_renames={}, no_parent={}, no_op_skips={}, dedup_skips={}",
2774                        stats.read_failures,
2775                        stats.pathdiff_failures,
2776                        stats.duplicate_renames,
2777                        stats.no_parent,
2778                        stats.no_op_skips,
2779                        stats.dedup_skips,
2780                    ),
2781                )
2782                .await;
2783        }
2784
2785        let all_edits = result.edits;
2786
2787        if all_edits.is_empty() {
2788            self.client
2789                .log_message(MessageType::INFO, "willRenameFiles: no import edits needed")
2790                .await;
2791            return Ok(None);
2792        }
2793
2794        // ── Phase 6: patch own text_cache ──────────────────────────────
2795        {
2796            let mut tc = self.text_cache.write().await;
2797            let patched = file_operations::apply_edits_to_cache(&all_edits, &mut tc);
2798            self.client
2799                .log_message(
2800                    MessageType::INFO,
2801                    format!("willRenameFiles: patched {} cached file(s)", patched),
2802                )
2803                .await;
2804        }
2805
2806        let total_edits: usize = all_edits.values().map(|v| v.len()).sum();
2807        self.client
2808            .log_message(
2809                MessageType::INFO,
2810                format!(
2811                    "willRenameFiles: {} edit(s) across {} file(s)",
2812                    total_edits,
2813                    all_edits.len()
2814                ),
2815            )
2816            .await;
2817
2818        Ok(Some(WorkspaceEdit {
2819            changes: Some(all_edits),
2820            document_changes: None,
2821            change_annotations: None,
2822        }))
2823    }
2824
2825    async fn did_rename_files(&self, params: RenameFilesParams) {
2826        self.client
2827            .log_message(
2828                MessageType::INFO,
2829                format!("workspace/didRenameFiles: {} file(s)", params.files.len()),
2830            )
2831            .await;
2832
2833        // ── Phase 1: parse params & expand folder renames ──────────────
2834        let raw_uri_pairs: Vec<(Url, Url)> = params
2835            .files
2836            .iter()
2837            .filter_map(|fr| {
2838                let old_uri = Url::parse(&fr.old_uri).ok()?;
2839                let new_uri = Url::parse(&fr.new_uri).ok()?;
2840                Some((old_uri, new_uri))
2841            })
2842            .collect();
2843
2844        let file_renames = {
2845            let tc = self.text_cache.read().await;
2846            let cache_paths: Vec<std::path::PathBuf> = tc
2847                .keys()
2848                .filter_map(|k| Url::parse(k).ok())
2849                .filter_map(|u| u.to_file_path().ok())
2850                .collect();
2851            drop(tc);
2852
2853            // Include discovered project files so folder renames also migrate
2854            // entries that aren't currently present in text_cache.
2855            let cfg = self.foundry_config.read().await.clone();
2856            let discovered_paths =
2857                tokio::task::spawn_blocking(move || crate::solc::discover_source_files(&cfg))
2858                    .await
2859                    .unwrap_or_default();
2860
2861            let mut all_paths: HashSet<std::path::PathBuf> = discovered_paths.into_iter().collect();
2862            all_paths.extend(cache_paths);
2863            let all_paths: Vec<std::path::PathBuf> = all_paths.into_iter().collect();
2864
2865            file_operations::expand_folder_renames_from_paths(&raw_uri_pairs, &all_paths)
2866        };
2867
2868        self.client
2869            .log_message(
2870                MessageType::INFO,
2871                format!(
2872                    "didRenameFiles: migrating {} cache entry/entries",
2873                    file_renames.len()
2874                ),
2875            )
2876            .await;
2877
2878        // ── Phase 2: migrate per-file caches ───────────────────────────
2879        // Take a single write lock per cache type and do all migrations
2880        // in one pass (avoids repeated lock/unlock per file).
2881        {
2882            let mut tc = self.text_cache.write().await;
2883            for (old_key, new_key) in &file_renames {
2884                if let Some(entry) = tc.remove(old_key) {
2885                    tc.insert(new_key.clone(), entry);
2886                }
2887            }
2888        }
2889        {
2890            let mut ac = self.ast_cache.write().await;
2891            for (old_key, _) in &file_renames {
2892                ac.remove(old_key);
2893            }
2894        }
2895        {
2896            let mut cc = self.completion_cache.write().await;
2897            for (old_key, _) in &file_renames {
2898                cc.remove(old_key);
2899            }
2900        }
2901
2902        // Invalidate the project index cache and rebuild so subsequent
2903        // willRenameFiles requests see the updated file layout.
2904        let root_key = self.root_uri.read().await.as_ref().map(|u| u.to_string());
2905        if let Some(ref key) = root_key {
2906            self.ast_cache.write().await.remove(key);
2907        }
2908
2909        let foundry_config = self.foundry_config.read().await.clone();
2910        let ast_cache = self.ast_cache.clone();
2911        let client = self.client.clone();
2912        // Snapshot text_cache so the re-index uses in-memory content
2913        // (with updated import paths from willRenameFiles) rather than
2914        // reading from disk where files may not yet reflect the edits.
2915        let text_cache_snapshot = self.text_cache.read().await.clone();
2916
2917        tokio::spawn(async move {
2918            let Some(cache_key) = root_key else {
2919                return;
2920            };
2921            match crate::solc::solc_project_index(
2922                &foundry_config,
2923                Some(&client),
2924                Some(&text_cache_snapshot),
2925            )
2926            .await
2927            {
2928                Ok(ast_data) => {
2929                    let cached_build = Arc::new(crate::goto::CachedBuild::new(ast_data, 0));
2930                    let source_count = cached_build.nodes.len();
2931                    ast_cache.write().await.insert(cache_key, cached_build);
2932                    client
2933                        .log_message(
2934                            MessageType::INFO,
2935                            format!("didRenameFiles: re-indexed {} source files", source_count),
2936                        )
2937                        .await;
2938                }
2939                Err(e) => {
2940                    client
2941                        .log_message(
2942                            MessageType::WARNING,
2943                            format!("didRenameFiles: re-index failed: {e}"),
2944                        )
2945                        .await;
2946                }
2947            }
2948        });
2949    }
2950
2951    async fn will_delete_files(
2952        &self,
2953        params: DeleteFilesParams,
2954    ) -> tower_lsp::jsonrpc::Result<Option<WorkspaceEdit>> {
2955        self.client
2956            .log_message(
2957                MessageType::INFO,
2958                format!("workspace/willDeleteFiles: {} file(s)", params.files.len()),
2959            )
2960            .await;
2961        if !update_imports_on_delete_enabled(&*self.settings.read().await) {
2962            self.client
2963                .log_message(
2964                    MessageType::INFO,
2965                    "willDeleteFiles: updateImportsOnDelete disabled",
2966                )
2967                .await;
2968            return Ok(None);
2969        }
2970
2971        let config = self.foundry_config.read().await.clone();
2972        let project_root = config.root.clone();
2973        let source_files: Vec<String> = tokio::task::spawn_blocking(move || {
2974            crate::solc::discover_source_files(&config)
2975                .into_iter()
2976                .filter_map(|p| p.to_str().map(String::from))
2977                .collect()
2978        })
2979        .await
2980        .unwrap_or_default();
2981
2982        if source_files.is_empty() {
2983            self.client
2984                .log_message(
2985                    MessageType::WARNING,
2986                    "willDeleteFiles: no source files found",
2987                )
2988                .await;
2989            return Ok(None);
2990        }
2991
2992        let raw_deletes: Vec<std::path::PathBuf> = params
2993            .files
2994            .iter()
2995            .filter_map(|fd| Url::parse(&fd.uri).ok())
2996            .filter_map(|u| u.to_file_path().ok())
2997            .collect();
2998
2999        let deletes = file_operations::expand_folder_deletes(&raw_deletes, &source_files);
3000        if deletes.is_empty() {
3001            return Ok(None);
3002        }
3003
3004        self.client
3005            .log_message(
3006                MessageType::INFO,
3007                format!(
3008                    "willDeleteFiles: {} delete target(s) after folder expansion",
3009                    deletes.len()
3010                ),
3011            )
3012            .await;
3013
3014        let files_to_read: Vec<(String, String)> = {
3015            let tc = self.text_cache.read().await;
3016            source_files
3017                .iter()
3018                .filter_map(|fs_path| {
3019                    let uri = Url::from_file_path(fs_path).ok()?;
3020                    let uri_str = uri.to_string();
3021                    if tc.contains_key(&uri_str) {
3022                        None
3023                    } else {
3024                        Some((uri_str, fs_path.clone()))
3025                    }
3026                })
3027                .collect()
3028        };
3029
3030        if !files_to_read.is_empty() {
3031            let loaded: Vec<(String, String)> = tokio::task::spawn_blocking(move || {
3032                files_to_read
3033                    .into_iter()
3034                    .filter_map(|(uri_str, fs_path)| {
3035                        let content = std::fs::read_to_string(&fs_path).ok()?;
3036                        Some((uri_str, content))
3037                    })
3038                    .collect()
3039            })
3040            .await
3041            .unwrap_or_default();
3042
3043            let mut tc = self.text_cache.write().await;
3044            for (uri_str, content) in loaded {
3045                tc.entry(uri_str).or_insert((0, content));
3046            }
3047        }
3048
3049        let result = {
3050            let tc = self.text_cache.read().await;
3051            let get_source_bytes = |fs_path: &str| -> Option<Vec<u8>> {
3052                let uri = Url::from_file_path(fs_path).ok()?;
3053                let (_, content) = tc.get(&uri.to_string())?;
3054                Some(content.as_bytes().to_vec())
3055            };
3056
3057            file_operations::delete_imports(
3058                &source_files,
3059                &deletes,
3060                &project_root,
3061                &get_source_bytes,
3062            )
3063        };
3064
3065        let stats = &result.stats;
3066        if stats.read_failures > 0
3067            || stats.statement_range_failures > 0
3068            || stats.duplicate_deletes > 0
3069        {
3070            self.client
3071                .log_message(
3072                    MessageType::WARNING,
3073                    format!(
3074                        "willDeleteFiles stats: read_failures={}, statement_range_failures={}, \
3075                         duplicate_deletes={}, no_parent={}, dedup_skips={}",
3076                        stats.read_failures,
3077                        stats.statement_range_failures,
3078                        stats.duplicate_deletes,
3079                        stats.no_parent,
3080                        stats.dedup_skips,
3081                    ),
3082                )
3083                .await;
3084        }
3085
3086        let all_edits = result.edits;
3087        if all_edits.is_empty() {
3088            self.client
3089                .log_message(
3090                    MessageType::INFO,
3091                    "willDeleteFiles: no import-removal edits needed",
3092                )
3093                .await;
3094            return Ok(None);
3095        }
3096
3097        {
3098            let mut tc = self.text_cache.write().await;
3099            let patched = file_operations::apply_edits_to_cache(&all_edits, &mut tc);
3100            self.client
3101                .log_message(
3102                    MessageType::INFO,
3103                    format!("willDeleteFiles: patched {} cached file(s)", patched),
3104                )
3105                .await;
3106        }
3107
3108        let total_edits: usize = all_edits.values().map(|v| v.len()).sum();
3109        self.client
3110            .log_message(
3111                MessageType::INFO,
3112                format!(
3113                    "willDeleteFiles: {} edit(s) across {} file(s)",
3114                    total_edits,
3115                    all_edits.len()
3116                ),
3117            )
3118            .await;
3119
3120        Ok(Some(WorkspaceEdit {
3121            changes: Some(all_edits),
3122            document_changes: None,
3123            change_annotations: None,
3124        }))
3125    }
3126
3127    async fn did_delete_files(&self, params: DeleteFilesParams) {
3128        self.client
3129            .log_message(
3130                MessageType::INFO,
3131                format!("workspace/didDeleteFiles: {} file(s)", params.files.len()),
3132            )
3133            .await;
3134
3135        let raw_delete_uris: Vec<Url> = params
3136            .files
3137            .iter()
3138            .filter_map(|fd| Url::parse(&fd.uri).ok())
3139            .collect();
3140
3141        let deleted_paths = {
3142            let tc = self.text_cache.read().await;
3143            let cache_paths: Vec<std::path::PathBuf> = tc
3144                .keys()
3145                .filter_map(|k| Url::parse(k).ok())
3146                .filter_map(|u| u.to_file_path().ok())
3147                .collect();
3148            drop(tc);
3149
3150            let cfg = self.foundry_config.read().await.clone();
3151            let discovered_paths =
3152                tokio::task::spawn_blocking(move || crate::solc::discover_source_files(&cfg))
3153                    .await
3154                    .unwrap_or_default();
3155
3156            let mut all_paths: HashSet<std::path::PathBuf> = discovered_paths.into_iter().collect();
3157            all_paths.extend(cache_paths);
3158            let all_paths: Vec<std::path::PathBuf> = all_paths.into_iter().collect();
3159
3160            file_operations::expand_folder_deletes_from_paths(&raw_delete_uris, &all_paths)
3161        };
3162
3163        let mut deleted_keys: HashSet<String> = HashSet::new();
3164        let mut deleted_uris: Vec<Url> = Vec::new();
3165        for path in deleted_paths {
3166            if let Ok(uri) = Url::from_file_path(&path) {
3167                deleted_keys.insert(uri.to_string());
3168                deleted_uris.push(uri);
3169            }
3170        }
3171        if deleted_keys.is_empty() {
3172            return;
3173        }
3174
3175        self.client
3176            .log_message(
3177                MessageType::INFO,
3178                format!(
3179                    "didDeleteFiles: deleting {} cache/diagnostic entry(ies)",
3180                    deleted_keys.len()
3181                ),
3182            )
3183            .await;
3184
3185        for uri in &deleted_uris {
3186            self.client
3187                .publish_diagnostics(uri.clone(), vec![], None)
3188                .await;
3189        }
3190
3191        let mut removed_text = 0usize;
3192        let mut removed_ast = 0usize;
3193        let mut removed_completion = 0usize;
3194        let mut removed_semantic = 0usize;
3195        let mut removed_pending_create = 0usize;
3196        {
3197            let mut tc = self.text_cache.write().await;
3198            for key in &deleted_keys {
3199                if tc.remove(key).is_some() {
3200                    removed_text += 1;
3201                }
3202            }
3203        }
3204        {
3205            let mut ac = self.ast_cache.write().await;
3206            for key in &deleted_keys {
3207                if ac.remove(key).is_some() {
3208                    removed_ast += 1;
3209                }
3210            }
3211        }
3212        {
3213            let mut cc = self.completion_cache.write().await;
3214            for key in &deleted_keys {
3215                if cc.remove(key).is_some() {
3216                    removed_completion += 1;
3217                }
3218            }
3219        }
3220        {
3221            let mut sc = self.semantic_token_cache.write().await;
3222            for key in &deleted_keys {
3223                if sc.remove(key).is_some() {
3224                    removed_semantic += 1;
3225                }
3226            }
3227        }
3228        {
3229            let mut pending = self.pending_create_scaffold.write().await;
3230            for key in &deleted_keys {
3231                if pending.remove(key) {
3232                    removed_pending_create += 1;
3233                }
3234            }
3235        }
3236        self.client
3237            .log_message(
3238                MessageType::INFO,
3239                format!(
3240                    "didDeleteFiles: removed caches text={} ast={} completion={} semantic={} pendingCreate={}",
3241                    removed_text,
3242                    removed_ast,
3243                    removed_completion,
3244                    removed_semantic,
3245                    removed_pending_create,
3246                ),
3247            )
3248            .await;
3249
3250        let root_key = self.root_uri.read().await.as_ref().map(|u| u.to_string());
3251        if let Some(ref key) = root_key {
3252            self.ast_cache.write().await.remove(key);
3253        }
3254
3255        let foundry_config = self.foundry_config.read().await.clone();
3256        let ast_cache = self.ast_cache.clone();
3257        let client = self.client.clone();
3258        let text_cache_snapshot = self.text_cache.read().await.clone();
3259
3260        tokio::spawn(async move {
3261            let Some(cache_key) = root_key else {
3262                return;
3263            };
3264            match crate::solc::solc_project_index(
3265                &foundry_config,
3266                Some(&client),
3267                Some(&text_cache_snapshot),
3268            )
3269            .await
3270            {
3271                Ok(ast_data) => {
3272                    let cached_build = Arc::new(crate::goto::CachedBuild::new(ast_data, 0));
3273                    let source_count = cached_build.nodes.len();
3274                    ast_cache.write().await.insert(cache_key, cached_build);
3275                    client
3276                        .log_message(
3277                            MessageType::INFO,
3278                            format!("didDeleteFiles: re-indexed {} source files", source_count),
3279                        )
3280                        .await;
3281                }
3282                Err(e) => {
3283                    client
3284                        .log_message(
3285                            MessageType::WARNING,
3286                            format!("didDeleteFiles: re-index failed: {e}"),
3287                        )
3288                        .await;
3289                }
3290            }
3291        });
3292    }
3293
3294    async fn will_create_files(
3295        &self,
3296        params: CreateFilesParams,
3297    ) -> tower_lsp::jsonrpc::Result<Option<WorkspaceEdit>> {
3298        self.client
3299            .log_message(
3300                MessageType::INFO,
3301                format!("workspace/willCreateFiles: {} file(s)", params.files.len()),
3302            )
3303            .await;
3304        if !self
3305            .settings
3306            .read()
3307            .await
3308            .file_operations
3309            .template_on_create
3310        {
3311            self.client
3312                .log_message(
3313                    MessageType::INFO,
3314                    "willCreateFiles: templateOnCreate disabled",
3315                )
3316                .await;
3317            return Ok(None);
3318        }
3319        self.client
3320            .log_message(
3321                MessageType::INFO,
3322                "willCreateFiles: skipping pre-create edits; scaffolding via didCreateFiles",
3323            )
3324            .await;
3325        Ok(None)
3326    }
3327
3328    async fn did_create_files(&self, params: CreateFilesParams) {
3329        self.client
3330            .log_message(
3331                MessageType::INFO,
3332                format!("workspace/didCreateFiles: {} file(s)", params.files.len()),
3333            )
3334            .await;
3335        if !self
3336            .settings
3337            .read()
3338            .await
3339            .file_operations
3340            .template_on_create
3341        {
3342            self.client
3343                .log_message(
3344                    MessageType::INFO,
3345                    "didCreateFiles: templateOnCreate disabled",
3346                )
3347                .await;
3348            return;
3349        }
3350
3351        let config = self.foundry_config.read().await;
3352        let solc_version = config.solc_version.clone();
3353        drop(config);
3354
3355        // Generate scaffold and push via workspace/applyEdit for files that
3356        // are empty in both cache and on disk. This avoids prepending content
3357        // to already-populated files while keeping a fallback for clients that
3358        // don't apply willCreateFiles edits.
3359        let mut apply_edits: HashMap<Url, Vec<TextEdit>> = HashMap::new();
3360        let mut staged_content: HashMap<String, String> = HashMap::new();
3361        let mut created_uris: Vec<String> = Vec::new();
3362        {
3363            let tc = self.text_cache.read().await;
3364            for file_create in &params.files {
3365                let uri = match Url::parse(&file_create.uri) {
3366                    Ok(u) => u,
3367                    Err(_) => continue,
3368                };
3369                let uri_str = uri.to_string();
3370
3371                let open_has_content = tc
3372                    .get(&uri_str)
3373                    .map_or(false, |(_, c)| c.chars().any(|ch| !ch.is_whitespace()));
3374                let path = match uri.to_file_path() {
3375                    Ok(p) => p,
3376                    Err(_) => continue,
3377                };
3378                let disk_has_content = std::fs::read_to_string(&path)
3379                    .map_or(false, |c| c.chars().any(|ch| !ch.is_whitespace()));
3380
3381                // If an open buffer already has content, skip. If buffer is
3382                // open but empty, still apply scaffold to that buffer.
3383                if open_has_content {
3384                    self.client
3385                        .log_message(
3386                            MessageType::INFO,
3387                            format!(
3388                                "didCreateFiles: skip {} (open buffer already has content)",
3389                                uri_str
3390                            ),
3391                        )
3392                        .await;
3393                    continue;
3394                }
3395
3396                // Also skip when the file already has content on disk.
3397                if disk_has_content {
3398                    self.client
3399                        .log_message(
3400                            MessageType::INFO,
3401                            format!(
3402                                "didCreateFiles: skip {} (disk file already has content)",
3403                                uri_str
3404                            ),
3405                        )
3406                        .await;
3407                    continue;
3408                }
3409
3410                let content =
3411                    match file_operations::generate_scaffold(&uri, solc_version.as_deref()) {
3412                        Some(s) => s,
3413                        None => continue,
3414                    };
3415
3416                staged_content.insert(uri_str, content.clone());
3417                created_uris.push(uri.to_string());
3418
3419                apply_edits.entry(uri).or_default().push(TextEdit {
3420                    range: Range {
3421                        start: Position {
3422                            line: 0,
3423                            character: 0,
3424                        },
3425                        end: Position {
3426                            line: 0,
3427                            character: 0,
3428                        },
3429                    },
3430                    new_text: content,
3431                });
3432            }
3433        }
3434
3435        if !apply_edits.is_empty() {
3436            {
3437                let mut pending = self.pending_create_scaffold.write().await;
3438                for uri in &created_uris {
3439                    pending.insert(uri.clone());
3440                }
3441            }
3442
3443            let edit = WorkspaceEdit {
3444                changes: Some(apply_edits.clone()),
3445                document_changes: None,
3446                change_annotations: None,
3447            };
3448            self.client
3449                .log_message(
3450                    MessageType::INFO,
3451                    format!(
3452                        "didCreateFiles: scaffolding {} empty file(s) via workspace/applyEdit",
3453                        apply_edits.len()
3454                    ),
3455                )
3456                .await;
3457            let apply_result = self.client.apply_edit(edit).await;
3458            let applied = apply_result.as_ref().is_ok_and(|r| r.applied);
3459
3460            if applied {
3461                let mut tc = self.text_cache.write().await;
3462                for (uri_str, content) in staged_content {
3463                    tc.insert(uri_str, (0, content));
3464                }
3465            } else {
3466                if let Ok(resp) = &apply_result {
3467                    self.client
3468                        .log_message(
3469                            MessageType::WARNING,
3470                            format!(
3471                                "didCreateFiles: applyEdit rejected (no disk fallback): {:?}",
3472                                resp.failure_reason
3473                            ),
3474                        )
3475                        .await;
3476                } else if let Err(e) = &apply_result {
3477                    self.client
3478                        .log_message(
3479                            MessageType::WARNING,
3480                            format!("didCreateFiles: applyEdit failed (no disk fallback): {e}"),
3481                        )
3482                        .await;
3483                }
3484            }
3485        }
3486
3487        // Refresh diagnostics for newly created files that now have in-memory
3488        // content (e.g. scaffold applied via willCreateFiles/didChange). This
3489        // clears stale diagnostics produced from the transient empty didOpen.
3490        for file_create in &params.files {
3491            let Ok(uri) = Url::parse(&file_create.uri) else {
3492                continue;
3493            };
3494            let (version, content) = {
3495                let tc = self.text_cache.read().await;
3496                match tc.get(&uri.to_string()) {
3497                    Some((v, c)) => (*v, c.clone()),
3498                    None => continue,
3499                }
3500            };
3501            if !content.chars().any(|ch| !ch.is_whitespace()) {
3502                continue;
3503            }
3504            self.on_change(TextDocumentItem {
3505                uri,
3506                version,
3507                text: content,
3508                language_id: "solidity".to_string(),
3509            })
3510            .await;
3511        }
3512
3513        // Trigger background re-index so new symbols become discoverable.
3514        let root_key = self.root_uri.read().await.as_ref().map(|u| u.to_string());
3515        if let Some(ref key) = root_key {
3516            self.ast_cache.write().await.remove(key);
3517        }
3518
3519        let foundry_config = self.foundry_config.read().await.clone();
3520        let ast_cache = self.ast_cache.clone();
3521        let client = self.client.clone();
3522        let text_cache_snapshot = self.text_cache.read().await.clone();
3523
3524        tokio::spawn(async move {
3525            let Some(cache_key) = root_key else {
3526                return;
3527            };
3528            match crate::solc::solc_project_index(
3529                &foundry_config,
3530                Some(&client),
3531                Some(&text_cache_snapshot),
3532            )
3533            .await
3534            {
3535                Ok(ast_data) => {
3536                    let cached_build = Arc::new(crate::goto::CachedBuild::new(ast_data, 0));
3537                    let source_count = cached_build.nodes.len();
3538                    ast_cache.write().await.insert(cache_key, cached_build);
3539                    client
3540                        .log_message(
3541                            MessageType::INFO,
3542                            format!("didCreateFiles: re-indexed {} source files", source_count),
3543                        )
3544                        .await;
3545                }
3546                Err(e) => {
3547                    client
3548                        .log_message(
3549                            MessageType::WARNING,
3550                            format!("didCreateFiles: re-index failed: {e}"),
3551                        )
3552                        .await;
3553                }
3554            }
3555        });
3556    }
3557}
3558
3559#[cfg(test)]
3560mod tests {
3561    use super::update_imports_on_delete_enabled;
3562
3563    #[test]
3564    fn update_imports_on_delete_enabled_defaults_true() {
3565        let s = crate::config::Settings::default();
3566        assert!(update_imports_on_delete_enabled(&s));
3567    }
3568
3569    #[test]
3570    fn update_imports_on_delete_enabled_respects_false() {
3571        let mut s = crate::config::Settings::default();
3572        s.file_operations.update_imports_on_delete = false;
3573        assert!(!update_imports_on_delete_enabled(&s));
3574    }
3575}