Skip to main content

forge_guard/ci/
vscode.rs

1//! VS Code extension config generator — `forge-guard ci --vscode`.
2//!
3//! Generates `.vscode/settings.json`, `.vscode/tasks.json`, and
4//! `.vscode/extensions.json` for Solidity developers:
5//!
6//! * **settings.json** — Solidity validation rules (compiler version pin,
7//!   remappings for the language server, solhint linter) plus audit-on-save
8//!   integration for `.sol` files via the Run on Save extension.
9//! * **tasks.json** — a `forge-guard: audit` build task whose output is parsed
10//!   into the VS Code Problems panel by problem matchers that map
11//!   forge-guard's severity levels (CRITICAL/HIGH → error, MEDIUM → warning,
12//!   LOW/INFO → info).
13//! * **extensions.json** — recommended extensions for the above.
14
15use serde_json::{json, Value};
16
17/// Generate `.vscode/settings.json`.
18///
19/// `solc_version` and `remappings` come from the project's forge-guard.toml
20/// (synced from foundry.toml via `doctor --sync`) when available.
21pub fn generate_settings(solc_version: Option<&str>, remappings: &[String]) -> String {
22    let mut settings = serde_json::Map::new();
23
24    // ── Custom Solidity validation rules (juanblanco.solidity) ──
25    settings.insert("solidity.validation.disable".into(), json!(false));
26    if let Some(version) = solc_version.filter(|v| !v.is_empty()) {
27        settings.insert(
28            "solidity.validation.requiredCompilerVersion".into(),
29            json!(version),
30        );
31    }
32    settings.insert("solidity.linter".into(), json!("solhint"));
33    if !remappings.is_empty() {
34        let remaps: Vec<Value> = remappings.iter().map(|r| json!(r)).collect();
35        settings.insert("solidity.remappings".into(), Value::Array(remaps));
36    }
37
38    // ── Audit-on-save for Solidity files (emeraldwalk.runonsave) ──
39    settings.insert(
40        "emeraldwalk.runonsave".into(),
41        json!({
42            "commands": [
43                {
44                    "match": "\\.sol$",
45                    "isAsync": true,
46                    "cmd": "forge-guard audit --offline"
47                }
48            ]
49        }),
50    );
51
52    serde_json::to_string_pretty(&Value::Object(settings)).expect("settings serialize") + "\n"
53}
54
55/// Generate `.vscode/tasks.json` with the audit task and problem matchers.
56pub fn generate_tasks() -> String {
57    // forge-guard's terminal finding format:
58    //
59    //   [CRITICAL] Reentrancy vulnerability
60    //        📄 src/MyContract.sol:42
61    //
62    // Each matcher targets one severity class; the 📄 line (shared by all)
63    // carries the file/line. VS Code walks the pattern list per problem, so a
64    // finding matches exactly one severity matcher and then the file line.
65    let file_line_pattern = json!({
66        "regexp": "^\\s*📄 (.+):(\\d+)$",
67        "file": 1,
68        "line": 2
69    });
70
71    let matcher = |severity: &str, severity_regex: &str| {
72        json!({
73            "owner": "forge-guard",
74            "severity": severity,
75            "fileLocation": ["relative", "${workspaceFolder}"],
76            "pattern": [
77                {
78                    "regexp": severity_regex,
79                    "message": 2
80                },
81                file_line_pattern.clone()
82            ]
83        })
84    };
85
86    let tasks = json!({
87        "version": "2.0.0",
88        "tasks": [
89            {
90                "label": "forge-guard: audit",
91                "type": "shell",
92                "command": "forge-guard",
93                "args": ["audit", "--offline"],
94                "group": { "kind": "build", "isDefault": true },
95                "presentation": {
96                    "reveal": "always",
97                    "panel": "dedicated",
98                    "clear": true
99                },
100                "problemMatcher": [
101                    matcher("error", "^\\s*\\[(CRITICAL|HIGH)\\] (.+)$"),
102                    matcher("warning", "^\\s*\\[(MEDIUM)\\] (.+)$"),
103                    matcher("info", "^\\s*\\[(LOW|INFO)\\] (.+)$")
104                ]
105            }
106        ]
107    });
108
109    serde_json::to_string_pretty(&tasks).expect("tasks serialize") + "\n"
110}
111
112/// Generate `.vscode/extensions.json` with recommended extensions.
113pub fn generate_extensions() -> String {
114    let extensions = json!({
115        "recommendations": [
116            "juanblanco.solidity",
117            "emeraldwalk.runonsave",
118            "tamasfe.even-better-toml"
119        ]
120    });
121    serde_json::to_string_pretty(&extensions).expect("extensions serialize") + "\n"
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_settings_contains_validation_rules() {
130        let settings = generate_settings(
131            Some("0.8.23"),
132            &["@openzeppelin/=lib/openzeppelin-contracts/".into()],
133        );
134        let parsed: Value = serde_json::from_str(&settings).unwrap();
135        assert_eq!(parsed["solidity.validation.disable"], json!(false));
136        assert_eq!(
137            parsed["solidity.validation.requiredCompilerVersion"],
138            json!("0.8.23")
139        );
140        assert_eq!(parsed["solidity.linter"], json!("solhint"));
141        assert_eq!(
142            parsed["solidity.remappings"][0],
143            json!("@openzeppelin/=lib/openzeppelin-contracts/")
144        );
145    }
146
147    #[test]
148    fn test_settings_omits_unknown_compiler_version() {
149        let settings = generate_settings(None, &[]);
150        let parsed: Value = serde_json::from_str(&settings).unwrap();
151        assert!(parsed
152            .get("solidity.validation.requiredCompilerVersion")
153            .is_none());
154        assert!(parsed.get("solidity.remappings").is_none());
155    }
156
157    #[test]
158    fn test_settings_has_audit_on_save() {
159        let settings = generate_settings(None, &[]);
160        let parsed: Value = serde_json::from_str(&settings).unwrap();
161        let run_on_save = &parsed["emeraldwalk.runonsave"];
162        assert_eq!(run_on_save["commands"][0]["match"], json!("\\.sol$"));
163        assert_eq!(
164            run_on_save["commands"][0]["cmd"],
165            json!("forge-guard audit --offline")
166        );
167    }
168
169    #[test]
170    fn test_tasks_has_audit_task_with_matchers() {
171        let tasks = generate_tasks();
172        let parsed: Value = serde_json::from_str(&tasks).unwrap();
173        let task = &parsed["tasks"][0];
174        assert_eq!(task["label"], json!("forge-guard: audit"));
175        assert_eq!(task["command"], json!("forge-guard"));
176        assert_eq!(task["group"]["isDefault"], json!(true));
177
178        let matchers = task["problemMatcher"].as_array().unwrap();
179        assert_eq!(matchers.len(), 3);
180        assert_eq!(matchers[0]["severity"], json!("error"));
181        assert_eq!(matchers[1]["severity"], json!("warning"));
182        assert_eq!(matchers[2]["severity"], json!("info"));
183        // error matcher covers CRITICAL and HIGH
184        let re = matchers[0]["pattern"][0]["regexp"].as_str().unwrap();
185        assert!(re.contains("CRITICAL") && re.contains("HIGH"));
186        // every matcher parses the 📄 file:line line
187        for m in matchers {
188            let file_pattern = &m["pattern"][1];
189            assert_eq!(file_pattern["file"], json!(1));
190            assert_eq!(file_pattern["line"], json!(2));
191        }
192    }
193
194    #[test]
195    fn test_extensions_recommendations() {
196        let extensions = generate_extensions();
197        let parsed: Value = serde_json::from_str(&extensions).unwrap();
198        let recs = parsed["recommendations"].as_array().unwrap();
199        assert!(recs.iter().any(|r| r == &json!("juanblanco.solidity")));
200        assert!(recs.iter().any(|r| r == &json!("emeraldwalk.runonsave")));
201    }
202}