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    if let Some(c) = client {
900        c.log_message(
901            tower_lsp::lsp_types::MessageType::INFO,
902            format!(
903                "project index: discovered {} source files in {}",
904                source_files.len(),
905                config.root.display()
906            ),
907        )
908        .await;
909    }
910
911    // Use the first file to detect pragma and resolve solc binary.
912    // Prefer cached content over disk.
913    let first_source = text_cache
914        .and_then(|tc| {
915            let uri = Url::from_file_path(&source_files[0]).ok()?;
916            tc.get(&uri.to_string()).map(|(_, c)| c.clone())
917        })
918        .or_else(|| std::fs::read_to_string(&source_files[0]).ok());
919    let solc_binary = resolve_solc_binary(config, first_source.as_deref(), client).await;
920    let remappings = resolve_remappings(config).await;
921
922    let input =
923        build_batch_standard_json_input_with_cache(&source_files, &remappings, config, text_cache);
924    let raw_output = run_solc(&solc_binary, &input, &config.root).await?;
925    Ok(normalize_solc_output(raw_output, Some(&config.root)))
926}
927
928#[cfg(test)]
929mod tests {
930    use super::*;
931
932    #[test]
933    fn test_normalize_solc_sources() {
934        let solc_output = json!({
935            "sources": {
936                "src/Foo.sol": {
937                    "id": 0,
938                    "ast": {
939                        "nodeType": "SourceUnit",
940                        "absolutePath": "src/Foo.sol",
941                        "id": 100
942                    }
943                },
944                "src/Bar.sol": {
945                    "id": 1,
946                    "ast": {
947                        "nodeType": "SourceUnit",
948                        "absolutePath": "src/Bar.sol",
949                        "id": 200
950                    }
951                }
952            },
953            "contracts": {},
954            "errors": []
955        });
956
957        let normalized = normalize_solc_output(solc_output, None);
958
959        // Sources kept in solc-native shape: path -> { id, ast }
960        let sources = normalized.get("sources").unwrap().as_object().unwrap();
961        assert_eq!(sources.len(), 2);
962
963        let foo = sources.get("src/Foo.sol").unwrap();
964        assert_eq!(foo.get("id").unwrap(), 0);
965        assert_eq!(
966            foo.get("ast")
967                .unwrap()
968                .get("nodeType")
969                .unwrap()
970                .as_str()
971                .unwrap(),
972            "SourceUnit"
973        );
974
975        // Check source_id_to_path constructed
976        let id_to_path = normalized
977            .get("source_id_to_path")
978            .unwrap()
979            .as_object()
980            .unwrap();
981        assert_eq!(id_to_path.len(), 2);
982    }
983
984    #[test]
985    fn test_normalize_solc_contracts() {
986        let solc_output = json!({
987            "sources": {},
988            "contracts": {
989                "src/Foo.sol": {
990                    "Foo": {
991                        "abi": [{"type": "function", "name": "bar"}],
992                        "evm": {
993                            "methodIdentifiers": {
994                                "bar(uint256)": "abcd1234"
995                            },
996                            "gasEstimates": {
997                                "external": {"bar(uint256)": "200"}
998                            }
999                        }
1000                    }
1001                }
1002            },
1003            "errors": []
1004        });
1005
1006        let normalized = normalize_solc_output(solc_output, None);
1007
1008        // Contracts kept in solc-native shape: path -> name -> { abi, evm, ... }
1009        let contracts = normalized.get("contracts").unwrap().as_object().unwrap();
1010        let foo_contracts = contracts.get("src/Foo.sol").unwrap().as_object().unwrap();
1011        let foo = foo_contracts.get("Foo").unwrap();
1012
1013        let method_ids = foo
1014            .get("evm")
1015            .unwrap()
1016            .get("methodIdentifiers")
1017            .unwrap()
1018            .as_object()
1019            .unwrap();
1020        assert_eq!(
1021            method_ids.get("bar(uint256)").unwrap().as_str().unwrap(),
1022            "abcd1234"
1023        );
1024    }
1025
1026    #[test]
1027    fn test_normalize_solc_errors_passthrough() {
1028        let solc_output = json!({
1029            "sources": {},
1030            "contracts": {},
1031            "errors": [{
1032                "sourceLocation": {"file": "src/Foo.sol", "start": 0, "end": 10},
1033                "type": "Warning",
1034                "component": "general",
1035                "severity": "warning",
1036                "errorCode": "2394",
1037                "message": "test warning",
1038                "formattedMessage": "Warning: test warning"
1039            }]
1040        });
1041
1042        let normalized = normalize_solc_output(solc_output, None);
1043
1044        let errors = normalized.get("errors").unwrap().as_array().unwrap();
1045        assert_eq!(errors.len(), 1);
1046        assert_eq!(
1047            errors[0].get("errorCode").unwrap().as_str().unwrap(),
1048            "2394"
1049        );
1050    }
1051
1052    #[test]
1053    fn test_normalize_empty_solc_output() {
1054        let solc_output = json!({
1055            "sources": {},
1056            "contracts": {}
1057        });
1058
1059        let normalized = normalize_solc_output(solc_output, None);
1060
1061        assert!(
1062            normalized
1063                .get("sources")
1064                .unwrap()
1065                .as_object()
1066                .unwrap()
1067                .is_empty()
1068        );
1069        assert!(
1070            normalized
1071                .get("contracts")
1072                .unwrap()
1073                .as_object()
1074                .unwrap()
1075                .is_empty()
1076        );
1077        assert_eq!(
1078            normalized.get("errors").unwrap().as_array().unwrap().len(),
1079            0
1080        );
1081        assert!(
1082            normalized
1083                .get("source_id_to_path")
1084                .unwrap()
1085                .as_object()
1086                .unwrap()
1087                .is_empty()
1088        );
1089    }
1090
1091    #[test]
1092    fn test_build_standard_json_input() {
1093        let config = FoundryConfig::default();
1094        let input = build_standard_json_input(
1095            "/path/to/Foo.sol",
1096            &[
1097                "ds-test/=lib/forge-std/lib/ds-test/src/".to_string(),
1098                "forge-std/=lib/forge-std/src/".to_string(),
1099            ],
1100            &config,
1101        );
1102
1103        let sources = input.get("sources").unwrap().as_object().unwrap();
1104        assert!(sources.contains_key("/path/to/Foo.sol"));
1105
1106        let settings = input.get("settings").unwrap();
1107        let remappings = settings.get("remappings").unwrap().as_array().unwrap();
1108        assert_eq!(remappings.len(), 2);
1109
1110        let output_sel = settings.get("outputSelection").unwrap();
1111        assert!(output_sel.get("*").is_some());
1112
1113        // Default config: no optimizer, no viaIR, no evmVersion
1114        assert!(settings.get("optimizer").is_none());
1115        assert!(settings.get("viaIR").is_none());
1116        assert!(settings.get("evmVersion").is_none());
1117
1118        // Without viaIR, gasEstimates is included (~0.7s, enables gas hints)
1119        let outputs = settings["outputSelection"]["*"]["*"].as_array().unwrap();
1120        let output_names: Vec<&str> = outputs.iter().map(|v| v.as_str().unwrap()).collect();
1121        assert!(output_names.contains(&"evm.gasEstimates"));
1122        assert!(output_names.contains(&"abi"));
1123        assert!(output_names.contains(&"devdoc"));
1124        assert!(output_names.contains(&"userdoc"));
1125        assert!(output_names.contains(&"evm.methodIdentifiers"));
1126    }
1127
1128    #[test]
1129    fn test_build_standard_json_input_with_config() {
1130        let config = FoundryConfig {
1131            optimizer: true,
1132            optimizer_runs: 9999999,
1133            via_ir: true,
1134            evm_version: Some("osaka".to_string()),
1135            ..Default::default()
1136        };
1137        let input = build_standard_json_input("/path/to/Foo.sol", &[], &config);
1138
1139        let settings = input.get("settings").unwrap();
1140
1141        // Optimizer is never passed — adds ~3s and doesn't affect AST/ABI/docs
1142        assert!(settings.get("optimizer").is_none());
1143
1144        // viaIR IS passed when config has it (some contracts require it to compile)
1145        assert!(settings.get("viaIR").unwrap().as_bool().unwrap());
1146
1147        // With viaIR, gasEstimates is excluded (would cause 14s cold start)
1148        let outputs = settings["outputSelection"]["*"]["*"].as_array().unwrap();
1149        let output_names: Vec<&str> = outputs.iter().map(|v| v.as_str().unwrap()).collect();
1150        assert!(!output_names.contains(&"evm.gasEstimates"));
1151
1152        // EVM version
1153        assert_eq!(
1154            settings.get("evmVersion").unwrap().as_str().unwrap(),
1155            "osaka"
1156        );
1157    }
1158
1159    #[tokio::test]
1160    async fn test_resolve_solc_binary_default() {
1161        let config = FoundryConfig::default();
1162        let binary = resolve_solc_binary(&config, None, None).await;
1163        assert_eq!(binary, PathBuf::from("solc"));
1164    }
1165
1166    #[test]
1167    fn test_parse_pragma_exact() {
1168        let source = "// SPDX\npragma solidity 0.8.26;\n";
1169        assert_eq!(
1170            parse_pragma(source),
1171            Some(PragmaConstraint::Exact(SemVer {
1172                major: 0,
1173                minor: 8,
1174                patch: 26
1175            }))
1176        );
1177    }
1178
1179    #[test]
1180    fn test_parse_pragma_caret() {
1181        let source = "pragma solidity ^0.8.0;\n";
1182        assert_eq!(
1183            parse_pragma(source),
1184            Some(PragmaConstraint::Caret(SemVer {
1185                major: 0,
1186                minor: 8,
1187                patch: 0
1188            }))
1189        );
1190    }
1191
1192    #[test]
1193    fn test_parse_pragma_gte() {
1194        let source = "pragma solidity >=0.8.0;\n";
1195        assert_eq!(
1196            parse_pragma(source),
1197            Some(PragmaConstraint::Gte(SemVer {
1198                major: 0,
1199                minor: 8,
1200                patch: 0
1201            }))
1202        );
1203    }
1204
1205    #[test]
1206    fn test_parse_pragma_range() {
1207        let source = "pragma solidity >=0.6.2 <0.9.0;\n";
1208        assert_eq!(
1209            parse_pragma(source),
1210            Some(PragmaConstraint::Range(
1211                SemVer {
1212                    major: 0,
1213                    minor: 6,
1214                    patch: 2
1215                },
1216                SemVer {
1217                    major: 0,
1218                    minor: 9,
1219                    patch: 0
1220                },
1221            ))
1222        );
1223    }
1224
1225    #[test]
1226    fn test_parse_pragma_none() {
1227        let source = "contract Foo {}\n";
1228        assert_eq!(parse_pragma(source), None);
1229    }
1230
1231    #[test]
1232    fn test_version_satisfies_exact() {
1233        let v = SemVer {
1234            major: 0,
1235            minor: 8,
1236            patch: 26,
1237        };
1238        assert!(version_satisfies(&v, &PragmaConstraint::Exact(v.clone())));
1239        assert!(!version_satisfies(
1240            &SemVer {
1241                major: 0,
1242                minor: 8,
1243                patch: 25
1244            },
1245            &PragmaConstraint::Exact(v)
1246        ));
1247    }
1248
1249    #[test]
1250    fn test_version_satisfies_caret() {
1251        let constraint = PragmaConstraint::Caret(SemVer {
1252            major: 0,
1253            minor: 8,
1254            patch: 0,
1255        });
1256        assert!(version_satisfies(
1257            &SemVer {
1258                major: 0,
1259                minor: 8,
1260                patch: 0
1261            },
1262            &constraint
1263        ));
1264        assert!(version_satisfies(
1265            &SemVer {
1266                major: 0,
1267                minor: 8,
1268                patch: 26
1269            },
1270            &constraint
1271        ));
1272        // 0.9.0 is outside ^0.8.0
1273        assert!(!version_satisfies(
1274            &SemVer {
1275                major: 0,
1276                minor: 9,
1277                patch: 0
1278            },
1279            &constraint
1280        ));
1281        // 0.7.0 is below
1282        assert!(!version_satisfies(
1283            &SemVer {
1284                major: 0,
1285                minor: 7,
1286                patch: 0
1287            },
1288            &constraint
1289        ));
1290    }
1291
1292    #[test]
1293    fn test_version_satisfies_gte() {
1294        let constraint = PragmaConstraint::Gte(SemVer {
1295            major: 0,
1296            minor: 8,
1297            patch: 0,
1298        });
1299        assert!(version_satisfies(
1300            &SemVer {
1301                major: 0,
1302                minor: 8,
1303                patch: 0
1304            },
1305            &constraint
1306        ));
1307        assert!(version_satisfies(
1308            &SemVer {
1309                major: 0,
1310                minor: 9,
1311                patch: 0
1312            },
1313            &constraint
1314        ));
1315        assert!(!version_satisfies(
1316            &SemVer {
1317                major: 0,
1318                minor: 7,
1319                patch: 0
1320            },
1321            &constraint
1322        ));
1323    }
1324
1325    #[test]
1326    fn test_version_satisfies_range() {
1327        let constraint = PragmaConstraint::Range(
1328            SemVer {
1329                major: 0,
1330                minor: 6,
1331                patch: 2,
1332            },
1333            SemVer {
1334                major: 0,
1335                minor: 9,
1336                patch: 0,
1337            },
1338        );
1339        assert!(version_satisfies(
1340            &SemVer {
1341                major: 0,
1342                minor: 6,
1343                patch: 2
1344            },
1345            &constraint
1346        ));
1347        assert!(version_satisfies(
1348            &SemVer {
1349                major: 0,
1350                minor: 8,
1351                patch: 26
1352            },
1353            &constraint
1354        ));
1355        // 0.9.0 is the upper bound (exclusive)
1356        assert!(!version_satisfies(
1357            &SemVer {
1358                major: 0,
1359                minor: 9,
1360                patch: 0
1361            },
1362            &constraint
1363        ));
1364        assert!(!version_satisfies(
1365            &SemVer {
1366                major: 0,
1367                minor: 6,
1368                patch: 1
1369            },
1370            &constraint
1371        ));
1372    }
1373
1374    #[test]
1375    fn test_find_matching_version() {
1376        let installed = vec![
1377            SemVer {
1378                major: 0,
1379                minor: 8,
1380                patch: 0,
1381            },
1382            SemVer {
1383                major: 0,
1384                minor: 8,
1385                patch: 20,
1386            },
1387            SemVer {
1388                major: 0,
1389                minor: 8,
1390                patch: 26,
1391            },
1392            SemVer {
1393                major: 0,
1394                minor: 8,
1395                patch: 33,
1396            },
1397        ];
1398        // ^0.8.20 should pick latest: 0.8.33
1399        let constraint = PragmaConstraint::Caret(SemVer {
1400            major: 0,
1401            minor: 8,
1402            patch: 20,
1403        });
1404        let matched = find_matching_version(&constraint, &installed);
1405        assert_eq!(
1406            matched,
1407            Some(SemVer {
1408                major: 0,
1409                minor: 8,
1410                patch: 33
1411            })
1412        );
1413
1414        // exact 0.8.20
1415        let constraint = PragmaConstraint::Exact(SemVer {
1416            major: 0,
1417            minor: 8,
1418            patch: 20,
1419        });
1420        let matched = find_matching_version(&constraint, &installed);
1421        assert_eq!(
1422            matched,
1423            Some(SemVer {
1424                major: 0,
1425                minor: 8,
1426                patch: 20
1427            })
1428        );
1429
1430        // exact 0.8.15 — not installed
1431        let constraint = PragmaConstraint::Exact(SemVer {
1432            major: 0,
1433            minor: 8,
1434            patch: 15,
1435        });
1436        let matched = find_matching_version(&constraint, &installed);
1437        assert_eq!(matched, None);
1438    }
1439
1440    #[test]
1441    fn test_list_installed_versions() {
1442        // Just verify it doesn't panic — actual versions depend on system
1443        let versions = list_installed_versions();
1444        // Versions should be sorted
1445        for w in versions.windows(2) {
1446            assert!(w[0] <= w[1]);
1447        }
1448    }
1449}