Skip to main content

jlens_core/
annotation.rs

1//! Audit annotations written as JSONata doc-comments.
2//!
3//! ```jsonata
4//! /** @label "Authorized customer" @message "Login must be on the approved list" */
5//! customer.login in $approved
6//! ```
7//!
8//! A doc-comment starts with `/**` (but is not the empty comment `/**/`). Free text before the
9//! first tag becomes the description. Tags are `@label`, `@message` and `@id`, each followed by
10//! a JSON string literal. Other implementations see an ordinary comment, so annotated
11//! expressions stay portable. See ADR 0005.
12
13use serde::Serialize;
14
15use crate::ast::Span;
16use crate::error::Diagnostic;
17
18/// Business metadata attached to a sub-expression through a doc-comment.
19#[derive(Debug, Clone, Default, PartialEq, Serialize)]
20pub struct Annotation {
21    /// Short name shown in audit trees and explanations (`@label`).
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub label: Option<String>,
24    /// Longer explanation of what the expression checks (`@message`).
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub message: Option<String>,
27    /// Stable identifier for referring to this check from other systems (`@id`).
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub id: Option<String>,
30    /// Free text written before the first tag.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub description: Option<String>,
33    /// Byte span of the comment(s) this annotation was read from.
34    pub span: Span,
35}
36
37impl Annotation {
38    /// Merges a later annotation into this one; fields set in `later` win.
39    pub(crate) fn merge(&mut self, later: Annotation) {
40        if later.label.is_some() {
41            self.label = later.label;
42        }
43        if later.message.is_some() {
44            self.message = later.message;
45        }
46        if later.id.is_some() {
47            self.id = later.id;
48        }
49        if later.description.is_some() {
50            self.description = later.description;
51        }
52        self.span = self.span.to(later.span);
53    }
54}
55
56/// A doc-comment as found by the lexer: the full span and the text between `/**` and `*/`.
57#[derive(Debug, Clone, PartialEq)]
58pub(crate) struct DocComment {
59    pub span: Span,
60    pub body_start: usize,
61    pub body: String,
62}
63
64/// Parses a doc-comment into an annotation, reporting malformed tags as warnings.
65pub(crate) fn parse_doc(doc: &DocComment, warnings: &mut Vec<Diagnostic>) -> Annotation {
66    let mut annotation = Annotation {
67        span: doc.span,
68        ..Annotation::default()
69    };
70    let text = doc.body.as_str();
71    let mut description = String::new();
72    let mut i = 0;
73    let bytes = text.as_bytes();
74
75    // Free text before the first tag.
76    while i < bytes.len() {
77        if bytes[i] == b'@'
78            && (i == 0 || bytes[i - 1].is_ascii_whitespace() || bytes[i - 1] == b'*')
79        {
80            break;
81        }
82        i += 1;
83    }
84    for line in text[..i].lines() {
85        let line = line.trim().trim_start_matches('*').trim();
86        if !line.is_empty() {
87            if !description.is_empty() {
88                description.push(' ');
89            }
90            description.push_str(line);
91        }
92    }
93    if !description.is_empty() {
94        annotation.description = Some(description);
95    }
96
97    // Tags.
98    while i < bytes.len() {
99        if bytes[i] != b'@' {
100            i += 1;
101            continue;
102        }
103        let tag_start = i;
104        i += 1;
105        let name_start = i;
106        while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
107            i += 1;
108        }
109        let tag = &text[name_start..i];
110        let tag_span = Span::new(doc.body_start + tag_start, doc.body_start + i);
111        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
112            i += 1;
113        }
114        let value = if i < bytes.len() && bytes[i] == b'"' {
115            match json_string_end(&bytes[i..]) {
116                Some(len) => {
117                    let literal = &text[i..i + len];
118                    i += len;
119                    serde_json::from_str::<String>(literal).ok()
120                }
121                None => None,
122            }
123        } else {
124            None
125        };
126        let value = match value {
127            Some(v) => v,
128            None => {
129                // Fall back to the bare text up to the end of the line or the next tag.
130                let start = i;
131                while i < bytes.len() && bytes[i] != b'\n' && bytes[i] != b'@' {
132                    i += 1;
133                }
134                let bare = text[start..i]
135                    .trim()
136                    .trim_end_matches('*')
137                    .trim()
138                    .to_owned();
139                if matches!(tag, "label" | "message" | "id") {
140                    warnings.push(Diagnostic::warning("W0103", tag_span, &[("tag", tag)]));
141                }
142                bare
143            }
144        };
145        match tag {
146            "label" => annotation.label = Some(value),
147            "message" => annotation.message = Some(value),
148            "id" => annotation.id = Some(value),
149            _ => warnings.push(Diagnostic::warning("W0102", tag_span, &[("tag", tag)])),
150        }
151    }
152    annotation
153}
154
155/// Length of the JSON string literal at the start of `bytes` (including both quotes).
156fn json_string_end(bytes: &[u8]) -> Option<usize> {
157    let mut i = 1;
158    while i < bytes.len() {
159        match bytes[i] {
160            b'\\' => i += 2,
161            b'"' => return Some(i + 1),
162            b'\n' => return None,
163            _ => i += 1,
164        }
165    }
166    None
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    fn parse(body: &str) -> (Annotation, Vec<Diagnostic>) {
174        let doc = DocComment {
175            span: Span::new(0, body.len() + 5),
176            body_start: 3,
177            body: body.to_owned(),
178        };
179        let mut warnings = Vec::new();
180        let a = parse_doc(&doc, &mut warnings);
181        (a, warnings)
182    }
183
184    #[test]
185    fn reads_tags_and_description() {
186        let (a, w) = parse(
187            "\n * Checks the customer.\n * @label \"Authorized customer\"\n * @message \"Login \\\"must\\\" match\" @id \"R-1\"\n ",
188        );
189        assert!(w.is_empty(), "{w:?}");
190        assert_eq!(a.description.as_deref(), Some("Checks the customer."));
191        assert_eq!(a.label.as_deref(), Some("Authorized customer"));
192        assert_eq!(a.message.as_deref(), Some("Login \"must\" match"));
193        assert_eq!(a.id.as_deref(), Some("R-1"));
194    }
195
196    #[test]
197    fn warns_on_unknown_tags_and_bare_values() {
198        let (a, w) = parse(" @label Total above limit @severity \"high\" ");
199        assert_eq!(a.label.as_deref(), Some("Total above limit"));
200        let codes: Vec<_> = w.iter().map(|d| d.code.as_str()).collect();
201        assert_eq!(codes, ["W0103", "W0102"]);
202    }
203}