Skip to main content

snapper_fmt/
output.rs

1use std::path::{Path, PathBuf};
2
3use serde_json::{Value, json};
4
5use crate::check::{DiagnosticKind, LineDiagnostic};
6
7/// SARIF artifact URI: repo-relative when `path` is under `cwd`, else `file:`.
8pub fn sarif_artifact_uri(path: &str, cwd: Option<&Path>) -> String {
9    if path == "<stdin>" || path == "stdin" {
10        return "stdin".to_string();
11    }
12    let given = Path::new(path);
13    if let Some(cwd) = cwd {
14        if let Ok(rel) = given.strip_prefix(cwd) {
15            return rel.to_string_lossy().replace('\\', "/");
16        }
17        if given.is_relative() {
18            return given.to_string_lossy().replace('\\', "/");
19        }
20        if let (Ok(abs), Ok(cwd_abs)) = (given.canonicalize(), cwd.canonicalize()) {
21            if let Ok(rel) = abs.strip_prefix(cwd_abs) {
22                return rel.to_string_lossy().replace('\\', "/");
23            }
24        }
25    }
26    if given.is_relative() {
27        return given.to_string_lossy().replace('\\', "/");
28    }
29    let abs = given.canonicalize().unwrap_or_else(|_| PathBuf::from(path));
30    format!("file://{}", abs.display())
31}
32
33fn file_uri(path: &Path) -> String {
34    let abs = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
35    format!("file://{}", abs.display())
36}
37
38/// Summary of a file inspected by `--check`, including line diagnostics.
39pub struct CheckResult {
40    pub file: String,
41    pub original_lines: usize,
42    pub formatted_lines: usize,
43    pub would_reformat: bool,
44    pub diagnostics: Vec<LineDiagnostic>,
45}
46
47/// Output check results in JSON format.
48pub fn output_json(results: &[CheckResult]) {
49    let arr: Vec<Value> = results
50        .iter()
51        .map(|r| {
52            json!({
53                "file": r.file,
54                "original_lines": r.original_lines,
55                "formatted_lines": r.formatted_lines,
56                "would_reformat": r.would_reformat,
57                "diagnostics": r.diagnostics,
58            })
59        })
60        .collect();
61    println!("{}", serde_json::to_string_pretty(&arr).unwrap_or_default());
62}
63
64/// Output check results in SARIF v2.1.0 format for GitHub Code Scanning.
65pub fn output_sarif(results: &[CheckResult]) {
66    let cwd = std::env::current_dir().ok();
67    let cwd_ref = cwd.as_deref();
68    let mut sarif_results: Vec<Value> = Vec::new();
69    for r in results {
70        let uri = sarif_artifact_uri(&r.file, cwd_ref);
71        if r.would_reformat {
72            sarif_results.push(json!({
73                "ruleId": "snapper/needs-reformat",
74                "level": "warning",
75                "message": {
76                    "text": format!(
77                        "File needs semantic line break formatting ({} -> {} lines)",
78                        r.original_lines, r.formatted_lines
79                    )
80                },
81                "locations": [{
82                    "physicalLocation": {
83                        "artifactLocation": {
84                            "uri": uri.clone()
85                        }
86                    }
87                }]
88            }));
89        }
90        for d in &r.diagnostics {
91            let (rule_id, level) = match d.kind {
92                DiagnosticKind::Fused => ("snapper/fused", "warning"),
93                DiagnosticKind::Wrap => ("snapper/wrap", "warning"),
94                DiagnosticKind::Long => ("snapper/long", "note"),
95            };
96            sarif_results.push(json!({
97                "ruleId": rule_id,
98                "level": level,
99                "message": {
100                    "text": format!("{}: {}", d.kind.as_str(), d.excerpt)
101                },
102                "locations": [{
103                    "physicalLocation": {
104                        "artifactLocation": {
105                            "uri": uri.clone()
106                        },
107                        "region": {
108                            "startLine": d.line,
109                            "snippet": {
110                                "text": d.excerpt
111                            }
112                        }
113                    }
114                }]
115            }));
116        }
117    }
118
119    let working_directory = cwd.as_ref().map(|p| json!({ "uri": file_uri(p) }));
120
121    let sarif = json!({
122        "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
123        "version": "2.1.0",
124        "runs": [{
125            "tool": {
126                "driver": {
127                    "name": "snapper",
128                    "informationUri": "https://snapper.turtletech.us",
129                    "rules": [
130                        {
131                            "id": "snapper/needs-reformat",
132                            "shortDescription": {
133                                "text": "File needs semantic line break formatting"
134                            }
135                        },
136                        {
137                            "id": "snapper/fused",
138                            "shortDescription": {
139                                "text": "Prose line contains more than one sentence"
140                            }
141                        },
142                        {
143                            "id": "snapper/wrap",
144                            "shortDescription": {
145                                "text": "Prose line continues a clause from the previous line"
146                            }
147                        },
148                        {
149                            "id": "snapper/long",
150                            "shortDescription": {
151                                "text": "Prose line exceeds the width threshold at a clause boundary"
152                            }
153                        }
154                    ]
155                }
156            },
157            "invocations": [{
158                "executionSuccessful": true,
159                "workingDirectory": working_directory
160            }],
161            "results": sarif_results
162        }]
163    });
164
165    println!(
166        "{}",
167        serde_json::to_string_pretty(&sarif).unwrap_or_default()
168    );
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use std::path::Path;
175
176    #[test]
177    fn relative_path_stays_relative() {
178        assert_eq!(
179            sarif_artifact_uri("fused.txt", Some(Path::new("/tmp/work"))),
180            "fused.txt"
181        );
182    }
183
184    #[test]
185    fn stdin_uri_is_literal() {
186        assert_eq!(sarif_artifact_uri("<stdin>", None), "stdin");
187    }
188}