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