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
70pub 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 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
169fn 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 "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
218pub 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 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 let upsert_abs: std::collections::HashSet<&str> =
282 build.nodes.keys().map(|s| s.as_str()).collect();
283
284 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 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 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 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 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
459pub 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 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 == ¤t_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 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 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 ¤t_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 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}