use crate::config::FoundryConfig;
use crate::links;
use crate::runner::RunnerError;
use serde_json::{Map, Value, json};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use tokio::process::Command;
use tower_lsp::lsp_types::Url;
static INSTALLED_VERSIONS: OnceLock<Mutex<Vec<SemVer>>> = OnceLock::new();
fn get_installed_versions() -> Vec<SemVer> {
let mutex = INSTALLED_VERSIONS.get_or_init(|| Mutex::new(scan_installed_versions()));
mutex.lock().unwrap().clone()
}
fn invalidate_installed_versions() {
if let Some(mutex) = INSTALLED_VERSIONS.get() {
*mutex.lock().unwrap() = scan_installed_versions();
}
}
fn semver_to_local(v: &semver::Version) -> SemVer {
SemVer {
major: v.major as u32,
minor: v.minor as u32,
patch: v.patch as u32,
}
}
pub async fn resolve_solc_binary(
config: &FoundryConfig,
constraint: Option<&PragmaConstraint>,
client: Option<&tower_lsp::Client>,
) -> PathBuf {
if let Some(constraint) = constraint {
if !matches!(constraint, PragmaConstraint::Exact(_))
&& let Some(ref config_ver) = config.solc_version
&& let Some(parsed) = SemVer::parse(config_ver)
&& version_satisfies(&parsed, constraint)
&& let Some(path) = find_solc_binary(config_ver)
{
if let Some(c) = client {
c.log_message(
tower_lsp::lsp_types::MessageType::INFO,
format!("using solc {config_ver} (pragma {constraint})"),
)
.await;
}
return path;
}
let installed = get_installed_versions();
if let Some(version) = find_matching_version(constraint, &installed)
&& let Some(path) = find_solc_binary(&version.to_string())
{
if let Some(c) = client {
c.log_message(
tower_lsp::lsp_types::MessageType::INFO,
format!("using solc {version}"),
)
.await;
}
return path;
}
let install_version = version_to_install(constraint);
if let Some(ref ver_str) = install_version {
if let Some(c) = client {
c.show_message(
tower_lsp::lsp_types::MessageType::INFO,
format!("Installing solc {ver_str}..."),
)
.await;
}
if svm_install(ver_str).await {
invalidate_installed_versions();
if let Some(c) = client {
c.show_message(
tower_lsp::lsp_types::MessageType::INFO,
format!("Installed solc {ver_str}"),
)
.await;
}
if let Some(path) = find_solc_binary(ver_str) {
return path;
}
} else if let Some(c) = client {
c.show_message(
tower_lsp::lsp_types::MessageType::WARNING,
format!(
"Failed to install solc {ver_str}. \
Install it manually: svm install {ver_str}"
),
)
.await;
}
}
}
if let Some(ref version) = config.solc_version
&& let Some(path) = find_solc_binary(version)
{
if let Some(c) = client {
c.log_message(
tower_lsp::lsp_types::MessageType::INFO,
format!(
"solc: no pragma, using foundry.toml version {version} → {}",
path.display()
),
)
.await;
}
return path;
}
if let Some(c) = client {
c.log_message(
tower_lsp::lsp_types::MessageType::INFO,
"solc: no pragma match, falling back to system solc",
)
.await;
}
PathBuf::from("solc")
}
fn version_to_install(constraint: &PragmaConstraint) -> Option<String> {
match constraint {
PragmaConstraint::Exact(v) => Some(v.to_string()),
PragmaConstraint::Caret(v) => Some(v.to_string()),
PragmaConstraint::Gte(v) => Some(v.to_string()),
PragmaConstraint::Range(lower, _) => Some(lower.to_string()),
}
}
async fn svm_install(version: &str) -> bool {
let ver = match semver::Version::parse(version) {
Ok(v) => v,
Err(_) => return false,
};
svm::install(&ver).await.is_ok()
}
fn find_solc_binary(version: &str) -> Option<PathBuf> {
let path = svm::version_binary(version);
if path.is_file() {
return Some(path);
}
None
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct SemVer {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl SemVer {
fn parse(s: &str) -> Option<SemVer> {
let parts: Vec<&str> = s.split('.').collect();
if parts.len() != 3 {
return None;
}
Some(SemVer {
major: parts[0].parse().ok()?,
minor: parts[1].parse().ok()?,
patch: parts[2].parse().ok()?,
})
}
}
impl std::fmt::Display for SemVer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum PragmaConstraint {
Exact(SemVer),
Caret(SemVer),
Gte(SemVer),
Range(SemVer, SemVer),
}
impl std::fmt::Display for PragmaConstraint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PragmaConstraint::Exact(v) => write!(f, "={v}"),
PragmaConstraint::Caret(v) => write!(f, "^{v}"),
PragmaConstraint::Gte(v) => write!(f, ">={v}"),
PragmaConstraint::Range(lo, hi) => write!(f, ">={lo} <{hi}"),
}
}
}
fn resolve_import_to_abs(
project_root: &Path,
importer_abs: &Path,
import_path: &str,
remappings: &[String],
) -> Option<PathBuf> {
if import_path.starts_with("./") || import_path.starts_with("../") {
let base = importer_abs.parent()?;
return Some(lexical_normalize(&base.join(import_path)));
}
for remap in remappings {
let mut it = remap.splitn(2, '=');
let prefix = it.next().unwrap_or_default();
let target = it.next().unwrap_or_default();
if prefix.is_empty() || target.is_empty() {
continue;
}
if import_path.starts_with(prefix) {
let suffix = import_path.strip_prefix(prefix).unwrap_or_default();
return Some(lexical_normalize(
&project_root.join(format!("{target}{suffix}")),
));
}
}
Some(lexical_normalize(&project_root.join(import_path)))
}
fn lexical_normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for comp in path.components() {
match comp {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
_ => out.push(comp.as_os_str()),
}
}
out
}
fn collect_import_pragmas(
file_path: &Path,
project_root: &Path,
remappings: &[String],
) -> Vec<PragmaConstraint> {
let mut pragmas = Vec::new();
let mut visited = HashSet::new();
collect_import_pragmas_recursive(
file_path,
project_root,
remappings,
&mut pragmas,
&mut visited,
);
pragmas
}
fn collect_import_pragmas_recursive(
file_path: &Path,
project_root: &Path,
remappings: &[String],
pragmas: &mut Vec<PragmaConstraint>,
visited: &mut HashSet<PathBuf>,
) {
if !visited.insert(file_path.to_path_buf()) {
return;
}
let source = match std::fs::read_to_string(file_path) {
Ok(s) => s,
Err(_) => return,
};
if let Some(pragma) = parse_pragma(&source) {
pragmas.push(pragma);
}
for imp in links::ts_find_imports(source.as_bytes()) {
if let Some(abs) = resolve_import_to_abs(project_root, file_path, &imp.path, remappings) {
collect_import_pragmas_recursive(&abs, project_root, remappings, pragmas, visited);
}
}
}
fn tightest_constraint(pragmas: &[PragmaConstraint]) -> Option<PragmaConstraint> {
if pragmas.is_empty() {
return None;
}
for p in pragmas {
if matches!(p, PragmaConstraint::Exact(_)) {
return Some(p.clone());
}
}
let mut lower = SemVer {
major: 0,
minor: 0,
patch: 0,
};
let mut upper: Option<SemVer> = None;
for p in pragmas {
let (lo, hi) = constraint_to_range(p);
if lo > lower {
lower = lo;
}
if let Some(hi) = hi {
upper = Some(match upper {
Some(cur) if hi < cur => hi,
Some(cur) => cur,
None => hi,
});
}
}
match upper {
Some(hi) if lower >= hi => None, Some(hi) => Some(PragmaConstraint::Range(lower, hi)),
None => Some(PragmaConstraint::Gte(lower)),
}
}
fn constraint_to_range(constraint: &PragmaConstraint) -> (SemVer, Option<SemVer>) {
match constraint {
PragmaConstraint::Exact(v) => (
v.clone(),
Some(SemVer {
major: v.major,
minor: v.minor,
patch: v.patch + 1,
}),
),
PragmaConstraint::Caret(v) => (
v.clone(),
Some(SemVer {
major: v.major,
minor: v.minor + 1,
patch: 0,
}),
),
PragmaConstraint::Gte(v) => (v.clone(), None),
PragmaConstraint::Range(lo, hi) => (lo.clone(), Some(hi.clone())),
}
}
pub fn parse_pragma(source: &str) -> Option<PragmaConstraint> {
let pragma_line = source
.lines()
.take(20)
.find(|line| line.trim_start().starts_with("pragma solidity"))?;
let after_keyword = pragma_line
.trim_start()
.strip_prefix("pragma solidity")?
.trim();
let constraint_str = after_keyword
.strip_suffix(';')
.unwrap_or(after_keyword)
.trim();
if constraint_str.is_empty() {
return None;
}
if let Some(rest) = constraint_str.strip_prefix(">=") {
let rest = rest.trim();
if let Some(space_idx) = rest.find(|c: char| c.is_whitespace() || c == '<') {
let lower_str = rest[..space_idx].trim();
let upper_part = rest[space_idx..].trim();
if let Some(upper_str) = upper_part.strip_prefix('<') {
let upper_str = upper_str.trim();
if let (Some(lower), Some(upper)) =
(SemVer::parse(lower_str), SemVer::parse(upper_str))
{
return Some(PragmaConstraint::Range(lower, upper));
}
}
}
if let Some(ver) = SemVer::parse(rest) {
return Some(PragmaConstraint::Gte(ver));
}
}
if let Some(rest) = constraint_str.strip_prefix('^')
&& let Some(ver) = SemVer::parse(rest.trim())
{
return Some(PragmaConstraint::Caret(ver));
}
if let Some(ver) = SemVer::parse(constraint_str) {
return Some(PragmaConstraint::Exact(ver));
}
None
}
pub fn list_installed_versions() -> Vec<SemVer> {
get_installed_versions()
}
fn scan_installed_versions() -> Vec<SemVer> {
svm::installed_versions()
.unwrap_or_default()
.iter()
.map(semver_to_local)
.collect()
}
pub fn find_matching_version(
constraint: &PragmaConstraint,
installed: &[SemVer],
) -> Option<SemVer> {
let candidates: Vec<&SemVer> = installed
.iter()
.filter(|v| version_satisfies(v, constraint))
.collect();
candidates.last().cloned().cloned()
}
pub fn version_satisfies(version: &SemVer, constraint: &PragmaConstraint) -> bool {
match constraint {
PragmaConstraint::Exact(v) => version == v,
PragmaConstraint::Caret(v) => {
version.major == v.major && version >= v && version.minor < v.minor + 1
}
PragmaConstraint::Gte(v) => version >= v,
PragmaConstraint::Range(lower, upper) => version >= lower && version < upper,
}
}
pub async fn resolve_remappings(config: &FoundryConfig) -> Vec<String> {
let output = Command::new("forge")
.arg("remappings")
.current_dir(&config.root)
.env("FOUNDRY_DISABLE_NIGHTLY_WARNING", "1")
.output()
.await;
if let Ok(output) = output
&& output.status.success()
{
let stdout = String::from_utf8_lossy(&output.stdout);
let remappings: Vec<String> = stdout
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| l.to_string())
.collect();
if !remappings.is_empty() {
return remappings;
}
}
if !config.remappings.is_empty() {
return config.remappings.clone();
}
let remappings_txt = config.root.join("remappings.txt");
if let Ok(content) = std::fs::read_to_string(&remappings_txt) {
return content
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| l.to_string())
.collect();
}
Vec::new()
}
pub fn build_standard_json_input(
file_path: &str,
remappings: &[String],
config: &FoundryConfig,
source_content: Option<&str>,
) -> Value {
let contract_outputs = vec![
"devdoc",
"userdoc",
"evm.methodIdentifiers",
"storageLayout",
"transientStorageLayout",
];
let mut settings = json!({
"remappings": remappings,
"outputSelection": {
"*": {
"*": contract_outputs,
"": ["ast"]
}
}
});
if config.via_ir {
settings["viaIR"] = json!(true);
}
if let Some(ref evm_version) = config.evm_version {
settings["evmVersion"] = json!(evm_version);
}
let source_value = match source_content {
Some(content) => json!({ "content": content }),
None => json!({ "urls": [file_path] }),
};
let mut sources = serde_json::Map::new();
sources.insert(file_path.to_string(), source_value);
json!({
"language": "Solidity",
"sources": sources,
"settings": settings
})
}
pub async fn run_solc(
solc_binary: &Path,
input: &Value,
project_root: &Path,
) -> Result<Value, RunnerError> {
let _ = crate::project_cache::save_last_solc_input(project_root, input);
let input_str = serde_json::to_string(input)?;
let mut child = Command::new(solc_binary)
.arg("--standard-json")
.current_dir(project_root)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
stdin
.write_all(input_str.as_bytes())
.await
.map_err(RunnerError::CommandError)?;
}
let output = child
.wait_with_output()
.await
.map_err(RunnerError::CommandError)?;
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.trim().is_empty() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RunnerError::CommandError(std::io::Error::other(format!(
"solc produced no output, stderr: {stderr}"
))));
}
let parsed: Value = serde_json::from_str(&stdout)?;
Ok(parsed)
}
pub fn normalize_solc_output(mut solc_output: Value, project_root: Option<&Path>) -> Value {
fn resolve_import_absolute_paths(node: &mut Value, resolve: &dyn Fn(&str) -> String) {
let is_import = node.get("nodeType").and_then(|v| v.as_str()) == Some("ImportDirective");
if is_import && let Some(abs_path) = node.get("absolutePath").and_then(|v| v.as_str()) {
let resolved = resolve(abs_path);
node.as_object_mut()
.unwrap()
.insert("absolutePath".to_string(), json!(resolved));
}
if let Some(nodes) = node.get_mut("nodes").and_then(|v| v.as_array_mut()) {
for child in nodes {
resolve_import_absolute_paths(child, resolve);
}
}
}
let mut result = Map::new();
let errors = solc_output
.get_mut("errors")
.map(Value::take)
.unwrap_or_else(|| json!([]));
result.insert("errors".to_string(), errors);
let resolve = |p: &str| -> String {
if let Some(root) = project_root {
let path = Path::new(p);
if path.is_relative() {
return root.join(path).to_string_lossy().into_owned();
}
}
p.to_string()
};
let mut source_id_to_path = Map::new();
let mut resolved_sources = Map::new();
if let Some(sources) = solc_output
.get_mut("sources")
.and_then(|s| s.as_object_mut())
{
let keys: Vec<String> = sources.keys().cloned().collect();
for key in keys {
if let Some(mut source_data) = sources.remove(&key) {
let abs_key = resolve(&key);
if let Some(ast) = source_data.get_mut("ast") {
if let Some(abs_path) = ast.get("absolutePath").and_then(|v| v.as_str()) {
let resolved = resolve(abs_path);
ast.as_object_mut()
.unwrap()
.insert("absolutePath".to_string(), json!(resolved));
}
resolve_import_absolute_paths(ast, &resolve);
}
if let Some(id) = source_data.get("id") {
source_id_to_path.insert(id.to_string(), json!(&abs_key));
}
resolved_sources.insert(abs_key, source_data);
}
}
}
result.insert("sources".to_string(), Value::Object(resolved_sources));
let mut resolved_contracts = Map::new();
if let Some(contracts) = solc_output
.get_mut("contracts")
.and_then(|c| c.as_object_mut())
{
let keys: Vec<String> = contracts.keys().cloned().collect();
for key in keys {
if let Some(contract_data) = contracts.remove(&key) {
resolved_contracts.insert(resolve(&key), contract_data);
}
}
}
result.insert("contracts".to_string(), Value::Object(resolved_contracts));
result.insert(
"source_id_to_path".to_string(),
Value::Object(source_id_to_path),
);
Value::Object(result)
}
pub fn normalize_forge_output(mut forge_output: Value) -> Value {
let mut result = Map::new();
let errors = forge_output
.get_mut("errors")
.map(Value::take)
.unwrap_or_else(|| json!([]));
result.insert("errors".to_string(), errors);
let mut normalized_sources = Map::new();
if let Some(sources) = forge_output
.get_mut("sources")
.and_then(|s| s.as_object_mut())
{
for (path, entries) in sources.iter_mut() {
if let Some(arr) = entries.as_array_mut()
&& let Some(first) = arr.first_mut()
&& let Some(sf) = first.get_mut("source_file")
{
normalized_sources.insert(path.clone(), sf.take());
}
}
}
result.insert("sources".to_string(), Value::Object(normalized_sources));
let mut normalized_contracts = Map::new();
if let Some(contracts) = forge_output
.get_mut("contracts")
.and_then(|c| c.as_object_mut())
{
for (path, names) in contracts.iter_mut() {
let mut path_contracts = Map::new();
if let Some(names_obj) = names.as_object_mut() {
for (name, entries) in names_obj.iter_mut() {
if let Some(arr) = entries.as_array_mut()
&& let Some(first) = arr.first_mut()
&& let Some(contract) = first.get_mut("contract")
{
path_contracts.insert(name.clone(), contract.take());
}
}
}
normalized_contracts.insert(path.clone(), Value::Object(path_contracts));
}
}
result.insert("contracts".to_string(), Value::Object(normalized_contracts));
let source_id_to_path = forge_output
.get_mut("build_infos")
.and_then(|bi| bi.as_array_mut())
.and_then(|arr| arr.first_mut())
.and_then(|info| info.get_mut("source_id_to_path"))
.map(Value::take)
.unwrap_or_else(|| json!({}));
result.insert("source_id_to_path".to_string(), source_id_to_path);
Value::Object(result)
}
pub async fn solc_ast(
file_path: &str,
config: &FoundryConfig,
client: Option<&tower_lsp::Client>,
source_content: Option<&str>,
) -> Result<Value, RunnerError> {
let remappings = resolve_remappings(config).await;
let file_abs = Path::new(file_path).to_path_buf();
let config_root = config.root.clone();
let remappings_clone = remappings.clone();
let pragmas = tokio::task::spawn_blocking(move || {
collect_import_pragmas(&file_abs, &config_root, &remappings_clone)
})
.await
.unwrap_or_default();
let constraint = tightest_constraint(&pragmas);
let solc_binary = resolve_solc_binary(config, constraint.as_ref(), client).await;
let rel_path = Path::new(file_path)
.strip_prefix(&config.root)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| file_path.to_string());
let input = build_standard_json_input(&rel_path, &remappings, config, source_content);
let raw_output = run_solc(&solc_binary, &input, &config.root).await?;
Ok(normalize_solc_output(raw_output, Some(&config.root)))
}
pub async fn solc_build(
file_path: &str,
config: &FoundryConfig,
client: Option<&tower_lsp::Client>,
) -> Result<Value, RunnerError> {
solc_ast(file_path, config, client, None).await
}
pub fn discover_source_files(config: &FoundryConfig) -> Vec<PathBuf> {
discover_source_files_inner(config, false)
}
pub fn discover_src_only_files(config: &FoundryConfig) -> Vec<PathBuf> {
let root = &config.root;
if !root.is_dir() {
return Vec::new();
}
let mut files = Vec::new();
let dir = root.join(&config.sources_dir);
if dir.is_dir() {
discover_recursive(&dir, &[], &mut files);
}
files.sort();
files
}
pub fn discover_src_only_closure(config: &FoundryConfig, remappings: &[String]) -> Vec<PathBuf> {
let seeds = discover_src_only_files(config);
let mut visited: HashSet<PathBuf> = HashSet::new();
let mut queue: std::collections::VecDeque<PathBuf> = seeds.into_iter().collect();
while let Some(file) = queue.pop_front() {
if !visited.insert(file.clone()) {
continue;
}
let source = match std::fs::read_to_string(&file) {
Ok(s) => s,
Err(_) => continue,
};
for imp in links::ts_find_imports(source.as_bytes()) {
if let Some(abs) = resolve_import_to_abs(&config.root, &file, &imp.path, remappings)
&& abs.exists()
&& !visited.contains(&abs)
{
queue.push_back(abs);
}
}
}
let mut result: Vec<PathBuf> = visited.into_iter().collect();
result.sort();
result
}
pub fn discover_source_files_with_libs(config: &FoundryConfig) -> Vec<PathBuf> {
discover_source_files_inner(config, true)
}
fn discover_source_files_inner(config: &FoundryConfig, include_libs: bool) -> Vec<PathBuf> {
let root = &config.root;
if !root.is_dir() {
return Vec::new();
}
let mut files = Vec::new();
let no_skip: &[String] = &[];
for dir_name in [&config.sources_dir, &config.test_dir, &config.script_dir] {
let dir = root.join(dir_name);
if dir.is_dir() {
discover_recursive(&dir, no_skip, &mut files);
}
}
if include_libs {
for lib_name in &config.libs {
let lib_dir = root.join(lib_name);
if lib_dir.is_dir() {
discover_recursive(&lib_dir, no_skip, &mut files);
}
}
}
files.sort();
files
}
pub fn discover_compilation_closure(config: &FoundryConfig, remappings: &[String]) -> Vec<PathBuf> {
let seeds = discover_source_files(config);
let mut visited: HashSet<PathBuf> = HashSet::new();
let mut queue: std::collections::VecDeque<PathBuf> = seeds.into_iter().collect();
while let Some(file) = queue.pop_front() {
if !visited.insert(file.clone()) {
continue;
}
let source = match std::fs::read_to_string(&file) {
Ok(s) => s,
Err(_) => continue,
};
for imp in links::ts_find_imports(source.as_bytes()) {
if let Some(abs) = resolve_import_to_abs(&config.root, &file, &imp.path, remappings)
&& abs.exists()
&& !visited.contains(&abs)
{
queue.push_back(abs);
}
}
}
let mut result: Vec<PathBuf> = visited.into_iter().collect();
result.sort();
result
}
const DISCOVER_SKIP_DIRS: &[&str] = &["out", "artifacts", "cache", "target", "broadcast"];
fn discover_recursive(dir: &Path, skip_libs: &[String], files: &mut Vec<PathBuf>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with('.') {
continue;
}
if DISCOVER_SKIP_DIRS.contains(&name) {
continue;
}
if skip_libs.iter().any(|lib| lib == name) {
continue;
}
}
discover_recursive(&path, skip_libs, files);
} else if let Some(name) = path.file_name().and_then(|n| n.to_str())
&& name.ends_with(".sol")
{
files.push(path);
}
}
}
pub fn build_batch_standard_json_input(
source_files: &[PathBuf],
remappings: &[String],
config: &FoundryConfig,
) -> Value {
build_batch_standard_json_input_with_cache(source_files, remappings, config, None)
}
pub fn build_batch_standard_json_input_with_cache(
source_files: &[PathBuf],
remappings: &[String],
config: &FoundryConfig,
content_cache: Option<&HashMap<crate::types::DocumentUri, (i32, String)>>,
) -> Value {
let contract_outputs = vec![
"devdoc",
"userdoc",
"evm.methodIdentifiers",
"storageLayout",
"transientStorageLayout",
];
let mut settings = json!({
"remappings": remappings,
"outputSelection": {
"*": {
"*": contract_outputs,
"": ["ast"]
}
}
});
if config.via_ir {
settings["viaIR"] = json!(true);
}
if let Some(ref evm_version) = config.evm_version {
settings["evmVersion"] = json!(evm_version);
}
let mut sources = serde_json::Map::new();
for file in source_files {
let rel_path = file
.strip_prefix(&config.root)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| file.to_string_lossy().into_owned());
let cached_content = content_cache.and_then(|cache| {
let uri = Url::from_file_path(file).ok()?;
cache.get(uri.as_str()).map(|(_, c)| c.as_str())
});
if let Some(content) = cached_content {
sources.insert(rel_path, json!({ "content": content }));
} else {
sources.insert(rel_path.clone(), json!({ "urls": [rel_path] }));
}
}
json!({
"language": "Solidity",
"sources": sources,
"settings": settings
})
}
pub fn build_batch_standard_json_input_ast_only(
source_files: &[PathBuf],
remappings: &[String],
root: &Path,
) -> Value {
let settings = json!({
"remappings": remappings,
"outputSelection": {
"*": {
"": ["ast"]
}
}
});
let mut sources = serde_json::Map::new();
for file in source_files {
let rel_path = file
.strip_prefix(root)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| file.to_string_lossy().into_owned());
sources.insert(rel_path.clone(), json!({ "urls": [rel_path] }));
}
json!({
"language": "Solidity",
"sources": sources,
"settings": settings
})
}
pub fn build_parse_only_json_input(
source_files: &[PathBuf],
remappings: &[String],
config: &FoundryConfig,
) -> Value {
let settings = json!({
"stopAfter": "parsing",
"remappings": remappings,
"outputSelection": {
"*": {
"": ["ast"]
}
}
});
let mut sources = serde_json::Map::new();
for file in source_files {
let rel_path = file
.strip_prefix(&config.root)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| file.to_string_lossy().into_owned());
sources.insert(rel_path.clone(), json!({ "urls": [rel_path] }));
}
json!({
"language": "Solidity",
"sources": sources,
"settings": settings
})
}
pub async fn solc_project_index(
config: &FoundryConfig,
client: Option<&tower_lsp::Client>,
text_cache: Option<&HashMap<crate::types::DocumentUri, (i32, String)>>,
) -> Result<Value, RunnerError> {
let remappings = resolve_remappings(config).await;
let source_files = discover_compilation_closure(config, &remappings);
if source_files.is_empty() {
return Err(RunnerError::CommandError(std::io::Error::other(
"no source files found for project index",
)));
}
solc_project_index_from_files(config, client, text_cache, &source_files).await
}
pub async fn solc_project_index_ast_only(
config: &FoundryConfig,
client: Option<&tower_lsp::Client>,
) -> Result<Value, RunnerError> {
let remappings = resolve_remappings(config).await;
let source_files = discover_compilation_closure(config, &remappings);
if source_files.is_empty() {
return Err(RunnerError::CommandError(std::io::Error::other(
"no source files found for project index",
)));
}
solc_project_index_from_files_ast_only(config, client, &source_files).await
}
async fn solc_project_index_from_files_ast_only(
config: &FoundryConfig,
client: Option<&tower_lsp::Client>,
source_files: &[PathBuf],
) -> Result<Value, RunnerError> {
if source_files.is_empty() {
return Err(RunnerError::CommandError(std::io::Error::other(
"no source files found for AST-only project index",
)));
}
let remappings = resolve_remappings(config).await;
let project_version: Option<SemVer> =
config.solc_version.as_ref().and_then(|v| SemVer::parse(v));
let constraint: Option<PragmaConstraint> = if let Some(ref v) = project_version {
Some(PragmaConstraint::Exact(v.clone()))
} else {
source_files.iter().find_map(|f| {
std::fs::read_to_string(f)
.ok()
.and_then(|src| parse_pragma(&src))
})
};
let solc_binary = resolve_solc_binary(config, constraint.as_ref(), client).await;
let (compatible_files, incompatible_files) = if let Some(ref ver) = project_version {
let mut compat = Vec::with_capacity(source_files.len());
let mut incompat = Vec::new();
for file in source_files {
let is_compatible = std::fs::read_to_string(file)
.ok()
.and_then(|src| parse_pragma(&src))
.map(|pragma| version_satisfies(ver, &pragma))
.unwrap_or(true);
if is_compatible {
compat.push(file.clone());
} else {
incompat.push(file.clone());
}
}
(compat, incompat)
} else {
(source_files.to_vec(), Vec::new())
};
if !incompatible_files.is_empty()
&& let Some(c) = client
{
c.log_message(
tower_lsp::lsp_types::MessageType::INFO,
format!(
"project index: {} compatible, {} incompatible with solc {}",
compatible_files.len(),
incompatible_files.len(),
project_version
.as_ref()
.map(|v| v.to_string())
.unwrap_or_default(),
),
)
.await;
}
let mut result = if compatible_files.is_empty() {
json!({"sources": {}, "contracts": {}, "errors": [], "source_id_to_path": {}})
} else {
let input =
build_batch_standard_json_input_ast_only(&compatible_files, &remappings, &config.root);
let raw = run_solc(&solc_binary, &input, &config.root).await?;
normalize_solc_output(raw, Some(&config.root))
};
if incompatible_files.is_empty() {
return Ok(result);
}
for file in &incompatible_files {
let pragma = std::fs::read_to_string(file)
.ok()
.and_then(|src| parse_pragma(&src));
let file_binary = resolve_solc_binary(config, pragma.as_ref(), client).await;
let input = build_batch_standard_json_input_ast_only(
std::slice::from_ref(file),
&remappings,
&config.root,
);
if let Ok(raw) = run_solc(&file_binary, &input, &config.root).await {
let normalized = normalize_solc_output(raw, Some(&config.root));
merge_normalized_outputs(&mut result, normalized);
}
}
if let Some(c) = client {
let total = result
.get("sources")
.and_then(|s| s.as_object())
.map_or(0, |obj| obj.len());
c.log_message(
tower_lsp::lsp_types::MessageType::INFO,
format!(
"project index: compiled {} files ({} needed different solc version)",
total,
incompatible_files.len(),
),
)
.await;
}
Ok(result)
}
pub async fn solc_project_index_scoped(
config: &FoundryConfig,
client: Option<&tower_lsp::Client>,
text_cache: Option<&HashMap<crate::types::DocumentUri, (i32, String)>>,
source_files: &[PathBuf],
) -> Result<Value, RunnerError> {
if source_files.is_empty() {
return Err(RunnerError::CommandError(std::io::Error::other(
"no source files provided for scoped project index",
)));
}
solc_project_index_from_files(config, client, text_cache, source_files).await
}
#[cfg(test)]
fn extract_version_error_files(solc_output: &Value) -> HashSet<String> {
let mut files = HashSet::new();
if let Some(errors) = solc_output.get("errors").and_then(|e| e.as_array()) {
for err in errors {
let is_5333 = err.get("errorCode").and_then(|c| c.as_str()) == Some("5333");
if is_5333
&& let Some(file) = err
.get("sourceLocation")
.and_then(|sl| sl.get("file"))
.and_then(|f| f.as_str())
{
files.insert(file.to_string());
}
}
}
files
}
#[cfg(test)]
#[allow(dead_code)]
fn extract_import_error_files(solc_output: &Value) -> HashSet<String> {
let mut files = HashSet::new();
if let Some(errors) = solc_output.get("errors").and_then(|e| e.as_array()) {
for err in errors {
let is_6275 = err.get("errorCode").and_then(|c| c.as_str()) == Some("6275");
if is_6275
&& let Some(file) = err
.get("sourceLocation")
.and_then(|sl| sl.get("file"))
.and_then(|f| f.as_str())
{
files.insert(file.to_string());
}
}
}
files
}
#[cfg(test)]
fn reverse_import_closure(
source_files: &[PathBuf],
exclude_abs: &HashSet<PathBuf>,
project_root: &Path,
remappings: &[String],
) -> HashSet<PathBuf> {
let mut reverse_edges: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
for file in source_files {
let Ok(bytes) = std::fs::read(file) else {
continue;
};
for imp in links::ts_find_imports(&bytes) {
if let Some(imported_abs) =
resolve_import_to_abs(project_root, file, &imp.path, remappings)
{
reverse_edges
.entry(imported_abs)
.or_default()
.insert(file.clone());
}
}
}
let mut closure: HashSet<PathBuf> = exclude_abs.clone();
let mut queue: std::collections::VecDeque<PathBuf> = exclude_abs.iter().cloned().collect();
while let Some(current) = queue.pop_front() {
if let Some(importers) = reverse_edges.get(¤t) {
for importer in importers {
if closure.insert(importer.clone()) {
queue.push_back(importer.clone());
}
}
}
}
closure
}
fn merge_normalized_outputs(base: &mut Value, other: Value) {
if let (Some(base_sources), Some(other_sources)) = (
base.get_mut("sources").and_then(|s| s.as_object_mut()),
other.get("sources").and_then(|s| s.as_object()),
) {
let max_base_id = base_sources
.values()
.filter_map(|v| v.get("id").and_then(|id| id.as_u64()))
.max()
.map(|m| m + 1)
.unwrap_or(0);
let mut remapped_id_to_path: Vec<(String, String)> = Vec::new();
for (path, mut source_data) in other_sources.clone() {
if let Some(id) = source_data.get("id").and_then(|id| id.as_u64()) {
let new_id = id + max_base_id;
source_data
.as_object_mut()
.unwrap()
.insert("id".to_string(), json!(new_id));
remapped_id_to_path.push((new_id.to_string(), path.clone()));
}
base_sources.insert(path, source_data);
}
if let Some(base_id_map) = base
.get_mut("source_id_to_path")
.and_then(|m| m.as_object_mut())
{
for (id, path) in remapped_id_to_path {
base_id_map.insert(id, json!(path));
}
}
}
if let (Some(base_contracts), Some(other_contracts)) = (
base.get_mut("contracts").and_then(|c| c.as_object_mut()),
other.get("contracts").and_then(|c| c.as_object()),
) {
for (path, contract_data) in other_contracts {
base_contracts.insert(path.clone(), contract_data.clone());
}
}
}
async fn solc_project_index_from_files(
config: &FoundryConfig,
client: Option<&tower_lsp::Client>,
text_cache: Option<&HashMap<crate::types::DocumentUri, (i32, String)>>,
source_files: &[PathBuf],
) -> Result<Value, RunnerError> {
if source_files.is_empty() {
return Err(RunnerError::CommandError(std::io::Error::other(
"no source files found for project index",
)));
}
let remappings = resolve_remappings(config).await;
let project_version: Option<SemVer> =
config.solc_version.as_ref().and_then(|v| SemVer::parse(v));
let constraint: Option<PragmaConstraint> = if let Some(ref v) = project_version {
Some(PragmaConstraint::Exact(v.clone()))
} else {
source_files.iter().find_map(|f| {
std::fs::read_to_string(f)
.ok()
.and_then(|src| parse_pragma(&src))
})
};
let solc_binary = resolve_solc_binary(config, constraint.as_ref(), client).await;
let (compatible_files, incompatible_files) = if let Some(ref ver) = project_version {
let mut compat = Vec::with_capacity(source_files.len());
let mut incompat = Vec::new();
for file in source_files {
let is_compatible = std::fs::read_to_string(file)
.ok()
.and_then(|src| parse_pragma(&src))
.map(|pragma| version_satisfies(ver, &pragma))
.unwrap_or(true);
if is_compatible {
compat.push(file.clone());
} else {
incompat.push(file.clone());
}
}
(compat, incompat)
} else {
(source_files.to_vec(), Vec::new())
};
if !incompatible_files.is_empty()
&& let Some(c) = client
{
c.log_message(
tower_lsp::lsp_types::MessageType::INFO,
format!(
"project index: {} compatible, {} incompatible with solc {}",
compatible_files.len(),
incompatible_files.len(),
project_version
.as_ref()
.map(|v| v.to_string())
.unwrap_or_default(),
),
)
.await;
}
let mut result = if compatible_files.is_empty() {
json!({"sources": {}, "contracts": {}, "errors": [], "source_id_to_path": {}})
} else {
let input = build_batch_standard_json_input_with_cache(
&compatible_files,
&remappings,
config,
text_cache,
);
let raw = run_solc(&solc_binary, &input, &config.root).await?;
normalize_solc_output(raw, Some(&config.root))
};
let batch_source_count = result
.get("sources")
.and_then(|s| s.as_object())
.map_or(0, |obj| obj.len());
if incompatible_files.is_empty() {
return Ok(result);
}
if let Some(c) = client {
let batch_errors: Vec<String> = result
.get("errors")
.and_then(|e| e.as_array())
.map(|arr| {
arr.iter()
.filter(|e| e.get("severity").and_then(|s| s.as_str()) == Some("error"))
.take(3)
.map(|e| {
let msg = e.get("message").and_then(|m| m.as_str()).unwrap_or("?");
let file = e
.get("sourceLocation")
.and_then(|sl| sl.get("file"))
.and_then(|f| f.as_str())
.unwrap_or("?");
format!("{file}: {msg}")
})
.collect()
})
.unwrap_or_default();
c.log_message(
tower_lsp::lsp_types::MessageType::INFO,
format!(
"project index: batch produced {} sources, now compiling {} incompatible files individually{}",
batch_source_count,
incompatible_files.len(),
if batch_errors.is_empty() {
String::new()
} else {
format!(" [first errors: {}]", batch_errors.join("; "))
},
),
)
.await;
}
let mut compiled = 0usize;
let mut skipped = 0usize;
for file in &incompatible_files {
let pragma = std::fs::read_to_string(file)
.ok()
.and_then(|src| parse_pragma(&src));
let Some(file_constraint) = pragma else {
skipped += 1;
continue;
};
let file_binary = resolve_solc_binary(config, Some(&file_constraint), client).await;
let input = build_batch_standard_json_input_with_cache(
std::slice::from_ref(file),
&remappings,
config,
text_cache,
);
match run_solc(&file_binary, &input, &config.root).await {
Ok(raw) => {
let normalized = normalize_solc_output(raw, Some(&config.root));
merge_normalized_outputs(&mut result, normalized);
compiled += 1;
}
Err(e) => {
if let Some(c) = client {
c.log_message(
tower_lsp::lsp_types::MessageType::WARNING,
format!(
"project index: incompatible file {} failed: {e}",
file.display(),
),
)
.await;
}
skipped += 1;
}
}
}
if let Some(c) = client {
c.log_message(
tower_lsp::lsp_types::MessageType::INFO,
format!(
"project index: incompatible files done — {compiled} compiled, {skipped} skipped",
),
)
.await;
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_solc_sources() {
let solc_output = json!({
"sources": {
"src/Foo.sol": {
"id": 0,
"ast": {
"nodeType": "SourceUnit",
"absolutePath": "src/Foo.sol",
"id": 100
}
},
"src/Bar.sol": {
"id": 1,
"ast": {
"nodeType": "SourceUnit",
"absolutePath": "src/Bar.sol",
"id": 200
}
}
},
"contracts": {},
"errors": []
});
let normalized = normalize_solc_output(solc_output, None);
let sources = normalized.get("sources").unwrap().as_object().unwrap();
assert_eq!(sources.len(), 2);
let foo = sources.get("src/Foo.sol").unwrap();
assert_eq!(foo.get("id").unwrap(), 0);
assert_eq!(
foo.get("ast")
.unwrap()
.get("nodeType")
.unwrap()
.as_str()
.unwrap(),
"SourceUnit"
);
let id_to_path = normalized
.get("source_id_to_path")
.unwrap()
.as_object()
.unwrap();
assert_eq!(id_to_path.len(), 2);
}
#[test]
fn test_normalize_solc_contracts() {
let solc_output = json!({
"sources": {},
"contracts": {
"src/Foo.sol": {
"Foo": {
"abi": [{"type": "function", "name": "bar"}],
"evm": {
"methodIdentifiers": {
"bar(uint256)": "abcd1234"
}
}
}
}
},
"errors": []
});
let normalized = normalize_solc_output(solc_output, None);
let contracts = normalized.get("contracts").unwrap().as_object().unwrap();
let foo_contracts = contracts.get("src/Foo.sol").unwrap().as_object().unwrap();
let foo = foo_contracts.get("Foo").unwrap();
let method_ids = foo
.get("evm")
.unwrap()
.get("methodIdentifiers")
.unwrap()
.as_object()
.unwrap();
assert_eq!(
method_ids.get("bar(uint256)").unwrap().as_str().unwrap(),
"abcd1234"
);
}
#[test]
fn test_normalize_solc_errors_passthrough() {
let solc_output = json!({
"sources": {},
"contracts": {},
"errors": [{
"sourceLocation": {"file": "src/Foo.sol", "start": 0, "end": 10},
"type": "Warning",
"component": "general",
"severity": "warning",
"errorCode": "2394",
"message": "test warning",
"formattedMessage": "Warning: test warning"
}]
});
let normalized = normalize_solc_output(solc_output, None);
let errors = normalized.get("errors").unwrap().as_array().unwrap();
assert_eq!(errors.len(), 1);
assert_eq!(
errors[0].get("errorCode").unwrap().as_str().unwrap(),
"2394"
);
}
#[test]
fn test_normalize_empty_solc_output() {
let solc_output = json!({
"sources": {},
"contracts": {}
});
let normalized = normalize_solc_output(solc_output, None);
assert!(
normalized
.get("sources")
.unwrap()
.as_object()
.unwrap()
.is_empty()
);
assert!(
normalized
.get("contracts")
.unwrap()
.as_object()
.unwrap()
.is_empty()
);
assert_eq!(
normalized.get("errors").unwrap().as_array().unwrap().len(),
0
);
assert!(
normalized
.get("source_id_to_path")
.unwrap()
.as_object()
.unwrap()
.is_empty()
);
}
#[test]
fn test_build_standard_json_input() {
let config = FoundryConfig::default();
let input = build_standard_json_input(
"/path/to/Foo.sol",
&[
"ds-test/=lib/forge-std/lib/ds-test/src/".to_string(),
"forge-std/=lib/forge-std/src/".to_string(),
],
&config,
None,
);
let sources = input.get("sources").unwrap().as_object().unwrap();
assert!(sources.contains_key("/path/to/Foo.sol"));
let settings = input.get("settings").unwrap();
let remappings = settings.get("remappings").unwrap().as_array().unwrap();
assert_eq!(remappings.len(), 2);
let output_sel = settings.get("outputSelection").unwrap();
assert!(output_sel.get("*").is_some());
assert!(settings.get("optimizer").is_none());
assert!(settings.get("viaIR").is_none());
assert!(settings.get("evmVersion").is_none());
let outputs = settings["outputSelection"]["*"]["*"].as_array().unwrap();
let output_names: Vec<&str> = outputs.iter().map(|v| v.as_str().unwrap()).collect();
assert!(!output_names.contains(&"evm.gasEstimates"));
assert!(!output_names.contains(&"abi")); assert!(output_names.contains(&"devdoc"));
assert!(output_names.contains(&"userdoc"));
assert!(output_names.contains(&"evm.methodIdentifiers"));
}
#[test]
fn test_build_standard_json_input_with_config() {
let config = FoundryConfig {
optimizer: true,
optimizer_runs: 9999999,
via_ir: true,
evm_version: Some("osaka".to_string()),
..Default::default()
};
let input = build_standard_json_input("/path/to/Foo.sol", &[], &config, None);
let settings = input.get("settings").unwrap();
assert!(settings.get("optimizer").is_none());
assert!(settings.get("viaIR").unwrap().as_bool().unwrap());
let outputs = settings["outputSelection"]["*"]["*"].as_array().unwrap();
let output_names: Vec<&str> = outputs.iter().map(|v| v.as_str().unwrap()).collect();
assert!(!output_names.contains(&"evm.gasEstimates"));
assert_eq!(
settings.get("evmVersion").unwrap().as_str().unwrap(),
"osaka"
);
}
#[tokio::test]
async fn test_resolve_solc_binary_default() {
let config = FoundryConfig::default();
let binary = resolve_solc_binary(&config, None, None).await;
assert_eq!(binary, PathBuf::from("solc"));
}
#[test]
fn test_parse_pragma_exact() {
let source = "// SPDX\npragma solidity 0.8.26;\n";
assert_eq!(
parse_pragma(source),
Some(PragmaConstraint::Exact(SemVer {
major: 0,
minor: 8,
patch: 26
}))
);
}
#[test]
fn test_parse_pragma_caret() {
let source = "pragma solidity ^0.8.0;\n";
assert_eq!(
parse_pragma(source),
Some(PragmaConstraint::Caret(SemVer {
major: 0,
minor: 8,
patch: 0
}))
);
}
#[test]
fn test_parse_pragma_gte() {
let source = "pragma solidity >=0.8.0;\n";
assert_eq!(
parse_pragma(source),
Some(PragmaConstraint::Gte(SemVer {
major: 0,
minor: 8,
patch: 0
}))
);
}
#[test]
fn test_parse_pragma_range() {
let source = "pragma solidity >=0.6.2 <0.9.0;\n";
assert_eq!(
parse_pragma(source),
Some(PragmaConstraint::Range(
SemVer {
major: 0,
minor: 6,
patch: 2
},
SemVer {
major: 0,
minor: 9,
patch: 0
},
))
);
}
#[test]
fn test_parse_pragma_none() {
let source = "contract Foo {}\n";
assert_eq!(parse_pragma(source), None);
}
#[test]
fn test_version_satisfies_exact() {
let v = SemVer {
major: 0,
minor: 8,
patch: 26,
};
assert!(version_satisfies(&v, &PragmaConstraint::Exact(v.clone())));
assert!(!version_satisfies(
&SemVer {
major: 0,
minor: 8,
patch: 25
},
&PragmaConstraint::Exact(v)
));
}
#[test]
fn test_version_satisfies_caret() {
let constraint = PragmaConstraint::Caret(SemVer {
major: 0,
minor: 8,
patch: 0,
});
assert!(version_satisfies(
&SemVer {
major: 0,
minor: 8,
patch: 0
},
&constraint
));
assert!(version_satisfies(
&SemVer {
major: 0,
minor: 8,
patch: 26
},
&constraint
));
assert!(!version_satisfies(
&SemVer {
major: 0,
minor: 9,
patch: 0
},
&constraint
));
assert!(!version_satisfies(
&SemVer {
major: 0,
minor: 7,
patch: 0
},
&constraint
));
}
#[test]
fn test_version_satisfies_gte() {
let constraint = PragmaConstraint::Gte(SemVer {
major: 0,
minor: 8,
patch: 0,
});
assert!(version_satisfies(
&SemVer {
major: 0,
minor: 8,
patch: 0
},
&constraint
));
assert!(version_satisfies(
&SemVer {
major: 0,
minor: 9,
patch: 0
},
&constraint
));
assert!(!version_satisfies(
&SemVer {
major: 0,
minor: 7,
patch: 0
},
&constraint
));
}
#[test]
fn test_version_satisfies_range() {
let constraint = PragmaConstraint::Range(
SemVer {
major: 0,
minor: 6,
patch: 2,
},
SemVer {
major: 0,
minor: 9,
patch: 0,
},
);
assert!(version_satisfies(
&SemVer {
major: 0,
minor: 6,
patch: 2
},
&constraint
));
assert!(version_satisfies(
&SemVer {
major: 0,
minor: 8,
patch: 26
},
&constraint
));
assert!(!version_satisfies(
&SemVer {
major: 0,
minor: 9,
patch: 0
},
&constraint
));
assert!(!version_satisfies(
&SemVer {
major: 0,
minor: 6,
patch: 1
},
&constraint
));
}
#[test]
fn test_find_matching_version() {
let installed = vec![
SemVer {
major: 0,
minor: 8,
patch: 0,
},
SemVer {
major: 0,
minor: 8,
patch: 20,
},
SemVer {
major: 0,
minor: 8,
patch: 26,
},
SemVer {
major: 0,
minor: 8,
patch: 33,
},
];
let constraint = PragmaConstraint::Caret(SemVer {
major: 0,
minor: 8,
patch: 20,
});
let matched = find_matching_version(&constraint, &installed);
assert_eq!(
matched,
Some(SemVer {
major: 0,
minor: 8,
patch: 33
})
);
let constraint = PragmaConstraint::Exact(SemVer {
major: 0,
minor: 8,
patch: 20,
});
let matched = find_matching_version(&constraint, &installed);
assert_eq!(
matched,
Some(SemVer {
major: 0,
minor: 8,
patch: 20
})
);
let constraint = PragmaConstraint::Exact(SemVer {
major: 0,
minor: 8,
patch: 15,
});
let matched = find_matching_version(&constraint, &installed);
assert_eq!(matched, None);
}
#[test]
fn test_list_installed_versions() {
let versions = list_installed_versions();
for w in versions.windows(2) {
assert!(w[0] <= w[1]);
}
}
#[test]
fn test_extract_version_error_files_basic() {
let output = json!({
"errors": [
{
"errorCode": "5333",
"severity": "error",
"message": "Source file requires different compiler version",
"sourceLocation": {
"file": "lib/openzeppelin/contracts/token/ERC20/ERC20.sol",
"start": 32,
"end": 58
}
},
{
"errorCode": "5333",
"severity": "error",
"message": "Source file requires different compiler version",
"sourceLocation": {
"file": "lib/old-lib/src/Legacy.sol",
"start": 32,
"end": 58
}
},
{
"errorCode": "9574",
"severity": "error",
"message": "Some other error",
"sourceLocation": {
"file": "src/Main.sol",
"start": 100,
"end": 200
}
}
]
});
let files = extract_version_error_files(&output);
assert_eq!(files.len(), 2);
assert!(files.contains("lib/openzeppelin/contracts/token/ERC20/ERC20.sol"));
assert!(files.contains("lib/old-lib/src/Legacy.sol"));
assert!(!files.contains("src/Main.sol"));
}
#[test]
fn test_extract_version_error_files_empty() {
let output = json!({
"errors": []
});
assert!(extract_version_error_files(&output).is_empty());
let output = json!({});
assert!(extract_version_error_files(&output).is_empty());
}
#[test]
fn test_extract_version_error_files_no_source_location() {
let output = json!({
"errors": [
{
"errorCode": "5333",
"severity": "error",
"message": "Source file requires different compiler version"
}
]
});
assert!(extract_version_error_files(&output).is_empty());
}
#[test]
fn test_extract_version_error_files_dedup() {
let output = json!({
"errors": [
{
"errorCode": "5333",
"severity": "error",
"sourceLocation": { "file": "lib/same.sol", "start": 0, "end": 10 }
},
{
"errorCode": "5333",
"severity": "error",
"sourceLocation": { "file": "lib/same.sol", "start": 50, "end": 70 }
}
]
});
let files = extract_version_error_files(&output);
assert_eq!(files.len(), 1);
assert!(files.contains("lib/same.sol"));
}
#[test]
fn test_reverse_import_closure_simple() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("a.sol"),
"// SPDX-License-Identifier: MIT\nimport \"./b.sol\";\ncontract A {}",
)
.unwrap();
std::fs::write(
root.join("b.sol"),
"// SPDX-License-Identifier: MIT\nimport \"./c.sol\";\ncontract B {}",
)
.unwrap();
std::fs::write(
root.join("c.sol"),
"// SPDX-License-Identifier: MIT\ncontract C {}",
)
.unwrap();
std::fs::write(
root.join("d.sol"),
"// SPDX-License-Identifier: MIT\ncontract D {}",
)
.unwrap();
let files: Vec<PathBuf> = vec![
root.join("a.sol"),
root.join("b.sol"),
root.join("c.sol"),
root.join("d.sol"),
];
let exclude: HashSet<PathBuf> = [root.join("c.sol")].into_iter().collect();
let closure = reverse_import_closure(&files, &exclude, root, &[]);
assert!(
closure.contains(&root.join("c.sol")),
"seed file in closure"
);
assert!(closure.contains(&root.join("b.sol")), "direct importer");
assert!(closure.contains(&root.join("a.sol")), "transitive importer");
assert!(
!closure.contains(&root.join("d.sol")),
"unrelated file not in closure"
);
assert_eq!(closure.len(), 3);
}
#[test]
fn test_reverse_import_closure_no_importers() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("a.sol"), "contract A {}").unwrap();
std::fs::write(root.join("b.sol"), "contract B {}").unwrap();
let files: Vec<PathBuf> = vec![root.join("a.sol"), root.join("b.sol")];
let exclude: HashSet<PathBuf> = [root.join("a.sol")].into_iter().collect();
let closure = reverse_import_closure(&files, &exclude, root, &[]);
assert_eq!(closure.len(), 1);
assert!(closure.contains(&root.join("a.sol")));
}
#[test]
fn test_reverse_import_closure_diamond() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("a.sol"),
"import \"./b.sol\";\nimport \"./c.sol\";\ncontract A {}",
)
.unwrap();
std::fs::write(root.join("b.sol"), "import \"./d.sol\";\ncontract B {}").unwrap();
std::fs::write(root.join("c.sol"), "import \"./d.sol\";\ncontract C {}").unwrap();
std::fs::write(root.join("d.sol"), "contract D {}").unwrap();
let files: Vec<PathBuf> = vec![
root.join("a.sol"),
root.join("b.sol"),
root.join("c.sol"),
root.join("d.sol"),
];
let exclude: HashSet<PathBuf> = [root.join("d.sol")].into_iter().collect();
let closure = reverse_import_closure(&files, &exclude, root, &[]);
assert_eq!(closure.len(), 4);
}
#[test]
fn test_merge_normalized_outputs_basic() {
let mut base = json!({
"sources": {
"/abs/src/A.sol": { "id": 0, "ast": { "nodeType": "SourceUnit" } },
"/abs/src/B.sol": { "id": 1, "ast": { "nodeType": "SourceUnit" } }
},
"contracts": {
"/abs/src/A.sol": { "A": { "abi": [] } }
},
"errors": [],
"source_id_to_path": {
"0": "/abs/src/A.sol",
"1": "/abs/src/B.sol"
}
});
let other = json!({
"sources": {
"/abs/lib/C.sol": { "id": 0, "ast": { "nodeType": "SourceUnit" } }
},
"contracts": {
"/abs/lib/C.sol": { "C": { "abi": [] } }
},
"errors": [],
"source_id_to_path": {
"0": "/abs/lib/C.sol"
}
});
merge_normalized_outputs(&mut base, other);
let sources = base["sources"].as_object().unwrap();
assert_eq!(sources.len(), 3);
assert!(sources.contains_key("/abs/lib/C.sol"));
let c_id = sources["/abs/lib/C.sol"]["id"].as_u64().unwrap();
assert_eq!(
c_id, 2,
"remapped id should be max_base_id (2) + original (0)"
);
let id_map = base["source_id_to_path"].as_object().unwrap();
assert_eq!(id_map.len(), 3);
assert_eq!(id_map["2"].as_str().unwrap(), "/abs/lib/C.sol");
let contracts = base["contracts"].as_object().unwrap();
assert_eq!(contracts.len(), 2);
assert!(contracts.contains_key("/abs/lib/C.sol"));
}
#[test]
fn test_merge_normalized_outputs_empty_other() {
let mut base = json!({
"sources": {
"/abs/src/A.sol": { "id": 0, "ast": {} }
},
"contracts": {},
"errors": [],
"source_id_to_path": { "0": "/abs/src/A.sol" }
});
let other = json!({
"sources": {},
"contracts": {},
"errors": [],
"source_id_to_path": {}
});
merge_normalized_outputs(&mut base, other);
let sources = base["sources"].as_object().unwrap();
assert_eq!(sources.len(), 1);
}
#[test]
fn test_merge_normalized_outputs_empty_base() {
let mut base = json!({
"sources": {},
"contracts": {},
"errors": [],
"source_id_to_path": {}
});
let other = json!({
"sources": {
"/abs/lib/X.sol": { "id": 0, "ast": {} }
},
"contracts": {
"/abs/lib/X.sol": { "X": { "abi": [] } }
},
"errors": [],
"source_id_to_path": { "0": "/abs/lib/X.sol" }
});
merge_normalized_outputs(&mut base, other);
let sources = base["sources"].as_object().unwrap();
assert_eq!(sources.len(), 1);
let x_id = sources["/abs/lib/X.sol"]["id"].as_u64().unwrap();
assert_eq!(x_id, 0);
}
}