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