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