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                    .or_else(|| forms.values().next())
237                    // Last-resort default for the ill-formed case where a
238                    // user-supplied override declares a `Discriminated`
239                    // spec with an empty `forms` map and no `fallback`.
240                    // Previously this branch panicked via `.expect()`,
241                    // making malformed override files crash the
242                    // formatter rather than degrade gracefully.
243                    .unwrap_or_else(|| empty_command_form())
244            }
245        }
246    }
247}
248
249fn empty_command_form() -> &'static CommandForm {
250    static EMPTY: std::sync::OnceLock<CommandForm> = std::sync::OnceLock::new();
251    EMPTY.get_or_init(CommandForm::default)
252}
253
254pub(crate) fn has_ascii_lowercase(s: &str) -> bool {
255    s.bytes().any(|byte| byte.is_ascii_lowercase())
256}
257
258pub(crate) fn has_ascii_uppercase(s: &str) -> bool {
259    s.bytes().any(|byte| byte.is_ascii_uppercase())
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
263pub(crate) struct SpecMetadata {
264    /// Upstream CMake version the built-in spec was last audited against.
265    #[serde(default)]
266    pub cmake_version: String,
267    /// Date of the most recent audit.
268    #[serde(default)]
269    pub audited_at: String,
270    /// Free-form notes about the current audit state.
271    #[serde(default)]
272    pub notes: String,
273}
274
275/// Top-level spec file containing metadata plus command entries.
276#[derive(Debug, Default, Deserialize, Serialize)]
277pub(crate) struct SpecFile {
278    /// Version and audit metadata for the built-in spec surface.
279    #[serde(default)]
280    pub metadata: SpecMetadata,
281    /// Built-in command specifications keyed by command name.
282    #[serde(default)]
283    pub commands: IndexMap<String, CommandSpec>,
284}
285
286// ── Mergeable override model ─────────────────────────────────────────────────
287
288#[derive(Debug, Clone, Default, Deserialize, Serialize)]
289#[serde(deny_unknown_fields)]
290pub(crate) struct LayoutOverridesOverride {
291    /// Override line width for this command form.
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub line_width: Option<usize>,
294    /// Override indentation width for this command form.
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub tab_size: Option<usize>,
297    /// Override dangling-paren behavior for this command form.
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub dangle_parens: Option<bool>,
300    /// Force this command form into a wrapped layout.
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub always_wrap: Option<bool>,
303    /// Override the positional-argument hanging-wrap threshold for this form.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub max_pargs_hwrap: Option<usize>,
306    /// Keep the first positional argument on the command line when wrapping.
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub wrap_after_first_arg: Option<bool>,
309    /// Override continuation-alignment behaviour for this command form.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub continuation_align: Option<crate::config::ContinuationAlign>,
312}
313
314/// Partial override for a keyword specification.
315#[derive(Debug, Clone, Default, Deserialize, Serialize)]
316#[serde(deny_unknown_fields)]
317pub(crate) struct KwargSpecOverride {
318    /// Override the number of positional arguments accepted after the keyword.
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub nargs: Option<NArgs>,
321    /// Nested keyword overrides.
322    #[serde(default)]
323    #[serde(skip_serializing_if = "IndexMap::is_empty")]
324    pub kwargs: IndexMap<String, KwargSpecOverride>,
325    /// Additional supported flags.
326    #[serde(default)]
327    #[serde(skip_serializing_if = "IndexSet::is_empty")]
328    pub flags: IndexSet<String>,
329    /// Mark this keyword section as sortable.
330    #[serde(default)]
331    pub sortable: bool,
332}
333
334/// Partial override for a command form.
335#[derive(Debug, Clone, Default, Deserialize, Serialize)]
336#[serde(deny_unknown_fields)]
337pub(crate) struct CommandFormOverride {
338    /// Override the positional argument count for the form.
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub pargs: Option<NArgs>,
341    /// Keyword overrides to merge into the form.
342    #[serde(default)]
343    #[serde(skip_serializing_if = "IndexMap::is_empty")]
344    pub kwargs: IndexMap<String, KwargSpecOverride>,
345    /// Additional supported flags.
346    #[serde(default)]
347    #[serde(skip_serializing_if = "IndexSet::is_empty")]
348    pub flags: IndexSet<String>,
349    /// Optional layout overrides for the form.
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub layout: Option<LayoutOverridesOverride>,
352}
353
354/// Partial override for a full command spec.
355#[derive(Debug, Clone, Deserialize, Serialize)]
356#[serde(untagged)]
357pub(crate) enum CommandSpecOverride {
358    /// Override a single-form command.
359    Single(CommandFormOverride),
360    /// Override one or more discriminated forms.
361    Discriminated {
362        /// Per-discriminator form overrides.
363        #[serde(default)]
364        #[serde(skip_serializing_if = "IndexMap::is_empty")]
365        forms: IndexMap<String, CommandFormOverride>,
366        /// Optional fallback form override.
367        #[serde(default)]
368        #[serde(skip_serializing_if = "Option::is_none")]
369        fallback: Option<CommandFormOverride>,
370    },
371}
372
373/// Top-level user override file containing command overrides only.
374#[derive(Debug, Default, Deserialize, Serialize)]
375pub(crate) struct SpecOverrideFile {
376    /// Override specs keyed by command name.
377    #[serde(default)]
378    pub commands: IndexMap<String, CommandSpecOverride>,
379}
380
381impl CommandSpecOverride {
382    /// Convert a partial override into a fully specified standalone command
383    /// spec.
384    pub(crate) fn into_full_spec(self) -> CommandSpec {
385        match self {
386            CommandSpecOverride::Single(form) => CommandSpec::Single(form.into_full_form()),
387            CommandSpecOverride::Discriminated { forms, fallback } => CommandSpec::Discriminated {
388                forms: forms
389                    .into_iter()
390                    .map(|(name, form)| (name.to_ascii_uppercase(), form.into_full_form()))
391                    .collect(),
392                fallback: fallback.map(CommandFormOverride::into_full_form),
393            },
394        }
395    }
396}
397
398impl CommandFormOverride {
399    /// Convert a partial command form override into a fully specified form.
400    pub(crate) fn into_full_form(self) -> CommandForm {
401        CommandForm {
402            pargs: self.pargs.unwrap_or_default(),
403            kwargs: self
404                .kwargs
405                .into_iter()
406                .map(|(name, spec)| (name.to_ascii_uppercase(), spec.into_full_spec()))
407                .collect(),
408            flags: self
409                .flags
410                .into_iter()
411                .map(|flag| flag.to_ascii_uppercase())
412                .collect(),
413            layout: self.layout.map(LayoutOverridesOverride::into_full_layout),
414        }
415    }
416}
417
418impl KwargSpecOverride {
419    /// Convert a partial keyword override into a fully specified keyword spec.
420    pub(crate) fn into_full_spec(self) -> KwargSpec {
421        KwargSpec {
422            nargs: self.nargs.unwrap_or_default(),
423            kwargs: self
424                .kwargs
425                .into_iter()
426                .map(|(name, spec)| (name.to_ascii_uppercase(), spec.into_full_spec()))
427                .collect(),
428            flags: self
429                .flags
430                .into_iter()
431                .map(|flag| flag.to_ascii_uppercase())
432                .collect(),
433            sortable: self.sortable,
434        }
435    }
436}
437
438impl LayoutOverridesOverride {
439    /// Convert a partial layout override into a fully specified layout block.
440    pub(crate) fn into_full_layout(self) -> LayoutOverrides {
441        LayoutOverrides {
442            line_width: self.line_width,
443            tab_size: self.tab_size,
444            dangle_parens: self.dangle_parens,
445            always_wrap: self.always_wrap,
446            max_pargs_hwrap: self.max_pargs_hwrap,
447            wrap_after_first_arg: self.wrap_after_first_arg,
448            continuation_align: self.continuation_align,
449        }
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn nargs_serialize_round_trip() {
459        let values = [
460            NArgs::Fixed(3),
461            NArgs::ZeroOrMore,
462            NArgs::OneOrMore,
463            NArgs::Optional,
464            NArgs::AtLeast(2),
465        ];
466        for value in values {
467            let encoded = serde_json::to_string(&value).unwrap();
468            let decoded: NArgs = serde_json::from_str(&encoded).unwrap();
469            assert_eq!(decoded, value);
470        }
471    }
472
473    #[test]
474    fn nargs_invalid_pattern_is_rejected() {
475        let err = toml::from_str::<KwargSpec>("nargs = \"abc+\"\n").unwrap_err();
476        assert!(err.to_string().contains("invalid NArgs pattern"));
477    }
478
479    #[test]
480    fn nargs_integer() {
481        let src = "nargs = 1\n";
482        let spec: KwargSpec = toml::from_str(src).unwrap();
483        assert_eq!(spec.nargs, NArgs::Fixed(1));
484    }
485
486    #[test]
487    fn nargs_zero_or_more() {
488        let src = "nargs = \"*\"\n";
489        let spec: KwargSpec = toml::from_str(src).unwrap();
490        assert_eq!(spec.nargs, NArgs::ZeroOrMore);
491    }
492
493    #[test]
494    fn nargs_one_or_more() {
495        let src = "nargs = \"+\"\n";
496        let spec: KwargSpec = toml::from_str(src).unwrap();
497        assert_eq!(spec.nargs, NArgs::OneOrMore);
498    }
499
500    #[test]
501    fn nargs_optional() {
502        let src = "nargs = \"?\"\n";
503        let spec: KwargSpec = toml::from_str(src).unwrap();
504        assert_eq!(spec.nargs, NArgs::Optional);
505    }
506
507    #[test]
508    fn nargs_at_least() {
509        let src = "nargs = \"2+\"\n";
510        let spec: KwargSpec = toml::from_str(src).unwrap();
511        assert_eq!(spec.nargs, NArgs::AtLeast(2));
512    }
513
514    #[test]
515    fn single_command_form() {
516        let src = r#"
517pargs = 1
518flags = ["REQUIRED"]
519
520[kwargs.COMPONENTS]
521nargs = "+"
522"#;
523        let form: CommandForm = toml::from_str(src).unwrap();
524        assert_eq!(form.pargs, NArgs::Fixed(1));
525        assert!(form.flags.contains("REQUIRED"));
526        assert!(form.kwargs.contains_key("COMPONENTS"));
527    }
528
529    #[test]
530    fn discriminated_command() {
531        let src = r#"
532[forms.TARGETS]
533pargs = "+"
534
535[forms.TARGETS.kwargs.DESTINATION]
536nargs = 1
537
538[forms.FILES]
539pargs = "+"
540"#;
541        let spec: CommandSpec = toml::from_str(src).unwrap();
542        assert!(matches!(spec, CommandSpec::Discriminated { .. }));
543        let form = spec.form_for(Some("targets"));
544        assert!(form.kwargs.contains_key("DESTINATION"));
545    }
546
547    #[test]
548    fn discriminated_command_uses_fallback_when_no_key_matches() {
549        let src = r#"
550[forms.FILE]
551pargs = 1
552
553[fallback]
554pargs = 2
555"#;
556        let spec: CommandSpec = toml::from_str(src).unwrap();
557        let form = spec.form_for(Some("unknown"));
558        assert_eq!(form.pargs, NArgs::Fixed(2));
559    }
560
561    #[test]
562    fn command_spec_override_into_full_spec_normalizes_casing() {
563        let override_spec = CommandSpecOverride::Single(CommandFormOverride {
564            pargs: Some(NArgs::Fixed(1)),
565            flags: ["quiet".to_owned()].into_iter().collect(),
566            kwargs: [(
567                "sources".to_owned(),
568                KwargSpecOverride {
569                    nargs: Some(NArgs::OneOrMore),
570                    ..KwargSpecOverride::default()
571                },
572            )]
573            .into_iter()
574            .collect(),
575            layout: Some(LayoutOverridesOverride {
576                always_wrap: Some(true),
577                ..LayoutOverridesOverride::default()
578            }),
579        });
580
581        let full = override_spec.into_full_spec();
582        let form = full.form_for(None);
583        assert!(form.flags.contains("QUIET"));
584        assert!(form.kwargs.contains_key("SOURCES"));
585        assert_eq!(form.kwargs["SOURCES"].nargs, NArgs::OneOrMore);
586        assert_eq!(form.layout.as_ref().unwrap().always_wrap, Some(true));
587    }
588
589    #[test]
590    fn partial_override_round_trips() {
591        let src = r#"
592layout.always_wrap = true
593
594[kwargs.COMPONENTS]
595nargs = "+"
596"#;
597        let override_form: CommandFormOverride = toml::from_str(src).unwrap();
598        assert_eq!(override_form.layout.unwrap().always_wrap, Some(true));
599        assert_eq!(
600            override_form.kwargs["COMPONENTS"].nargs,
601            Some(NArgs::OneOrMore)
602        );
603    }
604}