Skip to main content

solidity_language_server/
runner.rs

1use crate::{
2    build::build_output_to_diagnostics, config::LintSettings, lint::lint_output_to_diagnostics,
3    solc::normalize_forge_output,
4};
5use serde::{Deserialize, Serialize};
6use std::{io, path::PathBuf};
7use thiserror::Error;
8use tokio::process::Command;
9use tower_lsp::{
10    async_trait,
11    lsp_types::{Diagnostic, Url},
12};
13
14pub struct ForgeRunner;
15
16#[async_trait]
17pub trait Runner: Send + Sync {
18    async fn build(&self, file: &str) -> Result<serde_json::Value, RunnerError>;
19    async fn lint(
20        &self,
21        file: &str,
22        lint_settings: &LintSettings,
23    ) -> Result<serde_json::Value, RunnerError>;
24    async fn ast(&self, file: &str) -> Result<serde_json::Value, RunnerError>;
25    async fn format(&self, file: &str) -> Result<String, RunnerError>;
26    async fn get_build_diagnostics(&self, file: &Url) -> Result<Vec<Diagnostic>, RunnerError>;
27    async fn get_lint_diagnostics(
28        &self,
29        file: &Url,
30        lint_settings: &LintSettings,
31    ) -> Result<Vec<Diagnostic>, RunnerError>;
32}
33
34#[async_trait]
35impl Runner for ForgeRunner {
36    async fn lint(
37        &self,
38        file_path: &str,
39        lint_settings: &LintSettings,
40    ) -> Result<serde_json::Value, RunnerError> {
41        let mut cmd = Command::new("forge");
42        cmd.arg("lint")
43            .arg(file_path)
44            .arg("--json")
45            .env("FOUNDRY_DISABLE_NIGHTLY_WARNING", "1");
46
47        // Pass --severity flags from settings
48        for sev in &lint_settings.severity {
49            cmd.args(["--severity", sev]);
50        }
51
52        // Pass --only-lint flags from settings
53        for lint_id in &lint_settings.only {
54            cmd.args(["--only-lint", lint_id]);
55        }
56
57        let output = cmd.output().await?;
58
59        let stderr_str = String::from_utf8_lossy(&output.stderr);
60
61        // Parse JSON output line by line
62        let mut diagnostics = Vec::new();
63        for line in stderr_str.lines() {
64            if line.trim().is_empty() {
65                continue;
66            }
67
68            match serde_json::from_str::<serde_json::Value>(line) {
69                Ok(value) => diagnostics.push(value),
70                Err(_e) => {
71                    continue;
72                }
73            }
74        }
75
76        Ok(serde_json::Value::Array(diagnostics))
77    }
78
79    async fn build(&self, file_path: &str) -> Result<serde_json::Value, RunnerError> {
80        let output = Command::new("forge")
81            .arg("build")
82            .arg(file_path)
83            .arg("--json")
84            .arg("--no-cache")
85            .arg("--ast")
86            .arg("--ignore-eip-3860")
87            .args(["--ignored-error-codes", "5574"])
88            .env("FOUNDRY_DISABLE_NIGHTLY_WARNING", "1")
89            .env("FOUNDRY_LINT_LINT_ON_BUILD", "false")
90            .output()
91            .await?;
92
93        let stdout_str = String::from_utf8_lossy(&output.stdout);
94        let parsed = parse_json_from_mixed_stdout(&stdout_str)?;
95
96        Ok(parsed)
97    }
98
99    async fn ast(&self, file_path: &str) -> Result<serde_json::Value, RunnerError> {
100        let output = Command::new("forge")
101            .arg("build")
102            .arg(file_path)
103            .arg("--json")
104            .arg("--no-cache")
105            .arg("--ast")
106            .arg("--ignore-eip-3860")
107            .args(["--ignored-error-codes", "5574"])
108            .env("FOUNDRY_DISABLE_NIGHTLY_WARNING", "1")
109            .env("FOUNDRY_LINT_LINT_ON_BUILD", "false")
110            .output()
111            .await?;
112
113        let stdout_str = String::from_utf8_lossy(&output.stdout);
114        let parsed = parse_json_from_mixed_stdout(&stdout_str)?;
115
116        Ok(normalize_forge_output(parsed))
117    }
118
119    // NOTE: forge-fmt 0.2.0 on crates.io is from September 2023 and has not been updated since.
120    // Foundry is currently at v1.6.0 with active formatter fixes. Using the crate would produce
121    // different output than the user's installed `forge fmt`. Keep the subprocess — it is correct
122    // by definition and format requests are infrequent (once per save).
123    // Revisit if Foundry ever publishes updated crates to crates.io.
124    async fn format(&self, file_path: &str) -> Result<String, RunnerError> {
125        let output = Command::new("forge")
126            .arg("fmt")
127            .arg(file_path)
128            .arg("--check")
129            .arg("--raw")
130            .env("FOUNDRY_DISABLE_NIGHTLY_WARNING", "1")
131            .output()
132            .await?;
133        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
134        let stderr = String::from_utf8_lossy(&output.stderr);
135        match output.status.code() {
136            Some(0) => {
137                // Already formatted, read the current file content
138                tokio::fs::read_to_string(file_path)
139                    .await
140                    .map_err(|_| RunnerError::ReadError)
141            }
142            Some(1) => {
143                // Needs formatting, stdout has the formatted content
144                if stdout.is_empty() {
145                    Err(RunnerError::CommandError(io::Error::other(format!(
146                        "forge fmt unexpected empty output on {}: exit code {}, stderr: {}",
147                        file_path, output.status, stderr
148                    ))))
149                } else {
150                    Ok(stdout)
151                }
152            }
153            _ => Err(RunnerError::CommandError(io::Error::other(format!(
154                "forge fmt failed on {}: exit code {}, stderr: {}",
155                file_path, output.status, stderr
156            )))),
157        }
158    }
159
160    async fn get_lint_diagnostics(
161        &self,
162        file: &Url,
163        lint_settings: &LintSettings,
164    ) -> Result<Vec<Diagnostic>, RunnerError> {
165        let path: PathBuf = file.to_file_path().map_err(|_| RunnerError::InvalidUrl)?;
166        let path_str = path.to_str().ok_or(RunnerError::InvalidUrl)?;
167        let lint_output = self.lint(path_str, lint_settings).await?;
168        let diagnostics = lint_output_to_diagnostics(&lint_output, path_str);
169        Ok(diagnostics)
170    }
171
172    async fn get_build_diagnostics(&self, file: &Url) -> Result<Vec<Diagnostic>, RunnerError> {
173        let path = file.to_file_path().map_err(|_| RunnerError::InvalidUrl)?;
174        let path_str = path.to_str().ok_or(RunnerError::InvalidUrl)?;
175        let content = tokio::fs::read_to_string(&path)
176            .await
177            .map_err(|_| RunnerError::ReadError)?;
178        let build_output = self.build(path_str).await?;
179        let diagnostics = build_output_to_diagnostics(&build_output, &path, &content, &[]);
180        Ok(diagnostics)
181    }
182}
183
184/// Parse JSON from forge stdout, tolerating non-JSON log lines before payload.
185fn parse_json_from_mixed_stdout(stdout: &str) -> Result<serde_json::Value, RunnerError> {
186    let trimmed = stdout.trim();
187    if trimmed.is_empty() {
188        return Err(RunnerError::EmptyOutput);
189    }
190
191    if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
192        return Ok(v);
193    }
194
195    // Some forge/log integrations can print warnings to stdout before JSON.
196    // Try parsing from each `{` onward and take the first valid JSON object.
197    for (idx, ch) in trimmed.char_indices() {
198        if ch != '{' {
199            continue;
200        }
201        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&trimmed[idx..]) {
202            return Ok(v);
203        }
204    }
205
206    Err(RunnerError::JsonError(serde_json::Error::io(
207        io::Error::other("failed to parse forge JSON from mixed stdout"),
208    )))
209}
210
211#[cfg(test)]
212mod tests {
213    use super::parse_json_from_mixed_stdout;
214
215    #[test]
216    fn parse_json_from_mixed_stdout_accepts_plain_json() {
217        let out = r#"{ "errors": [], "sources": {}, "contracts": {}, "build_infos": [] }"#;
218        let parsed = parse_json_from_mixed_stdout(out).expect("valid JSON");
219        assert!(parsed.get("errors").is_some());
220    }
221
222    #[test]
223    fn parse_json_from_mixed_stdout_skips_leading_logs() {
224        let out = r#"WARN cache write failed
225{ "errors": [], "sources": {}, "contracts": {}, "build_infos": [] }"#;
226        let parsed = parse_json_from_mixed_stdout(out).expect("mixed output should parse");
227        assert!(parsed.get("sources").is_some());
228    }
229}
230
231#[derive(Error, Debug)]
232pub enum RunnerError {
233    #[error("Invalid file URL")]
234    InvalidUrl,
235    #[error("Failed to run command: {0}")]
236    CommandError(#[from] io::Error),
237    #[error("JSON error: {0}")]
238    JsonError(#[from] serde_json::Error),
239    #[error("Empty output from compiler")]
240    EmptyOutput,
241    #[error("ReadError")]
242    ReadError,
243}
244
245#[derive(Debug, Deserialize, Serialize)]
246pub struct SourceLocation {
247    file: String,
248    start: i32, // Changed to i32 to handle -1 values
249    end: i32,   // Changed to i32 to handle -1 values
250}
251
252#[derive(Debug, Deserialize, Serialize)]
253pub struct ForgeDiagnosticMessage {
254    #[serde(rename = "sourceLocation")]
255    source_location: SourceLocation,
256    #[serde(rename = "type")]
257    error_type: String,
258    component: String,
259    severity: String,
260    #[serde(rename = "errorCode")]
261    error_code: String,
262    message: String,
263    #[serde(rename = "formattedMessage")]
264    formatted_message: String,
265}
266
267#[derive(Debug, Deserialize, Serialize)]
268pub struct CompileOutput {
269    errors: Option<Vec<ForgeDiagnosticMessage>>,
270    sources: serde_json::Value,
271    contracts: serde_json::Value,
272    build_infos: Vec<serde_json::Value>,
273}