solidity_language_server/
runner.rs1use 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 for sev in &lint_settings.severity {
49 cmd.args(["--severity", sev]);
50 }
51
52 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 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 async fn format(&self, file_path: &str) -> Result<String, RunnerError> {
120 let output = Command::new("forge")
121 .arg("fmt")
122 .arg(file_path)
123 .arg("--check")
124 .arg("--raw")
125 .env("FOUNDRY_DISABLE_NIGHTLY_WARNING", "1")
126 .output()
127 .await?;
128 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
129 let stderr = String::from_utf8_lossy(&output.stderr);
130 match output.status.code() {
131 Some(0) => {
132 tokio::fs::read_to_string(file_path)
134 .await
135 .map_err(|_| RunnerError::ReadError)
136 }
137 Some(1) => {
138 if stdout.is_empty() {
140 Err(RunnerError::CommandError(io::Error::other(format!(
141 "forge fmt unexpected empty output on {}: exit code {}, stderr: {}",
142 file_path, output.status, stderr
143 ))))
144 } else {
145 Ok(stdout)
146 }
147 }
148 _ => Err(RunnerError::CommandError(io::Error::other(format!(
149 "forge fmt failed on {}: exit code {}, stderr: {}",
150 file_path, output.status, stderr
151 )))),
152 }
153 }
154
155 async fn get_lint_diagnostics(
156 &self,
157 file: &Url,
158 lint_settings: &LintSettings,
159 ) -> Result<Vec<Diagnostic>, RunnerError> {
160 let path: PathBuf = file.to_file_path().map_err(|_| RunnerError::InvalidUrl)?;
161 let path_str = path.to_str().ok_or(RunnerError::InvalidUrl)?;
162 let lint_output = self.lint(path_str, lint_settings).await?;
163 let diagnostics = lint_output_to_diagnostics(&lint_output, path_str);
164 Ok(diagnostics)
165 }
166
167 async fn get_build_diagnostics(&self, file: &Url) -> Result<Vec<Diagnostic>, RunnerError> {
168 let path = file.to_file_path().map_err(|_| RunnerError::InvalidUrl)?;
169 let path_str = path.to_str().ok_or(RunnerError::InvalidUrl)?;
170 let content = tokio::fs::read_to_string(&path)
171 .await
172 .map_err(|_| RunnerError::ReadError)?;
173 let build_output = self.build(path_str).await?;
174 let diagnostics = build_output_to_diagnostics(&build_output, &path, &content, &[]);
175 Ok(diagnostics)
176 }
177}
178
179fn parse_json_from_mixed_stdout(stdout: &str) -> Result<serde_json::Value, RunnerError> {
181 let trimmed = stdout.trim();
182 if trimmed.is_empty() {
183 return Err(RunnerError::EmptyOutput);
184 }
185
186 if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
187 return Ok(v);
188 }
189
190 for (idx, ch) in trimmed.char_indices() {
193 if ch != '{' {
194 continue;
195 }
196 if let Ok(v) = serde_json::from_str::<serde_json::Value>(&trimmed[idx..]) {
197 return Ok(v);
198 }
199 }
200
201 Err(RunnerError::JsonError(serde_json::Error::io(io::Error::other(
202 "failed to parse forge JSON from mixed stdout",
203 ))))
204}
205
206#[cfg(test)]
207mod tests {
208 use super::parse_json_from_mixed_stdout;
209
210 #[test]
211 fn parse_json_from_mixed_stdout_accepts_plain_json() {
212 let out = r#"{ "errors": [], "sources": {}, "contracts": {}, "build_infos": [] }"#;
213 let parsed = parse_json_from_mixed_stdout(out).expect("valid JSON");
214 assert!(parsed.get("errors").is_some());
215 }
216
217 #[test]
218 fn parse_json_from_mixed_stdout_skips_leading_logs() {
219 let out = r#"WARN cache write failed
220{ "errors": [], "sources": {}, "contracts": {}, "build_infos": [] }"#;
221 let parsed = parse_json_from_mixed_stdout(out).expect("mixed output should parse");
222 assert!(parsed.get("sources").is_some());
223 }
224}
225
226#[derive(Error, Debug)]
227pub enum RunnerError {
228 #[error("Invalid file URL")]
229 InvalidUrl,
230 #[error("Failed to run command: {0}")]
231 CommandError(#[from] io::Error),
232 #[error("JSON error: {0}")]
233 JsonError(#[from] serde_json::Error),
234 #[error("Empty output from compiler")]
235 EmptyOutput,
236 #[error("ReadError")]
237 ReadError,
238}
239
240#[derive(Debug, Deserialize, Serialize)]
241pub struct SourceLocation {
242 file: String,
243 start: i32, end: i32, }
246
247#[derive(Debug, Deserialize, Serialize)]
248pub struct ForgeDiagnosticMessage {
249 #[serde(rename = "sourceLocation")]
250 source_location: SourceLocation,
251 #[serde(rename = "type")]
252 error_type: String,
253 component: String,
254 severity: String,
255 #[serde(rename = "errorCode")]
256 error_code: String,
257 message: String,
258 #[serde(rename = "formattedMessage")]
259 formatted_message: String,
260}
261
262#[derive(Debug, Deserialize, Serialize)]
263pub struct CompileOutput {
264 errors: Option<Vec<ForgeDiagnosticMessage>>,
265 sources: serde_json::Value,
266 contracts: serde_json::Value,
267 build_infos: Vec<serde_json::Value>,
268}