Skip to main content

usage/spec/
helpers.rs

1use indexmap::IndexMap;
2use kdl::{KdlEntry, KdlEntryFormat, KdlNode, KdlValue};
3use miette::SourceSpan;
4use std::fmt::Debug;
5use std::ops::RangeBounds;
6
7use crate::error::UsageErr;
8use crate::spec::context::ParsingContext;
9
10/// Compute the number of `#` characters needed for a raw multiline string.
11/// We need `n` such that the value does not contain `"""` followed by `n` `#` characters.
12///
13/// Scans overlapping offsets: `""""#` contains `"""#` starting at the second
14/// quote, which `str::match_indices` skips. One hash would then emit a closer
15/// that sits inside the payload and truncates the string.
16fn raw_multiline_hash_count(value: &str) -> usize {
17    let mut max_count = 0;
18    let bytes = value.as_bytes();
19    for i in 0..bytes.len().saturating_sub(2) {
20        if bytes[i] == b'"' && bytes[i + 1] == b'"' && bytes[i + 2] == b'"' {
21            let count = value[i + 3..].chars().take_while(|&c| c == '#').count();
22            max_count = max_count.max(count);
23        }
24    }
25    max_count + 1
26}
27
28/// A KDL quoted string, with everything that has to be escaped, escaped.
29fn escape_string(value: &str) -> String {
30    let mut out = String::with_capacity(value.len() + 2);
31    out.push('"');
32    for ch in value.chars() {
33        match ch {
34            '"' => out.push_str("\\\""),
35            '\\' => out.push_str("\\\\"),
36            '\n' => out.push_str("\\n"),
37            '\r' => out.push_str("\\r"),
38            '\t' => out.push_str("\\t"),
39            c if c.is_control() => out.push_str(&format!("\\u{{{:x}}}", c as u32)),
40            c => out.push(c),
41        }
42    }
43    out.push('"');
44    out
45}
46
47/// An entry format that keeps a literal representation as written.
48fn quoted_format(value_repr: &str) -> KdlEntryFormat {
49    KdlEntryFormat {
50        value_repr: value_repr.to_string(),
51        leading: " ".into(),
52        trailing: "".into(),
53        after_ty: "".into(),
54        before_ty_name: "".into(),
55        after_ty_name: "".into(),
56        after_key: "".into(),
57        after_eq: "".into(),
58        autoformat_keep: true,
59    }
60}
61
62/// Create a KdlEntry for a string value, using KDL raw multiline string syntax (`#"""..."""#`)
63/// when the value contains newlines. The number of `#` characters is automatically determined
64/// to ensure the value can be embedded safely.
65pub(crate) fn string_entry(key: Option<&str>, value: &str) -> KdlEntry {
66    let mut entry = match key {
67        Some(k) => KdlEntry::new_prop(k, KdlValue::String(value.to_string())),
68        None => KdlEntry::new(KdlValue::String(value.to_string())),
69    };
70    // Two kinds of value the kdl crate renders in a form this crate cannot read
71    // back. Both produced specs that failed to reparse, which the argv round-trip
72    // tests caught.
73    //
74    // A node argument starting with a dash: KDL reads `overrides "--keep"` but not
75    // `overrides --keep`. Properties are left alone, since `negate=--no-color`
76    // renders and parses today and quoting it would rewrite every committed spec
77    // for no gain.
78    let dashed_argument = key.is_none() && value.starts_with('-');
79    // A control character other than a newline or tab, which KDL requires as an
80    // escape rather than a literal. Help text really does contain these: a CLI that
81    // colors its help has an escape character in the middle of it.
82    let has_control = value
83        .chars()
84        .any(|c| c.is_control() && c != '\n' && c != '\t');
85    if dashed_argument || has_control {
86        entry.set_format(quoted_format(&escape_string(value)));
87        return entry;
88    }
89    if value.contains('\n') {
90        let n = raw_multiline_hash_count(value);
91        let hashes = "#".repeat(n);
92        let repr = format!("{hashes}\"\"\"\n{value}\n\"\"\"{hashes}");
93        entry.set_format(KdlEntryFormat {
94            value_repr: repr,
95            leading: " ".into(),
96            trailing: "".into(),
97            after_ty: "".into(),
98            before_ty_name: "".into(),
99            after_ty_name: "".into(),
100            after_key: "".into(),
101            after_eq: "".into(),
102            autoformat_keep: true,
103        });
104    }
105    entry
106}
107
108#[derive(Debug)]
109pub struct NodeHelper<'a> {
110    pub(crate) node: &'a KdlNode,
111    pub(crate) ctx: &'a ParsingContext,
112}
113
114impl<'a> NodeHelper<'a> {
115    pub(crate) fn new(ctx: &'a ParsingContext, node: &'a KdlNode) -> Self {
116        Self { node, ctx }
117    }
118
119    pub(crate) fn name(&self) -> &str {
120        self.node.name().value()
121    }
122    pub(crate) fn span(&self) -> SourceSpan {
123        (self.node.span().offset(), self.node.span().len()).into()
124    }
125    pub(crate) fn ensure_arg_len<R>(&self, range: R) -> Result<&Self, UsageErr>
126    where
127        R: RangeBounds<usize> + Debug,
128    {
129        let count = self.args().count();
130        if !range.contains(&count) {
131            let ctx = self.ctx;
132            let span = self.span();
133            bail_parse!(ctx, span, "expected {range:?} arguments, got {count}",)
134        }
135        Ok(self)
136    }
137    pub(crate) fn get(&self, key: &str) -> Option<ParseEntry<'_>> {
138        self.node.entry(key).map(|e| ParseEntry::new(self.ctx, e))
139    }
140    pub(crate) fn arg(&self, i: usize) -> Result<ParseEntry<'_>, UsageErr> {
141        if let Some(entry) = self.args().nth(i) {
142            return Ok(entry);
143        }
144        bail_parse!(self.ctx, self.span(), "missing argument")
145    }
146    pub(crate) fn args(&self) -> impl Iterator<Item = ParseEntry<'_>> + '_ {
147        self.node
148            .entries()
149            .iter()
150            .filter(|e| e.name().is_none())
151            .map(|e| ParseEntry::new(self.ctx, e))
152    }
153    pub(crate) fn props(&self) -> IndexMap<&str, ParseEntry<'_>> {
154        self.node
155            .entries()
156            .iter()
157            .filter_map(|e| {
158                e.name()
159                    .map(|key| (key.value(), ParseEntry::new(self.ctx, e)))
160            })
161            .collect()
162    }
163    pub(crate) fn children(&self) -> Vec<Self> {
164        self.node
165            .children()
166            .map(|c| {
167                c.nodes()
168                    .iter()
169                    .map(|n| NodeHelper::new(self.ctx, n))
170                    .collect()
171            })
172            .unwrap_or_default()
173    }
174}
175
176#[derive(Debug)]
177pub(crate) struct ParseEntry<'a> {
178    pub(crate) ctx: &'a ParsingContext,
179    pub(crate) entry: &'a KdlEntry,
180    pub(crate) value: &'a KdlValue,
181}
182
183impl<'a> ParseEntry<'a> {
184    fn new(ctx: &'a ParsingContext, entry: &'a KdlEntry) -> Self {
185        Self {
186            ctx,
187            entry,
188            value: entry.value(),
189        }
190    }
191
192    fn span(&self) -> SourceSpan {
193        (self.entry.span().offset(), self.entry.span().len()).into()
194    }
195}
196
197impl ParseEntry<'_> {
198    pub fn ensure_usize(&self) -> Result<usize, UsageErr> {
199        match self.value.as_integer() {
200            Some(i) => Ok(i as usize),
201            None => bail_parse!(self.ctx, self.span(), "expected usize"),
202        }
203    }
204    #[allow(dead_code)]
205    pub fn ensure_f64(&self) -> Result<f64, UsageErr> {
206        match self.value.as_float() {
207            Some(f) => Ok(f),
208            None => bail_parse!(self.ctx, self.span(), "expected float"),
209        }
210    }
211    pub fn ensure_bool(&self) -> Result<bool, UsageErr> {
212        match self.value.as_bool() {
213            Some(b) => Ok(b),
214            None => bail_parse!(self.ctx, self.span(), "expected bool"),
215        }
216    }
217    pub fn ensure_string(&self) -> Result<String, UsageErr> {
218        match self.value.as_string() {
219            Some(s) => Ok(s.to_string()),
220            None => bail_parse!(self.ctx, self.span(), "expected string"),
221        }
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use kdl::KdlDocument;
229    use std::path::Path;
230
231    fn parse_node(input: &str) -> (ParsingContext, KdlDocument) {
232        let ctx = ParsingContext::new(Path::new("test.kdl"), input);
233        let doc: KdlDocument = input.parse().unwrap();
234        (ctx, doc)
235    }
236
237    #[test]
238    fn test_node_helper_name() {
239        let (ctx, doc) = parse_node("test_node \"arg1\"");
240        let node = doc.nodes().first().unwrap();
241        let helper = NodeHelper::new(&ctx, node);
242        assert_eq!(helper.name(), "test_node");
243    }
244
245    #[test]
246    fn test_node_helper_arg() {
247        let (ctx, doc) = parse_node("node \"first\" \"second\"");
248        let node = doc.nodes().first().unwrap();
249        let helper = NodeHelper::new(&ctx, node);
250
251        assert_eq!(helper.arg(0).unwrap().ensure_string().unwrap(), "first");
252        assert_eq!(helper.arg(1).unwrap().ensure_string().unwrap(), "second");
253    }
254
255    #[test]
256    fn test_node_helper_args_count() {
257        let (ctx, doc) = parse_node("node \"a\" \"b\" \"c\"");
258        let node = doc.nodes().first().unwrap();
259        let helper = NodeHelper::new(&ctx, node);
260
261        assert_eq!(helper.args().count(), 3);
262    }
263
264    #[test]
265    fn test_node_helper_props() {
266        let (ctx, doc) = parse_node("node key1=\"value1\" key2=\"value2\"");
267        let node = doc.nodes().first().unwrap();
268        let helper = NodeHelper::new(&ctx, node);
269
270        let props = helper.props();
271        assert_eq!(props.len(), 2);
272        assert_eq!(props["key1"].ensure_string().unwrap(), "value1");
273        assert_eq!(props["key2"].ensure_string().unwrap(), "value2");
274    }
275
276    #[test]
277    fn test_node_helper_get() {
278        let (ctx, doc) = parse_node("node name=\"test\"");
279        let node = doc.nodes().first().unwrap();
280        let helper = NodeHelper::new(&ctx, node);
281
282        assert!(helper.get("name").is_some());
283        assert!(helper.get("nonexistent").is_none());
284    }
285
286    #[test]
287    fn test_node_helper_children() {
288        let (ctx, doc) = parse_node("parent { child1; child2 }");
289        let node = doc.nodes().first().unwrap();
290        let helper = NodeHelper::new(&ctx, node);
291
292        let children = helper.children();
293        assert_eq!(children.len(), 2);
294        assert_eq!(children[0].name(), "child1");
295        assert_eq!(children[1].name(), "child2");
296    }
297
298    #[test]
299    fn test_node_helper_ensure_arg_len_valid() {
300        let (ctx, doc) = parse_node("node \"a\" \"b\"");
301        let node = doc.nodes().first().unwrap();
302        let helper = NodeHelper::new(&ctx, node);
303
304        assert!(helper.ensure_arg_len(2..=2).is_ok());
305        assert!(helper.ensure_arg_len(1..=3).is_ok());
306        assert!(helper.ensure_arg_len(0..).is_ok());
307    }
308
309    #[test]
310    fn test_node_helper_ensure_arg_len_invalid() {
311        let (ctx, doc) = parse_node("node \"a\"");
312        let node = doc.nodes().first().unwrap();
313        let helper = NodeHelper::new(&ctx, node);
314
315        assert!(helper.ensure_arg_len(2..=2).is_err());
316    }
317
318    #[test]
319    fn test_parse_entry_ensure_usize() {
320        let (ctx, doc) = parse_node("node 42");
321        let node = doc.nodes().first().unwrap();
322        let helper = NodeHelper::new(&ctx, node);
323
324        assert_eq!(helper.arg(0).unwrap().ensure_usize().unwrap(), 42);
325    }
326
327    #[test]
328    fn test_parse_entry_ensure_bool() {
329        let (ctx, doc) = parse_node("node #true");
330        let node = doc.nodes().first().unwrap();
331        let helper = NodeHelper::new(&ctx, node);
332
333        assert!(helper.arg(0).unwrap().ensure_bool().unwrap());
334    }
335
336    #[test]
337    fn test_parse_entry_ensure_string() {
338        let (ctx, doc) = parse_node("node \"hello\"");
339        let node = doc.nodes().first().unwrap();
340        let helper = NodeHelper::new(&ctx, node);
341
342        assert_eq!(helper.arg(0).unwrap().ensure_string().unwrap(), "hello");
343    }
344
345    #[test]
346    fn test_parse_entry_type_mismatch() {
347        let (ctx, doc) = parse_node("node \"not_a_number\"");
348        let node = doc.nodes().first().unwrap();
349        let helper = NodeHelper::new(&ctx, node);
350
351        assert!(helper.arg(0).unwrap().ensure_usize().is_err());
352    }
353
354    #[test]
355    fn overlapping_quotes_raise_the_raw_multiline_hash_count() {
356        assert_eq!(raw_multiline_hash_count("\"\"\"#"), 2);
357        assert_eq!(raw_multiline_hash_count("\"\"\"\"#"), 2);
358        assert_eq!(raw_multiline_hash_count("\"\"\"\"##"), 3);
359        assert_eq!(raw_multiline_hash_count("plain"), 1);
360    }
361
362    #[test]
363    fn overlapping_quotes_round_trip_through_parse() {
364        for value in [
365            "before\n\"\"\"\"#\nafter",
366            "before\n\"\"\"\"##\nafter",
367            "line\n\"\"\"#\nstill",
368        ] {
369            let mut node = KdlNode::new("about");
370            node.push(string_entry(None, value));
371            let kdl = format!("name ex\nbin ex\n{node}\n");
372            let spec: crate::Spec = kdl
373                .parse()
374                .unwrap_or_else(|e| panic!("emitted spec must parse: {e}\n{kdl}"));
375            assert_eq!(spec.about.as_deref(), Some(value), "{kdl}");
376        }
377    }
378}