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 && let Some(abs_path) = node.get("absolutePath").and_then(|v| v.as_str()) {
716 let resolved = resolve(abs_path);
717 node.as_object_mut()
718 .unwrap()
719 .insert("absolutePath".to_string(), json!(resolved));
720 }
721
722 if let Some(nodes) = node.get_mut("nodes").and_then(|v| v.as_array_mut()) {
724 for child in nodes {
725 resolve_import_absolute_paths(child, resolve);
726 }
727 }
728 }
729 let mut result = Map::new();
730
731 let errors = solc_output
733 .get_mut("errors")
734 .map(Value::take)
735 .unwrap_or_else(|| json!([]));
736 result.insert("errors".to_string(), errors);
737
738 let resolve = |p: &str| -> String {
741 if let Some(root) = project_root {
742 let path = Path::new(p);
743 if path.is_relative() {
744 return root.join(path).to_string_lossy().into_owned();
745 }
746 }
747 p.to_string()
748 };
749
750 let mut source_id_to_path = Map::new();
753 let mut resolved_sources = Map::new();
754
755 if let Some(sources) = solc_output
756 .get_mut("sources")
757 .and_then(|s| s.as_object_mut())
758 {
759 let keys: Vec<String> = sources.keys().cloned().collect();
761 for key in keys {
762 if let Some(mut source_data) = sources.remove(&key) {
763 let abs_key = resolve(&key);
764
765 if let Some(ast) = source_data.get_mut("ast") {
769 if let Some(abs_path) = ast.get("absolutePath").and_then(|v| v.as_str()) {
770 let resolved = resolve(abs_path);
771 ast.as_object_mut()
772 .unwrap()
773 .insert("absolutePath".to_string(), json!(resolved));
774 }
775 resolve_import_absolute_paths(ast, &resolve);
776 }
777
778 if let Some(id) = source_data.get("id") {
779 source_id_to_path.insert(id.to_string(), json!(&abs_key));
780 }
781
782 resolved_sources.insert(abs_key, source_data);
783 }
784 }
785 }
786
787 result.insert("sources".to_string(), Value::Object(resolved_sources));
788
789 let mut resolved_contracts = Map::new();
791 if let Some(contracts) = solc_output
792 .get_mut("contracts")
793 .and_then(|c| c.as_object_mut())
794 {
795 let keys: Vec<String> = contracts.keys().cloned().collect();
796 for key in keys {
797 if let Some(contract_data) = contracts.remove(&key) {
798 resolved_contracts.insert(resolve(&key), contract_data);
799 }
800 }
801 }
802 result.insert("contracts".to_string(), Value::Object(resolved_contracts));
803
804 result.insert(
806 "source_id_to_path".to_string(),
807 Value::Object(source_id_to_path),
808 );
809
810 Value::Object(result)
811}
812
813pub fn normalize_forge_output(mut forge_output: Value) -> Value {
825 let mut result = Map::new();
826
827 let errors = forge_output
829 .get_mut("errors")
830 .map(Value::take)
831 .unwrap_or_else(|| json!([]));
832 result.insert("errors".to_string(), errors);
833
834 let mut normalized_sources = Map::new();
836 if let Some(sources) = forge_output
837 .get_mut("sources")
838 .and_then(|s| s.as_object_mut())
839 {
840 for (path, entries) in sources.iter_mut() {
841 if let Some(arr) = entries.as_array_mut()
842 && let Some(first) = arr.first_mut()
843 && let Some(sf) = first.get_mut("source_file")
844 {
845 normalized_sources.insert(path.clone(), sf.take());
846 }
847 }
848 }
849 result.insert("sources".to_string(), Value::Object(normalized_sources));
850
851 let mut normalized_contracts = Map::new();
853 if let Some(contracts) = forge_output
854 .get_mut("contracts")
855 .and_then(|c| c.as_object_mut())
856 {
857 for (path, names) in contracts.iter_mut() {
858 let mut path_contracts = Map::new();
859 if let Some(names_obj) = names.as_object_mut() {
860 for (name, entries) in names_obj.iter_mut() {
861 if let Some(arr) = entries.as_array_mut()
862 && let Some(first) = arr.first_mut()
863 && let Some(contract) = first.get_mut("contract")
864 {
865 path_contracts.insert(name.clone(), contract.take());
866 }
867 }
868 }
869 normalized_contracts.insert(path.clone(), Value::Object(path_contracts));
870 }
871 }
872 result.insert("contracts".to_string(), Value::Object(normalized_contracts));
873
874 let source_id_to_path = forge_output
876 .get_mut("build_infos")
877 .and_then(|bi| bi.as_array_mut())
878 .and_then(|arr| arr.first_mut())
879 .and_then(|info| info.get_mut("source_id_to_path"))
880 .map(Value::take)
881 .unwrap_or_else(|| json!({}));
882 result.insert("source_id_to_path".to_string(), source_id_to_path);
883
884 Value::Object(result)
885}
886
887pub async fn solc_ast(
896 file_path: &str,
897 config: &FoundryConfig,
898 client: Option<&tower_lsp::Client>,
899 source_content: Option<&str>,
900) -> Result<Value, RunnerError> {
901 let remappings = resolve_remappings(config).await;
902
903 let file_abs = Path::new(file_path).to_path_buf();
908 let config_root = config.root.clone();
909 let remappings_clone = remappings.clone();
910 let pragmas = tokio::task::spawn_blocking(move || {
911 collect_import_pragmas(&file_abs, &config_root, &remappings_clone)
912 })
913 .await
914 .unwrap_or_default();
915 let constraint = tightest_constraint(&pragmas);
916 let solc_binary = resolve_solc_binary(config, constraint.as_ref(), client).await;
917
918 let rel_path = Path::new(file_path)
923 .strip_prefix(&config.root)
924 .map(|p| p.to_string_lossy().into_owned())
925 .unwrap_or_else(|_| file_path.to_string());
926
927 let input = build_standard_json_input(&rel_path, &remappings, config, source_content);
928 let raw_output = run_solc(&solc_binary, &input, &config.root).await?;
929
930 Ok(normalize_solc_output(raw_output, Some(&config.root)))
931}
932
933pub async fn solc_build(
935 file_path: &str,
936 config: &FoundryConfig,
937 client: Option<&tower_lsp::Client>,
938) -> Result<Value, RunnerError> {
939 solc_ast(file_path, config, client, None).await
940}
941
942pub fn discover_source_files(config: &FoundryConfig) -> Vec<PathBuf> {
962 discover_source_files_inner(config, false)
963}
964
965pub fn discover_src_only_files(config: &FoundryConfig) -> Vec<PathBuf> {
971 let root = &config.root;
972 if !root.is_dir() {
973 return Vec::new();
974 }
975 let mut files = Vec::new();
976 let dir = root.join(&config.sources_dir);
977 if dir.is_dir() {
978 discover_recursive(&dir, &[], &mut files);
979 }
980 files.sort();
981 files
982}
983
984pub fn discover_src_only_closure(config: &FoundryConfig, remappings: &[String]) -> Vec<PathBuf> {
991 let seeds = discover_src_only_files(config);
992 let mut visited: HashSet<PathBuf> = HashSet::new();
993 let mut queue: std::collections::VecDeque<PathBuf> = seeds.into_iter().collect();
994
995 while let Some(file) = queue.pop_front() {
996 if !visited.insert(file.clone()) {
997 continue;
998 }
999 let source = match std::fs::read_to_string(&file) {
1000 Ok(s) => s,
1001 Err(_) => continue,
1002 };
1003 for imp in links::ts_find_imports(source.as_bytes()) {
1004 if let Some(abs) = resolve_import_to_abs(&config.root, &file, &imp.path, remappings)
1005 && abs.exists()
1006 && !visited.contains(&abs)
1007 {
1008 queue.push_back(abs);
1009 }
1010 }
1011 }
1012
1013 let mut result: Vec<PathBuf> = visited.into_iter().collect();
1014 result.sort();
1015 result
1016}
1017
1018pub fn discover_source_files_with_libs(config: &FoundryConfig) -> Vec<PathBuf> {
1025 discover_source_files_inner(config, true)
1026}
1027
1028fn discover_source_files_inner(config: &FoundryConfig, include_libs: bool) -> Vec<PathBuf> {
1029 let root = &config.root;
1030 if !root.is_dir() {
1031 return Vec::new();
1032 }
1033
1034 let mut files = Vec::new();
1035 let no_skip: &[String] = &[];
1036
1037 for dir_name in [&config.sources_dir, &config.test_dir, &config.script_dir] {
1042 let dir = root.join(dir_name);
1043 if dir.is_dir() {
1044 discover_recursive(&dir, no_skip, &mut files);
1045 }
1046 }
1047
1048 if include_libs {
1050 for lib_name in &config.libs {
1051 let lib_dir = root.join(lib_name);
1052 if lib_dir.is_dir() {
1053 discover_recursive(&lib_dir, no_skip, &mut files);
1054 }
1055 }
1056 }
1057
1058 files.sort();
1059 files
1060}
1061
1062pub fn discover_compilation_closure(config: &FoundryConfig, remappings: &[String]) -> Vec<PathBuf> {
1081 let seeds = discover_source_files(config);
1083 let mut visited: HashSet<PathBuf> = HashSet::new();
1084 let mut queue: std::collections::VecDeque<PathBuf> = seeds.into_iter().collect();
1085
1086 while let Some(file) = queue.pop_front() {
1087 if !visited.insert(file.clone()) {
1088 continue;
1089 }
1090 let source = match std::fs::read_to_string(&file) {
1091 Ok(s) => s,
1092 Err(_) => continue,
1093 };
1094 for imp in links::ts_find_imports(source.as_bytes()) {
1095 if let Some(abs) = resolve_import_to_abs(&config.root, &file, &imp.path, remappings)
1096 && abs.exists()
1097 && !visited.contains(&abs)
1098 {
1099 queue.push_back(abs);
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 && let Some(c) = client
1419 {
1420 c.log_message(
1421 tower_lsp::lsp_types::MessageType::INFO,
1422 format!(
1423 "project index: {} compatible, {} incompatible with solc {}",
1424 compatible_files.len(),
1425 incompatible_files.len(),
1426 project_version
1427 .as_ref()
1428 .map(|v| v.to_string())
1429 .unwrap_or_default(),
1430 ),
1431 )
1432 .await;
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 = build_batch_standard_json_input_ast_only(
1455 std::slice::from_ref(file),
1456 &remappings,
1457 &config.root,
1458 );
1459 if let Ok(raw) = run_solc(&file_binary, &input, &config.root).await {
1460 let normalized = normalize_solc_output(raw, Some(&config.root));
1461 merge_normalized_outputs(&mut result, normalized);
1462 }
1463 }
1464
1465 if let Some(c) = client {
1466 let total = result
1467 .get("sources")
1468 .and_then(|s| s.as_object())
1469 .map_or(0, |obj| obj.len());
1470 c.log_message(
1471 tower_lsp::lsp_types::MessageType::INFO,
1472 format!(
1473 "project index: compiled {} files ({} needed different solc version)",
1474 total,
1475 incompatible_files.len(),
1476 ),
1477 )
1478 .await;
1479 }
1480
1481 Ok(result)
1482}
1483
1484pub async fn solc_project_index_scoped(
1489 config: &FoundryConfig,
1490 client: Option<&tower_lsp::Client>,
1491 text_cache: Option<&HashMap<crate::types::DocumentUri, (i32, String)>>,
1492 source_files: &[PathBuf],
1493) -> Result<Value, RunnerError> {
1494 if source_files.is_empty() {
1495 return Err(RunnerError::CommandError(std::io::Error::other(
1496 "no source files provided for scoped project index",
1497 )));
1498 }
1499
1500 solc_project_index_from_files(config, client, text_cache, source_files).await
1501}
1502
1503#[cfg(test)]
1507fn extract_version_error_files(solc_output: &Value) -> HashSet<String> {
1508 let mut files = HashSet::new();
1509 if let Some(errors) = solc_output.get("errors").and_then(|e| e.as_array()) {
1510 for err in errors {
1511 let is_5333 = err.get("errorCode").and_then(|c| c.as_str()) == Some("5333");
1512 if is_5333
1513 && let Some(file) = err
1514 .get("sourceLocation")
1515 .and_then(|sl| sl.get("file"))
1516 .and_then(|f| f.as_str())
1517 {
1518 files.insert(file.to_string());
1519 }
1520 }
1521 }
1522 files
1523}
1524
1525#[cfg(test)]
1528#[allow(dead_code)]
1529fn extract_import_error_files(solc_output: &Value) -> HashSet<String> {
1530 let mut files = HashSet::new();
1531 if let Some(errors) = solc_output.get("errors").and_then(|e| e.as_array()) {
1532 for err in errors {
1533 let is_6275 = err.get("errorCode").and_then(|c| c.as_str()) == Some("6275");
1534 if is_6275
1535 && let Some(file) = err
1536 .get("sourceLocation")
1537 .and_then(|sl| sl.get("file"))
1538 .and_then(|f| f.as_str())
1539 {
1540 files.insert(file.to_string());
1541 }
1542 }
1543 }
1544 files
1545}
1546
1547#[cfg(test)]
1553fn reverse_import_closure(
1554 source_files: &[PathBuf],
1555 exclude_abs: &HashSet<PathBuf>,
1556 project_root: &Path,
1557 remappings: &[String],
1558) -> HashSet<PathBuf> {
1559 let mut reverse_edges: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
1562
1563 for file in source_files {
1564 let Ok(bytes) = std::fs::read(file) else {
1565 continue;
1566 };
1567 for imp in links::ts_find_imports(&bytes) {
1568 if let Some(imported_abs) =
1569 resolve_import_to_abs(project_root, file, &imp.path, remappings)
1570 {
1571 reverse_edges
1572 .entry(imported_abs)
1573 .or_default()
1574 .insert(file.clone());
1575 }
1576 }
1577 }
1578
1579 let mut closure: HashSet<PathBuf> = exclude_abs.clone();
1581 let mut queue: std::collections::VecDeque<PathBuf> = exclude_abs.iter().cloned().collect();
1582
1583 while let Some(current) = queue.pop_front() {
1584 if let Some(importers) = reverse_edges.get(¤t) {
1585 for importer in importers {
1586 if closure.insert(importer.clone()) {
1587 queue.push_back(importer.clone());
1588 }
1589 }
1590 }
1591 }
1592
1593 closure
1594}
1595
1596fn merge_normalized_outputs(base: &mut Value, other: Value) {
1602 if let (Some(base_sources), Some(other_sources)) = (
1604 base.get_mut("sources").and_then(|s| s.as_object_mut()),
1605 other.get("sources").and_then(|s| s.as_object()),
1606 ) {
1607 let max_base_id = base_sources
1609 .values()
1610 .filter_map(|v| v.get("id").and_then(|id| id.as_u64()))
1611 .max()
1612 .map(|m| m + 1)
1613 .unwrap_or(0);
1614
1615 let mut remapped_id_to_path: Vec<(String, String)> = Vec::new();
1617
1618 for (path, mut source_data) in other_sources.clone() {
1619 if let Some(id) = source_data.get("id").and_then(|id| id.as_u64()) {
1621 let new_id = id + max_base_id;
1622 source_data
1623 .as_object_mut()
1624 .unwrap()
1625 .insert("id".to_string(), json!(new_id));
1626 remapped_id_to_path.push((new_id.to_string(), path.clone()));
1627 }
1628 base_sources.insert(path, source_data);
1629 }
1630
1631 if let Some(base_id_map) = base
1633 .get_mut("source_id_to_path")
1634 .and_then(|m| m.as_object_mut())
1635 {
1636 for (id, path) in remapped_id_to_path {
1637 base_id_map.insert(id, json!(path));
1638 }
1639 }
1640 }
1641
1642 if let (Some(base_contracts), Some(other_contracts)) = (
1644 base.get_mut("contracts").and_then(|c| c.as_object_mut()),
1645 other.get("contracts").and_then(|c| c.as_object()),
1646 ) {
1647 for (path, contract_data) in other_contracts {
1648 base_contracts.insert(path.clone(), contract_data.clone());
1649 }
1650 }
1651
1652 }
1655
1656async fn solc_project_index_from_files(
1657 config: &FoundryConfig,
1658 client: Option<&tower_lsp::Client>,
1659 text_cache: Option<&HashMap<crate::types::DocumentUri, (i32, String)>>,
1660 source_files: &[PathBuf],
1661) -> Result<Value, RunnerError> {
1662 if source_files.is_empty() {
1663 return Err(RunnerError::CommandError(std::io::Error::other(
1664 "no source files found for project index",
1665 )));
1666 }
1667
1668 let remappings = resolve_remappings(config).await;
1669
1670 let project_version: Option<SemVer> =
1672 config.solc_version.as_ref().and_then(|v| SemVer::parse(v));
1673
1674 let constraint: Option<PragmaConstraint> = if let Some(ref v) = project_version {
1677 Some(PragmaConstraint::Exact(v.clone()))
1678 } else {
1679 source_files.iter().find_map(|f| {
1680 std::fs::read_to_string(f)
1681 .ok()
1682 .and_then(|src| parse_pragma(&src))
1683 })
1684 };
1685 let solc_binary = resolve_solc_binary(config, constraint.as_ref(), client).await;
1686
1687 let (compatible_files, incompatible_files) = if let Some(ref ver) = project_version {
1693 let mut compat = Vec::with_capacity(source_files.len());
1694 let mut incompat = Vec::new();
1695 for file in source_files {
1696 let is_compatible = std::fs::read_to_string(file)
1697 .ok()
1698 .and_then(|src| parse_pragma(&src))
1699 .map(|pragma| version_satisfies(ver, &pragma))
1700 .unwrap_or(true);
1702 if is_compatible {
1703 compat.push(file.clone());
1704 } else {
1705 incompat.push(file.clone());
1706 }
1707 }
1708 (compat, incompat)
1709 } else {
1710 (source_files.to_vec(), Vec::new())
1712 };
1713
1714 if !incompatible_files.is_empty()
1715 && let Some(c) = client
1716 {
1717 c.log_message(
1718 tower_lsp::lsp_types::MessageType::INFO,
1719 format!(
1720 "project index: {} compatible, {} incompatible with solc {}",
1721 compatible_files.len(),
1722 incompatible_files.len(),
1723 project_version
1724 .as_ref()
1725 .map(|v| v.to_string())
1726 .unwrap_or_default(),
1727 ),
1728 )
1729 .await;
1730 }
1731
1732 let mut result = if compatible_files.is_empty() {
1740 json!({"sources": {}, "contracts": {}, "errors": [], "source_id_to_path": {}})
1741 } else {
1742 let input = build_batch_standard_json_input_with_cache(
1743 &compatible_files,
1744 &remappings,
1745 config,
1746 text_cache,
1747 );
1748 let raw = run_solc(&solc_binary, &input, &config.root).await?;
1749 normalize_solc_output(raw, Some(&config.root))
1750 };
1751
1752 let batch_source_count = result
1753 .get("sources")
1754 .and_then(|s| s.as_object())
1755 .map_or(0, |obj| obj.len());
1756
1757 if incompatible_files.is_empty() {
1758 return Ok(result);
1759 }
1760
1761 if let Some(c) = client {
1762 let batch_errors: Vec<String> = result
1764 .get("errors")
1765 .and_then(|e| e.as_array())
1766 .map(|arr| {
1767 arr.iter()
1768 .filter(|e| e.get("severity").and_then(|s| s.as_str()) == Some("error"))
1769 .take(3)
1770 .map(|e| {
1771 let msg = e.get("message").and_then(|m| m.as_str()).unwrap_or("?");
1772 let file = e
1773 .get("sourceLocation")
1774 .and_then(|sl| sl.get("file"))
1775 .and_then(|f| f.as_str())
1776 .unwrap_or("?");
1777 format!("{file}: {msg}")
1778 })
1779 .collect()
1780 })
1781 .unwrap_or_default();
1782
1783 c.log_message(
1784 tower_lsp::lsp_types::MessageType::INFO,
1785 format!(
1786 "project index: batch produced {} sources, now compiling {} incompatible files individually{}",
1787 batch_source_count,
1788 incompatible_files.len(),
1789 if batch_errors.is_empty() {
1790 String::new()
1791 } else {
1792 format!(" [first errors: {}]", batch_errors.join("; "))
1793 },
1794 ),
1795 )
1796 .await;
1797 }
1798
1799 let mut compiled = 0usize;
1801 let mut skipped = 0usize;
1802 for file in &incompatible_files {
1803 let pragma = std::fs::read_to_string(file)
1804 .ok()
1805 .and_then(|src| parse_pragma(&src));
1806
1807 let Some(file_constraint) = pragma else {
1808 skipped += 1;
1809 continue;
1810 };
1811
1812 let file_binary = resolve_solc_binary(config, Some(&file_constraint), client).await;
1813 let input = build_batch_standard_json_input_with_cache(
1814 std::slice::from_ref(file),
1815 &remappings,
1816 config,
1817 text_cache,
1818 );
1819 match run_solc(&file_binary, &input, &config.root).await {
1820 Ok(raw) => {
1821 let normalized = normalize_solc_output(raw, Some(&config.root));
1822 merge_normalized_outputs(&mut result, normalized);
1823 compiled += 1;
1824 }
1825 Err(e) => {
1826 if let Some(c) = client {
1827 c.log_message(
1828 tower_lsp::lsp_types::MessageType::WARNING,
1829 format!(
1830 "project index: incompatible file {} failed: {e}",
1831 file.display(),
1832 ),
1833 )
1834 .await;
1835 }
1836 skipped += 1;
1837 }
1838 }
1839 }
1840
1841 if let Some(c) = client {
1842 c.log_message(
1843 tower_lsp::lsp_types::MessageType::INFO,
1844 format!(
1845 "project index: incompatible files done — {compiled} compiled, {skipped} skipped",
1846 ),
1847 )
1848 .await;
1849 }
1850
1851 Ok(result)
1852}
1853
1854#[cfg(test)]
1855mod tests {
1856 use super::*;
1857
1858 #[test]
1859 fn test_normalize_solc_sources() {
1860 let solc_output = json!({
1861 "sources": {
1862 "src/Foo.sol": {
1863 "id": 0,
1864 "ast": {
1865 "nodeType": "SourceUnit",
1866 "absolutePath": "src/Foo.sol",
1867 "id": 100
1868 }
1869 },
1870 "src/Bar.sol": {
1871 "id": 1,
1872 "ast": {
1873 "nodeType": "SourceUnit",
1874 "absolutePath": "src/Bar.sol",
1875 "id": 200
1876 }
1877 }
1878 },
1879 "contracts": {},
1880 "errors": []
1881 });
1882
1883 let normalized = normalize_solc_output(solc_output, None);
1884
1885 let sources = normalized.get("sources").unwrap().as_object().unwrap();
1887 assert_eq!(sources.len(), 2);
1888
1889 let foo = sources.get("src/Foo.sol").unwrap();
1890 assert_eq!(foo.get("id").unwrap(), 0);
1891 assert_eq!(
1892 foo.get("ast")
1893 .unwrap()
1894 .get("nodeType")
1895 .unwrap()
1896 .as_str()
1897 .unwrap(),
1898 "SourceUnit"
1899 );
1900
1901 let id_to_path = normalized
1903 .get("source_id_to_path")
1904 .unwrap()
1905 .as_object()
1906 .unwrap();
1907 assert_eq!(id_to_path.len(), 2);
1908 }
1909
1910 #[test]
1911 fn test_normalize_solc_contracts() {
1912 let solc_output = json!({
1913 "sources": {},
1914 "contracts": {
1915 "src/Foo.sol": {
1916 "Foo": {
1917 "abi": [{"type": "function", "name": "bar"}],
1918 "evm": {
1919 "methodIdentifiers": {
1920 "bar(uint256)": "abcd1234"
1921 }
1922 }
1923 }
1924 }
1925 },
1926 "errors": []
1927 });
1928
1929 let normalized = normalize_solc_output(solc_output, None);
1930
1931 let contracts = normalized.get("contracts").unwrap().as_object().unwrap();
1933 let foo_contracts = contracts.get("src/Foo.sol").unwrap().as_object().unwrap();
1934 let foo = foo_contracts.get("Foo").unwrap();
1935
1936 let method_ids = foo
1937 .get("evm")
1938 .unwrap()
1939 .get("methodIdentifiers")
1940 .unwrap()
1941 .as_object()
1942 .unwrap();
1943 assert_eq!(
1944 method_ids.get("bar(uint256)").unwrap().as_str().unwrap(),
1945 "abcd1234"
1946 );
1947 }
1948
1949 #[test]
1950 fn test_normalize_solc_errors_passthrough() {
1951 let solc_output = json!({
1952 "sources": {},
1953 "contracts": {},
1954 "errors": [{
1955 "sourceLocation": {"file": "src/Foo.sol", "start": 0, "end": 10},
1956 "type": "Warning",
1957 "component": "general",
1958 "severity": "warning",
1959 "errorCode": "2394",
1960 "message": "test warning",
1961 "formattedMessage": "Warning: test warning"
1962 }]
1963 });
1964
1965 let normalized = normalize_solc_output(solc_output, None);
1966
1967 let errors = normalized.get("errors").unwrap().as_array().unwrap();
1968 assert_eq!(errors.len(), 1);
1969 assert_eq!(
1970 errors[0].get("errorCode").unwrap().as_str().unwrap(),
1971 "2394"
1972 );
1973 }
1974
1975 #[test]
1976 fn test_normalize_empty_solc_output() {
1977 let solc_output = json!({
1978 "sources": {},
1979 "contracts": {}
1980 });
1981
1982 let normalized = normalize_solc_output(solc_output, None);
1983
1984 assert!(
1985 normalized
1986 .get("sources")
1987 .unwrap()
1988 .as_object()
1989 .unwrap()
1990 .is_empty()
1991 );
1992 assert!(
1993 normalized
1994 .get("contracts")
1995 .unwrap()
1996 .as_object()
1997 .unwrap()
1998 .is_empty()
1999 );
2000 assert_eq!(
2001 normalized.get("errors").unwrap().as_array().unwrap().len(),
2002 0
2003 );
2004 assert!(
2005 normalized
2006 .get("source_id_to_path")
2007 .unwrap()
2008 .as_object()
2009 .unwrap()
2010 .is_empty()
2011 );
2012 }
2013
2014 #[test]
2015 fn test_build_standard_json_input() {
2016 let config = FoundryConfig::default();
2017 let input = build_standard_json_input(
2018 "/path/to/Foo.sol",
2019 &[
2020 "ds-test/=lib/forge-std/lib/ds-test/src/".to_string(),
2021 "forge-std/=lib/forge-std/src/".to_string(),
2022 ],
2023 &config,
2024 None,
2025 );
2026
2027 let sources = input.get("sources").unwrap().as_object().unwrap();
2028 assert!(sources.contains_key("/path/to/Foo.sol"));
2029
2030 let settings = input.get("settings").unwrap();
2031 let remappings = settings.get("remappings").unwrap().as_array().unwrap();
2032 assert_eq!(remappings.len(), 2);
2033
2034 let output_sel = settings.get("outputSelection").unwrap();
2035 assert!(output_sel.get("*").is_some());
2036
2037 assert!(settings.get("optimizer").is_none());
2039 assert!(settings.get("viaIR").is_none());
2040 assert!(settings.get("evmVersion").is_none());
2041
2042 let outputs = settings["outputSelection"]["*"]["*"].as_array().unwrap();
2044 let output_names: Vec<&str> = outputs.iter().map(|v| v.as_str().unwrap()).collect();
2045 assert!(!output_names.contains(&"evm.gasEstimates"));
2046 assert!(!output_names.contains(&"abi")); assert!(output_names.contains(&"devdoc"));
2048 assert!(output_names.contains(&"userdoc"));
2049 assert!(output_names.contains(&"evm.methodIdentifiers"));
2050 }
2051
2052 #[test]
2053 fn test_build_standard_json_input_with_config() {
2054 let config = FoundryConfig {
2055 optimizer: true,
2056 optimizer_runs: 9999999,
2057 via_ir: true,
2058 evm_version: Some("osaka".to_string()),
2059 ..Default::default()
2060 };
2061 let input = build_standard_json_input("/path/to/Foo.sol", &[], &config, None);
2062
2063 let settings = input.get("settings").unwrap();
2064
2065 assert!(settings.get("optimizer").is_none());
2067
2068 assert!(settings.get("viaIR").unwrap().as_bool().unwrap());
2070
2071 let outputs = settings["outputSelection"]["*"]["*"].as_array().unwrap();
2073 let output_names: Vec<&str> = outputs.iter().map(|v| v.as_str().unwrap()).collect();
2074 assert!(!output_names.contains(&"evm.gasEstimates"));
2075
2076 assert_eq!(
2078 settings.get("evmVersion").unwrap().as_str().unwrap(),
2079 "osaka"
2080 );
2081 }
2082
2083 #[tokio::test]
2084 async fn test_resolve_solc_binary_default() {
2085 let config = FoundryConfig::default();
2086 let binary = resolve_solc_binary(&config, None, None).await;
2087 assert_eq!(binary, PathBuf::from("solc"));
2088 }
2089
2090 #[test]
2091 fn test_parse_pragma_exact() {
2092 let source = "// SPDX\npragma solidity 0.8.26;\n";
2093 assert_eq!(
2094 parse_pragma(source),
2095 Some(PragmaConstraint::Exact(SemVer {
2096 major: 0,
2097 minor: 8,
2098 patch: 26
2099 }))
2100 );
2101 }
2102
2103 #[test]
2104 fn test_parse_pragma_caret() {
2105 let source = "pragma solidity ^0.8.0;\n";
2106 assert_eq!(
2107 parse_pragma(source),
2108 Some(PragmaConstraint::Caret(SemVer {
2109 major: 0,
2110 minor: 8,
2111 patch: 0
2112 }))
2113 );
2114 }
2115
2116 #[test]
2117 fn test_parse_pragma_gte() {
2118 let source = "pragma solidity >=0.8.0;\n";
2119 assert_eq!(
2120 parse_pragma(source),
2121 Some(PragmaConstraint::Gte(SemVer {
2122 major: 0,
2123 minor: 8,
2124 patch: 0
2125 }))
2126 );
2127 }
2128
2129 #[test]
2130 fn test_parse_pragma_range() {
2131 let source = "pragma solidity >=0.6.2 <0.9.0;\n";
2132 assert_eq!(
2133 parse_pragma(source),
2134 Some(PragmaConstraint::Range(
2135 SemVer {
2136 major: 0,
2137 minor: 6,
2138 patch: 2
2139 },
2140 SemVer {
2141 major: 0,
2142 minor: 9,
2143 patch: 0
2144 },
2145 ))
2146 );
2147 }
2148
2149 #[test]
2150 fn test_parse_pragma_none() {
2151 let source = "contract Foo {}\n";
2152 assert_eq!(parse_pragma(source), None);
2153 }
2154
2155 #[test]
2156 fn test_version_satisfies_exact() {
2157 let v = SemVer {
2158 major: 0,
2159 minor: 8,
2160 patch: 26,
2161 };
2162 assert!(version_satisfies(&v, &PragmaConstraint::Exact(v.clone())));
2163 assert!(!version_satisfies(
2164 &SemVer {
2165 major: 0,
2166 minor: 8,
2167 patch: 25
2168 },
2169 &PragmaConstraint::Exact(v)
2170 ));
2171 }
2172
2173 #[test]
2174 fn test_version_satisfies_caret() {
2175 let constraint = PragmaConstraint::Caret(SemVer {
2176 major: 0,
2177 minor: 8,
2178 patch: 0,
2179 });
2180 assert!(version_satisfies(
2181 &SemVer {
2182 major: 0,
2183 minor: 8,
2184 patch: 0
2185 },
2186 &constraint
2187 ));
2188 assert!(version_satisfies(
2189 &SemVer {
2190 major: 0,
2191 minor: 8,
2192 patch: 26
2193 },
2194 &constraint
2195 ));
2196 assert!(!version_satisfies(
2198 &SemVer {
2199 major: 0,
2200 minor: 9,
2201 patch: 0
2202 },
2203 &constraint
2204 ));
2205 assert!(!version_satisfies(
2207 &SemVer {
2208 major: 0,
2209 minor: 7,
2210 patch: 0
2211 },
2212 &constraint
2213 ));
2214 }
2215
2216 #[test]
2217 fn test_version_satisfies_gte() {
2218 let constraint = PragmaConstraint::Gte(SemVer {
2219 major: 0,
2220 minor: 8,
2221 patch: 0,
2222 });
2223 assert!(version_satisfies(
2224 &SemVer {
2225 major: 0,
2226 minor: 8,
2227 patch: 0
2228 },
2229 &constraint
2230 ));
2231 assert!(version_satisfies(
2232 &SemVer {
2233 major: 0,
2234 minor: 9,
2235 patch: 0
2236 },
2237 &constraint
2238 ));
2239 assert!(!version_satisfies(
2240 &SemVer {
2241 major: 0,
2242 minor: 7,
2243 patch: 0
2244 },
2245 &constraint
2246 ));
2247 }
2248
2249 #[test]
2250 fn test_version_satisfies_range() {
2251 let constraint = PragmaConstraint::Range(
2252 SemVer {
2253 major: 0,
2254 minor: 6,
2255 patch: 2,
2256 },
2257 SemVer {
2258 major: 0,
2259 minor: 9,
2260 patch: 0,
2261 },
2262 );
2263 assert!(version_satisfies(
2264 &SemVer {
2265 major: 0,
2266 minor: 6,
2267 patch: 2
2268 },
2269 &constraint
2270 ));
2271 assert!(version_satisfies(
2272 &SemVer {
2273 major: 0,
2274 minor: 8,
2275 patch: 26
2276 },
2277 &constraint
2278 ));
2279 assert!(!version_satisfies(
2281 &SemVer {
2282 major: 0,
2283 minor: 9,
2284 patch: 0
2285 },
2286 &constraint
2287 ));
2288 assert!(!version_satisfies(
2289 &SemVer {
2290 major: 0,
2291 minor: 6,
2292 patch: 1
2293 },
2294 &constraint
2295 ));
2296 }
2297
2298 #[test]
2299 fn test_find_matching_version() {
2300 let installed = vec![
2301 SemVer {
2302 major: 0,
2303 minor: 8,
2304 patch: 0,
2305 },
2306 SemVer {
2307 major: 0,
2308 minor: 8,
2309 patch: 20,
2310 },
2311 SemVer {
2312 major: 0,
2313 minor: 8,
2314 patch: 26,
2315 },
2316 SemVer {
2317 major: 0,
2318 minor: 8,
2319 patch: 33,
2320 },
2321 ];
2322 let constraint = PragmaConstraint::Caret(SemVer {
2324 major: 0,
2325 minor: 8,
2326 patch: 20,
2327 });
2328 let matched = find_matching_version(&constraint, &installed);
2329 assert_eq!(
2330 matched,
2331 Some(SemVer {
2332 major: 0,
2333 minor: 8,
2334 patch: 33
2335 })
2336 );
2337
2338 let constraint = PragmaConstraint::Exact(SemVer {
2340 major: 0,
2341 minor: 8,
2342 patch: 20,
2343 });
2344 let matched = find_matching_version(&constraint, &installed);
2345 assert_eq!(
2346 matched,
2347 Some(SemVer {
2348 major: 0,
2349 minor: 8,
2350 patch: 20
2351 })
2352 );
2353
2354 let constraint = PragmaConstraint::Exact(SemVer {
2356 major: 0,
2357 minor: 8,
2358 patch: 15,
2359 });
2360 let matched = find_matching_version(&constraint, &installed);
2361 assert_eq!(matched, None);
2362 }
2363
2364 #[test]
2365 fn test_list_installed_versions() {
2366 let versions = list_installed_versions();
2368 for w in versions.windows(2) {
2370 assert!(w[0] <= w[1]);
2371 }
2372 }
2373
2374 #[test]
2379 fn test_extract_version_error_files_basic() {
2380 let output = json!({
2381 "errors": [
2382 {
2383 "errorCode": "5333",
2384 "severity": "error",
2385 "message": "Source file requires different compiler version",
2386 "sourceLocation": {
2387 "file": "lib/openzeppelin/contracts/token/ERC20/ERC20.sol",
2388 "start": 32,
2389 "end": 58
2390 }
2391 },
2392 {
2393 "errorCode": "5333",
2394 "severity": "error",
2395 "message": "Source file requires different compiler version",
2396 "sourceLocation": {
2397 "file": "lib/old-lib/src/Legacy.sol",
2398 "start": 32,
2399 "end": 58
2400 }
2401 },
2402 {
2403 "errorCode": "9574",
2404 "severity": "error",
2405 "message": "Some other error",
2406 "sourceLocation": {
2407 "file": "src/Main.sol",
2408 "start": 100,
2409 "end": 200
2410 }
2411 }
2412 ]
2413 });
2414
2415 let files = extract_version_error_files(&output);
2416 assert_eq!(files.len(), 2);
2417 assert!(files.contains("lib/openzeppelin/contracts/token/ERC20/ERC20.sol"));
2418 assert!(files.contains("lib/old-lib/src/Legacy.sol"));
2419 assert!(!files.contains("src/Main.sol"));
2421 }
2422
2423 #[test]
2424 fn test_extract_version_error_files_empty() {
2425 let output = json!({
2426 "errors": []
2427 });
2428 assert!(extract_version_error_files(&output).is_empty());
2429
2430 let output = json!({});
2432 assert!(extract_version_error_files(&output).is_empty());
2433 }
2434
2435 #[test]
2436 fn test_extract_version_error_files_no_source_location() {
2437 let output = json!({
2438 "errors": [
2439 {
2440 "errorCode": "5333",
2441 "severity": "error",
2442 "message": "Source file requires different compiler version"
2443 }
2445 ]
2446 });
2447 assert!(extract_version_error_files(&output).is_empty());
2448 }
2449
2450 #[test]
2451 fn test_extract_version_error_files_dedup() {
2452 let output = json!({
2453 "errors": [
2454 {
2455 "errorCode": "5333",
2456 "severity": "error",
2457 "sourceLocation": { "file": "lib/same.sol", "start": 0, "end": 10 }
2458 },
2459 {
2460 "errorCode": "5333",
2461 "severity": "error",
2462 "sourceLocation": { "file": "lib/same.sol", "start": 50, "end": 70 }
2463 }
2464 ]
2465 });
2466 let files = extract_version_error_files(&output);
2467 assert_eq!(files.len(), 1);
2468 assert!(files.contains("lib/same.sol"));
2469 }
2470
2471 #[test]
2472 fn test_reverse_import_closure_simple() {
2473 let dir = tempfile::tempdir().unwrap();
2481 let root = dir.path();
2482
2483 std::fs::write(
2484 root.join("a.sol"),
2485 "// SPDX-License-Identifier: MIT\nimport \"./b.sol\";\ncontract A {}",
2486 )
2487 .unwrap();
2488 std::fs::write(
2489 root.join("b.sol"),
2490 "// SPDX-License-Identifier: MIT\nimport \"./c.sol\";\ncontract B {}",
2491 )
2492 .unwrap();
2493 std::fs::write(
2494 root.join("c.sol"),
2495 "// SPDX-License-Identifier: MIT\ncontract C {}",
2496 )
2497 .unwrap();
2498 std::fs::write(
2499 root.join("d.sol"),
2500 "// SPDX-License-Identifier: MIT\ncontract D {}",
2501 )
2502 .unwrap();
2503
2504 let files: Vec<PathBuf> = vec![
2505 root.join("a.sol"),
2506 root.join("b.sol"),
2507 root.join("c.sol"),
2508 root.join("d.sol"),
2509 ];
2510
2511 let exclude: HashSet<PathBuf> = [root.join("c.sol")].into_iter().collect();
2512 let closure = reverse_import_closure(&files, &exclude, root, &[]);
2513
2514 assert!(
2515 closure.contains(&root.join("c.sol")),
2516 "seed file in closure"
2517 );
2518 assert!(closure.contains(&root.join("b.sol")), "direct importer");
2519 assert!(closure.contains(&root.join("a.sol")), "transitive importer");
2520 assert!(
2521 !closure.contains(&root.join("d.sol")),
2522 "unrelated file not in closure"
2523 );
2524 assert_eq!(closure.len(), 3);
2525 }
2526
2527 #[test]
2528 fn test_reverse_import_closure_no_importers() {
2529 let dir = tempfile::tempdir().unwrap();
2531 let root = dir.path();
2532
2533 std::fs::write(root.join("a.sol"), "contract A {}").unwrap();
2534 std::fs::write(root.join("b.sol"), "contract B {}").unwrap();
2535
2536 let files: Vec<PathBuf> = vec![root.join("a.sol"), root.join("b.sol")];
2537 let exclude: HashSet<PathBuf> = [root.join("a.sol")].into_iter().collect();
2538
2539 let closure = reverse_import_closure(&files, &exclude, root, &[]);
2540 assert_eq!(closure.len(), 1);
2541 assert!(closure.contains(&root.join("a.sol")));
2542 }
2543
2544 #[test]
2545 fn test_reverse_import_closure_diamond() {
2546 let dir = tempfile::tempdir().unwrap();
2553 let root = dir.path();
2554
2555 std::fs::write(
2556 root.join("a.sol"),
2557 "import \"./b.sol\";\nimport \"./c.sol\";\ncontract A {}",
2558 )
2559 .unwrap();
2560 std::fs::write(root.join("b.sol"), "import \"./d.sol\";\ncontract B {}").unwrap();
2561 std::fs::write(root.join("c.sol"), "import \"./d.sol\";\ncontract C {}").unwrap();
2562 std::fs::write(root.join("d.sol"), "contract D {}").unwrap();
2563
2564 let files: Vec<PathBuf> = vec![
2565 root.join("a.sol"),
2566 root.join("b.sol"),
2567 root.join("c.sol"),
2568 root.join("d.sol"),
2569 ];
2570 let exclude: HashSet<PathBuf> = [root.join("d.sol")].into_iter().collect();
2571
2572 let closure = reverse_import_closure(&files, &exclude, root, &[]);
2573 assert_eq!(closure.len(), 4);
2574 }
2575
2576 #[test]
2577 fn test_merge_normalized_outputs_basic() {
2578 let mut base = json!({
2579 "sources": {
2580 "/abs/src/A.sol": { "id": 0, "ast": { "nodeType": "SourceUnit" } },
2581 "/abs/src/B.sol": { "id": 1, "ast": { "nodeType": "SourceUnit" } }
2582 },
2583 "contracts": {
2584 "/abs/src/A.sol": { "A": { "abi": [] } }
2585 },
2586 "errors": [],
2587 "source_id_to_path": {
2588 "0": "/abs/src/A.sol",
2589 "1": "/abs/src/B.sol"
2590 }
2591 });
2592
2593 let other = json!({
2594 "sources": {
2595 "/abs/lib/C.sol": { "id": 0, "ast": { "nodeType": "SourceUnit" } }
2596 },
2597 "contracts": {
2598 "/abs/lib/C.sol": { "C": { "abi": [] } }
2599 },
2600 "errors": [],
2601 "source_id_to_path": {
2602 "0": "/abs/lib/C.sol"
2603 }
2604 });
2605
2606 merge_normalized_outputs(&mut base, other);
2607
2608 let sources = base["sources"].as_object().unwrap();
2610 assert_eq!(sources.len(), 3);
2611 assert!(sources.contains_key("/abs/lib/C.sol"));
2612
2613 let c_id = sources["/abs/lib/C.sol"]["id"].as_u64().unwrap();
2615 assert_eq!(
2616 c_id, 2,
2617 "remapped id should be max_base_id (2) + original (0)"
2618 );
2619
2620 let id_map = base["source_id_to_path"].as_object().unwrap();
2622 assert_eq!(id_map.len(), 3);
2623 assert_eq!(id_map["2"].as_str().unwrap(), "/abs/lib/C.sol");
2624
2625 let contracts = base["contracts"].as_object().unwrap();
2627 assert_eq!(contracts.len(), 2);
2628 assert!(contracts.contains_key("/abs/lib/C.sol"));
2629 }
2630
2631 #[test]
2632 fn test_merge_normalized_outputs_empty_other() {
2633 let mut base = json!({
2634 "sources": {
2635 "/abs/src/A.sol": { "id": 0, "ast": {} }
2636 },
2637 "contracts": {},
2638 "errors": [],
2639 "source_id_to_path": { "0": "/abs/src/A.sol" }
2640 });
2641
2642 let other = json!({
2643 "sources": {},
2644 "contracts": {},
2645 "errors": [],
2646 "source_id_to_path": {}
2647 });
2648
2649 merge_normalized_outputs(&mut base, other);
2650
2651 let sources = base["sources"].as_object().unwrap();
2652 assert_eq!(sources.len(), 1);
2653 }
2654
2655 #[test]
2656 fn test_merge_normalized_outputs_empty_base() {
2657 let mut base = json!({
2658 "sources": {},
2659 "contracts": {},
2660 "errors": [],
2661 "source_id_to_path": {}
2662 });
2663
2664 let other = json!({
2665 "sources": {
2666 "/abs/lib/X.sol": { "id": 0, "ast": {} }
2667 },
2668 "contracts": {
2669 "/abs/lib/X.sol": { "X": { "abi": [] } }
2670 },
2671 "errors": [],
2672 "source_id_to_path": { "0": "/abs/lib/X.sol" }
2673 });
2674
2675 merge_normalized_outputs(&mut base, other);
2676
2677 let sources = base["sources"].as_object().unwrap();
2678 assert_eq!(sources.len(), 1);
2679 let x_id = sources["/abs/lib/X.sol"]["id"].as_u64().unwrap();
2681 assert_eq!(x_id, 0);
2682 }
2683}