1use std::path::{Path, PathBuf};
2
3use serde_json::{Value, json};
4
5use crate::check::{DiagnosticKind, LineDiagnostic};
6
7pub 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
38pub 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
47pub 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
64pub 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}