Skip to main content

solidity_language_server/
project_cache.rs

1use crate::config::FoundryConfig;
2use crate::config::ProjectIndexCacheMode;
3use crate::goto::{CachedBuild, NodeInfo};
4use crate::types::NodeId;
5use serde::{Deserialize, Serialize};
6use std::collections::{BTreeMap, HashMap};
7use std::fs;
8use std::io::Write;
9use std::path::{Path, PathBuf};
10use std::time::Instant;
11use tiny_keccak::{Hasher, Keccak};
12
13const CACHE_SCHEMA_VERSION_V2: u32 = 2;
14const CACHE_DIR: &str = ".solidity-language-server";
15const CACHE_FILE_V2: &str = "solidity-lsp-schema-v2.json";
16const CACHE_SHARDS_DIR_V2: &str = "reference-index-v2";
17const CACHE_GITIGNORE_FILE: &str = ".gitignore";
18const CACHE_GITIGNORE_CONTENTS: &str = "*\n";
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21struct PersistedNodeEntry {
22    id: u64,
23    info: NodeInfo,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27struct PersistedExternalRef {
28    src: String,
29    decl_id: u64,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33struct PersistedFileShardV2 {
34    abs_path: String,
35    entries: Vec<PersistedNodeEntry>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39struct PersistedReferenceCacheV2 {
40    schema_version: u32,
41    project_root: String,
42    config_fingerprint: String,
43    file_hashes: BTreeMap<String, String>,
44    #[serde(default)]
45    file_hash_history: BTreeMap<String, Vec<String>>,
46    path_to_abs: HashMap<String, String>,
47    id_to_path_map: HashMap<String, String>,
48    external_refs: Vec<PersistedExternalRef>,
49    // relative-path -> shard file name
50    node_shards: BTreeMap<String, String>,
51}
52
53#[derive(Debug, Clone)]
54pub struct CacheLoadReport {
55    pub build: Option<CachedBuild>,
56    pub hit: bool,
57    pub miss_reason: Option<String>,
58    pub file_count_hashed: usize,
59    pub file_count_reused: usize,
60    pub complete: bool,
61    pub duration_ms: u128,
62}
63
64#[derive(Debug, Clone)]
65pub struct CacheSaveReport {
66    pub file_count_hashed: usize,
67    pub duration_ms: u128,
68}
69
70/// Public helper — returns the root of the on-disk cache directory for
71/// `project_root`. Used by the `solidity.clearCache` command handler.
72pub fn cache_dir(root: &Path) -> PathBuf {
73    root.join(CACHE_DIR)
74}
75
76fn cache_file_path_v2(root: &Path) -> PathBuf {
77    root.join(CACHE_DIR).join(CACHE_FILE_V2)
78}
79
80fn cache_shards_dir_v2(root: &Path) -> PathBuf {
81    root.join(CACHE_DIR).join(CACHE_SHARDS_DIR_V2)
82}
83
84fn ensure_cache_dir_layout(root: &Path) -> Result<(PathBuf, PathBuf), String> {
85    let cache_root = root.join(CACHE_DIR);
86    fs::create_dir_all(&cache_root)
87        .map_err(|e| format!("failed to create cache dir {}: {e}", cache_root.display()))?;
88
89    // Ensure cache artifacts are ignored by Git in consumer projects.
90    let gitignore_path = cache_root.join(CACHE_GITIGNORE_FILE);
91    if !gitignore_path.exists() {
92        fs::write(&gitignore_path, CACHE_GITIGNORE_CONTENTS).map_err(|e| {
93            format!(
94                "failed to write cache gitignore {}: {e}",
95                gitignore_path.display()
96            )
97        })?;
98    }
99
100    let shards_dir = cache_shards_dir_v2(root);
101    fs::create_dir_all(&shards_dir)
102        .map_err(|e| format!("failed to create shards dir {}: {e}", shards_dir.display()))?;
103
104    Ok((cache_root, shards_dir))
105}
106
107fn shard_file_name_for_rel_path(rel_path: &str) -> String {
108    format!("{}.json", keccak_hex(rel_path.as_bytes()))
109}
110
111fn write_atomic_json(path: &Path, payload: &[u8]) -> Result<(), String> {
112    let tmp_path = path.with_extension(format!(
113        "{}.tmp",
114        path.extension()
115            .and_then(|s| s.to_str())
116            .unwrap_or_default()
117    ));
118    {
119        let mut file = fs::File::create(&tmp_path)
120            .map_err(|e| format!("create tmp {}: {e}", tmp_path.display()))?;
121        file.write_all(payload)
122            .map_err(|e| format!("write tmp {}: {e}", tmp_path.display()))?;
123        file.flush()
124            .map_err(|e| format!("flush tmp {}: {e}", tmp_path.display()))?;
125        file.sync_all()
126            .map_err(|e| format!("sync tmp {}: {e}", tmp_path.display()))?;
127    }
128    fs::rename(&tmp_path, path).map_err(|e| {
129        format!(
130            "rename tmp {} -> {}: {e}",
131            tmp_path.display(),
132            path.display()
133        )
134    })
135}
136
137fn keccak_hex(bytes: &[u8]) -> String {
138    let mut out = [0u8; 32];
139    let mut hasher = Keccak::v256();
140    hasher.update(bytes);
141    hasher.finalize(&mut out);
142    hex::encode(out)
143}
144
145fn file_hash(path: &Path) -> Option<String> {
146    let bytes = fs::read(path).ok()?;
147    Some(keccak_hex(&bytes))
148}
149
150fn relative_to_root(root: &Path, file: &Path) -> String {
151    file.strip_prefix(root)
152        .unwrap_or(file)
153        .to_string_lossy()
154        .replace('\\', "/")
155}
156
157fn current_file_hashes(
158    config: &FoundryConfig,
159    include_libs: bool,
160) -> Result<BTreeMap<String, String>, String> {
161    let source_files = if include_libs {
162        crate::solc::discover_source_files_with_libs(config)
163    } else {
164        crate::solc::discover_source_files(config)
165    };
166    hash_file_list(config, &source_files)
167}
168
169/// Hash an explicit list of absolute paths (relative to config.root).
170fn hash_file_list(
171    config: &FoundryConfig,
172    source_files: &[PathBuf],
173) -> Result<BTreeMap<String, String>, String> {
174    if source_files.is_empty() {
175        return Ok(BTreeMap::new());
176    }
177    let mut hashes = BTreeMap::new();
178    for path in source_files {
179        let rel = relative_to_root(&config.root, path);
180        let hash = file_hash(path)
181            .ok_or_else(|| format!("failed to hash source file {}", path.display()))?;
182        hashes.insert(rel, hash);
183    }
184    Ok(hashes)
185}
186
187fn config_fingerprint(config: &FoundryConfig) -> String {
188    let payload = serde_json::json!({
189        // Including the server version means any binary upgrade automatically
190        // invalidates the on-disk cache and triggers a clean rebuild.
191        "lsp_version": env!("CARGO_PKG_VERSION"),
192        "solc_version": config.solc_version,
193        "remappings": config.remappings,
194        "evm_version": config.evm_version,
195        "sources_dir": config.sources_dir,
196        "libs": config.libs,
197    });
198    keccak_hex(payload.to_string().as_bytes())
199}
200
201fn push_hash_history(meta: &mut PersistedReferenceCacheV2, rel: &str, hash: &str) {
202    const MAX_HISTORY: usize = 8;
203    let history = meta.file_hash_history.entry(rel.to_string()).or_default();
204    if history.last().is_some_and(|h| h == hash) {
205        return;
206    }
207    history.push(hash.to_string());
208    if history.len() > MAX_HISTORY {
209        let drop_count = history.len() - MAX_HISTORY;
210        history.drain(0..drop_count);
211    }
212}
213
214pub fn save_reference_cache(config: &FoundryConfig, build: &CachedBuild) -> Result<(), String> {
215    save_reference_cache_with_report(config, build, None).map(|_| ())
216}
217
218/// Incrementally upsert v2 cache shards from a partial build (typically a
219/// saved file compile). This is a fast-path: it updates per-file shards and
220/// file hashes for touched files, while preserving existing global metadata.
221///
222/// The authoritative full-project cache is still produced by full reconcile.
223pub fn upsert_reference_cache_v2_with_report(
224    config: &FoundryConfig,
225    build: &CachedBuild,
226) -> Result<CacheSaveReport, String> {
227    let started = Instant::now();
228    if !config.root.is_dir() {
229        return Err(format!("invalid project root: {}", config.root.display()));
230    }
231
232    let (_cache_root, shards_dir) = ensure_cache_dir_layout(&config.root)?;
233
234    let meta_path = cache_file_path_v2(&config.root);
235    let mut meta = if let Ok(bytes) = fs::read(&meta_path) {
236        serde_json::from_slice::<PersistedReferenceCacheV2>(&bytes).unwrap_or(
237            PersistedReferenceCacheV2 {
238                schema_version: CACHE_SCHEMA_VERSION_V2,
239                project_root: config.root.to_string_lossy().to_string(),
240                config_fingerprint: config_fingerprint(config),
241                file_hashes: BTreeMap::new(),
242                file_hash_history: BTreeMap::new(),
243                path_to_abs: HashMap::new(),
244                id_to_path_map: HashMap::new(),
245                external_refs: Vec::new(),
246                node_shards: BTreeMap::new(),
247            },
248        )
249    } else {
250        PersistedReferenceCacheV2 {
251            schema_version: CACHE_SCHEMA_VERSION_V2,
252            project_root: config.root.to_string_lossy().to_string(),
253            config_fingerprint: config_fingerprint(config),
254            file_hashes: BTreeMap::new(),
255            file_hash_history: BTreeMap::new(),
256            path_to_abs: HashMap::new(),
257            id_to_path_map: HashMap::new(),
258            external_refs: Vec::new(),
259            node_shards: BTreeMap::new(),
260        }
261    };
262
263    // Reset metadata when root/fingerprint changed.
264    if meta.project_root != config.root.to_string_lossy()
265        || meta.config_fingerprint != config_fingerprint(config)
266    {
267        meta = PersistedReferenceCacheV2 {
268            schema_version: CACHE_SCHEMA_VERSION_V2,
269            project_root: config.root.to_string_lossy().to_string(),
270            config_fingerprint: config_fingerprint(config),
271            file_hashes: BTreeMap::new(),
272            file_hash_history: BTreeMap::new(),
273            path_to_abs: HashMap::new(),
274            id_to_path_map: HashMap::new(),
275            external_refs: Vec::new(),
276            node_shards: BTreeMap::new(),
277        };
278    }
279
280    // Collect the set of abs_paths being upserted so we can purge stale IDs.
281    let upsert_abs: std::collections::HashSet<&str> =
282        build.nodes.keys().map(|s| s.as_str()).collect();
283
284    // Remove stale NodeIds for files being overwritten: old IDs are invalid
285    // after recompile (solc assigns new integers), so keep id_to_path_map and
286    // external_refs clean to avoid phantom cross-file reference hits.
287    meta.id_to_path_map
288        .retain(|_id, path| !upsert_abs.contains(path.as_str()));
289    meta.external_refs
290        .retain(|r| !upsert_abs.contains(r.src.as_str()));
291
292    let mut touched = 0usize;
293    for (abs_path, file_nodes) in &build.nodes {
294        let abs = Path::new(abs_path);
295        let rel = relative_to_root(&config.root, abs);
296        let shard_name = shard_file_name_for_rel_path(&rel);
297        let shard_path = shards_dir.join(&shard_name);
298
299        let mut entries = Vec::with_capacity(file_nodes.len());
300        for (id, info) in file_nodes {
301            entries.push(PersistedNodeEntry {
302                id: id.0,
303                info: info.clone(),
304            });
305        }
306        let shard = PersistedFileShardV2 {
307            abs_path: abs_path.clone(),
308            entries,
309        };
310        let shard_payload =
311            serde_json::to_vec(&shard).map_err(|e| format!("serialize shard {}: {e}", rel))?;
312        write_atomic_json(&shard_path, &shard_payload)?;
313
314        if let Some(hash) = file_hash(abs) {
315            push_hash_history(&mut meta, &rel, &hash);
316            meta.file_hashes.insert(rel.clone(), hash);
317            meta.node_shards.insert(rel, shard_name);
318            touched += 1;
319        }
320
321        meta.path_to_abs.insert(abs_path.clone(), abs_path.clone());
322    }
323
324    for (k, v) in &build.id_to_path_map {
325        meta.id_to_path_map.insert(k.clone(), v.clone());
326    }
327
328    // Also prune external_refs whose target decl_id no longer exists in
329    // id_to_path_map (i.e. old IDs from the recompiled files that were just
330    // removed above).  This prevents stale cross-file reference hits.
331    let live_ids: std::collections::HashSet<u64> = meta
332        .id_to_path_map
333        .keys()
334        .filter_map(|k| k.parse().ok())
335        .collect();
336    meta.external_refs.retain(|r| live_ids.contains(&r.decl_id));
337
338    // Append replacement external_refs from the partial build so that
339    // reference resolution still works for touched files without waiting
340    // for a full cache save.
341    for (src, decl_id) in &build.external_refs {
342        meta.external_refs.push(PersistedExternalRef {
343            src: src.clone(),
344            decl_id: decl_id.0,
345        });
346    }
347
348    let payload_v2 = serde_json::to_vec(&meta).map_err(|e| format!("serialize v2 cache: {e}"))?;
349    write_atomic_json(&meta_path, &payload_v2)?;
350
351    Ok(CacheSaveReport {
352        file_count_hashed: touched,
353        duration_ms: started.elapsed().as_millis(),
354    })
355}
356
357pub fn save_reference_cache_with_report(
358    config: &FoundryConfig,
359    build: &CachedBuild,
360    source_files: Option<&[PathBuf]>,
361) -> Result<CacheSaveReport, String> {
362    let started = Instant::now();
363    if !config.root.is_dir() {
364        return Err(format!("invalid project root: {}", config.root.display()));
365    }
366
367    // When an explicit file list is given, hash only those.
368    // Otherwise derive the list from the build's node keys (the files that
369    // were actually compiled) — this avoids walking unrelated lib files.
370    let file_hashes = if let Some(files) = source_files {
371        hash_file_list(config, files)?
372    } else {
373        let build_paths: Vec<PathBuf> = build.nodes.keys().map(PathBuf::from).collect();
374        if build_paths.is_empty() {
375            current_file_hashes(config, true)?
376        } else {
377            hash_file_list(config, &build_paths)?
378        }
379    };
380    let file_count_hashed = file_hashes.len();
381    let external_refs = build
382        .external_refs
383        .iter()
384        .map(|(src, id)| PersistedExternalRef {
385            src: src.clone(),
386            decl_id: id.0,
387        })
388        .collect::<Vec<_>>();
389
390    let (_cache_root, shards_dir) = ensure_cache_dir_layout(&config.root)?;
391
392    let mut node_shards: BTreeMap<String, String> = BTreeMap::new();
393    let mut live_shards = std::collections::HashSet::new();
394    for (abs_path, file_nodes) in &build.nodes {
395        let abs = Path::new(abs_path);
396        let rel = relative_to_root(&config.root, abs);
397        let shard_name = shard_file_name_for_rel_path(&rel);
398        let shard_path = shards_dir.join(&shard_name);
399
400        let mut entries = Vec::with_capacity(file_nodes.len());
401        for (id, info) in file_nodes {
402            entries.push(PersistedNodeEntry {
403                id: id.0,
404                info: info.clone(),
405            });
406        }
407        let shard = PersistedFileShardV2 {
408            abs_path: abs_path.clone(),
409            entries,
410        };
411        let shard_payload =
412            serde_json::to_vec(&shard).map_err(|e| format!("serialize shard {}: {e}", rel))?;
413        write_atomic_json(&shard_path, &shard_payload)?;
414        node_shards.insert(rel, shard_name.clone());
415        live_shards.insert(shard_name);
416    }
417
418    // Best-effort cleanup of stale shard files.
419    if let Ok(dir) = fs::read_dir(&shards_dir) {
420        for entry in dir.flatten() {
421            let file_name = entry.file_name().to_string_lossy().to_string();
422            if !live_shards.contains(&file_name) {
423                let _ = fs::remove_file(entry.path());
424            }
425        }
426    }
427
428    let persisted_v2 = PersistedReferenceCacheV2 {
429        schema_version: CACHE_SCHEMA_VERSION_V2,
430        project_root: config.root.to_string_lossy().to_string(),
431        config_fingerprint: config_fingerprint(config),
432        file_hashes: file_hashes.clone(),
433        file_hash_history: {
434            let mut h = BTreeMap::new();
435            for (rel, hash) in &file_hashes {
436                h.insert(rel.clone(), vec![hash.clone()]);
437            }
438            h
439        },
440        path_to_abs: build.path_to_abs.clone(),
441        external_refs: external_refs.clone(),
442        id_to_path_map: build.id_to_path_map.clone(),
443        node_shards,
444    };
445    let payload_v2 =
446        serde_json::to_vec(&persisted_v2).map_err(|e| format!("serialize v2 cache: {e}"))?;
447    write_atomic_json(&cache_file_path_v2(&config.root), &payload_v2)?;
448
449    Ok(CacheSaveReport {
450        file_count_hashed,
451        duration_ms: started.elapsed().as_millis(),
452    })
453}
454
455pub fn load_reference_cache(config: &FoundryConfig) -> Option<CachedBuild> {
456    load_reference_cache_with_report(config, ProjectIndexCacheMode::Auto, false).build
457}
458
459/// Return absolute paths of source files whose current hash differs from v2
460/// cache metadata (including newly-added files missing from metadata).
461pub fn changed_files_since_v2_cache(
462    config: &FoundryConfig,
463    _include_libs: bool,
464) -> Result<Vec<PathBuf>, String> {
465    if !config.root.is_dir() {
466        return Err(format!("invalid project root: {}", config.root.display()));
467    }
468
469    let cache_path_v2 = cache_file_path_v2(&config.root);
470    let bytes = fs::read(&cache_path_v2).map_err(|e| format!("cache file read failed: {e}"))?;
471    let persisted: PersistedReferenceCacheV2 =
472        serde_json::from_slice(&bytes).map_err(|e| format!("cache decode failed: {e}"))?;
473
474    if persisted.schema_version != CACHE_SCHEMA_VERSION_V2 {
475        return Err(format!(
476            "schema mismatch: cache={}, expected={}",
477            persisted.schema_version, CACHE_SCHEMA_VERSION_V2
478        ));
479    }
480    if persisted.project_root != config.root.to_string_lossy() {
481        return Err("project root mismatch".to_string());
482    }
483    if persisted.config_fingerprint != config_fingerprint(config) {
484        return Err("config fingerprint mismatch".to_string());
485    }
486
487    // Hash only the files listed in the saved cache (the compiled closure),
488    // not every file discovered by walking lib dirs.
489    let saved_paths: Vec<PathBuf> = persisted
490        .file_hashes
491        .keys()
492        .map(|rel| config.root.join(rel))
493        .collect();
494    let current_hashes = hash_file_list(config, &saved_paths)?;
495    let mut changed = Vec::new();
496    for (rel, current_hash) in current_hashes {
497        match persisted.file_hashes.get(&rel) {
498            Some(prev) if prev == &current_hash => {}
499            _ => changed.push(config.root.join(rel)),
500        }
501    }
502    Ok(changed)
503}
504
505pub fn load_reference_cache_with_report(
506    config: &FoundryConfig,
507    cache_mode: ProjectIndexCacheMode,
508    _include_libs: bool,
509) -> CacheLoadReport {
510    let started = Instant::now();
511    let miss = |reason: String, file_count_hashed: usize, duration_ms: u128| CacheLoadReport {
512        build: None,
513        hit: false,
514        miss_reason: Some(reason),
515        file_count_hashed,
516        file_count_reused: 0,
517        complete: false,
518        duration_ms,
519    };
520
521    if !config.root.is_dir() {
522        return miss(
523            format!("invalid project root: {}", config.root.display()),
524            0,
525            started.elapsed().as_millis(),
526        );
527    }
528
529    let should_try_v2 = matches!(
530        cache_mode,
531        ProjectIndexCacheMode::Auto | ProjectIndexCacheMode::V2
532    );
533
534    // Try v2 first (partial warm-start capable).
535    let cache_path_v2 = cache_file_path_v2(&config.root);
536    if should_try_v2
537        && let Ok(bytes) = fs::read(&cache_path_v2)
538        && let Ok(persisted) = serde_json::from_slice::<PersistedReferenceCacheV2>(&bytes)
539    {
540        if persisted.schema_version != CACHE_SCHEMA_VERSION_V2 {
541            return miss(
542                format!(
543                    "schema mismatch: cache={}, expected={}",
544                    persisted.schema_version, CACHE_SCHEMA_VERSION_V2
545                ),
546                0,
547                started.elapsed().as_millis(),
548            );
549        }
550        if persisted.project_root != config.root.to_string_lossy() {
551            return miss(
552                "project root mismatch".to_string(),
553                0,
554                started.elapsed().as_millis(),
555            );
556        }
557        if persisted.config_fingerprint != config_fingerprint(config) {
558            return miss(
559                "config fingerprint mismatch".to_string(),
560                0,
561                started.elapsed().as_millis(),
562            );
563        }
564
565        // Hash only the files that were saved — no rediscovery needed.
566        // This means we compare exactly the closure that was compiled last time.
567        let saved_paths: Vec<PathBuf> = persisted
568            .file_hashes
569            .keys()
570            .map(|rel| config.root.join(rel))
571            .collect();
572        let current_hashes = match hash_file_list(config, &saved_paths) {
573            Ok(h) => h,
574            Err(e) => return miss(e, 0, started.elapsed().as_millis()),
575        };
576        let file_count_hashed = current_hashes.len();
577
578        let shards_dir = cache_shards_dir_v2(&config.root);
579        let mut nodes: HashMap<String, HashMap<NodeId, NodeInfo>> = HashMap::new();
580        let mut file_count_reused = 0usize;
581        let mut reused_decl_ids = std::collections::HashSet::new();
582
583        for (rel_path, current_hash) in &current_hashes {
584            let Some(cached_hash) = persisted.file_hashes.get(rel_path) else {
585                continue;
586            };
587            if cached_hash != current_hash {
588                continue;
589            }
590            let Some(shard_name) = persisted.node_shards.get(rel_path) else {
591                continue;
592            };
593            let shard_path = shards_dir.join(shard_name);
594            let shard_bytes = match fs::read(&shard_path) {
595                Ok(v) => v,
596                Err(_) => continue,
597            };
598            let shard: PersistedFileShardV2 = match serde_json::from_slice(&shard_bytes) {
599                Ok(v) => v,
600                Err(_) => continue,
601            };
602            let mut file_nodes = HashMap::with_capacity(shard.entries.len());
603            for entry in shard.entries {
604                reused_decl_ids.insert(entry.id);
605                file_nodes.insert(NodeId(entry.id), entry.info);
606            }
607            nodes.insert(shard.abs_path, file_nodes);
608            file_count_reused += 1;
609        }
610
611        if file_count_reused == 0 {
612            return miss(
613                "v2 cache: no reusable files".to_string(),
614                file_count_hashed,
615                started.elapsed().as_millis(),
616            );
617        }
618
619        let mut external_refs = HashMap::new();
620        for item in persisted.external_refs {
621            if reused_decl_ids.contains(&item.decl_id) {
622                external_refs.insert(item.src, NodeId(item.decl_id));
623            }
624        }
625
626        // Complete = every saved file was reused with a matching hash.
627        let complete =
628            file_count_reused == file_count_hashed && current_hashes == persisted.file_hashes;
629
630        return CacheLoadReport {
631            build: Some(CachedBuild::from_reference_index(
632                nodes,
633                persisted.path_to_abs,
634                external_refs,
635                persisted.id_to_path_map,
636                0,
637            )),
638            hit: true,
639            miss_reason: if complete {
640                None
641            } else {
642                Some("v2 cache partial reuse".to_string())
643            },
644            file_count_hashed,
645            file_count_reused,
646            complete,
647            duration_ms: started.elapsed().as_millis(),
648        };
649    }
650
651    miss(
652        "cache mode v2: no usable v2 cache".to_string(),
653        0,
654        started.elapsed().as_millis(),
655    )
656}