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::types::DocumentUri;
17use crate::types::ErrorCode;
18use crate::utils;
19use std::collections::{HashMap, HashSet};
20use std::path::{Component, Path, PathBuf};
21use std::sync::Arc;
22use std::sync::atomic::{AtomicU64, Ordering};
23use tokio::sync::RwLock;
24use tower_lsp::{Client, LanguageServer, lsp_types::*};
25
26/// Per-document semantic token cache: `result_id` + token list.
27type SemanticTokenCache = HashMap<DocumentUri, (String, Vec<SemanticToken>)>;
28
29// ── Update check ──────────────────────────────────────────────────────
30
31/// The current version from Cargo.toml (e.g. "0.1.31").
32const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
33
34/// Check GitHub releases for a newer version and notify the user.
35async fn check_for_updates(client: Client) {
36    let url = "https://api.github.com/repos/asyncswap/solidity-language-server/releases/latest";
37
38    let http = match reqwest::Client::builder()
39        .user_agent("solidity-language-server")
40        .timeout(std::time::Duration::from_secs(5))
41        .build()
42    {
43        Ok(c) => c,
44        Err(_) => return,
45    };
46
47    let resp = match http.get(url).send().await {
48        Ok(r) if r.status().is_success() => r,
49        _ => return,
50    };
51
52    let body: serde_json::Value = match resp.json().await {
53        Ok(v) => v,
54        Err(_) => return,
55    };
56
57    let tag = match body.get("tag_name").and_then(|v| v.as_str()) {
58        Some(t) => t.strip_prefix('v').unwrap_or(t),
59        None => return,
60    };
61
62    let latest = match semver::Version::parse(tag) {
63        Ok(v) => v,
64        Err(_) => return,
65    };
66
67    let current = match semver::Version::parse(CURRENT_VERSION) {
68        Ok(v) => v,
69        Err(_) => return,
70    };
71
72    if latest > current {
73        client
74            .show_message(
75                MessageType::INFO,
76                format!(
77                    "Solidity Language Server v{latest} is available (current: v{current}). \
78                     Update: cargo install solidity-language-server"
79                ),
80            )
81            .await;
82    }
83}
84
85/// A per-file completion cache and the buffer text it was compiled from.
86type CompletionSourceText = (Arc<completion::CompletionCache>, Arc<str>);
87
88#[derive(Clone)]
89pub struct ForgeLsp {
90    client: Client,
91    compiler: Arc<dyn Runner>,
92    ast_cache: Arc<RwLock<HashMap<DocumentUri, Arc<goto::CachedBuild>>>>,
93    /// Text cache for opened documents
94    ///
95    /// The key is the file's URI converted to string, and the value is a tuple of (version, content).
96    text_cache: Arc<RwLock<HashMap<DocumentUri, (i32, String)>>>,
97    completion_cache: Arc<RwLock<HashMap<DocumentUri, Arc<completion::CompletionCache>>>>,
98    /// The buffer text each per-file completion cache was compiled from, paired
99    /// with that cache. Completion maps the live cursor offset into this text
100    /// before the scope lookup, and only when the cache it is serving is this
101    /// exact `Arc` — any other cache has no matching text.
102    completion_source_text: Arc<RwLock<HashMap<DocumentUri, CompletionSourceText>>>,
103    /// Cached lint configuration from `foundry.toml`.
104    lint_config: Arc<RwLock<LintConfig>>,
105    /// Cached project configuration from `foundry.toml`.
106    foundry_config: Arc<RwLock<FoundryConfig>>,
107    /// Client capabilities received during initialization.
108    client_capabilities: Arc<RwLock<Option<ClientCapabilities>>>,
109    /// Editor-provided settings (from `initializationOptions` / `didChangeConfiguration`).
110    settings: Arc<RwLock<Settings>>,
111    /// Whether to use solc directly for AST generation.
112    use_solc: bool,
113    /// Cache of semantic tokens per document for delta support.
114    semantic_token_cache: Arc<RwLock<SemanticTokenCache>>,
115    /// Monotonic counter for generating unique result_ids.
116    semantic_token_id: Arc<AtomicU64>,
117    /// Workspace root URI from `initialize`. Used for project-wide file discovery.
118    root_uri: Arc<RwLock<Option<Url>>>,
119    /// Whether background project indexing has already been triggered.
120    project_indexed: Arc<std::sync::atomic::AtomicBool>,
121    /// Whether workspace file operations changed project structure and
122    /// the persisted reference cache should be refreshed from disk.
123    project_cache_dirty: Arc<std::sync::atomic::AtomicBool>,
124    /// Whether a didSave cache-sync worker is currently running.
125    project_cache_sync_running: Arc<std::sync::atomic::AtomicBool>,
126    /// Whether a didSave cache-sync pass is pending (set by save bursts).
127    project_cache_sync_pending: Arc<std::sync::atomic::AtomicBool>,
128    /// When true the next cache-sync worker iteration must run a full project
129    /// rebuild, bypassing the incremental scoped path.  Set by
130    /// `solidity.reindex` to guarantee a complete reindex even when a worker
131    /// spawned by didSave is already running with `aggressive_scoped=true`.
132    project_cache_force_full_rebuild: Arc<std::sync::atomic::AtomicBool>,
133    /// Whether a didSave v2-upsert worker is currently running.
134    project_cache_upsert_running: Arc<std::sync::atomic::AtomicBool>,
135    /// Whether a didSave v2-upsert pass is pending (set by save bursts).
136    project_cache_upsert_pending: Arc<std::sync::atomic::AtomicBool>,
137    /// Absolute file paths changed during the session and awaiting dirty-sync
138    /// planning for aggressive affected-closure reindex.
139    project_cache_changed_files: Arc<RwLock<HashSet<String>>>,
140    /// Absolute file paths queued for debounced v2 shard upserts.
141    project_cache_upsert_files: Arc<RwLock<HashSet<String>>>,
142    /// URIs recently scaffolded in willCreateFiles (used to avoid re-applying
143    /// edits again in didCreateFiles for the same create operation).
144    pending_create_scaffold: Arc<RwLock<HashSet<DocumentUri>>>,
145    /// Whether settings were loaded from `initializationOptions`.  When false,
146    /// the server will pull settings via `workspace/configuration` during
147    /// `initialized()`.
148    settings_from_init: Arc<std::sync::atomic::AtomicBool>,
149    /// Per-URI watch channels for serialising didSave background work.
150    ///
151    /// Each URI gets one long-lived worker task that always processes the
152    /// *latest* save params.  Rapid saves collapse: the worker wakes once per
153    /// compile cycle and picks up the newest params via `borrow_and_update`.
154    did_save_workers: Arc<
155        RwLock<HashMap<DocumentUri, tokio::sync::watch::Sender<Option<DidSaveTextDocumentParams>>>>,
156    >,
157    /// JSON-driven code-action database loaded once at startup.
158    code_action_db: Arc<HashMap<ErrorCode, crate::code_actions::CodeActionDef>>,
159    /// Cached builds loaded from sub-project (lib) caches.
160    ///
161    /// When `fullProjectScan` is enabled, the server discovers existing LSP
162    /// caches in lib sub-projects (e.g. `lib/v4-core/.solidity-language-server/`)
163    /// and loads them.  The references handler searches these alongside the
164    /// main project build to find cross-file references in lib test files
165    /// that aren't part of the root project's compilation closure.
166    ///
167    /// Each sub-cache has its own node ID space — matching across caches
168    /// is done by absolute file path + byte offset, not by node ID.
169    sub_caches: Arc<RwLock<Vec<Arc<goto::CachedBuild>>>>,
170    /// Guards against multiple concurrent sub-cache loading tasks.
171    sub_caches_loading: Arc<std::sync::atomic::AtomicBool>,
172    /// Global file-ID-to-path index.  Assigns canonical file IDs from
173    /// file paths so every `CachedBuild` shares a single ID space.
174    /// Each solc compilation produces its own per-build file IDs;
175    /// `CachedBuild::new()` translates them into this global space via
176    /// `build_remap()`, then rewrites all `src` strings in `NodeInfo`
177    /// to use the canonical IDs.  At query time any build's
178    /// `id_to_path_map` can resolve any canonical file ID.
179    path_interner: Arc<RwLock<crate::types::PathInterner>>,
180    /// URIs that received cross-file error diagnostics from another file's
181    /// compilation.  Cleared on the next successful build so stale errors
182    /// don't linger after the underlying issue is fixed.
183    cross_file_diag_uris: Arc<RwLock<HashSet<Url>>>,
184}
185
186/// Spawn a background task to discover, build (if missing), and load caches
187/// from lib sub-projects.  Non-blocking: returns immediately.
188///
189/// Accepts already-resolved `FoundryConfig` so it can be called from inside
190/// `tokio::spawn` closures that don't have access to `&self`.
191fn spawn_load_lib_sub_caches_task(
192    foundry_config: crate::config::FoundryConfig,
193    sub_caches: Arc<RwLock<Vec<Arc<goto::CachedBuild>>>>,
194    loading_flag: Arc<std::sync::atomic::AtomicBool>,
195    path_interner: Arc<RwLock<crate::types::PathInterner>>,
196    client: Client,
197) {
198    // Atomic guard: only one task runs at a time.
199    if loading_flag
200        .compare_exchange(
201            false,
202            true,
203            std::sync::atomic::Ordering::SeqCst,
204            std::sync::atomic::Ordering::SeqCst,
205        )
206        .is_err()
207    {
208        return;
209    }
210    tokio::spawn(async move {
211        let cfg = foundry_config.clone();
212        let discovered = tokio::task::spawn_blocking(move || {
213            crate::project_cache::discover_lib_sub_projects(&cfg)
214        })
215        .await
216        .unwrap_or_else(|_| crate::project_cache::DiscoveredLibs {
217            cached: Vec::new(),
218            uncached: Vec::new(),
219        });
220
221        // Build all missing caches concurrently.
222        let sub_caches_start = std::time::Instant::now();
223        spawn_and_collect_sub_cache_builds(&discovered.uncached, &client, &path_interner).await;
224
225        // Now load all caches (existing + newly built).
226        let cfg2 = foundry_config.clone();
227        let all_cached =
228            tokio::task::spawn_blocking(move || crate::project_cache::discover_lib_caches(&cfg2))
229                .await
230                .unwrap_or_default();
231
232        if all_cached.is_empty() {
233            emit_sub_caches_loaded(&client, 0, 0, sub_caches_start.elapsed().as_secs_f64()).await;
234            loading_flag.store(false, std::sync::atomic::Ordering::SeqCst);
235            return;
236        }
237
238        let mut loaded = Vec::new();
239        for sub_root in &all_cached {
240            let root = sub_root.clone();
241            let build =
242                tokio::task::spawn_blocking(move || crate::project_cache::load_lib_cache(&root))
243                    .await
244                    .ok()
245                    .flatten();
246            if let Some(build) = build {
247                // Register loaded paths in the project-wide interner so
248                // file IDs stay consistent when merging or replacing builds.
249                {
250                    let mut interner = path_interner.write().await;
251                    for path in build.id_to_path_map.values() {
252                        interner.intern(path);
253                    }
254                }
255                loaded.push(Arc::new(build));
256            }
257        }
258
259        let count = loaded.len();
260        let total: usize = loaded.iter().map(|b| b.nodes.len()).sum();
261        let elapsed = sub_caches_start.elapsed().as_secs_f64();
262
263        if !loaded.is_empty() {
264            client
265                .log_message(
266                    MessageType::INFO,
267                    format!(
268                        "sub-caches: loaded {} lib caches ({} total sources, {:.1}s total)",
269                        count, total, elapsed,
270                    ),
271                )
272                .await;
273            *sub_caches.write().await = loaded;
274        }
275
276        emit_sub_caches_loaded(&client, count, total, elapsed).await;
277        loading_flag.store(false, std::sync::atomic::Ordering::SeqCst);
278    });
279}
280
281/// Spawn concurrent solc builds for a batch of sub-project roots, collect
282/// results, build `CachedBuild` for each, and persist to disk.
283///
284/// Concurrency is capped at the number of available CPU cores so that
285/// machines with fewer cores don't thrash from too many parallel solc
286/// processes.
287async fn spawn_and_collect_sub_cache_builds(
288    roots: &[std::path::PathBuf],
289    client: &Client,
290    path_interner: &Arc<RwLock<crate::types::PathInterner>>,
291) {
292    if roots.is_empty() {
293        return;
294    }
295    let max_parallel = std::thread::available_parallelism()
296        .map(|n| n.get())
297        .unwrap_or(4);
298    let semaphore = Arc::new(tokio::sync::Semaphore::new(max_parallel));
299    client
300        .log_message(
301            MessageType::INFO,
302            format!(
303                "sub-cache: building {} libs (max {max_parallel} parallel)",
304                roots.len()
305            ),
306        )
307        .await;
308    let mut join_set = tokio::task::JoinSet::new();
309    for sub_root in roots {
310        let sub_name = sub_root
311            .file_name()
312            .map(|n| n.to_string_lossy().into_owned())
313            .unwrap_or_else(|| sub_root.display().to_string());
314        let sub_config =
315            crate::config::load_foundry_config_from_toml(&sub_root.join("foundry.toml"));
316        let sem = semaphore.clone();
317        join_set.spawn(async move {
318            let _permit = sem.acquire().await.expect("semaphore closed");
319            let sub_start = std::time::Instant::now();
320            let result = crate::solc::solc_project_index_ast_only(&sub_config, None).await;
321            let elapsed = sub_start.elapsed().as_secs_f64();
322            (sub_name, sub_config, result, elapsed)
323        });
324    }
325
326    while let Some(join_result) = join_set.join_next().await {
327        let Ok((sub_name, sub_config, result, elapsed)) = join_result else {
328            continue;
329        };
330        match result {
331            Ok(ast_data) => {
332                let mut interner = path_interner.write().await;
333                let build = crate::goto::CachedBuild::new(ast_data, 0, Some(&mut interner));
334                drop(interner);
335                let source_count = build.nodes.len();
336                if source_count == 0 {
337                    client
338                        .log_message(
339                            MessageType::WARNING,
340                            format!("sub-cache: {sub_name} produced 0 sources"),
341                        )
342                        .await;
343                    continue;
344                }
345                let cfg_for_save = sub_config.clone();
346                let build_for_save = build.clone();
347                let _ = tokio::task::spawn_blocking(move || {
348                    crate::project_cache::save_reference_cache_with_report(
349                        &cfg_for_save,
350                        &build_for_save,
351                        None,
352                    )
353                })
354                .await;
355            }
356            Err(e) => {
357                client
358                    .log_message(
359                        MessageType::WARNING,
360                        format!("sub-cache: {sub_name} failed ({elapsed:.1}s): {e}"),
361                    )
362                    .await;
363            }
364        }
365    }
366}
367
368/// Emit the `solidity/subCachesLoaded` progress begin+end tokens so
369/// benchmarks and editors can detect when all library sub-caches are ready.
370async fn emit_sub_caches_loaded(client: &Client, count: usize, total: usize, elapsed: f64) {
371    let token = NumberOrString::String("solidity/subCachesLoaded".to_string());
372    let _ = client
373        .send_request::<request::WorkDoneProgressCreate>(WorkDoneProgressCreateParams {
374            token: token.clone(),
375        })
376        .await;
377    client
378        .send_notification::<notification::Progress>(ProgressParams {
379            token: token.clone(),
380            value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(WorkDoneProgressBegin {
381                title: "Sub-caches loaded".to_string(),
382                message: Some(format!(
383                    "{count} lib caches ({total} sources) in {elapsed:.1}s",
384                )),
385                cancellable: Some(false),
386                percentage: None,
387            })),
388        })
389        .await;
390    client
391        .send_notification::<notification::Progress>(ProgressParams {
392            token,
393            value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(WorkDoneProgressEnd {
394                message: Some(format!("Loaded {count} lib caches ({total} sources)",)),
395            })),
396        })
397        .await;
398}
399
400impl ForgeLsp {
401    pub fn new(client: Client, use_solar: bool, use_solc: bool) -> Self {
402        let compiler: Arc<dyn Runner> = if use_solar {
403            Arc::new(crate::solar_runner::SolarRunner)
404        } else {
405            Arc::new(ForgeRunner)
406        };
407        let ast_cache = Arc::new(RwLock::new(HashMap::new()));
408        let text_cache = Arc::new(RwLock::new(HashMap::new()));
409        let completion_cache = Arc::new(RwLock::new(HashMap::new()));
410        let completion_source_text = Arc::new(RwLock::new(HashMap::new()));
411        let lint_config = Arc::new(RwLock::new(LintConfig::default()));
412        let foundry_config = Arc::new(RwLock::new(FoundryConfig::default()));
413        let client_capabilities = Arc::new(RwLock::new(None));
414        let settings = Arc::new(RwLock::new(Settings::default()));
415        Self {
416            client,
417            compiler,
418            ast_cache,
419            text_cache,
420            completion_cache,
421            completion_source_text,
422            lint_config,
423            foundry_config,
424            client_capabilities,
425            settings,
426            use_solc,
427            semantic_token_cache: Arc::new(RwLock::new(HashMap::new())),
428            semantic_token_id: Arc::new(AtomicU64::new(0)),
429            root_uri: Arc::new(RwLock::new(None)),
430            project_indexed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
431            project_cache_dirty: Arc::new(std::sync::atomic::AtomicBool::new(false)),
432            project_cache_sync_running: Arc::new(std::sync::atomic::AtomicBool::new(false)),
433            project_cache_sync_pending: Arc::new(std::sync::atomic::AtomicBool::new(false)),
434            project_cache_force_full_rebuild: Arc::new(std::sync::atomic::AtomicBool::new(false)),
435            project_cache_upsert_running: Arc::new(std::sync::atomic::AtomicBool::new(false)),
436            project_cache_upsert_pending: Arc::new(std::sync::atomic::AtomicBool::new(false)),
437            project_cache_changed_files: Arc::new(RwLock::new(HashSet::new())),
438            project_cache_upsert_files: Arc::new(RwLock::new(HashSet::new())),
439            pending_create_scaffold: Arc::new(RwLock::new(HashSet::new())),
440            settings_from_init: Arc::new(std::sync::atomic::AtomicBool::new(false)),
441            did_save_workers: Arc::new(RwLock::new(HashMap::new())),
442            code_action_db: Arc::new(crate::code_actions::load()),
443            sub_caches: Arc::new(RwLock::new(Vec::new())),
444            sub_caches_loading: Arc::new(std::sync::atomic::AtomicBool::new(false)),
445            path_interner: Arc::new(RwLock::new(crate::types::PathInterner::new())),
446            cross_file_diag_uris: Arc::new(RwLock::new(HashSet::new())),
447        }
448    }
449
450    /// Resolve the foundry configuration for a specific file.
451    ///
452    /// Looks for `foundry.toml` starting from the file's own directory, which
453    /// handles files in nested projects (e.g. `lib/`, `example/`,
454    /// `node_modules/`).  When no `foundry.toml` exists at all (Hardhat, bare
455    /// projects), the file's git root or parent directory is used as the
456    /// project root so solc can still resolve imports.
457    async fn foundry_config_for_file(&self, file_path: &std::path::Path) -> FoundryConfig {
458        config::load_foundry_config(file_path)
459    }
460
461    /// Canonical project cache key for project-wide index entries.
462    ///
463    /// Prefer workspace root URI when available. If not provided by the
464    /// client, derive a file URI from detected foundry root.
465    async fn project_cache_key(&self) -> Option<String> {
466        if let Some(uri) = self.root_uri.read().await.as_ref() {
467            return Some(uri.to_string());
468        }
469
470        let mut root = self.foundry_config.read().await.root.clone();
471        if !root.is_absolute()
472            && let Ok(cwd) = std::env::current_dir()
473        {
474            root = cwd.join(root);
475        }
476        if !root.is_dir() {
477            root = root.parent()?.to_path_buf();
478        }
479        Url::from_directory_path(root).ok().map(|u| u.to_string())
480    }
481
482    /// Spawn a background task to discover and load existing caches from lib
483    /// sub-projects.
484    ///
485    /// Non-blocking: returns immediately.  The background task discovers
486    /// sub-projects with `.solidity-language-server/` cache dirs and loads
487    /// them into `self.sub_caches`.  The references handler reads whatever
488    /// sub-caches are available at query time — if loading hasn't finished
489    /// yet, the first query returns only root-project references.
490    fn spawn_load_lib_sub_caches(&self) {
491        let foundry_config = self.foundry_config.clone();
492        let sub_caches = self.sub_caches.clone();
493        let loading_flag = self.sub_caches_loading.clone();
494        let path_interner = self.path_interner.clone();
495        let client = self.client.clone();
496        tokio::spawn(async move {
497            let cfg = foundry_config.read().await.clone();
498            spawn_load_lib_sub_caches_task(cfg, sub_caches, loading_flag, path_interner, client);
499        });
500    }
501
502    /// If any of the given absolute paths fall under a `libs` directory
503    /// (typically `lib/`), clear the in-memory sub-caches and re-trigger the
504    /// background sub-cache load so stale lib references are replaced.
505    async fn invalidate_lib_sub_caches_if_affected(&self, changed_paths: &[std::path::PathBuf]) {
506        let config = self.foundry_config.read().await.clone();
507        let affected = changed_paths.iter().any(|p| {
508            config
509                .libs
510                .iter()
511                .any(|lib_name| p.starts_with(config.root.join(lib_name)))
512        });
513        if affected {
514            self.sub_caches.write().await.clear();
515            self.spawn_load_lib_sub_caches();
516        }
517    }
518
519    /// Ensure project-wide cached build is available for cross-file features.
520    ///
521    /// Fast path: return in-memory root build if present.
522    /// Slow path: load persisted cache from disk and insert it under project key.
523    async fn ensure_project_cached_build(&self) -> Option<Arc<goto::CachedBuild>> {
524        let root_key = self.project_cache_key().await?;
525        if let Some(existing) = self.ast_cache.read().await.get(&root_key).cloned() {
526            // Kick off sub-cache loading in the background (no-op if already loaded).
527            self.spawn_load_lib_sub_caches();
528            return Some(existing);
529        }
530
531        let settings = self.settings.read().await.clone();
532        if !self.use_solc || !settings.project_index.full_project_scan {
533            return None;
534        }
535
536        let foundry_config = self.foundry_config.read().await.clone();
537        if !foundry_config.root.is_dir() {
538            return None;
539        }
540
541        let cache_mode = settings.project_index.cache_mode.clone();
542        let cfg_for_load = foundry_config.clone();
543        let load_res = tokio::task::spawn_blocking(move || {
544            crate::project_cache::load_reference_cache_with_report(&cfg_for_load, cache_mode, true)
545        })
546        .await;
547
548        let Ok(report) = load_res else {
549            return None;
550        };
551        let build = report.build?;
552
553        let source_count = build.nodes.len();
554        let complete = report.complete;
555        let duration_ms = report.duration_ms;
556        let reused = report.file_count_reused;
557        let hashed = report.file_count_hashed;
558        let arc = Arc::new(build);
559        self.ast_cache
560            .write()
561            .await
562            .insert(root_key.clone().into(), arc.clone());
563        self.project_indexed
564            .store(true, std::sync::atomic::Ordering::Relaxed);
565        self.client
566            .log_message(
567                MessageType::INFO,
568                format!(
569                    "references warm-load: project cache loaded (sources={}, reused_files={}/{}, complete={}, duration={}ms)",
570                    source_count, reused, hashed, complete, duration_ms
571                ),
572            )
573            .await;
574
575        // Kick off sub-cache loading in the background (no-op if already loaded).
576        self.spawn_load_lib_sub_caches();
577
578        if complete {
579            return Some(arc);
580        }
581
582        // Partial warm load: immediately reconcile changed files only, merge,
583        // and persist back to disk so subsequent opens are fast and complete.
584        let cfg_for_diff = foundry_config.clone();
585        let changed = tokio::task::spawn_blocking(move || {
586            crate::project_cache::changed_files_since_v2_cache(&cfg_for_diff, true)
587        })
588        .await
589        .ok()
590        .and_then(Result::ok)
591        .unwrap_or_default();
592
593        if changed.is_empty() {
594            return Some(arc);
595        }
596
597        let remappings = crate::solc::resolve_remappings(&foundry_config).await;
598        let cfg_for_plan = foundry_config.clone();
599        let changed_for_plan = changed.clone();
600        let remappings_for_plan = remappings.clone();
601        let affected_set = tokio::task::spawn_blocking(move || {
602            compute_reverse_import_closure(&cfg_for_plan, &changed_for_plan, &remappings_for_plan)
603        })
604        .await
605        .ok()
606        .unwrap_or_default();
607        let mut affected_files: Vec<PathBuf> = affected_set.into_iter().collect();
608        if affected_files.is_empty() {
609            affected_files = changed;
610        }
611
612        let text_cache_snapshot = self.text_cache.read().await.clone();
613        match crate::solc::solc_project_index_scoped(
614            &foundry_config,
615            Some(&self.client),
616            Some(&text_cache_snapshot),
617            &affected_files,
618        )
619        .await
620        {
621            Ok(ast_data) => {
622                let scoped_build = Arc::new(crate::goto::CachedBuild::new(
623                    ast_data,
624                    0,
625                    Some(&mut *self.path_interner.write().await),
626                ));
627                let mut merge_error: Option<String> = None;
628                let merged = {
629                    let mut cache = self.ast_cache.write().await;
630                    let merged = if let Some(existing) = cache.get(&root_key).cloned() {
631                        let mut merged = (*existing).clone();
632                        match merge_scoped_cached_build(&mut merged, (*scoped_build).clone()) {
633                            Ok(_) => Arc::new(merged),
634                            Err(e) => {
635                                merge_error = Some(e);
636                                scoped_build.clone()
637                            }
638                        }
639                    } else {
640                        scoped_build.clone()
641                    };
642                    cache.insert(root_key.clone().into(), merged.clone());
643                    merged
644                };
645                if let Some(e) = merge_error {
646                    self.client
647                        .log_message(
648                            MessageType::WARNING,
649                            format!(
650                                "references warm-load reconcile: merge failed, using scoped build: {}",
651                                e
652                            ),
653                        )
654                        .await;
655                }
656
657                let cfg_for_save = foundry_config.clone();
658                let build_for_save = (*merged).clone();
659                let save_res = tokio::task::spawn_blocking(move || {
660                    crate::project_cache::save_reference_cache_with_report(
661                        &cfg_for_save,
662                        &build_for_save,
663                        None,
664                    )
665                })
666                .await;
667                if let Ok(Ok(report)) = save_res {
668                    self.client
669                        .log_message(
670                            MessageType::INFO,
671                            format!(
672                                "references warm-load reconcile: saved cache (affected={}, hashed_files={}, duration={}ms)",
673                                affected_files.len(),
674                                report.file_count_hashed,
675                                report.duration_ms
676                            ),
677                        )
678                        .await;
679                }
680                Some(merged)
681            }
682            Err(e) => {
683                self.client
684                    .log_message(
685                        MessageType::WARNING,
686                        format!(
687                            "references warm-load reconcile: scoped reindex failed: {}",
688                            e
689                        ),
690                    )
691                    .await;
692                Some(arc)
693            }
694        }
695    }
696
697    /// Best-effort persistence of the current in-memory project index.
698    ///
699    /// This writes the root project CachedBuild to disk if available.
700    async fn flush_project_cache_to_disk(&self, reason: &str) {
701        if !self.use_solc || !self.settings.read().await.project_index.full_project_scan {
702            return;
703        }
704        let Some(root_key) = self.project_cache_key().await else {
705            return;
706        };
707        let build = {
708            let cache = self.ast_cache.read().await;
709            cache.get(&root_key).cloned()
710        };
711        let Some(build) = build else {
712            return;
713        };
714
715        let foundry_config = self.foundry_config.read().await.clone();
716        let build_for_save = (*build).clone();
717        let res = tokio::task::spawn_blocking(move || {
718            crate::project_cache::save_reference_cache_with_report(
719                &foundry_config,
720                &build_for_save,
721                None,
722            )
723        })
724        .await;
725
726        match res {
727            Ok(Ok(report)) => {
728                self.client
729                    .log_message(
730                        MessageType::INFO,
731                        format!(
732                            "project cache flush ({}): saved hashed_files={}, duration={}ms",
733                            reason, report.file_count_hashed, report.duration_ms
734                        ),
735                    )
736                    .await;
737            }
738            Ok(Err(e)) => {
739                self.client
740                    .log_message(
741                        MessageType::WARNING,
742                        format!("project cache flush ({}) failed: {}", reason, e),
743                    )
744                    .await;
745            }
746            Err(e) => {
747                self.client
748                    .log_message(
749                        MessageType::WARNING,
750                        format!("project cache flush ({}) task failed: {}", reason, e),
751                    )
752                    .await;
753            }
754        }
755    }
756
757    async fn on_change(&self, params: TextDocumentItem) {
758        let uri = params.uri.clone();
759        let version = params.version;
760
761        let file_path = match uri.to_file_path() {
762            Ok(path) => path,
763            Err(_) => {
764                self.client
765                    .log_message(MessageType::ERROR, "Invalid file URI")
766                    .await;
767                return;
768            }
769        };
770
771        let path_str = match file_path.to_str() {
772            Some(s) => s,
773            None => {
774                self.client
775                    .log_message(MessageType::ERROR, "Invalid file path")
776                    .await;
777                return;
778            }
779        };
780
781        // Clear stale diagnostics immediately so the user sees instant feedback
782        // while solc is compiling.  Fresh diagnostics (if any) are published
783        // below once the build finishes.
784        self.client
785            .publish_diagnostics(uri.clone(), vec![], None)
786            .await;
787
788        // Check if linting should be skipped based on foundry.toml + editor settings.
789        let (should_lint, lint_settings) = {
790            let lint_cfg = self.lint_config.read().await;
791            let settings = self.settings.read().await;
792            let enabled = lint_cfg.should_lint(&file_path) && settings.lint.enabled;
793            let ls = settings.lint.clone();
794            (enabled, ls)
795        };
796
797        // When use_solc is enabled, run solc once for both AST and diagnostics.
798        // This is the default path — fast and direct.
799        let (lint_result, build_result, ast_result) = if self.use_solc {
800            let foundry_cfg = self.foundry_config_for_file(&file_path).await;
801            // Pass the editor's live buffer text directly so solc compiles
802            // what the user sees, not the on-disk version.
803            let solc_future = crate::solc::solc_ast(
804                path_str,
805                &foundry_cfg,
806                Some(&self.client),
807                Some(&params.text),
808            );
809
810            if should_lint {
811                let (lint, solc) = tokio::join!(
812                    self.compiler.get_lint_diagnostics(&uri, &lint_settings),
813                    solc_future
814                );
815                match solc {
816                    Ok(data) => {
817                        // Extract diagnostics from the same live buffer text
818                        // passed to solc so byte offsets and LSP ranges stay
819                        // aligned with the editor, not a stale on-disk file.
820                        let build_diags = crate::build::build_output_to_diagnostics(
821                            &data,
822                            &file_path,
823                            &params.text,
824                            &foundry_cfg.ignored_error_codes,
825                        );
826                        (Some(lint), Ok(build_diags), Ok(data))
827                    }
828                    Err(e) => {
829                        self.client
830                            .log_message(
831                                MessageType::WARNING,
832                                format!("solc failed, falling back to forge build: {e}"),
833                            )
834                            .await;
835                        let (build, ast) = tokio::join!(
836                            self.compiler.get_build_diagnostics(&uri),
837                            self.compiler.ast(path_str)
838                        );
839                        (Some(lint), build, ast)
840                    }
841                }
842            } else {
843                self.client
844                    .log_message(
845                        MessageType::INFO,
846                        format!("skipping lint for ignored file: {path_str}"),
847                    )
848                    .await;
849                match solc_future.await {
850                    Ok(data) => {
851                        let build_diags = crate::build::build_output_to_diagnostics(
852                            &data,
853                            &file_path,
854                            &params.text,
855                            &foundry_cfg.ignored_error_codes,
856                        );
857                        (None, Ok(build_diags), Ok(data))
858                    }
859                    Err(e) => {
860                        self.client
861                            .log_message(
862                                MessageType::WARNING,
863                                format!("solc failed, falling back to forge build: {e}"),
864                            )
865                            .await;
866                        let (build, ast) = tokio::join!(
867                            self.compiler.get_build_diagnostics(&uri),
868                            self.compiler.ast(path_str)
869                        );
870                        (None, build, ast)
871                    }
872                }
873            }
874        } else {
875            // forge build pipeline (--use-forge)
876            if should_lint {
877                let (lint, build, ast) = tokio::join!(
878                    self.compiler.get_lint_diagnostics(&uri, &lint_settings),
879                    self.compiler.get_build_diagnostics(&uri),
880                    self.compiler.ast(path_str)
881                );
882                (Some(lint), build, ast)
883            } else {
884                self.client
885                    .log_message(
886                        MessageType::INFO,
887                        format!("skipping lint for ignored file: {path_str}"),
888                    )
889                    .await;
890                let (build, ast) = tokio::join!(
891                    self.compiler.get_build_diagnostics(&uri),
892                    self.compiler.ast(path_str)
893                );
894                (None, build, ast)
895            }
896        };
897
898        // Only replace cache with new AST if build succeeded (no errors).
899        //
900        // `build_output_to_diagnostics` filters errors to the current file,
901        // so cross-file errors (e.g. an imported file referencing a symbol
902        // we just removed) are invisible to the file-local check.  When such
903        // errors occur solc returns `"sources": {}` — no AST at all — even
904        // though no errors are attributed to the current file.  Check the
905        // raw solc errors array as well so we don't replace a working cache
906        // with an empty build.
907        let has_file_local_errors = matches!(
908            &build_result,
909            Ok(diagnostics) if diagnostics.iter().any(|d| d.severity == Some(DiagnosticSeverity::ERROR))
910        );
911        let has_solc_errors = ast_result.as_ref().is_ok_and(|data| {
912            data.get("errors")
913                .and_then(|v| v.as_array())
914                .is_some_and(|errs| {
915                    errs.iter().any(|e| {
916                        e.get("severity")
917                            .and_then(|s| s.as_str())
918                            .is_some_and(|s| s == "error")
919                    })
920                })
921        });
922        let build_succeeded = !has_file_local_errors && !has_solc_errors;
923
924        // Extract cross-file error diagnostics before ast_result is consumed.
925        // These are errors in imported files that need to be published to
926        // those files and their content hashes invalidated.
927        let cross_file_diags = if has_solc_errors {
928            if let Ok(ref data) = ast_result {
929                let cfg = self.foundry_config_for_file(&file_path).await;
930                crate::build::cross_file_error_diagnostics(
931                    data,
932                    &file_path,
933                    &cfg.root,
934                    &cfg.ignored_error_codes,
935                )
936            } else {
937                std::collections::HashMap::new()
938            }
939        } else {
940            std::collections::HashMap::new()
941        };
942
943        if build_succeeded {
944            if let Ok(ast_data) = ast_result {
945                // Safety: never replace a populated cache with an empty build.
946                // Even when build_succeeded is true, solc may return empty
947                // sources for edge cases we haven't accounted for.
948                let sources_empty = ast_data
949                    .get("sources")
950                    .and_then(|v| v.as_object())
951                    .is_none_or(|m| m.is_empty());
952
953                if sources_empty {
954                    self.client
955                        .log_message(
956                            MessageType::INFO,
957                            "Build produced empty AST, keeping existing cache",
958                        )
959                        .await;
960                } else {
961                    let cached_build = goto::CachedBuild::new(
962                        ast_data,
963                        version,
964                        Some(&mut *self.path_interner.write().await),
965                    );
966                    let cached_build = Arc::new(cached_build);
967                    let mut cache = self.ast_cache.write().await;
968                    cache.insert(uri.to_string().into(), cached_build.clone());
969                    drop(cache);
970
971                    // Insert pre-built completion cache (built during CachedBuild::new)
972                    {
973                        let mut cc = self.completion_cache.write().await;
974                        cc.insert(
975                            uri.to_string().into(),
976                            cached_build.completion_cache.clone(),
977                        );
978                    }
979                    self.completion_source_text.write().await.insert(
980                        uri.to_string().into(),
981                        (
982                            cached_build.completion_cache.clone(),
983                            Arc::from(params.text.as_str()),
984                        ),
985                    );
986                }
987            } else if let Err(e) = ast_result {
988                self.client
989                    .log_message(
990                        MessageType::INFO,
991                        format!("Build succeeded but failed to get AST: {e}"),
992                    )
993                    .await;
994            }
995        } else {
996            // Build has errors — keep the existing AST cache so navigation
997            // continues to work while the user fixes errors.
998            let reason = if has_solc_errors && !has_file_local_errors {
999                "Cross-file compilation errors detected, keeping existing AST cache"
1000            } else {
1001                "Build errors detected, keeping existing AST cache"
1002            };
1003            self.client.log_message(MessageType::INFO, reason).await;
1004        }
1005
1006        // cache text — only if no newer version exists (e.g. from formatting/did_change)
1007        {
1008            let mut text_cache = self.text_cache.write().await;
1009            let uri_str = uri.to_string();
1010            let existing_version = text_cache.get(&uri_str).map(|(v, _)| *v).unwrap_or(-1);
1011            if version >= existing_version {
1012                text_cache.insert(uri_str.into(), (version, params.text));
1013            }
1014        }
1015
1016        let mut all_diagnostics = vec![];
1017
1018        if let Some(lint_result) = lint_result {
1019            match lint_result {
1020                Ok(mut lints) => {
1021                    // Filter out excluded lint rules from editor settings.
1022                    if !lint_settings.exclude.is_empty() {
1023                        lints.retain(|d| {
1024                            if let Some(NumberOrString::String(code)) = &d.code {
1025                                !lint_settings.exclude.iter().any(|ex| ex == code)
1026                            } else {
1027                                true
1028                            }
1029                        });
1030                    }
1031                    if !lints.is_empty() {
1032                        self.client
1033                            .log_message(
1034                                MessageType::INFO,
1035                                format!("found {} lint diagnostics", lints.len()),
1036                            )
1037                            .await;
1038                    }
1039                    all_diagnostics.append(&mut lints);
1040                }
1041                Err(e) => {
1042                    self.client
1043                        .log_message(
1044                            MessageType::ERROR,
1045                            format!("Forge lint diagnostics failed: {e}"),
1046                        )
1047                        .await;
1048                }
1049            }
1050        }
1051
1052        match build_result {
1053            Ok(mut builds) => {
1054                if !builds.is_empty() {
1055                    self.client
1056                        .log_message(
1057                            MessageType::INFO,
1058                            format!("found {} build diagnostics", builds.len()),
1059                        )
1060                        .await;
1061                }
1062                all_diagnostics.append(&mut builds);
1063            }
1064            Err(e) => {
1065                self.client
1066                    .log_message(
1067                        MessageType::WARNING,
1068                        format!("Forge build diagnostics failed: {e}"),
1069                    )
1070                    .await;
1071            }
1072        }
1073
1074        // Sanitize: some LSP clients (e.g. trunk.io) crash on diagnostics with
1075        // empty message fields. Replace any empty message with a safe fallback
1076        // before publishing regardless of which diagnostic source produced it.
1077        for diag in &mut all_diagnostics {
1078            if diag.message.is_empty() {
1079                diag.message = "Unknown issue".to_string();
1080            }
1081        }
1082
1083        // Publish diagnostics immediately — don't block on project indexing.
1084        self.client
1085            .publish_diagnostics(uri, all_diagnostics, None)
1086            .await;
1087
1088        // Cross-file diagnostics: publish errors to affected files and clear
1089        // stale errors from files that no longer have cross-file issues.
1090        {
1091            let mut prev_uris = self.cross_file_diag_uris.write().await;
1092            let mut new_uris = HashSet::new();
1093
1094            for (abs_path, diags) in &cross_file_diags {
1095                if let Ok(file_uri) = Url::from_file_path(abs_path) {
1096                    self.client
1097                        .publish_diagnostics(file_uri.clone(), diags.clone(), None)
1098                        .await;
1099                    new_uris.insert(file_uri);
1100                }
1101            }
1102
1103            // Clear diagnostics from files that previously had cross-file
1104            // errors but no longer do (e.g. the error was fixed).
1105            for stale_uri in prev_uris.difference(&new_uris) {
1106                self.client
1107                    .publish_diagnostics(stale_uri.clone(), vec![], None)
1108                    .await;
1109            }
1110
1111            *prev_uris = new_uris;
1112        }
1113
1114        // Refresh inlay hints after everything is updated
1115        if build_succeeded {
1116            let client = self.client.clone();
1117            tokio::spawn(async move {
1118                let _ = client.inlay_hint_refresh().await;
1119            });
1120        }
1121
1122        // Trigger project index in the background on first successful build.
1123        // This compiles all project files (src, test, script) in a single solc
1124        // invocation so that cross-file features (references, rename) discover
1125        // the full project. Runs asynchronously after diagnostics are published
1126        // so the user sees diagnostics immediately without waiting for the index.
1127        if build_succeeded
1128            && self.use_solc
1129            && self.settings.read().await.project_index.full_project_scan
1130            && !self
1131                .project_indexed
1132                .load(std::sync::atomic::Ordering::Relaxed)
1133        {
1134            let cache_mode = self.settings.read().await.project_index.cache_mode.clone();
1135            self.project_indexed
1136                .store(true, std::sync::atomic::Ordering::Relaxed);
1137            let foundry_config = self.foundry_config.read().await.clone();
1138            let cache_key = self.project_cache_key().await;
1139            let ast_cache = self.ast_cache.clone();
1140            let client = self.client.clone();
1141            let path_interner = self.path_interner.clone();
1142
1143            tokio::spawn(async move {
1144                let Some(cache_key) = cache_key else {
1145                    return;
1146                };
1147                if !foundry_config.root.is_dir() {
1148                    client
1149                        .log_message(
1150                            MessageType::INFO,
1151                            format!(
1152                                "project index: {} not found, skipping",
1153                                foundry_config.root.display(),
1154                            ),
1155                        )
1156                        .await;
1157                    return;
1158                }
1159
1160                // Create a progress token to show indexing status in the editor.
1161                let token = NumberOrString::String("solidity/projectIndex".to_string());
1162                let _ = client
1163                    .send_request::<request::WorkDoneProgressCreate>(WorkDoneProgressCreateParams {
1164                        token: token.clone(),
1165                    })
1166                    .await;
1167
1168                // Begin progress: show spinner in the status bar.
1169                client
1170                    .send_notification::<notification::Progress>(ProgressParams {
1171                        token: token.clone(),
1172                        value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(
1173                            WorkDoneProgressBegin {
1174                                title: "Indexing project".to_string(),
1175                                message: Some("Discovering source files...".to_string()),
1176                                cancellable: Some(false),
1177                                percentage: None,
1178                            },
1179                        )),
1180                    })
1181                    .await;
1182
1183                // Try persisted reference index first (fast warm start).
1184                let cfg_for_load = foundry_config.clone();
1185                let cache_mode_for_load = cache_mode.clone();
1186                let load_res = tokio::task::spawn_blocking(move || {
1187                    crate::project_cache::load_reference_cache_with_report(
1188                        &cfg_for_load,
1189                        cache_mode_for_load,
1190                        true,
1191                    )
1192                })
1193                .await;
1194                match load_res {
1195                    Ok(report) => {
1196                        if let Some(cached_build) = report.build {
1197                            let source_count = cached_build.nodes.len();
1198                            ast_cache
1199                                .write()
1200                                .await
1201                                .insert(cache_key.clone().into(), Arc::new(cached_build));
1202                            client
1203                                .log_message(
1204                                    MessageType::INFO,
1205                                    format!(
1206                                        "project index: cache load hit (sources={}, reused_files={}/{}, complete={}, duration={}ms)",
1207                                        source_count,
1208                                        report.file_count_reused,
1209                                        report.file_count_hashed,
1210                                        report.complete,
1211                                        report.duration_ms
1212                                    ),
1213                                )
1214                                .await;
1215                            if report.complete {
1216                                client
1217                                    .send_notification::<notification::Progress>(ProgressParams {
1218                                        token: token.clone(),
1219                                        value: ProgressParamsValue::WorkDone(
1220                                            WorkDoneProgress::End(WorkDoneProgressEnd {
1221                                                message: Some(format!(
1222                                                    "Loaded {} source files from cache",
1223                                                    source_count
1224                                                )),
1225                                            }),
1226                                        ),
1227                                    })
1228                                    .await;
1229                                return;
1230                            }
1231                        }
1232
1233                        client
1234                            .log_message(
1235                                MessageType::INFO,
1236                                format!(
1237                                    "project index: cache load miss/partial (reason={}, reused_files={}/{}, duration={}ms)",
1238                                    report
1239                                        .miss_reason
1240                                        .unwrap_or_else(|| "unknown".to_string()),
1241                                    report.file_count_reused,
1242                                    report.file_count_hashed,
1243                                    report.duration_ms
1244                                ),
1245                            )
1246                            .await;
1247                    }
1248                    Err(e) => {
1249                        client
1250                            .log_message(
1251                                MessageType::WARNING,
1252                                format!("project index: cache load task failed: {e}"),
1253                            )
1254                            .await;
1255                    }
1256                }
1257
1258                match crate::solc::solc_project_index(&foundry_config, Some(&client), None).await {
1259                    Ok(ast_data) => {
1260                        let mut new_build = crate::goto::CachedBuild::new(
1261                            ast_data,
1262                            0,
1263                            Some(&mut *path_interner.write().await),
1264                        );
1265                        // Merge any files from the previous cache that the
1266                        // new build doesn't cover (preserves warm-loaded data).
1267                        if let Some(prev) = ast_cache.read().await.get(&cache_key) {
1268                            new_build.merge_missing_from(prev);
1269                        }
1270                        let source_count = new_build.nodes.len();
1271                        let cached_build = Arc::new(new_build);
1272                        let build_for_save = (*cached_build).clone();
1273                        ast_cache
1274                            .write()
1275                            .await
1276                            .insert(cache_key.clone().into(), cached_build);
1277                        client
1278                            .log_message(
1279                                MessageType::INFO,
1280                                format!("project index: cached {} source files", source_count),
1281                            )
1282                            .await;
1283
1284                        let cfg_for_save = foundry_config.clone();
1285                        let client_for_save = client.clone();
1286                        tokio::spawn(async move {
1287                            let res = tokio::task::spawn_blocking(move || {
1288                                crate::project_cache::save_reference_cache_with_report(
1289                                    &cfg_for_save,
1290                                    &build_for_save,
1291                                    None,
1292                                )
1293                            })
1294                            .await;
1295                            match res {
1296                                Ok(Ok(report)) => {
1297                                    client_for_save
1298                                        .log_message(
1299                                            MessageType::INFO,
1300                                            format!(
1301                                                "project index: cache save complete (hashed_files={}, duration={}ms)",
1302                                                report.file_count_hashed, report.duration_ms
1303                                            ),
1304                                        )
1305                                        .await;
1306                                }
1307                                Ok(Err(e)) => {
1308                                    client_for_save
1309                                        .log_message(
1310                                            MessageType::WARNING,
1311                                            format!("project index: failed to persist cache: {e}"),
1312                                        )
1313                                        .await;
1314                                }
1315                                Err(e) => {
1316                                    client_for_save
1317                                        .log_message(
1318                                            MessageType::WARNING,
1319                                            format!("project index: cache save task failed: {e}"),
1320                                        )
1321                                        .await;
1322                                }
1323                            }
1324                        });
1325
1326                        // End progress: indexing complete.
1327                        client
1328                            .send_notification::<notification::Progress>(ProgressParams {
1329                                token: token.clone(),
1330                                value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(
1331                                    WorkDoneProgressEnd {
1332                                        message: Some(format!(
1333                                            "Indexed {} source files",
1334                                            source_count
1335                                        )),
1336                                    },
1337                                )),
1338                            })
1339                            .await;
1340                    }
1341                    Err(e) => {
1342                        client
1343                            .log_message(MessageType::WARNING, format!("project index failed: {e}"))
1344                            .await;
1345
1346                        // End progress on failure too.
1347                        client
1348                            .send_notification::<notification::Progress>(ProgressParams {
1349                                token: token.clone(),
1350                                value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(
1351                                    WorkDoneProgressEnd {
1352                                        message: Some("Indexing failed".to_string()),
1353                                    },
1354                                )),
1355                            })
1356                            .await;
1357                    }
1358                }
1359            });
1360        }
1361    }
1362
1363    /// Get a CachedBuild from the cache, or fetch and build one on demand.
1364    /// If `insert_on_miss` is true, the freshly-built entry is inserted into the cache
1365    /// (used by references handler so cross-file lookups can find it later).
1366    ///
1367    /// When the entry is in the cache but marked stale (text_cache changed
1368    /// since the last build), the text_cache content is flushed to disk and
1369    /// the AST is rebuilt so that rename / references work correctly on
1370    /// unsaved buffers.
1371    async fn get_or_fetch_build(
1372        &self,
1373        uri: &Url,
1374        file_path: &std::path::Path,
1375        insert_on_miss: bool,
1376    ) -> Option<Arc<goto::CachedBuild>> {
1377        let uri_str = uri.to_string();
1378
1379        // Return cached entry if it exists (stale or not — stale entries are
1380        // still usable, positions may be slightly off like goto-definition).
1381        {
1382            let cache = self.ast_cache.read().await;
1383            if let Some(cached) = cache.get(&uri_str) {
1384                return Some(cached.clone());
1385            }
1386        }
1387
1388        // Cache miss — if caller doesn't want to trigger a build, return None.
1389        // This prevents inlay hints, code lens, etc. from blocking on a full
1390        // solc/forge build. The cache will be populated by on_change (did_open/did_save).
1391        if !insert_on_miss {
1392            return None;
1393        }
1394
1395        // Cache miss — build the AST from disk.  Use open editor buffers
1396        // when available so solc sees unsaved edits.
1397        let path_str = file_path.to_str()?;
1398        let ast_result = if self.use_solc {
1399            let foundry_cfg = self.foundry_config_for_file(file_path).await;
1400            // Use the text_cache buffer if this file is open in the editor,
1401            // otherwise let solc read from disk.
1402            let cached_text = {
1403                let tc = self.text_cache.read().await;
1404                tc.get(&uri_str).map(|(_, c)| c.clone())
1405            };
1406            match crate::solc::solc_ast(
1407                path_str,
1408                &foundry_cfg,
1409                Some(&self.client),
1410                cached_text.as_deref(),
1411            )
1412            .await
1413            {
1414                Ok(data) => Ok(data),
1415                Err(_) => self.compiler.ast(path_str).await,
1416            }
1417        } else {
1418            self.compiler.ast(path_str).await
1419        };
1420        match ast_result {
1421            Ok(data) => {
1422                // Built from disk (cache miss) — use version 0; the next
1423                // didSave/on_change will stamp the correct version.
1424                let build = Arc::new(goto::CachedBuild::new(
1425                    data,
1426                    0,
1427                    Some(&mut *self.path_interner.write().await),
1428                ));
1429                let mut cache = self.ast_cache.write().await;
1430                cache.insert(uri_str.clone().into(), build.clone());
1431                Some(build)
1432            }
1433            Err(e) => {
1434                self.client
1435                    .log_message(MessageType::ERROR, format!("failed to get AST: {e}"))
1436                    .await;
1437                None
1438            }
1439        }
1440    }
1441
1442    /// Get the source bytes for a file, preferring the in-memory text cache
1443    /// (which reflects unsaved editor changes) over reading from disk.
1444    async fn get_source_bytes(&self, uri: &Url, file_path: &std::path::Path) -> Option<Vec<u8>> {
1445        {
1446            let text_cache = self.text_cache.read().await;
1447            if let Some((_, content)) = text_cache.get(&uri.to_string()) {
1448                return Some(content.as_bytes().to_vec());
1449            }
1450        }
1451        match std::fs::read(file_path) {
1452            Ok(bytes) => Some(bytes),
1453            Err(e) => {
1454                if e.kind() == std::io::ErrorKind::NotFound {
1455                    // Benign during create/delete races when the editor emits
1456                    // didOpen/didChange before the file is materialized on disk.
1457                    self.client
1458                        .log_message(
1459                            MessageType::INFO,
1460                            format!("file not found yet (transient): {e}"),
1461                        )
1462                        .await;
1463                } else {
1464                    self.client
1465                        .log_message(MessageType::ERROR, format!("failed to read file: {e}"))
1466                        .await;
1467                }
1468                None
1469            }
1470        }
1471    }
1472}
1473
1474fn update_imports_on_delete_enabled(settings: &crate::config::Settings) -> bool {
1475    settings.file_operations.update_imports_on_delete
1476}
1477
1478fn start_or_mark_project_cache_sync_pending(
1479    pending: &std::sync::atomic::AtomicBool,
1480    running: &std::sync::atomic::AtomicBool,
1481) -> bool {
1482    pending.store(true, Ordering::Release);
1483    running
1484        .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1485        .is_ok()
1486}
1487
1488fn take_project_cache_sync_pending(pending: &std::sync::atomic::AtomicBool) -> bool {
1489    pending.swap(false, Ordering::AcqRel)
1490}
1491
1492fn stop_project_cache_sync_worker_or_reclaim(
1493    pending: &std::sync::atomic::AtomicBool,
1494    running: &std::sync::atomic::AtomicBool,
1495) -> bool {
1496    running.store(false, Ordering::Release);
1497    pending.load(Ordering::Acquire)
1498        && running
1499            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1500            .is_ok()
1501}
1502
1503fn try_claim_project_cache_dirty(dirty: &std::sync::atomic::AtomicBool) -> bool {
1504    dirty
1505        .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
1506        .is_ok()
1507}
1508
1509fn start_or_mark_project_cache_upsert_pending(
1510    pending: &std::sync::atomic::AtomicBool,
1511    running: &std::sync::atomic::AtomicBool,
1512) -> bool {
1513    pending.store(true, Ordering::Release);
1514    running
1515        .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1516        .is_ok()
1517}
1518
1519fn take_project_cache_upsert_pending(pending: &std::sync::atomic::AtomicBool) -> bool {
1520    pending.swap(false, Ordering::AcqRel)
1521}
1522
1523fn stop_project_cache_upsert_worker_or_reclaim(
1524    pending: &std::sync::atomic::AtomicBool,
1525    running: &std::sync::atomic::AtomicBool,
1526) -> bool {
1527    running.store(false, Ordering::Release);
1528    pending.load(Ordering::Acquire)
1529        && running
1530            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1531            .is_ok()
1532}
1533
1534fn lexical_normalize(path: &Path) -> PathBuf {
1535    let mut out = PathBuf::new();
1536    for comp in path.components() {
1537        match comp {
1538            Component::CurDir => {}
1539            Component::ParentDir => {
1540                out.pop();
1541            }
1542            Component::RootDir => out.push(comp.as_os_str()),
1543            Component::Prefix(_) => out.push(comp.as_os_str()),
1544            Component::Normal(seg) => out.push(seg),
1545        }
1546    }
1547    out
1548}
1549
1550fn resolve_import_spec_to_abs(
1551    project_root: &Path,
1552    importer_abs: &Path,
1553    import_path: &str,
1554    remappings: &[String],
1555) -> Option<PathBuf> {
1556    if import_path.starts_with("./") || import_path.starts_with("../") {
1557        let base = importer_abs.parent()?;
1558        return Some(lexical_normalize(&base.join(import_path)));
1559    }
1560
1561    for remap in remappings {
1562        let mut it = remap.splitn(2, '=');
1563        let prefix = it.next().unwrap_or_default();
1564        let target = it.next().unwrap_or_default();
1565        if prefix.is_empty() || target.is_empty() {
1566            continue;
1567        }
1568        if import_path.starts_with(prefix) {
1569            let suffix = import_path.strip_prefix(prefix).unwrap_or_default();
1570            return Some(lexical_normalize(
1571                &project_root.join(format!("{target}{suffix}")),
1572            ));
1573        }
1574    }
1575
1576    Some(lexical_normalize(&project_root.join(import_path)))
1577}
1578
1579fn compute_reverse_import_closure(
1580    config: &FoundryConfig,
1581    changed_abs: &[PathBuf],
1582    remappings: &[String],
1583) -> HashSet<PathBuf> {
1584    let source_files = crate::solc::discover_source_files(config);
1585    let mut reverse_edges: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
1586
1587    for importer in &source_files {
1588        let Ok(bytes) = std::fs::read(importer) else {
1589            continue;
1590        };
1591        for imp in links::ts_find_imports(&bytes) {
1592            let Some(imported_abs) =
1593                resolve_import_spec_to_abs(&config.root, importer, &imp.path, remappings)
1594            else {
1595                continue;
1596            };
1597            if !imported_abs.starts_with(&config.root) {
1598                continue;
1599            }
1600            reverse_edges
1601                .entry(imported_abs)
1602                .or_default()
1603                .insert(importer.clone());
1604        }
1605    }
1606
1607    let mut affected: HashSet<PathBuf> = HashSet::new();
1608    let mut queue: std::collections::VecDeque<PathBuf> = std::collections::VecDeque::new();
1609
1610    for path in changed_abs {
1611        if !path.starts_with(&config.root) {
1612            continue;
1613        }
1614        let normalized = lexical_normalize(path);
1615        if affected.insert(normalized.clone()) {
1616            queue.push_back(normalized);
1617        }
1618    }
1619
1620    while let Some(current) = queue.pop_front() {
1621        if let Some(importers) = reverse_edges.get(&current) {
1622            for importer in importers {
1623                if affected.insert(importer.clone()) {
1624                    queue.push_back(importer.clone());
1625                }
1626            }
1627        }
1628    }
1629
1630    // Keep only files that currently exist and are source files known to the project.
1631    let source_set: HashSet<PathBuf> = source_files.into_iter().collect();
1632    affected
1633        .into_iter()
1634        .filter(|p| source_set.contains(p) && p.is_file())
1635        .collect()
1636}
1637
1638fn src_file_id(src: &str) -> Option<&str> {
1639    src.rsplit(':').next().filter(|id| !id.is_empty())
1640}
1641
1642fn doc_key_path(key: &hover::DocKey) -> Option<&str> {
1643    match key {
1644        hover::DocKey::Contract(k) | hover::DocKey::StateVar(k) | hover::DocKey::Method(k) => {
1645            k.split_once(':').map(|(path, _)| path)
1646        }
1647        hover::DocKey::Func(_) | hover::DocKey::Event(_) => None,
1648    }
1649}
1650
1651fn merge_scoped_cached_build(
1652    existing: &mut goto::CachedBuild,
1653    scoped: goto::CachedBuild,
1654) -> Result<usize, String> {
1655    let affected_paths: HashSet<String> = scoped.nodes.keys().map(|p| p.to_string()).collect();
1656    if affected_paths.is_empty() {
1657        return Ok(0);
1658    }
1659    let affected_abs_paths: HashSet<crate::types::AbsPath> =
1660        scoped.path_to_abs.values().cloned().collect();
1661
1662    // Safety guard: reject scoped merge when declaration IDs collide with
1663    // unaffected files in the existing cache.
1664    for scoped_id in scoped.decl_index.keys() {
1665        if existing.decl_index.contains_key(scoped_id)
1666            && let Some(path) = existing.node_id_to_source_path.get(scoped_id)
1667            && !affected_abs_paths.contains(path)
1668        {
1669            return Err(format!(
1670                "decl id collision for id={} in unaffected path {}",
1671                scoped_id, path
1672            ));
1673        }
1674    }
1675
1676    // With the PathInterner, both builds already use canonical file IDs.
1677    // No file-ID remapping is needed — just remove affected entries from
1678    // existing and insert the scoped entries directly.
1679
1680    let old_id_to_path = existing.id_to_path_map.clone();
1681    existing.external_refs.retain(|src, _| {
1682        src_file_id(src.as_str())
1683            .and_then(|fid| old_id_to_path.get(fid))
1684            .map(|path| !affected_paths.contains(path))
1685            .unwrap_or(true)
1686    });
1687    existing
1688        .nodes
1689        .retain(|path, _| !affected_paths.contains(path.as_str()));
1690    existing
1691        .path_to_abs
1692        .retain(|path, _| !affected_paths.contains(path.as_str()));
1693    existing
1694        .id_to_path_map
1695        .retain(|_, path| !affected_paths.contains(path));
1696
1697    existing
1698        .node_id_to_source_path
1699        .retain(|_, path| !affected_abs_paths.contains(path));
1700    existing
1701        .decl_index
1702        .retain(|id, _| match existing.node_id_to_source_path.get(id) {
1703            Some(path) => !affected_abs_paths.contains(path),
1704            None => true,
1705        });
1706    existing
1707        .hint_index
1708        .retain(|abs_path, _| !affected_abs_paths.contains(abs_path));
1709    existing.doc_index.retain(|k, _| {
1710        doc_key_path(k)
1711            .map(|p| !affected_paths.contains(p))
1712            .unwrap_or(true)
1713    });
1714    existing.nodes.extend(scoped.nodes);
1715    existing.path_to_abs.extend(scoped.path_to_abs);
1716    existing.external_refs.extend(scoped.external_refs);
1717    existing.id_to_path_map.extend(scoped.id_to_path_map);
1718    existing.decl_index.extend(scoped.decl_index);
1719    existing
1720        .node_id_to_source_path
1721        .extend(scoped.node_id_to_source_path);
1722    existing.hint_index.extend(scoped.hint_index);
1723    existing.doc_index.extend(scoped.doc_index);
1724
1725    Ok(affected_paths.len())
1726}
1727
1728/// Core per-save work: compile, diagnostics, cache upsert.
1729///
1730/// Called from the per-URI worker loop (see `did_save_workers`).  Because the
1731/// worker serialises calls for the same URI, this function never runs
1732/// concurrently for the same document.
1733async fn run_did_save(this: ForgeLsp, params: DidSaveTextDocumentParams) {
1734    this.client
1735        .log_message(MessageType::INFO, "file saved")
1736        .await;
1737
1738    let mut text_content = if let Some(text) = params.text {
1739        text
1740    } else {
1741        // Prefer text_cache (reflects unsaved changes), fall back to disk
1742        let cached = {
1743            let text_cache = this.text_cache.read().await;
1744            text_cache
1745                .get(params.text_document.uri.as_str())
1746                .map(|(_, content)| content.clone())
1747        };
1748        if let Some(content) = cached {
1749            content
1750        } else {
1751            match std::fs::read_to_string(params.text_document.uri.path()) {
1752                Ok(content) => content,
1753                Err(e) => {
1754                    this.client
1755                        .log_message(
1756                            MessageType::ERROR,
1757                            format!("Failed to read file on save: {e}"),
1758                        )
1759                        .await;
1760                    return;
1761                }
1762            }
1763        }
1764    };
1765
1766    // Recovery path for create-file races:
1767    // if a newly-created file is still whitespace-only at first save,
1768    // regenerate scaffold and apply it to the open buffer.
1769    let uri_str = params.text_document.uri.to_string();
1770    let template_on_create = this
1771        .settings
1772        .read()
1773        .await
1774        .file_operations
1775        .template_on_create;
1776    let needs_recover_scaffold = {
1777        let pending = this.pending_create_scaffold.read().await;
1778        template_on_create
1779            && pending.contains(&uri_str)
1780            && !text_content.chars().any(|ch| !ch.is_whitespace())
1781    };
1782    if needs_recover_scaffold {
1783        let solc_version = this.foundry_config.read().await.solc_version.clone();
1784        if let Some(scaffold) =
1785            file_operations::generate_scaffold(&params.text_document.uri, solc_version.as_deref())
1786        {
1787            let end = utils::byte_offset_to_position(&text_content, text_content.len());
1788            let edit = WorkspaceEdit {
1789                changes: Some(HashMap::from([(
1790                    params.text_document.uri.clone(),
1791                    vec![TextEdit {
1792                        range: Range {
1793                            start: Position::default(),
1794                            end,
1795                        },
1796                        new_text: scaffold.clone(),
1797                    }],
1798                )])),
1799                document_changes: None,
1800                change_annotations: None,
1801            };
1802            if this
1803                .client
1804                .apply_edit(edit)
1805                .await
1806                .as_ref()
1807                .is_ok_and(|r| r.applied)
1808            {
1809                text_content = scaffold.clone();
1810                let version = this
1811                    .text_cache
1812                    .read()
1813                    .await
1814                    .get(params.text_document.uri.as_str())
1815                    .map(|(v, _)| *v)
1816                    .unwrap_or_default();
1817                this.text_cache
1818                    .write()
1819                    .await
1820                    .insert(uri_str.clone().into(), (version, scaffold));
1821                this.pending_create_scaffold.write().await.remove(&uri_str);
1822                this.client
1823                    .log_message(
1824                        MessageType::INFO,
1825                        format!("didSave: recovered scaffold for {}", uri_str),
1826                    )
1827                    .await;
1828            }
1829        }
1830    }
1831
1832    let version = this
1833        .text_cache
1834        .read()
1835        .await
1836        .get(params.text_document.uri.as_str())
1837        .map(|(version, _)| *version)
1838        .unwrap_or_default();
1839
1840    let saved_uri = params.text_document.uri.clone();
1841    if let Ok(saved_file_path) = saved_uri.to_file_path() {
1842        let saved_abs = saved_file_path.to_string_lossy().to_string();
1843        this.project_cache_changed_files
1844            .write()
1845            .await
1846            .insert(saved_abs.clone());
1847        this.project_cache_upsert_files
1848            .write()
1849            .await
1850            .insert(saved_abs);
1851    }
1852    this.on_change(TextDocumentItem {
1853        uri: saved_uri.clone(),
1854        text: text_content,
1855        version,
1856        language_id: "".to_string(),
1857    })
1858    .await;
1859
1860    let settings_snapshot = this.settings.read().await.clone();
1861
1862    // Fast-path incremental v2 cache upsert on save (debounced single-flight):
1863    // serialize the authoritative root-key CachedBuild to disk, writing
1864    // shards only for recently changed files.  Global metadata (path_to_abs,
1865    // id_to_path_map, external_refs) comes from the merged in-memory build
1866    // which has correct globally-remapped file IDs.
1867    if this.use_solc
1868        && settings_snapshot.project_index.full_project_scan
1869        && matches!(
1870            settings_snapshot.project_index.cache_mode,
1871            crate::config::ProjectIndexCacheMode::V2 | crate::config::ProjectIndexCacheMode::Auto
1872        )
1873        && start_or_mark_project_cache_upsert_pending(
1874            &this.project_cache_upsert_pending,
1875            &this.project_cache_upsert_running,
1876        )
1877    {
1878        let upsert_files = this.project_cache_upsert_files.clone();
1879        let ast_cache = this.ast_cache.clone();
1880        let client = this.client.clone();
1881        let running_flag = this.project_cache_upsert_running.clone();
1882        let pending_flag = this.project_cache_upsert_pending.clone();
1883        let foundry_config = this.foundry_config.read().await.clone();
1884        let root_key = this.project_cache_key().await;
1885
1886        tokio::spawn(async move {
1887            loop {
1888                tokio::time::sleep(std::time::Duration::from_millis(350)).await;
1889
1890                if !take_project_cache_upsert_pending(&pending_flag) {
1891                    if stop_project_cache_upsert_worker_or_reclaim(&pending_flag, &running_flag) {
1892                        continue;
1893                    }
1894                    break;
1895                }
1896
1897                let changed_paths: Vec<String> = {
1898                    let mut paths = upsert_files.write().await;
1899                    paths.drain().collect()
1900                };
1901                if changed_paths.is_empty() {
1902                    continue;
1903                }
1904
1905                // Read the authoritative merged root-key build from
1906                // ast_cache.  This is the CachedBuild that
1907                // merge_scoped_cached_build has already remapped with
1908                // correct global file IDs.
1909                let Some(ref rk) = root_key else {
1910                    continue;
1911                };
1912                let Some(root_build) = ast_cache.read().await.get(rk).cloned() else {
1913                    continue;
1914                };
1915
1916                let cfg = foundry_config.clone();
1917                let build = (*root_build).clone();
1918                let changed = changed_paths.clone();
1919
1920                let res = tokio::task::spawn_blocking(move || {
1921                    crate::project_cache::upsert_reference_cache_v2_with_report(
1922                        &cfg, &build, &changed,
1923                    )
1924                })
1925                .await;
1926
1927                match res {
1928                    Ok(Ok(report)) => {
1929                        client
1930                                .log_message(
1931                                    MessageType::INFO,
1932                                    format!(
1933                                        "project cache v2 upsert (debounced): touched_files={}, duration={}ms",
1934                                        report.file_count_hashed, report.duration_ms
1935                                    ),
1936                                )
1937                                .await;
1938                    }
1939                    Ok(Err(e)) => {
1940                        client
1941                            .log_message(
1942                                MessageType::WARNING,
1943                                format!("project cache v2 upsert: {e}"),
1944                            )
1945                            .await;
1946                    }
1947                    Err(e) => {
1948                        client
1949                            .log_message(
1950                                MessageType::WARNING,
1951                                format!("project cache v2 upsert task failed: {e}"),
1952                            )
1953                            .await;
1954                    }
1955                }
1956            }
1957        });
1958    }
1959
1960    // If workspace file-ops changed project structure, schedule a
1961    // debounced latest-wins sync of on-disk reference cache.
1962    if this.use_solc
1963        && settings_snapshot.project_index.full_project_scan
1964        && this.project_cache_dirty.load(Ordering::Acquire)
1965        && start_or_mark_project_cache_sync_pending(
1966            &this.project_cache_sync_pending,
1967            &this.project_cache_sync_running,
1968        )
1969    {
1970        let foundry_config = this.foundry_config.read().await.clone();
1971        let root_key = this.project_cache_key().await;
1972        let ast_cache = this.ast_cache.clone();
1973        let text_cache = this.text_cache.clone();
1974        let client = this.client.clone();
1975        let dirty_flag = this.project_cache_dirty.clone();
1976        let running_flag = this.project_cache_sync_running.clone();
1977        let pending_flag = this.project_cache_sync_pending.clone();
1978        let changed_files = this.project_cache_changed_files.clone();
1979        let aggressive_scoped = settings_snapshot.project_index.incremental_edit_reindex;
1980        let force_full_rebuild_flag = this.project_cache_force_full_rebuild.clone();
1981        let path_interner = this.path_interner.clone();
1982
1983        tokio::spawn(async move {
1984            loop {
1985                // Debounce save bursts into one trailing sync.
1986                tokio::time::sleep(std::time::Duration::from_millis(700)).await;
1987
1988                if !take_project_cache_sync_pending(&pending_flag) {
1989                    if stop_project_cache_sync_worker_or_reclaim(&pending_flag, &running_flag) {
1990                        continue;
1991                    }
1992                    break;
1993                }
1994
1995                if !try_claim_project_cache_dirty(&dirty_flag) {
1996                    continue;
1997                }
1998
1999                let Some(cache_key) = &root_key else {
2000                    dirty_flag.store(true, Ordering::Release);
2001                    continue;
2002                };
2003                if !foundry_config.root.is_dir() {
2004                    dirty_flag.store(true, Ordering::Release);
2005                    client
2006                        .log_message(
2007                            MessageType::WARNING,
2008                            format!(
2009                                "didSave cache sync: invalid project root {}, deferring",
2010                                foundry_config.root.display()
2011                            ),
2012                        )
2013                        .await;
2014                    continue;
2015                }
2016
2017                let mut scoped_ok = false;
2018
2019                // If solidity.reindex was called while this worker was
2020                // already running, bypass the incremental scoped path and
2021                // do a full rebuild instead.
2022                let force_full = force_full_rebuild_flag.swap(false, Ordering::AcqRel);
2023
2024                if aggressive_scoped && !force_full {
2025                    let changed_abs: Vec<PathBuf> = {
2026                        let mut changed = changed_files.write().await;
2027
2028                        changed.drain().map(PathBuf::from).collect::<Vec<PathBuf>>()
2029                    };
2030                    if !changed_abs.is_empty() {
2031                        let remappings = crate::solc::resolve_remappings(&foundry_config).await;
2032                        let cfg_for_plan = foundry_config.clone();
2033                        let changed_for_plan = changed_abs.clone();
2034                        let remappings_for_plan = remappings.clone();
2035                        let plan_res = tokio::task::spawn_blocking(move || {
2036                            compute_reverse_import_closure(
2037                                &cfg_for_plan,
2038                                &changed_for_plan,
2039                                &remappings_for_plan,
2040                            )
2041                        })
2042                        .await;
2043
2044                        let affected_files = match plan_res {
2045                            Ok(set) => set.into_iter().collect::<Vec<PathBuf>>(),
2046                            Err(_) => Vec::new(),
2047                        };
2048                        if !affected_files.is_empty() {
2049                            client
2050                                    .log_message(
2051                                        MessageType::INFO,
2052                                        format!(
2053                                            "didSave cache sync: aggressive scoped reindex (affected={})",
2054                                            affected_files.len(),
2055                                        ),
2056                                    )
2057                                    .await;
2058
2059                            let text_cache_snapshot = text_cache.read().await.clone();
2060                            match crate::solc::solc_project_index_scoped(
2061                                &foundry_config,
2062                                Some(&client),
2063                                Some(&text_cache_snapshot),
2064                                &affected_files,
2065                            )
2066                            .await
2067                            {
2068                                Ok(ast_data) => {
2069                                    let scoped_build = Arc::new(crate::goto::CachedBuild::new(
2070                                        ast_data,
2071                                        0,
2072                                        Some(&mut *path_interner.write().await),
2073                                    ));
2074                                    let source_count = scoped_build.nodes.len();
2075                                    enum ScopedApply {
2076                                        Merged { affected_count: usize },
2077                                        Stored,
2078                                        Failed(String),
2079                                    }
2080                                    let apply_outcome = {
2081                                        let mut cache = ast_cache.write().await;
2082                                        if let Some(existing) = cache.get(cache_key).cloned() {
2083                                            let mut merged = (*existing).clone();
2084                                            match merge_scoped_cached_build(
2085                                                &mut merged,
2086                                                (*scoped_build).clone(),
2087                                            ) {
2088                                                Ok(affected_count) => {
2089                                                    cache.insert(
2090                                                        cache_key.clone().into(),
2091                                                        Arc::new(merged),
2092                                                    );
2093                                                    ScopedApply::Merged { affected_count }
2094                                                }
2095                                                Err(e) => ScopedApply::Failed(e),
2096                                            }
2097                                        } else {
2098                                            cache.insert(cache_key.clone().into(), scoped_build);
2099                                            ScopedApply::Stored
2100                                        }
2101                                    };
2102
2103                                    match apply_outcome {
2104                                        ScopedApply::Merged { affected_count } => {
2105                                            client
2106                                                    .log_message(
2107                                                        MessageType::INFO,
2108                                                        format!(
2109                                                            "didSave cache sync: scoped merge applied (scoped_sources={}, affected_paths={})",
2110                                                            source_count, affected_count
2111                                                        ),
2112                                                    )
2113                                                    .await;
2114                                            scoped_ok = true;
2115                                        }
2116                                        ScopedApply::Stored => {
2117                                            client
2118                                                    .log_message(
2119                                                        MessageType::INFO,
2120                                                        format!(
2121                                                            "didSave cache sync: scoped cache stored (scoped_sources={})",
2122                                                            source_count
2123                                                        ),
2124                                                    )
2125                                                    .await;
2126                                            scoped_ok = true;
2127                                        }
2128                                        ScopedApply::Failed(e) => {
2129                                            client
2130                                                .log_message(
2131                                                    MessageType::WARNING,
2132                                                    format!(
2133                                                        "didSave cache sync: scoped merge rejected, will retry scoped on next save: {e}"
2134                                                    ),
2135                                                )
2136                                                .await;
2137                                            dirty_flag.store(true, Ordering::Release);
2138                                        }
2139                                    }
2140                                }
2141                                Err(e) => {
2142                                    client
2143                                            .log_message(
2144                                                MessageType::WARNING,
2145                                                format!(
2146                                                    "didSave cache sync: scoped reindex failed, will retry scoped on next save: {e}"
2147                                                ),
2148                                            )
2149                                            .await;
2150                                    dirty_flag.store(true, Ordering::Release);
2151                                }
2152                            }
2153                        } else {
2154                            client
2155                                .log_message(
2156                                    MessageType::INFO,
2157                                    "didSave cache sync: no affected files from scoped planner",
2158                                )
2159                                .await;
2160                        }
2161                    }
2162                }
2163
2164                if scoped_ok {
2165                    continue;
2166                }
2167                if aggressive_scoped {
2168                    continue;
2169                }
2170
2171                client
2172                    .log_message(
2173                        MessageType::INFO,
2174                        "didSave cache sync: rebuilding project index from disk",
2175                    )
2176                    .await;
2177
2178                match crate::solc::solc_project_index(&foundry_config, Some(&client), None).await {
2179                    Ok(ast_data) => {
2180                        let mut new_build = crate::goto::CachedBuild::new(
2181                            ast_data,
2182                            0,
2183                            Some(&mut *path_interner.write().await),
2184                        );
2185                        if let Some(prev) = ast_cache.read().await.get(cache_key) {
2186                            new_build.merge_missing_from(prev);
2187                        }
2188                        let source_count = new_build.nodes.len();
2189                        let cached_build = Arc::new(new_build);
2190                        let build_for_save = (*cached_build).clone();
2191                        ast_cache
2192                            .write()
2193                            .await
2194                            .insert(cache_key.clone().into(), cached_build);
2195
2196                        let cfg_for_save = foundry_config.clone();
2197                        let save_res = tokio::task::spawn_blocking(move || {
2198                            crate::project_cache::save_reference_cache_with_report(
2199                                &cfg_for_save,
2200                                &build_for_save,
2201                                None,
2202                            )
2203                        })
2204                        .await;
2205
2206                        match save_res {
2207                            Ok(Ok(report)) => {
2208                                changed_files.write().await.clear();
2209                                client
2210                                        .log_message(
2211                                            MessageType::INFO,
2212                                            format!(
2213                                                "didSave cache sync: persisted cache (sources={}, hashed_files={}, duration={}ms)",
2214                                                source_count, report.file_count_hashed, report.duration_ms
2215                                            ),
2216                                        )
2217                                        .await;
2218                            }
2219                            Ok(Err(e)) => {
2220                                dirty_flag.store(true, Ordering::Release);
2221                                client
2222                                    .log_message(
2223                                        MessageType::WARNING,
2224                                        format!(
2225                                            "didSave cache sync: persist failed, will retry: {e}"
2226                                        ),
2227                                    )
2228                                    .await;
2229                            }
2230                            Err(e) => {
2231                                dirty_flag.store(true, Ordering::Release);
2232                                client
2233                                    .log_message(
2234                                        MessageType::WARNING,
2235                                        format!(
2236                                            "didSave cache sync: save task failed, will retry: {e}"
2237                                        ),
2238                                    )
2239                                    .await;
2240                            }
2241                        }
2242                    }
2243                    Err(e) => {
2244                        dirty_flag.store(true, Ordering::Release);
2245                        client
2246                            .log_message(
2247                                MessageType::WARNING,
2248                                format!("didSave cache sync: re-index failed, will retry: {e}"),
2249                            )
2250                            .await;
2251                    }
2252                }
2253            }
2254        });
2255    }
2256}
2257
2258#[tower_lsp::async_trait]
2259impl LanguageServer for ForgeLsp {
2260    async fn initialize(
2261        &self,
2262        params: InitializeParams,
2263    ) -> tower_lsp::jsonrpc::Result<InitializeResult> {
2264        // Store client capabilities for use during `initialized()`.
2265        {
2266            let mut caps = self.client_capabilities.write().await;
2267            *caps = Some(params.capabilities.clone());
2268        }
2269
2270        // Read editor settings from initializationOptions.
2271        if let Some(init_opts) = &params.initialization_options {
2272            let s = config::parse_settings(init_opts);
2273            self.client
2274                .log_message(
2275                    MessageType::INFO,
2276                    format!(
2277                        "settings: inlayHints.parameters={}, lint.enabled={}, lint.severity={:?}, lint.only={:?}, lint.exclude={:?}, fileOperations.templateOnCreate={}, fileOperations.updateImportsOnRename={}, fileOperations.updateImportsOnDelete={}, projectIndex.fullProjectScan={}, projectIndex.cacheMode={:?}, projectIndex.incrementalEditReindex={}",
2278                        s.inlay_hints.parameters, 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, s.project_index.full_project_scan, s.project_index.cache_mode, s.project_index.incremental_edit_reindex,
2279                    ),
2280                )
2281                .await;
2282            let mut settings = self.settings.write().await;
2283            *settings = s;
2284            self.settings_from_init
2285                .store(true, std::sync::atomic::Ordering::Relaxed);
2286        }
2287
2288        // Store root URI for project-wide file discovery.
2289        if let Some(uri) = params.root_uri.as_ref() {
2290            let mut root = self.root_uri.write().await;
2291            *root = Some(uri.clone());
2292        }
2293
2294        // Load config from the workspace root's foundry.toml.
2295        if let Some(root_uri) = params
2296            .root_uri
2297            .as_ref()
2298            .and_then(|uri| uri.to_file_path().ok())
2299        {
2300            let lint_cfg = config::load_lint_config(&root_uri);
2301            self.client
2302                .log_message(
2303                    MessageType::INFO,
2304                    format!(
2305                        "loaded foundry.toml lint config: lint_on_build={}, ignore_patterns={}",
2306                        lint_cfg.lint_on_build,
2307                        lint_cfg.ignore_patterns.len()
2308                    ),
2309                )
2310                .await;
2311            let mut config = self.lint_config.write().await;
2312            *config = lint_cfg;
2313
2314            let foundry_cfg = config::load_foundry_config(&root_uri);
2315            self.client
2316                .log_message(
2317                    MessageType::INFO,
2318                    format!(
2319                        "loaded foundry.toml: solc={}, remappings={}",
2320                        foundry_cfg.solc_version.as_deref().unwrap_or("auto"),
2321                        foundry_cfg.remappings.len()
2322                    ),
2323                )
2324                .await;
2325            let mut fc = self.foundry_config.write().await;
2326            *fc = foundry_cfg;
2327        }
2328
2329        // Negotiate position encoding with the client (once, for the session).
2330        let client_encodings = params
2331            .capabilities
2332            .general
2333            .as_ref()
2334            .and_then(|g| g.position_encodings.as_deref());
2335        let encoding = utils::PositionEncoding::negotiate(client_encodings);
2336        utils::set_encoding(encoding);
2337
2338        Ok(InitializeResult {
2339            server_info: Some(ServerInfo {
2340                name: "Solidity Language Server".to_string(),
2341                version: Some(env!("LONG_VERSION").to_string()),
2342            }),
2343            capabilities: ServerCapabilities {
2344                position_encoding: Some(encoding.into()),
2345                completion_provider: Some(CompletionOptions {
2346                    trigger_characters: Some(vec![
2347                        ".".to_string(),
2348                        "\"".to_string(),
2349                        "'".to_string(),
2350                        "/".to_string(),
2351                    ]),
2352                    resolve_provider: Some(false),
2353                    ..Default::default()
2354                }),
2355                signature_help_provider: Some(SignatureHelpOptions {
2356                    trigger_characters: Some(vec![
2357                        "(".to_string(),
2358                        ",".to_string(),
2359                        "[".to_string(),
2360                    ]),
2361                    retrigger_characters: None,
2362                    work_done_progress_options: WorkDoneProgressOptions {
2363                        work_done_progress: None,
2364                    },
2365                }),
2366                definition_provider: Some(OneOf::Left(true)),
2367                declaration_provider: Some(DeclarationCapability::Simple(true)),
2368                implementation_provider: Some(ImplementationProviderCapability::Simple(true)),
2369                references_provider: Some(OneOf::Left(true)),
2370                rename_provider: Some(OneOf::Right(RenameOptions {
2371                    prepare_provider: Some(true),
2372                    work_done_progress_options: WorkDoneProgressOptions {
2373                        work_done_progress: Some(true),
2374                    },
2375                })),
2376                workspace_symbol_provider: Some(OneOf::Left(true)),
2377                document_symbol_provider: Some(OneOf::Left(true)),
2378                document_highlight_provider: Some(OneOf::Left(true)),
2379                hover_provider: Some(HoverProviderCapability::Simple(true)),
2380                document_link_provider: Some(DocumentLinkOptions {
2381                    resolve_provider: Some(false),
2382                    work_done_progress_options: WorkDoneProgressOptions {
2383                        work_done_progress: None,
2384                    },
2385                }),
2386                document_formatting_provider: Some(OneOf::Left(true)),
2387                code_action_provider: Some(CodeActionProviderCapability::Options(
2388                    CodeActionOptions {
2389                        code_action_kinds: Some(vec![CodeActionKind::QUICKFIX]),
2390                        resolve_provider: Some(false),
2391                        work_done_progress_options: WorkDoneProgressOptions {
2392                            work_done_progress: None,
2393                        },
2394                    },
2395                )),
2396                call_hierarchy_provider: Some(CallHierarchyServerCapability::Simple(true)),
2397                code_lens_provider: None,
2398                folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
2399                selection_range_provider: Some(SelectionRangeProviderCapability::Simple(true)),
2400                inlay_hint_provider: Some(OneOf::Right(InlayHintServerCapabilities::Options(
2401                    InlayHintOptions {
2402                        resolve_provider: Some(false),
2403                        work_done_progress_options: WorkDoneProgressOptions {
2404                            work_done_progress: None,
2405                        },
2406                    },
2407                ))),
2408                semantic_tokens_provider: Some(
2409                    SemanticTokensServerCapabilities::SemanticTokensOptions(
2410                        SemanticTokensOptions {
2411                            legend: semantic_tokens::legend(),
2412                            full: Some(SemanticTokensFullOptions::Delta { delta: Some(true) }),
2413                            range: Some(true),
2414                            work_done_progress_options: WorkDoneProgressOptions {
2415                                work_done_progress: None,
2416                            },
2417                        },
2418                    ),
2419                ),
2420                text_document_sync: Some(TextDocumentSyncCapability::Options(
2421                    TextDocumentSyncOptions {
2422                        will_save: Some(true),
2423                        will_save_wait_until: None,
2424                        open_close: Some(true),
2425                        save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
2426                            include_text: Some(true),
2427                        })),
2428                        change: Some(TextDocumentSyncKind::FULL),
2429                    },
2430                )),
2431                workspace: Some(WorkspaceServerCapabilities {
2432                    workspace_folders: None,
2433                    file_operations: Some(WorkspaceFileOperationsServerCapabilities {
2434                        will_rename: Some(FileOperationRegistrationOptions {
2435                            filters: vec![
2436                                // Match .sol files
2437                                FileOperationFilter {
2438                                    scheme: Some("file".to_string()),
2439                                    pattern: FileOperationPattern {
2440                                        glob: "**/*.sol".to_string(),
2441                                        matches: Some(FileOperationPatternKind::File),
2442                                        options: None,
2443                                    },
2444                                },
2445                                // Match folders (moving a directory moves all .sol files within)
2446                                FileOperationFilter {
2447                                    scheme: Some("file".to_string()),
2448                                    pattern: FileOperationPattern {
2449                                        glob: "**".to_string(),
2450                                        matches: Some(FileOperationPatternKind::Folder),
2451                                        options: None,
2452                                    },
2453                                },
2454                            ],
2455                        }),
2456                        did_rename: Some(FileOperationRegistrationOptions {
2457                            filters: vec![
2458                                FileOperationFilter {
2459                                    scheme: Some("file".to_string()),
2460                                    pattern: FileOperationPattern {
2461                                        glob: "**/*.sol".to_string(),
2462                                        matches: Some(FileOperationPatternKind::File),
2463                                        options: None,
2464                                    },
2465                                },
2466                                FileOperationFilter {
2467                                    scheme: Some("file".to_string()),
2468                                    pattern: FileOperationPattern {
2469                                        glob: "**".to_string(),
2470                                        matches: Some(FileOperationPatternKind::Folder),
2471                                        options: None,
2472                                    },
2473                                },
2474                            ],
2475                        }),
2476                        will_delete: Some(FileOperationRegistrationOptions {
2477                            filters: vec![
2478                                FileOperationFilter {
2479                                    scheme: Some("file".to_string()),
2480                                    pattern: FileOperationPattern {
2481                                        glob: "**/*.sol".to_string(),
2482                                        matches: Some(FileOperationPatternKind::File),
2483                                        options: None,
2484                                    },
2485                                },
2486                                FileOperationFilter {
2487                                    scheme: Some("file".to_string()),
2488                                    pattern: FileOperationPattern {
2489                                        glob: "**".to_string(),
2490                                        matches: Some(FileOperationPatternKind::Folder),
2491                                        options: None,
2492                                    },
2493                                },
2494                            ],
2495                        }),
2496                        did_delete: Some(FileOperationRegistrationOptions {
2497                            filters: vec![
2498                                FileOperationFilter {
2499                                    scheme: Some("file".to_string()),
2500                                    pattern: FileOperationPattern {
2501                                        glob: "**/*.sol".to_string(),
2502                                        matches: Some(FileOperationPatternKind::File),
2503                                        options: None,
2504                                    },
2505                                },
2506                                FileOperationFilter {
2507                                    scheme: Some("file".to_string()),
2508                                    pattern: FileOperationPattern {
2509                                        glob: "**".to_string(),
2510                                        matches: Some(FileOperationPatternKind::Folder),
2511                                        options: None,
2512                                    },
2513                                },
2514                            ],
2515                        }),
2516                        will_create: Some(FileOperationRegistrationOptions {
2517                            filters: vec![FileOperationFilter {
2518                                scheme: Some("file".to_string()),
2519                                pattern: FileOperationPattern {
2520                                    glob: "**/*.sol".to_string(),
2521                                    matches: Some(FileOperationPatternKind::File),
2522                                    options: None,
2523                                },
2524                            }],
2525                        }),
2526                        did_create: Some(FileOperationRegistrationOptions {
2527                            filters: vec![FileOperationFilter {
2528                                scheme: Some("file".to_string()),
2529                                pattern: FileOperationPattern {
2530                                    glob: "**/*.sol".to_string(),
2531                                    matches: Some(FileOperationPatternKind::File),
2532                                    options: None,
2533                                },
2534                            }],
2535                        }),
2536                    }),
2537                }),
2538                execute_command_provider: Some(ExecuteCommandOptions {
2539                    commands: vec![
2540                        "solidity.clearCache".to_string(),
2541                        "solidity.reindex".to_string(),
2542                    ],
2543                    work_done_progress_options: WorkDoneProgressOptions {
2544                        work_done_progress: None,
2545                    },
2546                }),
2547                ..ServerCapabilities::default()
2548            },
2549        })
2550    }
2551
2552    async fn initialized(&self, _: InitializedParams) {
2553        self.client
2554            .log_message(MessageType::INFO, "lsp server initialized.")
2555            .await;
2556
2557        // Dynamically register a file watcher for foundry.toml changes.
2558        let supports_dynamic = self
2559            .client_capabilities
2560            .read()
2561            .await
2562            .as_ref()
2563            .and_then(|caps| caps.workspace.as_ref())
2564            .and_then(|ws| ws.did_change_watched_files.as_ref())
2565            .and_then(|dcwf| dcwf.dynamic_registration)
2566            .unwrap_or(false);
2567
2568        if supports_dynamic {
2569            let registration = Registration {
2570                id: "foundry-toml-watcher".to_string(),
2571                method: "workspace/didChangeWatchedFiles".to_string(),
2572                register_options: Some(
2573                    serde_json::to_value(DidChangeWatchedFilesRegistrationOptions {
2574                        watchers: vec![
2575                            FileSystemWatcher {
2576                                glob_pattern: GlobPattern::String("**/foundry.toml".to_string()),
2577                                kind: Some(WatchKind::all()),
2578                            },
2579                            FileSystemWatcher {
2580                                glob_pattern: GlobPattern::String("**/remappings.txt".to_string()),
2581                                kind: Some(WatchKind::all()),
2582                            },
2583                        ],
2584                    })
2585                    .unwrap(),
2586                ),
2587            };
2588
2589            if let Err(e) = self.client.register_capability(vec![registration]).await {
2590                self.client
2591                    .log_message(
2592                        MessageType::WARNING,
2593                        format!("failed to register foundry.toml watcher: {e}"),
2594                    )
2595                    .await;
2596            } else {
2597                self.client
2598                    .log_message(MessageType::INFO, "registered foundry.toml file watcher")
2599                    .await;
2600            }
2601        }
2602
2603        // Pull settings from the client via workspace/configuration.
2604        // Neovim (and other editors) expose user settings through this
2605        // request rather than initializationOptions, so we need to pull
2606        // them explicitly if initializationOptions was absent.
2607        if !self
2608            .settings_from_init
2609            .load(std::sync::atomic::Ordering::Relaxed)
2610        {
2611            let supports_config = self
2612                .client_capabilities
2613                .read()
2614                .await
2615                .as_ref()
2616                .and_then(|caps| caps.workspace.as_ref())
2617                .and_then(|ws| ws.configuration)
2618                .unwrap_or(false);
2619
2620            if supports_config {
2621                match self
2622                    .client
2623                    .configuration(vec![ConfigurationItem {
2624                        scope_uri: None,
2625                        section: Some("solidity-language-server".to_string()),
2626                    }])
2627                    .await
2628                {
2629                    Ok(values) => {
2630                        if let Some(val) = values.into_iter().next()
2631                            && !val.is_null()
2632                        {
2633                            let s = config::parse_settings(&val);
2634                            self.client
2635                                    .log_message(
2636                                        MessageType::INFO,
2637                                        format!(
2638                                            "settings (workspace/configuration): lint.enabled={}, lint.exclude={:?}, projectIndex.fullProjectScan={}, projectIndex.cacheMode={:?}",
2639                                            s.lint.enabled, s.lint.exclude, s.project_index.full_project_scan, s.project_index.cache_mode,
2640                                        ),
2641                                    )
2642                                    .await;
2643                            let mut settings = self.settings.write().await;
2644                            *settings = s;
2645                        }
2646                    }
2647                    Err(e) => {
2648                        self.client
2649                            .log_message(
2650                                MessageType::WARNING,
2651                                format!("workspace/configuration request failed: {e}"),
2652                            )
2653                            .await;
2654                    }
2655                }
2656            }
2657        }
2658
2659        // Check for updates in the background (non-blocking).
2660        if self.settings.read().await.check_for_updates {
2661            let client = self.client.clone();
2662            tokio::spawn(check_for_updates(client));
2663        }
2664
2665        // Eagerly build the project index on startup so cross-file features
2666        // (willRenameFiles, references, goto) work immediately — even before
2667        // the user opens any .sol file.
2668        if self.use_solc && self.settings.read().await.project_index.full_project_scan {
2669            let cache_mode = self.settings.read().await.project_index.cache_mode.clone();
2670            self.project_indexed
2671                .store(true, std::sync::atomic::Ordering::Relaxed);
2672            let foundry_config = self.foundry_config.read().await.clone();
2673            let cache_key = self.project_cache_key().await;
2674            let ast_cache = self.ast_cache.clone();
2675            let client = self.client.clone();
2676            let sub_caches_arc = self.sub_caches.clone();
2677            let sub_caches_loading_flag = self.sub_caches_loading.clone();
2678            let path_interner = self.path_interner.clone();
2679
2680            tokio::spawn(async move {
2681                let Some(cache_key) = cache_key else {
2682                    return;
2683                };
2684                if !foundry_config.root.is_dir() {
2685                    client
2686                        .log_message(
2687                            MessageType::INFO,
2688                            format!(
2689                                "project index: {} not found, skipping eager index",
2690                                foundry_config.root.display(),
2691                            ),
2692                        )
2693                        .await;
2694                    return;
2695                }
2696
2697                let token = NumberOrString::String("solidity/projectIndex".to_string());
2698                let _ = client
2699                    .send_request::<request::WorkDoneProgressCreate>(WorkDoneProgressCreateParams {
2700                        token: token.clone(),
2701                    })
2702                    .await;
2703
2704                client
2705                    .send_notification::<notification::Progress>(ProgressParams {
2706                        token: token.clone(),
2707                        value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(
2708                            WorkDoneProgressBegin {
2709                                title: "Indexing project".to_string(),
2710                                message: Some("Discovering source files...".to_string()),
2711                                cancellable: Some(false),
2712                                percentage: None,
2713                            },
2714                        )),
2715                    })
2716                    .await;
2717
2718                // Pre-populate the path interner with all discoverable .sol
2719                // files (src + test + script + libs).  This assigns canonical
2720                // file IDs upfront so every CachedBuild — root, sub-caches,
2721                // and future parallel compilations — shares the same ID space.
2722                {
2723                    let cfg_for_discover = foundry_config.clone();
2724                    let all_files = tokio::task::spawn_blocking(move || {
2725                        crate::solc::discover_source_files_with_libs(&cfg_for_discover)
2726                    })
2727                    .await
2728                    .unwrap_or_default();
2729                    let mut interner = path_interner.write().await;
2730                    for file in &all_files {
2731                        if let Some(path_str) = file.to_str() {
2732                            interner.intern(path_str);
2733                        }
2734                    }
2735                }
2736
2737                // Try persisted reference index first (fast warm start).
2738                let cfg_for_load = foundry_config.clone();
2739                let cache_mode_for_load = cache_mode.clone();
2740                let load_res = tokio::task::spawn_blocking(move || {
2741                    crate::project_cache::load_reference_cache_with_report(
2742                        &cfg_for_load,
2743                        cache_mode_for_load,
2744                        true,
2745                    )
2746                })
2747                .await;
2748                match load_res {
2749                    Ok(report) => {
2750                        if let Some(cached_build) = report.build {
2751                            let source_count = cached_build.nodes.len();
2752                            ast_cache
2753                                .write()
2754                                .await
2755                                .insert(cache_key.clone().into(), Arc::new(cached_build));
2756                            client
2757                                .log_message(
2758                                    MessageType::INFO,
2759                                    format!(
2760                                        "loaded {source_count} sources from cache ({}ms)",
2761                                        report.duration_ms
2762                                    ),
2763                                )
2764                                .await;
2765                            if report.complete {
2766                                // Pre-load lib sub-caches so references
2767                                // include lib test files on the first call.
2768                                spawn_load_lib_sub_caches_task(
2769                                    foundry_config.clone(),
2770                                    sub_caches_arc.clone(),
2771                                    sub_caches_loading_flag.clone(),
2772                                    path_interner.clone(),
2773                                    client.clone(),
2774                                );
2775                                // End the discovery progress.
2776                                client
2777                                    .send_notification::<notification::Progress>(ProgressParams {
2778                                        token: token.clone(),
2779                                        value: ProgressParamsValue::WorkDone(
2780                                            WorkDoneProgress::End(WorkDoneProgressEnd {
2781                                                message: Some(format!(
2782                                                    "Loaded {} source files from cache",
2783                                                    source_count
2784                                                )),
2785                                            }),
2786                                        ),
2787                                    })
2788                                    .await;
2789                                // Even though phase 2 isn't running, the
2790                                // full project index *is* ready — every
2791                                // project file (src/test/script) is
2792                                // covered by the warm-loaded cache, which
2793                                // is exactly what `report.complete` means
2794                                // (see project_cache.rs:`project_covered`
2795                                // check). Emit `solidity/projectIndexFull`
2796                                // Begin+End so consumers waiting on that
2797                                // token (e.g. lsp-bench's
2798                                // `waitForProgressToken`) unblock —
2799                                // otherwise they hang forever waiting on
2800                                // a phase-2 that won't run.
2801                                let token2 =
2802                                    NumberOrString::String("solidity/projectIndexFull".to_string());
2803                                let _ = client
2804                                    .send_request::<request::WorkDoneProgressCreate>(
2805                                        WorkDoneProgressCreateParams {
2806                                            token: token2.clone(),
2807                                        },
2808                                    )
2809                                    .await;
2810                                client
2811                                    .send_notification::<notification::Progress>(ProgressParams {
2812                                        token: token2.clone(),
2813                                        value: ProgressParamsValue::WorkDone(
2814                                            WorkDoneProgress::Begin(WorkDoneProgressBegin {
2815                                                title: "Full project index".to_string(),
2816                                                message: Some(format!(
2817                                                    "Restored {} source files from cache",
2818                                                    source_count,
2819                                                )),
2820                                                cancellable: Some(false),
2821                                                percentage: None,
2822                                            }),
2823                                        ),
2824                                    })
2825                                    .await;
2826                                client
2827                                    .send_notification::<notification::Progress>(ProgressParams {
2828                                        token: token2,
2829                                        value: ProgressParamsValue::WorkDone(
2830                                            WorkDoneProgress::End(WorkDoneProgressEnd {
2831                                                message: Some(format!(
2832                                                    "Restored {} source files from cache",
2833                                                    source_count,
2834                                                )),
2835                                            }),
2836                                        ),
2837                                    })
2838                                    .await;
2839                                return;
2840                            }
2841                        }
2842
2843                        client
2844                            .log_message(
2845                                MessageType::INFO,
2846                                "no cached index found, building from source",
2847                            )
2848                            .await;
2849                    }
2850                    Err(e) => {
2851                        client
2852                            .log_message(MessageType::WARNING, format!("cache load failed: {e}"))
2853                            .await;
2854                    }
2855                }
2856
2857                // ── Two-phase project index ──────────────────────────
2858                //
2859                // Phase 1: compile only the src-closure (production code
2860                // + its transitive dependencies).  This is much faster
2861                // than the full closure because test/script files are
2862                // excluded.  After phase 1 completes, cross-file
2863                // features (references, goto, hover) work for all src
2864                // code and $/progress end is sent.
2865                //
2866                // Phase 2: compile the full closure (src + test + script
2867                // + all deps) in the background.  When done, the cache
2868                // entry is replaced with the complete build so
2869                // references into test files also work.
2870
2871                // Discover the src-only closure for phase 1.
2872                let remappings = crate::solc::resolve_remappings(&foundry_config).await;
2873                let cfg_for_src = foundry_config.clone();
2874                let remappings_for_src = remappings.clone();
2875                let src_files = tokio::task::spawn_blocking(move || {
2876                    crate::solc::discover_src_only_closure(&cfg_for_src, &remappings_for_src)
2877                })
2878                .await
2879                .unwrap_or_default();
2880
2881                // Discover the full closure for phase 2 (can overlap
2882                // with phase 1 compile since it's just import tracing).
2883                let cfg_for_full = foundry_config.clone();
2884                let remappings_for_full = remappings.clone();
2885                let full_files = tokio::task::spawn_blocking(move || {
2886                    crate::solc::discover_compilation_closure(&cfg_for_full, &remappings_for_full)
2887                })
2888                .await
2889                .unwrap_or_default();
2890
2891                let src_count = src_files.len();
2892                let full_count = full_files.len();
2893
2894                // ── Phase 1: src-only compile ──
2895                let phase1_start = std::time::Instant::now();
2896                client
2897                    .send_notification::<notification::Progress>(ProgressParams {
2898                        token: token.clone(),
2899                        value: ProgressParamsValue::WorkDone(WorkDoneProgress::Report(
2900                            WorkDoneProgressReport {
2901                                message: Some(format!("Compiling {} src files...", src_count,)),
2902                                cancellable: Some(false),
2903                                percentage: None,
2904                            },
2905                        )),
2906                    })
2907                    .await;
2908
2909                let phase1_ok = match crate::solc::solc_project_index_scoped(
2910                    &foundry_config,
2911                    Some(&client),
2912                    None,
2913                    &src_files,
2914                )
2915                .await
2916                {
2917                    Ok(ast_data) => {
2918                        let mut new_build = crate::goto::CachedBuild::new(
2919                            ast_data,
2920                            0,
2921                            Some(&mut *path_interner.write().await),
2922                        );
2923                        if let Some(prev) = ast_cache.read().await.get(&cache_key) {
2924                            new_build.merge_missing_from(prev);
2925                        }
2926                        let source_count = new_build.nodes.len();
2927                        ast_cache
2928                            .write()
2929                            .await
2930                            .insert(cache_key.clone().into(), Arc::new(new_build));
2931                        client
2932                            .log_message(
2933                                MessageType::INFO,
2934                                format!(
2935                                    "project index: phase 1 complete — {} source files indexed in {:.1}s",
2936                                    source_count,
2937                                    phase1_start.elapsed().as_secs_f64(),
2938                                ),
2939                            )
2940                            .await;
2941
2942                        // Signal that src references are ready.
2943                        client
2944                            .send_notification::<notification::Progress>(ProgressParams {
2945                                token: token.clone(),
2946                                value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(
2947                                    WorkDoneProgressEnd {
2948                                        message: Some(format!(
2949                                            "Indexed {} source files (full index in background)",
2950                                            source_count,
2951                                        )),
2952                                    },
2953                                )),
2954                            })
2955                            .await;
2956                        true
2957                    }
2958                    Err(e) => {
2959                        client
2960                            .log_message(
2961                                MessageType::WARNING,
2962                                format!("project index: phase 1 failed: {e}"),
2963                            )
2964                            .await;
2965                        // Fall through — phase 2 will attempt the full compile.
2966                        false
2967                    }
2968                };
2969
2970                // ── Phase 2: full compile (background) ──
2971                //
2972                // If phase 1 succeeded, this runs in the background so
2973                // the user isn't blocked.  If phase 1 failed, run
2974                // synchronously so we at least get something indexed.
2975                let phase2_foundry_config = foundry_config.clone();
2976                let phase2_client = client.clone();
2977                let phase2_cache_key = cache_key.clone();
2978                let phase2_ast_cache = ast_cache.clone();
2979                let phase2_path_interner = path_interner.clone();
2980                let phase2_sub_caches = sub_caches_arc.clone();
2981                let phase2_loading_flag = sub_caches_loading_flag.clone();
2982                let phase2 = async move {
2983                    let phase2_start = std::time::Instant::now();
2984                    // Create a new progress token for phase 2.
2985                    let token2 = NumberOrString::String("solidity/projectIndexFull".to_string());
2986                    let _ = phase2_client
2987                        .send_request::<request::WorkDoneProgressCreate>(
2988                            WorkDoneProgressCreateParams {
2989                                token: token2.clone(),
2990                            },
2991                        )
2992                        .await;
2993                    phase2_client
2994                        .send_notification::<notification::Progress>(ProgressParams {
2995                            token: token2.clone(),
2996                            value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(
2997                                WorkDoneProgressBegin {
2998                                    title: "Full project index".to_string(),
2999                                    message: Some(format!(
3000                                        "Compiling {} files (src + test + script)...",
3001                                        full_count,
3002                                    )),
3003                                    cancellable: Some(false),
3004                                    percentage: None,
3005                                },
3006                            )),
3007                        })
3008                        .await;
3009
3010                    match crate::solc::solc_project_index_scoped(
3011                        &phase2_foundry_config,
3012                        Some(&phase2_client),
3013                        None,
3014                        &full_files,
3015                    )
3016                    .await
3017                    {
3018                        Ok(ast_data) => {
3019                            let mut new_build = crate::goto::CachedBuild::new(
3020                                ast_data,
3021                                0,
3022                                Some(&mut *phase2_path_interner.write().await),
3023                            );
3024                            // Capture how many sources phase 2's solc actually
3025                            // compiled this round, BEFORE merging in phase-1
3026                            // data — otherwise the log conflates "files
3027                            // freshly compiled" with "files already in cache",
3028                            // making a silent phase-2 failure (0 ASTs back)
3029                            // look like a successful run.
3030                            let phase2_compiled = new_build.nodes.len();
3031                            // Merge any data from the phase-1 build that
3032                            // might not be in the full closure (shouldn't
3033                            // happen, but defensive).
3034                            if let Some(prev) = phase2_ast_cache.read().await.get(&phase2_cache_key)
3035                            {
3036                                new_build.merge_missing_from(prev);
3037                            }
3038                            let source_count = new_build.nodes.len();
3039                            let cached_build = Arc::new(new_build);
3040                            let build_for_save = (*cached_build).clone();
3041                            phase2_ast_cache
3042                                .write()
3043                                .await
3044                                .insert(phase2_cache_key.clone().into(), cached_build);
3045                            let phase2_message = if phase2_compiled == 0 {
3046                                format!(
3047                                    "project index: phase 2 produced no ASTs in {:.1}s — solc likely errored on the batch (cache retains {} merged sources from phase 1)",
3048                                    phase2_start.elapsed().as_secs_f64(),
3049                                    source_count,
3050                                )
3051                            } else {
3052                                format!(
3053                                    "project index: phase 2 complete — {} sources compiled this round, {} total in cache, in {:.1}s",
3054                                    phase2_compiled,
3055                                    source_count,
3056                                    phase2_start.elapsed().as_secs_f64(),
3057                                )
3058                            };
3059                            phase2_client
3060                                .log_message(MessageType::INFO, phase2_message)
3061                                .await;
3062
3063                            // Pre-load lib sub-caches after full build.
3064                            spawn_load_lib_sub_caches_task(
3065                                phase2_foundry_config.clone(),
3066                                phase2_sub_caches,
3067                                phase2_loading_flag,
3068                                phase2_path_interner,
3069                                phase2_client.clone(),
3070                            );
3071
3072                            // Persist the full build to disk.
3073                            let cfg_for_save = phase2_foundry_config.clone();
3074                            let client_for_save = phase2_client.clone();
3075                            tokio::spawn(async move {
3076                                let res = tokio::task::spawn_blocking(move || {
3077                                    crate::project_cache::save_reference_cache_with_report(
3078                                        &cfg_for_save,
3079                                        &build_for_save,
3080                                        None,
3081                                    )
3082                                })
3083                                .await;
3084                                match res {
3085                                    Ok(Ok(_report)) => {}
3086                                    Ok(Err(e)) => {
3087                                        client_for_save
3088                                            .log_message(
3089                                                MessageType::WARNING,
3090                                                format!("project index: cache save failed: {e}"),
3091                                            )
3092                                            .await;
3093                                    }
3094                                    Err(e) => {
3095                                        client_for_save
3096                                            .log_message(
3097                                                MessageType::WARNING,
3098                                                format!(
3099                                                    "project index: cache save task failed: {e}"
3100                                                ),
3101                                            )
3102                                            .await;
3103                                    }
3104                                }
3105                            });
3106
3107                            phase2_client
3108                                .send_notification::<notification::Progress>(ProgressParams {
3109                                    token: token2,
3110                                    value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(
3111                                        WorkDoneProgressEnd {
3112                                            message: Some(format!(
3113                                                "Indexed {} source files in {:.1}s",
3114                                                source_count,
3115                                                phase2_start.elapsed().as_secs_f64(),
3116                                            )),
3117                                        },
3118                                    )),
3119                                })
3120                                .await;
3121                        }
3122                        Err(e) => {
3123                            phase2_client
3124                                .log_message(
3125                                    MessageType::WARNING,
3126                                    format!("project index: phase 2 failed: {e}"),
3127                                )
3128                                .await;
3129                            phase2_client
3130                                .send_notification::<notification::Progress>(ProgressParams {
3131                                    token: token2,
3132                                    value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(
3133                                        WorkDoneProgressEnd {
3134                                            message: Some(format!("Full index failed: {e}",)),
3135                                        },
3136                                    )),
3137                                })
3138                                .await;
3139                        }
3140                    }
3141                };
3142
3143                if phase1_ok {
3144                    // Phase 1 succeeded — run phase 2 in background.
3145                    tokio::spawn(phase2);
3146                } else {
3147                    // Phase 1 failed — run phase 2 synchronously so we
3148                    // don't leave the user with no index at all.
3149                    phase2.await;
3150
3151                    // Send progress end for the original token if phase 1
3152                    // didn't send it.
3153                    client
3154                        .send_notification::<notification::Progress>(ProgressParams {
3155                            token,
3156                            value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(
3157                                WorkDoneProgressEnd {
3158                                    message: Some("Index complete (phase 1 skipped)".to_string()),
3159                                },
3160                            )),
3161                        })
3162                        .await;
3163                }
3164            });
3165        }
3166    }
3167
3168    async fn execute_command(
3169        &self,
3170        params: ExecuteCommandParams,
3171    ) -> tower_lsp::jsonrpc::Result<Option<serde_json::Value>> {
3172        match params.command.as_str() {
3173            // ----------------------------------------------------------------
3174            // solidity.clearCache
3175            //
3176            // Deletes the entire `.solidity-language-server/` directory on disk
3177            // and wipes the in-memory AST cache for the current project root.
3178            // The next save or file-open will trigger a clean rebuild from
3179            // scratch.
3180            //
3181            // Usage (nvim):
3182            //   vim.lsp.buf.execute_command({ command = "solidity.clearCache" })
3183            // ----------------------------------------------------------------
3184            "solidity.clearCache" => {
3185                let root = self.foundry_config.read().await.root.clone();
3186                let cache_dir = crate::project_cache::cache_dir(&root);
3187
3188                // 1. Remove the on-disk cache directory.
3189                let disk_result = if cache_dir.exists() {
3190                    std::fs::remove_dir_all(&cache_dir).map_err(|e| format!("{e}"))
3191                } else {
3192                    Ok(())
3193                };
3194
3195                // 2. Wipe all in-memory caches for a full reset.
3196                self.ast_cache.write().await.clear();
3197                self.completion_cache.write().await.clear();
3198                self.completion_source_text.write().await.clear();
3199                self.sub_caches.write().await.clear();
3200                self.semantic_token_cache.write().await.clear();
3201                *self.path_interner.write().await = crate::types::PathInterner::new();
3202
3203                match disk_result {
3204                    Ok(()) => {
3205                        self.client
3206                            .show_message(
3207                                MessageType::INFO,
3208                                format!(
3209                                    "Cache cleared: {} removed, all in-memory caches reset",
3210                                    cache_dir.display()
3211                                ),
3212                            )
3213                            .await;
3214                        Ok(Some(serde_json::json!({ "success": true })))
3215                    }
3216                    Err(e) => {
3217                        self.client
3218                            .show_message(
3219                                MessageType::ERROR,
3220                                format!("solidity.clearCache: failed to remove cache dir: {e}"),
3221                            )
3222                            .await;
3223                        Err(tower_lsp::jsonrpc::Error {
3224                            code: tower_lsp::jsonrpc::ErrorCode::InternalError,
3225                            message: std::borrow::Cow::Owned(e),
3226                            data: None,
3227                        })
3228                    }
3229                }
3230            }
3231
3232            // ----------------------------------------------------------------
3233            // solidity.reindex
3234            //
3235            // Evicts the in-memory AST cache entry for the current project root
3236            // and sets the dirty flag so the background cache-sync worker
3237            // triggers a fresh project index build. The on-disk cache is left
3238            // intact so the warm-load on reindex will be fast.
3239            //
3240            // Usage (nvim):
3241            //   vim.lsp.buf.execute_command({ command = "solidity.reindex" })
3242            // ----------------------------------------------------------------
3243            "solidity.reindex" => {
3244                if let Some(root_key) = self.project_cache_key().await {
3245                    self.ast_cache.write().await.remove(&root_key);
3246                }
3247                self.project_cache_dirty
3248                    .store(true, std::sync::atomic::Ordering::Relaxed);
3249                // Signal any already-running cache-sync worker (spawned from
3250                // didSave) that it must skip the incremental scoped path and
3251                // perform a full rebuild on its next iteration.
3252                self.project_cache_force_full_rebuild
3253                    .store(true, std::sync::atomic::Ordering::Release);
3254
3255                // Wake the background cache-sync worker directly. Setting the
3256                // dirty flag alone is not enough — the worker is only started
3257                // from the didSave path, so without an explicit spawn here the
3258                // reindex would be silently deferred until the next file save.
3259                if start_or_mark_project_cache_sync_pending(
3260                    &self.project_cache_sync_pending,
3261                    &self.project_cache_sync_running,
3262                ) {
3263                    let foundry_config = self.foundry_config.read().await.clone();
3264                    let root_key = self.project_cache_key().await;
3265                    let ast_cache = self.ast_cache.clone();
3266                    let client = self.client.clone();
3267                    let dirty_flag = self.project_cache_dirty.clone();
3268                    let running_flag = self.project_cache_sync_running.clone();
3269                    let pending_flag = self.project_cache_sync_pending.clone();
3270                    let changed_files = self.project_cache_changed_files.clone();
3271                    let force_full_rebuild_flag = self.project_cache_force_full_rebuild.clone();
3272                    let path_interner = self.path_interner.clone();
3273
3274                    tokio::spawn(async move {
3275                        loop {
3276                            tokio::time::sleep(std::time::Duration::from_millis(700)).await;
3277
3278                            if !take_project_cache_sync_pending(&pending_flag) {
3279                                if stop_project_cache_sync_worker_or_reclaim(
3280                                    &pending_flag,
3281                                    &running_flag,
3282                                ) {
3283                                    continue;
3284                                }
3285                                break;
3286                            }
3287
3288                            if !try_claim_project_cache_dirty(&dirty_flag) {
3289                                continue;
3290                            }
3291
3292                            let Some(cache_key) = &root_key else {
3293                                dirty_flag.store(true, Ordering::Release);
3294                                continue;
3295                            };
3296                            if !foundry_config.root.is_dir() {
3297                                dirty_flag.store(true, Ordering::Release);
3298                                client
3299                                    .log_message(
3300                                        MessageType::WARNING,
3301                                        format!(
3302                                            "solidity.reindex cache sync: invalid project root {}, deferring",
3303                                            foundry_config.root.display()
3304                                        ),
3305                                    )
3306                                    .await;
3307                                continue;
3308                            }
3309
3310                            client
3311                                .log_message(
3312                                    MessageType::INFO,
3313                                    "solidity.reindex: rebuilding project index from disk",
3314                                )
3315                                .await;
3316
3317                            match crate::solc::solc_project_index(
3318                                &foundry_config,
3319                                Some(&client),
3320                                None,
3321                            )
3322                            .await
3323                            {
3324                                Ok(ast_data) => {
3325                                    let mut new_build = crate::goto::CachedBuild::new(
3326                                        ast_data,
3327                                        0,
3328                                        Some(&mut *path_interner.write().await),
3329                                    );
3330                                    if let Some(prev) = ast_cache.read().await.get(cache_key) {
3331                                        new_build.merge_missing_from(prev);
3332                                    }
3333                                    let source_count = new_build.nodes.len();
3334                                    let cached_build = Arc::new(new_build);
3335                                    let build_for_save = (*cached_build).clone();
3336                                    ast_cache
3337                                        .write()
3338                                        .await
3339                                        .insert(cache_key.clone().into(), cached_build);
3340
3341                                    let cfg_for_save = foundry_config.clone();
3342                                    let save_res = tokio::task::spawn_blocking(move || {
3343                                        crate::project_cache::save_reference_cache_with_report(
3344                                            &cfg_for_save,
3345                                            &build_for_save,
3346                                            None,
3347                                        )
3348                                    })
3349                                    .await;
3350
3351                                    match save_res {
3352                                        Ok(Ok(report)) => {
3353                                            changed_files.write().await.clear();
3354                                            // Clear any pending force-full flag: this worker
3355                                            // already did a full rebuild on behalf of reindex.
3356                                            force_full_rebuild_flag.store(false, Ordering::Release);
3357                                            client
3358                                                .log_message(
3359                                                    MessageType::INFO,
3360                                                    format!(
3361                                                        "solidity.reindex: persisted cache (sources={}, hashed_files={}, duration={}ms)",
3362                                                        source_count, report.file_count_hashed, report.duration_ms
3363                                                    ),
3364                                                )
3365                                                .await;
3366                                        }
3367                                        Ok(Err(e)) => {
3368                                            dirty_flag.store(true, Ordering::Release);
3369                                            client
3370                                                .log_message(
3371                                                    MessageType::WARNING,
3372                                                    format!(
3373                                                        "solidity.reindex: persist failed, will retry: {e}"
3374                                                    ),
3375                                                )
3376                                                .await;
3377                                        }
3378                                        Err(e) => {
3379                                            dirty_flag.store(true, Ordering::Release);
3380                                            client
3381                                                .log_message(
3382                                                    MessageType::WARNING,
3383                                                    format!(
3384                                                        "solidity.reindex: save task failed, will retry: {e}"
3385                                                    ),
3386                                                )
3387                                                .await;
3388                                        }
3389                                    }
3390                                }
3391                                Err(e) => {
3392                                    dirty_flag.store(true, Ordering::Release);
3393                                    client
3394                                        .log_message(
3395                                            MessageType::WARNING,
3396                                            format!(
3397                                                "solidity.reindex: re-index failed, will retry: {e}"
3398                                            ),
3399                                        )
3400                                        .await;
3401                                }
3402                            }
3403
3404                            if stop_project_cache_sync_worker_or_reclaim(
3405                                &pending_flag,
3406                                &running_flag,
3407                            ) {
3408                                continue;
3409                            }
3410                            break;
3411                        }
3412                    });
3413                }
3414
3415                self.client
3416                    .log_message(
3417                        MessageType::INFO,
3418                        "solidity.reindex: in-memory cache evicted, background reindex triggered",
3419                    )
3420                    .await;
3421                Ok(Some(serde_json::json!({ "success": true })))
3422            }
3423
3424            _ => Err(tower_lsp::jsonrpc::Error::method_not_found()),
3425        }
3426    }
3427
3428    async fn shutdown(&self) -> tower_lsp::jsonrpc::Result<()> {
3429        self.flush_project_cache_to_disk("shutdown").await;
3430        self.client
3431            .log_message(MessageType::INFO, "lsp server shutting down.")
3432            .await;
3433        Ok(())
3434    }
3435
3436    async fn did_open(&self, params: DidOpenTextDocumentParams) {
3437        self.client
3438            .log_message(MessageType::INFO, "file opened")
3439            .await;
3440
3441        let mut td = params.text_document;
3442        let template_on_create = self
3443            .settings
3444            .read()
3445            .await
3446            .file_operations
3447            .template_on_create;
3448
3449        // Fallback path for clients/flows that don't emit file-operation
3450        // create events reliably: scaffold an empty newly-opened `.sol` file.
3451        let should_attempt_scaffold = template_on_create
3452            && td.text.chars().all(|ch| ch.is_whitespace())
3453            && td.uri.scheme() == "file"
3454            && td
3455                .uri
3456                .to_file_path()
3457                .ok()
3458                .and_then(|p| p.extension().map(|e| e == "sol"))
3459                .unwrap_or(false);
3460
3461        if should_attempt_scaffold {
3462            let uri_str = td.uri.to_string();
3463            let create_flow_pending = {
3464                let pending = self.pending_create_scaffold.read().await;
3465                pending.contains(&uri_str)
3466            };
3467            if create_flow_pending {
3468                self.client
3469                    .log_message(
3470                        MessageType::INFO,
3471                        format!(
3472                            "didOpen: skip scaffold for {} (didCreateFiles scaffold pending)",
3473                            uri_str
3474                        ),
3475                    )
3476                    .await;
3477            } else {
3478                let cache_has_content = {
3479                    let tc = self.text_cache.read().await;
3480                    tc.get(&uri_str)
3481                        .is_some_and(|(_, c)| c.chars().any(|ch| !ch.is_whitespace()))
3482                };
3483
3484                if !cache_has_content {
3485                    let file_has_content = td.uri.to_file_path().ok().is_some_and(|p| {
3486                        std::fs::read_to_string(&p)
3487                            .is_ok_and(|c| c.chars().any(|ch| !ch.is_whitespace()))
3488                    });
3489
3490                    if !file_has_content {
3491                        let solc_version = self.foundry_config.read().await.solc_version.clone();
3492                        if let Some(scaffold) =
3493                            file_operations::generate_scaffold(&td.uri, solc_version.as_deref())
3494                        {
3495                            let end = utils::byte_offset_to_position(&td.text, td.text.len());
3496                            let edit = WorkspaceEdit {
3497                                changes: Some(HashMap::from([(
3498                                    td.uri.clone(),
3499                                    vec![TextEdit {
3500                                        range: Range {
3501                                            start: Position::default(),
3502                                            end,
3503                                        },
3504                                        new_text: scaffold.clone(),
3505                                    }],
3506                                )])),
3507                                document_changes: None,
3508                                change_annotations: None,
3509                            };
3510                            if self
3511                                .client
3512                                .apply_edit(edit)
3513                                .await
3514                                .as_ref()
3515                                .is_ok_and(|r| r.applied)
3516                            {
3517                                td.text = scaffold;
3518                                self.client
3519                                    .log_message(
3520                                        MessageType::INFO,
3521                                        format!("didOpen: scaffolded empty file {}", uri_str),
3522                                    )
3523                                    .await;
3524                            }
3525                        }
3526                    }
3527                }
3528            }
3529        }
3530
3531        self.on_change(td).await
3532    }
3533
3534    async fn did_change(&self, params: DidChangeTextDocumentParams) {
3535        self.client
3536            .log_message(MessageType::INFO, "file changed")
3537            .await;
3538
3539        // update text cache
3540        if let Some(change) = params.content_changes.into_iter().next() {
3541            let has_substantive_content = change.text.chars().any(|ch| !ch.is_whitespace());
3542            let mut text_cache = self.text_cache.write().await;
3543            text_cache.insert(
3544                params.text_document.uri.to_string().into(),
3545                (params.text_document.version, change.text),
3546            );
3547            drop(text_cache);
3548
3549            if has_substantive_content {
3550                self.pending_create_scaffold
3551                    .write()
3552                    .await
3553                    .remove(params.text_document.uri.as_str());
3554            }
3555        }
3556    }
3557
3558    async fn did_save(&self, params: DidSaveTextDocumentParams) {
3559        // did_save is a notification — return to the editor immediately.
3560        // We route each URI through a dedicated watch channel so that rapid
3561        // saves collapse: the worker always picks up the *latest* params via
3562        // `borrow_and_update`, avoiding stale-result races.
3563        let uri_key = params.text_document.uri.to_string();
3564
3565        // Fast path: worker already running for this URI — just send new params.
3566        {
3567            let workers = self.did_save_workers.read().await;
3568            if let Some(tx) = workers.get(&uri_key) {
3569                // Ignore send errors — worker may have panicked; fall through to
3570                // the slow path below which will respawn it.
3571                if tx.send(Some(params.clone())).is_ok() {
3572                    return;
3573                }
3574            }
3575        }
3576
3577        // Slow path: first save for this URI (or worker died) — create channel
3578        // and spawn the worker.
3579        let (tx, mut rx) = tokio::sync::watch::channel(Some(params));
3580        // Mark the initial value as unseen so the worker processes the first
3581        // save immediately.  Without this, `rx.changed()` blocks until the
3582        // *second* send because the initial channel value isn't counted as a
3583        // change by default.
3584        rx.mark_changed();
3585        self.did_save_workers
3586            .write()
3587            .await
3588            .insert(uri_key.into(), tx);
3589
3590        let this = self.clone();
3591        tokio::spawn(async move {
3592            loop {
3593                // Wait for a new value to be sent.
3594                if rx.changed().await.is_err() {
3595                    // All senders dropped — shouldn't happen while ForgeLsp is
3596                    // alive, but exit cleanly just in case.
3597                    break;
3598                }
3599                let params = match rx.borrow_and_update().clone() {
3600                    Some(p) => p,
3601                    None => continue,
3602                };
3603                run_did_save(this.clone(), params).await;
3604            }
3605        });
3606    }
3607
3608    async fn will_save(&self, params: WillSaveTextDocumentParams) {
3609        self.client
3610            .log_message(
3611                MessageType::INFO,
3612                format!(
3613                    "file will save reason:{:?} {}",
3614                    params.reason, params.text_document.uri
3615                ),
3616            )
3617            .await;
3618    }
3619
3620    async fn formatting(
3621        &self,
3622        params: DocumentFormattingParams,
3623    ) -> tower_lsp::jsonrpc::Result<Option<Vec<TextEdit>>> {
3624        self.client
3625            .log_message(MessageType::INFO, "formatting request")
3626            .await;
3627
3628        let uri = params.text_document.uri;
3629        let file_path = match uri.to_file_path() {
3630            Ok(path) => path,
3631            Err(_) => {
3632                self.client
3633                    .log_message(MessageType::ERROR, "Invalid file URI for formatting")
3634                    .await;
3635                return Ok(None);
3636            }
3637        };
3638        let path_str = match file_path.to_str() {
3639            Some(s) => s,
3640            None => {
3641                self.client
3642                    .log_message(MessageType::ERROR, "Invalid file path for formatting")
3643                    .await;
3644                return Ok(None);
3645            }
3646        };
3647
3648        // Get original content
3649        let original_content = {
3650            let text_cache = self.text_cache.read().await;
3651            if let Some((_, content)) = text_cache.get(&uri.to_string()) {
3652                content.clone()
3653            } else {
3654                // Fallback to reading file
3655                match std::fs::read_to_string(&file_path) {
3656                    Ok(content) => content,
3657                    Err(_) => {
3658                        self.client
3659                            .log_message(MessageType::ERROR, "Failed to read file for formatting")
3660                            .await;
3661                        return Ok(None);
3662                    }
3663                }
3664            }
3665        };
3666
3667        // Get formatted content
3668        let formatted_content = match self.compiler.format(path_str).await {
3669            Ok(content) => content,
3670            Err(e) => {
3671                self.client
3672                    .log_message(MessageType::WARNING, format!("Formatting failed: {e}"))
3673                    .await;
3674                return Ok(None);
3675            }
3676        };
3677
3678        // If changed, update text_cache with formatted content and return edit
3679        if original_content != formatted_content {
3680            let end = utils::byte_offset_to_position(&original_content, original_content.len());
3681
3682            // Update text_cache immediately so goto/hover use the formatted text
3683            {
3684                let mut text_cache = self.text_cache.write().await;
3685                let version = text_cache
3686                    .get(&uri.to_string())
3687                    .map(|(v, _)| *v)
3688                    .unwrap_or(0);
3689                text_cache.insert(uri.to_string().into(), (version, formatted_content.clone()));
3690            }
3691
3692            let edit = TextEdit {
3693                range: Range {
3694                    start: Position::default(),
3695                    end,
3696                },
3697                new_text: formatted_content,
3698            };
3699            Ok(Some(vec![edit]))
3700        } else {
3701            Ok(None)
3702        }
3703    }
3704
3705    async fn did_close(&self, params: DidCloseTextDocumentParams) {
3706        self.flush_project_cache_to_disk("didClose").await;
3707        let uri = params.text_document.uri.to_string();
3708        self.ast_cache.write().await.remove(&uri);
3709        self.text_cache.write().await.remove(&uri);
3710        self.completion_cache.write().await.remove(&uri);
3711        self.completion_source_text.write().await.remove(&uri);
3712        self.client
3713            .log_message(MessageType::INFO, "file closed, caches cleared.")
3714            .await;
3715    }
3716
3717    async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
3718        let s = config::parse_settings(&params.settings);
3719        self.client
3720                .log_message(
3721                    MessageType::INFO,
3722                    format!(
3723                        "settings updated: inlayHints.parameters={}, lint.enabled={}, lint.severity={:?}, lint.only={:?}, lint.exclude={:?}, fileOperations.templateOnCreate={}, fileOperations.updateImportsOnRename={}, fileOperations.updateImportsOnDelete={}, projectIndex.fullProjectScan={}, projectIndex.cacheMode={:?}, projectIndex.incrementalEditReindex={}",
3724                    s.inlay_hints.parameters, 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, s.project_index.full_project_scan, s.project_index.cache_mode, s.project_index.incremental_edit_reindex,
3725                ),
3726            )
3727            .await;
3728        let mut settings = self.settings.write().await;
3729        *settings = s;
3730
3731        // Refresh inlay hints so the editor re-requests them with new settings.
3732        let client = self.client.clone();
3733        tokio::spawn(async move {
3734            let _ = client.inlay_hint_refresh().await;
3735        });
3736    }
3737    async fn did_change_workspace_folders(&self, _: DidChangeWorkspaceFoldersParams) {
3738        self.client
3739            .log_message(MessageType::INFO, "workdspace folders changed.")
3740            .await;
3741    }
3742
3743    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
3744        self.client
3745            .log_message(MessageType::INFO, "watched files have changed.")
3746            .await;
3747
3748        // Reload configs if foundry.toml or remappings.txt changed.
3749        for change in &params.changes {
3750            let path = match change.uri.to_file_path() {
3751                Ok(p) => p,
3752                Err(_) => continue,
3753            };
3754
3755            let filename = path.file_name().and_then(|n| n.to_str());
3756
3757            if filename == Some("foundry.toml") {
3758                let lint_cfg = config::load_lint_config_from_toml(&path);
3759                self.client
3760                    .log_message(
3761                        MessageType::INFO,
3762                        format!(
3763                            "reloaded foundry.toml lint config: lint_on_build={}, ignore_patterns={}",
3764                            lint_cfg.lint_on_build,
3765                            lint_cfg.ignore_patterns.len()
3766                        ),
3767                    )
3768                    .await;
3769                let mut lc = self.lint_config.write().await;
3770                *lc = lint_cfg;
3771
3772                let foundry_cfg = config::load_foundry_config_from_toml(&path);
3773                self.client
3774                    .log_message(
3775                        MessageType::INFO,
3776                        format!(
3777                            "reloaded foundry.toml: solc={}, remappings={}",
3778                            foundry_cfg.solc_version.as_deref().unwrap_or("auto"),
3779                            foundry_cfg.remappings.len()
3780                        ),
3781                    )
3782                    .await;
3783                if foundry_cfg.via_ir {
3784                    self.client
3785                        .log_message(
3786                            MessageType::WARNING,
3787                            "via_ir is enabled in foundry.toml — gas estimate inlay hints are disabled to avoid slow compilation",
3788                        )
3789                        .await;
3790                }
3791                let mut fc = self.foundry_config.write().await;
3792                *fc = foundry_cfg;
3793                break;
3794            }
3795
3796            if filename == Some("remappings.txt") {
3797                self.client
3798                    .log_message(
3799                        MessageType::INFO,
3800                        "remappings.txt changed, config may need refresh",
3801                    )
3802                    .await;
3803                // Remappings from remappings.txt are resolved at solc invocation time
3804                // via `forge remappings`, so no cached state to update here.
3805            }
3806        }
3807    }
3808
3809    async fn completion(
3810        &self,
3811        params: CompletionParams,
3812    ) -> tower_lsp::jsonrpc::Result<Option<CompletionResponse>> {
3813        let uri = params.text_document_position.text_document.uri;
3814        let position = params.text_document_position.position;
3815
3816        let trigger_char = params
3817            .context
3818            .as_ref()
3819            .and_then(|ctx| ctx.trigger_character.as_deref());
3820
3821        // Get source text — only needed for dot completions (to parse the line)
3822        let source_text = {
3823            let text_cache = self.text_cache.read().await;
3824            if let Some((_, text)) = text_cache.get(&uri.to_string()) {
3825                text.clone()
3826            } else {
3827                match uri.to_file_path() {
3828                    Ok(path) => std::fs::read_to_string(&path).unwrap_or_default(),
3829                    Err(_) => return Ok(None),
3830                }
3831            }
3832        };
3833
3834        // Clone URI-specific cache (pointer copy, instant) and drop the lock immediately.
3835        let local_cached: Option<Arc<completion::CompletionCache>> = {
3836            let comp_cache = self.completion_cache.read().await;
3837            comp_cache.get(&uri.to_string()).cloned()
3838        };
3839
3840        // Project-wide cache for global top-level symbol tail candidates.
3841        let root_cached: Option<Arc<completion::CompletionCache>> = {
3842            let root_key = self.project_cache_key().await;
3843            match root_key {
3844                Some(root_key) => {
3845                    let ast_cache = self.ast_cache.read().await;
3846                    ast_cache
3847                        .get(&root_key)
3848                        .map(|root_build| root_build.completion_cache.clone())
3849                }
3850                None => None,
3851            }
3852        };
3853
3854        // Base cache remains per-file first; root cache is only a fallback.
3855        let cached = local_cached.or(root_cached.clone());
3856
3857        if cached.is_none() {
3858            // Use pre-built completion cache from CachedBuild
3859            let ast_cache = self.ast_cache.clone();
3860            let completion_cache = self.completion_cache.clone();
3861            let uri_string = uri.to_string();
3862            tokio::spawn(async move {
3863                let cached_build = {
3864                    let cache = ast_cache.read().await;
3865                    match cache.get(&uri_string) {
3866                        Some(v) => v.clone(),
3867                        None => return,
3868                    }
3869                };
3870                completion_cache
3871                    .write()
3872                    .await
3873                    .insert(uri_string.into(), cached_build.completion_cache.clone());
3874            });
3875        }
3876
3877        let cache_ref = cached.as_deref();
3878
3879        // Text the served cache was compiled from, if we have it.
3880        let compiled_text: Option<Arc<str>> = match cached.as_ref() {
3881            Some(c) => self
3882                .completion_source_text
3883                .read()
3884                .await
3885                .get(&uri.to_string())
3886                .filter(|(src_cache, _)| Arc::ptr_eq(src_cache, c))
3887                .map(|(_, text)| text.clone()),
3888            None => None,
3889        };
3890
3891        // Look up the AST file_id for scope-aware resolution
3892        let file_id = {
3893            let uri_path = uri.to_file_path().ok();
3894            cache_ref.and_then(|c| {
3895                uri_path.as_ref().and_then(|p| {
3896                    let path_str = p.to_str()?;
3897                    c.path_to_file_id.get(path_str).copied()
3898                })
3899            })
3900        };
3901
3902        let current_file_path = uri
3903            .to_file_path()
3904            .ok()
3905            .and_then(|p| p.to_str().map(|s| s.to_string()));
3906
3907        // --- Import path completions ---
3908        // Use tree-sitter to determine whether the cursor is inside an import
3909        // string.  This is exact: it finds `import_directive > string` nodes
3910        // and checks if the cursor falls within the inner range (excluding
3911        // quotes).  This avoids false positives for arbitrary string literals
3912        // like `string memory s = "l`.
3913        //
3914        // For `"` / `'` trigger chars the LSP trigger position is the column
3915        // of the quote character itself; the inside of the string starts one
3916        // character to the right.
3917        let check_pos = if matches!(trigger_char, Some("\"") | Some("'")) {
3918            Position {
3919                line: position.line,
3920                character: position.character.saturating_add(1),
3921            }
3922        } else {
3923            position
3924        };
3925
3926        // --- Assembly dialect completions ---
3927        // `assembly ("memory-safe") {}` — the only valid Solidity assembly
3928        // dialect is "memory-safe".  Fire exactly one completion item when
3929        // the cursor is inside the assembly_flags string.
3930        if let Some(asm_range) =
3931            links::ts_cursor_in_assembly_flags(source_text.as_bytes(), check_pos)
3932        {
3933            let text_edit = CompletionTextEdit::Edit(TextEdit {
3934                range: Range {
3935                    start: Position {
3936                        line: position.line,
3937                        character: asm_range.start.character,
3938                    },
3939                    end: Position {
3940                        line: position.line,
3941                        character: check_pos.character,
3942                    },
3943                },
3944                new_text: "memory-safe".to_string(),
3945            });
3946            let item = CompletionItem {
3947                label: "memory-safe".to_string(),
3948                kind: Some(CompletionItemKind::VALUE),
3949                detail: Some("Solidity assembly dialect".to_string()),
3950                filter_text: Some("memory-safe".to_string()),
3951                text_edit: Some(text_edit),
3952                ..Default::default()
3953            };
3954            return Ok(Some(CompletionResponse::List(CompletionList {
3955                is_incomplete: false,
3956                items: vec![item],
3957            })));
3958        }
3959
3960        // --- Import path completions ---
3961        // Use tree-sitter to determine whether the cursor is inside an import
3962        // string.  This is exact: it finds `import_directive > string` nodes
3963        // and checks if the cursor falls within the inner range (excluding
3964        // quotes).  This avoids false positives for arbitrary string literals
3965        // like `string memory s = "l`.
3966        if let Some(import_range) =
3967            links::ts_cursor_in_import_string(source_text.as_bytes(), check_pos)
3968        {
3969            if let Ok(current_file) = uri.to_file_path() {
3970                let foundry_cfg = self.foundry_config.read().await.clone();
3971                let project_root = foundry_cfg.root.clone();
3972                let remappings = crate::solc::resolve_remappings(&foundry_cfg).await;
3973                // Replace only the already-typed portion of the path so the
3974                // client inserts cleanly (no duplication).
3975                let typed_range = Some((
3976                    position.line,
3977                    import_range.start.character,
3978                    check_pos.character,
3979                ));
3980                let items = completion::all_sol_import_paths(
3981                    &current_file,
3982                    &project_root,
3983                    &remappings,
3984                    typed_range,
3985                );
3986                return Ok(Some(CompletionResponse::List(CompletionList {
3987                    is_incomplete: true,
3988                    items,
3989                })));
3990            }
3991            return Ok(None);
3992        }
3993
3994        // A `"` or `'` trigger that is not inside an import string or assembly
3995        // flags string should never produce completions — return null so the
3996        // client does not show a spurious popup.
3997        if matches!(trigger_char, Some("\"") | Some("'")) {
3998            return Ok(None);
3999        }
4000
4001        let tail_candidates = if trigger_char == Some(".") {
4002            vec![]
4003        } else {
4004            root_cached.as_deref().map_or_else(Vec::new, |c| {
4005                completion::top_level_importable_completion_candidates(
4006                    c,
4007                    current_file_path.as_deref(),
4008                    &source_text,
4009                )
4010            })
4011        };
4012
4013        let result = completion::handle_completion_with_tail_candidates(
4014            cache_ref,
4015            &source_text,
4016            position,
4017            trigger_char,
4018            file_id,
4019            compiled_text.as_deref(),
4020            tail_candidates,
4021        );
4022        Ok(result)
4023    }
4024
4025    async fn goto_definition(
4026        &self,
4027        params: GotoDefinitionParams,
4028    ) -> tower_lsp::jsonrpc::Result<Option<GotoDefinitionResponse>> {
4029        self.client
4030            .log_message(MessageType::INFO, "got textDocument/definition request")
4031            .await;
4032
4033        let uri = params.text_document_position_params.text_document.uri;
4034        let position = params.text_document_position_params.position;
4035
4036        let file_path = match uri.to_file_path() {
4037            Ok(path) => path,
4038            Err(_) => {
4039                self.client
4040                    .log_message(MessageType::ERROR, "Invalid file uri")
4041                    .await;
4042                return Ok(None);
4043            }
4044        };
4045
4046        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
4047            Some(bytes) => bytes,
4048            None => return Ok(None),
4049        };
4050
4051        let source_text = String::from_utf8_lossy(&source_bytes).to_string();
4052
4053        // Fast path: if cursor is on an import path string, resolve it with
4054        // tree-sitter.  This works regardless of AST state (dirty, errors,
4055        // empty cache) because it only needs the live source text and the
4056        // project's import resolution rules.
4057        {
4058            let imports = crate::links::ts_find_imports(&source_bytes);
4059            if let Some(imp) = imports.iter().find(|imp| {
4060                let r = &imp.inner_range;
4061                position >= r.start && position <= r.end
4062            }) {
4063                let foundry_cfg = self.foundry_config_for_file(&file_path).await;
4064                let remappings = crate::solc::resolve_remappings(&foundry_cfg).await;
4065                if let Some(abs) = resolve_import_spec_to_abs(
4066                    &foundry_cfg.root,
4067                    &file_path,
4068                    &imp.path,
4069                    &remappings,
4070                ) && abs.exists()
4071                    && let Ok(target_uri) = Url::from_file_path(&abs)
4072                {
4073                    let location = Location {
4074                        uri: target_uri,
4075                        range: Range::default(), // start of file
4076                    };
4077                    self.client
4078                        .log_message(
4079                            MessageType::INFO,
4080                            format!("found definition (import path) at {}", location.uri),
4081                        )
4082                        .await;
4083                    return Ok(Some(GotoDefinitionResponse::from(location)));
4084                }
4085            }
4086        }
4087
4088        // Fast path: if cursor is on an import alias name at a usage site,
4089        // go to the alias declaration in the import statement.  The AST
4090        // would follow referencedDeclaration to the original definition
4091        // (e.g. Test in A.sol), but definition/declaration should go to
4092        // the local alias (e.g. MyTest in the import line).
4093        {
4094            let identifier = crate::rename::get_identifier_at_position(&source_bytes, position);
4095            if let Some(ref ident) = identifier {
4096                let alias_names = crate::rename::ts_find_alias_names(&source_bytes);
4097                if alias_names.contains(ident.as_str())
4098                    && let Some(decl_range) =
4099                        crate::rename::ts_find_alias_declaration(&source_bytes, ident)
4100                {
4101                    let location = Location {
4102                        uri: uri.clone(),
4103                        range: decl_range,
4104                    };
4105                    self.client
4106                        .log_message(
4107                            MessageType::INFO,
4108                            format!(
4109                                "found definition (alias declaration) for '{}' at {}:{}",
4110                                ident, location.uri, decl_range.start.line
4111                            ),
4112                        )
4113                        .await;
4114                    return Ok(Some(GotoDefinitionResponse::from(location)));
4115                }
4116            }
4117        }
4118
4119        // Extract the identifier name under the cursor for tree-sitter validation.
4120        let cursor_name = goto::cursor_context(&source_text, position).map(|ctx| ctx.name);
4121
4122        // Determine if the file is dirty (unsaved edits since last build).
4123        // When dirty, AST byte offsets are stale so we prefer tree-sitter.
4124        // When clean, AST has proper semantic resolution (scoping, types).
4125        let (is_dirty, cached_build) = {
4126            let text_version = self
4127                .text_cache
4128                .read()
4129                .await
4130                .get(&uri.to_string())
4131                .map(|(v, _)| *v)
4132                .unwrap_or(0);
4133            let cb = self.get_or_fetch_build(&uri, &file_path, false).await;
4134            let build_version = cb.as_ref().map(|b| b.build_version).unwrap_or(0);
4135            (text_version > build_version, cb)
4136        };
4137
4138        // Validate a tree-sitter result: read the target source and check that
4139        // the text at the location matches the cursor identifier. Tree-sitter
4140        // resolves by name so a mismatch means it landed on the wrong node.
4141        // AST results are NOT validated — the AST can legitimately resolve to a
4142        // different name (e.g. `.selector` → error declaration).
4143        let validate_ts = |loc: &Location| -> bool {
4144            let Some(ref name) = cursor_name else {
4145                return true; // can't validate, trust it
4146            };
4147            let target_src = if loc.uri == uri {
4148                Some(source_text.clone())
4149            } else {
4150                loc.uri
4151                    .to_file_path()
4152                    .ok()
4153                    .and_then(|p| std::fs::read_to_string(&p).ok())
4154            };
4155            match target_src {
4156                Some(src) => goto::validate_goto_target(&src, loc, name),
4157                None => true, // can't read target, trust it
4158            }
4159        };
4160
4161        if is_dirty {
4162            self.client
4163                .log_message(MessageType::INFO, "file is dirty, trying tree-sitter first")
4164                .await;
4165
4166            // DIRTY: tree-sitter first (validated) → AST fallback
4167            let ts_result = {
4168                let comp_cache = self.completion_cache.read().await;
4169                let text_cache = self.text_cache.read().await;
4170                if let Some(cc) = comp_cache.get(&uri.to_string()) {
4171                    goto::goto_definition_ts(&source_text, position, &uri, cc, &text_cache)
4172                } else {
4173                    None
4174                }
4175            };
4176
4177            if let Some(location) = ts_result {
4178                if validate_ts(&location) {
4179                    self.client
4180                        .log_message(
4181                            MessageType::INFO,
4182                            format!(
4183                                "found definition (tree-sitter) at {}:{}",
4184                                location.uri, location.range.start.line
4185                            ),
4186                        )
4187                        .await;
4188                    return Ok(Some(GotoDefinitionResponse::from(location)));
4189                }
4190                self.client
4191                    .log_message(
4192                        MessageType::INFO,
4193                        "tree-sitter result failed validation, trying AST fallback",
4194                    )
4195                    .await;
4196            }
4197
4198            // Tree-sitter failed or didn't validate — try name-based AST lookup.
4199            // Instead of matching by byte offset (which is stale on dirty files),
4200            // search cached AST nodes whose source text matches the cursor name
4201            // and follow their referencedDeclaration.
4202            if let Some(ref cb) = cached_build
4203                && let Some(ref name) = cursor_name
4204            {
4205                let byte_hint = goto::pos_to_bytes(&source_bytes, position);
4206                if let Some(location) = goto::goto_declaration_by_name(cb, &uri, name, byte_hint) {
4207                    self.client
4208                        .log_message(
4209                            MessageType::INFO,
4210                            format!(
4211                                "found definition (AST by name) at {}:{}",
4212                                location.uri, location.range.start.line
4213                            ),
4214                        )
4215                        .await;
4216                    return Ok(Some(GotoDefinitionResponse::from(location)));
4217                }
4218            }
4219        } else {
4220            // CLEAN: AST first → tree-sitter fallback (validated)
4221            if let Some(ref cb) = cached_build
4222                && let Some(location) =
4223                    goto::goto_declaration_cached(cb, &uri, position, &source_bytes)
4224            {
4225                self.client
4226                    .log_message(
4227                        MessageType::INFO,
4228                        format!(
4229                            "found definition (AST) at {}:{}",
4230                            location.uri, location.range.start.line
4231                        ),
4232                    )
4233                    .await;
4234                return Ok(Some(GotoDefinitionResponse::from(location)));
4235            }
4236
4237            // AST couldn't resolve — try tree-sitter fallback (validated)
4238            let ts_result = {
4239                let comp_cache = self.completion_cache.read().await;
4240                let text_cache = self.text_cache.read().await;
4241                if let Some(cc) = comp_cache.get(&uri.to_string()) {
4242                    goto::goto_definition_ts(&source_text, position, &uri, cc, &text_cache)
4243                } else {
4244                    None
4245                }
4246            };
4247
4248            if let Some(location) = ts_result {
4249                if validate_ts(&location) {
4250                    self.client
4251                        .log_message(
4252                            MessageType::INFO,
4253                            format!(
4254                                "found definition (tree-sitter fallback) at {}:{}",
4255                                location.uri, location.range.start.line
4256                            ),
4257                        )
4258                        .await;
4259                    return Ok(Some(GotoDefinitionResponse::from(location)));
4260                }
4261                self.client
4262                    .log_message(MessageType::INFO, "tree-sitter fallback failed validation")
4263                    .await;
4264            }
4265        }
4266
4267        self.client
4268            .log_message(MessageType::INFO, "no definition found")
4269            .await;
4270        Ok(None)
4271    }
4272
4273    async fn goto_declaration(
4274        &self,
4275        params: request::GotoDeclarationParams,
4276    ) -> tower_lsp::jsonrpc::Result<Option<request::GotoDeclarationResponse>> {
4277        self.client
4278            .log_message(MessageType::INFO, "got textDocument/declaration request")
4279            .await;
4280
4281        let uri = params.text_document_position_params.text_document.uri;
4282        let position = params.text_document_position_params.position;
4283
4284        let file_path = match uri.to_file_path() {
4285            Ok(path) => path,
4286            Err(_) => {
4287                self.client
4288                    .log_message(MessageType::ERROR, "invalid file uri")
4289                    .await;
4290                return Ok(None);
4291            }
4292        };
4293
4294        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
4295            Some(bytes) => bytes,
4296            None => return Ok(None),
4297        };
4298
4299        // Fast path: alias usage → alias declaration in the import statement.
4300        {
4301            let identifier = crate::rename::get_identifier_at_position(&source_bytes, position);
4302            if let Some(ref ident) = identifier {
4303                let alias_names = crate::rename::ts_find_alias_names(&source_bytes);
4304                if alias_names.contains(ident.as_str())
4305                    && let Some(decl_range) =
4306                        crate::rename::ts_find_alias_declaration(&source_bytes, ident)
4307                {
4308                    let location = Location {
4309                        uri: uri.clone(),
4310                        range: decl_range,
4311                    };
4312                    self.client
4313                        .log_message(
4314                            MessageType::INFO,
4315                            format!(
4316                                "found declaration (alias) for '{}' at line {}",
4317                                ident, decl_range.start.line
4318                            ),
4319                        )
4320                        .await;
4321                    return Ok(Some(request::GotoDeclarationResponse::from(location)));
4322                }
4323            }
4324        }
4325
4326        let cached_build = self.get_or_fetch_build(&uri, &file_path, false).await;
4327        let cached_build = match cached_build {
4328            Some(cb) => cb,
4329            None => return Ok(None),
4330        };
4331
4332        if let Some(location) =
4333            goto::goto_declaration_cached(&cached_build, &uri, position, &source_bytes)
4334        {
4335            self.client
4336                .log_message(
4337                    MessageType::INFO,
4338                    format!(
4339                        "found declaration at {}:{}",
4340                        location.uri, location.range.start.line
4341                    ),
4342                )
4343                .await;
4344            Ok(Some(request::GotoDeclarationResponse::from(location)))
4345        } else {
4346            self.client
4347                .log_message(MessageType::INFO, "no declaration found")
4348                .await;
4349            Ok(None)
4350        }
4351    }
4352
4353    async fn goto_implementation(
4354        &self,
4355        params: request::GotoImplementationParams,
4356    ) -> tower_lsp::jsonrpc::Result<Option<request::GotoImplementationResponse>> {
4357        self.client
4358            .log_message(MessageType::INFO, "got textDocument/implementation request")
4359            .await;
4360
4361        let uri = params.text_document_position_params.text_document.uri;
4362        let position = params.text_document_position_params.position;
4363
4364        let file_path = match uri.to_file_path() {
4365            Ok(path) => path,
4366            Err(_) => {
4367                self.client
4368                    .log_message(MessageType::ERROR, "invalid file uri")
4369                    .await;
4370                return Ok(None);
4371            }
4372        };
4373
4374        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
4375            Some(bytes) => bytes,
4376            None => return Ok(None),
4377        };
4378
4379        // For aliases, "go to implementation" means "go to the original
4380        // definition" (e.g., Test struct in A.sol when cursor is on MyTest).
4381        // This uses the AST's referencedDeclaration via goto_declaration_cached.
4382        // Check early so we can also handle the no-build case.
4383        let is_alias = {
4384            let ident = crate::rename::get_identifier_at_position(&source_bytes, position);
4385            if let Some(ref name) = ident {
4386                let alias_names = crate::rename::ts_find_alias_names(&source_bytes);
4387                alias_names.contains(name.as_str())
4388            } else {
4389                false
4390            }
4391        };
4392
4393        let cached_build = self.get_or_fetch_build(&uri, &file_path, false).await;
4394
4395        // If cursor is on an alias name, go directly to the original definition
4396        // via the AST's referencedDeclaration chain.  This gives "go to
4397        // implementation" the semantics of "go to the real thing" while
4398        // "go to definition/declaration" goes to the alias declaration.
4399        if is_alias {
4400            // For aliases we need the AST — if the fast cache miss returned
4401            // None (build not yet ready), trigger a build on demand.
4402            let build = match &cached_build {
4403                Some(cb) => Some(cb.clone()),
4404                None => self.get_or_fetch_build(&uri, &file_path, true).await,
4405            };
4406            if let Some(ref cb) = build {
4407                let byte_pos = goto::pos_to_bytes(&source_bytes, position);
4408
4409                // At the import site (`import {Test as MyTest}`), the alias name
4410                // has nameLocation "-1:-1:-1" so goto_bytes lands on the
4411                // ImportDirective and returns offset 0 (top of file).  Redirect
4412                // the lookup to the foreign (original) identifier before "as",
4413                // which has a valid referencedDeclaration and byte position.
4414                let lookup_position = if let Some(foreign_byte) =
4415                    crate::rename::ts_alias_foreign_byte_offset(&source_bytes, byte_pos)
4416                {
4417                    // Symbol alias — redirect to the foreign identifier position.
4418                    goto::bytes_to_pos(&source_bytes, foreign_byte).unwrap_or(position)
4419                } else {
4420                    // Usage site or unit alias — use original cursor position.
4421                    position
4422                };
4423
4424                if let Some(location) =
4425                    goto::goto_declaration_cached(cb, &uri, lookup_position, &source_bytes)
4426                {
4427                    let ident = crate::rename::get_identifier_at_position(&source_bytes, position)
4428                        .unwrap_or_default();
4429                    self.client
4430                        .log_message(
4431                            MessageType::INFO,
4432                            format!(
4433                                "found implementation (alias → original) for '{}' at {}:{}",
4434                                ident, location.uri, location.range.start.line
4435                            ),
4436                        )
4437                        .await;
4438                    return Ok(Some(request::GotoImplementationResponse::Scalar(location)));
4439                }
4440            }
4441        }
4442
4443        let cached_build = match cached_build {
4444            Some(cb) => cb,
4445            None => return Ok(None),
4446        };
4447
4448        let byte_position = goto::pos_to_bytes(&source_bytes, position);
4449        let abs_path = uri.as_ref().strip_prefix("file://").unwrap_or(uri.as_ref());
4450
4451        // Resolve node ID at cursor and follow referencedDeclaration.
4452        // Also resolve the target's declaration abs_path + byte_offset for
4453        // cross-build re-resolution (node IDs differ across builds).
4454        let (target_id, target_decl_abs, target_decl_offset) =
4455            match references::byte_to_id(&cached_build.nodes, abs_path, byte_position) {
4456                Some(id) => {
4457                    let resolved = cached_build
4458                        .nodes
4459                        .get(abs_path)
4460                        .and_then(|f| f.get(&id))
4461                        .and_then(|info| info.referenced_declaration)
4462                        .unwrap_or(id);
4463
4464                    // Find the declaration's file and name_location byte offset.
4465                    let (decl_abs, decl_offset) = references::resolve_target_location(
4466                        &cached_build,
4467                        &uri,
4468                        position,
4469                        &source_bytes,
4470                    )
4471                    .unwrap_or_else(|| (abs_path.to_string(), byte_position));
4472
4473                    (resolved, decl_abs, decl_offset)
4474                }
4475                None => return Ok(None),
4476            };
4477
4478        // Collect all builds to search.
4479        let project_build = self.ensure_project_cached_build().await;
4480        let sub_caches = self.sub_caches.read().await;
4481
4482        let mut builds: Vec<&goto::CachedBuild> = vec![&cached_build];
4483        if let Some(ref pb) = project_build {
4484            builds.push(pb);
4485        }
4486        for sc in sub_caches.iter() {
4487            builds.push(sc);
4488        }
4489
4490        // For each build, re-resolve the target by byte offset (stable across
4491        // compilations), then look up base_function_implementation with the
4492        // build-local node ID.  Collect (impl_id, build_ref) pairs so we can
4493        // resolve locations within the same build that produced the ID.
4494        let mut locations: Vec<Location> = Vec::new();
4495        let mut seen_positions: Vec<(String, u32, u32)> = Vec::new(); // (uri, line, char)
4496
4497        for build in &builds {
4498            // Re-resolve target in this build's node-ID space.
4499            let local_target =
4500                references::byte_to_id(&build.nodes, &target_decl_abs, target_decl_offset).or_else(
4501                    || {
4502                        // If the declaration file isn't in this build, try the original ID.
4503                        if build.nodes.values().any(|f| f.contains_key(&target_id)) {
4504                            Some(target_id)
4505                        } else {
4506                            None
4507                        }
4508                    },
4509                );
4510
4511            let Some(local_id) = local_target else {
4512                continue;
4513            };
4514
4515            // Look up equivalents in this build.
4516            let Some(impls) = build.base_function_implementation.get(&local_id) else {
4517                continue;
4518            };
4519
4520            for &impl_id in impls {
4521                if let Some(loc) =
4522                    references::id_to_location(&build.nodes, &build.id_to_path_map, impl_id)
4523                {
4524                    // Dedup by source position.
4525                    let key = (
4526                        loc.uri.to_string(),
4527                        loc.range.start.line,
4528                        loc.range.start.character,
4529                    );
4530                    if !seen_positions.contains(&key) {
4531                        seen_positions.push(key);
4532                        locations.push(loc);
4533                    }
4534                }
4535            }
4536        }
4537
4538        // Fallback: when no baseFunctions/inheritance implementations are found,
4539        // behave like goto-definition so the user always lands somewhere useful.
4540        if locations.is_empty() {
4541            if let Some(location) =
4542                goto::goto_declaration_cached(&cached_build, &uri, position, &source_bytes)
4543            {
4544                self.client
4545                    .log_message(
4546                        MessageType::INFO,
4547                        "no implementations found, falling back to definition",
4548                    )
4549                    .await;
4550                return Ok(Some(request::GotoImplementationResponse::Scalar(location)));
4551            }
4552
4553            self.client
4554                .log_message(MessageType::INFO, "no implementations found")
4555                .await;
4556            return Ok(None);
4557        }
4558
4559        self.client
4560            .log_message(
4561                MessageType::INFO,
4562                format!("found {} implementation(s)", locations.len()),
4563            )
4564            .await;
4565
4566        if locations.len() == 1 {
4567            Ok(Some(request::GotoImplementationResponse::Scalar(
4568                locations.into_iter().next().unwrap(),
4569            )))
4570        } else {
4571            Ok(Some(request::GotoImplementationResponse::Array(locations)))
4572        }
4573    }
4574
4575    async fn references(
4576        &self,
4577        params: ReferenceParams,
4578    ) -> tower_lsp::jsonrpc::Result<Option<Vec<Location>>> {
4579        self.client
4580            .log_message(MessageType::INFO, "Got a textDocument/references request")
4581            .await;
4582
4583        let uri = params.text_document_position.text_document.uri;
4584        let position = params.text_document_position.position;
4585        let file_path = match uri.to_file_path() {
4586            Ok(path) => path,
4587            Err(_) => {
4588                self.client
4589                    .log_message(MessageType::ERROR, "Invalid file URI")
4590                    .await;
4591                return Ok(None);
4592            }
4593        };
4594        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
4595            Some(bytes) => bytes,
4596            None => return Ok(None),
4597        };
4598
4599        // Fast path: if the cursor is on an import alias name (either at the
4600        // import declaration or at a usage site), collect references via
4601        // tree-sitter instead of the AST.  Alias local names (e.g. `MyTest`
4602        // in `import {Test as MyTest}`) have `nameLocation: "-1:-1:-1"` in
4603        // the solc AST, so `byte_to_id` cannot resolve them.  Even at usage
4604        // sites, the AST's `referencedDeclaration` points to the original
4605        // symbol (Test), not the alias — the AST path would return references
4606        // to the wrong name.
4607        //
4608        // Tree-sitter gives us exact positions for all identifier nodes whose
4609        // text matches the alias name within this file.
4610        {
4611            let cursor_byte = crate::goto::pos_to_bytes(&source_bytes, position);
4612            let identifier = crate::rename::get_identifier_at_position(&source_bytes, position);
4613
4614            let is_alias = if let Some(alias_name) =
4615                crate::rename::ts_alias_local_name_at_cursor(&source_bytes, cursor_byte)
4616            {
4617                // Cursor is directly on an alias name at the import site.
4618                Some(alias_name)
4619            } else if let Some(ref ident) = identifier {
4620                // Cursor is on an identifier — check if it matches any alias
4621                // name declared in this file's imports.
4622                let alias_names = crate::rename::ts_find_alias_names(&source_bytes);
4623                if alias_names.contains(ident.as_str()) {
4624                    Some(ident.clone())
4625                } else {
4626                    None
4627                }
4628            } else {
4629                None
4630            };
4631
4632            if let Some(alias_name) = is_alias {
4633                let locations = crate::rename::ts_collect_identifier_locations(
4634                    &source_bytes,
4635                    &uri,
4636                    &alias_name,
4637                );
4638                self.client
4639                    .log_message(
4640                        MessageType::INFO,
4641                        format!(
4642                            "Found {} references for alias '{}'",
4643                            locations.len(),
4644                            alias_name
4645                        ),
4646                    )
4647                    .await;
4648                return Ok(Some(locations));
4649            }
4650        }
4651
4652        let file_build = self.get_or_fetch_build(&uri, &file_path, true).await;
4653        let file_build = match file_build {
4654            Some(cb) => cb,
4655            None => return Ok(None),
4656        };
4657        let mut project_build = self.ensure_project_cached_build().await;
4658        let current_abs = file_path.to_string_lossy().to_string();
4659        if self.use_solc
4660            && self.settings.read().await.project_index.full_project_scan
4661            && project_build
4662                .as_ref()
4663                .is_some_and(|b| !b.nodes.contains_key(current_abs.as_str()))
4664        {
4665            let foundry_config = self.foundry_config_for_file(&file_path).await;
4666            let remappings = crate::solc::resolve_remappings(&foundry_config).await;
4667            let changed = vec![PathBuf::from(&current_abs)];
4668            let cfg_for_plan = foundry_config.clone();
4669            let remappings_for_plan = remappings.clone();
4670            let affected_set = tokio::task::spawn_blocking(move || {
4671                compute_reverse_import_closure(&cfg_for_plan, &changed, &remappings_for_plan)
4672            })
4673            .await
4674            .ok()
4675            .unwrap_or_default();
4676            let mut affected_files: Vec<PathBuf> = affected_set.into_iter().collect();
4677            if affected_files.is_empty() {
4678                affected_files.push(PathBuf::from(&current_abs));
4679            }
4680            let text_cache_snapshot = self.text_cache.read().await.clone();
4681            match crate::solc::solc_project_index_scoped(
4682                &foundry_config,
4683                Some(&self.client),
4684                Some(&text_cache_snapshot),
4685                &affected_files,
4686            )
4687            .await
4688            {
4689                Ok(ast_data) => {
4690                    let scoped_build = Arc::new(crate::goto::CachedBuild::new(
4691                        ast_data,
4692                        0,
4693                        Some(&mut *self.path_interner.write().await),
4694                    ));
4695                    if let Some(root_key) = self.project_cache_key().await {
4696                        let merged = {
4697                            let mut cache = self.ast_cache.write().await;
4698                            let merged = if let Some(existing) = cache.get(&root_key).cloned() {
4699                                let mut merged = (*existing).clone();
4700                                match merge_scoped_cached_build(
4701                                    &mut merged,
4702                                    (*scoped_build).clone(),
4703                                ) {
4704                                    Ok(_) => Arc::new(merged),
4705                                    Err(_) => scoped_build.clone(),
4706                                }
4707                            } else {
4708                                scoped_build.clone()
4709                            };
4710                            cache.insert(root_key.into(), merged.clone());
4711                            merged
4712                        };
4713                        project_build = Some(merged);
4714                    } else {
4715                        project_build = Some(scoped_build);
4716                    }
4717                    self.client
4718                        .log_message(
4719                            MessageType::INFO,
4720                            format!(
4721                                "references warm-refresh: scoped reindex applied (affected={})",
4722                                affected_files.len()
4723                            ),
4724                        )
4725                        .await;
4726                }
4727                Err(e) => {
4728                    self.client
4729                        .log_message(
4730                            MessageType::WARNING,
4731                            format!("references warm-refresh: scoped reindex failed: {e}"),
4732                        )
4733                        .await;
4734                }
4735            }
4736        }
4737
4738        // Always resolve target/local references from the current file build.
4739        // This avoids stale/partial project-cache misses immediately after edits.
4740        let mut locations = references::goto_references_cached(
4741            &file_build,
4742            &uri,
4743            position,
4744            &source_bytes,
4745            None,
4746            params.context.include_declaration,
4747        );
4748
4749        // Cross-file: resolve target from current file, then expand in project cache.
4750        // Exclude the current file from the project-cache scan — the file-level
4751        // build already covers it with correct (freshly compiled) byte offsets.
4752        // The project cache may have stale offsets if the file was edited since
4753        // the last full index, which would produce duplicate bogus locations.
4754        if let Some((def_abs_path, def_byte_offset)) =
4755            references::resolve_target_location(&file_build, &uri, position, &source_bytes)
4756        {
4757            if let Some(project_build) = project_build {
4758                let other_locations = references::goto_references_for_target(
4759                    &project_build,
4760                    &def_abs_path,
4761                    def_byte_offset,
4762                    None,
4763                    params.context.include_declaration,
4764                    Some(&current_abs),
4765                );
4766                locations.extend(other_locations);
4767            }
4768
4769            // Search sub-project caches for references in lib test files.
4770            // Each sub-cache has its own node ID space; byte_to_id matches
4771            // the target declaration by absolute file path + byte offset.
4772            // No exclusion needed — sub-caches don't contain the current file.
4773            let sub_caches = self.sub_caches.read().await;
4774            for sub_cache in sub_caches.iter() {
4775                let sub_locations = references::goto_references_for_target(
4776                    sub_cache,
4777                    &def_abs_path,
4778                    def_byte_offset,
4779                    None,
4780                    params.context.include_declaration,
4781                    None,
4782                );
4783                locations.extend(sub_locations);
4784            }
4785
4786            // Per-file builds for files the user has opened. Each per-file
4787            // build is the AST of that file's compile unit (the file plus
4788            // its transitive imports), with its own solc id space. The
4789            // project build may not yet cover those files (phase 2 still
4790            // running, partial cache, compile failure for some files), but
4791            // their per-file builds will contain refs that match the target
4792            // when re-resolved by `byte_to_id` against `def_abs_path` /
4793            // `def_byte_offset`. Without this iteration, references queried
4794            // from a different file (e.g. `IHubBase.sol` defining `event Add`)
4795            // would not see refs in test files that are present only in
4796            // their own per-file builds.
4797            //
4798            // The current file's build was already searched as `file_build`,
4799            // and `project_build` was searched above — exclude both to keep
4800            // the workload minimal; `dedup_locations` handles overlaps.
4801            let project_root_key = self.project_cache_key().await;
4802            let other_builds: Vec<Arc<goto::CachedBuild>> = {
4803                let cache = self.ast_cache.read().await;
4804                cache
4805                    .iter()
4806                    .filter(|(key, _)| {
4807                        key.as_str() != uri.to_string()
4808                            && project_root_key.as_deref() != Some(key.as_str())
4809                    })
4810                    .map(|(_, v)| v.clone())
4811                    .collect()
4812            };
4813            for other in &other_builds {
4814                let extra = references::goto_references_for_target(
4815                    other,
4816                    &def_abs_path,
4817                    def_byte_offset,
4818                    None,
4819                    params.context.include_declaration,
4820                    Some(&current_abs),
4821                );
4822                locations.extend(extra);
4823            }
4824        }
4825
4826        // Deduplicate across all caches — removes exact duplicates and
4827        // contained-range duplicates (e.g., UserDefinedTypeName full-span
4828        // vs IdentifierPath name-only span for qualified type paths).
4829        locations = references::dedup_locations(locations);
4830
4831        self.client
4832            .log_message(
4833                MessageType::INFO,
4834                format!("Found {} references", locations.len()),
4835            )
4836            .await;
4837        Ok(Some(locations))
4838    }
4839
4840    async fn prepare_rename(
4841        &self,
4842        params: TextDocumentPositionParams,
4843    ) -> tower_lsp::jsonrpc::Result<Option<PrepareRenameResponse>> {
4844        self.client
4845            .log_message(MessageType::INFO, "got textDocument/prepareRename request")
4846            .await;
4847
4848        let uri = params.text_document.uri;
4849        let position = params.position;
4850
4851        let file_path = match uri.to_file_path() {
4852            Ok(path) => path,
4853            Err(_) => {
4854                self.client
4855                    .log_message(MessageType::ERROR, "invalid file uri")
4856                    .await;
4857                return Ok(None);
4858            }
4859        };
4860
4861        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
4862            Some(bytes) => bytes,
4863            None => return Ok(None),
4864        };
4865
4866        if let Some(range) = rename::get_identifier_range(&source_bytes, position) {
4867            self.client
4868                .log_message(
4869                    MessageType::INFO,
4870                    format!(
4871                        "prepare rename range: {}:{}",
4872                        range.start.line, range.start.character
4873                    ),
4874                )
4875                .await;
4876            Ok(Some(PrepareRenameResponse::Range(range)))
4877        } else {
4878            self.client
4879                .log_message(MessageType::INFO, "no identifier found for prepare rename")
4880                .await;
4881            Ok(None)
4882        }
4883    }
4884
4885    async fn rename(
4886        &self,
4887        params: RenameParams,
4888    ) -> tower_lsp::jsonrpc::Result<Option<WorkspaceEdit>> {
4889        self.client
4890            .log_message(MessageType::INFO, "got textDocument/rename request")
4891            .await;
4892
4893        let uri = params.text_document_position.text_document.uri;
4894        let position = params.text_document_position.position;
4895        let new_name = params.new_name;
4896        let file_path = match uri.to_file_path() {
4897            Ok(p) => p,
4898            Err(_) => {
4899                self.client
4900                    .log_message(MessageType::ERROR, "invalid file uri")
4901                    .await;
4902                return Ok(None);
4903            }
4904        };
4905        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
4906            Some(bytes) => bytes,
4907            None => return Ok(None),
4908        };
4909
4910        let current_identifier = match rename::get_identifier_at_position(&source_bytes, position) {
4911            Some(id) => id,
4912            None => {
4913                self.client
4914                    .log_message(MessageType::ERROR, "No identifier found at position")
4915                    .await;
4916                return Ok(None);
4917            }
4918        };
4919
4920        if !utils::is_valid_solidity_identifier(&new_name) {
4921            return Err(tower_lsp::jsonrpc::Error::invalid_params(
4922                "new name is not a valid solidity identifier",
4923            ));
4924        }
4925
4926        if new_name == current_identifier {
4927            self.client
4928                .log_message(
4929                    MessageType::INFO,
4930                    "new name is the same as current identifier",
4931                )
4932                .await;
4933            return Ok(None);
4934        }
4935
4936        let cached_build = self.get_or_fetch_build(&uri, &file_path, false).await;
4937        let cached_build = match cached_build {
4938            Some(cb) => cb,
4939            None => return Ok(None),
4940        };
4941        let other_builds: Vec<Arc<goto::CachedBuild>> = {
4942            let cache = self.ast_cache.read().await;
4943            cache
4944                .iter()
4945                .filter(|(key, _)| key.as_str() != uri.to_string())
4946                .map(|(_, v)| v.clone())
4947                .collect()
4948        };
4949        let other_refs: Vec<&goto::CachedBuild> = other_builds.iter().map(|v| v.as_ref()).collect();
4950
4951        // Build a map of URI → file content from the text_cache so rename
4952        // verification reads from in-memory buffers (unsaved edits) instead
4953        // of from disk.
4954        let text_buffers: HashMap<String, Vec<u8>> = {
4955            let text_cache = self.text_cache.read().await;
4956            text_cache
4957                .iter()
4958                .map(|(uri, (_, content))| (uri.to_string(), content.as_bytes().to_vec()))
4959                .collect()
4960        };
4961
4962        match rename::rename_symbol(
4963            &cached_build,
4964            &uri,
4965            position,
4966            &source_bytes,
4967            new_name,
4968            &other_refs,
4969            &text_buffers,
4970        ) {
4971            Some(workspace_edit) => {
4972                self.client
4973                    .log_message(
4974                        MessageType::INFO,
4975                        format!(
4976                            "created rename edit with {} file(s), {} total change(s)",
4977                            workspace_edit
4978                                .changes
4979                                .as_ref()
4980                                .map(|c| c.len())
4981                                .unwrap_or(0),
4982                            workspace_edit
4983                                .changes
4984                                .as_ref()
4985                                .map(|c| c.values().map(|v| v.len()).sum::<usize>())
4986                                .unwrap_or(0)
4987                        ),
4988                    )
4989                    .await;
4990
4991                // Return the full WorkspaceEdit to the client so the editor
4992                // applies all changes (including cross-file renames) via the
4993                // LSP protocol. This keeps undo working and avoids writing
4994                // files behind the editor's back.
4995                Ok(Some(workspace_edit))
4996            }
4997
4998            None => {
4999                self.client
5000                    .log_message(MessageType::INFO, "No locations found for renaming")
5001                    .await;
5002                Ok(None)
5003            }
5004        }
5005    }
5006
5007    async fn symbol(
5008        &self,
5009        params: WorkspaceSymbolParams,
5010    ) -> tower_lsp::jsonrpc::Result<Option<Vec<SymbolInformation>>> {
5011        self.client
5012            .log_message(MessageType::INFO, "got workspace/symbol request")
5013            .await;
5014
5015        // Collect sources from open files in text_cache
5016        let files: Vec<(Url, String)> = {
5017            let cache = self.text_cache.read().await;
5018            cache
5019                .iter()
5020                .filter(|(uri_str, _)| uri_str.ends_with(".sol"))
5021                .filter_map(|(uri_str, (_, content))| {
5022                    Url::parse(uri_str).ok().map(|uri| (uri, content.clone()))
5023                })
5024                .collect()
5025        };
5026
5027        let mut all_symbols = symbols::extract_workspace_symbols(&files);
5028        if !params.query.is_empty() {
5029            let query = params.query.to_lowercase();
5030            all_symbols.retain(|symbol| symbol.name.to_lowercase().contains(&query));
5031        }
5032        self.client
5033            .log_message(
5034                MessageType::INFO,
5035                format!("found {} symbols", all_symbols.len()),
5036            )
5037            .await;
5038        Ok(Some(all_symbols))
5039    }
5040
5041    async fn document_symbol(
5042        &self,
5043        params: DocumentSymbolParams,
5044    ) -> tower_lsp::jsonrpc::Result<Option<DocumentSymbolResponse>> {
5045        self.client
5046            .log_message(MessageType::INFO, "got textDocument/documentSymbol request")
5047            .await;
5048        let uri = params.text_document.uri;
5049        let file_path = match uri.to_file_path() {
5050            Ok(path) => path,
5051            Err(_) => {
5052                self.client
5053                    .log_message(MessageType::ERROR, "invalid file uri")
5054                    .await;
5055                return Ok(None);
5056            }
5057        };
5058
5059        // Read source from text_cache (open files) or disk
5060        let source = {
5061            let cache = self.text_cache.read().await;
5062            cache
5063                .get(&uri.to_string())
5064                .map(|(_, content)| content.clone())
5065        };
5066        let source = match source {
5067            Some(s) => s,
5068            None => match std::fs::read_to_string(&file_path) {
5069                Ok(s) => s,
5070                Err(_) => return Ok(None),
5071            },
5072        };
5073
5074        let symbols = symbols::extract_document_symbols(&source);
5075        self.client
5076            .log_message(
5077                MessageType::INFO,
5078                format!("found {} document symbols", symbols.len()),
5079            )
5080            .await;
5081        Ok(Some(DocumentSymbolResponse::Nested(symbols)))
5082    }
5083
5084    async fn document_highlight(
5085        &self,
5086        params: DocumentHighlightParams,
5087    ) -> tower_lsp::jsonrpc::Result<Option<Vec<DocumentHighlight>>> {
5088        self.client
5089            .log_message(
5090                MessageType::INFO,
5091                "got textDocument/documentHighlight request",
5092            )
5093            .await;
5094
5095        let uri = params.text_document_position_params.text_document.uri;
5096        let position = params.text_document_position_params.position;
5097
5098        let source = {
5099            let cache = self.text_cache.read().await;
5100            cache.get(&uri.to_string()).map(|(_, s)| s.clone())
5101        };
5102
5103        let source = match source {
5104            Some(s) => s,
5105            None => {
5106                let file_path = match uri.to_file_path() {
5107                    Ok(p) => p,
5108                    Err(_) => return Ok(None),
5109                };
5110                match std::fs::read_to_string(&file_path) {
5111                    Ok(s) => s,
5112                    Err(_) => return Ok(None),
5113                }
5114            }
5115        };
5116
5117        let highlights = highlight::document_highlights(&source, position);
5118        self.client
5119            .log_message(
5120                MessageType::INFO,
5121                format!("found {} document highlights", highlights.len()),
5122            )
5123            .await;
5124        Ok(Some(highlights))
5125    }
5126
5127    async fn hover(&self, params: HoverParams) -> tower_lsp::jsonrpc::Result<Option<Hover>> {
5128        self.client
5129            .log_message(MessageType::INFO, "got textDocument/hover request")
5130            .await;
5131
5132        let uri = params.text_document_position_params.text_document.uri;
5133        let position = params.text_document_position_params.position;
5134
5135        let file_path = match uri.to_file_path() {
5136            Ok(path) => path,
5137            Err(_) => {
5138                self.client
5139                    .log_message(MessageType::ERROR, "invalid file uri")
5140                    .await;
5141                return Ok(None);
5142            }
5143        };
5144
5145        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
5146            Some(bytes) => bytes,
5147            None => return Ok(None),
5148        };
5149
5150        let cached_build = self.get_or_fetch_build(&uri, &file_path, false).await;
5151        let cached_build = match cached_build {
5152            Some(cb) => cb,
5153            None => return Ok(None),
5154        };
5155
5156        // Text the cached build was compiled from, so hover can map the live
5157        // cursor into the AST's offsets and detect a stale match.
5158        let compiled_text: Option<Arc<str>> = self
5159            .completion_source_text
5160            .read()
5161            .await
5162            .get(&uri.to_string())
5163            .filter(|(src_cache, _)| Arc::ptr_eq(src_cache, &cached_build.completion_cache))
5164            .map(|(_, text)| text.clone());
5165
5166        let result = hover::hover_info(
5167            &cached_build,
5168            &uri,
5169            position,
5170            &source_bytes,
5171            compiled_text.as_deref(),
5172        );
5173
5174        if result.is_some() {
5175            self.client
5176                .log_message(MessageType::INFO, "hover info found")
5177                .await;
5178        } else {
5179            self.client
5180                .log_message(MessageType::INFO, "no hover info found")
5181                .await;
5182        }
5183
5184        Ok(result)
5185    }
5186
5187    async fn signature_help(
5188        &self,
5189        params: SignatureHelpParams,
5190    ) -> tower_lsp::jsonrpc::Result<Option<SignatureHelp>> {
5191        self.client
5192            .log_message(MessageType::INFO, "got textDocument/signatureHelp request")
5193            .await;
5194
5195        let uri = params.text_document_position_params.text_document.uri;
5196        let position = params.text_document_position_params.position;
5197
5198        let file_path = match uri.to_file_path() {
5199            Ok(path) => path,
5200            Err(_) => {
5201                self.client
5202                    .log_message(MessageType::ERROR, "invalid file uri")
5203                    .await;
5204                return Ok(None);
5205            }
5206        };
5207
5208        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
5209            Some(bytes) => bytes,
5210            None => return Ok(None),
5211        };
5212
5213        let cached_build = self.get_or_fetch_build(&uri, &file_path, false).await;
5214        let cached_build = match cached_build {
5215            Some(cb) => cb,
5216            None => return Ok(None),
5217        };
5218
5219        let result = hover::signature_help(&cached_build, &source_bytes, position);
5220
5221        Ok(result)
5222    }
5223
5224    async fn document_link(
5225        &self,
5226        params: DocumentLinkParams,
5227    ) -> tower_lsp::jsonrpc::Result<Option<Vec<DocumentLink>>> {
5228        self.client
5229            .log_message(MessageType::INFO, "got textDocument/documentLink request")
5230            .await;
5231
5232        let uri = params.text_document.uri;
5233        let file_path = match uri.to_file_path() {
5234            Ok(path) => path,
5235            Err(_) => {
5236                self.client
5237                    .log_message(MessageType::ERROR, "invalid file uri")
5238                    .await;
5239                return Ok(None);
5240            }
5241        };
5242
5243        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
5244            Some(bytes) => bytes,
5245            None => return Ok(None),
5246        };
5247
5248        let cached_build = self.get_or_fetch_build(&uri, &file_path, false).await;
5249        let cached_build = match cached_build {
5250            Some(cb) => cb,
5251            None => return Ok(None),
5252        };
5253
5254        let result = links::document_links(&cached_build, &uri, &source_bytes);
5255        self.client
5256            .log_message(
5257                MessageType::INFO,
5258                format!("found {} document links", result.len()),
5259            )
5260            .await;
5261        Ok(Some(result))
5262    }
5263
5264    async fn semantic_tokens_full(
5265        &self,
5266        params: SemanticTokensParams,
5267    ) -> tower_lsp::jsonrpc::Result<Option<SemanticTokensResult>> {
5268        self.client
5269            .log_message(
5270                MessageType::INFO,
5271                "got textDocument/semanticTokens/full request",
5272            )
5273            .await;
5274
5275        let uri = params.text_document.uri;
5276        let source = {
5277            let cache = self.text_cache.read().await;
5278            cache.get(&uri.to_string()).map(|(_, s)| s.clone())
5279        };
5280
5281        let source = match source {
5282            Some(s) => s,
5283            None => {
5284                // File not open in editor — try reading from disk
5285                let file_path = match uri.to_file_path() {
5286                    Ok(p) => p,
5287                    Err(_) => return Ok(None),
5288                };
5289                match std::fs::read_to_string(&file_path) {
5290                    Ok(s) => s,
5291                    Err(_) => return Ok(None),
5292                }
5293            }
5294        };
5295
5296        let mut tokens = semantic_tokens::semantic_tokens_full(&source);
5297
5298        // Generate a unique result_id and cache the tokens for delta requests
5299        let id = self.semantic_token_id.fetch_add(1, Ordering::Relaxed);
5300        let result_id = id.to_string();
5301        tokens.result_id = Some(result_id.clone());
5302
5303        {
5304            let mut cache = self.semantic_token_cache.write().await;
5305            cache.insert(uri.to_string().into(), (result_id, tokens.data.clone()));
5306        }
5307
5308        Ok(Some(SemanticTokensResult::Tokens(tokens)))
5309    }
5310
5311    async fn semantic_tokens_range(
5312        &self,
5313        params: SemanticTokensRangeParams,
5314    ) -> tower_lsp::jsonrpc::Result<Option<SemanticTokensRangeResult>> {
5315        self.client
5316            .log_message(
5317                MessageType::INFO,
5318                "got textDocument/semanticTokens/range request",
5319            )
5320            .await;
5321
5322        let uri = params.text_document.uri;
5323        let range = params.range;
5324        let source = {
5325            let cache = self.text_cache.read().await;
5326            cache.get(&uri.to_string()).map(|(_, s)| s.clone())
5327        };
5328
5329        let source = match source {
5330            Some(s) => s,
5331            None => {
5332                let file_path = match uri.to_file_path() {
5333                    Ok(p) => p,
5334                    Err(_) => return Ok(None),
5335                };
5336                match std::fs::read_to_string(&file_path) {
5337                    Ok(s) => s,
5338                    Err(_) => return Ok(None),
5339                }
5340            }
5341        };
5342
5343        let tokens =
5344            semantic_tokens::semantic_tokens_range(&source, range.start.line, range.end.line);
5345
5346        Ok(Some(SemanticTokensRangeResult::Tokens(tokens)))
5347    }
5348
5349    async fn semantic_tokens_full_delta(
5350        &self,
5351        params: SemanticTokensDeltaParams,
5352    ) -> tower_lsp::jsonrpc::Result<Option<SemanticTokensFullDeltaResult>> {
5353        self.client
5354            .log_message(
5355                MessageType::INFO,
5356                "got textDocument/semanticTokens/full/delta request",
5357            )
5358            .await;
5359
5360        let uri = params.text_document.uri;
5361        let previous_result_id = params.previous_result_id;
5362
5363        let source = {
5364            let cache = self.text_cache.read().await;
5365            cache.get(&uri.to_string()).map(|(_, s)| s.clone())
5366        };
5367
5368        let source = match source {
5369            Some(s) => s,
5370            None => {
5371                let file_path = match uri.to_file_path() {
5372                    Ok(p) => p,
5373                    Err(_) => return Ok(None),
5374                };
5375                match std::fs::read_to_string(&file_path) {
5376                    Ok(s) => s,
5377                    Err(_) => return Ok(None),
5378                }
5379            }
5380        };
5381
5382        let mut new_tokens = semantic_tokens::semantic_tokens_full(&source);
5383
5384        // Generate a new result_id
5385        let id = self.semantic_token_id.fetch_add(1, Ordering::Relaxed);
5386        let new_result_id = id.to_string();
5387        new_tokens.result_id = Some(new_result_id.clone());
5388
5389        let uri_str = uri.to_string();
5390
5391        // Look up the previous tokens by result_id
5392        let old_tokens = {
5393            let cache = self.semantic_token_cache.read().await;
5394            cache
5395                .get(&uri_str)
5396                .filter(|(rid, _)| *rid == previous_result_id)
5397                .map(|(_, tokens)| tokens.clone())
5398        };
5399
5400        // Update the cache with the new tokens
5401        {
5402            let mut cache = self.semantic_token_cache.write().await;
5403            cache.insert(
5404                uri_str.into(),
5405                (new_result_id.clone(), new_tokens.data.clone()),
5406            );
5407        }
5408
5409        match old_tokens {
5410            Some(old) => {
5411                // Compute delta
5412                let edits = semantic_tokens::compute_delta(&old, &new_tokens.data);
5413                Ok(Some(SemanticTokensFullDeltaResult::TokensDelta(
5414                    SemanticTokensDelta {
5415                        result_id: Some(new_result_id),
5416                        edits,
5417                    },
5418                )))
5419            }
5420            None => {
5421                // No cached previous — fall back to full response
5422                Ok(Some(SemanticTokensFullDeltaResult::Tokens(new_tokens)))
5423            }
5424        }
5425    }
5426
5427    async fn folding_range(
5428        &self,
5429        params: FoldingRangeParams,
5430    ) -> tower_lsp::jsonrpc::Result<Option<Vec<FoldingRange>>> {
5431        self.client
5432            .log_message(MessageType::INFO, "got textDocument/foldingRange request")
5433            .await;
5434
5435        let uri = params.text_document.uri;
5436
5437        let source = {
5438            let cache = self.text_cache.read().await;
5439            cache.get(&uri.to_string()).map(|(_, s)| s.clone())
5440        };
5441
5442        let source = match source {
5443            Some(s) => s,
5444            None => {
5445                let file_path = match uri.to_file_path() {
5446                    Ok(p) => p,
5447                    Err(_) => return Ok(None),
5448                };
5449                match std::fs::read_to_string(&file_path) {
5450                    Ok(s) => s,
5451                    Err(_) => return Ok(None),
5452                }
5453            }
5454        };
5455
5456        let ranges = folding::folding_ranges(&source);
5457        self.client
5458            .log_message(
5459                MessageType::INFO,
5460                format!("found {} folding ranges", ranges.len()),
5461            )
5462            .await;
5463        Ok(Some(ranges))
5464    }
5465
5466    async fn selection_range(
5467        &self,
5468        params: SelectionRangeParams,
5469    ) -> tower_lsp::jsonrpc::Result<Option<Vec<SelectionRange>>> {
5470        self.client
5471            .log_message(MessageType::INFO, "got textDocument/selectionRange request")
5472            .await;
5473
5474        let uri = params.text_document.uri;
5475
5476        let source = {
5477            let cache = self.text_cache.read().await;
5478            cache.get(&uri.to_string()).map(|(_, s)| s.clone())
5479        };
5480
5481        let source = match source {
5482            Some(s) => s,
5483            None => {
5484                let file_path = match uri.to_file_path() {
5485                    Ok(p) => p,
5486                    Err(_) => return Ok(None),
5487                };
5488                match std::fs::read_to_string(&file_path) {
5489                    Ok(s) => s,
5490                    Err(_) => return Ok(None),
5491                }
5492            }
5493        };
5494
5495        let ranges = selection::selection_ranges(&source, &params.positions);
5496        self.client
5497            .log_message(
5498                MessageType::INFO,
5499                format!("found {} selection ranges", ranges.len()),
5500            )
5501            .await;
5502        Ok(Some(ranges))
5503    }
5504
5505    async fn inlay_hint(
5506        &self,
5507        params: InlayHintParams,
5508    ) -> tower_lsp::jsonrpc::Result<Option<Vec<InlayHint>>> {
5509        self.client
5510            .log_message(MessageType::INFO, "got textDocument/inlayHint request")
5511            .await;
5512
5513        let uri = params.text_document.uri;
5514        let range = params.range;
5515
5516        let file_path = match uri.to_file_path() {
5517            Ok(path) => path,
5518            Err(_) => {
5519                self.client
5520                    .log_message(MessageType::ERROR, "invalid file uri")
5521                    .await;
5522                return Ok(None);
5523            }
5524        };
5525
5526        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
5527            Some(bytes) => bytes,
5528            None => return Ok(None),
5529        };
5530
5531        let cached_build = self.get_or_fetch_build(&uri, &file_path, false).await;
5532        let cached_build = match cached_build {
5533            Some(cb) => cb,
5534            None => return Ok(None),
5535        };
5536
5537        let mut hints = inlay_hints::inlay_hints(&cached_build, &uri, range, &source_bytes);
5538
5539        // Filter hints based on settings.
5540        let settings = self.settings.read().await;
5541        if !settings.inlay_hints.parameters {
5542            hints.retain(|h| h.kind != Some(InlayHintKind::PARAMETER));
5543        }
5544        self.client
5545            .log_message(
5546                MessageType::INFO,
5547                format!("found {} inlay hints", hints.len()),
5548            )
5549            .await;
5550
5551        // Always return Some — even when empty.  Returning None serialises
5552        // to JSON `null` which LSP clients (notably Neovim) interpret as
5553        // "server couldn't compute hints" and keep displaying whatever
5554        // stale hints they had.  An empty array tells the client "there
5555        // are zero hints" so it clears them.
5556        Ok(Some(hints))
5557    }
5558
5559    async fn code_action(
5560        &self,
5561        params: CodeActionParams,
5562    ) -> tower_lsp::jsonrpc::Result<Option<CodeActionResponse>> {
5563        use crate::code_actions::FixKind;
5564
5565        let uri = &params.text_document.uri;
5566
5567        // Resolve source text once — needed by all tree-sitter backed actions.
5568        let source: Option<String> = if let Ok(path) = uri.to_file_path() {
5569            self.get_source_bytes(uri, &path)
5570                .await
5571                .map(|b| String::from_utf8_lossy(&b).into_owned())
5572        } else {
5573            None
5574        };
5575
5576        let db = &self.code_action_db;
5577        let mut actions: Vec<CodeActionOrCommand> = Vec::new();
5578
5579        for diag in &params.context.diagnostics {
5580            // ── forge-lint string codes ───────────────────────────────────────
5581            if let Some(NumberOrString::String(s)) = &diag.code
5582                && s == "unused-import"
5583            {
5584                if let Some(edit) = source.as_deref().and_then(|src| {
5585                    goto::code_action_edit(
5586                        src,
5587                        diag.range,
5588                        goto::CodeActionKind::DeleteNodeByKind {
5589                            node_kind: "import_directive",
5590                        },
5591                    )
5592                }) {
5593                    let mut changes = HashMap::new();
5594                    changes.insert(uri.clone(), vec![edit]);
5595                    actions.push(CodeActionOrCommand::CodeAction(CodeAction {
5596                        title: "Remove unused import".to_string(),
5597                        kind: Some(CodeActionKind::QUICKFIX),
5598                        diagnostics: Some(vec![diag.clone()]),
5599                        edit: Some(WorkspaceEdit {
5600                            changes: Some(changes),
5601                            ..Default::default()
5602                        }),
5603                        is_preferred: Some(true),
5604                        ..Default::default()
5605                    }));
5606                }
5607                continue;
5608            }
5609
5610            // Diagnostics from solc carry the error code as a string.
5611            let code: ErrorCode = match &diag.code {
5612                Some(NumberOrString::String(s)) => match s.parse() {
5613                    Ok(n) => ErrorCode(n),
5614                    Err(_) => continue,
5615                },
5616                _ => continue,
5617            };
5618
5619            // ── JSON-driven path ──────────────────────────────────────────────
5620            if let Some(def) = db.get(&code) {
5621                // Build the TextEdit from the fix kind.
5622                let edit_opt: Option<TextEdit> = match &def.fix {
5623                    FixKind::Insert { text, anchor: _ } => {
5624                        // InsertAtFileStart is the only anchor for now.
5625                        goto::code_action_edit(
5626                            source.as_deref().unwrap_or(""),
5627                            diag.range,
5628                            goto::CodeActionKind::InsertAtFileStart { text },
5629                        )
5630                    }
5631
5632                    FixKind::ReplaceToken {
5633                        replacement,
5634                        walk_to,
5635                    } => source.as_deref().and_then(|src| {
5636                        goto::code_action_edit(
5637                            src,
5638                            diag.range,
5639                            goto::CodeActionKind::ReplaceToken {
5640                                replacement,
5641                                walk_to: walk_to.as_deref(),
5642                            },
5643                        )
5644                    }),
5645
5646                    FixKind::DeleteToken => source.as_deref().and_then(|src| {
5647                        goto::code_action_edit(src, diag.range, goto::CodeActionKind::DeleteToken)
5648                    }),
5649
5650                    FixKind::DeleteNode { node_kind } => {
5651                        // Only variable_declaration_statement supported so far.
5652                        if node_kind == "variable_declaration_statement" {
5653                            source.as_deref().and_then(|src| {
5654                                goto::code_action_edit(
5655                                    src,
5656                                    diag.range,
5657                                    goto::CodeActionKind::DeleteLocalVar,
5658                                )
5659                            })
5660                        } else {
5661                            None
5662                        }
5663                    }
5664
5665                    FixKind::DeleteChildNode {
5666                        walk_to,
5667                        child_kinds,
5668                    } => {
5669                        let ck: Vec<&str> = child_kinds.iter().map(|s| s.as_str()).collect();
5670                        source.as_deref().and_then(|src| {
5671                            goto::code_action_edit(
5672                                src,
5673                                diag.range,
5674                                goto::CodeActionKind::DeleteChildNode {
5675                                    walk_to,
5676                                    child_kinds: &ck,
5677                                },
5678                            )
5679                        })
5680                    }
5681
5682                    FixKind::ReplaceChildNode {
5683                        walk_to,
5684                        child_kind,
5685                        replacement,
5686                    } => source.as_deref().and_then(|src| {
5687                        goto::code_action_edit(
5688                            src,
5689                            diag.range,
5690                            goto::CodeActionKind::ReplaceChildNode {
5691                                walk_to,
5692                                child_kind,
5693                                replacement,
5694                            },
5695                        )
5696                    }),
5697
5698                    FixKind::InsertBeforeNode {
5699                        walk_to,
5700                        before_child,
5701                        text,
5702                    } => {
5703                        let bc: Vec<&str> = before_child.iter().map(|s| s.as_str()).collect();
5704                        source.as_deref().and_then(|src| {
5705                            goto::code_action_edit(
5706                                src,
5707                                diag.range,
5708                                goto::CodeActionKind::InsertBeforeNode {
5709                                    walk_to,
5710                                    before_child: &bc,
5711                                    text,
5712                                },
5713                            )
5714                        })
5715                    }
5716
5717                    // Custom fixes are handled below.
5718                    FixKind::Custom => None,
5719                };
5720
5721                if let Some(edit) = edit_opt {
5722                    let mut changes = HashMap::new();
5723                    changes.insert(uri.clone(), vec![edit]);
5724                    actions.push(CodeActionOrCommand::CodeAction(CodeAction {
5725                        title: def.title.clone(),
5726                        kind: Some(CodeActionKind::QUICKFIX),
5727                        diagnostics: Some(vec![diag.clone()]),
5728                        edit: Some(WorkspaceEdit {
5729                            changes: Some(changes),
5730                            ..Default::default()
5731                        }),
5732                        is_preferred: Some(true),
5733                        ..Default::default()
5734                    }));
5735                    continue; // handled — skip custom fallback
5736                }
5737
5738                // If it's not Custom and edit_opt was None, the TS lookup failed
5739                // (e.g. file not parseable). Nothing to emit for this diagnostic.
5740                if !matches!(def.fix, FixKind::Custom) {
5741                    continue;
5742                }
5743            }
5744
5745            // ── Custom / hand-written fallback ────────────────────────────────
5746            // Only reaches here for codes with kind=custom or codes not in the DB.
5747            // Add bespoke arms here as needed.
5748            #[allow(clippy::match_single_binding)]
5749            match code {
5750                // 2018 — state mutability can be restricted to pure/view.
5751                // The replacement (pure vs view) is embedded in the diagnostic message.
5752                // 9456 — missing `override` specifier.
5753                // Needs a TS walk to find the right insertion point in the modifier list.
5754                _ => {}
5755            }
5756        }
5757
5758        Ok(Some(actions))
5759    }
5760
5761    async fn will_rename_files(
5762        &self,
5763        params: RenameFilesParams,
5764    ) -> tower_lsp::jsonrpc::Result<Option<WorkspaceEdit>> {
5765        self.client
5766            .log_message(
5767                MessageType::INFO,
5768                format!("workspace/willRenameFiles: {} file(s)", params.files.len()),
5769            )
5770            .await;
5771        if !self
5772            .settings
5773            .read()
5774            .await
5775            .file_operations
5776            .update_imports_on_rename
5777        {
5778            self.client
5779                .log_message(
5780                    MessageType::INFO,
5781                    "willRenameFiles: updateImportsOnRename disabled",
5782                )
5783                .await;
5784            return Ok(None);
5785        }
5786
5787        // ── Phase 1: discover source files (blocking I/O) ──────────────
5788        let config = self.foundry_config.read().await.clone();
5789        let project_root = config.root.clone();
5790        let source_files: Vec<String> = tokio::task::spawn_blocking(move || {
5791            crate::solc::discover_source_files(&config)
5792                .into_iter()
5793                .filter_map(|p| p.to_str().map(String::from))
5794                .collect()
5795        })
5796        .await
5797        .unwrap_or_default();
5798
5799        if source_files.is_empty() {
5800            self.client
5801                .log_message(
5802                    MessageType::WARNING,
5803                    "willRenameFiles: no source files found",
5804                )
5805                .await;
5806            return Ok(None);
5807        }
5808
5809        // ── Phase 2: parse rename params & expand folders ──────────────
5810        let raw_renames: Vec<(std::path::PathBuf, std::path::PathBuf)> = params
5811            .files
5812            .iter()
5813            .filter_map(|fr| {
5814                let old_uri = Url::parse(&fr.old_uri).ok()?;
5815                let new_uri = Url::parse(&fr.new_uri).ok()?;
5816                let old_path = old_uri.to_file_path().ok()?;
5817                let new_path = new_uri.to_file_path().ok()?;
5818                Some((old_path, new_path))
5819            })
5820            .collect();
5821
5822        let renames = file_operations::expand_folder_renames(&raw_renames, &source_files);
5823
5824        if renames.is_empty() {
5825            return Ok(None);
5826        }
5827
5828        self.client
5829            .log_message(
5830                MessageType::INFO,
5831                format!(
5832                    "willRenameFiles: {} rename(s) after folder expansion",
5833                    renames.len()
5834                ),
5835            )
5836            .await;
5837
5838        // ── Phase 3: hydrate text_cache (blocking I/O) ─────────────────
5839        // Collect which files need reading from disk (not already in cache).
5840        let files_to_read: Vec<(String, String)> = {
5841            let tc = self.text_cache.read().await;
5842            source_files
5843                .iter()
5844                .filter_map(|fs_path| {
5845                    let uri = Url::from_file_path(fs_path).ok()?;
5846                    let uri_str = uri.to_string();
5847                    if tc.contains_key(&uri_str) {
5848                        None
5849                    } else {
5850                        Some((uri_str, fs_path.clone()))
5851                    }
5852                })
5853                .collect()
5854        };
5855
5856        if !files_to_read.is_empty() {
5857            let loaded: Vec<(String, String)> = tokio::task::spawn_blocking(move || {
5858                files_to_read
5859                    .into_iter()
5860                    .filter_map(|(uri_str, fs_path)| {
5861                        let content = std::fs::read_to_string(&fs_path).ok()?;
5862                        Some((uri_str, content))
5863                    })
5864                    .collect()
5865            })
5866            .await
5867            .unwrap_or_default();
5868
5869            let mut tc = self.text_cache.write().await;
5870            for (uri_str, content) in loaded {
5871                tc.entry(uri_str.into()).or_insert((0, content));
5872            }
5873        }
5874
5875        // ── Phase 4: compute edits (pure, no I/O) ──────────────────────
5876        // Build source-bytes provider that reads from the cache held behind
5877        // the Arc<RwLock>.  We hold a read guard only for the duration of
5878        // each lookup, not for the full computation.
5879        let text_cache = self.text_cache.clone();
5880        let result = {
5881            let tc = text_cache.read().await;
5882            let get_source_bytes = |fs_path: &str| -> Option<Vec<u8>> {
5883                let uri = Url::from_file_path(fs_path).ok()?;
5884                let (_, content) = tc.get(&uri.to_string())?;
5885                Some(content.as_bytes().to_vec())
5886            };
5887
5888            file_operations::rename_imports(
5889                &source_files,
5890                &renames,
5891                &project_root,
5892                &get_source_bytes,
5893            )
5894        };
5895
5896        // ── Phase 5: log diagnostics ───────────────────────────────────
5897        let stats = &result.stats;
5898        if stats.read_failures > 0 || stats.pathdiff_failures > 0 || stats.duplicate_renames > 0 {
5899            self.client
5900                .log_message(
5901                    MessageType::WARNING,
5902                    format!(
5903                        "willRenameFiles stats: read_failures={}, pathdiff_failures={}, \
5904                         duplicate_renames={}, no_parent={}, no_op_skips={}, dedup_skips={}",
5905                        stats.read_failures,
5906                        stats.pathdiff_failures,
5907                        stats.duplicate_renames,
5908                        stats.no_parent,
5909                        stats.no_op_skips,
5910                        stats.dedup_skips,
5911                    ),
5912                )
5913                .await;
5914        }
5915
5916        let all_edits = result.edits;
5917
5918        if all_edits.is_empty() {
5919            self.client
5920                .log_message(MessageType::INFO, "willRenameFiles: no import edits needed")
5921                .await;
5922            return Ok(None);
5923        }
5924
5925        // ── Phase 6: patch own text_cache ──────────────────────────────
5926        {
5927            let mut tc = self.text_cache.write().await;
5928            let patched = file_operations::apply_edits_to_cache(&all_edits, &mut tc);
5929            self.client
5930                .log_message(
5931                    MessageType::INFO,
5932                    format!("willRenameFiles: patched {} cached file(s)", patched),
5933                )
5934                .await;
5935        }
5936
5937        let total_edits: usize = all_edits.values().map(|v| v.len()).sum();
5938        self.client
5939            .log_message(
5940                MessageType::INFO,
5941                format!(
5942                    "willRenameFiles: {} edit(s) across {} file(s)",
5943                    total_edits,
5944                    all_edits.len()
5945                ),
5946            )
5947            .await;
5948
5949        Ok(Some(WorkspaceEdit {
5950            changes: Some(all_edits),
5951            document_changes: None,
5952            change_annotations: None,
5953        }))
5954    }
5955
5956    async fn did_rename_files(&self, params: RenameFilesParams) {
5957        self.client
5958            .log_message(
5959                MessageType::INFO,
5960                format!("workspace/didRenameFiles: {} file(s)", params.files.len()),
5961            )
5962            .await;
5963        self.project_cache_dirty.store(true, Ordering::Release);
5964        {
5965            let mut changed = self.project_cache_changed_files.write().await;
5966            for file in &params.files {
5967                if let Ok(old_uri) = Url::parse(&file.old_uri)
5968                    && let Ok(old_path) = old_uri.to_file_path()
5969                {
5970                    changed.insert(old_path.to_string_lossy().to_string());
5971                }
5972                if let Ok(new_uri) = Url::parse(&file.new_uri)
5973                    && let Ok(new_path) = new_uri.to_file_path()
5974                {
5975                    changed.insert(new_path.to_string_lossy().to_string());
5976                }
5977            }
5978        }
5979
5980        // ── Phase 1: parse params & expand folder renames ──────────────
5981        let raw_uri_pairs: Vec<(Url, Url)> = params
5982            .files
5983            .iter()
5984            .filter_map(|fr| {
5985                let old_uri = Url::parse(&fr.old_uri).ok()?;
5986                let new_uri = Url::parse(&fr.new_uri).ok()?;
5987                Some((old_uri, new_uri))
5988            })
5989            .collect();
5990
5991        let file_renames = {
5992            let tc = self.text_cache.read().await;
5993            let cache_paths: Vec<std::path::PathBuf> = tc
5994                .keys()
5995                .filter_map(|k| Url::parse(k).ok())
5996                .filter_map(|u| u.to_file_path().ok())
5997                .collect();
5998            drop(tc);
5999
6000            // Include discovered project files so folder renames also migrate
6001            // entries that aren't currently present in text_cache.
6002            let cfg = self.foundry_config.read().await.clone();
6003            let discovered_paths =
6004                tokio::task::spawn_blocking(move || crate::solc::discover_source_files(&cfg))
6005                    .await
6006                    .unwrap_or_default();
6007
6008            let mut all_paths: HashSet<std::path::PathBuf> = discovered_paths.into_iter().collect();
6009            all_paths.extend(cache_paths);
6010            let all_paths: Vec<std::path::PathBuf> = all_paths.into_iter().collect();
6011
6012            file_operations::expand_folder_renames_from_paths(&raw_uri_pairs, &all_paths)
6013        };
6014
6015        self.client
6016            .log_message(
6017                MessageType::INFO,
6018                format!(
6019                    "didRenameFiles: migrating {} cache entry/entries",
6020                    file_renames.len()
6021                ),
6022            )
6023            .await;
6024
6025        // ── Phase 2: migrate per-file caches ───────────────────────────
6026        // Take a single write lock per cache type and do all migrations
6027        // in one pass (avoids repeated lock/unlock per file).
6028        {
6029            let mut tc = self.text_cache.write().await;
6030            for (old_key, new_key) in &file_renames {
6031                if let Some(entry) = tc.remove(old_key) {
6032                    tc.insert(new_key.clone().into(), entry);
6033                }
6034            }
6035        }
6036        {
6037            let mut ac = self.ast_cache.write().await;
6038            for (old_key, _) in &file_renames {
6039                ac.remove(old_key);
6040            }
6041        }
6042        {
6043            let mut cc = self.completion_cache.write().await;
6044            for (old_key, _) in &file_renames {
6045                cc.remove(old_key);
6046            }
6047        }
6048        {
6049            let mut cst = self.completion_source_text.write().await;
6050            for (old_key, _) in &file_renames {
6051                cst.remove(old_key);
6052            }
6053        }
6054        {
6055            let mut sc = self.semantic_token_cache.write().await;
6056            for (old_key, _) in &file_renames {
6057                sc.remove(old_key);
6058            }
6059        }
6060        {
6061            let mut pending = self.pending_create_scaffold.write().await;
6062            for (old_key, _) in &file_renames {
6063                pending.remove(old_key);
6064            }
6065        }
6066
6067        // Invalidate lib sub-caches if any renamed files are under lib/.
6068        {
6069            let affected_paths: Vec<std::path::PathBuf> = file_renames
6070                .iter()
6071                .flat_map(|(old_key, new_key)| {
6072                    let mut paths = Vec::new();
6073                    if let Ok(u) = Url::parse(old_key)
6074                        && let Ok(p) = u.to_file_path()
6075                    {
6076                        paths.push(p);
6077                    }
6078                    if let Ok(u) = Url::parse(new_key)
6079                        && let Ok(p) = u.to_file_path()
6080                    {
6081                        paths.push(p);
6082                    }
6083                    paths
6084                })
6085                .collect();
6086            self.invalidate_lib_sub_caches_if_affected(&affected_paths)
6087                .await;
6088        }
6089
6090        // Re-index in the background.  Keep the old project index entry
6091        // alive so cross-file features (goto-def, references) keep working
6092        // with slightly-stale data until the new build replaces it.
6093        let root_key = self.project_cache_key().await;
6094
6095        let foundry_config = self.foundry_config.read().await.clone();
6096        let ast_cache = self.ast_cache.clone();
6097        let client = self.client.clone();
6098        let path_interner = self.path_interner.clone();
6099        // Snapshot text_cache so the re-index uses in-memory content
6100        // (with updated import paths from willRenameFiles) rather than
6101        // reading from disk where files may not yet reflect the edits.
6102        let text_cache_snapshot = self.text_cache.read().await.clone();
6103
6104        tokio::spawn(async move {
6105            let Some(cache_key) = root_key else {
6106                return;
6107            };
6108            match crate::solc::solc_project_index(
6109                &foundry_config,
6110                Some(&client),
6111                Some(&text_cache_snapshot),
6112            )
6113            .await
6114            {
6115                Ok(ast_data) => {
6116                    let cached_build = Arc::new(crate::goto::CachedBuild::new(
6117                        ast_data,
6118                        0,
6119                        Some(&mut *path_interner.write().await),
6120                    ));
6121                    let source_count = cached_build.nodes.len();
6122                    ast_cache
6123                        .write()
6124                        .await
6125                        .insert(cache_key.into(), cached_build);
6126                    client
6127                        .log_message(
6128                            MessageType::INFO,
6129                            format!("didRenameFiles: re-indexed {} source files", source_count),
6130                        )
6131                        .await;
6132                }
6133                Err(e) => {
6134                    client
6135                        .log_message(
6136                            MessageType::WARNING,
6137                            format!("didRenameFiles: re-index failed: {e}"),
6138                        )
6139                        .await;
6140                }
6141            }
6142        });
6143    }
6144
6145    async fn will_delete_files(
6146        &self,
6147        params: DeleteFilesParams,
6148    ) -> tower_lsp::jsonrpc::Result<Option<WorkspaceEdit>> {
6149        self.client
6150            .log_message(
6151                MessageType::INFO,
6152                format!("workspace/willDeleteFiles: {} file(s)", params.files.len()),
6153            )
6154            .await;
6155        if !update_imports_on_delete_enabled(&*self.settings.read().await) {
6156            self.client
6157                .log_message(
6158                    MessageType::INFO,
6159                    "willDeleteFiles: updateImportsOnDelete disabled",
6160                )
6161                .await;
6162            return Ok(None);
6163        }
6164
6165        let config = self.foundry_config.read().await.clone();
6166        let project_root = config.root.clone();
6167        let source_files: Vec<String> = tokio::task::spawn_blocking(move || {
6168            crate::solc::discover_source_files(&config)
6169                .into_iter()
6170                .filter_map(|p| p.to_str().map(String::from))
6171                .collect()
6172        })
6173        .await
6174        .unwrap_or_default();
6175
6176        if source_files.is_empty() {
6177            self.client
6178                .log_message(
6179                    MessageType::WARNING,
6180                    "willDeleteFiles: no source files found",
6181                )
6182                .await;
6183            return Ok(None);
6184        }
6185
6186        let raw_deletes: Vec<std::path::PathBuf> = params
6187            .files
6188            .iter()
6189            .filter_map(|fd| Url::parse(&fd.uri).ok())
6190            .filter_map(|u| u.to_file_path().ok())
6191            .collect();
6192
6193        let deletes = file_operations::expand_folder_deletes(&raw_deletes, &source_files);
6194        if deletes.is_empty() {
6195            return Ok(None);
6196        }
6197
6198        self.client
6199            .log_message(
6200                MessageType::INFO,
6201                format!(
6202                    "willDeleteFiles: {} delete target(s) after folder expansion",
6203                    deletes.len()
6204                ),
6205            )
6206            .await;
6207
6208        let files_to_read: Vec<(String, String)> = {
6209            let tc = self.text_cache.read().await;
6210            source_files
6211                .iter()
6212                .filter_map(|fs_path| {
6213                    let uri = Url::from_file_path(fs_path).ok()?;
6214                    let uri_str = uri.to_string();
6215                    if tc.contains_key(&uri_str) {
6216                        None
6217                    } else {
6218                        Some((uri_str, fs_path.clone()))
6219                    }
6220                })
6221                .collect()
6222        };
6223
6224        if !files_to_read.is_empty() {
6225            let loaded: Vec<(String, String)> = tokio::task::spawn_blocking(move || {
6226                files_to_read
6227                    .into_iter()
6228                    .filter_map(|(uri_str, fs_path)| {
6229                        let content = std::fs::read_to_string(&fs_path).ok()?;
6230                        Some((uri_str, content))
6231                    })
6232                    .collect()
6233            })
6234            .await
6235            .unwrap_or_default();
6236
6237            let mut tc = self.text_cache.write().await;
6238            for (uri_str, content) in loaded {
6239                tc.entry(uri_str.into()).or_insert((0, content));
6240            }
6241        }
6242
6243        let result = {
6244            let tc = self.text_cache.read().await;
6245            let get_source_bytes = |fs_path: &str| -> Option<Vec<u8>> {
6246                let uri = Url::from_file_path(fs_path).ok()?;
6247                let (_, content) = tc.get(&uri.to_string())?;
6248                Some(content.as_bytes().to_vec())
6249            };
6250
6251            file_operations::delete_imports(
6252                &source_files,
6253                &deletes,
6254                &project_root,
6255                &get_source_bytes,
6256            )
6257        };
6258
6259        let stats = &result.stats;
6260        if stats.read_failures > 0
6261            || stats.statement_range_failures > 0
6262            || stats.duplicate_deletes > 0
6263        {
6264            self.client
6265                .log_message(
6266                    MessageType::WARNING,
6267                    format!(
6268                        "willDeleteFiles stats: read_failures={}, statement_range_failures={}, \
6269                         duplicate_deletes={}, no_parent={}, dedup_skips={}",
6270                        stats.read_failures,
6271                        stats.statement_range_failures,
6272                        stats.duplicate_deletes,
6273                        stats.no_parent,
6274                        stats.dedup_skips,
6275                    ),
6276                )
6277                .await;
6278        }
6279
6280        let all_edits = result.edits;
6281        if all_edits.is_empty() {
6282            self.client
6283                .log_message(
6284                    MessageType::INFO,
6285                    "willDeleteFiles: no import-removal edits needed",
6286                )
6287                .await;
6288            return Ok(None);
6289        }
6290
6291        {
6292            let mut tc = self.text_cache.write().await;
6293            let patched = file_operations::apply_edits_to_cache(&all_edits, &mut tc);
6294            self.client
6295                .log_message(
6296                    MessageType::INFO,
6297                    format!("willDeleteFiles: patched {} cached file(s)", patched),
6298                )
6299                .await;
6300        }
6301
6302        let total_edits: usize = all_edits.values().map(|v| v.len()).sum();
6303        self.client
6304            .log_message(
6305                MessageType::INFO,
6306                format!(
6307                    "willDeleteFiles: {} edit(s) across {} file(s)",
6308                    total_edits,
6309                    all_edits.len()
6310                ),
6311            )
6312            .await;
6313
6314        Ok(Some(WorkspaceEdit {
6315            changes: Some(all_edits),
6316            document_changes: None,
6317            change_annotations: None,
6318        }))
6319    }
6320
6321    async fn did_delete_files(&self, params: DeleteFilesParams) {
6322        self.client
6323            .log_message(
6324                MessageType::INFO,
6325                format!("workspace/didDeleteFiles: {} file(s)", params.files.len()),
6326            )
6327            .await;
6328        self.project_cache_dirty.store(true, Ordering::Release);
6329        {
6330            let mut changed = self.project_cache_changed_files.write().await;
6331            for file in &params.files {
6332                if let Ok(uri) = Url::parse(&file.uri)
6333                    && let Ok(path) = uri.to_file_path()
6334                {
6335                    changed.insert(path.to_string_lossy().to_string());
6336                }
6337            }
6338        }
6339
6340        let raw_delete_uris: Vec<Url> = params
6341            .files
6342            .iter()
6343            .filter_map(|fd| Url::parse(&fd.uri).ok())
6344            .collect();
6345
6346        let deleted_paths = {
6347            let tc = self.text_cache.read().await;
6348            let cache_paths: Vec<std::path::PathBuf> = tc
6349                .keys()
6350                .filter_map(|k| Url::parse(k).ok())
6351                .filter_map(|u| u.to_file_path().ok())
6352                .collect();
6353            drop(tc);
6354
6355            let cfg = self.foundry_config.read().await.clone();
6356            let discovered_paths =
6357                tokio::task::spawn_blocking(move || crate::solc::discover_source_files(&cfg))
6358                    .await
6359                    .unwrap_or_default();
6360
6361            let mut all_paths: HashSet<std::path::PathBuf> = discovered_paths.into_iter().collect();
6362            all_paths.extend(cache_paths);
6363            let all_paths: Vec<std::path::PathBuf> = all_paths.into_iter().collect();
6364
6365            file_operations::expand_folder_deletes_from_paths(&raw_delete_uris, &all_paths)
6366        };
6367
6368        let mut deleted_keys: HashSet<String> = HashSet::new();
6369        let mut deleted_uris: Vec<Url> = Vec::new();
6370        for path in deleted_paths {
6371            if let Ok(uri) = Url::from_file_path(&path) {
6372                deleted_keys.insert(uri.to_string());
6373                deleted_uris.push(uri);
6374            }
6375        }
6376        if deleted_keys.is_empty() {
6377            return;
6378        }
6379
6380        self.client
6381            .log_message(
6382                MessageType::INFO,
6383                format!(
6384                    "didDeleteFiles: deleting {} cache/diagnostic entry(ies)",
6385                    deleted_keys.len()
6386                ),
6387            )
6388            .await;
6389
6390        for uri in &deleted_uris {
6391            self.client
6392                .publish_diagnostics(uri.clone(), vec![], None)
6393                .await;
6394        }
6395
6396        let mut removed_text = 0usize;
6397        let mut removed_ast = 0usize;
6398        let mut removed_completion = 0usize;
6399        let mut removed_semantic = 0usize;
6400        let mut removed_pending_create = 0usize;
6401        {
6402            let mut tc = self.text_cache.write().await;
6403            for key in &deleted_keys {
6404                if tc.remove(key).is_some() {
6405                    removed_text += 1;
6406                }
6407            }
6408        }
6409        {
6410            let mut ac = self.ast_cache.write().await;
6411            for key in &deleted_keys {
6412                if ac.remove(key).is_some() {
6413                    removed_ast += 1;
6414                }
6415            }
6416        }
6417        {
6418            let mut cc = self.completion_cache.write().await;
6419            for key in &deleted_keys {
6420                if cc.remove(key).is_some() {
6421                    removed_completion += 1;
6422                }
6423            }
6424        }
6425        {
6426            let mut cst = self.completion_source_text.write().await;
6427            for key in &deleted_keys {
6428                cst.remove(key);
6429            }
6430        }
6431        {
6432            let mut sc = self.semantic_token_cache.write().await;
6433            for key in &deleted_keys {
6434                if sc.remove(key).is_some() {
6435                    removed_semantic += 1;
6436                }
6437            }
6438        }
6439        {
6440            let mut pending = self.pending_create_scaffold.write().await;
6441            for key in &deleted_keys {
6442                if pending.remove(key) {
6443                    removed_pending_create += 1;
6444                }
6445            }
6446        }
6447        self.client
6448            .log_message(
6449                MessageType::INFO,
6450                format!(
6451                    "didDeleteFiles: removed caches text={} ast={} completion={} semantic={} pendingCreate={}",
6452                    removed_text,
6453                    removed_ast,
6454                    removed_completion,
6455                    removed_semantic,
6456                    removed_pending_create,
6457                ),
6458            )
6459            .await;
6460
6461        // Invalidate lib sub-caches if any deleted files are under lib/.
6462        {
6463            let affected_paths: Vec<std::path::PathBuf> = deleted_keys
6464                .iter()
6465                .filter_map(|k| Url::parse(k).ok())
6466                .filter_map(|u| u.to_file_path().ok())
6467                .collect();
6468            self.invalidate_lib_sub_caches_if_affected(&affected_paths)
6469                .await;
6470        }
6471
6472        // Re-index in the background.  Keep the old project index entry
6473        // alive so cross-file features keep working with slightly-stale
6474        // data until the new build replaces it.
6475        let root_key = self.project_cache_key().await;
6476
6477        let foundry_config = self.foundry_config.read().await.clone();
6478        let ast_cache = self.ast_cache.clone();
6479        let client = self.client.clone();
6480        let path_interner = self.path_interner.clone();
6481        let text_cache_snapshot = self.text_cache.read().await.clone();
6482
6483        tokio::spawn(async move {
6484            let Some(cache_key) = root_key else {
6485                return;
6486            };
6487            match crate::solc::solc_project_index(
6488                &foundry_config,
6489                Some(&client),
6490                Some(&text_cache_snapshot),
6491            )
6492            .await
6493            {
6494                Ok(ast_data) => {
6495                    let cached_build = Arc::new(crate::goto::CachedBuild::new(
6496                        ast_data,
6497                        0,
6498                        Some(&mut *path_interner.write().await),
6499                    ));
6500                    let source_count = cached_build.nodes.len();
6501                    ast_cache
6502                        .write()
6503                        .await
6504                        .insert(cache_key.into(), cached_build);
6505                    client
6506                        .log_message(
6507                            MessageType::INFO,
6508                            format!("didDeleteFiles: re-indexed {} source files", source_count),
6509                        )
6510                        .await;
6511                }
6512                Err(e) => {
6513                    client
6514                        .log_message(
6515                            MessageType::WARNING,
6516                            format!("didDeleteFiles: re-index failed: {e}"),
6517                        )
6518                        .await;
6519                }
6520            }
6521        });
6522    }
6523
6524    async fn will_create_files(
6525        &self,
6526        params: CreateFilesParams,
6527    ) -> tower_lsp::jsonrpc::Result<Option<WorkspaceEdit>> {
6528        self.client
6529            .log_message(
6530                MessageType::INFO,
6531                format!("workspace/willCreateFiles: {} file(s)", params.files.len()),
6532            )
6533            .await;
6534        if !self
6535            .settings
6536            .read()
6537            .await
6538            .file_operations
6539            .template_on_create
6540        {
6541            self.client
6542                .log_message(
6543                    MessageType::INFO,
6544                    "willCreateFiles: templateOnCreate disabled",
6545                )
6546                .await;
6547            return Ok(None);
6548        }
6549        self.client
6550            .log_message(
6551                MessageType::INFO,
6552                "willCreateFiles: skipping pre-create edits; scaffolding via didCreateFiles",
6553            )
6554            .await;
6555        Ok(None)
6556    }
6557
6558    async fn did_create_files(&self, params: CreateFilesParams) {
6559        self.client
6560            .log_message(
6561                MessageType::INFO,
6562                format!("workspace/didCreateFiles: {} file(s)", params.files.len()),
6563            )
6564            .await;
6565        self.project_cache_dirty.store(true, Ordering::Release);
6566        {
6567            let mut changed = self.project_cache_changed_files.write().await;
6568            for file in &params.files {
6569                if let Ok(uri) = Url::parse(&file.uri)
6570                    && let Ok(path) = uri.to_file_path()
6571                {
6572                    changed.insert(path.to_string_lossy().to_string());
6573                }
6574            }
6575        }
6576        if !self
6577            .settings
6578            .read()
6579            .await
6580            .file_operations
6581            .template_on_create
6582        {
6583            self.client
6584                .log_message(
6585                    MessageType::INFO,
6586                    "didCreateFiles: templateOnCreate disabled",
6587                )
6588                .await;
6589            return;
6590        }
6591
6592        let config = self.foundry_config.read().await;
6593        let solc_version = config.solc_version.clone();
6594        drop(config);
6595
6596        // Generate scaffold and push via workspace/applyEdit for files that
6597        // are empty in both cache and on disk. This avoids prepending content
6598        // to already-populated files while keeping a fallback for clients that
6599        // don't apply willCreateFiles edits.
6600        let mut apply_edits: HashMap<Url, Vec<TextEdit>> = HashMap::new();
6601        let mut staged_content: HashMap<String, String> = HashMap::new();
6602        let mut created_uris: Vec<String> = Vec::new();
6603        {
6604            let tc = self.text_cache.read().await;
6605            for file_create in &params.files {
6606                let uri = match Url::parse(&file_create.uri) {
6607                    Ok(u) => u,
6608                    Err(_) => continue,
6609                };
6610                let uri_str = uri.to_string();
6611
6612                let open_has_content = tc
6613                    .get(&uri_str)
6614                    .is_some_and(|(_, c)| c.chars().any(|ch| !ch.is_whitespace()));
6615                let path = match uri.to_file_path() {
6616                    Ok(p) => p,
6617                    Err(_) => continue,
6618                };
6619                let disk_has_content = std::fs::read_to_string(&path)
6620                    .is_ok_and(|c| c.chars().any(|ch| !ch.is_whitespace()));
6621
6622                // If an open buffer already has content, skip. If buffer is
6623                // open but empty, still apply scaffold to that buffer.
6624                if open_has_content {
6625                    self.client
6626                        .log_message(
6627                            MessageType::INFO,
6628                            format!(
6629                                "didCreateFiles: skip {} (open buffer already has content)",
6630                                uri_str
6631                            ),
6632                        )
6633                        .await;
6634                    continue;
6635                }
6636
6637                // Also skip when the file already has content on disk.
6638                if disk_has_content {
6639                    self.client
6640                        .log_message(
6641                            MessageType::INFO,
6642                            format!(
6643                                "didCreateFiles: skip {} (disk file already has content)",
6644                                uri_str
6645                            ),
6646                        )
6647                        .await;
6648                    continue;
6649                }
6650
6651                let content =
6652                    match file_operations::generate_scaffold(&uri, solc_version.as_deref()) {
6653                        Some(s) => s,
6654                        None => continue,
6655                    };
6656
6657                staged_content.insert(uri_str, content.clone());
6658                created_uris.push(uri.to_string());
6659
6660                apply_edits.entry(uri).or_default().push(TextEdit {
6661                    range: Range {
6662                        start: Position {
6663                            line: 0,
6664                            character: 0,
6665                        },
6666                        end: Position {
6667                            line: 0,
6668                            character: 0,
6669                        },
6670                    },
6671                    new_text: content,
6672                });
6673            }
6674        }
6675
6676        if !apply_edits.is_empty() {
6677            {
6678                let mut pending = self.pending_create_scaffold.write().await;
6679                for uri in &created_uris {
6680                    pending.insert(uri.clone().into());
6681                }
6682            }
6683
6684            let edit = WorkspaceEdit {
6685                changes: Some(apply_edits.clone()),
6686                document_changes: None,
6687                change_annotations: None,
6688            };
6689            self.client
6690                .log_message(
6691                    MessageType::INFO,
6692                    format!(
6693                        "didCreateFiles: scaffolding {} empty file(s) via workspace/applyEdit",
6694                        apply_edits.len()
6695                    ),
6696                )
6697                .await;
6698            let apply_result = self.client.apply_edit(edit).await;
6699            let applied = apply_result.as_ref().is_ok_and(|r| r.applied);
6700
6701            if applied {
6702                let mut tc = self.text_cache.write().await;
6703                for (uri_str, content) in staged_content {
6704                    tc.insert(uri_str.into(), (0, content));
6705                }
6706            } else if let Ok(resp) = &apply_result {
6707                self.client
6708                    .log_message(
6709                        MessageType::WARNING,
6710                        format!(
6711                            "didCreateFiles: applyEdit rejected (no disk fallback): {:?}",
6712                            resp.failure_reason
6713                        ),
6714                    )
6715                    .await;
6716            } else if let Err(e) = &apply_result {
6717                self.client
6718                    .log_message(
6719                        MessageType::WARNING,
6720                        format!("didCreateFiles: applyEdit failed (no disk fallback): {e}"),
6721                    )
6722                    .await;
6723            }
6724        }
6725
6726        // Refresh diagnostics for newly created files that now have in-memory
6727        // content (e.g. scaffold applied via willCreateFiles/didChange). This
6728        // clears stale diagnostics produced from the transient empty didOpen.
6729        for file_create in &params.files {
6730            let Ok(uri) = Url::parse(&file_create.uri) else {
6731                continue;
6732            };
6733            let (version, content) = {
6734                let tc = self.text_cache.read().await;
6735                match tc.get(&uri.to_string()) {
6736                    Some((v, c)) => (*v, c.clone()),
6737                    None => continue,
6738                }
6739            };
6740            if !content.chars().any(|ch| !ch.is_whitespace()) {
6741                continue;
6742            }
6743            self.on_change(TextDocumentItem {
6744                uri,
6745                version,
6746                text: content,
6747                language_id: "solidity".to_string(),
6748            })
6749            .await;
6750        }
6751
6752        // Invalidate lib sub-caches if any created files are under lib/.
6753        {
6754            let affected_paths: Vec<std::path::PathBuf> = params
6755                .files
6756                .iter()
6757                .filter_map(|f| Url::parse(&f.uri).ok())
6758                .filter_map(|u| u.to_file_path().ok())
6759                .collect();
6760            self.invalidate_lib_sub_caches_if_affected(&affected_paths)
6761                .await;
6762        }
6763
6764        // Re-index in the background.  Keep the old project index entry
6765        // alive so cross-file features keep working with slightly-stale
6766        // data until the new build replaces it.
6767        let root_key = self.project_cache_key().await;
6768
6769        let foundry_config = self.foundry_config.read().await.clone();
6770        let ast_cache = self.ast_cache.clone();
6771        let client = self.client.clone();
6772        let path_interner = self.path_interner.clone();
6773        let text_cache_snapshot = self.text_cache.read().await.clone();
6774
6775        tokio::spawn(async move {
6776            let Some(cache_key) = root_key else {
6777                return;
6778            };
6779            match crate::solc::solc_project_index(
6780                &foundry_config,
6781                Some(&client),
6782                Some(&text_cache_snapshot),
6783            )
6784            .await
6785            {
6786                Ok(ast_data) => {
6787                    let cached_build = Arc::new(crate::goto::CachedBuild::new(
6788                        ast_data,
6789                        0,
6790                        Some(&mut *path_interner.write().await),
6791                    ));
6792                    let source_count = cached_build.nodes.len();
6793                    ast_cache
6794                        .write()
6795                        .await
6796                        .insert(cache_key.into(), cached_build);
6797                    client
6798                        .log_message(
6799                            MessageType::INFO,
6800                            format!("didCreateFiles: re-indexed {} source files", source_count),
6801                        )
6802                        .await;
6803                }
6804                Err(e) => {
6805                    client
6806                        .log_message(
6807                            MessageType::WARNING,
6808                            format!("didCreateFiles: re-index failed: {e}"),
6809                        )
6810                        .await;
6811                }
6812            }
6813        });
6814    }
6815
6816    // ── Call hierarchy ─────────────────────────────────────────────────
6817
6818    async fn prepare_call_hierarchy(
6819        &self,
6820        params: CallHierarchyPrepareParams,
6821    ) -> tower_lsp::jsonrpc::Result<Option<Vec<CallHierarchyItem>>> {
6822        self.client
6823            .log_message(
6824                MessageType::INFO,
6825                "got textDocument/prepareCallHierarchy request",
6826            )
6827            .await;
6828
6829        let uri = params.text_document_position_params.text_document.uri;
6830        let position = params.text_document_position_params.position;
6831
6832        let file_path = match uri.to_file_path() {
6833            Ok(path) => path,
6834            Err(_) => {
6835                self.client
6836                    .log_message(MessageType::ERROR, "invalid file uri")
6837                    .await;
6838                return Ok(None);
6839            }
6840        };
6841
6842        let source_bytes = match self.get_source_bytes(&uri, &file_path).await {
6843            Some(bytes) => bytes,
6844            None => return Ok(None),
6845        };
6846
6847        let cached_build = match self.get_or_fetch_build(&uri, &file_path, true).await {
6848            Some(cb) => cb,
6849            None => return Ok(None),
6850        };
6851
6852        let path_str = match file_path.to_str() {
6853            Some(s) => s,
6854            None => return Ok(None),
6855        };
6856        let abs_path = match cached_build.path_to_abs.get(path_str) {
6857            Some(ap) => ap.clone(),
6858            None => {
6859                // Try using the file path directly as the abs path key.
6860                crate::types::AbsPath::new(path_str)
6861            }
6862        };
6863
6864        let byte_position = goto::pos_to_bytes(&source_bytes, position);
6865
6866        // Resolve the callable at the cursor position.
6867        let callable_id = match crate::call_hierarchy::resolve_callable_at_position(
6868            &cached_build,
6869            abs_path.as_str(),
6870            byte_position,
6871        ) {
6872            Some(id) => id,
6873            None => {
6874                self.client
6875                    .log_message(MessageType::INFO, "no callable found at cursor position")
6876                    .await;
6877                return Ok(None);
6878            }
6879        };
6880
6881        // Convert the callable to a CallHierarchyItem.
6882        // Try decl_index first (available in fresh builds), fall back to nodes.
6883        let item = if let Some(decl) = cached_build.decl_index.get(&callable_id) {
6884            crate::call_hierarchy::decl_to_hierarchy_item(
6885                decl,
6886                callable_id,
6887                &cached_build.node_id_to_source_path,
6888                &cached_build.id_to_path_map,
6889                &cached_build.nodes,
6890            )
6891        } else if let Some(info) =
6892            crate::call_hierarchy::find_node_info(&cached_build.nodes, callable_id)
6893        {
6894            crate::call_hierarchy::node_info_to_hierarchy_item(
6895                callable_id,
6896                info,
6897                &cached_build.id_to_path_map,
6898            )
6899        } else {
6900            None
6901        };
6902
6903        match item {
6904            Some(it) => {
6905                self.client
6906                    .log_message(
6907                        MessageType::INFO,
6908                        format!("prepared call hierarchy for: {}", it.name),
6909                    )
6910                    .await;
6911                Ok(Some(vec![it]))
6912            }
6913            None => {
6914                self.client
6915                    .log_message(
6916                        MessageType::INFO,
6917                        "could not build CallHierarchyItem for callable",
6918                    )
6919                    .await;
6920                Ok(None)
6921            }
6922        }
6923    }
6924
6925    async fn incoming_calls(
6926        &self,
6927        params: CallHierarchyIncomingCallsParams,
6928    ) -> tower_lsp::jsonrpc::Result<Option<Vec<CallHierarchyIncomingCall>>> {
6929        self.client
6930            .log_message(MessageType::INFO, "got callHierarchy/incomingCalls request")
6931            .await;
6932
6933        let item = &params.item;
6934
6935        // Extract the node ID from the item's `data` field.
6936        let node_id = match item
6937            .data
6938            .as_ref()
6939            .and_then(|d| d.get("nodeId"))
6940            .and_then(|v| v.as_i64())
6941        {
6942            Some(id) => crate::types::NodeId(id),
6943            None => {
6944                self.client
6945                    .log_message(
6946                        MessageType::ERROR,
6947                        "missing nodeId in CallHierarchyItem data",
6948                    )
6949                    .await;
6950                return Ok(None);
6951            }
6952        };
6953
6954        // Get the file-level build for the item's file.
6955        let file_path = match item.uri.to_file_path() {
6956            Ok(p) => p,
6957            Err(_) => return Ok(None),
6958        };
6959        let file_build = match self.get_or_fetch_build(&item.uri, &file_path, true).await {
6960            Some(cb) => cb,
6961            None => return Ok(None),
6962        };
6963
6964        // Also get the project-wide build (includes test + script files).
6965        let project_build = self.ensure_project_cached_build().await;
6966
6967        // Collect all builds to search for call edges.
6968        let mut builds: Vec<&goto::CachedBuild> = vec![&file_build];
6969        if let Some(ref pb) = project_build {
6970            builds.push(pb);
6971        }
6972        let sub_caches = self.sub_caches.read().await;
6973        for sc in sub_caches.iter() {
6974            builds.push(sc);
6975        }
6976
6977        // Node IDs differ between compilations (single-file vs. project-wide).
6978        // For each build, resolve the target within THAT build's node ID space,
6979        // expand with base_function_implementation, then search only that build.
6980        // This prevents cross-build ID collisions (e.g. ID 41897 meaning
6981        // PoolManager.swap in the project build but something unrelated in a
6982        // sub-cache).
6983        let target_name = &item.name;
6984        let target_sel = &item.selection_range;
6985        let target_abs = item
6986            .uri
6987            .as_ref()
6988            .strip_prefix("file://")
6989            .unwrap_or(item.uri.as_ref());
6990
6991        // Compute the name byte offset once (stable across builds).
6992        // This converts the LSP selection_range start position to a byte
6993        // offset in the source file, which is the cross-build-safe anchor.
6994        let target_name_offset = {
6995            let source_bytes = std::fs::read(target_abs).unwrap_or_default();
6996            goto::pos_to_bytes(&source_bytes, target_sel.start)
6997        };
6998
6999        // Resolve incoming calls per-build.  The caller_id returned by
7000        // `incoming_calls()` belongs to the producing build's node-ID space,
7001        // so we must resolve the caller `CallHierarchyItem` using the SAME
7002        // build's indexes to avoid cross-build node-ID collisions.
7003        let mut resolved_incoming: Vec<(CallHierarchyItem, (u32, u32), Range)> = Vec::new();
7004
7005        for build in &builds {
7006            // Resolve the target within this build's node ID space.
7007            // verify_node_identity() checks file + byte offset + name;
7008            // falls back to byte_to_id() if the numeric ID doesn't match.
7009            let mut build_target_ids = crate::call_hierarchy::resolve_target_in_build(
7010                build,
7011                node_id,
7012                target_abs,
7013                target_name,
7014                target_name_offset,
7015            );
7016
7017            // Expand with interface ↔ implementation IDs within THIS build only.
7018            let snapshot: Vec<crate::types::NodeId> = build_target_ids.clone();
7019            for id in &snapshot {
7020                if let Some(related) = build.base_function_implementation.get(id) {
7021                    for &related_id in related {
7022                        if !build_target_ids.contains(&related_id) {
7023                            build_target_ids.push(related_id);
7024                        }
7025                    }
7026                }
7027            }
7028
7029            if build_target_ids.is_empty() {
7030                continue;
7031            }
7032
7033            let calls = crate::call_hierarchy::incoming_calls(&build.nodes, &build_target_ids);
7034            for (caller_id, call_src) in calls {
7035                let call_range = match crate::call_hierarchy::call_src_to_range(
7036                    &call_src,
7037                    &build.id_to_path_map,
7038                ) {
7039                    Some(r) => r,
7040                    None => continue,
7041                };
7042                // Resolve caller item from THIS build only — node IDs
7043                // are per-compilation and must not leak across builds.
7044                let caller_item = if let Some(decl) = build.decl_index.get(&caller_id) {
7045                    crate::call_hierarchy::decl_to_hierarchy_item(
7046                        decl,
7047                        caller_id,
7048                        &build.node_id_to_source_path,
7049                        &build.id_to_path_map,
7050                        &build.nodes,
7051                    )
7052                } else if let Some(info) =
7053                    crate::call_hierarchy::find_node_info(&build.nodes, caller_id)
7054                {
7055                    crate::call_hierarchy::node_info_to_hierarchy_item(
7056                        caller_id,
7057                        info,
7058                        &build.id_to_path_map,
7059                    )
7060                } else {
7061                    None
7062                };
7063                let Some(caller_item) = caller_item else {
7064                    continue;
7065                };
7066                let pos = (
7067                    caller_item.selection_range.start.line,
7068                    caller_item.selection_range.start.character,
7069                );
7070                resolved_incoming.push((caller_item, pos, call_range));
7071            }
7072        }
7073
7074        if resolved_incoming.is_empty() {
7075            self.client
7076                .log_message(MessageType::INFO, "no incoming calls found")
7077                .await;
7078            return Ok(Some(vec![]));
7079        }
7080
7081        // Group by caller source position to merge duplicates from
7082        // overlapping builds (same function, different node IDs).
7083        let mut grouped: HashMap<(u32, u32), (CallHierarchyItem, Vec<Range>)> = HashMap::new();
7084        for (caller_item, pos, call_range) in resolved_incoming {
7085            let entry = grouped
7086                .entry(pos)
7087                .or_insert_with(|| (caller_item, Vec::new()));
7088            if !entry.1.contains(&call_range) {
7089                entry.1.push(call_range);
7090            }
7091        }
7092
7093        let results: Vec<CallHierarchyIncomingCall> = grouped
7094            .into_values()
7095            .map(|(from, from_ranges)| CallHierarchyIncomingCall { from, from_ranges })
7096            .collect();
7097
7098        self.client
7099            .log_message(
7100                MessageType::INFO,
7101                format!("found {} incoming callers", results.len()),
7102            )
7103            .await;
7104        Ok(Some(results))
7105    }
7106
7107    async fn outgoing_calls(
7108        &self,
7109        params: CallHierarchyOutgoingCallsParams,
7110    ) -> tower_lsp::jsonrpc::Result<Option<Vec<CallHierarchyOutgoingCall>>> {
7111        self.client
7112            .log_message(MessageType::INFO, "got callHierarchy/outgoingCalls request")
7113            .await;
7114
7115        let item = &params.item;
7116
7117        // Extract the node ID from the item's `data` field.
7118        let node_id = match item
7119            .data
7120            .as_ref()
7121            .and_then(|d| d.get("nodeId"))
7122            .and_then(|v| v.as_i64())
7123        {
7124            Some(id) => crate::types::NodeId(id),
7125            None => {
7126                self.client
7127                    .log_message(
7128                        MessageType::ERROR,
7129                        "missing nodeId in CallHierarchyItem data",
7130                    )
7131                    .await;
7132                return Ok(None);
7133            }
7134        };
7135
7136        // Get the file-level build for the item's file.
7137        let file_path = match item.uri.to_file_path() {
7138            Ok(p) => p,
7139            Err(_) => return Ok(None),
7140        };
7141        let file_build = match self.get_or_fetch_build(&item.uri, &file_path, true).await {
7142            Some(cb) => cb,
7143            None => return Ok(None),
7144        };
7145
7146        // Also get the project-wide build (includes test + script files).
7147        let project_build = self.ensure_project_cached_build().await;
7148
7149        // Collect all builds to search for call edges.
7150        let mut builds: Vec<&goto::CachedBuild> = vec![&file_build];
7151        if let Some(ref pb) = project_build {
7152            builds.push(pb);
7153        }
7154        let sub_caches = self.sub_caches.read().await;
7155        for sc in sub_caches.iter() {
7156            builds.push(sc);
7157        }
7158
7159        // Node IDs differ between compilations (single-file vs. project-wide).
7160        // For each build, resolve the caller within THAT build's node ID space,
7161        // then search only that build for outgoing calls.
7162        let target_name = &item.name;
7163        let target_sel = &item.selection_range;
7164        let target_abs = item
7165            .uri
7166            .as_ref()
7167            .strip_prefix("file://")
7168            .unwrap_or(item.uri.as_ref());
7169
7170        // Compute the name byte offset once (stable across builds).
7171        let target_name_offset = {
7172            let source_bytes = std::fs::read(target_abs).unwrap_or_default();
7173            goto::pos_to_bytes(&source_bytes, target_sel.start)
7174        };
7175
7176        // Resolve outgoing calls per-build.  Both the callee_id and the
7177        // call_src come from a specific build's node-ID space, so we must
7178        // resolve the callee `CallHierarchyItem` using the SAME build's
7179        // indexes.  Deferring resolution to a later `builds.iter().find_map()`
7180        // would hit cross-build node-ID collisions (e.g. ID 5344 meaning
7181        // `checkPoolInitialized` in the file build but something unrelated
7182        // in the project build).
7183        let mut resolved_outgoing: Vec<(CallHierarchyItem, (u32, u32), Range)> = Vec::new();
7184
7185        for build in &builds {
7186            // Resolve the caller within this build's node ID space.
7187            // verify_node_identity() checks file + byte offset + name;
7188            // falls back to byte_to_id() if the numeric ID doesn't match.
7189            let build_caller_ids = crate::call_hierarchy::resolve_target_in_build(
7190                build,
7191                node_id,
7192                target_abs,
7193                target_name,
7194                target_name_offset,
7195            );
7196
7197            for &cid in &build_caller_ids {
7198                let calls = crate::call_hierarchy::outgoing_calls(&build.nodes, cid);
7199                for (callee_id, call_src) in calls {
7200                    let call_range = match crate::call_hierarchy::call_src_to_range(
7201                        &call_src,
7202                        &build.id_to_path_map,
7203                    ) {
7204                        Some(r) => r,
7205                        None => continue,
7206                    };
7207                    // Resolve callee item from THIS build only — node IDs
7208                    // are per-compilation and must not leak across builds.
7209                    let callee_item = if let Some(decl) = build.decl_index.get(&callee_id) {
7210                        crate::call_hierarchy::decl_to_hierarchy_item(
7211                            decl,
7212                            callee_id,
7213                            &build.node_id_to_source_path,
7214                            &build.id_to_path_map,
7215                            &build.nodes,
7216                        )
7217                    } else if let Some(info) =
7218                        crate::call_hierarchy::find_node_info(&build.nodes, callee_id)
7219                    {
7220                        crate::call_hierarchy::node_info_to_hierarchy_item(
7221                            callee_id,
7222                            info,
7223                            &build.id_to_path_map,
7224                        )
7225                    } else {
7226                        None
7227                    };
7228                    let Some(callee_item) = callee_item else {
7229                        continue;
7230                    };
7231                    let pos = (
7232                        callee_item.selection_range.start.line,
7233                        callee_item.selection_range.start.character,
7234                    );
7235                    resolved_outgoing.push((callee_item, pos, call_range));
7236                }
7237
7238                // Low-level calls (.call/.staticcall/.delegatecall + Yul opcodes).
7239                let ll_calls = crate::call_hierarchy::outgoing_low_level_calls(build, cid);
7240                for (ll_item, call_range) in ll_calls {
7241                    let pos = (
7242                        ll_item.selection_range.start.line,
7243                        ll_item.selection_range.start.character,
7244                    );
7245                    resolved_outgoing.push((ll_item, pos, call_range));
7246                }
7247            }
7248        }
7249
7250        if resolved_outgoing.is_empty() {
7251            return Ok(Some(vec![]));
7252        }
7253
7254        // Group by callee source position to merge duplicates from
7255        // overlapping builds (same function, different node IDs).
7256        let mut grouped: HashMap<(u32, u32), (CallHierarchyItem, Vec<Range>)> = HashMap::new();
7257        for (callee_item, pos, call_range) in resolved_outgoing {
7258            let entry = grouped
7259                .entry(pos)
7260                .or_insert_with(|| (callee_item, Vec::new()));
7261            if !entry.1.contains(&call_range) {
7262                entry.1.push(call_range);
7263            }
7264        }
7265
7266        let mut results: Vec<CallHierarchyOutgoingCall> = grouped
7267            .into_values()
7268            .map(|(to, from_ranges)| CallHierarchyOutgoingCall { to, from_ranges })
7269            .collect();
7270
7271        // Sort by the earliest call-site position so results follow
7272        // the function body order (first call, second call, ...).
7273        results.sort_by(|a, b| {
7274            let a_first = a.from_ranges.first();
7275            let b_first = b.from_ranges.first();
7276            match (a_first, b_first) {
7277                (Some(a_r), Some(b_r)) => a_r
7278                    .start
7279                    .line
7280                    .cmp(&b_r.start.line)
7281                    .then_with(|| a_r.start.character.cmp(&b_r.start.character)),
7282                (Some(_), None) => std::cmp::Ordering::Less,
7283                (None, Some(_)) => std::cmp::Ordering::Greater,
7284                (None, None) => std::cmp::Ordering::Equal,
7285            }
7286        });
7287
7288        Ok(Some(results))
7289    }
7290}
7291
7292#[cfg(test)]
7293mod tests {
7294    use super::{
7295        start_or_mark_project_cache_sync_pending, stop_project_cache_sync_worker_or_reclaim,
7296        take_project_cache_sync_pending, try_claim_project_cache_dirty,
7297        update_imports_on_delete_enabled,
7298    };
7299    use std::sync::atomic::{AtomicBool, Ordering};
7300
7301    #[test]
7302    fn update_imports_on_delete_enabled_defaults_true() {
7303        let s = crate::config::Settings::default();
7304        assert!(update_imports_on_delete_enabled(&s));
7305    }
7306
7307    #[test]
7308    fn update_imports_on_delete_enabled_respects_false() {
7309        let mut s = crate::config::Settings::default();
7310        s.file_operations.update_imports_on_delete = false;
7311        assert!(!update_imports_on_delete_enabled(&s));
7312    }
7313
7314    #[test]
7315    fn project_cache_sync_burst_only_first_starts_worker() {
7316        let pending = AtomicBool::new(false);
7317        let running = AtomicBool::new(false);
7318
7319        assert!(start_or_mark_project_cache_sync_pending(&pending, &running));
7320        assert!(pending.load(Ordering::Acquire));
7321        assert!(running.load(Ordering::Acquire));
7322
7323        // Subsequent save while running should only mark pending, not spawn.
7324        assert!(!start_or_mark_project_cache_sync_pending(
7325            &pending, &running
7326        ));
7327        assert!(pending.load(Ordering::Acquire));
7328        assert!(running.load(Ordering::Acquire));
7329    }
7330
7331    #[test]
7332    fn project_cache_sync_take_pending_is_one_shot() {
7333        let pending = AtomicBool::new(true);
7334        assert!(take_project_cache_sync_pending(&pending));
7335        assert!(!pending.load(Ordering::Acquire));
7336        assert!(!take_project_cache_sync_pending(&pending));
7337    }
7338
7339    #[test]
7340    fn project_cache_sync_worker_stop_or_reclaim_handles_race() {
7341        let pending = AtomicBool::new(false);
7342        let running = AtomicBool::new(true);
7343
7344        // No new pending work: worker stops.
7345        assert!(!stop_project_cache_sync_worker_or_reclaim(
7346            &pending, &running
7347        ));
7348        assert!(!running.load(Ordering::Acquire));
7349
7350        // Simulate a new save arriving right as worker tries to stop.
7351        pending.store(true, Ordering::Release);
7352        running.store(true, Ordering::Release);
7353        assert!(stop_project_cache_sync_worker_or_reclaim(
7354            &pending, &running
7355        ));
7356        assert!(running.load(Ordering::Acquire));
7357    }
7358
7359    #[test]
7360    fn project_cache_dirty_claim_and_retry_cycle() {
7361        let dirty = AtomicBool::new(true);
7362
7363        assert!(try_claim_project_cache_dirty(&dirty));
7364        assert!(!dirty.load(Ordering::Acquire));
7365
7366        // Second claim without retry mark should fail.
7367        assert!(!try_claim_project_cache_dirty(&dirty));
7368
7369        // Retry path marks dirty again.
7370        dirty.store(true, Ordering::Release);
7371        assert!(try_claim_project_cache_dirty(&dirty));
7372        assert!(!dirty.load(Ordering::Acquire));
7373    }
7374}