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    /// Collapse duplicate object keys, keeping the first occurrence with its
91    /// comments.
92    ///
93    /// The plain-`Value` twin is `convert::dedupe_keys`; both are needed
94    /// because a comment-carrying selection is emitted from this tree, not from
95    /// the `Value`, so deduping only the latter would warn and change nothing.
96    pub fn dedupe_keys(&self) -> Commented {
97        let node = match &self.node {
98            CommentedNode::Object(entries) => {
99                let mut seen = std::collections::HashSet::new();
100                CommentedNode::Object(
101                    entries
102                        .iter()
103                        .filter(|(k, _)| seen.insert(k.clone()))
104                        .map(|(k, v)| (k.clone(), v.dedupe_keys()))
105                        .collect(),
106                )
107            }
108            CommentedNode::Array(items) => {
109                CommentedNode::Array(items.iter().map(Commented::dedupe_keys).collect())
110            }
111            scalar => scalar.clone(),
112        };
113        Commented {
114            comments: self.comments.clone(),
115            node,
116        }
117    }
118
119    /// Wrap a plain value with no comments anywhere.
120    pub fn from_value(value: &Value) -> Commented {
121        let node = match value {
122            Value::Array(items) => {
123                CommentedNode::Array(items.iter().map(Commented::from_value).collect())
124            }
125            Value::Object(entries) => CommentedNode::Object(
126                entries
127                    .iter()
128                    .map(|(k, v)| (k.clone(), Commented::from_value(v)))
129                    .collect(),
130            ),
131            scalar => CommentedNode::Scalar(scalar.clone()),
132        };
133        Commented {
134            comments: Comments::default(),
135            node,
136        }
137    }
138
139    /// A scalar node with no comments (convenience for extractors).
140    pub fn scalar(value: Value) -> Commented {
141        Commented {
142            comments: Comments::default(),
143            node: CommentedNode::Scalar(value),
144        }
145    }
146
147    /// Strip comments back to the plain value model.
148    pub fn to_value(&self) -> Value {
149        match &self.node {
150            CommentedNode::Scalar(v) => v.clone(),
151            CommentedNode::Array(items) => {
152                Value::Array(items.iter().map(Commented::to_value).collect())
153            }
154            CommentedNode::Object(entries) => Value::Object(
155                entries
156                    .iter()
157                    .map(|(k, v)| (k.clone(), v.to_value()))
158                    .collect(),
159            ),
160        }
161    }
162
163    /// Does this node or any descendant carry a comment?
164    pub fn has_comments(&self) -> bool {
165        if !self.comments.is_empty() {
166            return true;
167        }
168        match &self.node {
169            CommentedNode::Scalar(_) => false,
170            CommentedNode::Array(items) => items.iter().any(Commented::has_comments),
171            CommentedNode::Object(entries) => entries.iter().any(|(_, v)| v.has_comments()),
172        }
173    }
174
175    /// Attach trailing document comments as the foot of the deepest last entry
176    /// - the node they physically follow, so re-emission keeps them at the end.
177    pub fn attach_trailing_foot(&mut self, lines: Vec<String>) {
178        match &mut self.node {
179            CommentedNode::Object(entries) if !entries.is_empty() => {
180                entries.last_mut().unwrap().1.attach_trailing_foot(lines);
181            }
182            CommentedNode::Array(items) if !items.is_empty() => {
183                items.last_mut().unwrap().attach_trailing_foot(lines);
184            }
185            _ => self.comments.foot.extend(lines),
186        }
187    }
188
189    /// The nodes a pure path selects, in document order, mirroring the
190    /// evaluator's path semantics (a missing field/index yields nothing;
191    /// `[]` iterates elements/values), so the results align 1:1 with
192    /// [`crate::eval`] on the same path. A step that cannot apply (e.g. a field
193    /// of a scalar) yields nothing here; the evaluator errors first in that
194    /// case, so the mismatch is never observed.
195    pub fn descend(&self, path: &[Step]) -> Vec<&Commented> {
196        let mut stream = vec![self];
197        for step in path {
198            let mut next = Vec::new();
199            for node in stream {
200                match (step, &node.node) {
201                    (Step::Field(k), CommentedNode::Object(entries)) => {
202                        next.extend(entries.iter().find(|(kk, _)| kk == k).map(|(_, v)| v));
203                    }
204                    (Step::Index(i), CommentedNode::Array(items)) => {
205                        let idx = if *i < 0 { items.len() as i64 + i } else { *i };
206                        if idx >= 0 && (idx as usize) < items.len() {
207                            next.push(&items[idx as usize]);
208                        }
209                    }
210                    (Step::Iterate, CommentedNode::Array(items)) => next.extend(items.iter()),
211                    (Step::Iterate, CommentedNode::Object(entries)) => {
212                        next.extend(entries.iter().map(|(_, v)| v));
213                    }
214                    _ => {}
215                }
216            }
217            stream = next;
218        }
219        stream
220    }
221
222    /// Resolve a comment-addressing path (a value prefix ending in a terminal
223    /// [`Step::Comment`]) to the comment text of each selected node, as a
224    /// stream of `Value::Str`. A node without that comment kind contributes
225    /// nothing: a miss, matching the rest of the language. The `Comment` step
226    /// is terminal by construction (the parser forbids steps after it).
227    pub fn resolve_comment(&self, path: &[Step]) -> Vec<Value> {
228        let Some((Step::Comment(kind), prefix)) = path.split_last() else {
229            return Vec::new();
230        };
231        self.descend(prefix)
232            .into_iter()
233            .filter_map(|n| n.comments.get(*kind).map(Value::Str))
234            .collect()
235    }
236
237    /// Every comment in the tree as `(path steps, kind, text)`, in document
238    /// order: the backbone of the document-wide `comments` stream (query) and
239    /// bulk comment edits (`comments |= ...`). The steps address the *node*; the
240    /// kind and text are the comment. Paths stay valid across edits (they are
241    /// logical, not byte offsets), so a caller may snapshot then apply.
242    pub fn comment_targets(&self) -> Vec<(Vec<Step>, crate::CommentKind, String)> {
243        let mut out = Vec::new();
244        collect_targets(self, &mut Vec::new(), &mut out);
245        out
246    }
247}
248
249fn collect_targets(
250    node: &Commented,
251    steps: &mut Vec<Step>,
252    out: &mut Vec<(Vec<Step>, crate::CommentKind, String)>,
253) {
254    use crate::CommentKind::{Foot, Head, Inline};
255    for kind in [Head, Inline, Foot] {
256        if let Some(text) = node.comments.get(kind) {
257            out.push((steps.clone(), kind, text));
258        }
259    }
260    match &node.node {
261        CommentedNode::Scalar(_) => {}
262        CommentedNode::Object(entries) => {
263            for (k, v) in entries {
264                steps.push(Step::Field(k.clone()));
265                collect_targets(v, steps, out);
266                steps.pop();
267            }
268        }
269        CommentedNode::Array(items) => {
270            for (i, v) in items.iter().enumerate() {
271                steps.push(Step::Index(i as i64));
272                collect_targets(v, steps, out);
273                steps.pop();
274            }
275        }
276    }
277}
278
279/// One flattened `key = value` line with the comments it carries: the shape
280/// the flat emitters (INI sections, `.env`) place comments through.
281#[derive(Debug, Clone, Default, PartialEq)]
282pub struct FlatEntry {
283    pub key: String,
284    pub value: String,
285    pub comments: Comments,
286}
287
288/// Flatten a commented tree to dotted-key entries (the commented analogue of
289/// [`crate::convert::flatten`]). A container's own comments ride along: its
290/// `head` (and `inline`, which has no line of its own once flattened) prepend
291/// to its first entry's `head`; its `foot` appends to its last entry's `foot`.
292/// An empty container vanishes, its comments carried to... nowhere: the caller
293/// sees them dropped via [`Commented::has_comments`] on the re-projected result;
294/// in practice empty containers with comments are vanishingly rare.
295pub fn flatten_commented(node: &Commented) -> Vec<FlatEntry> {
296    let mut out = Vec::new();
297    walk("", node, &mut out);
298    out
299}
300
301fn walk(prefix: &str, node: &Commented, out: &mut Vec<FlatEntry>) {
302    match &node.node {
303        CommentedNode::Object(entries) => {
304            let first = out.len();
305            for (k, v) in entries {
306                walk(&join_key(prefix, k), v, out);
307            }
308            distribute_container_comments(node, first, out);
309        }
310        CommentedNode::Array(items) => {
311            let first = out.len();
312            for (i, v) in items.iter().enumerate() {
313                walk(&join_key(prefix, &i.to_string()), v, out);
314            }
315            distribute_container_comments(node, first, out);
316        }
317        CommentedNode::Scalar(v) => out.push(FlatEntry {
318            key: prefix.to_string(),
319            value: v.to_raw_string(),
320            comments: node.comments.clone(),
321        }),
322    }
323}
324
325/// Attach a flattened container's own comments to its first/last entries.
326fn distribute_container_comments(node: &Commented, first: usize, out: &mut [FlatEntry]) {
327    if node.comments.is_empty() || out.len() <= first {
328        return;
329    }
330    let mut head = node.comments.head.clone();
331    // An inline comment loses its own line when the container flattens; it
332    // becomes the last head line of the first entry.
333    head.extend(node.comments.inline.clone());
334    let existing = std::mem::take(&mut out[first].comments.head);
335    head.extend(existing);
336    out[first].comments.head = head;
337    let last = out.len() - 1;
338    out[last].comments.foot.extend(node.comments.foot.clone());
339}
340
341fn join_key(prefix: &str, key: &str) -> String {
342    if prefix.is_empty() {
343        key.to_string()
344    } else {
345        format!("{prefix}.{key}")
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    fn commented(head: &[&str], inline: Option<&str>, node: CommentedNode) -> Commented {
354        Commented {
355            comments: Comments {
356                head: head.iter().map(|s| s.to_string()).collect(),
357                inline: inline.map(|s| s.to_string()),
358                foot: Vec::new(),
359            },
360            node,
361        }
362    }
363
364    #[test]
365    fn resolve_comment_reads_by_kind() {
366        let tree = Commented {
367            comments: Comments {
368                head: vec!["banner".into()],
369                inline: None,
370                foot: Vec::new(),
371            },
372            node: CommentedNode::Object(vec![
373                (
374                    "a".into(),
375                    commented(
376                        &["one", "two"],
377                        Some("why"),
378                        CommentedNode::Scalar(Value::Int(1)),
379                    ),
380                ),
381                ("b".into(), Commented::scalar(Value::Int(2))),
382            ]),
383        };
384        let head = Step::Comment(CommentKind::Head);
385        let inline = Step::Comment(CommentKind::Inline);
386        // `.a.#` (head): multi-line joins with a space.
387        assert_eq!(
388            tree.resolve_comment(&[Step::Field("a".into()), head.clone()]),
389            vec![Value::Str("one two".into())]
390        );
391        // `.a.#.inline`.
392        assert_eq!(
393            tree.resolve_comment(&[Step::Field("a".into()), inline]),
394            vec![Value::Str("why".into())]
395        );
396        // `.#`: the document banner.
397        assert_eq!(
398            tree.resolve_comment(std::slice::from_ref(&head)),
399            vec![Value::Str("banner".into())]
400        );
401        // A node with no comment of that kind -> miss (empty).
402        assert!(
403            tree.resolve_comment(&[Step::Field("b".into()), head.clone()])
404                .is_empty()
405        );
406        // A missing node -> miss.
407        assert!(
408            tree.resolve_comment(&[Step::Field("nope".into()), head])
409                .is_empty()
410        );
411    }
412
413    #[test]
414    fn from_value_round_trips_and_is_comment_free() {
415        let v = Value::Object(vec![
416            ("a".into(), Value::Int(1)),
417            ("b".into(), Value::Array(vec![Value::Str("x".into())])),
418        ]);
419        let c = Commented::from_value(&v);
420        assert!(!c.has_comments());
421        assert_eq!(c.to_value(), v);
422    }
423
424    #[test]
425    fn descend_mirrors_eval_paths() {
426        let tree = Commented {
427            comments: Comments::default(),
428            node: CommentedNode::Object(vec![
429                (
430                    "a".into(),
431                    commented(&["on a"], None, CommentedNode::Scalar(Value::Int(1))),
432                ),
433                (
434                    "xs".into(),
435                    Commented {
436                        comments: Comments::default(),
437                        node: CommentedNode::Array(vec![
438                            Commented::scalar(Value::Int(10)),
439                            commented(&[], Some("last"), CommentedNode::Scalar(Value::Int(20))),
440                        ]),
441                    },
442                ),
443            ]),
444        };
445        // identity
446        assert_eq!(tree.descend(&[]).len(), 1);
447        // field
448        let a = tree.descend(&[Step::Field("a".into())]);
449        assert_eq!(a.len(), 1);
450        assert_eq!(a[0].comments.head, vec!["on a"]);
451        // negative index
452        let last = tree.descend(&[Step::Field("xs".into()), Step::Index(-1)]);
453        assert_eq!(last[0].comments.inline.as_deref(), Some("last"));
454        // iterate
455        assert_eq!(
456            tree.descend(&[Step::Field("xs".into()), Step::Iterate])
457                .len(),
458            2
459        );
460        // a miss yields nothing, matching the evaluator
461        assert!(tree.descend(&[Step::Field("nope".into())]).is_empty());
462    }
463
464    #[test]
465    fn flatten_carries_comments_to_dotted_keys() {
466        let tree = Commented {
467            comments: Comments {
468                head: vec!["banner".into()],
469                inline: None,
470                foot: vec!["trailer".into()],
471            },
472            node: CommentedNode::Object(vec![(
473                "a".into(),
474                commented(
475                    &["section"],
476                    None,
477                    CommentedNode::Object(vec![(
478                        "b".into(),
479                        commented(&[], Some("why"), CommentedNode::Scalar(Value::Int(1))),
480                    )]),
481                ),
482            )]),
483        };
484        let flat = flatten_commented(&tree);
485        assert_eq!(flat.len(), 1);
486        assert_eq!(flat[0].key, "a.b");
487        assert_eq!(flat[0].value, "1");
488        // banner (root head) + section (container head) land on the first
489        // entry; the scalar keeps its inline; root foot lands on the last.
490        assert_eq!(flat[0].comments.head, vec!["banner", "section"]);
491        assert_eq!(flat[0].comments.inline.as_deref(), Some("why"));
492        assert_eq!(flat[0].comments.foot, vec!["trailer"]);
493    }
494}