Skip to main content

edikt_core/
comment.rs

1//! The uniform comment model for comment-preserving conversion.
2//!
3//! Comments cross formats through a shared vocabulary of three kinds - **head**
4//! (own-line comments before a node), **inline** (a trailing comment on the
5//! node's line), and **foot** (own-line comments after a node that no following
6//! sibling claims - in practice, trailing comments at the end of a container or
7//! document). Each format parses its comments *out* to this model
8//! ([`crate::Document::to_commented`]), and each format's emitter decides per
9//! kind: place it, remap it to a kind it supports (warn), or drop it (warn) -
10//! N-in + N-out against one model, not N×N per format pair.
11//!
12//! Comment text is stored without delimiters (`# `, `// `, `; `) and trimmed,
13//! so the target format re-delimits it natively. A multi-line block comment
14//! contributes one `head`/`foot` entry per line.
15
16use crate::{Step, Value};
17
18/// One of the three comment kinds in the uniform model. Which kinds a format
19/// supports is its comment capability - each format declares a
20/// `COMMENT_KINDS: &[CommentKind]` (empty => no comments, subsuming the boolean
21/// `Feature::Comments`). The `#` accessor addresses a node's comment by kind.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum CommentKind {
24    /// Own-line comment(s) before the node - what `#` alone selects.
25    Head,
26    /// A trailing comment on the node's line.
27    Inline,
28    /// Own-line comment(s) after the node.
29    Foot,
30}
31
32impl CommentKind {
33    pub fn as_str(self) -> &'static str {
34        match self {
35            CommentKind::Head => "head",
36            CommentKind::Inline => "inline",
37            CommentKind::Foot => "foot",
38        }
39    }
40}
41
42/// The comments attached to one node, by kind.
43#[derive(Debug, Clone, Default, PartialEq)]
44pub struct Comments {
45    /// Own-line comments immediately before the node, one entry per line.
46    pub head: Vec<String>,
47    /// A trailing comment on the node's own line.
48    pub inline: Option<String>,
49    /// Own-line comments after the node that no following sibling claims.
50    pub foot: Vec<String>,
51}
52
53impl Comments {
54    pub fn is_empty(&self) -> bool {
55        self.head.is_empty() && self.inline.is_none() && self.foot.is_empty()
56    }
57
58    /// The text of one kind as a single string, or `None` if that kind is
59    /// absent - head/foot lines join with a space (they read back unwrapped,
60    /// per the wrapping design; the emitter re-wraps on write).
61    pub fn get(&self, kind: CommentKind) -> Option<String> {
62        match kind {
63            CommentKind::Head if !self.head.is_empty() => Some(self.head.join(" ")),
64            CommentKind::Foot if !self.foot.is_empty() => Some(self.foot.join(" ")),
65            CommentKind::Inline => self.inline.clone(),
66            _ => None,
67        }
68    }
69}
70
71/// A [`Value`] enriched with per-node comments - what conversion carries so a
72/// commented source survives `-T` into a commented target.
73#[derive(Debug, Clone, PartialEq)]
74pub struct Commented {
75    pub comments: Comments,
76    pub node: CommentedNode,
77}
78
79/// The shape of a [`Commented`] node, mirroring [`Value`].
80#[derive(Debug, Clone, PartialEq)]
81pub enum CommentedNode {
82    /// A scalar (never `Value::Array`/`Value::Object` - those are the variants
83    /// below, so comments can attach to every element/entry).
84    Scalar(Value),
85    Array(Vec<Commented>),
86    Object(Vec<(String, Commented)>),
87}
88
89impl Commented {
90    /// Wrap a plain value with no comments anywhere.
91    pub fn from_value(value: &Value) -> Commented {
92        let node = match value {
93            Value::Array(items) => {
94                CommentedNode::Array(items.iter().map(Commented::from_value).collect())
95            }
96            Value::Object(entries) => CommentedNode::Object(
97                entries
98                    .iter()
99                    .map(|(k, v)| (k.clone(), Commented::from_value(v)))
100                    .collect(),
101            ),
102            scalar => CommentedNode::Scalar(scalar.clone()),
103        };
104        Commented {
105            comments: Comments::default(),
106            node,
107        }
108    }
109
110    /// A scalar node with no comments (convenience for extractors).
111    pub fn scalar(value: Value) -> Commented {
112        Commented {
113            comments: Comments::default(),
114            node: CommentedNode::Scalar(value),
115        }
116    }
117
118    /// Strip comments back to the plain value model.
119    pub fn to_value(&self) -> Value {
120        match &self.node {
121            CommentedNode::Scalar(v) => v.clone(),
122            CommentedNode::Array(items) => {
123                Value::Array(items.iter().map(Commented::to_value).collect())
124            }
125            CommentedNode::Object(entries) => Value::Object(
126                entries
127                    .iter()
128                    .map(|(k, v)| (k.clone(), v.to_value()))
129                    .collect(),
130            ),
131        }
132    }
133
134    /// Does this node or any descendant carry a comment?
135    pub fn has_comments(&self) -> bool {
136        if !self.comments.is_empty() {
137            return true;
138        }
139        match &self.node {
140            CommentedNode::Scalar(_) => false,
141            CommentedNode::Array(items) => items.iter().any(Commented::has_comments),
142            CommentedNode::Object(entries) => entries.iter().any(|(_, v)| v.has_comments()),
143        }
144    }
145
146    /// Attach trailing document comments as the foot of the deepest last entry
147    /// - the node they physically follow, so re-emission keeps them at the end.
148    pub fn attach_trailing_foot(&mut self, lines: Vec<String>) {
149        match &mut self.node {
150            CommentedNode::Object(entries) if !entries.is_empty() => {
151                entries.last_mut().unwrap().1.attach_trailing_foot(lines);
152            }
153            CommentedNode::Array(items) if !items.is_empty() => {
154                items.last_mut().unwrap().attach_trailing_foot(lines);
155            }
156            _ => self.comments.foot.extend(lines),
157        }
158    }
159
160    /// The nodes a pure path selects, in document order - mirroring the
161    /// evaluator's path semantics (a missing field/index yields nothing;
162    /// `[]` iterates elements/values), so the results align 1:1 with
163    /// [`crate::eval`] on the same path. A step that cannot apply (e.g. a field
164    /// of a scalar) yields nothing here; the evaluator errors first in that
165    /// case, so the mismatch is never observed.
166    pub fn descend(&self, path: &[Step]) -> Vec<&Commented> {
167        let mut stream = vec![self];
168        for step in path {
169            let mut next = Vec::new();
170            for node in stream {
171                match (step, &node.node) {
172                    (Step::Field(k), CommentedNode::Object(entries)) => {
173                        next.extend(entries.iter().find(|(kk, _)| kk == k).map(|(_, v)| v));
174                    }
175                    (Step::Index(i), CommentedNode::Array(items)) => {
176                        let idx = if *i < 0 { items.len() as i64 + i } else { *i };
177                        if idx >= 0 && (idx as usize) < items.len() {
178                            next.push(&items[idx as usize]);
179                        }
180                    }
181                    (Step::Iterate, CommentedNode::Array(items)) => next.extend(items.iter()),
182                    (Step::Iterate, CommentedNode::Object(entries)) => {
183                        next.extend(entries.iter().map(|(_, v)| v));
184                    }
185                    _ => {}
186                }
187            }
188            stream = next;
189        }
190        stream
191    }
192
193    /// Resolve a comment-addressing path (a value prefix ending in a terminal
194    /// [`Step::Comment`]) to the comment text of each selected node, as a
195    /// stream of `Value::Str`. A node without that comment kind contributes
196    /// nothing - a miss, matching the rest of the language. The `Comment` step
197    /// is terminal by construction (the parser forbids steps after it).
198    pub fn resolve_comment(&self, path: &[Step]) -> Vec<Value> {
199        let Some((Step::Comment(kind), prefix)) = path.split_last() else {
200            return Vec::new();
201        };
202        self.descend(prefix)
203            .into_iter()
204            .filter_map(|n| n.comments.get(*kind).map(Value::Str))
205            .collect()
206    }
207
208    /// Every comment in the tree as `(path steps, kind, text)`, in document
209    /// order - the backbone of the document-wide `comments` stream (query) and
210    /// bulk comment edits (`comments |= ...`). The steps address the *node*; the
211    /// kind and text are the comment. Paths stay valid across edits (they are
212    /// logical, not byte offsets), so a caller may snapshot then apply.
213    pub fn comment_targets(&self) -> Vec<(Vec<Step>, crate::CommentKind, String)> {
214        let mut out = Vec::new();
215        collect_targets(self, &mut Vec::new(), &mut out);
216        out
217    }
218}
219
220fn collect_targets(
221    node: &Commented,
222    steps: &mut Vec<Step>,
223    out: &mut Vec<(Vec<Step>, crate::CommentKind, String)>,
224) {
225    use crate::CommentKind::{Foot, Head, Inline};
226    for kind in [Head, Inline, Foot] {
227        if let Some(text) = node.comments.get(kind) {
228            out.push((steps.clone(), kind, text));
229        }
230    }
231    match &node.node {
232        CommentedNode::Scalar(_) => {}
233        CommentedNode::Object(entries) => {
234            for (k, v) in entries {
235                steps.push(Step::Field(k.clone()));
236                collect_targets(v, steps, out);
237                steps.pop();
238            }
239        }
240        CommentedNode::Array(items) => {
241            for (i, v) in items.iter().enumerate() {
242                steps.push(Step::Index(i as i64));
243                collect_targets(v, steps, out);
244                steps.pop();
245            }
246        }
247    }
248}
249
250/// One flattened `key = value` line with the comments it carries - the shape
251/// the flat emitters (INI sections, `.env`) place comments through.
252#[derive(Debug, Clone, Default, PartialEq)]
253pub struct FlatEntry {
254    pub key: String,
255    pub value: String,
256    pub comments: Comments,
257}
258
259/// Flatten a commented tree to dotted-key entries (the commented analogue of
260/// [`crate::convert::flatten`]). A container's own comments ride along: its
261/// `head` (and `inline`, which has no line of its own once flattened) prepend
262/// to its first entry's `head`; its `foot` appends to its last entry's `foot`.
263/// An empty container vanishes, its comments carried to... nowhere - the caller
264/// sees them dropped via [`Commented::has_comments`] on the re-projected result;
265/// in practice empty containers with comments are vanishingly rare.
266pub fn flatten_commented(node: &Commented) -> Vec<FlatEntry> {
267    let mut out = Vec::new();
268    walk("", node, &mut out);
269    out
270}
271
272fn walk(prefix: &str, node: &Commented, out: &mut Vec<FlatEntry>) {
273    match &node.node {
274        CommentedNode::Object(entries) => {
275            let first = out.len();
276            for (k, v) in entries {
277                walk(&join_key(prefix, k), v, out);
278            }
279            distribute_container_comments(node, first, out);
280        }
281        CommentedNode::Array(items) => {
282            let first = out.len();
283            for (i, v) in items.iter().enumerate() {
284                walk(&join_key(prefix, &i.to_string()), v, out);
285            }
286            distribute_container_comments(node, first, out);
287        }
288        CommentedNode::Scalar(v) => out.push(FlatEntry {
289            key: prefix.to_string(),
290            value: v.to_raw_string(),
291            comments: node.comments.clone(),
292        }),
293    }
294}
295
296/// Attach a flattened container's own comments to its first/last entries.
297fn distribute_container_comments(node: &Commented, first: usize, out: &mut [FlatEntry]) {
298    if node.comments.is_empty() || out.len() <= first {
299        return;
300    }
301    let mut head = node.comments.head.clone();
302    // An inline comment loses its own line when the container flattens; it
303    // becomes the last head line of the first entry.
304    head.extend(node.comments.inline.clone());
305    let existing = std::mem::take(&mut out[first].comments.head);
306    head.extend(existing);
307    out[first].comments.head = head;
308    let last = out.len() - 1;
309    out[last].comments.foot.extend(node.comments.foot.clone());
310}
311
312fn join_key(prefix: &str, key: &str) -> String {
313    if prefix.is_empty() {
314        key.to_string()
315    } else {
316        format!("{prefix}.{key}")
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    fn commented(head: &[&str], inline: Option<&str>, node: CommentedNode) -> Commented {
325        Commented {
326            comments: Comments {
327                head: head.iter().map(|s| s.to_string()).collect(),
328                inline: inline.map(|s| s.to_string()),
329                foot: Vec::new(),
330            },
331            node,
332        }
333    }
334
335    #[test]
336    fn resolve_comment_reads_by_kind() {
337        let tree = Commented {
338            comments: Comments {
339                head: vec!["banner".into()],
340                inline: None,
341                foot: Vec::new(),
342            },
343            node: CommentedNode::Object(vec![
344                (
345                    "a".into(),
346                    commented(
347                        &["one", "two"],
348                        Some("why"),
349                        CommentedNode::Scalar(Value::Int(1)),
350                    ),
351                ),
352                ("b".into(), Commented::scalar(Value::Int(2))),
353            ]),
354        };
355        let head = Step::Comment(CommentKind::Head);
356        let inline = Step::Comment(CommentKind::Inline);
357        // `.a.#` (head): multi-line joins with a space.
358        assert_eq!(
359            tree.resolve_comment(&[Step::Field("a".into()), head.clone()]),
360            vec![Value::Str("one two".into())]
361        );
362        // `.a.#.inline`.
363        assert_eq!(
364            tree.resolve_comment(&[Step::Field("a".into()), inline]),
365            vec![Value::Str("why".into())]
366        );
367        // `.#` - the document banner.
368        assert_eq!(
369            tree.resolve_comment(std::slice::from_ref(&head)),
370            vec![Value::Str("banner".into())]
371        );
372        // A node with no comment of that kind -> miss (empty).
373        assert!(
374            tree.resolve_comment(&[Step::Field("b".into()), head.clone()])
375                .is_empty()
376        );
377        // A missing node -> miss.
378        assert!(
379            tree.resolve_comment(&[Step::Field("nope".into()), head])
380                .is_empty()
381        );
382    }
383
384    #[test]
385    fn from_value_round_trips_and_is_comment_free() {
386        let v = Value::Object(vec![
387            ("a".into(), Value::Int(1)),
388            ("b".into(), Value::Array(vec![Value::Str("x".into())])),
389        ]);
390        let c = Commented::from_value(&v);
391        assert!(!c.has_comments());
392        assert_eq!(c.to_value(), v);
393    }
394
395    #[test]
396    fn descend_mirrors_eval_paths() {
397        let tree = Commented {
398            comments: Comments::default(),
399            node: CommentedNode::Object(vec![
400                (
401                    "a".into(),
402                    commented(&["on a"], None, CommentedNode::Scalar(Value::Int(1))),
403                ),
404                (
405                    "xs".into(),
406                    Commented {
407                        comments: Comments::default(),
408                        node: CommentedNode::Array(vec![
409                            Commented::scalar(Value::Int(10)),
410                            commented(&[], Some("last"), CommentedNode::Scalar(Value::Int(20))),
411                        ]),
412                    },
413                ),
414            ]),
415        };
416        // identity
417        assert_eq!(tree.descend(&[]).len(), 1);
418        // field
419        let a = tree.descend(&[Step::Field("a".into())]);
420        assert_eq!(a.len(), 1);
421        assert_eq!(a[0].comments.head, vec!["on a"]);
422        // negative index
423        let last = tree.descend(&[Step::Field("xs".into()), Step::Index(-1)]);
424        assert_eq!(last[0].comments.inline.as_deref(), Some("last"));
425        // iterate
426        assert_eq!(
427            tree.descend(&[Step::Field("xs".into()), Step::Iterate])
428                .len(),
429            2
430        );
431        // a miss yields nothing, matching the evaluator
432        assert!(tree.descend(&[Step::Field("nope".into())]).is_empty());
433    }
434
435    #[test]
436    fn flatten_carries_comments_to_dotted_keys() {
437        let tree = Commented {
438            comments: Comments {
439                head: vec!["banner".into()],
440                inline: None,
441                foot: vec!["trailer".into()],
442            },
443            node: CommentedNode::Object(vec![(
444                "a".into(),
445                commented(
446                    &["section"],
447                    None,
448                    CommentedNode::Object(vec![(
449                        "b".into(),
450                        commented(&[], Some("why"), CommentedNode::Scalar(Value::Int(1))),
451                    )]),
452                ),
453            )]),
454        };
455        let flat = flatten_commented(&tree);
456        assert_eq!(flat.len(), 1);
457        assert_eq!(flat[0].key, "a.b");
458        assert_eq!(flat[0].value, "1");
459        // banner (root head) + section (container head) land on the first
460        // entry; the scalar keeps its inline; root foot lands on the last.
461        assert_eq!(flat[0].comments.head, vec!["banner", "section"]);
462        assert_eq!(flat[0].comments.inline.as_deref(), Some("why"));
463        assert_eq!(flat[0].comments.foot, vec!["trailer"]);
464    }
465}