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