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    /// When `true`, the autosort heuristic must never reorder
164    /// arguments in this section. Use for kwargs whose value list has
165    /// positional semantics that flat sorting would corrupt — e.g.
166    /// `PROPERTY <name> <values…>` in `set_property` or the
167    /// `<name> <value>` pair structure under `PROPERTIES`. The spec's
168    /// `sortable: true` setting still wins over this — if a section is
169    /// explicitly marked sortable, that's a deliberate opt-in.
170    #[serde(default)]
171    pub no_autosort: bool,
172}
173
174/// One fully resolved command form.
175#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
176#[serde(deny_unknown_fields)]
177pub struct CommandForm {
178    /// Number of positional arguments before keyword/flag processing starts.
179    #[serde(default)]
180    pub pargs: NArgs,
181    /// Recognized top-level keywords for this form.
182    #[serde(default)]
183    pub kwargs: IndexMap<String, KwargSpec>,
184    /// Recognized top-level flags for this form.
185    #[serde(default)]
186    pub flags: IndexSet<String>,
187    /// Optional per-form layout hints. `None` means "inherit every
188    /// layout decision from the global [`crate::Config`]"; `Some`
189    /// overrides only the fields that are set, with unset fields
190    /// still falling back to the global config.
191    #[serde(default)]
192    pub layout: Option<LayoutOverrides>,
193}
194
195impl Default for CommandForm {
196    fn default() -> Self {
197        Self {
198            pargs: NArgs::ZeroOrMore,
199            kwargs: IndexMap::new(),
200            flags: IndexSet::new(),
201            layout: None,
202        }
203    }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
207#[serde(untagged)]
208pub enum CommandSpec {
209    /// A command whose structure depends on a discriminator token,
210    /// usually the first positional argument. `file(...)`,
211    /// `install(...)`, and `export(...)` are canonical examples —
212    /// their argument shape differs entirely based on the first
213    /// token (`TARGETS`, `FILES`, `DIRECTORY`, …).
214    Discriminated {
215        /// Known forms keyed by normalized discriminator token.
216        forms: IndexMap<String, CommandForm>,
217        /// Fallback form to use when no discriminator matches.
218        #[serde(default)]
219        fallback: Option<CommandForm>,
220    },
221    /// A command with a single argument structure. Most CMake
222    /// commands fall here — `target_link_libraries`, `project`,
223    /// `cmake_minimum_required`, user-defined commands, etc.
224    Single(CommandForm),
225}
226
227impl CommandSpec {
228    /// Resolve the command form for a specific invocation.
229    ///
230    /// `first_arg` is typically the first non-comment argument in the call and
231    /// is used for discriminated commands such as `file(...)` or `install(...)`.
232    pub fn form_for(&self, first_arg: Option<&str>) -> &CommandForm {
233        match self {
234            CommandSpec::Single(form) => form,
235            CommandSpec::Discriminated { forms, fallback } => {
236                let key = first_arg.unwrap_or_default();
237                forms
238                    .get(key)
239                    .or_else(|| {
240                        has_ascii_lowercase(key)
241                            .then(|| key.to_ascii_uppercase())
242                            .and_then(|normalized| forms.get(&normalized))
243                    })
244                    .or(fallback.as_ref())
245                    .or_else(|| forms.values().next())
246                    // Last-resort default for the ill-formed case where a
247                    // user-supplied override declares a `Discriminated`
248                    // spec with an empty `forms` map and no `fallback`.
249                    // Previously this branch panicked via `.expect()`,
250                    // making malformed override files crash the
251                    // formatter rather than degrade gracefully.
252                    .unwrap_or_else(|| empty_command_form())
253            }
254        }
255    }
256}
257
258fn empty_command_form() -> &'static CommandForm {
259    static EMPTY: std::sync::OnceLock<CommandForm> = std::sync::OnceLock::new();
260    EMPTY.get_or_init(CommandForm::default)
261}
262
263pub(crate) fn has_ascii_lowercase(s: &str) -> bool {
264    s.bytes().any(|byte| byte.is_ascii_lowercase())
265}
266
267pub(crate) fn has_ascii_uppercase(s: &str) -> bool {
268    s.bytes().any(|byte| byte.is_ascii_uppercase())
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
272pub(crate) struct SpecMetadata {
273    /// Upstream CMake version the built-in spec was last audited against.
274    #[serde(default)]
275    pub cmake_version: String,
276    /// Date of the most recent audit.
277    #[serde(default)]
278    pub audited_at: String,
279    /// Free-form notes about the current audit state.
280    #[serde(default)]
281    pub notes: String,
282}
283
284/// Top-level spec file containing metadata plus command entries.
285#[derive(Debug, Default, Deserialize, Serialize)]
286pub(crate) struct SpecFile {
287    /// Version and audit metadata for the built-in spec surface.
288    #[serde(default)]
289    pub metadata: SpecMetadata,
290    /// Built-in command specifications keyed by command name.
291    #[serde(default)]
292    pub commands: IndexMap<String, CommandSpec>,
293}
294
295// ── Mergeable override model ─────────────────────────────────────────────────
296
297#[derive(Debug, Clone, Default, Deserialize, Serialize)]
298#[serde(deny_unknown_fields)]
299pub(crate) struct LayoutOverridesOverride {
300    /// Override line width for this command form.
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub line_width: Option<usize>,
303    /// Override indentation width for this command form.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub tab_size: Option<usize>,
306    /// Override dangling-paren behavior for this command form.
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub dangle_parens: Option<bool>,
309    /// Force this command form into a wrapped layout.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub always_wrap: Option<bool>,
312    /// Override the positional-argument hanging-wrap threshold for this form.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub max_pargs_hwrap: Option<usize>,
315    /// Keep the first positional argument on the command line when wrapping.
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub wrap_after_first_arg: Option<bool>,
318    /// Override continuation-alignment behaviour for this command form.
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub continuation_align: Option<crate::config::ContinuationAlign>,
321}
322
323/// Partial override for a keyword specification.
324#[derive(Debug, Clone, Default, Deserialize, Serialize)]
325#[serde(deny_unknown_fields)]
326pub(crate) struct KwargSpecOverride {
327    /// Override the number of positional arguments accepted after the keyword.
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub nargs: Option<NArgs>,
330    /// Nested keyword overrides.
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    /// Mark this keyword section as sortable.
339    #[serde(default)]
340    pub sortable: bool,
341    /// Mark this keyword section as exempt from autosort.
342    #[serde(default)]
343    pub no_autosort: bool,
344}
345
346/// Partial override for a command form.
347#[derive(Debug, Clone, Default, Deserialize, Serialize)]
348#[serde(deny_unknown_fields)]
349pub(crate) struct CommandFormOverride {
350    /// Override the positional argument count for the form.
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub pargs: Option<NArgs>,
353    /// Keyword overrides to merge into the form.
354    #[serde(default)]
355    #[serde(skip_serializing_if = "IndexMap::is_empty")]
356    pub kwargs: IndexMap<String, KwargSpecOverride>,
357    /// Additional supported flags.
358    #[serde(default)]
359    #[serde(skip_serializing_if = "IndexSet::is_empty")]
360    pub flags: IndexSet<String>,
361    /// Optional layout overrides for the form.
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub layout: Option<LayoutOverridesOverride>,
364}
365
366/// Partial override for a full command spec.
367#[derive(Debug, Clone, Deserialize, Serialize)]
368#[serde(untagged)]
369pub(crate) enum CommandSpecOverride {
370    /// Override a single-form command.
371    Single(CommandFormOverride),
372    /// Override one or more discriminated forms.
373    Discriminated {
374        /// Per-discriminator form overrides.
375        #[serde(default)]
376        #[serde(skip_serializing_if = "IndexMap::is_empty")]
377        forms: IndexMap<String, CommandFormOverride>,
378        /// Optional fallback form override.
379        #[serde(default)]
380        #[serde(skip_serializing_if = "Option::is_none")]
381        fallback: Option<CommandFormOverride>,
382    },
383}
384
385/// Top-level user override file containing command overrides only.
386#[derive(Debug, Default, Deserialize, Serialize)]
387pub(crate) struct SpecOverrideFile {
388    /// Override specs keyed by command name.
389    #[serde(default)]
390    pub commands: IndexMap<String, CommandSpecOverride>,
391}
392
393impl CommandSpecOverride {
394    /// Convert a partial override into a fully specified standalone command
395    /// spec.
396    pub(crate) fn into_full_spec(self) -> CommandSpec {
397        match self {
398            CommandSpecOverride::Single(form) => CommandSpec::Single(form.into_full_form()),
399            CommandSpecOverride::Discriminated { forms, fallback } => CommandSpec::Discriminated {
400                forms: forms
401                    .into_iter()
402                    .map(|(name, form)| (name.to_ascii_uppercase(), form.into_full_form()))
403                    .collect(),
404                fallback: fallback.map(CommandFormOverride::into_full_form),
405            },
406        }
407    }
408}
409
410impl CommandFormOverride {
411    /// Convert a partial command form override into a fully specified form.
412    pub(crate) fn into_full_form(self) -> CommandForm {
413        CommandForm {
414            pargs: self.pargs.unwrap_or_default(),
415            kwargs: self
416                .kwargs
417                .into_iter()
418                .map(|(name, spec)| (name.to_ascii_uppercase(), spec.into_full_spec()))
419                .collect(),
420            flags: self
421                .flags
422                .into_iter()
423                .map(|flag| flag.to_ascii_uppercase())
424                .collect(),
425            layout: self.layout.map(LayoutOverridesOverride::into_full_layout),
426        }
427    }
428}
429
430impl KwargSpecOverride {
431    /// Convert a partial keyword override into a fully specified keyword spec.
432    pub(crate) fn into_full_spec(self) -> KwargSpec {
433        KwargSpec {
434            nargs: self.nargs.unwrap_or_default(),
435            kwargs: self
436                .kwargs
437                .into_iter()
438                .map(|(name, spec)| (name.to_ascii_uppercase(), spec.into_full_spec()))
439                .collect(),
440            flags: self
441                .flags
442                .into_iter()
443                .map(|flag| flag.to_ascii_uppercase())
444                .collect(),
445            sortable: self.sortable,
446            no_autosort: self.no_autosort,
447        }
448    }
449}
450
451impl LayoutOverridesOverride {
452    /// Convert a partial layout override into a fully specified layout block.
453    pub(crate) fn into_full_layout(self) -> LayoutOverrides {
454        LayoutOverrides {
455            line_width: self.line_width,
456            tab_size: self.tab_size,
457            dangle_parens: self.dangle_parens,
458            always_wrap: self.always_wrap,
459            max_pargs_hwrap: self.max_pargs_hwrap,
460            wrap_after_first_arg: self.wrap_after_first_arg,
461            continuation_align: self.continuation_align,
462        }
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    #[test]
471    fn nargs_serialize_round_trip() {
472        let values = [
473            NArgs::Fixed(3),
474            NArgs::ZeroOrMore,
475            NArgs::OneOrMore,
476            NArgs::Optional,
477            NArgs::AtLeast(2),
478        ];
479        for value in values {
480            let encoded = serde_json::to_string(&value).unwrap();
481            let decoded: NArgs = serde_json::from_str(&encoded).unwrap();
482            assert_eq!(decoded, value);
483        }
484    }
485
486    #[test]
487    fn nargs_invalid_pattern_is_rejected() {
488        let err = toml::from_str::<KwargSpec>("nargs = \"abc+\"\n").unwrap_err();
489        assert!(err.to_string().contains("invalid NArgs pattern"));
490    }
491
492    #[test]
493    fn nargs_integer() {
494        let src = "nargs = 1\n";
495        let spec: KwargSpec = toml::from_str(src).unwrap();
496        assert_eq!(spec.nargs, NArgs::Fixed(1));
497    }
498
499    #[test]
500    fn nargs_zero_or_more() {
501        let src = "nargs = \"*\"\n";
502        let spec: KwargSpec = toml::from_str(src).unwrap();
503        assert_eq!(spec.nargs, NArgs::ZeroOrMore);
504    }
505
506    #[test]
507    fn nargs_one_or_more() {
508        let src = "nargs = \"+\"\n";
509        let spec: KwargSpec = toml::from_str(src).unwrap();
510        assert_eq!(spec.nargs, NArgs::OneOrMore);
511    }
512
513    #[test]
514    fn nargs_optional() {
515        let src = "nargs = \"?\"\n";
516        let spec: KwargSpec = toml::from_str(src).unwrap();
517        assert_eq!(spec.nargs, NArgs::Optional);
518    }
519
520    #[test]
521    fn nargs_at_least() {
522        let src = "nargs = \"2+\"\n";
523        let spec: KwargSpec = toml::from_str(src).unwrap();
524        assert_eq!(spec.nargs, NArgs::AtLeast(2));
525    }
526
527    #[test]
528    fn single_command_form() {
529        let src = r#"
530pargs = 1
531flags = ["REQUIRED"]
532
533[kwargs.COMPONENTS]
534nargs = "+"
535"#;
536        let form: CommandForm = toml::from_str(src).unwrap();
537        assert_eq!(form.pargs, NArgs::Fixed(1));
538        assert!(form.flags.contains("REQUIRED"));
539        assert!(form.kwargs.contains_key("COMPONENTS"));
540    }
541
542    #[test]
543    fn discriminated_command() {
544        let src = r#"
545[forms.TARGETS]
546pargs = "+"
547
548[forms.TARGETS.kwargs.DESTINATION]
549nargs = 1
550
551[forms.FILES]
552pargs = "+"
553"#;
554        let spec: CommandSpec = toml::from_str(src).unwrap();
555        assert!(matches!(spec, CommandSpec::Discriminated { .. }));
556        let form = spec.form_for(Some("targets"));
557        assert!(form.kwargs.contains_key("DESTINATION"));
558    }
559
560    #[test]
561    fn discriminated_command_uses_fallback_when_no_key_matches() {
562        let src = r#"
563[forms.FILE]
564pargs = 1
565
566[fallback]
567pargs = 2
568"#;
569        let spec: CommandSpec = toml::from_str(src).unwrap();
570        let form = spec.form_for(Some("unknown"));
571        assert_eq!(form.pargs, NArgs::Fixed(2));
572    }
573
574    #[test]
575    fn command_spec_override_into_full_spec_normalizes_casing() {
576        let override_spec = CommandSpecOverride::Single(CommandFormOverride {
577            pargs: Some(NArgs::Fixed(1)),
578            flags: ["quiet".to_owned()].into_iter().collect(),
579            kwargs: [(
580                "sources".to_owned(),
581                KwargSpecOverride {
582                    nargs: Some(NArgs::OneOrMore),
583                    ..KwargSpecOverride::default()
584                },
585            )]
586            .into_iter()
587            .collect(),
588            layout: Some(LayoutOverridesOverride {
589                always_wrap: Some(true),
590                ..LayoutOverridesOverride::default()
591            }),
592        });
593
594        let full = override_spec.into_full_spec();
595        let form = full.form_for(None);
596        assert!(form.flags.contains("QUIET"));
597        assert!(form.kwargs.contains_key("SOURCES"));
598        assert_eq!(form.kwargs["SOURCES"].nargs, NArgs::OneOrMore);
599        assert_eq!(form.layout.as_ref().unwrap().always_wrap, Some(true));
600    }
601
602    #[test]
603    fn partial_override_round_trips() {
604        let src = r#"
605layout.always_wrap = true
606
607[kwargs.COMPONENTS]
608nargs = "+"
609"#;
610        let override_form: CommandFormOverride = toml::from_str(src).unwrap();
611        assert_eq!(override_form.layout.unwrap().always_wrap, Some(true));
612        assert_eq!(
613            override_form.kwargs["COMPONENTS"].nargs,
614            Some(NArgs::OneOrMore)
615        );
616    }
617}