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