Skip to main content

cmakefmt/spec/
mod.rs

1// SPDX-FileCopyrightText: Copyright 2026 Puneet Matharu
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Command-spec data model used by the formatter.
6//!
7//! The built-in registry describes the argument structure of known commands so
8//! the formatter can recognize positional arguments, keywords, flags, and
9//! command-specific layout hints.
10
11pub mod registry;
12
13use indexmap::{IndexMap, IndexSet};
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15use std::fmt;
16
17// ── NArgs ────────────────────────────────────────────────────────────────────
18
19/// How many arguments a positional slot or keyword takes.
20///
21/// In TOML this can be written as:
22///   - integer   `nargs = 1`       → `Fixed(1)`
23///   - `"*"`                      → `ZeroOrMore`
24///   - `"+"`                      → `OneOrMore`
25///   - `"?"`                      → `Optional`
26///   - `"N+"` e.g. `"2+"`         → `AtLeast(2)`
27#[derive(Debug, Clone, PartialEq, Eq, Default)]
28pub enum NArgs {
29    Fixed(usize),
30    #[default]
31    ZeroOrMore,
32    OneOrMore,
33    Optional,
34    AtLeast(usize),
35}
36
37impl Serialize for NArgs {
38    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
39        match self {
40            NArgs::Fixed(value) => serializer.serialize_u64(*value as u64),
41            NArgs::ZeroOrMore => serializer.serialize_str("*"),
42            NArgs::OneOrMore => serializer.serialize_str("+"),
43            NArgs::Optional => serializer.serialize_str("?"),
44            NArgs::AtLeast(value) => serializer.serialize_str(&format!("{value}+")),
45        }
46    }
47}
48
49impl<'de> Deserialize<'de> for NArgs {
50    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
51        struct Visitor;
52
53        impl<'de> serde::de::Visitor<'de> for Visitor {
54            type Value = NArgs;
55
56            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57                write!(f, r#"integer or string ("*", "+", "?", "N+")"#)
58            }
59
60            fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<NArgs, E> {
61                Ok(NArgs::Fixed(v as usize))
62            }
63
64            fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<NArgs, E> {
65                Ok(NArgs::Fixed(v.max(0) as usize))
66            }
67
68            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<NArgs, E> {
69                match v {
70                    "*" => Ok(NArgs::ZeroOrMore),
71                    "+" => Ok(NArgs::OneOrMore),
72                    "?" => Ok(NArgs::Optional),
73                    s if s.ends_with('+') && s.len() > 1 => {
74                        let n = s[..s.len() - 1]
75                            .parse::<usize>()
76                            .map_err(|_| E::custom(format!("invalid NArgs pattern: {s}")))?;
77                        Ok(NArgs::AtLeast(n))
78                    }
79                    s => {
80                        let n = s
81                            .parse::<usize>()
82                            .map_err(|_| E::custom(format!("invalid NArgs value: {s}")))?;
83                        Ok(NArgs::Fixed(n))
84                    }
85                }
86            }
87        }
88
89        d.deserialize_any(Visitor)
90    }
91}
92
93// ── Fully specified command model ────────────────────────────────────────────
94
95#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct LayoutOverrides {
98    /// Override line width for this command form.
99    pub line_width: Option<usize>,
100    /// Override indentation width for this command form.
101    pub tab_size: Option<usize>,
102    /// Override dangling-paren behavior for this command form.
103    pub dangle_parens: Option<bool>,
104    /// Force this command form into a wrapped layout.
105    pub always_wrap: Option<bool>,
106    /// Override the positional-argument hanging-wrap threshold for this form.
107    pub max_pargs_hwrap: Option<usize>,
108}
109
110/// Specification for a keyword section and any nested sub-keywords it accepts.
111#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct KwargSpec {
114    /// Number of positional arguments accepted after the keyword itself.
115    #[serde(default)]
116    pub nargs: NArgs,
117    /// Nested keywords that may appear after this keyword.
118    #[serde(default)]
119    pub kwargs: IndexMap<String, KwargSpec>,
120    /// Flag tokens accepted within this keyword section.
121    #[serde(default)]
122    pub flags: IndexSet<String>,
123}
124
125/// One fully resolved command form.
126#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
127#[serde(deny_unknown_fields)]
128pub struct CommandForm {
129    /// Number of positional arguments before keyword/flag processing starts.
130    #[serde(default)]
131    pub pargs: NArgs,
132    /// Recognized top-level keywords for this form.
133    #[serde(default)]
134    pub kwargs: IndexMap<String, KwargSpec>,
135    /// Recognized top-level flags for this form.
136    #[serde(default)]
137    pub flags: IndexSet<String>,
138    /// Optional layout hints for this form.
139    #[serde(default)]
140    pub layout: Option<LayoutOverrides>,
141}
142
143impl Default for CommandForm {
144    fn default() -> Self {
145        Self {
146            pargs: NArgs::ZeroOrMore,
147            kwargs: IndexMap::new(),
148            flags: IndexSet::new(),
149            layout: None,
150        }
151    }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
155#[serde(untagged)]
156pub enum CommandSpec {
157    /// A command whose structure depends on a discriminator token, usually the
158    /// first positional argument.
159    Discriminated {
160        /// Known forms keyed by normalized discriminator token.
161        forms: IndexMap<String, CommandForm>,
162        /// Fallback form to use when no discriminator matches.
163        #[serde(default)]
164        fallback: Option<CommandForm>,
165    },
166    /// A command with a single argument structure.
167    Single(CommandForm),
168}
169
170impl CommandSpec {
171    /// Resolve the command form for a specific invocation.
172    ///
173    /// `first_arg` is typically the first non-comment argument in the call and
174    /// is used for discriminated commands such as `file(...)` or `install(...)`.
175    pub fn form_for(&self, first_arg: Option<&str>) -> &CommandForm {
176        match self {
177            CommandSpec::Single(form) => form,
178            CommandSpec::Discriminated { forms, fallback } => {
179                let key = first_arg.unwrap_or_default();
180                forms
181                    .get(key)
182                    .or_else(|| {
183                        has_ascii_lowercase(key)
184                            .then(|| key.to_ascii_uppercase())
185                            .and_then(|normalized| forms.get(&normalized))
186                    })
187                    .or(fallback.as_ref())
188                    .unwrap_or_else(|| {
189                        forms
190                            .values()
191                            .next()
192                            .expect("discriminated spec has a form")
193                    })
194            }
195        }
196    }
197}
198
199fn has_ascii_lowercase(s: &str) -> bool {
200    s.bytes().any(|byte| byte.is_ascii_lowercase())
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
204pub struct SpecMetadata {
205    /// Upstream CMake version the built-in spec was last audited against.
206    #[serde(default)]
207    pub cmake_version: String,
208    /// Date of the most recent audit.
209    #[serde(default)]
210    pub audited_at: String,
211    /// Free-form notes about the current audit state.
212    #[serde(default)]
213    pub notes: String,
214}
215
216/// Top-level spec file containing metadata plus command entries.
217#[derive(Debug, Default, Deserialize)]
218pub struct SpecFile {
219    /// Version and audit metadata for the built-in spec surface.
220    #[serde(default)]
221    pub metadata: SpecMetadata,
222    /// Built-in command specifications keyed by command name.
223    #[serde(default)]
224    pub commands: IndexMap<String, CommandSpec>,
225}
226
227// ── Mergeable override model ─────────────────────────────────────────────────
228
229#[derive(Debug, Clone, Default, Deserialize, Serialize)]
230#[serde(deny_unknown_fields)]
231pub struct LayoutOverridesOverride {
232    /// Override line width for this command form.
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub line_width: Option<usize>,
235    /// Override indentation width for this command form.
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub tab_size: Option<usize>,
238    /// Override dangling-paren behavior for this command form.
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub dangle_parens: Option<bool>,
241    /// Force this command form into a wrapped layout.
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub always_wrap: Option<bool>,
244    /// Override the positional-argument hanging-wrap threshold for this form.
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub max_pargs_hwrap: Option<usize>,
247}
248
249/// Partial override for a keyword specification.
250#[derive(Debug, Clone, Default, Deserialize, Serialize)]
251#[serde(deny_unknown_fields)]
252pub struct KwargSpecOverride {
253    /// Override the number of positional arguments accepted after the keyword.
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub nargs: Option<NArgs>,
256    /// Nested keyword overrides.
257    #[serde(default)]
258    #[serde(skip_serializing_if = "IndexMap::is_empty")]
259    pub kwargs: IndexMap<String, KwargSpecOverride>,
260    /// Additional supported flags.
261    #[serde(default)]
262    #[serde(skip_serializing_if = "IndexSet::is_empty")]
263    pub flags: IndexSet<String>,
264}
265
266/// Partial override for a command form.
267#[derive(Debug, Clone, Default, Deserialize, Serialize)]
268#[serde(deny_unknown_fields)]
269pub struct CommandFormOverride {
270    /// Override the positional argument count for the form.
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub pargs: Option<NArgs>,
273    /// Keyword overrides to merge into the form.
274    #[serde(default)]
275    #[serde(skip_serializing_if = "IndexMap::is_empty")]
276    pub kwargs: IndexMap<String, KwargSpecOverride>,
277    /// Additional supported flags.
278    #[serde(default)]
279    #[serde(skip_serializing_if = "IndexSet::is_empty")]
280    pub flags: IndexSet<String>,
281    /// Optional layout overrides for the form.
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub layout: Option<LayoutOverridesOverride>,
284}
285
286/// Partial override for a full command spec.
287#[derive(Debug, Clone, Deserialize, Serialize)]
288#[serde(untagged)]
289pub enum CommandSpecOverride {
290    /// Override a single-form command.
291    Single(CommandFormOverride),
292    /// Override one or more discriminated forms.
293    Discriminated {
294        /// Per-discriminator form overrides.
295        #[serde(default)]
296        #[serde(skip_serializing_if = "IndexMap::is_empty")]
297        forms: IndexMap<String, CommandFormOverride>,
298        /// Optional fallback form override.
299        #[serde(default)]
300        #[serde(skip_serializing_if = "Option::is_none")]
301        fallback: Option<CommandFormOverride>,
302    },
303}
304
305/// Top-level user override file containing command overrides only.
306#[derive(Debug, Default, Deserialize, Serialize)]
307pub struct SpecOverrideFile {
308    /// Override specs keyed by command name.
309    #[serde(default)]
310    pub commands: IndexMap<String, CommandSpecOverride>,
311}
312
313impl CommandSpecOverride {
314    /// Convert a partial override into a fully specified standalone command
315    /// spec.
316    pub fn into_full_spec(self) -> CommandSpec {
317        match self {
318            CommandSpecOverride::Single(form) => CommandSpec::Single(form.into_full_form()),
319            CommandSpecOverride::Discriminated { forms, fallback } => CommandSpec::Discriminated {
320                forms: forms
321                    .into_iter()
322                    .map(|(name, form)| (name.to_ascii_uppercase(), form.into_full_form()))
323                    .collect(),
324                fallback: fallback.map(CommandFormOverride::into_full_form),
325            },
326        }
327    }
328}
329
330impl CommandFormOverride {
331    /// Convert a partial command form override into a fully specified form.
332    pub fn into_full_form(self) -> CommandForm {
333        CommandForm {
334            pargs: self.pargs.unwrap_or_default(),
335            kwargs: self
336                .kwargs
337                .into_iter()
338                .map(|(name, spec)| (name.to_ascii_uppercase(), spec.into_full_spec()))
339                .collect(),
340            flags: self
341                .flags
342                .into_iter()
343                .map(|flag| flag.to_ascii_uppercase())
344                .collect(),
345            layout: self.layout.map(LayoutOverridesOverride::into_full_layout),
346        }
347    }
348}
349
350impl KwargSpecOverride {
351    /// Convert a partial keyword override into a fully specified keyword spec.
352    pub fn into_full_spec(self) -> KwargSpec {
353        KwargSpec {
354            nargs: self.nargs.unwrap_or_default(),
355            kwargs: self
356                .kwargs
357                .into_iter()
358                .map(|(name, spec)| (name.to_ascii_uppercase(), spec.into_full_spec()))
359                .collect(),
360            flags: self
361                .flags
362                .into_iter()
363                .map(|flag| flag.to_ascii_uppercase())
364                .collect(),
365        }
366    }
367}
368
369impl LayoutOverridesOverride {
370    /// Convert a partial layout override into a fully specified layout block.
371    pub fn into_full_layout(self) -> LayoutOverrides {
372        LayoutOverrides {
373            line_width: self.line_width,
374            tab_size: self.tab_size,
375            dangle_parens: self.dangle_parens,
376            always_wrap: self.always_wrap,
377            max_pargs_hwrap: self.max_pargs_hwrap,
378        }
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn nargs_serialize_round_trip() {
388        let values = [
389            NArgs::Fixed(3),
390            NArgs::ZeroOrMore,
391            NArgs::OneOrMore,
392            NArgs::Optional,
393            NArgs::AtLeast(2),
394        ];
395        for value in values {
396            let encoded = serde_json::to_string(&value).unwrap();
397            let decoded: NArgs = serde_json::from_str(&encoded).unwrap();
398            assert_eq!(decoded, value);
399        }
400    }
401
402    #[test]
403    fn nargs_invalid_pattern_is_rejected() {
404        let err = toml::from_str::<KwargSpec>("nargs = \"abc+\"\n").unwrap_err();
405        assert!(err.to_string().contains("invalid NArgs pattern"));
406    }
407
408    #[test]
409    fn nargs_integer() {
410        let src = "nargs = 1\n";
411        let spec: KwargSpec = toml::from_str(src).unwrap();
412        assert_eq!(spec.nargs, NArgs::Fixed(1));
413    }
414
415    #[test]
416    fn nargs_zero_or_more() {
417        let src = "nargs = \"*\"\n";
418        let spec: KwargSpec = toml::from_str(src).unwrap();
419        assert_eq!(spec.nargs, NArgs::ZeroOrMore);
420    }
421
422    #[test]
423    fn nargs_one_or_more() {
424        let src = "nargs = \"+\"\n";
425        let spec: KwargSpec = toml::from_str(src).unwrap();
426        assert_eq!(spec.nargs, NArgs::OneOrMore);
427    }
428
429    #[test]
430    fn nargs_optional() {
431        let src = "nargs = \"?\"\n";
432        let spec: KwargSpec = toml::from_str(src).unwrap();
433        assert_eq!(spec.nargs, NArgs::Optional);
434    }
435
436    #[test]
437    fn nargs_at_least() {
438        let src = "nargs = \"2+\"\n";
439        let spec: KwargSpec = toml::from_str(src).unwrap();
440        assert_eq!(spec.nargs, NArgs::AtLeast(2));
441    }
442
443    #[test]
444    fn single_command_form() {
445        let src = r#"
446pargs = 1
447flags = ["REQUIRED"]
448
449[kwargs.COMPONENTS]
450nargs = "+"
451"#;
452        let form: CommandForm = toml::from_str(src).unwrap();
453        assert_eq!(form.pargs, NArgs::Fixed(1));
454        assert!(form.flags.contains("REQUIRED"));
455        assert!(form.kwargs.contains_key("COMPONENTS"));
456    }
457
458    #[test]
459    fn discriminated_command() {
460        let src = r#"
461[forms.TARGETS]
462pargs = "+"
463
464[forms.TARGETS.kwargs.DESTINATION]
465nargs = 1
466
467[forms.FILES]
468pargs = "+"
469"#;
470        let spec: CommandSpec = toml::from_str(src).unwrap();
471        assert!(matches!(spec, CommandSpec::Discriminated { .. }));
472        let form = spec.form_for(Some("targets"));
473        assert!(form.kwargs.contains_key("DESTINATION"));
474    }
475
476    #[test]
477    fn discriminated_command_uses_fallback_when_no_key_matches() {
478        let src = r#"
479[forms.FILE]
480pargs = 1
481
482[fallback]
483pargs = 2
484"#;
485        let spec: CommandSpec = toml::from_str(src).unwrap();
486        let form = spec.form_for(Some("unknown"));
487        assert_eq!(form.pargs, NArgs::Fixed(2));
488    }
489
490    #[test]
491    fn command_spec_override_into_full_spec_normalizes_casing() {
492        let override_spec = CommandSpecOverride::Single(CommandFormOverride {
493            pargs: Some(NArgs::Fixed(1)),
494            flags: ["quiet".to_owned()].into_iter().collect(),
495            kwargs: [(
496                "sources".to_owned(),
497                KwargSpecOverride {
498                    nargs: Some(NArgs::OneOrMore),
499                    ..KwargSpecOverride::default()
500                },
501            )]
502            .into_iter()
503            .collect(),
504            layout: Some(LayoutOverridesOverride {
505                always_wrap: Some(true),
506                ..LayoutOverridesOverride::default()
507            }),
508        });
509
510        let full = override_spec.into_full_spec();
511        let form = full.form_for(None);
512        assert!(form.flags.contains("QUIET"));
513        assert!(form.kwargs.contains_key("SOURCES"));
514        assert_eq!(form.kwargs["SOURCES"].nargs, NArgs::OneOrMore);
515        assert_eq!(form.layout.as_ref().unwrap().always_wrap, Some(true));
516    }
517
518    #[test]
519    fn partial_override_round_trips() {
520        let src = r#"
521layout.always_wrap = true
522
523[kwargs.COMPONENTS]
524nargs = "+"
525"#;
526        let override_form: CommandFormOverride = toml::from_str(src).unwrap();
527        assert_eq!(override_form.layout.unwrap().always_wrap, Some(true));
528        assert_eq!(
529            override_form.kwargs["COMPONENTS"].nargs,
530            Some(NArgs::OneOrMore)
531        );
532    }
533}