Skip to main content

solidity_language_server/
solc.rs

1//! Direct `solc --standard-json` runner for fast AST generation.
2//!
3//! The output is normalized into the same shape that `forge build --json --ast`
4//! produces, so all downstream consumers (goto, hover, completions, etc.) work
5//! unchanged.
6
7use 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
17/// Cached list of installed solc versions. Populated on first access,
18/// invalidated after a successful `svm::install`.
19static 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
32/// Convert a `semver::Version` (from svm-rs) to our lightweight `SemVer`.
33fn 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
41/// Resolve the path to the solc binary.
42///
43/// Resolution order:
44/// 1. Parse `pragma solidity` from the source file.
45///    - **Exact pragma** (`=0.7.6`): always use the file's version — foundry.toml
46///      cannot override an exact pragma without breaking compilation.
47///    - **Wildcard pragma** (`^0.8.0`, `>=0.8.0`, `>=0.6.2 <0.9.0`): if
48///      `foundry.toml` specifies a solc version that satisfies the constraint,
49///      use it. Otherwise pick the latest matching installed version.
50/// 2. If no pragma, use the `foundry.toml` solc version if set.
51/// 3. If no match is installed, auto-install via `svm install`.
52/// 4. Fall back to whatever `solc` is on `$PATH`.
53pub async fn resolve_solc_binary(
54    config: &FoundryConfig,
55    constraint: Option<&PragmaConstraint>,
56    client: Option<&tower_lsp::Client>,
57) -> PathBuf {
58    // 1. Try pragma constraint (may be tightened from the full import graph)
59    if let Some(constraint) = constraint {
60        // For exact pragmas, always honour the file — foundry.toml can't override
61        // without causing a compilation failure.
62        // For wildcard pragmas, prefer the foundry.toml version if it satisfies
63        // the constraint. This mirrors `forge build` behaviour where the project
64        // config picks the version but the pragma must still be satisfied.
65        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        // No matching version installed — try auto-install via svm
96        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                // Refresh the cached version list after install
108                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    // 2. No pragma — use foundry.toml version if available
134    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    // 3. Fall back to system solc
151    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
161/// Determine which version to install for a pragma constraint.
162///
163/// - Exact: install that version
164/// - Caret `^0.8.20`: install `0.8.20` (minimum satisfying)
165/// - Gte `>=0.8.0`: install `0.8.0` (minimum satisfying)
166/// - Range `>=0.6.2 <0.9.0`: install `0.6.2` (minimum satisfying)
167fn 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
176/// Install a solc version using svm-rs library.
177///
178/// Returns `true` if the install succeeded.
179async 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
187/// Look up a solc binary by version string using `svm::version_binary()`.
188fn 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// ── Pragma parsing ────────────────────────────────────────────────────────
197
198/// A parsed semver version (major.minor.patch).
199#[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/// A version constraint from `pragma solidity`.
227#[derive(Debug, Clone, PartialEq)]
228pub enum PragmaConstraint {
229    /// `0.8.26` — exact match
230    Exact(SemVer),
231    /// `^0.8.0` — same major.minor, patch >= specified
232    /// Actually in Solidity: `^0.8.0` means `>=0.8.0 <0.9.0`
233    Caret(SemVer),
234    /// `>=0.8.0` — at least this version
235    Gte(SemVer),
236    /// `>=0.6.2 <0.9.0` — range
237    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
251/// Resolve a Solidity import path to an absolute filesystem path.
252///
253/// Handles relative imports (`./`, `../`) and remapped imports.
254fn 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
283/// Normalize a path by resolving `.` and `..` components lexically
284/// (without hitting the filesystem).
285fn 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
299/// Collect pragma constraints from a file and all its transitive imports.
300///
301/// Walks the import graph using simple string scanning (no tree-sitter),
302/// resolving import paths via remappings.  Returns all pragmas found so
303/// that the caller can pick a solc version satisfying every file.
304fn 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
345/// Tighten a set of pragma constraints into a single constraint that
346/// satisfies all of them.
347///
348/// Rules:
349/// - An exact pragma always wins (if any file requires `0.8.23`, we must
350///   use exactly `0.8.23`).
351/// - Multiple exact pragmas that disagree → returns the first one (solc
352///   will error anyway, but we still try).
353/// - For wildcard pragmas, compute the intersection range and return it.
354fn tightest_constraint(pragmas: &[PragmaConstraint]) -> Option<PragmaConstraint> {
355    if pragmas.is_empty() {
356        return None;
357    }
358
359    // If any pragma is Exact, that version must be used.
360    for p in pragmas {
361        if matches!(p, PragmaConstraint::Exact(_)) {
362            return Some(p.clone());
363        }
364    }
365
366    // Normalize every constraint to a (lower, upper) range, then intersect.
367    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, // empty intersection
390        Some(hi) => Some(PragmaConstraint::Range(lower, hi)),
391        None => Some(PragmaConstraint::Gte(lower)),
392    }
393}
394
395/// Convert a pragma constraint to an inclusive lower bound and optional
396/// exclusive upper bound.
397fn 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
420/// Parse `pragma solidity <constraint>;` from Solidity source.
421///
422/// Handles:
423/// - `pragma solidity 0.8.26;` → Exact
424/// - `pragma solidity ^0.8.0;` → Caret
425/// - `pragma solidity >=0.8.0;` → Gte
426/// - `pragma solidity >=0.6.2 <0.9.0;` → Range
427pub fn parse_pragma(source: &str) -> Option<PragmaConstraint> {
428    // Find the pragma line — only scan the first ~20 lines for performance
429    let pragma_line = source
430        .lines()
431        .take(20)
432        .find(|line| line.trim_start().starts_with("pragma solidity"))?;
433
434    // Extract the constraint string between "pragma solidity" and ";"
435    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    // Range: >=X.Y.Z <A.B.C
449    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        // Just >=X.Y.Z
464        if let Some(ver) = SemVer::parse(rest) {
465            return Some(PragmaConstraint::Gte(ver));
466        }
467    }
468
469    // Caret: ^X.Y.Z
470    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    // Exact: X.Y.Z
477    if let Some(ver) = SemVer::parse(constraint_str) {
478        return Some(PragmaConstraint::Exact(ver));
479    }
480
481    None
482}
483
484/// List installed solc versions (cached — use `get_installed_versions()` internally).
485pub fn list_installed_versions() -> Vec<SemVer> {
486    get_installed_versions()
487}
488
489/// Scan the filesystem for installed solc versions using `svm::installed_versions()`.
490///
491/// Returns sorted, deduplicated versions (ascending).
492fn scan_installed_versions() -> Vec<SemVer> {
493    svm::installed_versions()
494        .unwrap_or_default()
495        .iter()
496        .map(semver_to_local)
497        .collect()
498}
499
500/// Find the best matching installed version for a pragma constraint.
501///
502/// For all constraint types, picks the **latest** installed version that
503/// satisfies the constraint.
504pub 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    // Pick the latest (last, since installed is sorted ascending)
514    candidates.last().cloned().cloned()
515}
516
517/// Check if a version satisfies a pragma constraint.
518pub fn version_satisfies(version: &SemVer, constraint: &PragmaConstraint) -> bool {
519    match constraint {
520        PragmaConstraint::Exact(v) => version == v,
521        PragmaConstraint::Caret(v) => {
522            // Solidity caret: ^0.8.0 means >=0.8.0 <0.9.0
523            // i.e. same major, next minor is the ceiling
524            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
531/// Fetch remappings by running `forge remappings` in the project root.
532///
533/// Falls back to config remappings, then to an empty list.
534pub async fn resolve_remappings(config: &FoundryConfig) -> Vec<String> {
535    // Try `forge remappings` first — it merges all sources (foundry.toml,
536    // remappings.txt, auto-detected libs).
537    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    // Fall back to remappings from foundry.toml
559    if !config.remappings.is_empty() {
560        return config.remappings.clone();
561    }
562
563    // Fall back to remappings.txt at project root
564    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
576/// Build the `--standard-json` input for solc.
577///
578/// Reads compiler settings from the `FoundryConfig` (parsed from `foundry.toml`)
579/// and maps them to the solc standard JSON `settings` object:
580///
581/// - `via_ir` → `settings.viaIR`
582/// - `evm_version` → `settings.evmVersion`
583///
584/// Note: `optimizer` and `evm.gasEstimates` are intentionally excluded.
585/// The optimizer adds ~3s and doesn't affect AST/doc quality.
586/// Gas estimates force solc through full EVM codegen — benchmarking on
587/// a 510-file project showed 56s with vs 6s without (88% of cost).
588/// Build a standard-json input for a single-file solc compilation.
589///
590/// Only the entry file is listed in `sources`.  Solc discovers imported
591/// files on its own via the import callback and reads them from disk.
592///
593/// When `source_content` is provided, the text is inlined as `"content"`
594/// so solc compiles the editor's live buffer instead of the on-disk version.
595/// When `None`, solc reads the file from disk via `"urls"`.
596pub 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    // EVM version
625    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    // Build sources manually to avoid json! macro key interpolation issues.
635    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
645/// Run `solc --standard-json` and return the parsed output.
646pub 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    // Write the standard-json input to solc's stdin.
663    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        // Drop stdin to close it, signaling EOF to solc.
670    }
671
672    let output = child
673        .wait_with_output()
674        .await
675        .map_err(RunnerError::CommandError)?;
676
677    // solc writes JSON to stdout even on errors (errors are in the JSON)
678    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
690/// Normalize raw solc `--standard-json` output into the canonical shape.
691///
692/// Solc's native shape is already close to canonical:
693/// - `sources[path] = { id, ast }` — kept as-is
694/// - `contracts[path][name] = { abi, evm, ... }` — kept as-is
695/// - `errors` — kept as-is (defaults to `[]` if absent)
696///
697/// When `project_root` is provided, relative source paths are resolved to
698/// absolute paths so that downstream code (goto, hover, links) can map AST
699/// paths back to `file://` URIs. This is necessary because `solc_ast()`
700/// passes a relative path to solc (to fix import resolution), and solc then
701/// returns relative paths in the AST `absolutePath` and source keys.
702///
703/// Constructs `source_id_to_path` from source IDs for cross-file resolution.
704///
705/// Takes ownership and uses `Value::take()` to move AST nodes in-place,
706/// avoiding expensive clones of multi-MB AST data.
707///
708/// Also resolves `absolutePath` on nested `ImportDirective` nodes so that
709/// goto-definition on import strings works regardless of CWD.
710pub fn normalize_solc_output(mut solc_output: Value, project_root: Option<&Path>) -> Value {
711    /// Walk an AST node tree and resolve `absolutePath` on `ImportDirective` nodes.
712    fn resolve_import_absolute_paths(node: &mut Value, resolve: &dyn Fn(&str) -> String) {
713        let is_import = node.get("nodeType").and_then(|v| v.as_str()) == Some("ImportDirective");
714
715        if is_import {
716            if let Some(abs_path) = node.get("absolutePath").and_then(|v| v.as_str()) {
717                let resolved = resolve(abs_path);
718                node.as_object_mut()
719                    .unwrap()
720                    .insert("absolutePath".to_string(), json!(resolved));
721            }
722        }
723
724        // Recurse into "nodes" array (top-level AST children)
725        if let Some(nodes) = node.get_mut("nodes").and_then(|v| v.as_array_mut()) {
726            for child in nodes {
727                resolve_import_absolute_paths(child, resolve);
728            }
729        }
730    }
731    let mut result = Map::new();
732
733    // Move errors out (defaults to [] if absent)
734    let errors = solc_output
735        .get_mut("errors")
736        .map(Value::take)
737        .unwrap_or_else(|| json!([]));
738    result.insert("errors".to_string(), errors);
739
740    // Helper: resolve a path to absolute using the project root.
741    // If the path is already absolute or no project root is given, return as-is.
742    let resolve = |p: &str| -> String {
743        if let Some(root) = project_root {
744            let path = Path::new(p);
745            if path.is_relative() {
746                return root.join(path).to_string_lossy().into_owned();
747            }
748        }
749        p.to_string()
750    };
751
752    // Sources: rekey with absolute paths and update AST absolutePath fields.
753    // Also build source_id_to_path for cross-file resolution.
754    let mut source_id_to_path = Map::new();
755    let mut resolved_sources = Map::new();
756
757    if let Some(sources) = solc_output
758        .get_mut("sources")
759        .and_then(|s| s.as_object_mut())
760    {
761        // Collect keys first to avoid borrow issues
762        let keys: Vec<String> = sources.keys().cloned().collect();
763        for key in keys {
764            if let Some(mut source_data) = sources.remove(&key) {
765                let abs_key = resolve(&key);
766
767                // Update the AST absolutePath field to match, and resolve
768                // absolutePath on nested ImportDirective nodes so that
769                // goto-definition works regardless of CWD.
770                if let Some(ast) = source_data.get_mut("ast") {
771                    if let Some(abs_path) = ast.get("absolutePath").and_then(|v| v.as_str()) {
772                        let resolved = resolve(abs_path);
773                        ast.as_object_mut()
774                            .unwrap()
775                            .insert("absolutePath".to_string(), json!(resolved));
776                    }
777                    resolve_import_absolute_paths(ast, &resolve);
778                }
779
780                if let Some(id) = source_data.get("id") {
781                    source_id_to_path.insert(id.to_string(), json!(&abs_key));
782                }
783
784                resolved_sources.insert(abs_key, source_data);
785            }
786        }
787    }
788
789    result.insert("sources".to_string(), Value::Object(resolved_sources));
790
791    // Contracts: rekey with absolute paths
792    let mut resolved_contracts = Map::new();
793    if let Some(contracts) = solc_output
794        .get_mut("contracts")
795        .and_then(|c| c.as_object_mut())
796    {
797        let keys: Vec<String> = contracts.keys().cloned().collect();
798        for key in keys {
799            if let Some(contract_data) = contracts.remove(&key) {
800                resolved_contracts.insert(resolve(&key), contract_data);
801            }
802        }
803    }
804    result.insert("contracts".to_string(), Value::Object(resolved_contracts));
805
806    // Construct source_id_to_path for cross-file resolution
807    result.insert(
808        "source_id_to_path".to_string(),
809        Value::Object(source_id_to_path),
810    );
811
812    Value::Object(result)
813}
814
815/// Normalize forge `build --json --ast` output into the canonical shape.
816///
817/// Forge wraps data in arrays with metadata:
818/// - `sources[path] = [{ source_file: { id, ast }, build_id, profile, version }]`
819/// - `contracts[path][name] = [{ contract: { abi, evm, ... }, build_id, profile, version }]`
820/// - `build_infos = [{ source_id_to_path: { ... } }]`
821///
822/// This unwraps to the canonical flat shape:
823/// - `sources[path] = { id, ast }`
824/// - `contracts[path][name] = { abi, evm, ... }`
825/// - `source_id_to_path = { ... }`
826pub fn normalize_forge_output(mut forge_output: Value) -> Value {
827    let mut result = Map::new();
828
829    // Move errors out
830    let errors = forge_output
831        .get_mut("errors")
832        .map(Value::take)
833        .unwrap_or_else(|| json!([]));
834    result.insert("errors".to_string(), errors);
835
836    // Unwrap sources: [{ source_file: { id, ast } }] → { id, ast }
837    let mut normalized_sources = Map::new();
838    if let Some(sources) = forge_output
839        .get_mut("sources")
840        .and_then(|s| s.as_object_mut())
841    {
842        for (path, entries) in sources.iter_mut() {
843            if let Some(arr) = entries.as_array_mut()
844                && let Some(first) = arr.first_mut()
845                && let Some(sf) = first.get_mut("source_file")
846            {
847                normalized_sources.insert(path.clone(), sf.take());
848            }
849        }
850    }
851    result.insert("sources".to_string(), Value::Object(normalized_sources));
852
853    // Unwrap contracts: [{ contract: { ... } }] → { ... }
854    let mut normalized_contracts = Map::new();
855    if let Some(contracts) = forge_output
856        .get_mut("contracts")
857        .and_then(|c| c.as_object_mut())
858    {
859        for (path, names) in contracts.iter_mut() {
860            let mut path_contracts = Map::new();
861            if let Some(names_obj) = names.as_object_mut() {
862                for (name, entries) in names_obj.iter_mut() {
863                    if let Some(arr) = entries.as_array_mut()
864                        && let Some(first) = arr.first_mut()
865                        && let Some(contract) = first.get_mut("contract")
866                    {
867                        path_contracts.insert(name.clone(), contract.take());
868                    }
869                }
870            }
871            normalized_contracts.insert(path.clone(), Value::Object(path_contracts));
872        }
873    }
874    result.insert("contracts".to_string(), Value::Object(normalized_contracts));
875
876    // Extract source_id_to_path from build_infos
877    let source_id_to_path = forge_output
878        .get_mut("build_infos")
879        .and_then(|bi| bi.as_array_mut())
880        .and_then(|arr| arr.first_mut())
881        .and_then(|info| info.get_mut("source_id_to_path"))
882        .map(Value::take)
883        .unwrap_or_else(|| json!({}));
884    result.insert("source_id_to_path".to_string(), source_id_to_path);
885
886    Value::Object(result)
887}
888
889/// Run solc for a file and return normalized output.
890///
891/// This is the main entry point used by the LSP. Reads the file source
892/// to detect the pragma version and resolve the correct solc binary.
893///
894/// When `content_cache` is provided, open editor buffers are fed to solc
895/// When `source_content` is provided, the text is inlined so solc compiles
896/// the editor's live buffer instead of reading from disk.
897pub async fn solc_ast(
898    file_path: &str,
899    config: &FoundryConfig,
900    client: Option<&tower_lsp::Client>,
901    source_content: Option<&str>,
902) -> Result<Value, RunnerError> {
903    let remappings = resolve_remappings(config).await;
904
905    // Collect pragma constraints from the file and all its transitive imports
906    // so we pick a solc version that satisfies the entire dependency graph.
907    // This is a synchronous recursive FS crawl — run it on the blocking pool
908    // so we don't stall the tokio async runtime on large projects.
909    let file_abs = Path::new(file_path).to_path_buf();
910    let config_root = config.root.clone();
911    let remappings_clone = remappings.clone();
912    let pragmas = tokio::task::spawn_blocking(move || {
913        collect_import_pragmas(&file_abs, &config_root, &remappings_clone)
914    })
915    .await
916    .unwrap_or_default();
917    let constraint = tightest_constraint(&pragmas);
918    let solc_binary = resolve_solc_binary(config, constraint.as_ref(), client).await;
919
920    // Solc's import resolver fails when sources use absolute paths — it resolves
921    // 0 transitive imports, causing "No matching declaration found" errors for
922    // inherited members. Convert to a path relative to the project root so solc
923    // can properly resolve `src/`, `lib/`, and remapped imports.
924    let rel_path = Path::new(file_path)
925        .strip_prefix(&config.root)
926        .map(|p| p.to_string_lossy().into_owned())
927        .unwrap_or_else(|_| file_path.to_string());
928
929    let input = build_standard_json_input(&rel_path, &remappings, config, source_content);
930    let raw_output = run_solc(&solc_binary, &input, &config.root).await?;
931
932    Ok(normalize_solc_output(raw_output, Some(&config.root)))
933}
934
935/// Run solc for build diagnostics (same output, just used for error extraction).
936pub async fn solc_build(
937    file_path: &str,
938    config: &FoundryConfig,
939    client: Option<&tower_lsp::Client>,
940) -> Result<Value, RunnerError> {
941    solc_ast(file_path, config, client, None).await
942}
943
944// ── Project-wide indexing ──────────────────────────────────────────────────
945
946/// Discover all Solidity source files under the project root.
947///
948/// Walks the entire project directory, including `test/`, `script/`, and
949/// any other user-authored directories. Only skips:
950/// - Directories listed in `config.libs` (default: `["lib"]`)
951/// - Directories in `DISCOVER_SKIP_DIRS` (build artifacts)
952/// - Hidden directories (starting with `.`)
953///
954/// Includes `.t.sol` (test) and `.s.sol` (script) files so that
955/// find-references and rename work across the full project.
956/// Discover the project's own source files by walking only the directories
957/// configured in `foundry.toml`: `src`, `test`, and `script`.
958///
959/// This mirrors how Forge discovers compilable files — it never walks
960/// directories outside these three (plus libs).  Stray directories like
961/// `certora/` or `hardhat/` are ignored, preventing broken imports from
962/// poisoning the solc batch.
963pub fn discover_source_files(config: &FoundryConfig) -> Vec<PathBuf> {
964    discover_source_files_inner(config, false)
965}
966
967/// Discover only the `src` directory files (no test, no script).
968///
969/// Used as the seed set for phase-1 of two-phase project indexing, where
970/// we want to compile only the production source closure first for fast
971/// time-to-first-reference.
972pub fn discover_src_only_files(config: &FoundryConfig) -> Vec<PathBuf> {
973    let root = &config.root;
974    if !root.is_dir() {
975        return Vec::new();
976    }
977    let mut files = Vec::new();
978    let dir = root.join(&config.sources_dir);
979    if dir.is_dir() {
980        discover_recursive(&dir, &[], &mut files);
981    }
982    files.sort();
983    files
984}
985
986/// Discover the compilation closure seeded only from `src` files.
987///
988/// Like [`discover_compilation_closure`] but seeds only from
989/// [`discover_src_only_files`] instead of all project directories.
990/// This produces the minimal set of files needed to compile the
991/// production source code, excluding test and script files.
992pub fn discover_src_only_closure(config: &FoundryConfig, remappings: &[String]) -> Vec<PathBuf> {
993    let seeds = discover_src_only_files(config);
994    let mut visited: HashSet<PathBuf> = HashSet::new();
995    let mut queue: std::collections::VecDeque<PathBuf> = seeds.into_iter().collect();
996
997    while let Some(file) = queue.pop_front() {
998        if !visited.insert(file.clone()) {
999            continue;
1000        }
1001        let source = match std::fs::read_to_string(&file) {
1002            Ok(s) => s,
1003            Err(_) => continue,
1004        };
1005        for imp in links::ts_find_imports(source.as_bytes()) {
1006            if let Some(abs) = resolve_import_to_abs(&config.root, &file, &imp.path, remappings) {
1007                if abs.exists() && !visited.contains(&abs) {
1008                    queue.push_back(abs);
1009                }
1010            }
1011        }
1012    }
1013
1014    let mut result: Vec<PathBuf> = visited.into_iter().collect();
1015    result.sort();
1016    result
1017}
1018
1019/// Discover source files including library directories.
1020///
1021/// When `fullProjectScan` is enabled, this includes files from the configured
1022/// `libs` directories (e.g. `dependencies/`, `node_modules/`).  Files with
1023/// incompatible pragma versions are handled by the error-driven retry loop
1024/// in [`solc_project_index_from_files`].
1025pub fn discover_source_files_with_libs(config: &FoundryConfig) -> Vec<PathBuf> {
1026    discover_source_files_inner(config, true)
1027}
1028
1029fn discover_source_files_inner(config: &FoundryConfig, include_libs: bool) -> Vec<PathBuf> {
1030    let root = &config.root;
1031    if !root.is_dir() {
1032        return Vec::new();
1033    }
1034
1035    let mut files = Vec::new();
1036    let no_skip: &[String] = &[];
1037
1038    // Walk only the configured source directories (src, test, script).
1039    // This matches Forge's behaviour: only files under these three directories
1040    // are considered project sources.  Directories like `certora/`, `hardhat/`,
1041    // etc. are never seeded.
1042    for dir_name in [&config.sources_dir, &config.test_dir, &config.script_dir] {
1043        let dir = root.join(dir_name);
1044        if dir.is_dir() {
1045            discover_recursive(&dir, no_skip, &mut files);
1046        }
1047    }
1048
1049    // When include_libs is requested, also walk lib directories.
1050    if include_libs {
1051        for lib_name in &config.libs {
1052            let lib_dir = root.join(lib_name);
1053            if lib_dir.is_dir() {
1054                discover_recursive(&lib_dir, no_skip, &mut files);
1055            }
1056        }
1057    }
1058
1059    files.sort();
1060    files
1061}
1062
1063/// Discover the true compilation closure by tracing imports from the
1064/// project's own source files (`src/`, `test/`, `script/`, and any other
1065/// non-lib top-level directories).
1066///
1067/// Starting from every `.sol` file returned by [`discover_source_files`]
1068/// (project files only, no lib dirs), this BFS-walks the import graph using
1069/// the provided remappings to resolve each `import` statement to an absolute
1070/// path.  It adds every reachable file — including lib files that are actually
1071/// imported — to the result set.
1072///
1073/// Files whose imports cannot be resolved (missing external deps that aren't
1074/// in this project) are silently skipped at that edge; the importer is still
1075/// included.
1076///
1077/// This produces a much smaller, self-consistent set than scanning all files
1078/// in lib directories, and avoids pulling in lib files that have broken
1079/// transitive deps (e.g. chainlink automation files that need `@eth-optimism`
1080/// which is not vendored here).
1081pub fn discover_compilation_closure(config: &FoundryConfig, remappings: &[String]) -> Vec<PathBuf> {
1082    // Seed: all project source files (no lib dirs).
1083    let seeds = discover_source_files(config);
1084    let mut visited: HashSet<PathBuf> = HashSet::new();
1085    let mut queue: std::collections::VecDeque<PathBuf> = seeds.into_iter().collect();
1086
1087    while let Some(file) = queue.pop_front() {
1088        if !visited.insert(file.clone()) {
1089            continue;
1090        }
1091        let source = match std::fs::read_to_string(&file) {
1092            Ok(s) => s,
1093            Err(_) => continue,
1094        };
1095        for imp in links::ts_find_imports(source.as_bytes()) {
1096            if let Some(abs) = resolve_import_to_abs(&config.root, &file, &imp.path, remappings) {
1097                if abs.exists() && !visited.contains(&abs) {
1098                    queue.push_back(abs);
1099                }
1100            }
1101        }
1102    }
1103
1104    let mut result: Vec<PathBuf> = visited.into_iter().collect();
1105    result.sort();
1106    result
1107}
1108
1109/// Directories that are always skipped during source file discovery,
1110/// regardless of the `include_libs` setting.
1111const 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                // Skip hidden directories (e.g., .git, .github)
1123                if name.starts_with('.') {
1124                    continue;
1125                }
1126                // Skip build artifact directories
1127                if DISCOVER_SKIP_DIRS.contains(&name) {
1128                    continue;
1129                }
1130                // Skip user-configured library directories (unless include_libs)
1131                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
1144/// Build a `--standard-json` input that compiles all given source files at once.
1145///
1146/// Each file is added as a source entry with a `urls` field (relative to project root).
1147/// This produces a single AST covering the entire project in one solc invocation.
1148///
1149/// See [`build_standard_json_input`] for rationale on excluded settings.
1150pub 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
1158/// Build a batch standard-json input for solc.
1159///
1160/// When `content_cache` is provided, files whose URI string appears as a key
1161/// are included with `"content"` (in-memory source).  Files not in the cache
1162/// fall back to `"urls"` (solc reads from disk).
1163///
1164/// This allows the re-index after a rename to feed solc the updated import
1165/// paths from our text_cache without requiring the editor to have flushed
1166/// them to disk yet.
1167pub 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        // Try to use cached content so solc doesn't need to read from disk.
1206        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
1225/// Build an AST-only batch standard-json input for sub-cache builds.
1226///
1227/// Unlike the full batch input, this omits all codegen-affecting settings
1228/// (`viaIR`, `evmVersion`, optimizer) and only requests the AST — no
1229/// `devdoc`, `userdoc`, or `evm.methodIdentifiers`.  This is significantly
1230/// faster because solc skips type-checking contract outputs and codegen.
1231///
1232/// Sub-caches only need the AST for cross-file reference lookup (node IDs,
1233/// `referencedDeclaration`, source locations).
1234pub 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
1264/// Build a parse-only standard-json input (``stopAfter: "parsing"``).
1265///
1266/// Unlike the full batch input this mode stops before import resolution and
1267/// type-checking.  That means:
1268///
1269/// * No version 5333 errors cascade from imported incompatible files — the
1270///   compatible files are NOT fetched from disk as imports.
1271/// * The resulting ASTs contain all declaration nodes and local
1272///   ``referencedDeclaration`` IDs but **not** cross-file resolved IDs.
1273/// * Only ``ast`` output is requested; contract outputs (abi, gas …) are
1274///   omitted because they require type-checking.
1275///
1276/// This is used for the compatible-file batch in the mixed-version project
1277/// index so we can get parse-time ASTs for all project/lib files that satisfy
1278/// the project pragma, without being blocked by imports into incompatible lib
1279/// files.
1280pub 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
1311/// Run a project-wide solc compilation and return normalized output.
1312///
1313/// Discovers all source files, compiles them in a single `solc --standard-json`
1314/// invocation, and returns the normalized AST data.
1315///
1316/// When `text_cache` is provided, files whose URI string appears as a key
1317/// are fed to solc via `"content"` (in-memory) rather than `"urls"` (disk).
1318/// This ensures the re-index after a rename uses the updated import paths
1319/// from our cache, even if the editor hasn't flushed them to disk yet.
1320pub 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    // Resolve remappings first — needed for import tracing.
1326    let remappings = resolve_remappings(config).await;
1327
1328    // Trace imports from project source files to find the true compilation
1329    // closure.  This avoids pulling in lib files that are never imported by
1330    // the project (e.g. chainlink automation files that need @eth-optimism,
1331    // which isn't vendored here).
1332    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
1342/// AST-only project index for sub-cache builds.
1343///
1344/// Identical to [`solc_project_index`] but requests only AST output —
1345/// no `devdoc`, `userdoc`, or `evm.methodIdentifiers`.  Also omits
1346/// `viaIR`, `evmVersion`, and optimizer settings since they only affect
1347/// codegen (which is skipped when no contract outputs are requested).
1348///
1349/// This is significantly faster because solc skips all codegen work.
1350/// "Stack too deep" errors cannot occur in AST-only mode.
1351pub 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
1365/// AST-only compile over a list of source files.
1366///
1367/// Like [`solc_project_index_from_files`] but uses
1368/// [`build_batch_standard_json_input_ast_only`] — no codegen settings,
1369/// no contract outputs.
1370async 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    // Pre-scan pragmas to separate compatible vs incompatible files.
1397    let (compatible_files, incompatible_files) = if let Some(ref ver) = project_version {
1398        let mut compat = Vec::with_capacity(source_files.len());
1399        let mut incompat = Vec::new();
1400        for file in source_files {
1401            let is_compatible = std::fs::read_to_string(file)
1402                .ok()
1403                .and_then(|src| parse_pragma(&src))
1404                .map(|pragma| version_satisfies(ver, &pragma))
1405                .unwrap_or(true);
1406            if is_compatible {
1407                compat.push(file.clone());
1408            } else {
1409                incompat.push(file.clone());
1410            }
1411        }
1412        (compat, incompat)
1413    } else {
1414        (source_files.to_vec(), Vec::new())
1415    };
1416
1417    if !incompatible_files.is_empty() {
1418        if let Some(c) = client {
1419            c.log_message(
1420                tower_lsp::lsp_types::MessageType::INFO,
1421                format!(
1422                    "project index: {} compatible, {} incompatible with solc {}",
1423                    compatible_files.len(),
1424                    incompatible_files.len(),
1425                    project_version
1426                        .as_ref()
1427                        .map(|v| v.to_string())
1428                        .unwrap_or_default(),
1429                ),
1430            )
1431            .await;
1432        }
1433    }
1434
1435    let mut result = if compatible_files.is_empty() {
1436        json!({"sources": {}, "contracts": {}, "errors": [], "source_id_to_path": {}})
1437    } else {
1438        let input =
1439            build_batch_standard_json_input_ast_only(&compatible_files, &remappings, &config.root);
1440        let raw = run_solc(&solc_binary, &input, &config.root).await?;
1441        normalize_solc_output(raw, Some(&config.root))
1442    };
1443
1444    if incompatible_files.is_empty() {
1445        return Ok(result);
1446    }
1447
1448    // Compile incompatible files individually with their own solc versions.
1449    for file in &incompatible_files {
1450        let pragma = std::fs::read_to_string(file)
1451            .ok()
1452            .and_then(|src| parse_pragma(&src));
1453        let file_binary = resolve_solc_binary(config, pragma.as_ref(), client).await;
1454        let input =
1455            build_batch_standard_json_input_ast_only(&[file.clone()], &remappings, &config.root);
1456        if let Ok(raw) = run_solc(&file_binary, &input, &config.root).await {
1457            let normalized = normalize_solc_output(raw, Some(&config.root));
1458            merge_normalized_outputs(&mut result, normalized);
1459        }
1460    }
1461
1462    if let Some(c) = client {
1463        let total = result
1464            .get("sources")
1465            .and_then(|s| s.as_object())
1466            .map_or(0, |obj| obj.len());
1467        c.log_message(
1468            tower_lsp::lsp_types::MessageType::INFO,
1469            format!(
1470                "project index: compiled {} files ({} needed different solc version)",
1471                total,
1472                incompatible_files.len(),
1473            ),
1474        )
1475        .await;
1476    }
1477
1478    Ok(result)
1479}
1480
1481/// Run a scoped project-index compile over a selected file list.
1482///
1483/// This is intended for aggressive incremental reindex strategies where only
1484/// a dependency-closure subset should be recompiled.
1485pub async fn solc_project_index_scoped(
1486    config: &FoundryConfig,
1487    client: Option<&tower_lsp::Client>,
1488    text_cache: Option<&HashMap<crate::types::DocumentUri, (i32, String)>>,
1489    source_files: &[PathBuf],
1490) -> Result<Value, RunnerError> {
1491    if source_files.is_empty() {
1492        return Err(RunnerError::CommandError(std::io::Error::other(
1493            "no source files provided for scoped project index",
1494        )));
1495    }
1496
1497    solc_project_index_from_files(config, client, text_cache, source_files).await
1498}
1499
1500/// Extract source file paths from solc error code 5333 ("Source file requires
1501/// different compiler version") errors.  Returns the relative paths exactly
1502/// as they appear in `sourceLocation.file`.
1503#[cfg(test)]
1504fn extract_version_error_files(solc_output: &Value) -> HashSet<String> {
1505    let mut files = HashSet::new();
1506    if let Some(errors) = solc_output.get("errors").and_then(|e| e.as_array()) {
1507        for err in errors {
1508            let is_5333 = err.get("errorCode").and_then(|c| c.as_str()) == Some("5333");
1509            if is_5333
1510                && let Some(file) = err
1511                    .get("sourceLocation")
1512                    .and_then(|sl| sl.get("file"))
1513                    .and_then(|f| f.as_str())
1514            {
1515                files.insert(file.to_string());
1516            }
1517        }
1518    }
1519    files
1520}
1521
1522/// Extract source file paths from solc error code 6275 ("Source not found")
1523/// errors.  Returns the relative paths of source files whose imports failed.
1524#[cfg(test)]
1525#[allow(dead_code)]
1526fn extract_import_error_files(solc_output: &Value) -> HashSet<String> {
1527    let mut files = HashSet::new();
1528    if let Some(errors) = solc_output.get("errors").and_then(|e| e.as_array()) {
1529        for err in errors {
1530            let is_6275 = err.get("errorCode").and_then(|c| c.as_str()) == Some("6275");
1531            if is_6275
1532                && let Some(file) = err
1533                    .get("sourceLocation")
1534                    .and_then(|sl| sl.get("file"))
1535                    .and_then(|f| f.as_str())
1536            {
1537                files.insert(file.to_string());
1538            }
1539        }
1540    }
1541    files
1542}
1543
1544/// Build a reverse-import closure: given a set of files to exclude, find all
1545/// files that transitively import any of them.  Those files must also be
1546/// excluded because solc will still resolve their imports from disk and fail.
1547///
1548/// Returns the full exclusion set (seed files + their transitive importers).
1549#[cfg(test)]
1550fn reverse_import_closure(
1551    source_files: &[PathBuf],
1552    exclude_abs: &HashSet<PathBuf>,
1553    project_root: &Path,
1554    remappings: &[String],
1555) -> HashSet<PathBuf> {
1556    // Build forward import graph: file -> set of files it imports.
1557    // Then invert to get reverse edges: imported_file -> set of importers.
1558    let mut reverse_edges: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
1559
1560    for file in source_files {
1561        let Ok(bytes) = std::fs::read(file) else {
1562            continue;
1563        };
1564        for imp in links::ts_find_imports(&bytes) {
1565            if let Some(imported_abs) =
1566                resolve_import_to_abs(project_root, file, &imp.path, remappings)
1567            {
1568                reverse_edges
1569                    .entry(imported_abs)
1570                    .or_default()
1571                    .insert(file.clone());
1572            }
1573        }
1574    }
1575
1576    // BFS from excluded files through reverse edges.
1577    let mut closure: HashSet<PathBuf> = exclude_abs.clone();
1578    let mut queue: std::collections::VecDeque<PathBuf> = exclude_abs.iter().cloned().collect();
1579
1580    while let Some(current) = queue.pop_front() {
1581        if let Some(importers) = reverse_edges.get(&current) {
1582            for importer in importers {
1583                if closure.insert(importer.clone()) {
1584                    queue.push_back(importer.clone());
1585                }
1586            }
1587        }
1588    }
1589
1590    closure
1591}
1592
1593/// Merge two normalized solc outputs at the `Value` level.
1594///
1595/// Combines `sources`, `contracts`, `source_id_to_path`, and `errors` from
1596/// `other` into `base`.  Source IDs in `other` are remapped to avoid
1597/// collisions with `base`.
1598fn merge_normalized_outputs(base: &mut Value, other: Value) {
1599    // Merge sources (keyed by absolute path — no collisions across partitions).
1600    if let (Some(base_sources), Some(other_sources)) = (
1601        base.get_mut("sources").and_then(|s| s.as_object_mut()),
1602        other.get("sources").and_then(|s| s.as_object()),
1603    ) {
1604        // Find the max source ID in base so we can remap other's IDs.
1605        let max_base_id = base_sources
1606            .values()
1607            .filter_map(|v| v.get("id").and_then(|id| id.as_u64()))
1608            .max()
1609            .map(|m| m + 1)
1610            .unwrap_or(0);
1611
1612        // Collect other's id -> path mappings for source_id_to_path.
1613        let mut remapped_id_to_path: Vec<(String, String)> = Vec::new();
1614
1615        for (path, mut source_data) in other_sources.clone() {
1616            // Remap the source ID to avoid collisions.
1617            if let Some(id) = source_data.get("id").and_then(|id| id.as_u64()) {
1618                let new_id = id + max_base_id;
1619                source_data
1620                    .as_object_mut()
1621                    .unwrap()
1622                    .insert("id".to_string(), json!(new_id));
1623                remapped_id_to_path.push((new_id.to_string(), path.clone()));
1624            }
1625            base_sources.insert(path, source_data);
1626        }
1627
1628        // Merge source_id_to_path.
1629        if let Some(base_id_map) = base
1630            .get_mut("source_id_to_path")
1631            .and_then(|m| m.as_object_mut())
1632        {
1633            for (id, path) in remapped_id_to_path {
1634                base_id_map.insert(id, json!(path));
1635            }
1636        }
1637    }
1638
1639    // Merge contracts.
1640    if let (Some(base_contracts), Some(other_contracts)) = (
1641        base.get_mut("contracts").and_then(|c| c.as_object_mut()),
1642        other.get("contracts").and_then(|c| c.as_object()),
1643    ) {
1644        for (path, contract_data) in other_contracts {
1645            base_contracts.insert(path.clone(), contract_data.clone());
1646        }
1647    }
1648
1649    // Don't merge errors — the retry errors from incompatible files are noise.
1650    // The base already has the clean errors from the successful compilation.
1651}
1652
1653async fn solc_project_index_from_files(
1654    config: &FoundryConfig,
1655    client: Option<&tower_lsp::Client>,
1656    text_cache: Option<&HashMap<crate::types::DocumentUri, (i32, String)>>,
1657    source_files: &[PathBuf],
1658) -> Result<Value, RunnerError> {
1659    if source_files.is_empty() {
1660        return Err(RunnerError::CommandError(std::io::Error::other(
1661            "no source files found for project index",
1662        )));
1663    }
1664
1665    let remappings = resolve_remappings(config).await;
1666
1667    // Resolve the project's solc version from foundry.toml.
1668    let project_version: Option<SemVer> =
1669        config.solc_version.as_ref().and_then(|v| SemVer::parse(v));
1670
1671    // When no version is pinned in foundry.toml, derive a constraint from the
1672    // source files' pragmas so that svm can auto-install a matching binary.
1673    let constraint: Option<PragmaConstraint> = if let Some(ref v) = project_version {
1674        Some(PragmaConstraint::Exact(v.clone()))
1675    } else {
1676        source_files.iter().find_map(|f| {
1677            std::fs::read_to_string(f)
1678                .ok()
1679                .and_then(|src| parse_pragma(&src))
1680        })
1681    };
1682    let solc_binary = resolve_solc_binary(config, constraint.as_ref(), client).await;
1683
1684    // -- Pre-scan pragmas to separate compatible vs incompatible files. --
1685    //
1686    // Solc emits ZERO ASTs when any file in the batch has a version error
1687    // (5333).  We must exclude incompatible files before compiling so the
1688    // batch produces full AST output for all compatible files.
1689    let (compatible_files, incompatible_files) = if let Some(ref ver) = project_version {
1690        let mut compat = Vec::with_capacity(source_files.len());
1691        let mut incompat = Vec::new();
1692        for file in source_files {
1693            let is_compatible = std::fs::read_to_string(file)
1694                .ok()
1695                .and_then(|src| parse_pragma(&src))
1696                .map(|pragma| version_satisfies(ver, &pragma))
1697                // Files without a pragma are assumed compatible.
1698                .unwrap_or(true);
1699            if is_compatible {
1700                compat.push(file.clone());
1701            } else {
1702                incompat.push(file.clone());
1703            }
1704        }
1705        (compat, incompat)
1706    } else {
1707        // No project version configured — compile everything in one batch.
1708        (source_files.to_vec(), Vec::new())
1709    };
1710
1711    if !incompatible_files.is_empty() {
1712        if let Some(c) = client {
1713            c.log_message(
1714                tower_lsp::lsp_types::MessageType::INFO,
1715                format!(
1716                    "project index: {} compatible, {} incompatible with solc {}",
1717                    compatible_files.len(),
1718                    incompatible_files.len(),
1719                    project_version
1720                        .as_ref()
1721                        .map(|v| v.to_string())
1722                        .unwrap_or_default(),
1723                ),
1724            )
1725            .await;
1726        }
1727    }
1728
1729    // -- Full batch compile of compatible files. --
1730    //
1731    // The source file list comes from discover_compilation_closure which only
1732    // includes files reachable via imports from src/test/script — so all files
1733    // in the batch are version-compatible and their transitive imports resolve.
1734    // A full (non-parse-only) compile is required so that cross-file
1735    // referencedDeclaration IDs are populated for goto-references to work.
1736    let mut result = if compatible_files.is_empty() {
1737        json!({"sources": {}, "contracts": {}, "errors": [], "source_id_to_path": {}})
1738    } else {
1739        let input = build_batch_standard_json_input_with_cache(
1740            &compatible_files,
1741            &remappings,
1742            config,
1743            text_cache,
1744        );
1745        let raw = run_solc(&solc_binary, &input, &config.root).await?;
1746        normalize_solc_output(raw, Some(&config.root))
1747    };
1748
1749    let batch_source_count = result
1750        .get("sources")
1751        .and_then(|s| s.as_object())
1752        .map_or(0, |obj| obj.len());
1753
1754    if incompatible_files.is_empty() {
1755        return Ok(result);
1756    }
1757
1758    if let Some(c) = client {
1759        // Log first few errors from the batch to understand why sources=0.
1760        let batch_errors: Vec<String> = result
1761            .get("errors")
1762            .and_then(|e| e.as_array())
1763            .map(|arr| {
1764                arr.iter()
1765                    .filter(|e| e.get("severity").and_then(|s| s.as_str()) == Some("error"))
1766                    .take(3)
1767                    .filter_map(|e| {
1768                        let msg = e.get("message").and_then(|m| m.as_str()).unwrap_or("?");
1769                        let file = e
1770                            .get("sourceLocation")
1771                            .and_then(|sl| sl.get("file"))
1772                            .and_then(|f| f.as_str())
1773                            .unwrap_or("?");
1774                        Some(format!("{file}: {msg}"))
1775                    })
1776                    .collect()
1777            })
1778            .unwrap_or_default();
1779
1780        c.log_message(
1781            tower_lsp::lsp_types::MessageType::INFO,
1782            format!(
1783                "project index: batch produced {} sources, now compiling {} incompatible files individually{}",
1784                batch_source_count,
1785                incompatible_files.len(),
1786                if batch_errors.is_empty() {
1787                    String::new()
1788                } else {
1789                    format!(" [first errors: {}]", batch_errors.join("; "))
1790                },
1791            ),
1792        )
1793        .await;
1794    }
1795
1796    // -- Individually compile incompatible files with their matching solc. --
1797    let mut compiled = 0usize;
1798    let mut skipped = 0usize;
1799    for file in &incompatible_files {
1800        let pragma = std::fs::read_to_string(file)
1801            .ok()
1802            .and_then(|src| parse_pragma(&src));
1803
1804        let Some(file_constraint) = pragma else {
1805            skipped += 1;
1806            continue;
1807        };
1808
1809        let file_binary = resolve_solc_binary(config, Some(&file_constraint), client).await;
1810        let input = build_batch_standard_json_input_with_cache(
1811            &[file.clone()],
1812            &remappings,
1813            config,
1814            text_cache,
1815        );
1816        match run_solc(&file_binary, &input, &config.root).await {
1817            Ok(raw) => {
1818                let normalized = normalize_solc_output(raw, Some(&config.root));
1819                merge_normalized_outputs(&mut result, normalized);
1820                compiled += 1;
1821            }
1822            Err(e) => {
1823                if let Some(c) = client {
1824                    c.log_message(
1825                        tower_lsp::lsp_types::MessageType::WARNING,
1826                        format!(
1827                            "project index: incompatible file {} failed: {e}",
1828                            file.display(),
1829                        ),
1830                    )
1831                    .await;
1832                }
1833                skipped += 1;
1834            }
1835        }
1836    }
1837
1838    if let Some(c) = client {
1839        c.log_message(
1840            tower_lsp::lsp_types::MessageType::INFO,
1841            format!(
1842                "project index: incompatible files done — {compiled} compiled, {skipped} skipped",
1843            ),
1844        )
1845        .await;
1846    }
1847
1848    Ok(result)
1849}
1850
1851#[cfg(test)]
1852mod tests {
1853    use super::*;
1854
1855    #[test]
1856    fn test_normalize_solc_sources() {
1857        let solc_output = json!({
1858            "sources": {
1859                "src/Foo.sol": {
1860                    "id": 0,
1861                    "ast": {
1862                        "nodeType": "SourceUnit",
1863                        "absolutePath": "src/Foo.sol",
1864                        "id": 100
1865                    }
1866                },
1867                "src/Bar.sol": {
1868                    "id": 1,
1869                    "ast": {
1870                        "nodeType": "SourceUnit",
1871                        "absolutePath": "src/Bar.sol",
1872                        "id": 200
1873                    }
1874                }
1875            },
1876            "contracts": {},
1877            "errors": []
1878        });
1879
1880        let normalized = normalize_solc_output(solc_output, None);
1881
1882        // Sources kept in solc-native shape: path -> { id, ast }
1883        let sources = normalized.get("sources").unwrap().as_object().unwrap();
1884        assert_eq!(sources.len(), 2);
1885
1886        let foo = sources.get("src/Foo.sol").unwrap();
1887        assert_eq!(foo.get("id").unwrap(), 0);
1888        assert_eq!(
1889            foo.get("ast")
1890                .unwrap()
1891                .get("nodeType")
1892                .unwrap()
1893                .as_str()
1894                .unwrap(),
1895            "SourceUnit"
1896        );
1897
1898        // Check source_id_to_path constructed
1899        let id_to_path = normalized
1900            .get("source_id_to_path")
1901            .unwrap()
1902            .as_object()
1903            .unwrap();
1904        assert_eq!(id_to_path.len(), 2);
1905    }
1906
1907    #[test]
1908    fn test_normalize_solc_contracts() {
1909        let solc_output = json!({
1910            "sources": {},
1911            "contracts": {
1912                "src/Foo.sol": {
1913                    "Foo": {
1914                        "abi": [{"type": "function", "name": "bar"}],
1915                        "evm": {
1916                            "methodIdentifiers": {
1917                                "bar(uint256)": "abcd1234"
1918                            }
1919                        }
1920                    }
1921                }
1922            },
1923            "errors": []
1924        });
1925
1926        let normalized = normalize_solc_output(solc_output, None);
1927
1928        // Contracts kept in solc-native shape: path -> name -> { abi, evm, ... }
1929        let contracts = normalized.get("contracts").unwrap().as_object().unwrap();
1930        let foo_contracts = contracts.get("src/Foo.sol").unwrap().as_object().unwrap();
1931        let foo = foo_contracts.get("Foo").unwrap();
1932
1933        let method_ids = foo
1934            .get("evm")
1935            .unwrap()
1936            .get("methodIdentifiers")
1937            .unwrap()
1938            .as_object()
1939            .unwrap();
1940        assert_eq!(
1941            method_ids.get("bar(uint256)").unwrap().as_str().unwrap(),
1942            "abcd1234"
1943        );
1944    }
1945
1946    #[test]
1947    fn test_normalize_solc_errors_passthrough() {
1948        let solc_output = json!({
1949            "sources": {},
1950            "contracts": {},
1951            "errors": [{
1952                "sourceLocation": {"file": "src/Foo.sol", "start": 0, "end": 10},
1953                "type": "Warning",
1954                "component": "general",
1955                "severity": "warning",
1956                "errorCode": "2394",
1957                "message": "test warning",
1958                "formattedMessage": "Warning: test warning"
1959            }]
1960        });
1961
1962        let normalized = normalize_solc_output(solc_output, None);
1963
1964        let errors = normalized.get("errors").unwrap().as_array().unwrap();
1965        assert_eq!(errors.len(), 1);
1966        assert_eq!(
1967            errors[0].get("errorCode").unwrap().as_str().unwrap(),
1968            "2394"
1969        );
1970    }
1971
1972    #[test]
1973    fn test_normalize_empty_solc_output() {
1974        let solc_output = json!({
1975            "sources": {},
1976            "contracts": {}
1977        });
1978
1979        let normalized = normalize_solc_output(solc_output, None);
1980
1981        assert!(
1982            normalized
1983                .get("sources")
1984                .unwrap()
1985                .as_object()
1986                .unwrap()
1987                .is_empty()
1988        );
1989        assert!(
1990            normalized
1991                .get("contracts")
1992                .unwrap()
1993                .as_object()
1994                .unwrap()
1995                .is_empty()
1996        );
1997        assert_eq!(
1998            normalized.get("errors").unwrap().as_array().unwrap().len(),
1999            0
2000        );
2001        assert!(
2002            normalized
2003                .get("source_id_to_path")
2004                .unwrap()
2005                .as_object()
2006                .unwrap()
2007                .is_empty()
2008        );
2009    }
2010
2011    #[test]
2012    fn test_build_standard_json_input() {
2013        let config = FoundryConfig::default();
2014        let input = build_standard_json_input(
2015            "/path/to/Foo.sol",
2016            &[
2017                "ds-test/=lib/forge-std/lib/ds-test/src/".to_string(),
2018                "forge-std/=lib/forge-std/src/".to_string(),
2019            ],
2020            &config,
2021            None,
2022        );
2023
2024        let sources = input.get("sources").unwrap().as_object().unwrap();
2025        assert!(sources.contains_key("/path/to/Foo.sol"));
2026
2027        let settings = input.get("settings").unwrap();
2028        let remappings = settings.get("remappings").unwrap().as_array().unwrap();
2029        assert_eq!(remappings.len(), 2);
2030
2031        let output_sel = settings.get("outputSelection").unwrap();
2032        assert!(output_sel.get("*").is_some());
2033
2034        // Default config: no optimizer, no viaIR, no evmVersion
2035        assert!(settings.get("optimizer").is_none());
2036        assert!(settings.get("viaIR").is_none());
2037        assert!(settings.get("evmVersion").is_none());
2038
2039        // gasEstimates is never requested — forces full EVM codegen (88% of compile time)
2040        let outputs = settings["outputSelection"]["*"]["*"].as_array().unwrap();
2041        let output_names: Vec<&str> = outputs.iter().map(|v| v.as_str().unwrap()).collect();
2042        assert!(!output_names.contains(&"evm.gasEstimates"));
2043        assert!(!output_names.contains(&"abi")); // ABI is intentionally omitted — no consumer
2044        assert!(output_names.contains(&"devdoc"));
2045        assert!(output_names.contains(&"userdoc"));
2046        assert!(output_names.contains(&"evm.methodIdentifiers"));
2047    }
2048
2049    #[test]
2050    fn test_build_standard_json_input_with_config() {
2051        let config = FoundryConfig {
2052            optimizer: true,
2053            optimizer_runs: 9999999,
2054            via_ir: true,
2055            evm_version: Some("osaka".to_string()),
2056            ..Default::default()
2057        };
2058        let input = build_standard_json_input("/path/to/Foo.sol", &[], &config, None);
2059
2060        let settings = input.get("settings").unwrap();
2061
2062        // Optimizer is never passed — adds ~3s and doesn't affect AST/ABI/docs
2063        assert!(settings.get("optimizer").is_none());
2064
2065        // viaIR IS passed when config has it (some contracts require it to compile)
2066        assert!(settings.get("viaIR").unwrap().as_bool().unwrap());
2067
2068        // gasEstimates is never requested regardless of viaIR
2069        let outputs = settings["outputSelection"]["*"]["*"].as_array().unwrap();
2070        let output_names: Vec<&str> = outputs.iter().map(|v| v.as_str().unwrap()).collect();
2071        assert!(!output_names.contains(&"evm.gasEstimates"));
2072
2073        // EVM version
2074        assert_eq!(
2075            settings.get("evmVersion").unwrap().as_str().unwrap(),
2076            "osaka"
2077        );
2078    }
2079
2080    #[tokio::test]
2081    async fn test_resolve_solc_binary_default() {
2082        let config = FoundryConfig::default();
2083        let binary = resolve_solc_binary(&config, None, None).await;
2084        assert_eq!(binary, PathBuf::from("solc"));
2085    }
2086
2087    #[test]
2088    fn test_parse_pragma_exact() {
2089        let source = "// SPDX\npragma solidity 0.8.26;\n";
2090        assert_eq!(
2091            parse_pragma(source),
2092            Some(PragmaConstraint::Exact(SemVer {
2093                major: 0,
2094                minor: 8,
2095                patch: 26
2096            }))
2097        );
2098    }
2099
2100    #[test]
2101    fn test_parse_pragma_caret() {
2102        let source = "pragma solidity ^0.8.0;\n";
2103        assert_eq!(
2104            parse_pragma(source),
2105            Some(PragmaConstraint::Caret(SemVer {
2106                major: 0,
2107                minor: 8,
2108                patch: 0
2109            }))
2110        );
2111    }
2112
2113    #[test]
2114    fn test_parse_pragma_gte() {
2115        let source = "pragma solidity >=0.8.0;\n";
2116        assert_eq!(
2117            parse_pragma(source),
2118            Some(PragmaConstraint::Gte(SemVer {
2119                major: 0,
2120                minor: 8,
2121                patch: 0
2122            }))
2123        );
2124    }
2125
2126    #[test]
2127    fn test_parse_pragma_range() {
2128        let source = "pragma solidity >=0.6.2 <0.9.0;\n";
2129        assert_eq!(
2130            parse_pragma(source),
2131            Some(PragmaConstraint::Range(
2132                SemVer {
2133                    major: 0,
2134                    minor: 6,
2135                    patch: 2
2136                },
2137                SemVer {
2138                    major: 0,
2139                    minor: 9,
2140                    patch: 0
2141                },
2142            ))
2143        );
2144    }
2145
2146    #[test]
2147    fn test_parse_pragma_none() {
2148        let source = "contract Foo {}\n";
2149        assert_eq!(parse_pragma(source), None);
2150    }
2151
2152    #[test]
2153    fn test_version_satisfies_exact() {
2154        let v = SemVer {
2155            major: 0,
2156            minor: 8,
2157            patch: 26,
2158        };
2159        assert!(version_satisfies(&v, &PragmaConstraint::Exact(v.clone())));
2160        assert!(!version_satisfies(
2161            &SemVer {
2162                major: 0,
2163                minor: 8,
2164                patch: 25
2165            },
2166            &PragmaConstraint::Exact(v)
2167        ));
2168    }
2169
2170    #[test]
2171    fn test_version_satisfies_caret() {
2172        let constraint = PragmaConstraint::Caret(SemVer {
2173            major: 0,
2174            minor: 8,
2175            patch: 0,
2176        });
2177        assert!(version_satisfies(
2178            &SemVer {
2179                major: 0,
2180                minor: 8,
2181                patch: 0
2182            },
2183            &constraint
2184        ));
2185        assert!(version_satisfies(
2186            &SemVer {
2187                major: 0,
2188                minor: 8,
2189                patch: 26
2190            },
2191            &constraint
2192        ));
2193        // 0.9.0 is outside ^0.8.0
2194        assert!(!version_satisfies(
2195            &SemVer {
2196                major: 0,
2197                minor: 9,
2198                patch: 0
2199            },
2200            &constraint
2201        ));
2202        // 0.7.0 is below
2203        assert!(!version_satisfies(
2204            &SemVer {
2205                major: 0,
2206                minor: 7,
2207                patch: 0
2208            },
2209            &constraint
2210        ));
2211    }
2212
2213    #[test]
2214    fn test_version_satisfies_gte() {
2215        let constraint = PragmaConstraint::Gte(SemVer {
2216            major: 0,
2217            minor: 8,
2218            patch: 0,
2219        });
2220        assert!(version_satisfies(
2221            &SemVer {
2222                major: 0,
2223                minor: 8,
2224                patch: 0
2225            },
2226            &constraint
2227        ));
2228        assert!(version_satisfies(
2229            &SemVer {
2230                major: 0,
2231                minor: 9,
2232                patch: 0
2233            },
2234            &constraint
2235        ));
2236        assert!(!version_satisfies(
2237            &SemVer {
2238                major: 0,
2239                minor: 7,
2240                patch: 0
2241            },
2242            &constraint
2243        ));
2244    }
2245
2246    #[test]
2247    fn test_version_satisfies_range() {
2248        let constraint = PragmaConstraint::Range(
2249            SemVer {
2250                major: 0,
2251                minor: 6,
2252                patch: 2,
2253            },
2254            SemVer {
2255                major: 0,
2256                minor: 9,
2257                patch: 0,
2258            },
2259        );
2260        assert!(version_satisfies(
2261            &SemVer {
2262                major: 0,
2263                minor: 6,
2264                patch: 2
2265            },
2266            &constraint
2267        ));
2268        assert!(version_satisfies(
2269            &SemVer {
2270                major: 0,
2271                minor: 8,
2272                patch: 26
2273            },
2274            &constraint
2275        ));
2276        // 0.9.0 is the upper bound (exclusive)
2277        assert!(!version_satisfies(
2278            &SemVer {
2279                major: 0,
2280                minor: 9,
2281                patch: 0
2282            },
2283            &constraint
2284        ));
2285        assert!(!version_satisfies(
2286            &SemVer {
2287                major: 0,
2288                minor: 6,
2289                patch: 1
2290            },
2291            &constraint
2292        ));
2293    }
2294
2295    #[test]
2296    fn test_find_matching_version() {
2297        let installed = vec![
2298            SemVer {
2299                major: 0,
2300                minor: 8,
2301                patch: 0,
2302            },
2303            SemVer {
2304                major: 0,
2305                minor: 8,
2306                patch: 20,
2307            },
2308            SemVer {
2309                major: 0,
2310                minor: 8,
2311                patch: 26,
2312            },
2313            SemVer {
2314                major: 0,
2315                minor: 8,
2316                patch: 33,
2317            },
2318        ];
2319        // ^0.8.20 should pick latest: 0.8.33
2320        let constraint = PragmaConstraint::Caret(SemVer {
2321            major: 0,
2322            minor: 8,
2323            patch: 20,
2324        });
2325        let matched = find_matching_version(&constraint, &installed);
2326        assert_eq!(
2327            matched,
2328            Some(SemVer {
2329                major: 0,
2330                minor: 8,
2331                patch: 33
2332            })
2333        );
2334
2335        // exact 0.8.20
2336        let constraint = PragmaConstraint::Exact(SemVer {
2337            major: 0,
2338            minor: 8,
2339            patch: 20,
2340        });
2341        let matched = find_matching_version(&constraint, &installed);
2342        assert_eq!(
2343            matched,
2344            Some(SemVer {
2345                major: 0,
2346                minor: 8,
2347                patch: 20
2348            })
2349        );
2350
2351        // exact 0.8.15 — not installed
2352        let constraint = PragmaConstraint::Exact(SemVer {
2353            major: 0,
2354            minor: 8,
2355            patch: 15,
2356        });
2357        let matched = find_matching_version(&constraint, &installed);
2358        assert_eq!(matched, None);
2359    }
2360
2361    #[test]
2362    fn test_list_installed_versions() {
2363        // Just verify it doesn't panic — actual versions depend on system
2364        let versions = list_installed_versions();
2365        // Versions should be sorted
2366        for w in versions.windows(2) {
2367            assert!(w[0] <= w[1]);
2368        }
2369    }
2370
2371    // -------------------------------------------------------------------
2372    // Tests for mixed-version retry helpers
2373    // -------------------------------------------------------------------
2374
2375    #[test]
2376    fn test_extract_version_error_files_basic() {
2377        let output = json!({
2378            "errors": [
2379                {
2380                    "errorCode": "5333",
2381                    "severity": "error",
2382                    "message": "Source file requires different compiler version",
2383                    "sourceLocation": {
2384                        "file": "lib/openzeppelin/contracts/token/ERC20/ERC20.sol",
2385                        "start": 32,
2386                        "end": 58
2387                    }
2388                },
2389                {
2390                    "errorCode": "5333",
2391                    "severity": "error",
2392                    "message": "Source file requires different compiler version",
2393                    "sourceLocation": {
2394                        "file": "lib/old-lib/src/Legacy.sol",
2395                        "start": 32,
2396                        "end": 58
2397                    }
2398                },
2399                {
2400                    "errorCode": "9574",
2401                    "severity": "error",
2402                    "message": "Some other error",
2403                    "sourceLocation": {
2404                        "file": "src/Main.sol",
2405                        "start": 100,
2406                        "end": 200
2407                    }
2408                }
2409            ]
2410        });
2411
2412        let files = extract_version_error_files(&output);
2413        assert_eq!(files.len(), 2);
2414        assert!(files.contains("lib/openzeppelin/contracts/token/ERC20/ERC20.sol"));
2415        assert!(files.contains("lib/old-lib/src/Legacy.sol"));
2416        // Non-5333 error files should NOT be included.
2417        assert!(!files.contains("src/Main.sol"));
2418    }
2419
2420    #[test]
2421    fn test_extract_version_error_files_empty() {
2422        let output = json!({
2423            "errors": []
2424        });
2425        assert!(extract_version_error_files(&output).is_empty());
2426
2427        // No errors key at all.
2428        let output = json!({});
2429        assert!(extract_version_error_files(&output).is_empty());
2430    }
2431
2432    #[test]
2433    fn test_extract_version_error_files_no_source_location() {
2434        let output = json!({
2435            "errors": [
2436                {
2437                    "errorCode": "5333",
2438                    "severity": "error",
2439                    "message": "Source file requires different compiler version"
2440                    // No sourceLocation field.
2441                }
2442            ]
2443        });
2444        assert!(extract_version_error_files(&output).is_empty());
2445    }
2446
2447    #[test]
2448    fn test_extract_version_error_files_dedup() {
2449        let output = json!({
2450            "errors": [
2451                {
2452                    "errorCode": "5333",
2453                    "severity": "error",
2454                    "sourceLocation": { "file": "lib/same.sol", "start": 0, "end": 10 }
2455                },
2456                {
2457                    "errorCode": "5333",
2458                    "severity": "error",
2459                    "sourceLocation": { "file": "lib/same.sol", "start": 50, "end": 70 }
2460                }
2461            ]
2462        });
2463        let files = extract_version_error_files(&output);
2464        assert_eq!(files.len(), 1);
2465        assert!(files.contains("lib/same.sol"));
2466    }
2467
2468    #[test]
2469    fn test_reverse_import_closure_simple() {
2470        // Create a temp directory with three files:
2471        //   a.sol imports b.sol
2472        //   b.sol imports c.sol
2473        //   d.sol (standalone)
2474        //
2475        // If c.sol is excluded, the closure should include: c.sol, b.sol, a.sol
2476        // (b imports c, a imports b — both are transitive importers of c).
2477        let dir = tempfile::tempdir().unwrap();
2478        let root = dir.path();
2479
2480        std::fs::write(
2481            root.join("a.sol"),
2482            "// SPDX-License-Identifier: MIT\nimport \"./b.sol\";\ncontract A {}",
2483        )
2484        .unwrap();
2485        std::fs::write(
2486            root.join("b.sol"),
2487            "// SPDX-License-Identifier: MIT\nimport \"./c.sol\";\ncontract B {}",
2488        )
2489        .unwrap();
2490        std::fs::write(
2491            root.join("c.sol"),
2492            "// SPDX-License-Identifier: MIT\ncontract C {}",
2493        )
2494        .unwrap();
2495        std::fs::write(
2496            root.join("d.sol"),
2497            "// SPDX-License-Identifier: MIT\ncontract D {}",
2498        )
2499        .unwrap();
2500
2501        let files: Vec<PathBuf> = vec![
2502            root.join("a.sol"),
2503            root.join("b.sol"),
2504            root.join("c.sol"),
2505            root.join("d.sol"),
2506        ];
2507
2508        let exclude: HashSet<PathBuf> = [root.join("c.sol")].into_iter().collect();
2509        let closure = reverse_import_closure(&files, &exclude, root, &[]);
2510
2511        assert!(
2512            closure.contains(&root.join("c.sol")),
2513            "seed file in closure"
2514        );
2515        assert!(closure.contains(&root.join("b.sol")), "direct importer");
2516        assert!(closure.contains(&root.join("a.sol")), "transitive importer");
2517        assert!(
2518            !closure.contains(&root.join("d.sol")),
2519            "unrelated file not in closure"
2520        );
2521        assert_eq!(closure.len(), 3);
2522    }
2523
2524    #[test]
2525    fn test_reverse_import_closure_no_importers() {
2526        // Excluding a file that nothing imports — closure is just the seed.
2527        let dir = tempfile::tempdir().unwrap();
2528        let root = dir.path();
2529
2530        std::fs::write(root.join("a.sol"), "contract A {}").unwrap();
2531        std::fs::write(root.join("b.sol"), "contract B {}").unwrap();
2532
2533        let files: Vec<PathBuf> = vec![root.join("a.sol"), root.join("b.sol")];
2534        let exclude: HashSet<PathBuf> = [root.join("a.sol")].into_iter().collect();
2535
2536        let closure = reverse_import_closure(&files, &exclude, root, &[]);
2537        assert_eq!(closure.len(), 1);
2538        assert!(closure.contains(&root.join("a.sol")));
2539    }
2540
2541    #[test]
2542    fn test_reverse_import_closure_diamond() {
2543        // Diamond pattern:
2544        //   a.sol imports b.sol and c.sol
2545        //   b.sol imports d.sol
2546        //   c.sol imports d.sol
2547        //
2548        // Excluding d.sol → closure = {d, b, c, a}
2549        let dir = tempfile::tempdir().unwrap();
2550        let root = dir.path();
2551
2552        std::fs::write(
2553            root.join("a.sol"),
2554            "import \"./b.sol\";\nimport \"./c.sol\";\ncontract A {}",
2555        )
2556        .unwrap();
2557        std::fs::write(root.join("b.sol"), "import \"./d.sol\";\ncontract B {}").unwrap();
2558        std::fs::write(root.join("c.sol"), "import \"./d.sol\";\ncontract C {}").unwrap();
2559        std::fs::write(root.join("d.sol"), "contract D {}").unwrap();
2560
2561        let files: Vec<PathBuf> = vec![
2562            root.join("a.sol"),
2563            root.join("b.sol"),
2564            root.join("c.sol"),
2565            root.join("d.sol"),
2566        ];
2567        let exclude: HashSet<PathBuf> = [root.join("d.sol")].into_iter().collect();
2568
2569        let closure = reverse_import_closure(&files, &exclude, root, &[]);
2570        assert_eq!(closure.len(), 4);
2571    }
2572
2573    #[test]
2574    fn test_merge_normalized_outputs_basic() {
2575        let mut base = json!({
2576            "sources": {
2577                "/abs/src/A.sol": { "id": 0, "ast": { "nodeType": "SourceUnit" } },
2578                "/abs/src/B.sol": { "id": 1, "ast": { "nodeType": "SourceUnit" } }
2579            },
2580            "contracts": {
2581                "/abs/src/A.sol": { "A": { "abi": [] } }
2582            },
2583            "errors": [],
2584            "source_id_to_path": {
2585                "0": "/abs/src/A.sol",
2586                "1": "/abs/src/B.sol"
2587            }
2588        });
2589
2590        let other = json!({
2591            "sources": {
2592                "/abs/lib/C.sol": { "id": 0, "ast": { "nodeType": "SourceUnit" } }
2593            },
2594            "contracts": {
2595                "/abs/lib/C.sol": { "C": { "abi": [] } }
2596            },
2597            "errors": [],
2598            "source_id_to_path": {
2599                "0": "/abs/lib/C.sol"
2600            }
2601        });
2602
2603        merge_normalized_outputs(&mut base, other);
2604
2605        // Sources should now have 3 entries.
2606        let sources = base["sources"].as_object().unwrap();
2607        assert_eq!(sources.len(), 3);
2608        assert!(sources.contains_key("/abs/lib/C.sol"));
2609
2610        // The merged source's ID should be remapped (0 + max_base_id=2 → 2).
2611        let c_id = sources["/abs/lib/C.sol"]["id"].as_u64().unwrap();
2612        assert_eq!(
2613            c_id, 2,
2614            "remapped id should be max_base_id (2) + original (0)"
2615        );
2616
2617        // source_id_to_path should have 3 entries.
2618        let id_map = base["source_id_to_path"].as_object().unwrap();
2619        assert_eq!(id_map.len(), 3);
2620        assert_eq!(id_map["2"].as_str().unwrap(), "/abs/lib/C.sol");
2621
2622        // Contracts should have 2 entries.
2623        let contracts = base["contracts"].as_object().unwrap();
2624        assert_eq!(contracts.len(), 2);
2625        assert!(contracts.contains_key("/abs/lib/C.sol"));
2626    }
2627
2628    #[test]
2629    fn test_merge_normalized_outputs_empty_other() {
2630        let mut base = json!({
2631            "sources": {
2632                "/abs/src/A.sol": { "id": 0, "ast": {} }
2633            },
2634            "contracts": {},
2635            "errors": [],
2636            "source_id_to_path": { "0": "/abs/src/A.sol" }
2637        });
2638
2639        let other = json!({
2640            "sources": {},
2641            "contracts": {},
2642            "errors": [],
2643            "source_id_to_path": {}
2644        });
2645
2646        merge_normalized_outputs(&mut base, other);
2647
2648        let sources = base["sources"].as_object().unwrap();
2649        assert_eq!(sources.len(), 1);
2650    }
2651
2652    #[test]
2653    fn test_merge_normalized_outputs_empty_base() {
2654        let mut base = json!({
2655            "sources": {},
2656            "contracts": {},
2657            "errors": [],
2658            "source_id_to_path": {}
2659        });
2660
2661        let other = json!({
2662            "sources": {
2663                "/abs/lib/X.sol": { "id": 0, "ast": {} }
2664            },
2665            "contracts": {
2666                "/abs/lib/X.sol": { "X": { "abi": [] } }
2667            },
2668            "errors": [],
2669            "source_id_to_path": { "0": "/abs/lib/X.sol" }
2670        });
2671
2672        merge_normalized_outputs(&mut base, other);
2673
2674        let sources = base["sources"].as_object().unwrap();
2675        assert_eq!(sources.len(), 1);
2676        // max_base_id is 0 (no entries), so remapped id = 0 + 0 = 0.
2677        let x_id = sources["/abs/lib/X.sol"]["id"].as_u64().unwrap();
2678        assert_eq!(x_id, 0);
2679    }
2680}