use solang_parser::pt::{Import, ImportPath, SourceUnitPart};
use std::collections::VecDeque;
use std::path::PathBuf;
#[derive(Debug, Clone)]
struct ResolvedSoliditySources {
files: Vec<(PathBuf, String)>,
combined_source: String,
}
#[derive(Debug, Clone)]
struct ImportRemapping {
prefix: String,
replacement: PathBuf,
}
#[allow(dead_code)] fn resolve_solidity_sources_with_imports(
entry_file: &Path,
include_paths: &[PathBuf],
) -> Result<ResolvedSoliditySources, String> {
resolve_solidity_sources_with_options(entry_file, include_paths, &[])
}
fn resolve_solidity_sources_with_options(
entry_file: &Path,
include_paths: &[PathBuf],
remappings: &[ImportRemapping],
) -> Result<ResolvedSoliditySources, String> {
let mut visited: HashSet<PathBuf> = HashSet::new();
let mut visiting: HashSet<PathBuf> = HashSet::new();
let mut ordered: Vec<(PathBuf, String)> = Vec::new();
let mut stack: VecDeque<PathBuf> = VecDeque::new();
let mut effective_include_paths: Vec<PathBuf> = include_paths.to_vec();
extend_with_auto_node_modules(entry_file, &mut effective_include_paths);
for inc in include_paths {
extend_with_auto_node_modules(inc, &mut effective_include_paths);
}
let mut effective_remappings: Vec<ImportRemapping> = remappings.to_vec();
extend_with_auto_remappings(entry_file, &mut effective_remappings);
for inc in include_paths {
extend_with_auto_remappings(inc, &mut effective_remappings);
}
fn extract_imports(source: &str, file: &Path) -> Result<Vec<String>, String> {
fn offset_to_line_column(source: &str, offset: usize) -> (usize, usize) {
let mut line = 1usize;
let mut column = 1usize;
let mut current = 0usize;
for ch in source.chars() {
if current >= offset {
break;
}
if ch == '\n' {
line += 1;
column = 1;
} else {
column += 1;
}
current += ch.len_utf8();
}
(line, column)
}
let (unit, _comments) = neo_devpack_solidity::frontend::parse_solidity_guarded(source)
.map_err(|diags| {
let summary = diags
.iter()
.map(|diag| {
if let solang_parser::pt::Loc::File(_, start, _) = diag.loc {
let (line, column) = offset_to_line_column(source, start);
format!("{}:{}: {}", line, column, diag.message)
} else {
diag.message.clone()
}
})
.collect::<Vec<_>>()
.join("\n");
format!(
"failed to parse '{}' while resolving imports:\n{}",
file.display(),
summary
)
})?;
let mut imports = Vec::new();
for part in unit.0.iter() {
let SourceUnitPart::ImportDirective(import) = part else {
continue;
};
match import {
Import::Plain(path, _) => {
imports.push(extract_import_path_string(path, file)?);
}
Import::Rename(path, _renames, _) => {
imports.push(extract_import_path_string(path, file)?);
}
Import::GlobalSymbol(path, _, _) => {
imports.push(extract_import_path_string(path, file)?);
}
}
}
Ok(imports)
}
fn extract_import_path_string(path: &ImportPath, file: &Path) -> Result<String, String> {
match path {
ImportPath::Filename(lit) => Ok(lit.string.clone()),
ImportPath::Path(_) => Err(format!(
"unsupported import path kind in '{}': path imports are not supported",
file.display()
)),
}
}
fn resolve_import_file(
import_path: &str,
from_file: &Path,
include_paths: &[PathBuf],
remappings: &[ImportRemapping],
) -> Result<PathBuf, String> {
fn import_aliases(import_path: &str) -> Vec<String> {
let mut aliases = vec![import_path.to_string()];
if let Some(rest) = import_path.strip_prefix("openzeppelin-contracts/contracts/") {
aliases.push(format!("@openzeppelin/contracts/{rest}"));
} else if let Some(rest) =
import_path.strip_prefix("openzeppelin-contracts-upgradeable/contracts/")
{
aliases.push(format!("@openzeppelin/contracts-upgradeable/{rest}"));
} else if let Some(rest) = import_path.strip_prefix("openzeppelin-contracts/") {
aliases.push(format!("@openzeppelin/contracts/{rest}"));
} else if let Some(rest) =
import_path.strip_prefix("openzeppelin-contracts-upgradeable/")
{
aliases.push(format!("@openzeppelin/contracts-upgradeable/{rest}"));
}
for alias in version_pin_aliases(import_path) {
if alias != import_path {
aliases.push(alias);
}
}
aliases
}
let mut working_import = import_path.to_string();
let mut matched_remapping: Option<&ImportRemapping> = None;
for remap in remappings {
if working_import.starts_with(&remap.prefix) {
let suffix = &working_import[remap.prefix.len()..];
let replacement_str = remap.replacement.to_string_lossy();
let stitched = if replacement_str.ends_with('/') || suffix.starts_with('/') {
format!("{replacement_str}{suffix}")
} else if replacement_str.is_empty() {
suffix.to_string()
} else {
format!("{replacement_str}/{suffix}")
};
working_import = stitched;
matched_remapping = Some(remap);
break;
}
}
let mut candidates: Vec<PathBuf> = Vec::new();
let from_dir = from_file.parent().unwrap_or_else(|| Path::new("."));
if matched_remapping.is_some() {
let remapped = Path::new(&working_import);
if remapped.is_absolute() {
candidates.push(remapped.to_path_buf());
} else {
candidates.push(remapped.to_path_buf());
candidates.push(from_dir.join(remapped));
for include_dir in include_paths {
candidates.push(include_dir.join(remapped));
}
}
}
let mut alias_sources: Vec<String> =
import_aliases(&working_import).into_iter().collect();
if matched_remapping.is_some() {
for alias in import_aliases(import_path) {
if !alias_sources.contains(&alias) {
alias_sources.push(alias);
}
}
}
for candidate_import in alias_sources {
let import = Path::new(&candidate_import);
if import.is_absolute() {
candidates.push(import.to_path_buf());
continue;
}
let is_relative_import = candidate_import.starts_with("./")
|| candidate_import.starts_with("../");
if is_relative_import {
candidates.push(from_dir.join(import));
for inc in include_paths {
let Some(virtual_dir) = virtual_dir_under(from_dir, inc) else {
continue;
};
for alt in include_paths {
candidates.push(alt.join(&virtual_dir).join(import));
}
}
} else {
for include_dir in include_paths {
candidates.push(include_dir.join(import));
}
candidates.push(from_dir.join(import));
candidates.push(import.to_path_buf());
}
}
for candidate in candidates {
if candidate.exists() {
return Ok(candidate.canonicalize().unwrap_or(candidate));
}
}
Err(format!(
"failed to resolve import '{import_path}' from '{}'",
from_file.display()
))
}
fn visit_file(
file: &Path,
include_paths: &[PathBuf],
remappings: &[ImportRemapping],
visited: &mut HashSet<PathBuf>,
visiting: &mut HashSet<PathBuf>,
ordered: &mut Vec<(PathBuf, String)>,
stack: &mut VecDeque<PathBuf>,
) -> Result<(), String> {
let canonical = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
if visited.contains(&canonical) {
return Ok(());
}
if !visiting.insert(canonical.clone()) {
return Ok(());
}
stack.push_back(canonical.clone());
let content = fs::read_to_string(&canonical)
.map_err(|err| format!("failed to read '{}': {err}", canonical.display()))?;
let imports = extract_imports(&content, &canonical)?;
for import in imports {
let resolved = resolve_import_file(&import, &canonical, include_paths, remappings)?;
visit_file(
&resolved,
include_paths,
remappings,
visited,
visiting,
ordered,
stack,
)?;
}
stack.pop_back();
visiting.remove(&canonical);
visited.insert(canonical.clone());
ordered.push((canonical, content));
Ok(())
}
visit_file(
entry_file,
&effective_include_paths,
&effective_remappings,
&mut visited,
&mut visiting,
&mut ordered,
&mut stack,
)?;
let mut combined = String::new();
for (idx, (path, content)) in ordered.iter().enumerate() {
if idx > 0 {
combined.push_str("\n\n");
}
combined.push_str(&format!("// --- {}\n", path.display()));
combined.push_str(content);
}
Ok(ResolvedSoliditySources {
files: ordered,
combined_source: combined,
})
}
fn virtual_dir_under(from_dir: &Path, root: &Path) -> Option<PathBuf> {
let canonical_from = from_dir.canonicalize().ok()?;
let canonical_root = root.canonicalize().ok()?;
canonical_from
.strip_prefix(&canonical_root)
.ok()
.map(std::path::Path::to_path_buf)
}
fn extend_with_auto_node_modules(start: &Path, out: &mut Vec<PathBuf>) {
const MAX_CLIMB: usize = 16;
let initial = if start.is_file() {
start.parent().map(Path::to_path_buf)
} else {
Some(start.to_path_buf())
};
let Some(mut cursor) = initial.and_then(|p| p.canonicalize().ok()) else {
return;
};
for _ in 0..MAX_CLIMB {
let candidate = cursor.join("node_modules");
if candidate.is_dir() && !out.contains(&candidate) {
out.push(candidate);
}
match cursor.parent() {
Some(parent) => cursor = parent.to_path_buf(),
None => break,
}
}
}
fn extend_with_auto_remappings(start: &Path, out: &mut Vec<ImportRemapping>) {
const MAX_CLIMB: usize = 16;
let initial = if start.is_file() {
start.parent().map(Path::to_path_buf)
} else {
Some(start.to_path_buf())
};
let Some(start_dir) = initial.and_then(|p| p.canonicalize().ok()) else {
return;
};
let mut seen: HashSet<PathBuf> = HashSet::new();
fn try_load(
dir: &Path,
out: &mut Vec<ImportRemapping>,
seen: &mut HashSet<PathBuf>,
) {
let candidate = dir.join("remappings.txt");
if candidate.is_file() && seen.insert(candidate.clone()) {
if let Ok(loaded) = load_remappings_file(&candidate) {
let base_dir = dir.to_path_buf();
for mut remap in loaded {
if remap.replacement.is_relative() {
remap.replacement = base_dir.join(&remap.replacement);
}
if !out.iter().any(|existing| existing.prefix == remap.prefix) {
out.push(remap);
}
}
}
}
}
let mut cursor = start_dir.clone();
for _ in 0..MAX_CLIMB {
try_load(&cursor, out, &mut seen);
match cursor.parent() {
Some(parent) => cursor = parent.to_path_buf(),
None => break,
}
}
let mut cursor = start_dir;
for _ in 0..MAX_CLIMB {
let node_modules = cursor.join("node_modules");
if node_modules.is_dir() {
scan_node_modules_for_remappings(&node_modules, out, &mut seen);
}
match cursor.parent() {
Some(parent) => cursor = parent.to_path_buf(),
None => break,
}
}
}
fn scan_node_modules_for_remappings(
node_modules: &Path,
out: &mut Vec<ImportRemapping>,
seen: &mut HashSet<PathBuf>,
) {
fn auto_register_foundry_lib(
pkg_dir: &Path,
out: &mut Vec<ImportRemapping>,
) {
let lib_dir = pkg_dir.join("lib");
let Ok(lib_entries) = std::fs::read_dir(&lib_dir) else {
return;
};
for lib_entry in lib_entries.flatten() {
let lib_path = lib_entry.path();
if !lib_path.is_dir() {
continue;
}
let Some(dep_name) = lib_path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let prefix = format!("{dep_name}/");
if !out.iter().any(|existing| existing.prefix == prefix) {
out.push(ImportRemapping {
prefix,
replacement: lib_path.clone(),
});
}
}
}
fn try_load(
dir: &Path,
out: &mut Vec<ImportRemapping>,
seen: &mut HashSet<PathBuf>,
) {
let candidate = dir.join("remappings.txt");
if candidate.is_file() && seen.insert(candidate.clone()) {
if let Ok(loaded) = load_remappings_file(&candidate) {
let base_dir = dir.to_path_buf();
for mut remap in loaded {
if remap.replacement.is_relative() {
remap.replacement = base_dir.join(&remap.replacement);
}
if !out.iter().any(|existing| existing.prefix == remap.prefix) {
out.push(remap);
}
}
}
}
}
let Ok(entries) = std::fs::read_dir(node_modules) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.starts_with('@') {
let Ok(scoped) = std::fs::read_dir(&path) else {
continue;
};
for scoped_entry in scoped.flatten() {
let scoped_path = scoped_entry.path();
if scoped_path.is_dir() {
try_load(&scoped_path, out, seen);
auto_register_foundry_lib(&scoped_path, out);
}
}
} else if path.is_dir() {
try_load(&path, out, seen);
auto_register_foundry_lib(&path, out);
}
}
}
fn version_pin_aliases(import_path: &str) -> Vec<String> {
let head_and_tail = import_path.splitn(2, '/');
let mut iter = head_and_tail;
let Some(head) = iter.next() else { return Vec::new() };
let Some(tail) = iter.next() else { return Vec::new() };
let (scope_prefix, name_and_rest) = if head.starts_with('@') {
(Some(head.to_string()), tail.to_string())
} else {
(None, import_path.to_string())
};
let Some((pkg_name, rest)) = name_and_rest.split_once('/') else {
return Vec::new();
};
let Some((pkg, version)) = pkg_name.split_once('@') else {
return Vec::new();
};
let looks_like_version = version
.chars()
.next()
.is_some_and(|c| c.is_ascii_digit() || (c == 'v' && version.len() > 1));
if !looks_like_version {
return Vec::new();
}
let mut out = Vec::new();
let dashed = match &scope_prefix {
Some(scope) => format!("{scope}/{pkg}-{version}/{rest}"),
None => format!("{pkg}-{version}/{rest}"),
};
out.push(dashed);
let unpinned = match scope_prefix {
Some(scope) => format!("{scope}/{pkg}/{rest}"),
None => format!("{pkg}/{rest}"),
};
out.push(unpinned);
out
}
fn parse_remapping(spec: &str) -> Result<ImportRemapping, String> {
let (prefix, replacement) = spec
.split_once('=')
.ok_or_else(|| format!("invalid remapping '{spec}': expected `prefix=path`"))?;
if prefix.is_empty() {
return Err(format!("invalid remapping '{spec}': prefix is empty"));
}
Ok(ImportRemapping {
prefix: prefix.to_string(),
replacement: PathBuf::from(replacement),
})
}
fn load_remappings_file(path: &Path) -> Result<Vec<ImportRemapping>, String> {
let contents = fs::read_to_string(path)
.map_err(|err| format!("failed to read remappings file '{}': {err}", path.display()))?;
let mut out = Vec::new();
for (line_no, raw) in contents.lines().enumerate() {
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
match parse_remapping(trimmed) {
Ok(remap) => out.push(remap),
Err(err) => {
return Err(format!(
"{}:{}: {err}",
path.display(),
line_no + 1
));
}
}
}
Ok(out)
}