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