Skip to main content

noxid_source/
lib.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3
4#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
5pub struct SourceId(pub u32);
6
7#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
8pub struct Span {
9    pub start: u32,
10    pub end: u32,
11}
12
13impl Span {
14    pub const fn new(start: usize, end: usize) -> Self {
15        Self {
16            start: start as u32,
17            end: end as u32,
18        }
19    }
20
21    pub const fn empty(at: usize) -> Self {
22        Self::new(at, at)
23    }
24
25    pub fn join(self, other: Self) -> Self {
26        Self {
27            start: self.start.min(other.start),
28            end: self.end.max(other.end),
29        }
30    }
31}
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub struct Position {
35    pub line: u32,
36    pub column: u32,
37    pub offset: u32,
38}
39
40#[derive(Clone, Debug)]
41pub struct SourceFile {
42    pub id: SourceId,
43    pub path: PathBuf,
44    text: String,
45    line_starts: Vec<u32>,
46}
47
48impl SourceFile {
49    pub fn new(id: SourceId, path: impl Into<PathBuf>, text: impl Into<String>) -> Self {
50        let text = text.into();
51        let mut line_starts = vec![0];
52        for (index, byte) in text.bytes().enumerate() {
53            if byte == b'\n' {
54                line_starts.push((index + 1) as u32);
55            }
56        }
57        Self {
58            id,
59            path: path.into(),
60            text,
61            line_starts,
62        }
63    }
64
65    pub fn text(&self) -> &str {
66        &self.text
67    }
68
69    pub fn path(&self) -> &Path {
70        &self.path
71    }
72
73    pub fn slice(&self, span: Span) -> &str {
74        self.text
75            .get(span.start as usize..span.end as usize)
76            .unwrap_or("")
77    }
78
79    pub fn position(&self, offset: u32) -> Position {
80        let line = self
81            .line_starts
82            .partition_point(|start| *start <= offset)
83            .saturating_sub(1);
84        Position {
85            line: line as u32 + 1,
86            column: offset - self.line_starts[line] + 1,
87            offset,
88        }
89    }
90}
91
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub enum Severity {
94    Error,
95    Warning,
96    Info,
97}
98
99impl Severity {
100    pub fn as_str(self) -> &'static str {
101        match self {
102            Self::Error => "error",
103            Self::Warning => "warning",
104            Self::Info => "info",
105        }
106    }
107}
108
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub struct Label {
111    pub span: Span,
112    pub message: String,
113}
114
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct DiagnosticField {
117    pub key: String,
118    pub value: String,
119}
120
121#[derive(Clone, Debug, PartialEq, Eq)]
122pub struct Diagnostic {
123    pub code: &'static str,
124    pub severity: Severity,
125    pub message: String,
126    pub span: Span,
127    // The file this diagnostic belongs to, for multi-file compilations.
128    pub path: Option<String>,
129    pub symbol: Option<String>,
130    pub labels: Vec<Label>,
131    pub notes: Vec<String>,
132    pub fields: Vec<DiagnosticField>,
133}
134
135impl Diagnostic {
136    pub fn error(code: &'static str, message: impl Into<String>, span: Span) -> Self {
137        Self {
138            code,
139            severity: Severity::Error,
140            message: message.into(),
141            span,
142            path: None,
143            symbol: None,
144            labels: vec![],
145            notes: vec![],
146            fields: vec![],
147        }
148    }
149
150    pub fn warning(code: &'static str, message: impl Into<String>, span: Span) -> Self {
151        Self {
152            code,
153            severity: Severity::Warning,
154            message: message.into(),
155            span,
156            path: None,
157            symbol: None,
158            labels: vec![],
159            notes: vec![],
160            fields: vec![],
161        }
162    }
163
164    pub fn with_symbol(mut self, symbol: impl Into<String>) -> Self {
165        self.symbol = Some(symbol.into());
166        self
167    }
168
169    pub fn with_path(mut self, path: impl Into<String>) -> Self {
170        self.path = Some(path.into());
171        self
172    }
173
174    pub fn with_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
175        self.fields.push(DiagnosticField {
176            key: key.into(),
177            value: value.into(),
178        });
179        self
180    }
181
182    pub fn render(&self, source: &SourceFile) -> String {
183        let pos = source.position(self.span.start);
184        format!(
185            "{}:{}:{}: {}[{}]: {}",
186            source.path.display(),
187            pos.line,
188            pos.column,
189            self.severity.as_str(),
190            self.code,
191            self.message
192        )
193    }
194
195    pub fn to_json(&self) -> String {
196        let symbol = self
197            .symbol
198            .as_ref()
199            .map(|s| format!("\"{}\"", json_escape(s)))
200            .unwrap_or_else(|| "null".into());
201        let fields = self
202            .fields
203            .iter()
204            .map(|f| format!("\"{}\":\"{}\"", json_escape(&f.key), json_escape(&f.value)))
205            .collect::<Vec<_>>()
206            .join(",");
207        let fixes = self.fixes_json();
208        let path = self
209            .path
210            .as_ref()
211            .map(|path| format!("\"{}\"", json_escape(path)))
212            .unwrap_or_else(|| "null".into());
213        format!(
214            "{{\"code\":\"{}\",\"severity\":\"{}\",\"message\":\"{}\",\"span\":{{\"start\":{},\"end\":{}}},\"path\":{},\"symbol\":{},\"fields\":{{{}}},\"fixes\":{fixes}}}",
215            self.code,
216            self.severity.as_str(),
217            json_escape(&self.message),
218            self.span.start,
219            self.span.end,
220            path,
221            symbol,
222            fields
223        )
224    }
225
226    fn fixes_json(&self) -> String {
227        let operation = if self.code.contains("TRANSITION") {
228            "inspect_machine"
229        } else if self.code.contains("EXHAUSTIVE") || self.code.contains("MISSING_CASE") {
230            "add_missing_cases"
231        } else if self.code.starts_with("UNKNOWN_") || self.code.contains("UNRESOLVED") {
232            "find_symbol"
233        } else if self.code.contains("TYPE") {
234            "inspect_expected_type"
235        } else if self.code.starts_with("A11Y_") {
236            "apply_accessibility_semantics"
237        } else {
238            return "[]".into();
239        };
240        let symbol = self
241            .symbol
242            .as_ref()
243            .map(|value| format!(",\"symbol\":\"{}\"", json_escape(value)))
244            .unwrap_or_default();
245        let arguments = self
246            .fields
247            .iter()
248            .map(|field| {
249                format!(
250                    "\"{}\":\"{}\"",
251                    json_escape(&field.key),
252                    json_escape(&field.value)
253                )
254            })
255            .collect::<Vec<_>>()
256            .join(",");
257        format!(
258            "[{{\"kind\":\"semantic-operation\",\"operation\":\"{operation}\",\"applicability\":\"compiler-validated\",\"targetSpan\":{{\"start\":{},\"end\":{}}}{symbol},\"arguments\":{{{arguments}}}}}]",
259            self.span.start, self.span.end
260        )
261    }
262}
263
264pub fn json_escape(value: &str) -> String {
265    let mut out = String::with_capacity(value.len());
266    for ch in value.chars() {
267        match ch {
268            '"' => out.push_str("\\\""),
269            '\\' => out.push_str("\\\\"),
270            '\n' => out.push_str("\\n"),
271            '\r' => out.push_str("\\r"),
272            '\t' => out.push_str("\\t"),
273            c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
274            c => out.push(c),
275        }
276    }
277    out
278}
279
280/// Escape a string for a JavaScript string literal in generated code.
281///
282/// The union of what the client, SSR, and server emitters each used to escape
283/// on their own: `json_escape`'s quotes, backslash, and control characters,
284/// plus `<`, `>`, and `&` so user data containing `</script>` can never close
285/// the surrounding tag or open an HTML entity when a module is inlined into a
286/// document, plus U+2028 and U+2029, which JSON permits raw but JavaScript
287/// treats as line terminators. One escape, one threat model, three emitters.
288pub fn js_escape(value: &str) -> String {
289    let mut out = String::with_capacity(value.len());
290    for ch in json_escape(value).chars() {
291        match ch {
292            '<' => out.push_str("\\u003c"),
293            '>' => out.push_str("\\u003e"),
294            '&' => out.push_str("\\u0026"),
295            '\u{2028}' => out.push_str("\\u2028"),
296            '\u{2029}' => out.push_str("\\u2029"),
297            other => out.push(other),
298        }
299    }
300    out
301}
302
303/// Escape a string for HTML text or a double-quoted attribute value. `&` is
304/// handled first by construction, so an escaped `&` is never re-escaped.
305pub fn html_escape(value: &str) -> String {
306    let mut out = String::with_capacity(value.len());
307    for ch in value.chars() {
308        match ch {
309            '&' => out.push_str("&amp;"),
310            '<' => out.push_str("&lt;"),
311            '>' => out.push_str("&gt;"),
312            '"' => out.push_str("&quot;"),
313            other => out.push(other),
314        }
315    }
316    out
317}
318
319impl fmt::Display for Span {
320    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
321        write!(f, "{}..{}", self.start, self.end)
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    /// One escape, one threat model: the union of what the client, SSR, and
330    /// server emitters used to cover separately. Dropping any arm here lets a
331    /// generated module either break out of an inline `<script>` or acquire a
332    /// line terminator JavaScript honours and JSON does not.
333    #[test]
334    fn js_escape_covers_the_union_of_every_emitter_threat_model() {
335        assert_eq!(js_escape("say \"hi\""), "say \\\"hi\\\"");
336        assert_eq!(js_escape("back\\slash"), "back\\\\slash");
337        assert_eq!(js_escape("line\nfeed\ttab\r"), "line\\nfeed\\ttab\\r");
338        assert_eq!(js_escape("bell\u{7}"), "bell\\u0007");
339        assert_eq!(js_escape("</script>"), "\\u003c/script\\u003e");
340        assert_eq!(js_escape("a & b"), "a \\u0026 b");
341        assert_eq!(
342            js_escape("split\u{2028}here\u{2029}too"),
343            "split\\u2028here\\u2029too"
344        );
345        assert_eq!(js_escape("plain"), "plain");
346    }
347
348    #[test]
349    fn html_escape_covers_text_and_double_quoted_attribute_positions() {
350        assert_eq!(
351            html_escape("<img src=\"x\" onerror=y & z>"),
352            "&lt;img src=&quot;x&quot; onerror=y &amp; z&gt;"
353        );
354        // `&` is escaped first by construction, so an escape is never doubled.
355        assert_eq!(html_escape("&lt;"), "&amp;lt;");
356        assert_eq!(html_escape("plain"), "plain");
357    }
358
359    #[test]
360    fn maps_utf8_byte_offsets_to_lines_and_columns() {
361        let source = SourceFile::new(SourceId(0), "test.nox", "one\nthree");
362        assert_eq!(
363            source.position(4),
364            Position {
365                line: 2,
366                column: 1,
367                offset: 4
368            }
369        );
370    }
371
372    #[test]
373    fn structured_diagnostics_include_machine_actionable_fixes() {
374        let diagnostic = Diagnostic::error("TYPE_MISMATCH", "expected Int", Span::new(2, 7))
375            .with_symbol("state:Counter.count")
376            .with_field("expected", "Int")
377            .with_field("found", "String");
378        let json = diagnostic.to_json();
379        assert!(json.contains("\"operation\":\"inspect_expected_type\""));
380        assert!(json.contains("\"applicability\":\"compiler-validated\""));
381        assert!(json.contains("\"expected\":\"Int\""));
382    }
383
384    #[test]
385    fn warning_diagnostics_are_structured_without_becoming_errors() {
386        let diagnostic = Diagnostic::warning(
387            "SCENARIO_PROSE_UNEXECUTED",
388            "prose scenario steps are documentation, not executable proof",
389            Span::new(4, 12),
390        )
391        .with_symbol("scenario:Counter.Legacy");
392
393        assert_eq!(diagnostic.severity, Severity::Warning);
394        assert!(diagnostic.to_json().contains("\"severity\":\"warning\""));
395    }
396}