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::runner::RunnerError;
9use serde_json::{Map, Value, json};
10use std::path::{Path, PathBuf};
11use std::sync::{Mutex, OnceLock};
12use tokio::process::Command;
13
14/// Cached list of installed solc versions. Populated on first access,
15/// invalidated after a successful `svm install`.
16static INSTALLED_VERSIONS: OnceLock<Mutex<Vec<SemVer>>> = OnceLock::new();
17
18fn get_installed_versions() -> Vec<SemVer> {
19    let mutex = INSTALLED_VERSIONS.get_or_init(|| Mutex::new(scan_installed_versions()));
20    mutex.lock().unwrap().clone()
21}
22
23fn invalidate_installed_versions() {
24    if let Some(mutex) = INSTALLED_VERSIONS.get() {
25        *mutex.lock().unwrap() = scan_installed_versions();
26    }
27}
28
29/// Resolve the path to the solc binary.
30///
31/// Resolution order:
32/// 1. Parse `pragma solidity` from the source file.
33///    - **Exact pragma** (`=0.7.6`): always use the file's version — foundry.toml
34///      cannot override an exact pragma without breaking compilation.
35///    - **Wildcard pragma** (`^0.8.0`, `>=0.8.0`, `>=0.6.2 <0.9.0`): if
36///      `foundry.toml` specifies a solc version that satisfies the constraint,
37///      use it. Otherwise pick the latest matching installed version.
38/// 2. If no pragma, use the `foundry.toml` solc version if set.
39/// 3. If no match is installed, auto-install via `svm install`.
40/// 4. Fall back to whatever `solc` is on `$PATH`.
41pub async fn resolve_solc_binary(
42    config: &FoundryConfig,
43    file_source: Option<&str>,
44    client: Option<&tower_lsp::Client>,
45) -> PathBuf {
46    // 1. Try pragma from the file being compiled
47    if let Some(source) = file_source
48        && let Some(constraint) = parse_pragma(source)
49    {
50        // For exact pragmas, always honour the file — foundry.toml can't override
51        // without causing a compilation failure.
52        // For wildcard pragmas, prefer the foundry.toml version if it satisfies
53        // the constraint. This mirrors `forge build` behaviour where the project
54        // config picks the version but the pragma must still be satisfied.
55        if !matches!(constraint, PragmaConstraint::Exact(_)) {
56            if let Some(ref config_ver) = config.solc_version
57                && let Some(parsed) = SemVer::parse(config_ver)
58                && version_satisfies(&parsed, &constraint)
59                && let Some(path) = find_solc_binary(config_ver)
60            {
61                if let Some(c) = client {
62                    c.log_message(
63                        tower_lsp::lsp_types::MessageType::INFO,
64                        format!(
65                            "solc: foundry.toml {config_ver} satisfies pragma {constraint:?} → {}",
66                            path.display()
67                        ),
68                    )
69                    .await;
70                }
71                return path;
72            }
73        }
74
75        let installed = get_installed_versions();
76        if let Some(version) = find_matching_version(&constraint, &installed)
77            && let Some(path) = find_solc_binary(&version.to_string())
78        {
79            if let Some(c) = client {
80                c.log_message(
81                    tower_lsp::lsp_types::MessageType::INFO,
82                    format!(
83                        "solc: pragma {constraint:?} → {version} → {}",
84                        path.display()
85                    ),
86                )
87                .await;
88            }
89            return path;
90        }
91
92        // No matching version installed — try auto-install via svm
93        let install_version = version_to_install(&constraint);
94        if let Some(ref ver_str) = install_version {
95            if let Some(c) = client {
96                c.show_message(
97                    tower_lsp::lsp_types::MessageType::INFO,
98                    format!("Installing solc {ver_str}..."),
99                )
100                .await;
101            }
102
103            if svm_install(ver_str).await {
104                // Refresh the cached version list after install
105                invalidate_installed_versions();
106
107                if let Some(c) = client {
108                    c.show_message(
109                        tower_lsp::lsp_types::MessageType::INFO,
110                        format!("Installed solc {ver_str}"),
111                    )
112                    .await;
113                }
114                if let Some(path) = find_solc_binary(ver_str) {
115                    return path;
116                }
117            } else if let Some(c) = client {
118                c.show_message(
119                    tower_lsp::lsp_types::MessageType::WARNING,
120                    format!(
121                        "Failed to install solc {ver_str}. \
122                             Install it manually: svm install {ver_str}"
123                    ),
124                )
125                .await;
126            }
127        }
128    }
129
130    // 2. No pragma — use foundry.toml version if available
131    if let Some(ref version) = config.solc_version
132        && let Some(path) = find_solc_binary(version)
133    {
134        if let Some(c) = client {
135            c.log_message(
136                tower_lsp::lsp_types::MessageType::INFO,
137                format!(
138                    "solc: no pragma, using foundry.toml version {version} → {}",
139                    path.display()
140                ),
141            )
142            .await;
143        }
144        return path;
145    }
146
147    // 3. Fall back to system solc
148    if let Some(c) = client {
149        c.log_message(
150            tower_lsp::lsp_types::MessageType::INFO,
151            "solc: no pragma match, falling back to system solc",
152        )
153        .await;
154    }
155    PathBuf::from("solc")
156}
157
158/// Determine which version to install for a pragma constraint.
159///
160/// - Exact: install that version
161/// - Caret `^0.8.20`: install `0.8.20` (minimum satisfying)
162/// - Gte `>=0.8.0`: install `0.8.0` (minimum satisfying)
163/// - Range `>=0.6.2 <0.9.0`: install `0.6.2` (minimum satisfying)
164fn version_to_install(constraint: &PragmaConstraint) -> Option<String> {
165    match constraint {
166        PragmaConstraint::Exact(v) => Some(v.to_string()),
167        PragmaConstraint::Caret(v) => Some(v.to_string()),
168        PragmaConstraint::Gte(v) => Some(v.to_string()),
169        PragmaConstraint::Range(lower, _) => Some(lower.to_string()),
170    }
171}
172
173/// Run `svm install {version}` to auto-install a solc version.
174///
175/// Returns `true` if the install succeeded.
176async fn svm_install(version: &str) -> bool {
177    let result = Command::new("svm")
178        .arg("install")
179        .arg(version)
180        .stdout(std::process::Stdio::piped())
181        .stderr(std::process::Stdio::piped())
182        .status()
183        .await;
184
185    matches!(result, Ok(status) if status.success())
186}
187
188/// All directories where solc versions may be installed.
189///
190/// Each entry is `(dir, binary_name_fn)` where `dir` contains version
191/// subdirectories and `binary_name_fn` produces the binary filename.
192///
193/// Locations checked:
194/// - svm-rs (forge): `{data_dir}/svm/{ver}/solc-{ver}` (platform-specific)
195///   - macOS: `~/Library/Application Support/svm/`
196///   - Linux: `~/.svm/`
197///   - Windows: `%APPDATA%\svm\`
198/// - solc-select: `~/.solc-select/artifacts/solc-{ver}/solc-{ver}`
199fn svm_data_dirs() -> Vec<PathBuf> {
200    let mut dirs = Vec::new();
201
202    if let Some(home) = home_dir() {
203        // svm-rs: platform-specific data directory
204        #[cfg(target_os = "macos")]
205        dirs.push(home.join("Library/Application Support/svm"));
206
207        #[cfg(target_os = "linux")]
208        dirs.push(home.join(".svm"));
209
210        #[cfg(target_os = "windows")]
211        if let Some(appdata) = std::env::var_os("APPDATA") {
212            dirs.push(PathBuf::from(appdata).join("svm"));
213        }
214
215        // Also check ~/.svm on all platforms (some setups use this)
216        let dot_svm = home.join(".svm");
217        if !dirs.contains(&dot_svm) {
218            dirs.push(dot_svm);
219        }
220
221        // solc-select (pip-based, common on all Unix)
222        dirs.push(home.join(".solc-select").join("artifacts"));
223    }
224
225    dirs
226}
227
228/// Look up a solc binary by version, checking all known install locations.
229fn find_solc_binary(version: &str) -> Option<PathBuf> {
230    for dir in svm_data_dirs() {
231        // svm-rs layout: {dir}/{version}/solc-{version}
232        let svm_path = dir.join(version).join(format!("solc-{version}"));
233        if svm_path.is_file() {
234            return Some(svm_path);
235        }
236
237        // solc-select layout: {dir}/solc-{version}/solc-{version}
238        let select_path = dir
239            .join(format!("solc-{version}"))
240            .join(format!("solc-{version}"));
241        if select_path.is_file() {
242            return Some(select_path);
243        }
244    }
245
246    None
247}
248
249/// Try to get the user's home directory (cross-platform).
250fn home_dir() -> Option<PathBuf> {
251    // $HOME works on macOS/Linux, USERPROFILE on Windows
252    std::env::var_os("HOME")
253        .or_else(|| std::env::var_os("USERPROFILE"))
254        .map(PathBuf::from)
255}
256
257// ── Pragma parsing ────────────────────────────────────────────────────────
258
259/// A parsed semver version (major.minor.patch).
260#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
261pub struct SemVer {
262    pub major: u32,
263    pub minor: u32,
264    pub patch: u32,
265}
266
267impl SemVer {
268    fn parse(s: &str) -> Option<SemVer> {
269        let parts: Vec<&str> = s.split('.').collect();
270        if parts.len() != 3 {
271            return None;
272        }
273        Some(SemVer {
274            major: parts[0].parse().ok()?,
275            minor: parts[1].parse().ok()?,
276            patch: parts[2].parse().ok()?,
277        })
278    }
279}
280
281impl std::fmt::Display for SemVer {
282    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
284    }
285}
286
287/// A version constraint from `pragma solidity`.
288#[derive(Debug, Clone, PartialEq)]
289pub enum PragmaConstraint {
290    /// `0.8.26` — exact match
291    Exact(SemVer),
292    /// `^0.8.0` — same major.minor, patch >= specified
293    /// Actually in Solidity: `^0.8.0` means `>=0.8.0 <0.9.0`
294    Caret(SemVer),
295    /// `>=0.8.0` — at least this version
296    Gte(SemVer),
297    /// `>=0.6.2 <0.9.0` — range
298    Range(SemVer, SemVer),
299}
300
301/// Parse `pragma solidity <constraint>;` from Solidity source.
302///
303/// Handles:
304/// - `pragma solidity 0.8.26;` → Exact
305/// - `pragma solidity ^0.8.0;` → Caret
306/// - `pragma solidity >=0.8.0;` → Gte
307/// - `pragma solidity >=0.6.2 <0.9.0;` → Range
308pub fn parse_pragma(source: &str) -> Option<PragmaConstraint> {
309    // Find the pragma line — only scan the first ~20 lines for performance
310    let pragma_line = source
311        .lines()
312        .take(20)
313        .find(|line| line.trim_start().starts_with("pragma solidity"))?;
314
315    // Extract the constraint string between "pragma solidity" and ";"
316    let after_keyword = pragma_line
317        .trim_start()
318        .strip_prefix("pragma solidity")?
319        .trim();
320    let constraint_str = after_keyword
321        .strip_suffix(';')
322        .unwrap_or(after_keyword)
323        .trim();
324
325    if constraint_str.is_empty() {
326        return None;
327    }
328
329    // Range: >=X.Y.Z <A.B.C
330    if let Some(rest) = constraint_str.strip_prefix(">=") {
331        let rest = rest.trim();
332        if let Some(space_idx) = rest.find(|c: char| c.is_whitespace() || c == '<') {
333            let lower_str = rest[..space_idx].trim();
334            let upper_part = rest[space_idx..].trim();
335            if let Some(upper_str) = upper_part.strip_prefix('<') {
336                let upper_str = upper_str.trim();
337                if let (Some(lower), Some(upper)) =
338                    (SemVer::parse(lower_str), SemVer::parse(upper_str))
339                {
340                    return Some(PragmaConstraint::Range(lower, upper));
341                }
342            }
343        }
344        // Just >=X.Y.Z
345        if let Some(ver) = SemVer::parse(rest) {
346            return Some(PragmaConstraint::Gte(ver));
347        }
348    }
349
350    // Caret: ^X.Y.Z
351    if let Some(rest) = constraint_str.strip_prefix('^')
352        && let Some(ver) = SemVer::parse(rest.trim())
353    {
354        return Some(PragmaConstraint::Caret(ver));
355    }
356
357    // Exact: X.Y.Z
358    if let Some(ver) = SemVer::parse(constraint_str) {
359        return Some(PragmaConstraint::Exact(ver));
360    }
361
362    None
363}
364
365/// List installed solc versions (cached — use `get_installed_versions()` internally).
366pub fn list_installed_versions() -> Vec<SemVer> {
367    get_installed_versions()
368}
369
370/// Scan the filesystem for installed solc versions from all known locations.
371///
372/// Returns sorted, deduplicated versions (ascending).
373fn scan_installed_versions() -> Vec<SemVer> {
374    let mut versions = Vec::new();
375
376    for dir in svm_data_dirs() {
377        if let Ok(entries) = std::fs::read_dir(&dir) {
378            for entry in entries.flatten() {
379                let name = match entry.file_name().into_string() {
380                    Ok(n) => n,
381                    Err(_) => continue,
382                };
383                // svm-rs: directory named "0.8.26"
384                // solc-select: directory named "solc-0.8.26"
385                let version_str = name.strip_prefix("solc-").unwrap_or(&name);
386                if let Some(ver) = SemVer::parse(version_str) {
387                    versions.push(ver);
388                }
389            }
390        }
391    }
392
393    versions.sort();
394    versions.dedup();
395    versions
396}
397
398/// Find the best matching installed version for a pragma constraint.
399///
400/// For all constraint types, picks the **latest** installed version that
401/// satisfies the constraint.
402pub fn find_matching_version(
403    constraint: &PragmaConstraint,
404    installed: &[SemVer],
405) -> Option<SemVer> {
406    let candidates: Vec<&SemVer> = installed
407        .iter()
408        .filter(|v| version_satisfies(v, constraint))
409        .collect();
410
411    // Pick the latest (last, since installed is sorted ascending)
412    candidates.last().cloned().cloned()
413}
414
415/// Check if a version satisfies a pragma constraint.
416pub fn version_satisfies(version: &SemVer, constraint: &PragmaConstraint) -> bool {
417    match constraint {
418        PragmaConstraint::Exact(v) => version == v,
419        PragmaConstraint::Caret(v) => {
420            // Solidity caret: ^0.8.0 means >=0.8.0 <0.9.0
421            // i.e. same major, next minor is the ceiling
422            version.major == v.major && version >= v && version.minor < v.minor + 1
423        }
424        PragmaConstraint::Gte(v) => version >= v,
425        PragmaConstraint::Range(lower, upper) => version >= lower && version < upper,
426    }
427}
428
429/// Fetch remappings by running `forge remappings` in the project root.
430///
431/// Falls back to config remappings, then to an empty list.
432pub async fn resolve_remappings(config: &FoundryConfig) -> Vec<String> {
433    // Try `forge remappings` first — it merges all sources (foundry.toml,
434    // remappings.txt, auto-detected libs).
435    let output = Command::new("forge")
436        .arg("remappings")
437        .current_dir(&config.root)
438        .env("FOUNDRY_DISABLE_NIGHTLY_WARNING", "1")
439        .output()
440        .await;
441
442    if let Ok(output) = output
443        && output.status.success()
444    {
445        let stdout = String::from_utf8_lossy(&output.stdout);
446        let remappings: Vec<String> = stdout
447            .lines()
448            .filter(|l| !l.trim().is_empty())
449            .map(|l| l.to_string())
450            .collect();
451        if !remappings.is_empty() {
452            return remappings;
453        }
454    }
455
456    // Fall back to remappings from foundry.toml
457    if !config.remappings.is_empty() {
458        return config.remappings.clone();
459    }
460
461    // Fall back to remappings.txt at project root
462    let remappings_txt = config.root.join("remappings.txt");
463    if let Ok(content) = std::fs::read_to_string(&remappings_txt) {
464        return content
465            .lines()
466            .filter(|l| !l.trim().is_empty())
467            .map(|l| l.to_string())
468            .collect();
469    }
470
471    Vec::new()
472}
473
474/// Build the `--standard-json` input for solc.
475///
476/// Reads compiler settings from the `FoundryConfig` (parsed from `foundry.toml`)
477/// and maps them to the solc standard JSON `settings` object:
478///
479/// - `optimizer` / `optimizer_runs` → `settings.optimizer.enabled` / `settings.optimizer.runs`
480/// - `via_ir` → `settings.viaIR`
481/// - `evm_version` → `settings.evmVersion`
482pub fn build_standard_json_input(
483    file_path: &str,
484    remappings: &[String],
485    config: &FoundryConfig,
486) -> Value {
487    let mut settings = json!({
488        "remappings": remappings,
489        "outputSelection": {
490            "*": {
491                "*": [
492                    "abi",
493                    "devdoc",
494                    "userdoc",
495                    "evm.methodIdentifiers",
496                    "evm.gasEstimates"
497                ],
498                "": ["ast"]
499            }
500        }
501    });
502
503    // Optimizer settings
504    if config.optimizer {
505        settings["optimizer"] = json!({
506            "enabled": true,
507            "runs": config.optimizer_runs
508        });
509    }
510
511    // Via IR pipeline
512    if config.via_ir {
513        settings["viaIR"] = json!(true);
514    }
515
516    // EVM version
517    if let Some(ref evm_version) = config.evm_version {
518        settings["evmVersion"] = json!(evm_version);
519    }
520
521    json!({
522        "language": "Solidity",
523        "sources": {
524            file_path: {
525                "urls": [file_path]
526            }
527        },
528        "settings": settings
529    })
530}
531
532/// Run `solc --standard-json` and return the parsed output.
533pub async fn run_solc(
534    solc_binary: &Path,
535    input: &Value,
536    project_root: &Path,
537) -> Result<Value, RunnerError> {
538    let input_str = serde_json::to_string(input)?;
539
540    let mut child = Command::new(solc_binary)
541        .arg("--standard-json")
542        .current_dir(project_root)
543        .stdin(std::process::Stdio::piped())
544        .stdout(std::process::Stdio::piped())
545        .stderr(std::process::Stdio::piped())
546        .spawn()?;
547
548    // Write the standard-json input to solc's stdin.
549    if let Some(mut stdin) = child.stdin.take() {
550        use tokio::io::AsyncWriteExt;
551        stdin
552            .write_all(input_str.as_bytes())
553            .await
554            .map_err(RunnerError::CommandError)?;
555        // Drop stdin to close it, signaling EOF to solc.
556    }
557
558    let output = child
559        .wait_with_output()
560        .await
561        .map_err(RunnerError::CommandError)?;
562
563    // solc writes JSON to stdout even on errors (errors are in the JSON)
564    let stdout = String::from_utf8_lossy(&output.stdout);
565    if stdout.trim().is_empty() {
566        let stderr = String::from_utf8_lossy(&output.stderr);
567        return Err(RunnerError::CommandError(std::io::Error::other(format!(
568            "solc produced no output, stderr: {stderr}"
569        ))));
570    }
571
572    let parsed: Value = serde_json::from_str(&stdout)?;
573    Ok(parsed)
574}
575
576/// Normalize raw solc `--standard-json` output into the canonical shape.
577///
578/// Solc's native shape is already close to canonical:
579/// - `sources[path] = { id, ast }` — kept as-is
580/// - `contracts[path][name] = { abi, evm, ... }` — kept as-is
581/// - `errors` — kept as-is (defaults to `[]` if absent)
582///
583/// When `project_root` is provided, relative source paths are resolved to
584/// absolute paths so that downstream code (goto, hover, links) can map AST
585/// paths back to `file://` URIs. This is necessary because `solc_ast()`
586/// passes a relative path to solc (to fix import resolution), and solc then
587/// returns relative paths in the AST `absolutePath` and source keys.
588///
589/// Constructs `source_id_to_path` from source IDs for cross-file resolution.
590///
591/// Takes ownership and uses `Value::take()` to move AST nodes in-place,
592/// avoiding expensive clones of multi-MB AST data.
593pub fn normalize_solc_output(mut solc_output: Value, project_root: Option<&Path>) -> Value {
594    let mut result = Map::new();
595
596    // Move errors out (defaults to [] if absent)
597    let errors = solc_output
598        .get_mut("errors")
599        .map(Value::take)
600        .unwrap_or_else(|| json!([]));
601    result.insert("errors".to_string(), errors);
602
603    // Helper: resolve a path to absolute using the project root.
604    // If the path is already absolute or no project root is given, return as-is.
605    let resolve = |p: &str| -> String {
606        if let Some(root) = project_root {
607            let path = Path::new(p);
608            if path.is_relative() {
609                return root.join(path).to_string_lossy().into_owned();
610            }
611        }
612        p.to_string()
613    };
614
615    // Sources: rekey with absolute paths and update AST absolutePath fields.
616    // Also build source_id_to_path for cross-file resolution.
617    let mut source_id_to_path = Map::new();
618    let mut resolved_sources = Map::new();
619
620    if let Some(sources) = solc_output
621        .get_mut("sources")
622        .and_then(|s| s.as_object_mut())
623    {
624        // Collect keys first to avoid borrow issues
625        let keys: Vec<String> = sources.keys().cloned().collect();
626        for key in keys {
627            if let Some(mut source_data) = sources.remove(&key) {
628                let abs_key = resolve(&key);
629
630                // Update the AST absolutePath field to match
631                if let Some(ast) = source_data.get_mut("ast") {
632                    if let Some(abs_path) = ast.get("absolutePath").and_then(|v| v.as_str()) {
633                        let resolved = resolve(abs_path);
634                        ast.as_object_mut()
635                            .unwrap()
636                            .insert("absolutePath".to_string(), json!(resolved));
637                    }
638                }
639
640                if let Some(id) = source_data.get("id") {
641                    source_id_to_path.insert(id.to_string(), json!(&abs_key));
642                }
643
644                resolved_sources.insert(abs_key, source_data);
645            }
646        }
647    }
648
649    result.insert("sources".to_string(), Value::Object(resolved_sources));
650
651    // Contracts: rekey with absolute paths
652    let mut resolved_contracts = Map::new();
653    if let Some(contracts) = solc_output
654        .get_mut("contracts")
655        .and_then(|c| c.as_object_mut())
656    {
657        let keys: Vec<String> = contracts.keys().cloned().collect();
658        for key in keys {
659            if let Some(contract_data) = contracts.remove(&key) {
660                resolved_contracts.insert(resolve(&key), contract_data);
661            }
662        }
663    }
664    result.insert("contracts".to_string(), Value::Object(resolved_contracts));
665
666    // Construct source_id_to_path for cross-file resolution
667    result.insert(
668        "source_id_to_path".to_string(),
669        Value::Object(source_id_to_path),
670    );
671
672    Value::Object(result)
673}
674
675/// Normalize forge `build --json --ast` output into the canonical shape.
676///
677/// Forge wraps data in arrays with metadata:
678/// - `sources[path] = [{ source_file: { id, ast }, build_id, profile, version }]`
679/// - `contracts[path][name] = [{ contract: { abi, evm, ... }, build_id, profile, version }]`
680/// - `build_infos = [{ source_id_to_path: { ... } }]`
681///
682/// This unwraps to the canonical flat shape:
683/// - `sources[path] = { id, ast }`
684/// - `contracts[path][name] = { abi, evm, ... }`
685/// - `source_id_to_path = { ... }`
686pub fn normalize_forge_output(mut forge_output: Value) -> Value {
687    let mut result = Map::new();
688
689    // Move errors out
690    let errors = forge_output
691        .get_mut("errors")
692        .map(Value::take)
693        .unwrap_or_else(|| json!([]));
694    result.insert("errors".to_string(), errors);
695
696    // Unwrap sources: [{ source_file: { id, ast } }] → { id, ast }
697    let mut normalized_sources = Map::new();
698    if let Some(sources) = forge_output
699        .get_mut("sources")
700        .and_then(|s| s.as_object_mut())
701    {
702        for (path, entries) in sources.iter_mut() {
703            if let Some(arr) = entries.as_array_mut()
704                && let Some(first) = arr.first_mut()
705                && let Some(sf) = first.get_mut("source_file")
706            {
707                normalized_sources.insert(path.clone(), sf.take());
708            }
709        }
710    }
711    result.insert("sources".to_string(), Value::Object(normalized_sources));
712
713    // Unwrap contracts: [{ contract: { ... } }] → { ... }
714    let mut normalized_contracts = Map::new();
715    if let Some(contracts) = forge_output
716        .get_mut("contracts")
717        .and_then(|c| c.as_object_mut())
718    {
719        for (path, names) in contracts.iter_mut() {
720            let mut path_contracts = Map::new();
721            if let Some(names_obj) = names.as_object_mut() {
722                for (name, entries) in names_obj.iter_mut() {
723                    if let Some(arr) = entries.as_array_mut()
724                        && let Some(first) = arr.first_mut()
725                        && let Some(contract) = first.get_mut("contract")
726                    {
727                        path_contracts.insert(name.clone(), contract.take());
728                    }
729                }
730            }
731            normalized_contracts.insert(path.clone(), Value::Object(path_contracts));
732        }
733    }
734    result.insert("contracts".to_string(), Value::Object(normalized_contracts));
735
736    // Extract source_id_to_path from build_infos
737    let source_id_to_path = forge_output
738        .get_mut("build_infos")
739        .and_then(|bi| bi.as_array_mut())
740        .and_then(|arr| arr.first_mut())
741        .and_then(|info| info.get_mut("source_id_to_path"))
742        .map(Value::take)
743        .unwrap_or_else(|| json!({}));
744    result.insert("source_id_to_path".to_string(), source_id_to_path);
745
746    Value::Object(result)
747}
748
749/// Run solc for a file and return normalized output.
750///
751/// This is the main entry point used by the LSP. Reads the file source
752/// to detect the pragma version and resolve the correct solc binary.
753pub async fn solc_ast(
754    file_path: &str,
755    config: &FoundryConfig,
756    client: Option<&tower_lsp::Client>,
757) -> Result<Value, RunnerError> {
758    // Read source to detect pragma version
759    let file_source = std::fs::read_to_string(file_path).ok();
760    let solc_binary = resolve_solc_binary(config, file_source.as_deref(), client).await;
761    let remappings = resolve_remappings(config).await;
762
763    // Solc's import resolver fails when sources use absolute paths — it resolves
764    // 0 transitive imports, causing "No matching declaration found" errors for
765    // inherited members. Convert to a path relative to the project root so solc
766    // can properly resolve `src/`, `lib/`, and remapped imports.
767    let rel_path = Path::new(file_path)
768        .strip_prefix(&config.root)
769        .map(|p| p.to_string_lossy().into_owned())
770        .unwrap_or_else(|_| file_path.to_string());
771
772    let input = build_standard_json_input(&rel_path, &remappings, config);
773    let raw_output = run_solc(&solc_binary, &input, &config.root).await?;
774    Ok(normalize_solc_output(raw_output, Some(&config.root)))
775}
776
777/// Run solc for build diagnostics (same output, just used for error extraction).
778pub async fn solc_build(
779    file_path: &str,
780    config: &FoundryConfig,
781    client: Option<&tower_lsp::Client>,
782) -> Result<Value, RunnerError> {
783    solc_ast(file_path, config, client).await
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789
790    #[test]
791    fn test_normalize_solc_sources() {
792        let solc_output = json!({
793            "sources": {
794                "src/Foo.sol": {
795                    "id": 0,
796                    "ast": {
797                        "nodeType": "SourceUnit",
798                        "absolutePath": "src/Foo.sol",
799                        "id": 100
800                    }
801                },
802                "src/Bar.sol": {
803                    "id": 1,
804                    "ast": {
805                        "nodeType": "SourceUnit",
806                        "absolutePath": "src/Bar.sol",
807                        "id": 200
808                    }
809                }
810            },
811            "contracts": {},
812            "errors": []
813        });
814
815        let normalized = normalize_solc_output(solc_output, None);
816
817        // Sources kept in solc-native shape: path -> { id, ast }
818        let sources = normalized.get("sources").unwrap().as_object().unwrap();
819        assert_eq!(sources.len(), 2);
820
821        let foo = sources.get("src/Foo.sol").unwrap();
822        assert_eq!(foo.get("id").unwrap(), 0);
823        assert_eq!(
824            foo.get("ast")
825                .unwrap()
826                .get("nodeType")
827                .unwrap()
828                .as_str()
829                .unwrap(),
830            "SourceUnit"
831        );
832
833        // Check source_id_to_path constructed
834        let id_to_path = normalized
835            .get("source_id_to_path")
836            .unwrap()
837            .as_object()
838            .unwrap();
839        assert_eq!(id_to_path.len(), 2);
840    }
841
842    #[test]
843    fn test_normalize_solc_contracts() {
844        let solc_output = json!({
845            "sources": {},
846            "contracts": {
847                "src/Foo.sol": {
848                    "Foo": {
849                        "abi": [{"type": "function", "name": "bar"}],
850                        "evm": {
851                            "methodIdentifiers": {
852                                "bar(uint256)": "abcd1234"
853                            },
854                            "gasEstimates": {
855                                "external": {"bar(uint256)": "200"}
856                            }
857                        }
858                    }
859                }
860            },
861            "errors": []
862        });
863
864        let normalized = normalize_solc_output(solc_output, None);
865
866        // Contracts kept in solc-native shape: path -> name -> { abi, evm, ... }
867        let contracts = normalized.get("contracts").unwrap().as_object().unwrap();
868        let foo_contracts = contracts.get("src/Foo.sol").unwrap().as_object().unwrap();
869        let foo = foo_contracts.get("Foo").unwrap();
870
871        let method_ids = foo
872            .get("evm")
873            .unwrap()
874            .get("methodIdentifiers")
875            .unwrap()
876            .as_object()
877            .unwrap();
878        assert_eq!(
879            method_ids.get("bar(uint256)").unwrap().as_str().unwrap(),
880            "abcd1234"
881        );
882    }
883
884    #[test]
885    fn test_normalize_solc_errors_passthrough() {
886        let solc_output = json!({
887            "sources": {},
888            "contracts": {},
889            "errors": [{
890                "sourceLocation": {"file": "src/Foo.sol", "start": 0, "end": 10},
891                "type": "Warning",
892                "component": "general",
893                "severity": "warning",
894                "errorCode": "2394",
895                "message": "test warning",
896                "formattedMessage": "Warning: test warning"
897            }]
898        });
899
900        let normalized = normalize_solc_output(solc_output, None);
901
902        let errors = normalized.get("errors").unwrap().as_array().unwrap();
903        assert_eq!(errors.len(), 1);
904        assert_eq!(
905            errors[0].get("errorCode").unwrap().as_str().unwrap(),
906            "2394"
907        );
908    }
909
910    #[test]
911    fn test_normalize_empty_solc_output() {
912        let solc_output = json!({
913            "sources": {},
914            "contracts": {}
915        });
916
917        let normalized = normalize_solc_output(solc_output, None);
918
919        assert!(
920            normalized
921                .get("sources")
922                .unwrap()
923                .as_object()
924                .unwrap()
925                .is_empty()
926        );
927        assert!(
928            normalized
929                .get("contracts")
930                .unwrap()
931                .as_object()
932                .unwrap()
933                .is_empty()
934        );
935        assert_eq!(
936            normalized.get("errors").unwrap().as_array().unwrap().len(),
937            0
938        );
939        assert!(
940            normalized
941                .get("source_id_to_path")
942                .unwrap()
943                .as_object()
944                .unwrap()
945                .is_empty()
946        );
947    }
948
949    #[test]
950    fn test_build_standard_json_input() {
951        let config = FoundryConfig::default();
952        let input = build_standard_json_input(
953            "/path/to/Foo.sol",
954            &[
955                "ds-test/=lib/forge-std/lib/ds-test/src/".to_string(),
956                "forge-std/=lib/forge-std/src/".to_string(),
957            ],
958            &config,
959        );
960
961        let sources = input.get("sources").unwrap().as_object().unwrap();
962        assert!(sources.contains_key("/path/to/Foo.sol"));
963
964        let settings = input.get("settings").unwrap();
965        let remappings = settings.get("remappings").unwrap().as_array().unwrap();
966        assert_eq!(remappings.len(), 2);
967
968        let output_sel = settings.get("outputSelection").unwrap();
969        assert!(output_sel.get("*").is_some());
970
971        // Default config: no optimizer, no viaIR, no evmVersion
972        assert!(settings.get("optimizer").is_none());
973        assert!(settings.get("viaIR").is_none());
974        assert!(settings.get("evmVersion").is_none());
975    }
976
977    #[test]
978    fn test_build_standard_json_input_with_config() {
979        let config = FoundryConfig {
980            optimizer: true,
981            optimizer_runs: 9999999,
982            via_ir: true,
983            evm_version: Some("osaka".to_string()),
984            ..Default::default()
985        };
986        let input = build_standard_json_input("/path/to/Foo.sol", &[], &config);
987
988        let settings = input.get("settings").unwrap();
989
990        // Optimizer
991        let optimizer = settings.get("optimizer").unwrap();
992        assert_eq!(optimizer.get("enabled").unwrap().as_bool().unwrap(), true);
993        assert_eq!(optimizer.get("runs").unwrap().as_u64().unwrap(), 9999999);
994
995        // Via IR
996        assert_eq!(settings.get("viaIR").unwrap().as_bool().unwrap(), true);
997
998        // EVM version
999        assert_eq!(
1000            settings.get("evmVersion").unwrap().as_str().unwrap(),
1001            "osaka"
1002        );
1003    }
1004
1005    #[tokio::test]
1006    async fn test_resolve_solc_binary_default() {
1007        let config = FoundryConfig::default();
1008        let binary = resolve_solc_binary(&config, None, None).await;
1009        assert_eq!(binary, PathBuf::from("solc"));
1010    }
1011
1012    #[test]
1013    fn test_parse_pragma_exact() {
1014        let source = "// SPDX\npragma solidity 0.8.26;\n";
1015        assert_eq!(
1016            parse_pragma(source),
1017            Some(PragmaConstraint::Exact(SemVer {
1018                major: 0,
1019                minor: 8,
1020                patch: 26
1021            }))
1022        );
1023    }
1024
1025    #[test]
1026    fn test_parse_pragma_caret() {
1027        let source = "pragma solidity ^0.8.0;\n";
1028        assert_eq!(
1029            parse_pragma(source),
1030            Some(PragmaConstraint::Caret(SemVer {
1031                major: 0,
1032                minor: 8,
1033                patch: 0
1034            }))
1035        );
1036    }
1037
1038    #[test]
1039    fn test_parse_pragma_gte() {
1040        let source = "pragma solidity >=0.8.0;\n";
1041        assert_eq!(
1042            parse_pragma(source),
1043            Some(PragmaConstraint::Gte(SemVer {
1044                major: 0,
1045                minor: 8,
1046                patch: 0
1047            }))
1048        );
1049    }
1050
1051    #[test]
1052    fn test_parse_pragma_range() {
1053        let source = "pragma solidity >=0.6.2 <0.9.0;\n";
1054        assert_eq!(
1055            parse_pragma(source),
1056            Some(PragmaConstraint::Range(
1057                SemVer {
1058                    major: 0,
1059                    minor: 6,
1060                    patch: 2
1061                },
1062                SemVer {
1063                    major: 0,
1064                    minor: 9,
1065                    patch: 0
1066                },
1067            ))
1068        );
1069    }
1070
1071    #[test]
1072    fn test_parse_pragma_none() {
1073        let source = "contract Foo {}\n";
1074        assert_eq!(parse_pragma(source), None);
1075    }
1076
1077    #[test]
1078    fn test_version_satisfies_exact() {
1079        let v = SemVer {
1080            major: 0,
1081            minor: 8,
1082            patch: 26,
1083        };
1084        assert!(version_satisfies(&v, &PragmaConstraint::Exact(v.clone())));
1085        assert!(!version_satisfies(
1086            &SemVer {
1087                major: 0,
1088                minor: 8,
1089                patch: 25
1090            },
1091            &PragmaConstraint::Exact(v)
1092        ));
1093    }
1094
1095    #[test]
1096    fn test_version_satisfies_caret() {
1097        let constraint = PragmaConstraint::Caret(SemVer {
1098            major: 0,
1099            minor: 8,
1100            patch: 0,
1101        });
1102        assert!(version_satisfies(
1103            &SemVer {
1104                major: 0,
1105                minor: 8,
1106                patch: 0
1107            },
1108            &constraint
1109        ));
1110        assert!(version_satisfies(
1111            &SemVer {
1112                major: 0,
1113                minor: 8,
1114                patch: 26
1115            },
1116            &constraint
1117        ));
1118        // 0.9.0 is outside ^0.8.0
1119        assert!(!version_satisfies(
1120            &SemVer {
1121                major: 0,
1122                minor: 9,
1123                patch: 0
1124            },
1125            &constraint
1126        ));
1127        // 0.7.0 is below
1128        assert!(!version_satisfies(
1129            &SemVer {
1130                major: 0,
1131                minor: 7,
1132                patch: 0
1133            },
1134            &constraint
1135        ));
1136    }
1137
1138    #[test]
1139    fn test_version_satisfies_gte() {
1140        let constraint = PragmaConstraint::Gte(SemVer {
1141            major: 0,
1142            minor: 8,
1143            patch: 0,
1144        });
1145        assert!(version_satisfies(
1146            &SemVer {
1147                major: 0,
1148                minor: 8,
1149                patch: 0
1150            },
1151            &constraint
1152        ));
1153        assert!(version_satisfies(
1154            &SemVer {
1155                major: 0,
1156                minor: 9,
1157                patch: 0
1158            },
1159            &constraint
1160        ));
1161        assert!(!version_satisfies(
1162            &SemVer {
1163                major: 0,
1164                minor: 7,
1165                patch: 0
1166            },
1167            &constraint
1168        ));
1169    }
1170
1171    #[test]
1172    fn test_version_satisfies_range() {
1173        let constraint = PragmaConstraint::Range(
1174            SemVer {
1175                major: 0,
1176                minor: 6,
1177                patch: 2,
1178            },
1179            SemVer {
1180                major: 0,
1181                minor: 9,
1182                patch: 0,
1183            },
1184        );
1185        assert!(version_satisfies(
1186            &SemVer {
1187                major: 0,
1188                minor: 6,
1189                patch: 2
1190            },
1191            &constraint
1192        ));
1193        assert!(version_satisfies(
1194            &SemVer {
1195                major: 0,
1196                minor: 8,
1197                patch: 26
1198            },
1199            &constraint
1200        ));
1201        // 0.9.0 is the upper bound (exclusive)
1202        assert!(!version_satisfies(
1203            &SemVer {
1204                major: 0,
1205                minor: 9,
1206                patch: 0
1207            },
1208            &constraint
1209        ));
1210        assert!(!version_satisfies(
1211            &SemVer {
1212                major: 0,
1213                minor: 6,
1214                patch: 1
1215            },
1216            &constraint
1217        ));
1218    }
1219
1220    #[test]
1221    fn test_find_matching_version() {
1222        let installed = vec![
1223            SemVer {
1224                major: 0,
1225                minor: 8,
1226                patch: 0,
1227            },
1228            SemVer {
1229                major: 0,
1230                minor: 8,
1231                patch: 20,
1232            },
1233            SemVer {
1234                major: 0,
1235                minor: 8,
1236                patch: 26,
1237            },
1238            SemVer {
1239                major: 0,
1240                minor: 8,
1241                patch: 33,
1242            },
1243        ];
1244        // ^0.8.20 should pick latest: 0.8.33
1245        let constraint = PragmaConstraint::Caret(SemVer {
1246            major: 0,
1247            minor: 8,
1248            patch: 20,
1249        });
1250        let matched = find_matching_version(&constraint, &installed);
1251        assert_eq!(
1252            matched,
1253            Some(SemVer {
1254                major: 0,
1255                minor: 8,
1256                patch: 33
1257            })
1258        );
1259
1260        // exact 0.8.20
1261        let constraint = PragmaConstraint::Exact(SemVer {
1262            major: 0,
1263            minor: 8,
1264            patch: 20,
1265        });
1266        let matched = find_matching_version(&constraint, &installed);
1267        assert_eq!(
1268            matched,
1269            Some(SemVer {
1270                major: 0,
1271                minor: 8,
1272                patch: 20
1273            })
1274        );
1275
1276        // exact 0.8.15 — not installed
1277        let constraint = PragmaConstraint::Exact(SemVer {
1278            major: 0,
1279            minor: 8,
1280            patch: 15,
1281        });
1282        let matched = find_matching_version(&constraint, &installed);
1283        assert_eq!(matched, None);
1284    }
1285
1286    #[test]
1287    fn test_list_installed_versions() {
1288        // Just verify it doesn't panic — actual versions depend on system
1289        let versions = list_installed_versions();
1290        // Versions should be sorted
1291        for w in versions.windows(2) {
1292            assert!(w[0] <= w[1]);
1293        }
1294    }
1295}