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