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 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 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
163fn 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
212pub 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 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 let upsert_abs: std::collections::HashSet<&str> =
276 build.nodes.keys().map(|s| s.as_str()).collect();
277
278 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 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 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 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 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
453pub 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 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 == ¤t_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 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 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 ¤t_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 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}