1use crate::config::FoundryConfig;
8use crate::links;
9use crate::runner::RunnerError;
10use serde_json::{Map, Value, json};
11use std::collections::{HashMap, HashSet};
12use std::path::{Path, PathBuf};
13use std::sync::{Mutex, OnceLock};
14use tokio::process::Command;
15use tower_lsp::lsp_types::Url;
16
17static INSTALLED_VERSIONS: OnceLock<Mutex<Vec<SemVer>>> = OnceLock::new();
20
21fn get_installed_versions() -> Vec<SemVer> {
22 let mutex = INSTALLED_VERSIONS.get_or_init(|| Mutex::new(scan_installed_versions()));
23 mutex.lock().unwrap().clone()
24}
25
26fn invalidate_installed_versions() {
27 if let Some(mutex) = INSTALLED_VERSIONS.get() {
28 *mutex.lock().unwrap() = scan_installed_versions();
29 }
30}
31
32fn semver_to_local(v: &semver::Version) -> SemVer {
34 SemVer {
35 major: v.major as u32,
36 minor: v.minor as u32,
37 patch: v.patch as u32,
38 }
39}
40
41pub async fn resolve_solc_binary(
54 config: &FoundryConfig,
55 constraint: Option<&PragmaConstraint>,
56 client: Option<&tower_lsp::Client>,
57) -> PathBuf {
58 if let Some(constraint) = constraint {
60 if !matches!(constraint, PragmaConstraint::Exact(_))
66 && let Some(ref config_ver) = config.solc_version
67 && let Some(parsed) = SemVer::parse(config_ver)
68 && version_satisfies(&parsed, constraint)
69 && let Some(path) = find_solc_binary(config_ver)
70 {
71 if let Some(c) = client {
72 c.log_message(
73 tower_lsp::lsp_types::MessageType::INFO,
74 format!("using solc {config_ver} (pragma {constraint})"),
75 )
76 .await;
77 }
78 return path;
79 }
80
81 let installed = get_installed_versions();
82 if let Some(version) = find_matching_version(constraint, &installed)
83 && let Some(path) = find_solc_binary(&version.to_string())
84 {
85 if let Some(c) = client {
86 c.log_message(
87 tower_lsp::lsp_types::MessageType::INFO,
88 format!("using solc {version}"),
89 )
90 .await;
91 }
92 return path;
93 }
94
95 let install_version = version_to_install(constraint);
97 if let Some(ref ver_str) = install_version {
98 if let Some(c) = client {
99 c.show_message(
100 tower_lsp::lsp_types::MessageType::INFO,
101 format!("Installing solc {ver_str}..."),
102 )
103 .await;
104 }
105
106 if svm_install(ver_str).await {
107 invalidate_installed_versions();
109
110 if let Some(c) = client {
111 c.show_message(
112 tower_lsp::lsp_types::MessageType::INFO,
113 format!("Installed solc {ver_str}"),
114 )
115 .await;
116 }
117 if let Some(path) = find_solc_binary(ver_str) {
118 return path;
119 }
120 } else if let Some(c) = client {
121 c.show_message(
122 tower_lsp::lsp_types::MessageType::WARNING,
123 format!(
124 "Failed to install solc {ver_str}. \
125 Install it manually: svm install {ver_str}"
126 ),
127 )
128 .await;
129 }
130 }
131 }
132
133 if let Some(ref version) = config.solc_version
135 && let Some(path) = find_solc_binary(version)
136 {
137 if let Some(c) = client {
138 c.log_message(
139 tower_lsp::lsp_types::MessageType::INFO,
140 format!(
141 "solc: no pragma, using foundry.toml version {version} → {}",
142 path.display()
143 ),
144 )
145 .await;
146 }
147 return path;
148 }
149
150 if let Some(c) = client {
152 c.log_message(
153 tower_lsp::lsp_types::MessageType::INFO,
154 "solc: no pragma match, falling back to system solc",
155 )
156 .await;
157 }
158 PathBuf::from("solc")
159}
160
161fn version_to_install(constraint: &PragmaConstraint) -> Option<String> {
168 match constraint {
169 PragmaConstraint::Exact(v) => Some(v.to_string()),
170 PragmaConstraint::Caret(v) => Some(v.to_string()),
171 PragmaConstraint::Gte(v) => Some(v.to_string()),
172 PragmaConstraint::Range(lower, _) => Some(lower.to_string()),
173 }
174}
175
176async fn svm_install(version: &str) -> bool {
180 let ver = match semver::Version::parse(version) {
181 Ok(v) => v,
182 Err(_) => return false,
183 };
184 svm::install(&ver).await.is_ok()
185}
186
187fn find_solc_binary(version: &str) -> Option<PathBuf> {
189 let path = svm::version_binary(version);
190 if path.is_file() {
191 return Some(path);
192 }
193 None
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
200pub struct SemVer {
201 pub major: u32,
202 pub minor: u32,
203 pub patch: u32,
204}
205
206impl SemVer {
207 fn parse(s: &str) -> Option<SemVer> {
208 let parts: Vec<&str> = s.split('.').collect();
209 if parts.len() != 3 {
210 return None;
211 }
212 Some(SemVer {
213 major: parts[0].parse().ok()?,
214 minor: parts[1].parse().ok()?,
215 patch: parts[2].parse().ok()?,
216 })
217 }
218}
219
220impl std::fmt::Display for SemVer {
221 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
223 }
224}
225
226#[derive(Debug, Clone, PartialEq)]
228pub enum PragmaConstraint {
229 Exact(SemVer),
231 Caret(SemVer),
234 Gte(SemVer),
236 Range(SemVer, SemVer),
238}
239
240impl std::fmt::Display for PragmaConstraint {
241 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242 match self {
243 PragmaConstraint::Exact(v) => write!(f, "={v}"),
244 PragmaConstraint::Caret(v) => write!(f, "^{v}"),
245 PragmaConstraint::Gte(v) => write!(f, ">={v}"),
246 PragmaConstraint::Range(lo, hi) => write!(f, ">={lo} <{hi}"),
247 }
248 }
249}
250
251fn resolve_import_to_abs(
255 project_root: &Path,
256 importer_abs: &Path,
257 import_path: &str,
258 remappings: &[String],
259) -> Option<PathBuf> {
260 if import_path.starts_with("./") || import_path.starts_with("../") {
261 let base = importer_abs.parent()?;
262 return Some(lexical_normalize(&base.join(import_path)));
263 }
264
265 for remap in remappings {
266 let mut it = remap.splitn(2, '=');
267 let prefix = it.next().unwrap_or_default();
268 let target = it.next().unwrap_or_default();
269 if prefix.is_empty() || target.is_empty() {
270 continue;
271 }
272 if import_path.starts_with(prefix) {
273 let suffix = import_path.strip_prefix(prefix).unwrap_or_default();
274 return Some(lexical_normalize(
275 &project_root.join(format!("{target}{suffix}")),
276 ));
277 }
278 }
279
280 Some(lexical_normalize(&project_root.join(import_path)))
281}
282
283fn lexical_normalize(path: &Path) -> PathBuf {
286 let mut out = PathBuf::new();
287 for comp in path.components() {
288 match comp {
289 std::path::Component::CurDir => {}
290 std::path::Component::ParentDir => {
291 out.pop();
292 }
293 _ => out.push(comp.as_os_str()),
294 }
295 }
296 out
297}
298
299fn collect_import_pragmas(
305 file_path: &Path,
306 project_root: &Path,
307 remappings: &[String],
308) -> Vec<PragmaConstraint> {
309 let mut pragmas = Vec::new();
310 let mut visited = HashSet::new();
311 collect_import_pragmas_recursive(
312 file_path,
313 project_root,
314 remappings,
315 &mut pragmas,
316 &mut visited,
317 );
318 pragmas
319}
320
321fn collect_import_pragmas_recursive(
322 file_path: &Path,
323 project_root: &Path,
324 remappings: &[String],
325 pragmas: &mut Vec<PragmaConstraint>,
326 visited: &mut HashSet<PathBuf>,
327) {
328 if !visited.insert(file_path.to_path_buf()) {
329 return;
330 }
331 let source = match std::fs::read_to_string(file_path) {
332 Ok(s) => s,
333 Err(_) => return,
334 };
335 if let Some(pragma) = parse_pragma(&source) {
336 pragmas.push(pragma);
337 }
338 for imp in links::ts_find_imports(source.as_bytes()) {
339 if let Some(abs) = resolve_import_to_abs(project_root, file_path, &imp.path, remappings) {
340 collect_import_pragmas_recursive(&abs, project_root, remappings, pragmas, visited);
341 }
342 }
343}
344
345fn tightest_constraint(pragmas: &[PragmaConstraint]) -> Option<PragmaConstraint> {
355 if pragmas.is_empty() {
356 return None;
357 }
358
359 for p in pragmas {
361 if matches!(p, PragmaConstraint::Exact(_)) {
362 return Some(p.clone());
363 }
364 }
365
366 let mut lower = SemVer {
368 major: 0,
369 minor: 0,
370 patch: 0,
371 };
372 let mut upper: Option<SemVer> = None;
373
374 for p in pragmas {
375 let (lo, hi) = constraint_to_range(p);
376 if lo > lower {
377 lower = lo;
378 }
379 if let Some(hi) = hi {
380 upper = Some(match upper {
381 Some(cur) if hi < cur => hi,
382 Some(cur) => cur,
383 None => hi,
384 });
385 }
386 }
387
388 match upper {
389 Some(hi) if lower >= hi => None, Some(hi) => Some(PragmaConstraint::Range(lower, hi)),
391 None => Some(PragmaConstraint::Gte(lower)),
392 }
393}
394
395fn constraint_to_range(constraint: &PragmaConstraint) -> (SemVer, Option<SemVer>) {
398 match constraint {
399 PragmaConstraint::Exact(v) => (
400 v.clone(),
401 Some(SemVer {
402 major: v.major,
403 minor: v.minor,
404 patch: v.patch + 1,
405 }),
406 ),
407 PragmaConstraint::Caret(v) => (
408 v.clone(),
409 Some(SemVer {
410 major: v.major,
411 minor: v.minor + 1,
412 patch: 0,
413 }),
414 ),
415 PragmaConstraint::Gte(v) => (v.clone(), None),
416 PragmaConstraint::Range(lo, hi) => (lo.clone(), Some(hi.clone())),
417 }
418}
419
420pub fn parse_pragma(source: &str) -> Option<PragmaConstraint> {
428 let pragma_line = source
430 .lines()
431 .take(20)
432 .find(|line| line.trim_start().starts_with("pragma solidity"))?;
433
434 let after_keyword = pragma_line
436 .trim_start()
437 .strip_prefix("pragma solidity")?
438 .trim();
439 let constraint_str = after_keyword
440 .strip_suffix(';')
441 .unwrap_or(after_keyword)
442 .trim();
443
444 if constraint_str.is_empty() {
445 return None;
446 }
447
448 if let Some(rest) = constraint_str.strip_prefix(">=") {
450 let rest = rest.trim();
451 if let Some(space_idx) = rest.find(|c: char| c.is_whitespace() || c == '<') {
452 let lower_str = rest[..space_idx].trim();
453 let upper_part = rest[space_idx..].trim();
454 if let Some(upper_str) = upper_part.strip_prefix('<') {
455 let upper_str = upper_str.trim();
456 if let (Some(lower), Some(upper)) =
457 (SemVer::parse(lower_str), SemVer::parse(upper_str))
458 {
459 return Some(PragmaConstraint::Range(lower, upper));
460 }
461 }
462 }
463 if let Some(ver) = SemVer::parse(rest) {
465 return Some(PragmaConstraint::Gte(ver));
466 }
467 }
468
469 if let Some(rest) = constraint_str.strip_prefix('^')
471 && let Some(ver) = SemVer::parse(rest.trim())
472 {
473 return Some(PragmaConstraint::Caret(ver));
474 }
475
476 if let Some(ver) = SemVer::parse(constraint_str) {
478 return Some(PragmaConstraint::Exact(ver));
479 }
480
481 None
482}
483
484pub fn list_installed_versions() -> Vec<SemVer> {
486 get_installed_versions()
487}
488
489fn scan_installed_versions() -> Vec<SemVer> {
493 svm::installed_versions()
494 .unwrap_or_default()
495 .iter()
496 .map(semver_to_local)
497 .collect()
498}
499
500pub fn find_matching_version(
505 constraint: &PragmaConstraint,
506 installed: &[SemVer],
507) -> Option<SemVer> {
508 let candidates: Vec<&SemVer> = installed
509 .iter()
510 .filter(|v| version_satisfies(v, constraint))
511 .collect();
512
513 candidates.last().cloned().cloned()
515}
516
517pub fn version_satisfies(version: &SemVer, constraint: &PragmaConstraint) -> bool {
519 match constraint {
520 PragmaConstraint::Exact(v) => version == v,
521 PragmaConstraint::Caret(v) => {
522 version.major == v.major && version >= v && version.minor < v.minor + 1
525 }
526 PragmaConstraint::Gte(v) => version >= v,
527 PragmaConstraint::Range(lower, upper) => version >= lower && version < upper,
528 }
529}
530
531pub async fn resolve_remappings(config: &FoundryConfig) -> Vec<String> {
535 let output = Command::new("forge")
538 .arg("remappings")
539 .current_dir(&config.root)
540 .env("FOUNDRY_DISABLE_NIGHTLY_WARNING", "1")
541 .output()
542 .await;
543
544 if let Ok(output) = output
545 && output.status.success()
546 {
547 let stdout = String::from_utf8_lossy(&output.stdout);
548 let remappings: Vec<String> = stdout
549 .lines()
550 .filter(|l| !l.trim().is_empty())
551 .map(|l| l.to_string())
552 .collect();
553 if !remappings.is_empty() {
554 return remappings;
555 }
556 }
557
558 if !config.remappings.is_empty() {
560 return config.remappings.clone();
561 }
562
563 let remappings_txt = config.root.join("remappings.txt");
565 if let Ok(content) = std::fs::read_to_string(&remappings_txt) {
566 return content
567 .lines()
568 .filter(|l| !l.trim().is_empty())
569 .map(|l| l.to_string())
570 .collect();
571 }
572
573 Vec::new()
574}
575
576pub fn build_standard_json_input(
597 file_path: &str,
598 remappings: &[String],
599 config: &FoundryConfig,
600 source_content: Option<&str>,
601) -> Value {
602 let contract_outputs = vec![
603 "devdoc",
604 "userdoc",
605 "evm.methodIdentifiers",
606 "storageLayout",
607 "transientStorageLayout",
608 ];
609
610 let mut settings = json!({
611 "remappings": remappings,
612 "outputSelection": {
613 "*": {
614 "*": contract_outputs,
615 "": ["ast"]
616 }
617 }
618 });
619
620 if config.via_ir {
621 settings["viaIR"] = json!(true);
622 }
623
624 if let Some(ref evm_version) = config.evm_version {
626 settings["evmVersion"] = json!(evm_version);
627 }
628
629 let source_value = match source_content {
630 Some(content) => json!({ "content": content }),
631 None => json!({ "urls": [file_path] }),
632 };
633
634 let mut sources = serde_json::Map::new();
636 sources.insert(file_path.to_string(), source_value);
637
638 json!({
639 "language": "Solidity",
640 "sources": sources,
641 "settings": settings
642 })
643}
644
645pub async fn run_solc(
647 solc_binary: &Path,
648 input: &Value,
649 project_root: &Path,
650) -> Result<Value, RunnerError> {
651 let _ = crate::project_cache::save_last_solc_input(project_root, input);
652 let input_str = serde_json::to_string(input)?;
653
654 let mut child = Command::new(solc_binary)
655 .arg("--standard-json")
656 .current_dir(project_root)
657 .stdin(std::process::Stdio::piped())
658 .stdout(std::process::Stdio::piped())
659 .stderr(std::process::Stdio::piped())
660 .spawn()?;
661
662 if let Some(mut stdin) = child.stdin.take() {
664 use tokio::io::AsyncWriteExt;
665 stdin
666 .write_all(input_str.as_bytes())
667 .await
668 .map_err(RunnerError::CommandError)?;
669 }
671
672 let output = child
673 .wait_with_output()
674 .await
675 .map_err(RunnerError::CommandError)?;
676
677 let stdout = String::from_utf8_lossy(&output.stdout);
679 if stdout.trim().is_empty() {
680 let stderr = String::from_utf8_lossy(&output.stderr);
681 return Err(RunnerError::CommandError(std::io::Error::other(format!(
682 "solc produced no output, stderr: {stderr}"
683 ))));
684 }
685
686 let parsed: Value = serde_json::from_str(&stdout)?;
687 Ok(parsed)
688}
689
690pub fn normalize_solc_output(mut solc_output: Value, project_root: Option<&Path>) -> Value {
711 fn resolve_import_absolute_paths(node: &mut Value, resolve: &dyn Fn(&str) -> String) {
713 let is_import = node.get("nodeType").and_then(|v| v.as_str()) == Some("ImportDirective");
714
715 if is_import {
716 if let Some(abs_path) = node.get("absolutePath").and_then(|v| v.as_str()) {
717 let resolved = resolve(abs_path);
718 node.as_object_mut()
719 .unwrap()
720 .insert("absolutePath".to_string(), json!(resolved));
721 }
722 }
723
724 if let Some(nodes) = node.get_mut("nodes").and_then(|v| v.as_array_mut()) {
726 for child in nodes {
727 resolve_import_absolute_paths(child, resolve);
728 }
729 }
730 }
731 let mut result = Map::new();
732
733 let errors = solc_output
735 .get_mut("errors")
736 .map(Value::take)
737 .unwrap_or_else(|| json!([]));
738 result.insert("errors".to_string(), errors);
739
740 let resolve = |p: &str| -> String {
743 if let Some(root) = project_root {
744 let path = Path::new(p);
745 if path.is_relative() {
746 return root.join(path).to_string_lossy().into_owned();
747 }
748 }
749 p.to_string()
750 };
751
752 let mut source_id_to_path = Map::new();
755 let mut resolved_sources = Map::new();
756
757 if let Some(sources) = solc_output
758 .get_mut("sources")
759 .and_then(|s| s.as_object_mut())
760 {
761 let keys: Vec<String> = sources.keys().cloned().collect();
763 for key in keys {
764 if let Some(mut source_data) = sources.remove(&key) {
765 let abs_key = resolve(&key);
766
767 if let Some(ast) = source_data.get_mut("ast") {
771 if let Some(abs_path) = ast.get("absolutePath").and_then(|v| v.as_str()) {
772 let resolved = resolve(abs_path);
773 ast.as_object_mut()
774 .unwrap()
775 .insert("absolutePath".to_string(), json!(resolved));
776 }
777 resolve_import_absolute_paths(ast, &resolve);
778 }
779
780 if let Some(id) = source_data.get("id") {
781 source_id_to_path.insert(id.to_string(), json!(&abs_key));
782 }
783
784 resolved_sources.insert(abs_key, source_data);
785 }
786 }
787 }
788
789 result.insert("sources".to_string(), Value::Object(resolved_sources));
790
791 let mut resolved_contracts = Map::new();
793 if let Some(contracts) = solc_output
794 .get_mut("contracts")
795 .and_then(|c| c.as_object_mut())
796 {
797 let keys: Vec<String> = contracts.keys().cloned().collect();
798 for key in keys {
799 if let Some(contract_data) = contracts.remove(&key) {
800 resolved_contracts.insert(resolve(&key), contract_data);
801 }
802 }
803 }
804 result.insert("contracts".to_string(), Value::Object(resolved_contracts));
805
806 result.insert(
808 "source_id_to_path".to_string(),
809 Value::Object(source_id_to_path),
810 );
811
812 Value::Object(result)
813}
814
815pub fn normalize_forge_output(mut forge_output: Value) -> Value {
827 let mut result = Map::new();
828
829 let errors = forge_output
831 .get_mut("errors")
832 .map(Value::take)
833 .unwrap_or_else(|| json!([]));
834 result.insert("errors".to_string(), errors);
835
836 let mut normalized_sources = Map::new();
838 if let Some(sources) = forge_output
839 .get_mut("sources")
840 .and_then(|s| s.as_object_mut())
841 {
842 for (path, entries) in sources.iter_mut() {
843 if let Some(arr) = entries.as_array_mut()
844 && let Some(first) = arr.first_mut()
845 && let Some(sf) = first.get_mut("source_file")
846 {
847 normalized_sources.insert(path.clone(), sf.take());
848 }
849 }
850 }
851 result.insert("sources".to_string(), Value::Object(normalized_sources));
852
853 let mut normalized_contracts = Map::new();
855 if let Some(contracts) = forge_output
856 .get_mut("contracts")
857 .and_then(|c| c.as_object_mut())
858 {
859 for (path, names) in contracts.iter_mut() {
860 let mut path_contracts = Map::new();
861 if let Some(names_obj) = names.as_object_mut() {
862 for (name, entries) in names_obj.iter_mut() {
863 if let Some(arr) = entries.as_array_mut()
864 && let Some(first) = arr.first_mut()
865 && let Some(contract) = first.get_mut("contract")
866 {
867 path_contracts.insert(name.clone(), contract.take());
868 }
869 }
870 }
871 normalized_contracts.insert(path.clone(), Value::Object(path_contracts));
872 }
873 }
874 result.insert("contracts".to_string(), Value::Object(normalized_contracts));
875
876 let source_id_to_path = forge_output
878 .get_mut("build_infos")
879 .and_then(|bi| bi.as_array_mut())
880 .and_then(|arr| arr.first_mut())
881 .and_then(|info| info.get_mut("source_id_to_path"))
882 .map(Value::take)
883 .unwrap_or_else(|| json!({}));
884 result.insert("source_id_to_path".to_string(), source_id_to_path);
885
886 Value::Object(result)
887}
888
889pub async fn solc_ast(
898 file_path: &str,
899 config: &FoundryConfig,
900 client: Option<&tower_lsp::Client>,
901 source_content: Option<&str>,
902) -> Result<Value, RunnerError> {
903 let remappings = resolve_remappings(config).await;
904
905 let file_abs = Path::new(file_path).to_path_buf();
910 let config_root = config.root.clone();
911 let remappings_clone = remappings.clone();
912 let pragmas = tokio::task::spawn_blocking(move || {
913 collect_import_pragmas(&file_abs, &config_root, &remappings_clone)
914 })
915 .await
916 .unwrap_or_default();
917 let constraint = tightest_constraint(&pragmas);
918 let solc_binary = resolve_solc_binary(config, constraint.as_ref(), client).await;
919
920 let rel_path = Path::new(file_path)
925 .strip_prefix(&config.root)
926 .map(|p| p.to_string_lossy().into_owned())
927 .unwrap_or_else(|_| file_path.to_string());
928
929 let input = build_standard_json_input(&rel_path, &remappings, config, source_content);
930 let raw_output = run_solc(&solc_binary, &input, &config.root).await?;
931
932 Ok(normalize_solc_output(raw_output, Some(&config.root)))
933}
934
935pub async fn solc_build(
937 file_path: &str,
938 config: &FoundryConfig,
939 client: Option<&tower_lsp::Client>,
940) -> Result<Value, RunnerError> {
941 solc_ast(file_path, config, client, None).await
942}
943
944pub fn discover_source_files(config: &FoundryConfig) -> Vec<PathBuf> {
964 discover_source_files_inner(config, false)
965}
966
967pub fn discover_src_only_files(config: &FoundryConfig) -> Vec<PathBuf> {
973 let root = &config.root;
974 if !root.is_dir() {
975 return Vec::new();
976 }
977 let mut files = Vec::new();
978 let dir = root.join(&config.sources_dir);
979 if dir.is_dir() {
980 discover_recursive(&dir, &[], &mut files);
981 }
982 files.sort();
983 files
984}
985
986pub fn discover_src_only_closure(config: &FoundryConfig, remappings: &[String]) -> Vec<PathBuf> {
993 let seeds = discover_src_only_files(config);
994 let mut visited: HashSet<PathBuf> = HashSet::new();
995 let mut queue: std::collections::VecDeque<PathBuf> = seeds.into_iter().collect();
996
997 while let Some(file) = queue.pop_front() {
998 if !visited.insert(file.clone()) {
999 continue;
1000 }
1001 let source = match std::fs::read_to_string(&file) {
1002 Ok(s) => s,
1003 Err(_) => continue,
1004 };
1005 for imp in links::ts_find_imports(source.as_bytes()) {
1006 if let Some(abs) = resolve_import_to_abs(&config.root, &file, &imp.path, remappings) {
1007 if abs.exists() && !visited.contains(&abs) {
1008 queue.push_back(abs);
1009 }
1010 }
1011 }
1012 }
1013
1014 let mut result: Vec<PathBuf> = visited.into_iter().collect();
1015 result.sort();
1016 result
1017}
1018
1019pub fn discover_source_files_with_libs(config: &FoundryConfig) -> Vec<PathBuf> {
1026 discover_source_files_inner(config, true)
1027}
1028
1029fn discover_source_files_inner(config: &FoundryConfig, include_libs: bool) -> Vec<PathBuf> {
1030 let root = &config.root;
1031 if !root.is_dir() {
1032 return Vec::new();
1033 }
1034
1035 let mut files = Vec::new();
1036 let no_skip: &[String] = &[];
1037
1038 for dir_name in [&config.sources_dir, &config.test_dir, &config.script_dir] {
1043 let dir = root.join(dir_name);
1044 if dir.is_dir() {
1045 discover_recursive(&dir, no_skip, &mut files);
1046 }
1047 }
1048
1049 if include_libs {
1051 for lib_name in &config.libs {
1052 let lib_dir = root.join(lib_name);
1053 if lib_dir.is_dir() {
1054 discover_recursive(&lib_dir, no_skip, &mut files);
1055 }
1056 }
1057 }
1058
1059 files.sort();
1060 files
1061}
1062
1063pub fn discover_compilation_closure(config: &FoundryConfig, remappings: &[String]) -> Vec<PathBuf> {
1082 let seeds = discover_source_files(config);
1084 let mut visited: HashSet<PathBuf> = HashSet::new();
1085 let mut queue: std::collections::VecDeque<PathBuf> = seeds.into_iter().collect();
1086
1087 while let Some(file) = queue.pop_front() {
1088 if !visited.insert(file.clone()) {
1089 continue;
1090 }
1091 let source = match std::fs::read_to_string(&file) {
1092 Ok(s) => s,
1093 Err(_) => continue,
1094 };
1095 for imp in links::ts_find_imports(source.as_bytes()) {
1096 if let Some(abs) = resolve_import_to_abs(&config.root, &file, &imp.path, remappings) {
1097 if abs.exists() && !visited.contains(&abs) {
1098 queue.push_back(abs);
1099 }
1100 }
1101 }
1102 }
1103
1104 let mut result: Vec<PathBuf> = visited.into_iter().collect();
1105 result.sort();
1106 result
1107}
1108
1109const DISCOVER_SKIP_DIRS: &[&str] = &["out", "artifacts", "cache", "target", "broadcast"];
1112
1113fn discover_recursive(dir: &Path, skip_libs: &[String], files: &mut Vec<PathBuf>) {
1114 let entries = match std::fs::read_dir(dir) {
1115 Ok(e) => e,
1116 Err(_) => return,
1117 };
1118 for entry in entries.flatten() {
1119 let path = entry.path();
1120 if path.is_dir() {
1121 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
1122 if name.starts_with('.') {
1124 continue;
1125 }
1126 if DISCOVER_SKIP_DIRS.contains(&name) {
1128 continue;
1129 }
1130 if skip_libs.iter().any(|lib| lib == name) {
1132 continue;
1133 }
1134 }
1135 discover_recursive(&path, skip_libs, files);
1136 } else if let Some(name) = path.file_name().and_then(|n| n.to_str())
1137 && name.ends_with(".sol")
1138 {
1139 files.push(path);
1140 }
1141 }
1142}
1143
1144pub fn build_batch_standard_json_input(
1151 source_files: &[PathBuf],
1152 remappings: &[String],
1153 config: &FoundryConfig,
1154) -> Value {
1155 build_batch_standard_json_input_with_cache(source_files, remappings, config, None)
1156}
1157
1158pub fn build_batch_standard_json_input_with_cache(
1168 source_files: &[PathBuf],
1169 remappings: &[String],
1170 config: &FoundryConfig,
1171 content_cache: Option<&HashMap<crate::types::DocumentUri, (i32, String)>>,
1172) -> Value {
1173 let contract_outputs = vec![
1174 "devdoc",
1175 "userdoc",
1176 "evm.methodIdentifiers",
1177 "storageLayout",
1178 "transientStorageLayout",
1179 ];
1180
1181 let mut settings = json!({
1182 "remappings": remappings,
1183 "outputSelection": {
1184 "*": {
1185 "*": contract_outputs,
1186 "": ["ast"]
1187 }
1188 }
1189 });
1190
1191 if config.via_ir {
1192 settings["viaIR"] = json!(true);
1193 }
1194 if let Some(ref evm_version) = config.evm_version {
1195 settings["evmVersion"] = json!(evm_version);
1196 }
1197
1198 let mut sources = serde_json::Map::new();
1199 for file in source_files {
1200 let rel_path = file
1201 .strip_prefix(&config.root)
1202 .map(|p| p.to_string_lossy().into_owned())
1203 .unwrap_or_else(|_| file.to_string_lossy().into_owned());
1204
1205 let cached_content = content_cache.and_then(|cache| {
1207 let uri = Url::from_file_path(file).ok()?;
1208 cache.get(uri.as_str()).map(|(_, c)| c.as_str())
1209 });
1210
1211 if let Some(content) = cached_content {
1212 sources.insert(rel_path, json!({ "content": content }));
1213 } else {
1214 sources.insert(rel_path.clone(), json!({ "urls": [rel_path] }));
1215 }
1216 }
1217
1218 json!({
1219 "language": "Solidity",
1220 "sources": sources,
1221 "settings": settings
1222 })
1223}
1224
1225pub fn build_batch_standard_json_input_ast_only(
1235 source_files: &[PathBuf],
1236 remappings: &[String],
1237 root: &Path,
1238) -> Value {
1239 let settings = json!({
1240 "remappings": remappings,
1241 "outputSelection": {
1242 "*": {
1243 "": ["ast"]
1244 }
1245 }
1246 });
1247
1248 let mut sources = serde_json::Map::new();
1249 for file in source_files {
1250 let rel_path = file
1251 .strip_prefix(root)
1252 .map(|p| p.to_string_lossy().into_owned())
1253 .unwrap_or_else(|_| file.to_string_lossy().into_owned());
1254 sources.insert(rel_path.clone(), json!({ "urls": [rel_path] }));
1255 }
1256
1257 json!({
1258 "language": "Solidity",
1259 "sources": sources,
1260 "settings": settings
1261 })
1262}
1263
1264pub fn build_parse_only_json_input(
1281 source_files: &[PathBuf],
1282 remappings: &[String],
1283 config: &FoundryConfig,
1284) -> Value {
1285 let settings = json!({
1286 "stopAfter": "parsing",
1287 "remappings": remappings,
1288 "outputSelection": {
1289 "*": {
1290 "": ["ast"]
1291 }
1292 }
1293 });
1294
1295 let mut sources = serde_json::Map::new();
1296 for file in source_files {
1297 let rel_path = file
1298 .strip_prefix(&config.root)
1299 .map(|p| p.to_string_lossy().into_owned())
1300 .unwrap_or_else(|_| file.to_string_lossy().into_owned());
1301 sources.insert(rel_path.clone(), json!({ "urls": [rel_path] }));
1302 }
1303
1304 json!({
1305 "language": "Solidity",
1306 "sources": sources,
1307 "settings": settings
1308 })
1309}
1310
1311pub async fn solc_project_index(
1321 config: &FoundryConfig,
1322 client: Option<&tower_lsp::Client>,
1323 text_cache: Option<&HashMap<crate::types::DocumentUri, (i32, String)>>,
1324) -> Result<Value, RunnerError> {
1325 let remappings = resolve_remappings(config).await;
1327
1328 let source_files = discover_compilation_closure(config, &remappings);
1333 if source_files.is_empty() {
1334 return Err(RunnerError::CommandError(std::io::Error::other(
1335 "no source files found for project index",
1336 )));
1337 }
1338
1339 solc_project_index_from_files(config, client, text_cache, &source_files).await
1340}
1341
1342pub async fn solc_project_index_ast_only(
1352 config: &FoundryConfig,
1353 client: Option<&tower_lsp::Client>,
1354) -> Result<Value, RunnerError> {
1355 let remappings = resolve_remappings(config).await;
1356 let source_files = discover_compilation_closure(config, &remappings);
1357 if source_files.is_empty() {
1358 return Err(RunnerError::CommandError(std::io::Error::other(
1359 "no source files found for project index",
1360 )));
1361 }
1362 solc_project_index_from_files_ast_only(config, client, &source_files).await
1363}
1364
1365async fn solc_project_index_from_files_ast_only(
1371 config: &FoundryConfig,
1372 client: Option<&tower_lsp::Client>,
1373 source_files: &[PathBuf],
1374) -> Result<Value, RunnerError> {
1375 if source_files.is_empty() {
1376 return Err(RunnerError::CommandError(std::io::Error::other(
1377 "no source files found for AST-only project index",
1378 )));
1379 }
1380
1381 let remappings = resolve_remappings(config).await;
1382
1383 let project_version: Option<SemVer> =
1384 config.solc_version.as_ref().and_then(|v| SemVer::parse(v));
1385 let constraint: Option<PragmaConstraint> = if let Some(ref v) = project_version {
1386 Some(PragmaConstraint::Exact(v.clone()))
1387 } else {
1388 source_files.iter().find_map(|f| {
1389 std::fs::read_to_string(f)
1390 .ok()
1391 .and_then(|src| parse_pragma(&src))
1392 })
1393 };
1394 let solc_binary = resolve_solc_binary(config, constraint.as_ref(), client).await;
1395
1396 let (compatible_files, incompatible_files) = if let Some(ref ver) = project_version {
1398 let mut compat = Vec::with_capacity(source_files.len());
1399 let mut incompat = Vec::new();
1400 for file in source_files {
1401 let is_compatible = std::fs::read_to_string(file)
1402 .ok()
1403 .and_then(|src| parse_pragma(&src))
1404 .map(|pragma| version_satisfies(ver, &pragma))
1405 .unwrap_or(true);
1406 if is_compatible {
1407 compat.push(file.clone());
1408 } else {
1409 incompat.push(file.clone());
1410 }
1411 }
1412 (compat, incompat)
1413 } else {
1414 (source_files.to_vec(), Vec::new())
1415 };
1416
1417 if !incompatible_files.is_empty() {
1418 if let Some(c) = client {
1419 c.log_message(
1420 tower_lsp::lsp_types::MessageType::INFO,
1421 format!(
1422 "project index: {} compatible, {} incompatible with solc {}",
1423 compatible_files.len(),
1424 incompatible_files.len(),
1425 project_version
1426 .as_ref()
1427 .map(|v| v.to_string())
1428 .unwrap_or_default(),
1429 ),
1430 )
1431 .await;
1432 }
1433 }
1434
1435 let mut result = if compatible_files.is_empty() {
1436 json!({"sources": {}, "contracts": {}, "errors": [], "source_id_to_path": {}})
1437 } else {
1438 let input =
1439 build_batch_standard_json_input_ast_only(&compatible_files, &remappings, &config.root);
1440 let raw = run_solc(&solc_binary, &input, &config.root).await?;
1441 normalize_solc_output(raw, Some(&config.root))
1442 };
1443
1444 if incompatible_files.is_empty() {
1445 return Ok(result);
1446 }
1447
1448 for file in &incompatible_files {
1450 let pragma = std::fs::read_to_string(file)
1451 .ok()
1452 .and_then(|src| parse_pragma(&src));
1453 let file_binary = resolve_solc_binary(config, pragma.as_ref(), client).await;
1454 let input =
1455 build_batch_standard_json_input_ast_only(&[file.clone()], &remappings, &config.root);
1456 if let Ok(raw) = run_solc(&file_binary, &input, &config.root).await {
1457 let normalized = normalize_solc_output(raw, Some(&config.root));
1458 merge_normalized_outputs(&mut result, normalized);
1459 }
1460 }
1461
1462 if let Some(c) = client {
1463 let total = result
1464 .get("sources")
1465 .and_then(|s| s.as_object())
1466 .map_or(0, |obj| obj.len());
1467 c.log_message(
1468 tower_lsp::lsp_types::MessageType::INFO,
1469 format!(
1470 "project index: compiled {} files ({} needed different solc version)",
1471 total,
1472 incompatible_files.len(),
1473 ),
1474 )
1475 .await;
1476 }
1477
1478 Ok(result)
1479}
1480
1481pub async fn solc_project_index_scoped(
1486 config: &FoundryConfig,
1487 client: Option<&tower_lsp::Client>,
1488 text_cache: Option<&HashMap<crate::types::DocumentUri, (i32, String)>>,
1489 source_files: &[PathBuf],
1490) -> Result<Value, RunnerError> {
1491 if source_files.is_empty() {
1492 return Err(RunnerError::CommandError(std::io::Error::other(
1493 "no source files provided for scoped project index",
1494 )));
1495 }
1496
1497 solc_project_index_from_files(config, client, text_cache, source_files).await
1498}
1499
1500#[cfg(test)]
1504fn extract_version_error_files(solc_output: &Value) -> HashSet<String> {
1505 let mut files = HashSet::new();
1506 if let Some(errors) = solc_output.get("errors").and_then(|e| e.as_array()) {
1507 for err in errors {
1508 let is_5333 = err.get("errorCode").and_then(|c| c.as_str()) == Some("5333");
1509 if is_5333
1510 && let Some(file) = err
1511 .get("sourceLocation")
1512 .and_then(|sl| sl.get("file"))
1513 .and_then(|f| f.as_str())
1514 {
1515 files.insert(file.to_string());
1516 }
1517 }
1518 }
1519 files
1520}
1521
1522#[cfg(test)]
1525#[allow(dead_code)]
1526fn extract_import_error_files(solc_output: &Value) -> HashSet<String> {
1527 let mut files = HashSet::new();
1528 if let Some(errors) = solc_output.get("errors").and_then(|e| e.as_array()) {
1529 for err in errors {
1530 let is_6275 = err.get("errorCode").and_then(|c| c.as_str()) == Some("6275");
1531 if is_6275
1532 && let Some(file) = err
1533 .get("sourceLocation")
1534 .and_then(|sl| sl.get("file"))
1535 .and_then(|f| f.as_str())
1536 {
1537 files.insert(file.to_string());
1538 }
1539 }
1540 }
1541 files
1542}
1543
1544#[cfg(test)]
1550fn reverse_import_closure(
1551 source_files: &[PathBuf],
1552 exclude_abs: &HashSet<PathBuf>,
1553 project_root: &Path,
1554 remappings: &[String],
1555) -> HashSet<PathBuf> {
1556 let mut reverse_edges: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
1559
1560 for file in source_files {
1561 let Ok(bytes) = std::fs::read(file) else {
1562 continue;
1563 };
1564 for imp in links::ts_find_imports(&bytes) {
1565 if let Some(imported_abs) =
1566 resolve_import_to_abs(project_root, file, &imp.path, remappings)
1567 {
1568 reverse_edges
1569 .entry(imported_abs)
1570 .or_default()
1571 .insert(file.clone());
1572 }
1573 }
1574 }
1575
1576 let mut closure: HashSet<PathBuf> = exclude_abs.clone();
1578 let mut queue: std::collections::VecDeque<PathBuf> = exclude_abs.iter().cloned().collect();
1579
1580 while let Some(current) = queue.pop_front() {
1581 if let Some(importers) = reverse_edges.get(¤t) {
1582 for importer in importers {
1583 if closure.insert(importer.clone()) {
1584 queue.push_back(importer.clone());
1585 }
1586 }
1587 }
1588 }
1589
1590 closure
1591}
1592
1593fn merge_normalized_outputs(base: &mut Value, other: Value) {
1599 if let (Some(base_sources), Some(other_sources)) = (
1601 base.get_mut("sources").and_then(|s| s.as_object_mut()),
1602 other.get("sources").and_then(|s| s.as_object()),
1603 ) {
1604 let max_base_id = base_sources
1606 .values()
1607 .filter_map(|v| v.get("id").and_then(|id| id.as_u64()))
1608 .max()
1609 .map(|m| m + 1)
1610 .unwrap_or(0);
1611
1612 let mut remapped_id_to_path: Vec<(String, String)> = Vec::new();
1614
1615 for (path, mut source_data) in other_sources.clone() {
1616 if let Some(id) = source_data.get("id").and_then(|id| id.as_u64()) {
1618 let new_id = id + max_base_id;
1619 source_data
1620 .as_object_mut()
1621 .unwrap()
1622 .insert("id".to_string(), json!(new_id));
1623 remapped_id_to_path.push((new_id.to_string(), path.clone()));
1624 }
1625 base_sources.insert(path, source_data);
1626 }
1627
1628 if let Some(base_id_map) = base
1630 .get_mut("source_id_to_path")
1631 .and_then(|m| m.as_object_mut())
1632 {
1633 for (id, path) in remapped_id_to_path {
1634 base_id_map.insert(id, json!(path));
1635 }
1636 }
1637 }
1638
1639 if let (Some(base_contracts), Some(other_contracts)) = (
1641 base.get_mut("contracts").and_then(|c| c.as_object_mut()),
1642 other.get("contracts").and_then(|c| c.as_object()),
1643 ) {
1644 for (path, contract_data) in other_contracts {
1645 base_contracts.insert(path.clone(), contract_data.clone());
1646 }
1647 }
1648
1649 }
1652
1653async fn solc_project_index_from_files(
1654 config: &FoundryConfig,
1655 client: Option<&tower_lsp::Client>,
1656 text_cache: Option<&HashMap<crate::types::DocumentUri, (i32, String)>>,
1657 source_files: &[PathBuf],
1658) -> Result<Value, RunnerError> {
1659 if source_files.is_empty() {
1660 return Err(RunnerError::CommandError(std::io::Error::other(
1661 "no source files found for project index",
1662 )));
1663 }
1664
1665 let remappings = resolve_remappings(config).await;
1666
1667 let project_version: Option<SemVer> =
1669 config.solc_version.as_ref().and_then(|v| SemVer::parse(v));
1670
1671 let constraint: Option<PragmaConstraint> = if let Some(ref v) = project_version {
1674 Some(PragmaConstraint::Exact(v.clone()))
1675 } else {
1676 source_files.iter().find_map(|f| {
1677 std::fs::read_to_string(f)
1678 .ok()
1679 .and_then(|src| parse_pragma(&src))
1680 })
1681 };
1682 let solc_binary = resolve_solc_binary(config, constraint.as_ref(), client).await;
1683
1684 let (compatible_files, incompatible_files) = if let Some(ref ver) = project_version {
1690 let mut compat = Vec::with_capacity(source_files.len());
1691 let mut incompat = Vec::new();
1692 for file in source_files {
1693 let is_compatible = std::fs::read_to_string(file)
1694 .ok()
1695 .and_then(|src| parse_pragma(&src))
1696 .map(|pragma| version_satisfies(ver, &pragma))
1697 .unwrap_or(true);
1699 if is_compatible {
1700 compat.push(file.clone());
1701 } else {
1702 incompat.push(file.clone());
1703 }
1704 }
1705 (compat, incompat)
1706 } else {
1707 (source_files.to_vec(), Vec::new())
1709 };
1710
1711 if !incompatible_files.is_empty() {
1712 if let Some(c) = client {
1713 c.log_message(
1714 tower_lsp::lsp_types::MessageType::INFO,
1715 format!(
1716 "project index: {} compatible, {} incompatible with solc {}",
1717 compatible_files.len(),
1718 incompatible_files.len(),
1719 project_version
1720 .as_ref()
1721 .map(|v| v.to_string())
1722 .unwrap_or_default(),
1723 ),
1724 )
1725 .await;
1726 }
1727 }
1728
1729 let mut result = if compatible_files.is_empty() {
1737 json!({"sources": {}, "contracts": {}, "errors": [], "source_id_to_path": {}})
1738 } else {
1739 let input = build_batch_standard_json_input_with_cache(
1740 &compatible_files,
1741 &remappings,
1742 config,
1743 text_cache,
1744 );
1745 let raw = run_solc(&solc_binary, &input, &config.root).await?;
1746 normalize_solc_output(raw, Some(&config.root))
1747 };
1748
1749 let batch_source_count = result
1750 .get("sources")
1751 .and_then(|s| s.as_object())
1752 .map_or(0, |obj| obj.len());
1753
1754 if incompatible_files.is_empty() {
1755 return Ok(result);
1756 }
1757
1758 if let Some(c) = client {
1759 let batch_errors: Vec<String> = result
1761 .get("errors")
1762 .and_then(|e| e.as_array())
1763 .map(|arr| {
1764 arr.iter()
1765 .filter(|e| e.get("severity").and_then(|s| s.as_str()) == Some("error"))
1766 .take(3)
1767 .filter_map(|e| {
1768 let msg = e.get("message").and_then(|m| m.as_str()).unwrap_or("?");
1769 let file = e
1770 .get("sourceLocation")
1771 .and_then(|sl| sl.get("file"))
1772 .and_then(|f| f.as_str())
1773 .unwrap_or("?");
1774 Some(format!("{file}: {msg}"))
1775 })
1776 .collect()
1777 })
1778 .unwrap_or_default();
1779
1780 c.log_message(
1781 tower_lsp::lsp_types::MessageType::INFO,
1782 format!(
1783 "project index: batch produced {} sources, now compiling {} incompatible files individually{}",
1784 batch_source_count,
1785 incompatible_files.len(),
1786 if batch_errors.is_empty() {
1787 String::new()
1788 } else {
1789 format!(" [first errors: {}]", batch_errors.join("; "))
1790 },
1791 ),
1792 )
1793 .await;
1794 }
1795
1796 let mut compiled = 0usize;
1798 let mut skipped = 0usize;
1799 for file in &incompatible_files {
1800 let pragma = std::fs::read_to_string(file)
1801 .ok()
1802 .and_then(|src| parse_pragma(&src));
1803
1804 let Some(file_constraint) = pragma else {
1805 skipped += 1;
1806 continue;
1807 };
1808
1809 let file_binary = resolve_solc_binary(config, Some(&file_constraint), client).await;
1810 let input = build_batch_standard_json_input_with_cache(
1811 &[file.clone()],
1812 &remappings,
1813 config,
1814 text_cache,
1815 );
1816 match run_solc(&file_binary, &input, &config.root).await {
1817 Ok(raw) => {
1818 let normalized = normalize_solc_output(raw, Some(&config.root));
1819 merge_normalized_outputs(&mut result, normalized);
1820 compiled += 1;
1821 }
1822 Err(e) => {
1823 if let Some(c) = client {
1824 c.log_message(
1825 tower_lsp::lsp_types::MessageType::WARNING,
1826 format!(
1827 "project index: incompatible file {} failed: {e}",
1828 file.display(),
1829 ),
1830 )
1831 .await;
1832 }
1833 skipped += 1;
1834 }
1835 }
1836 }
1837
1838 if let Some(c) = client {
1839 c.log_message(
1840 tower_lsp::lsp_types::MessageType::INFO,
1841 format!(
1842 "project index: incompatible files done — {compiled} compiled, {skipped} skipped",
1843 ),
1844 )
1845 .await;
1846 }
1847
1848 Ok(result)
1849}
1850
1851#[cfg(test)]
1852mod tests {
1853 use super::*;
1854
1855 #[test]
1856 fn test_normalize_solc_sources() {
1857 let solc_output = json!({
1858 "sources": {
1859 "src/Foo.sol": {
1860 "id": 0,
1861 "ast": {
1862 "nodeType": "SourceUnit",
1863 "absolutePath": "src/Foo.sol",
1864 "id": 100
1865 }
1866 },
1867 "src/Bar.sol": {
1868 "id": 1,
1869 "ast": {
1870 "nodeType": "SourceUnit",
1871 "absolutePath": "src/Bar.sol",
1872 "id": 200
1873 }
1874 }
1875 },
1876 "contracts": {},
1877 "errors": []
1878 });
1879
1880 let normalized = normalize_solc_output(solc_output, None);
1881
1882 let sources = normalized.get("sources").unwrap().as_object().unwrap();
1884 assert_eq!(sources.len(), 2);
1885
1886 let foo = sources.get("src/Foo.sol").unwrap();
1887 assert_eq!(foo.get("id").unwrap(), 0);
1888 assert_eq!(
1889 foo.get("ast")
1890 .unwrap()
1891 .get("nodeType")
1892 .unwrap()
1893 .as_str()
1894 .unwrap(),
1895 "SourceUnit"
1896 );
1897
1898 let id_to_path = normalized
1900 .get("source_id_to_path")
1901 .unwrap()
1902 .as_object()
1903 .unwrap();
1904 assert_eq!(id_to_path.len(), 2);
1905 }
1906
1907 #[test]
1908 fn test_normalize_solc_contracts() {
1909 let solc_output = json!({
1910 "sources": {},
1911 "contracts": {
1912 "src/Foo.sol": {
1913 "Foo": {
1914 "abi": [{"type": "function", "name": "bar"}],
1915 "evm": {
1916 "methodIdentifiers": {
1917 "bar(uint256)": "abcd1234"
1918 }
1919 }
1920 }
1921 }
1922 },
1923 "errors": []
1924 });
1925
1926 let normalized = normalize_solc_output(solc_output, None);
1927
1928 let contracts = normalized.get("contracts").unwrap().as_object().unwrap();
1930 let foo_contracts = contracts.get("src/Foo.sol").unwrap().as_object().unwrap();
1931 let foo = foo_contracts.get("Foo").unwrap();
1932
1933 let method_ids = foo
1934 .get("evm")
1935 .unwrap()
1936 .get("methodIdentifiers")
1937 .unwrap()
1938 .as_object()
1939 .unwrap();
1940 assert_eq!(
1941 method_ids.get("bar(uint256)").unwrap().as_str().unwrap(),
1942 "abcd1234"
1943 );
1944 }
1945
1946 #[test]
1947 fn test_normalize_solc_errors_passthrough() {
1948 let solc_output = json!({
1949 "sources": {},
1950 "contracts": {},
1951 "errors": [{
1952 "sourceLocation": {"file": "src/Foo.sol", "start": 0, "end": 10},
1953 "type": "Warning",
1954 "component": "general",
1955 "severity": "warning",
1956 "errorCode": "2394",
1957 "message": "test warning",
1958 "formattedMessage": "Warning: test warning"
1959 }]
1960 });
1961
1962 let normalized = normalize_solc_output(solc_output, None);
1963
1964 let errors = normalized.get("errors").unwrap().as_array().unwrap();
1965 assert_eq!(errors.len(), 1);
1966 assert_eq!(
1967 errors[0].get("errorCode").unwrap().as_str().unwrap(),
1968 "2394"
1969 );
1970 }
1971
1972 #[test]
1973 fn test_normalize_empty_solc_output() {
1974 let solc_output = json!({
1975 "sources": {},
1976 "contracts": {}
1977 });
1978
1979 let normalized = normalize_solc_output(solc_output, None);
1980
1981 assert!(
1982 normalized
1983 .get("sources")
1984 .unwrap()
1985 .as_object()
1986 .unwrap()
1987 .is_empty()
1988 );
1989 assert!(
1990 normalized
1991 .get("contracts")
1992 .unwrap()
1993 .as_object()
1994 .unwrap()
1995 .is_empty()
1996 );
1997 assert_eq!(
1998 normalized.get("errors").unwrap().as_array().unwrap().len(),
1999 0
2000 );
2001 assert!(
2002 normalized
2003 .get("source_id_to_path")
2004 .unwrap()
2005 .as_object()
2006 .unwrap()
2007 .is_empty()
2008 );
2009 }
2010
2011 #[test]
2012 fn test_build_standard_json_input() {
2013 let config = FoundryConfig::default();
2014 let input = build_standard_json_input(
2015 "/path/to/Foo.sol",
2016 &[
2017 "ds-test/=lib/forge-std/lib/ds-test/src/".to_string(),
2018 "forge-std/=lib/forge-std/src/".to_string(),
2019 ],
2020 &config,
2021 None,
2022 );
2023
2024 let sources = input.get("sources").unwrap().as_object().unwrap();
2025 assert!(sources.contains_key("/path/to/Foo.sol"));
2026
2027 let settings = input.get("settings").unwrap();
2028 let remappings = settings.get("remappings").unwrap().as_array().unwrap();
2029 assert_eq!(remappings.len(), 2);
2030
2031 let output_sel = settings.get("outputSelection").unwrap();
2032 assert!(output_sel.get("*").is_some());
2033
2034 assert!(settings.get("optimizer").is_none());
2036 assert!(settings.get("viaIR").is_none());
2037 assert!(settings.get("evmVersion").is_none());
2038
2039 let outputs = settings["outputSelection"]["*"]["*"].as_array().unwrap();
2041 let output_names: Vec<&str> = outputs.iter().map(|v| v.as_str().unwrap()).collect();
2042 assert!(!output_names.contains(&"evm.gasEstimates"));
2043 assert!(!output_names.contains(&"abi")); assert!(output_names.contains(&"devdoc"));
2045 assert!(output_names.contains(&"userdoc"));
2046 assert!(output_names.contains(&"evm.methodIdentifiers"));
2047 }
2048
2049 #[test]
2050 fn test_build_standard_json_input_with_config() {
2051 let config = FoundryConfig {
2052 optimizer: true,
2053 optimizer_runs: 9999999,
2054 via_ir: true,
2055 evm_version: Some("osaka".to_string()),
2056 ..Default::default()
2057 };
2058 let input = build_standard_json_input("/path/to/Foo.sol", &[], &config, None);
2059
2060 let settings = input.get("settings").unwrap();
2061
2062 assert!(settings.get("optimizer").is_none());
2064
2065 assert!(settings.get("viaIR").unwrap().as_bool().unwrap());
2067
2068 let outputs = settings["outputSelection"]["*"]["*"].as_array().unwrap();
2070 let output_names: Vec<&str> = outputs.iter().map(|v| v.as_str().unwrap()).collect();
2071 assert!(!output_names.contains(&"evm.gasEstimates"));
2072
2073 assert_eq!(
2075 settings.get("evmVersion").unwrap().as_str().unwrap(),
2076 "osaka"
2077 );
2078 }
2079
2080 #[tokio::test]
2081 async fn test_resolve_solc_binary_default() {
2082 let config = FoundryConfig::default();
2083 let binary = resolve_solc_binary(&config, None, None).await;
2084 assert_eq!(binary, PathBuf::from("solc"));
2085 }
2086
2087 #[test]
2088 fn test_parse_pragma_exact() {
2089 let source = "// SPDX\npragma solidity 0.8.26;\n";
2090 assert_eq!(
2091 parse_pragma(source),
2092 Some(PragmaConstraint::Exact(SemVer {
2093 major: 0,
2094 minor: 8,
2095 patch: 26
2096 }))
2097 );
2098 }
2099
2100 #[test]
2101 fn test_parse_pragma_caret() {
2102 let source = "pragma solidity ^0.8.0;\n";
2103 assert_eq!(
2104 parse_pragma(source),
2105 Some(PragmaConstraint::Caret(SemVer {
2106 major: 0,
2107 minor: 8,
2108 patch: 0
2109 }))
2110 );
2111 }
2112
2113 #[test]
2114 fn test_parse_pragma_gte() {
2115 let source = "pragma solidity >=0.8.0;\n";
2116 assert_eq!(
2117 parse_pragma(source),
2118 Some(PragmaConstraint::Gte(SemVer {
2119 major: 0,
2120 minor: 8,
2121 patch: 0
2122 }))
2123 );
2124 }
2125
2126 #[test]
2127 fn test_parse_pragma_range() {
2128 let source = "pragma solidity >=0.6.2 <0.9.0;\n";
2129 assert_eq!(
2130 parse_pragma(source),
2131 Some(PragmaConstraint::Range(
2132 SemVer {
2133 major: 0,
2134 minor: 6,
2135 patch: 2
2136 },
2137 SemVer {
2138 major: 0,
2139 minor: 9,
2140 patch: 0
2141 },
2142 ))
2143 );
2144 }
2145
2146 #[test]
2147 fn test_parse_pragma_none() {
2148 let source = "contract Foo {}\n";
2149 assert_eq!(parse_pragma(source), None);
2150 }
2151
2152 #[test]
2153 fn test_version_satisfies_exact() {
2154 let v = SemVer {
2155 major: 0,
2156 minor: 8,
2157 patch: 26,
2158 };
2159 assert!(version_satisfies(&v, &PragmaConstraint::Exact(v.clone())));
2160 assert!(!version_satisfies(
2161 &SemVer {
2162 major: 0,
2163 minor: 8,
2164 patch: 25
2165 },
2166 &PragmaConstraint::Exact(v)
2167 ));
2168 }
2169
2170 #[test]
2171 fn test_version_satisfies_caret() {
2172 let constraint = PragmaConstraint::Caret(SemVer {
2173 major: 0,
2174 minor: 8,
2175 patch: 0,
2176 });
2177 assert!(version_satisfies(
2178 &SemVer {
2179 major: 0,
2180 minor: 8,
2181 patch: 0
2182 },
2183 &constraint
2184 ));
2185 assert!(version_satisfies(
2186 &SemVer {
2187 major: 0,
2188 minor: 8,
2189 patch: 26
2190 },
2191 &constraint
2192 ));
2193 assert!(!version_satisfies(
2195 &SemVer {
2196 major: 0,
2197 minor: 9,
2198 patch: 0
2199 },
2200 &constraint
2201 ));
2202 assert!(!version_satisfies(
2204 &SemVer {
2205 major: 0,
2206 minor: 7,
2207 patch: 0
2208 },
2209 &constraint
2210 ));
2211 }
2212
2213 #[test]
2214 fn test_version_satisfies_gte() {
2215 let constraint = PragmaConstraint::Gte(SemVer {
2216 major: 0,
2217 minor: 8,
2218 patch: 0,
2219 });
2220 assert!(version_satisfies(
2221 &SemVer {
2222 major: 0,
2223 minor: 8,
2224 patch: 0
2225 },
2226 &constraint
2227 ));
2228 assert!(version_satisfies(
2229 &SemVer {
2230 major: 0,
2231 minor: 9,
2232 patch: 0
2233 },
2234 &constraint
2235 ));
2236 assert!(!version_satisfies(
2237 &SemVer {
2238 major: 0,
2239 minor: 7,
2240 patch: 0
2241 },
2242 &constraint
2243 ));
2244 }
2245
2246 #[test]
2247 fn test_version_satisfies_range() {
2248 let constraint = PragmaConstraint::Range(
2249 SemVer {
2250 major: 0,
2251 minor: 6,
2252 patch: 2,
2253 },
2254 SemVer {
2255 major: 0,
2256 minor: 9,
2257 patch: 0,
2258 },
2259 );
2260 assert!(version_satisfies(
2261 &SemVer {
2262 major: 0,
2263 minor: 6,
2264 patch: 2
2265 },
2266 &constraint
2267 ));
2268 assert!(version_satisfies(
2269 &SemVer {
2270 major: 0,
2271 minor: 8,
2272 patch: 26
2273 },
2274 &constraint
2275 ));
2276 assert!(!version_satisfies(
2278 &SemVer {
2279 major: 0,
2280 minor: 9,
2281 patch: 0
2282 },
2283 &constraint
2284 ));
2285 assert!(!version_satisfies(
2286 &SemVer {
2287 major: 0,
2288 minor: 6,
2289 patch: 1
2290 },
2291 &constraint
2292 ));
2293 }
2294
2295 #[test]
2296 fn test_find_matching_version() {
2297 let installed = vec![
2298 SemVer {
2299 major: 0,
2300 minor: 8,
2301 patch: 0,
2302 },
2303 SemVer {
2304 major: 0,
2305 minor: 8,
2306 patch: 20,
2307 },
2308 SemVer {
2309 major: 0,
2310 minor: 8,
2311 patch: 26,
2312 },
2313 SemVer {
2314 major: 0,
2315 minor: 8,
2316 patch: 33,
2317 },
2318 ];
2319 let constraint = PragmaConstraint::Caret(SemVer {
2321 major: 0,
2322 minor: 8,
2323 patch: 20,
2324 });
2325 let matched = find_matching_version(&constraint, &installed);
2326 assert_eq!(
2327 matched,
2328 Some(SemVer {
2329 major: 0,
2330 minor: 8,
2331 patch: 33
2332 })
2333 );
2334
2335 let constraint = PragmaConstraint::Exact(SemVer {
2337 major: 0,
2338 minor: 8,
2339 patch: 20,
2340 });
2341 let matched = find_matching_version(&constraint, &installed);
2342 assert_eq!(
2343 matched,
2344 Some(SemVer {
2345 major: 0,
2346 minor: 8,
2347 patch: 20
2348 })
2349 );
2350
2351 let constraint = PragmaConstraint::Exact(SemVer {
2353 major: 0,
2354 minor: 8,
2355 patch: 15,
2356 });
2357 let matched = find_matching_version(&constraint, &installed);
2358 assert_eq!(matched, None);
2359 }
2360
2361 #[test]
2362 fn test_list_installed_versions() {
2363 let versions = list_installed_versions();
2365 for w in versions.windows(2) {
2367 assert!(w[0] <= w[1]);
2368 }
2369 }
2370
2371 #[test]
2376 fn test_extract_version_error_files_basic() {
2377 let output = json!({
2378 "errors": [
2379 {
2380 "errorCode": "5333",
2381 "severity": "error",
2382 "message": "Source file requires different compiler version",
2383 "sourceLocation": {
2384 "file": "lib/openzeppelin/contracts/token/ERC20/ERC20.sol",
2385 "start": 32,
2386 "end": 58
2387 }
2388 },
2389 {
2390 "errorCode": "5333",
2391 "severity": "error",
2392 "message": "Source file requires different compiler version",
2393 "sourceLocation": {
2394 "file": "lib/old-lib/src/Legacy.sol",
2395 "start": 32,
2396 "end": 58
2397 }
2398 },
2399 {
2400 "errorCode": "9574",
2401 "severity": "error",
2402 "message": "Some other error",
2403 "sourceLocation": {
2404 "file": "src/Main.sol",
2405 "start": 100,
2406 "end": 200
2407 }
2408 }
2409 ]
2410 });
2411
2412 let files = extract_version_error_files(&output);
2413 assert_eq!(files.len(), 2);
2414 assert!(files.contains("lib/openzeppelin/contracts/token/ERC20/ERC20.sol"));
2415 assert!(files.contains("lib/old-lib/src/Legacy.sol"));
2416 assert!(!files.contains("src/Main.sol"));
2418 }
2419
2420 #[test]
2421 fn test_extract_version_error_files_empty() {
2422 let output = json!({
2423 "errors": []
2424 });
2425 assert!(extract_version_error_files(&output).is_empty());
2426
2427 let output = json!({});
2429 assert!(extract_version_error_files(&output).is_empty());
2430 }
2431
2432 #[test]
2433 fn test_extract_version_error_files_no_source_location() {
2434 let output = json!({
2435 "errors": [
2436 {
2437 "errorCode": "5333",
2438 "severity": "error",
2439 "message": "Source file requires different compiler version"
2440 }
2442 ]
2443 });
2444 assert!(extract_version_error_files(&output).is_empty());
2445 }
2446
2447 #[test]
2448 fn test_extract_version_error_files_dedup() {
2449 let output = json!({
2450 "errors": [
2451 {
2452 "errorCode": "5333",
2453 "severity": "error",
2454 "sourceLocation": { "file": "lib/same.sol", "start": 0, "end": 10 }
2455 },
2456 {
2457 "errorCode": "5333",
2458 "severity": "error",
2459 "sourceLocation": { "file": "lib/same.sol", "start": 50, "end": 70 }
2460 }
2461 ]
2462 });
2463 let files = extract_version_error_files(&output);
2464 assert_eq!(files.len(), 1);
2465 assert!(files.contains("lib/same.sol"));
2466 }
2467
2468 #[test]
2469 fn test_reverse_import_closure_simple() {
2470 let dir = tempfile::tempdir().unwrap();
2478 let root = dir.path();
2479
2480 std::fs::write(
2481 root.join("a.sol"),
2482 "// SPDX-License-Identifier: MIT\nimport \"./b.sol\";\ncontract A {}",
2483 )
2484 .unwrap();
2485 std::fs::write(
2486 root.join("b.sol"),
2487 "// SPDX-License-Identifier: MIT\nimport \"./c.sol\";\ncontract B {}",
2488 )
2489 .unwrap();
2490 std::fs::write(
2491 root.join("c.sol"),
2492 "// SPDX-License-Identifier: MIT\ncontract C {}",
2493 )
2494 .unwrap();
2495 std::fs::write(
2496 root.join("d.sol"),
2497 "// SPDX-License-Identifier: MIT\ncontract D {}",
2498 )
2499 .unwrap();
2500
2501 let files: Vec<PathBuf> = vec![
2502 root.join("a.sol"),
2503 root.join("b.sol"),
2504 root.join("c.sol"),
2505 root.join("d.sol"),
2506 ];
2507
2508 let exclude: HashSet<PathBuf> = [root.join("c.sol")].into_iter().collect();
2509 let closure = reverse_import_closure(&files, &exclude, root, &[]);
2510
2511 assert!(
2512 closure.contains(&root.join("c.sol")),
2513 "seed file in closure"
2514 );
2515 assert!(closure.contains(&root.join("b.sol")), "direct importer");
2516 assert!(closure.contains(&root.join("a.sol")), "transitive importer");
2517 assert!(
2518 !closure.contains(&root.join("d.sol")),
2519 "unrelated file not in closure"
2520 );
2521 assert_eq!(closure.len(), 3);
2522 }
2523
2524 #[test]
2525 fn test_reverse_import_closure_no_importers() {
2526 let dir = tempfile::tempdir().unwrap();
2528 let root = dir.path();
2529
2530 std::fs::write(root.join("a.sol"), "contract A {}").unwrap();
2531 std::fs::write(root.join("b.sol"), "contract B {}").unwrap();
2532
2533 let files: Vec<PathBuf> = vec![root.join("a.sol"), root.join("b.sol")];
2534 let exclude: HashSet<PathBuf> = [root.join("a.sol")].into_iter().collect();
2535
2536 let closure = reverse_import_closure(&files, &exclude, root, &[]);
2537 assert_eq!(closure.len(), 1);
2538 assert!(closure.contains(&root.join("a.sol")));
2539 }
2540
2541 #[test]
2542 fn test_reverse_import_closure_diamond() {
2543 let dir = tempfile::tempdir().unwrap();
2550 let root = dir.path();
2551
2552 std::fs::write(
2553 root.join("a.sol"),
2554 "import \"./b.sol\";\nimport \"./c.sol\";\ncontract A {}",
2555 )
2556 .unwrap();
2557 std::fs::write(root.join("b.sol"), "import \"./d.sol\";\ncontract B {}").unwrap();
2558 std::fs::write(root.join("c.sol"), "import \"./d.sol\";\ncontract C {}").unwrap();
2559 std::fs::write(root.join("d.sol"), "contract D {}").unwrap();
2560
2561 let files: Vec<PathBuf> = vec![
2562 root.join("a.sol"),
2563 root.join("b.sol"),
2564 root.join("c.sol"),
2565 root.join("d.sol"),
2566 ];
2567 let exclude: HashSet<PathBuf> = [root.join("d.sol")].into_iter().collect();
2568
2569 let closure = reverse_import_closure(&files, &exclude, root, &[]);
2570 assert_eq!(closure.len(), 4);
2571 }
2572
2573 #[test]
2574 fn test_merge_normalized_outputs_basic() {
2575 let mut base = json!({
2576 "sources": {
2577 "/abs/src/A.sol": { "id": 0, "ast": { "nodeType": "SourceUnit" } },
2578 "/abs/src/B.sol": { "id": 1, "ast": { "nodeType": "SourceUnit" } }
2579 },
2580 "contracts": {
2581 "/abs/src/A.sol": { "A": { "abi": [] } }
2582 },
2583 "errors": [],
2584 "source_id_to_path": {
2585 "0": "/abs/src/A.sol",
2586 "1": "/abs/src/B.sol"
2587 }
2588 });
2589
2590 let other = json!({
2591 "sources": {
2592 "/abs/lib/C.sol": { "id": 0, "ast": { "nodeType": "SourceUnit" } }
2593 },
2594 "contracts": {
2595 "/abs/lib/C.sol": { "C": { "abi": [] } }
2596 },
2597 "errors": [],
2598 "source_id_to_path": {
2599 "0": "/abs/lib/C.sol"
2600 }
2601 });
2602
2603 merge_normalized_outputs(&mut base, other);
2604
2605 let sources = base["sources"].as_object().unwrap();
2607 assert_eq!(sources.len(), 3);
2608 assert!(sources.contains_key("/abs/lib/C.sol"));
2609
2610 let c_id = sources["/abs/lib/C.sol"]["id"].as_u64().unwrap();
2612 assert_eq!(
2613 c_id, 2,
2614 "remapped id should be max_base_id (2) + original (0)"
2615 );
2616
2617 let id_map = base["source_id_to_path"].as_object().unwrap();
2619 assert_eq!(id_map.len(), 3);
2620 assert_eq!(id_map["2"].as_str().unwrap(), "/abs/lib/C.sol");
2621
2622 let contracts = base["contracts"].as_object().unwrap();
2624 assert_eq!(contracts.len(), 2);
2625 assert!(contracts.contains_key("/abs/lib/C.sol"));
2626 }
2627
2628 #[test]
2629 fn test_merge_normalized_outputs_empty_other() {
2630 let mut base = json!({
2631 "sources": {
2632 "/abs/src/A.sol": { "id": 0, "ast": {} }
2633 },
2634 "contracts": {},
2635 "errors": [],
2636 "source_id_to_path": { "0": "/abs/src/A.sol" }
2637 });
2638
2639 let other = json!({
2640 "sources": {},
2641 "contracts": {},
2642 "errors": [],
2643 "source_id_to_path": {}
2644 });
2645
2646 merge_normalized_outputs(&mut base, other);
2647
2648 let sources = base["sources"].as_object().unwrap();
2649 assert_eq!(sources.len(), 1);
2650 }
2651
2652 #[test]
2653 fn test_merge_normalized_outputs_empty_base() {
2654 let mut base = json!({
2655 "sources": {},
2656 "contracts": {},
2657 "errors": [],
2658 "source_id_to_path": {}
2659 });
2660
2661 let other = json!({
2662 "sources": {
2663 "/abs/lib/X.sol": { "id": 0, "ast": {} }
2664 },
2665 "contracts": {
2666 "/abs/lib/X.sol": { "X": { "abi": [] } }
2667 },
2668 "errors": [],
2669 "source_id_to_path": { "0": "/abs/lib/X.sol" }
2670 });
2671
2672 merge_normalized_outputs(&mut base, other);
2673
2674 let sources = base["sources"].as_object().unwrap();
2675 assert_eq!(sources.len(), 1);
2676 let x_id = sources["/abs/lib/X.sol"]["id"].as_u64().unwrap();
2678 assert_eq!(x_id, 0);
2679 }
2680}