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                if SKIP_DIRS.contains(&name) {
756                    continue;
757                }
758            }
759            discover_recursive(&path, files);
760        } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
761            if name.ends_with(".sol") && !name.ends_with(".t.sol") && !name.ends_with(".s.sol") {
762                files.push(path);
763            }
764        }
765    }
766}
767
768/// Build a `--standard-json` input that compiles all given source files at once.
769///
770/// Each file is added as a source entry with a `urls` field (relative to project root).
771/// This produces a single AST covering the entire project in one solc invocation.
772///
773/// See [`build_standard_json_input`] for rationale on excluded settings.
774pub fn build_batch_standard_json_input(
775    source_files: &[PathBuf],
776    remappings: &[String],
777    config: &FoundryConfig,
778) -> Value {
779    let mut contract_outputs = vec!["abi", "devdoc", "userdoc", "evm.methodIdentifiers"];
780    if !config.via_ir {
781        contract_outputs.push("evm.gasEstimates");
782    }
783
784    let mut settings = json!({
785        "remappings": remappings,
786        "outputSelection": {
787            "*": {
788                "*": contract_outputs,
789                "": ["ast"]
790            }
791        }
792    });
793
794    if config.via_ir {
795        settings["viaIR"] = json!(true);
796    }
797    if let Some(ref evm_version) = config.evm_version {
798        settings["evmVersion"] = json!(evm_version);
799    }
800
801    let mut sources = serde_json::Map::new();
802    for file in source_files {
803        let rel_path = file
804            .strip_prefix(&config.root)
805            .map(|p| p.to_string_lossy().into_owned())
806            .unwrap_or_else(|_| file.to_string_lossy().into_owned());
807        sources.insert(rel_path.clone(), json!({ "urls": [rel_path] }));
808    }
809
810    json!({
811        "language": "Solidity",
812        "sources": sources,
813        "settings": settings
814    })
815}
816
817/// Run a project-wide solc compilation and return normalized output.
818///
819/// Discovers all source files, compiles them in a single `solc --standard-json`
820/// invocation, and returns the normalized AST data.
821pub async fn solc_project_index(
822    config: &FoundryConfig,
823    client: Option<&tower_lsp::Client>,
824) -> Result<Value, RunnerError> {
825    let source_files = discover_source_files(config);
826    if source_files.is_empty() {
827        return Err(RunnerError::CommandError(std::io::Error::other(
828            "no source files found for project index",
829        )));
830    }
831
832    if let Some(c) = client {
833        c.log_message(
834            tower_lsp::lsp_types::MessageType::INFO,
835            format!(
836                "project index: discovered {} source files in {}/",
837                source_files.len(),
838                config.sources_dir
839            ),
840        )
841        .await;
842    }
843
844    // Use the first file to detect pragma and resolve solc binary.
845    let first_source = std::fs::read_to_string(&source_files[0]).ok();
846    let solc_binary = resolve_solc_binary(config, first_source.as_deref(), client).await;
847    let remappings = resolve_remappings(config).await;
848
849    let input = build_batch_standard_json_input(&source_files, &remappings, config);
850    let raw_output = run_solc(&solc_binary, &input, &config.root).await?;
851    Ok(normalize_solc_output(raw_output, Some(&config.root)))
852}
853
854#[cfg(test)]
855mod tests {
856    use super::*;
857
858    #[test]
859    fn test_normalize_solc_sources() {
860        let solc_output = json!({
861            "sources": {
862                "src/Foo.sol": {
863                    "id": 0,
864                    "ast": {
865                        "nodeType": "SourceUnit",
866                        "absolutePath": "src/Foo.sol",
867                        "id": 100
868                    }
869                },
870                "src/Bar.sol": {
871                    "id": 1,
872                    "ast": {
873                        "nodeType": "SourceUnit",
874                        "absolutePath": "src/Bar.sol",
875                        "id": 200
876                    }
877                }
878            },
879            "contracts": {},
880            "errors": []
881        });
882
883        let normalized = normalize_solc_output(solc_output, None);
884
885        // Sources kept in solc-native shape: path -> { id, ast }
886        let sources = normalized.get("sources").unwrap().as_object().unwrap();
887        assert_eq!(sources.len(), 2);
888
889        let foo = sources.get("src/Foo.sol").unwrap();
890        assert_eq!(foo.get("id").unwrap(), 0);
891        assert_eq!(
892            foo.get("ast")
893                .unwrap()
894                .get("nodeType")
895                .unwrap()
896                .as_str()
897                .unwrap(),
898            "SourceUnit"
899        );
900
901        // Check source_id_to_path constructed
902        let id_to_path = normalized
903            .get("source_id_to_path")
904            .unwrap()
905            .as_object()
906            .unwrap();
907        assert_eq!(id_to_path.len(), 2);
908    }
909
910    #[test]
911    fn test_normalize_solc_contracts() {
912        let solc_output = json!({
913            "sources": {},
914            "contracts": {
915                "src/Foo.sol": {
916                    "Foo": {
917                        "abi": [{"type": "function", "name": "bar"}],
918                        "evm": {
919                            "methodIdentifiers": {
920                                "bar(uint256)": "abcd1234"
921                            },
922                            "gasEstimates": {
923                                "external": {"bar(uint256)": "200"}
924                            }
925                        }
926                    }
927                }
928            },
929            "errors": []
930        });
931
932        let normalized = normalize_solc_output(solc_output, None);
933
934        // Contracts kept in solc-native shape: path -> name -> { abi, evm, ... }
935        let contracts = normalized.get("contracts").unwrap().as_object().unwrap();
936        let foo_contracts = contracts.get("src/Foo.sol").unwrap().as_object().unwrap();
937        let foo = foo_contracts.get("Foo").unwrap();
938
939        let method_ids = foo
940            .get("evm")
941            .unwrap()
942            .get("methodIdentifiers")
943            .unwrap()
944            .as_object()
945            .unwrap();
946        assert_eq!(
947            method_ids.get("bar(uint256)").unwrap().as_str().unwrap(),
948            "abcd1234"
949        );
950    }
951
952    #[test]
953    fn test_normalize_solc_errors_passthrough() {
954        let solc_output = json!({
955            "sources": {},
956            "contracts": {},
957            "errors": [{
958                "sourceLocation": {"file": "src/Foo.sol", "start": 0, "end": 10},
959                "type": "Warning",
960                "component": "general",
961                "severity": "warning",
962                "errorCode": "2394",
963                "message": "test warning",
964                "formattedMessage": "Warning: test warning"
965            }]
966        });
967
968        let normalized = normalize_solc_output(solc_output, None);
969
970        let errors = normalized.get("errors").unwrap().as_array().unwrap();
971        assert_eq!(errors.len(), 1);
972        assert_eq!(
973            errors[0].get("errorCode").unwrap().as_str().unwrap(),
974            "2394"
975        );
976    }
977
978    #[test]
979    fn test_normalize_empty_solc_output() {
980        let solc_output = json!({
981            "sources": {},
982            "contracts": {}
983        });
984
985        let normalized = normalize_solc_output(solc_output, None);
986
987        assert!(
988            normalized
989                .get("sources")
990                .unwrap()
991                .as_object()
992                .unwrap()
993                .is_empty()
994        );
995        assert!(
996            normalized
997                .get("contracts")
998                .unwrap()
999                .as_object()
1000                .unwrap()
1001                .is_empty()
1002        );
1003        assert_eq!(
1004            normalized.get("errors").unwrap().as_array().unwrap().len(),
1005            0
1006        );
1007        assert!(
1008            normalized
1009                .get("source_id_to_path")
1010                .unwrap()
1011                .as_object()
1012                .unwrap()
1013                .is_empty()
1014        );
1015    }
1016
1017    #[test]
1018    fn test_build_standard_json_input() {
1019        let config = FoundryConfig::default();
1020        let input = build_standard_json_input(
1021            "/path/to/Foo.sol",
1022            &[
1023                "ds-test/=lib/forge-std/lib/ds-test/src/".to_string(),
1024                "forge-std/=lib/forge-std/src/".to_string(),
1025            ],
1026            &config,
1027        );
1028
1029        let sources = input.get("sources").unwrap().as_object().unwrap();
1030        assert!(sources.contains_key("/path/to/Foo.sol"));
1031
1032        let settings = input.get("settings").unwrap();
1033        let remappings = settings.get("remappings").unwrap().as_array().unwrap();
1034        assert_eq!(remappings.len(), 2);
1035
1036        let output_sel = settings.get("outputSelection").unwrap();
1037        assert!(output_sel.get("*").is_some());
1038
1039        // Default config: no optimizer, no viaIR, no evmVersion
1040        assert!(settings.get("optimizer").is_none());
1041        assert!(settings.get("viaIR").is_none());
1042        assert!(settings.get("evmVersion").is_none());
1043
1044        // Without viaIR, gasEstimates is included (~0.7s, enables gas hints)
1045        let outputs = settings["outputSelection"]["*"]["*"].as_array().unwrap();
1046        let output_names: Vec<&str> = outputs.iter().map(|v| v.as_str().unwrap()).collect();
1047        assert!(output_names.contains(&"evm.gasEstimates"));
1048        assert!(output_names.contains(&"abi"));
1049        assert!(output_names.contains(&"devdoc"));
1050        assert!(output_names.contains(&"userdoc"));
1051        assert!(output_names.contains(&"evm.methodIdentifiers"));
1052    }
1053
1054    #[test]
1055    fn test_build_standard_json_input_with_config() {
1056        let config = FoundryConfig {
1057            optimizer: true,
1058            optimizer_runs: 9999999,
1059            via_ir: true,
1060            evm_version: Some("osaka".to_string()),
1061            ..Default::default()
1062        };
1063        let input = build_standard_json_input("/path/to/Foo.sol", &[], &config);
1064
1065        let settings = input.get("settings").unwrap();
1066
1067        // Optimizer is never passed — adds ~3s and doesn't affect AST/ABI/docs
1068        assert!(settings.get("optimizer").is_none());
1069
1070        // viaIR IS passed when config has it (some contracts require it to compile)
1071        assert!(settings.get("viaIR").unwrap().as_bool().unwrap());
1072
1073        // With viaIR, gasEstimates is excluded (would cause 14s cold start)
1074        let outputs = settings["outputSelection"]["*"]["*"].as_array().unwrap();
1075        let output_names: Vec<&str> = outputs.iter().map(|v| v.as_str().unwrap()).collect();
1076        assert!(!output_names.contains(&"evm.gasEstimates"));
1077
1078        // EVM version
1079        assert_eq!(
1080            settings.get("evmVersion").unwrap().as_str().unwrap(),
1081            "osaka"
1082        );
1083    }
1084
1085    #[tokio::test]
1086    async fn test_resolve_solc_binary_default() {
1087        let config = FoundryConfig::default();
1088        let binary = resolve_solc_binary(&config, None, None).await;
1089        assert_eq!(binary, PathBuf::from("solc"));
1090    }
1091
1092    #[test]
1093    fn test_parse_pragma_exact() {
1094        let source = "// SPDX\npragma solidity 0.8.26;\n";
1095        assert_eq!(
1096            parse_pragma(source),
1097            Some(PragmaConstraint::Exact(SemVer {
1098                major: 0,
1099                minor: 8,
1100                patch: 26
1101            }))
1102        );
1103    }
1104
1105    #[test]
1106    fn test_parse_pragma_caret() {
1107        let source = "pragma solidity ^0.8.0;\n";
1108        assert_eq!(
1109            parse_pragma(source),
1110            Some(PragmaConstraint::Caret(SemVer {
1111                major: 0,
1112                minor: 8,
1113                patch: 0
1114            }))
1115        );
1116    }
1117
1118    #[test]
1119    fn test_parse_pragma_gte() {
1120        let source = "pragma solidity >=0.8.0;\n";
1121        assert_eq!(
1122            parse_pragma(source),
1123            Some(PragmaConstraint::Gte(SemVer {
1124                major: 0,
1125                minor: 8,
1126                patch: 0
1127            }))
1128        );
1129    }
1130
1131    #[test]
1132    fn test_parse_pragma_range() {
1133        let source = "pragma solidity >=0.6.2 <0.9.0;\n";
1134        assert_eq!(
1135            parse_pragma(source),
1136            Some(PragmaConstraint::Range(
1137                SemVer {
1138                    major: 0,
1139                    minor: 6,
1140                    patch: 2
1141                },
1142                SemVer {
1143                    major: 0,
1144                    minor: 9,
1145                    patch: 0
1146                },
1147            ))
1148        );
1149    }
1150
1151    #[test]
1152    fn test_parse_pragma_none() {
1153        let source = "contract Foo {}\n";
1154        assert_eq!(parse_pragma(source), None);
1155    }
1156
1157    #[test]
1158    fn test_version_satisfies_exact() {
1159        let v = SemVer {
1160            major: 0,
1161            minor: 8,
1162            patch: 26,
1163        };
1164        assert!(version_satisfies(&v, &PragmaConstraint::Exact(v.clone())));
1165        assert!(!version_satisfies(
1166            &SemVer {
1167                major: 0,
1168                minor: 8,
1169                patch: 25
1170            },
1171            &PragmaConstraint::Exact(v)
1172        ));
1173    }
1174
1175    #[test]
1176    fn test_version_satisfies_caret() {
1177        let constraint = PragmaConstraint::Caret(SemVer {
1178            major: 0,
1179            minor: 8,
1180            patch: 0,
1181        });
1182        assert!(version_satisfies(
1183            &SemVer {
1184                major: 0,
1185                minor: 8,
1186                patch: 0
1187            },
1188            &constraint
1189        ));
1190        assert!(version_satisfies(
1191            &SemVer {
1192                major: 0,
1193                minor: 8,
1194                patch: 26
1195            },
1196            &constraint
1197        ));
1198        // 0.9.0 is outside ^0.8.0
1199        assert!(!version_satisfies(
1200            &SemVer {
1201                major: 0,
1202                minor: 9,
1203                patch: 0
1204            },
1205            &constraint
1206        ));
1207        // 0.7.0 is below
1208        assert!(!version_satisfies(
1209            &SemVer {
1210                major: 0,
1211                minor: 7,
1212                patch: 0
1213            },
1214            &constraint
1215        ));
1216    }
1217
1218    #[test]
1219    fn test_version_satisfies_gte() {
1220        let constraint = PragmaConstraint::Gte(SemVer {
1221            major: 0,
1222            minor: 8,
1223            patch: 0,
1224        });
1225        assert!(version_satisfies(
1226            &SemVer {
1227                major: 0,
1228                minor: 8,
1229                patch: 0
1230            },
1231            &constraint
1232        ));
1233        assert!(version_satisfies(
1234            &SemVer {
1235                major: 0,
1236                minor: 9,
1237                patch: 0
1238            },
1239            &constraint
1240        ));
1241        assert!(!version_satisfies(
1242            &SemVer {
1243                major: 0,
1244                minor: 7,
1245                patch: 0
1246            },
1247            &constraint
1248        ));
1249    }
1250
1251    #[test]
1252    fn test_version_satisfies_range() {
1253        let constraint = PragmaConstraint::Range(
1254            SemVer {
1255                major: 0,
1256                minor: 6,
1257                patch: 2,
1258            },
1259            SemVer {
1260                major: 0,
1261                minor: 9,
1262                patch: 0,
1263            },
1264        );
1265        assert!(version_satisfies(
1266            &SemVer {
1267                major: 0,
1268                minor: 6,
1269                patch: 2
1270            },
1271            &constraint
1272        ));
1273        assert!(version_satisfies(
1274            &SemVer {
1275                major: 0,
1276                minor: 8,
1277                patch: 26
1278            },
1279            &constraint
1280        ));
1281        // 0.9.0 is the upper bound (exclusive)
1282        assert!(!version_satisfies(
1283            &SemVer {
1284                major: 0,
1285                minor: 9,
1286                patch: 0
1287            },
1288            &constraint
1289        ));
1290        assert!(!version_satisfies(
1291            &SemVer {
1292                major: 0,
1293                minor: 6,
1294                patch: 1
1295            },
1296            &constraint
1297        ));
1298    }
1299
1300    #[test]
1301    fn test_find_matching_version() {
1302        let installed = vec![
1303            SemVer {
1304                major: 0,
1305                minor: 8,
1306                patch: 0,
1307            },
1308            SemVer {
1309                major: 0,
1310                minor: 8,
1311                patch: 20,
1312            },
1313            SemVer {
1314                major: 0,
1315                minor: 8,
1316                patch: 26,
1317            },
1318            SemVer {
1319                major: 0,
1320                minor: 8,
1321                patch: 33,
1322            },
1323        ];
1324        // ^0.8.20 should pick latest: 0.8.33
1325        let constraint = PragmaConstraint::Caret(SemVer {
1326            major: 0,
1327            minor: 8,
1328            patch: 20,
1329        });
1330        let matched = find_matching_version(&constraint, &installed);
1331        assert_eq!(
1332            matched,
1333            Some(SemVer {
1334                major: 0,
1335                minor: 8,
1336                patch: 33
1337            })
1338        );
1339
1340        // exact 0.8.20
1341        let constraint = PragmaConstraint::Exact(SemVer {
1342            major: 0,
1343            minor: 8,
1344            patch: 20,
1345        });
1346        let matched = find_matching_version(&constraint, &installed);
1347        assert_eq!(
1348            matched,
1349            Some(SemVer {
1350                major: 0,
1351                minor: 8,
1352                patch: 20
1353            })
1354        );
1355
1356        // exact 0.8.15 — not installed
1357        let constraint = PragmaConstraint::Exact(SemVer {
1358            major: 0,
1359            minor: 8,
1360            patch: 15,
1361        });
1362        let matched = find_matching_version(&constraint, &installed);
1363        assert_eq!(matched, None);
1364    }
1365
1366    #[test]
1367    fn test_list_installed_versions() {
1368        // Just verify it doesn't panic — actual versions depend on system
1369        let versions = list_installed_versions();
1370        // Versions should be sorted
1371        for w in versions.windows(2) {
1372            assert!(w[0] <= w[1]);
1373        }
1374    }
1375}