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