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