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