Skip to main content

cmakefmt/config/
file.rs

1// SPDX-FileCopyrightText: Copyright 2026 Puneet Matharu
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Config-file loading and starter template generation.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::config::{
13    CaseStyle, Config, DangleAlign, FractionalTabPolicy, LineEnding, PerCommandConfig,
14};
15use crate::error::{Error, FileParseError, Result};
16
17/// The user-config file structure for `.cmakefmt.yaml`, `.cmakefmt.yml`, and
18/// `.cmakefmt.toml`.
19///
20/// All fields are optional — only specified values override the defaults.
21#[derive(Debug, Clone, Deserialize, Default)]
22#[serde(default)]
23struct FileConfig {
24    format: FormatSection,
25    style: StyleSection,
26    markup: MarkupSection,
27    #[serde(rename = "per_command_overrides")]
28    per_command_overrides: HashMap<String, PerCommandConfig>,
29    #[serde(rename = "per_command")]
30    legacy_per_command: HashMap<String, PerCommandConfig>,
31}
32
33#[derive(Debug, Clone, Deserialize, Default)]
34#[serde(default)]
35#[serde(deny_unknown_fields)]
36struct FormatSection {
37    disable: Option<bool>,
38    line_ending: Option<LineEnding>,
39    line_width: Option<usize>,
40    tab_size: Option<usize>,
41    use_tabs: Option<bool>,
42    fractional_tab_policy: Option<FractionalTabPolicy>,
43    max_empty_lines: Option<usize>,
44    max_hanging_wrap_lines: Option<usize>,
45    max_hanging_wrap_positional_args: Option<usize>,
46    max_hanging_wrap_groups: Option<usize>,
47    max_rows_cmdline: Option<usize>,
48    always_wrap: Option<Vec<String>>,
49    require_valid_layout: Option<bool>,
50    dangle_parens: Option<bool>,
51    dangle_align: Option<DangleAlign>,
52    min_prefix_length: Option<usize>,
53    max_prefix_length: Option<usize>,
54    space_before_control_paren: Option<bool>,
55    space_before_definition_paren: Option<bool>,
56}
57
58#[derive(Debug, Clone, Deserialize, Default)]
59#[serde(default)]
60#[serde(deny_unknown_fields)]
61struct StyleSection {
62    command_case: Option<CaseStyle>,
63    keyword_case: Option<CaseStyle>,
64}
65
66#[derive(Debug, Clone, Deserialize, Default)]
67#[serde(default)]
68#[serde(deny_unknown_fields)]
69struct MarkupSection {
70    enable_markup: Option<bool>,
71    reflow_comments: Option<bool>,
72    first_comment_is_literal: Option<bool>,
73    literal_comment_pattern: Option<String>,
74    bullet_char: Option<String>,
75    enum_char: Option<String>,
76    fence_pattern: Option<String>,
77    ruler_pattern: Option<String>,
78    hashruler_min_length: Option<usize>,
79    canonicalize_hashrulers: Option<bool>,
80    explicit_trailing_pattern: Option<String>,
81}
82
83const CONFIG_FILE_NAME_TOML: &str = ".cmakefmt.toml";
84const CONFIG_FILE_NAME_YAML: &str = ".cmakefmt.yaml";
85const CONFIG_FILE_NAME_YML: &str = ".cmakefmt.yml";
86const CONFIG_FILE_NAMES: &[&str] = &[
87    CONFIG_FILE_NAME_YAML,
88    CONFIG_FILE_NAME_YML,
89    CONFIG_FILE_NAME_TOML,
90];
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub(crate) enum ConfigFileFormat {
94    Toml,
95    Yaml,
96}
97
98impl ConfigFileFormat {
99    pub(crate) fn as_str(self) -> &'static str {
100        match self {
101            Self::Toml => "TOML",
102            Self::Yaml => "YAML",
103        }
104    }
105}
106
107/// Supported `--dump-config` output formats.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
109pub enum DumpConfigFormat {
110    /// Emit YAML.
111    Yaml,
112    /// Emit TOML.
113    Toml,
114}
115
116/// Render a commented starter config in the requested format.
117///
118/// The template is intentionally verbose: every option is introduced by a
119/// short explanatory comment so users can understand the surface without
120/// needing to cross-reference the docs immediately.
121pub fn default_config_template_for(format: DumpConfigFormat) -> String {
122    match format {
123        DumpConfigFormat::Yaml => default_config_template_yaml(),
124        DumpConfigFormat::Toml => default_config_template_toml(),
125    }
126}
127
128/// Render a resolved runtime config back into the user-facing config schema.
129///
130/// This is primarily used by CLI introspection commands such as
131/// `cmakefmt --show-config`.
132pub fn render_effective_config(config: &Config, format: DumpConfigFormat) -> Result<String> {
133    let view = EffectiveConfigFile::from(config);
134    match format {
135        DumpConfigFormat::Yaml => serde_yaml::to_string(&view).map_err(|err| {
136            Error::Formatter(format!("failed to render effective config as YAML: {err}"))
137        }),
138        DumpConfigFormat::Toml => toml::to_string_pretty(&view).map_err(|err| {
139            Error::Formatter(format!("failed to render effective config as TOML: {err}"))
140        }),
141    }
142}
143
144/// Render a commented starter config using the default user-facing dump format.
145pub fn default_config_template() -> String {
146    default_config_template_for(DumpConfigFormat::Yaml)
147}
148
149fn default_config_template_toml() -> String {
150    format!(
151        concat!(
152            "# Default cmakefmt configuration.\n",
153            "# Copy this to .cmakefmt.toml and uncomment the optional settings\n",
154            "# you want to customize.\n\n",
155            "[format]\n",
156            "# Disable formatting entirely (return source unchanged).\n",
157            "# disable = true\n\n",
158            "# Output line-ending style: unix (LF), windows (CRLF), or auto (detect from input).\n",
159            "# line_ending = \"windows\"\n\n",
160            "# Maximum rendered line width before cmakefmt wraps a call.\n",
161            "line_width = {line_width}\n\n",
162            "# Number of spaces per indentation level when use_tabs is false.\n",
163            "tab_size = {tab_size}\n\n",
164            "# Indent with tab characters instead of spaces.\n",
165            "# use_tabs = true\n\n",
166            "# How to handle fractional indentation when use_tabs is true: use-space or round-up.\n",
167            "# fractional_tab_policy = \"round-up\"\n\n",
168            "# Maximum number of consecutive blank lines to preserve.\n",
169            "max_empty_lines = {max_empty_lines}\n\n",
170            "# Maximum wrapped lines to tolerate before switching to a more vertical layout.\n",
171            "max_hanging_wrap_lines = {max_lines_hwrap}\n\n",
172            "# Maximum positional arguments to keep in a hanging-wrap layout.\n",
173            "max_hanging_wrap_positional_args = {max_pargs_hwrap}\n\n",
174            "# Maximum keyword/flag subgroups to keep in a hanging-wrap layout.\n",
175            "max_hanging_wrap_groups = {max_subgroups_hwrap}\n\n",
176            "# Maximum rows a hanging-wrap positional group may consume before nesting is forced.\n",
177            "max_rows_cmdline = {max_rows_cmdline}\n\n",
178            "# Commands that must always use vertical (wrapped) layout.\n",
179            "# always_wrap = [\"target_link_libraries\"]\n\n",
180            "# Return an error if any formatted line exceeds line_width.\n",
181            "# require_valid_layout = true\n\n",
182            "# Put the closing ')' on its own line when a call wraps.\n",
183            "dangle_parens = {dangle_parens}\n\n",
184            "# Alignment strategy for a dangling ')': prefix, open, or close.\n",
185            "dangle_align = \"{dangle_align}\"\n\n",
186            "# Lower heuristic bound used when deciding between compact and wrapped layouts.\n",
187            "min_prefix_length = {min_prefix_chars}\n\n",
188            "# Upper heuristic bound used when deciding between compact and wrapped layouts.\n",
189            "max_prefix_length = {max_prefix_chars}\n\n",
190            "# Insert a space before '(' for control-flow commands like if/foreach.\n",
191            "# space_before_control_paren = true\n\n",
192            "# Insert a space before '(' for function() and macro() definitions.\n",
193            "# space_before_definition_paren = true\n\n",
194            "[style]\n",
195            "# Output casing for command names: lower, upper, or unchanged.\n",
196            "command_case = \"{command_case}\"\n\n",
197            "# Output casing for recognized keywords and flags: lower, upper, or unchanged.\n",
198            "keyword_case = \"{keyword_case}\"\n\n",
199            "[markup]\n",
200            "# Enable markup-aware comment handling.\n",
201            "enable_markup = {enable_markup}\n\n",
202            "# Reflow plain line comments to fit within the configured line width.\n",
203            "# reflow_comments = true\n\n",
204            "# Preserve the first comment block in a file literally.\n",
205            "first_comment_is_literal = {first_comment_is_literal}\n\n",
206            "# Preserve comments matching a custom regex literally.\n",
207            "# literal_comment_pattern = \"^\\\\s*NOTE:\"\n\n",
208            "# Preferred bullet character when normalizing markup lists.\n",
209            "bullet_char = \"{bullet_char}\"\n\n",
210            "# Preferred punctuation for numbered lists when normalizing markup.\n",
211            "enum_char = \"{enum_char}\"\n\n",
212            "# Regex describing fenced literal comment blocks.\n",
213            "fence_pattern = '{fence_pattern}'\n\n",
214            "# Regex describing ruler-style comments that should be treated specially.\n",
215            "ruler_pattern = '{ruler_pattern}'\n\n",
216            "# Minimum ruler length before a hash-only line is treated as a ruler.\n",
217            "hashruler_min_length = {hashruler_min_length}\n\n",
218            "# Normalize ruler comments when markup handling is enabled.\n",
219            "canonicalize_hashrulers = {canonicalize_hashrulers}\n\n",
220            "# Regex pattern for inline comments that are explicitly trailing their preceding\n",
221            "# argument (rendered on the same line). Default: '#<'.\n",
222            "# explicit_trailing_pattern = \"#<\"\n\n",
223            "# Uncomment and edit a block like this to override formatting knobs\n",
224            "# for a specific command. This changes layout behavior for that\n",
225            "# command name only; it does not define new command syntax.\n",
226            "#\n",
227            "# [per_command_overrides.my_custom_command]\n",
228            "# Override the line width just for this command.\n",
229            "# line_width = 120\n\n",
230            "# Override command casing just for this command.\n",
231            "# command_case = \"unchanged\"\n\n",
232            "# Override keyword casing just for this command.\n",
233            "# keyword_case = \"upper\"\n\n",
234            "# Override indentation width just for this command.\n",
235            "# tab_size = 4\n\n",
236            "# Override dangling-paren placement just for this command.\n",
237            "# dangle_parens = false\n\n",
238            "# Override dangling-paren alignment just for this command.\n",
239            "# dangle_align = \"prefix\"\n\n",
240            "# Override the positional-argument hanging-wrap threshold just for this command.\n",
241            "# max_hanging_wrap_positional_args = 8\n\n",
242            "# Override the subgroup hanging-wrap threshold just for this command.\n",
243            "# max_hanging_wrap_groups = 3\n\n",
244            "# TOML custom-command specs live under [commands.<name>]. For\n",
245            "# user config, prefer YAML once these specs grow beyond a couple\n",
246            "# of simple flags/kwargs.\n",
247            "# Command specs tell the formatter which tokens are positional\n",
248            "# arguments, standalone flags, and keyword sections.\n",
249            "#\n",
250            "# Example: one positional argument, one flag, and two keyword\n",
251            "# sections that each take one or more values.\n",
252            "#\n",
253            "# [commands.my_custom_command]\n",
254            "# pargs = 1\n",
255            "# flags = [\"QUIET\"]\n",
256            "# kwargs = {{ SOURCES = {{ nargs = \"+\" }}, LIBRARIES = {{ nargs = \"+\" }} }}\n",
257        ),
258        line_width = Config::default().line_width,
259        tab_size = Config::default().tab_size,
260        max_empty_lines = Config::default().max_empty_lines,
261        max_lines_hwrap = Config::default().max_lines_hwrap,
262        max_pargs_hwrap = Config::default().max_pargs_hwrap,
263        max_subgroups_hwrap = Config::default().max_subgroups_hwrap,
264        max_rows_cmdline = Config::default().max_rows_cmdline,
265        dangle_parens = Config::default().dangle_parens,
266        dangle_align = "prefix",
267        min_prefix_chars = Config::default().min_prefix_chars,
268        max_prefix_chars = Config::default().max_prefix_chars,
269        command_case = "lower",
270        keyword_case = "upper",
271        enable_markup = Config::default().enable_markup,
272        first_comment_is_literal = Config::default().first_comment_is_literal,
273        bullet_char = Config::default().bullet_char,
274        enum_char = Config::default().enum_char,
275        fence_pattern = Config::default().fence_pattern,
276        ruler_pattern = Config::default().ruler_pattern,
277        hashruler_min_length = Config::default().hashruler_min_length,
278        canonicalize_hashrulers = Config::default().canonicalize_hashrulers,
279    )
280}
281
282#[derive(Debug, Clone, Serialize)]
283struct EffectiveConfigFile {
284    format: EffectiveFormatSection,
285    style: EffectiveStyleSection,
286    markup: EffectiveMarkupSection,
287    per_command_overrides: HashMap<String, PerCommandConfig>,
288}
289
290#[derive(Debug, Clone, Serialize)]
291struct EffectiveFormatSection {
292    #[serde(skip_serializing_if = "std::ops::Not::not")]
293    disable: bool,
294    line_ending: LineEnding,
295    line_width: usize,
296    tab_size: usize,
297    use_tabs: bool,
298    fractional_tab_policy: FractionalTabPolicy,
299    max_empty_lines: usize,
300    max_hanging_wrap_lines: usize,
301    max_hanging_wrap_positional_args: usize,
302    max_hanging_wrap_groups: usize,
303    max_rows_cmdline: usize,
304    #[serde(skip_serializing_if = "Vec::is_empty")]
305    always_wrap: Vec<String>,
306    #[serde(skip_serializing_if = "std::ops::Not::not")]
307    require_valid_layout: bool,
308    dangle_parens: bool,
309    dangle_align: DangleAlign,
310    min_prefix_length: usize,
311    max_prefix_length: usize,
312    space_before_control_paren: bool,
313    space_before_definition_paren: bool,
314}
315
316#[derive(Debug, Clone, Serialize)]
317struct EffectiveStyleSection {
318    command_case: CaseStyle,
319    keyword_case: CaseStyle,
320}
321
322#[derive(Debug, Clone, Serialize)]
323struct EffectiveMarkupSection {
324    enable_markup: bool,
325    reflow_comments: bool,
326    first_comment_is_literal: bool,
327    literal_comment_pattern: String,
328    bullet_char: String,
329    enum_char: String,
330    fence_pattern: String,
331    ruler_pattern: String,
332    hashruler_min_length: usize,
333    canonicalize_hashrulers: bool,
334    explicit_trailing_pattern: String,
335}
336
337impl From<&Config> for EffectiveConfigFile {
338    fn from(config: &Config) -> Self {
339        Self {
340            format: EffectiveFormatSection {
341                disable: config.disable,
342                line_ending: config.line_ending,
343                line_width: config.line_width,
344                tab_size: config.tab_size,
345                use_tabs: config.use_tabchars,
346                fractional_tab_policy: config.fractional_tab_policy,
347                max_empty_lines: config.max_empty_lines,
348                max_hanging_wrap_lines: config.max_lines_hwrap,
349                max_hanging_wrap_positional_args: config.max_pargs_hwrap,
350                max_hanging_wrap_groups: config.max_subgroups_hwrap,
351                max_rows_cmdline: config.max_rows_cmdline,
352                always_wrap: config.always_wrap.clone(),
353                require_valid_layout: config.require_valid_layout,
354                dangle_parens: config.dangle_parens,
355                dangle_align: config.dangle_align,
356                min_prefix_length: config.min_prefix_chars,
357                max_prefix_length: config.max_prefix_chars,
358                space_before_control_paren: config.separate_ctrl_name_with_space,
359                space_before_definition_paren: config.separate_fn_name_with_space,
360            },
361            style: EffectiveStyleSection {
362                command_case: config.command_case,
363                keyword_case: config.keyword_case,
364            },
365            markup: EffectiveMarkupSection {
366                enable_markup: config.enable_markup,
367                reflow_comments: config.reflow_comments,
368                first_comment_is_literal: config.first_comment_is_literal,
369                literal_comment_pattern: config.literal_comment_pattern.clone(),
370                bullet_char: config.bullet_char.clone(),
371                enum_char: config.enum_char.clone(),
372                fence_pattern: config.fence_pattern.clone(),
373                ruler_pattern: config.ruler_pattern.clone(),
374                hashruler_min_length: config.hashruler_min_length,
375                canonicalize_hashrulers: config.canonicalize_hashrulers,
376                explicit_trailing_pattern: config.explicit_trailing_pattern.clone(),
377            },
378            per_command_overrides: config.per_command_overrides.clone(),
379        }
380    }
381}
382
383fn default_config_template_yaml() -> String {
384    format!(
385        concat!(
386            "# Default cmakefmt configuration.\n",
387            "# Copy this to .cmakefmt.yaml and uncomment the optional settings\n",
388            "# you want to customize.\n\n",
389            "format:\n",
390            "  # Disable formatting entirely (return source unchanged).\n",
391            "  # disable: true\n\n",
392            "  # Output line-ending style: unix (LF), windows (CRLF), or auto (detect from input).\n",
393            "  # line_ending: windows\n\n",
394            "  # Maximum rendered line width before cmakefmt wraps a call.\n",
395            "  line_width: {line_width}\n\n",
396            "  # Number of spaces per indentation level when use_tabs is false.\n",
397            "  tab_size: {tab_size}\n\n",
398            "  # Indent with tab characters instead of spaces.\n",
399            "  # use_tabs: true\n\n",
400            "  # How to handle fractional indentation when use_tabs is true: use-space or round-up.\n",
401            "  # fractional_tab_policy: round-up\n\n",
402            "  # Maximum number of consecutive blank lines to preserve.\n",
403            "  max_empty_lines: {max_empty_lines}\n\n",
404            "  # Maximum wrapped lines to tolerate before switching to a more vertical layout.\n",
405            "  max_hanging_wrap_lines: {max_lines_hwrap}\n\n",
406            "  # Maximum positional arguments to keep in a hanging-wrap layout.\n",
407            "  max_hanging_wrap_positional_args: {max_pargs_hwrap}\n\n",
408            "  # Maximum keyword/flag subgroups to keep in a hanging-wrap layout.\n",
409            "  max_hanging_wrap_groups: {max_subgroups_hwrap}\n\n",
410            "  # Maximum rows a hanging-wrap positional group may consume before nesting is forced.\n",
411            "  max_rows_cmdline: {max_rows_cmdline}\n\n",
412            "  # Commands that must always use vertical (wrapped) layout.\n",
413            "  # always_wrap:\n",
414            "  #   - target_link_libraries\n\n",
415            "  # Return an error if any formatted line exceeds line_width.\n",
416            "  # require_valid_layout: true\n\n",
417            "  # Put the closing ')' on its own line when a call wraps.\n",
418            "  dangle_parens: {dangle_parens}\n\n",
419            "  # Alignment strategy for a dangling ')': prefix, open, or close.\n",
420            "  dangle_align: {dangle_align}\n\n",
421            "  # Lower heuristic bound used when deciding between compact and wrapped layouts.\n",
422            "  min_prefix_length: {min_prefix_chars}\n\n",
423            "  # Upper heuristic bound used when deciding between compact and wrapped layouts.\n",
424            "  max_prefix_length: {max_prefix_chars}\n\n",
425            "  # Insert a space before '(' for control-flow commands like if/foreach.\n",
426            "  # space_before_control_paren: true\n\n",
427            "  # Insert a space before '(' for function() and macro() definitions.\n",
428            "  # space_before_definition_paren: true\n\n",
429            "style:\n",
430            "  # Output casing for command names: lower, upper, or unchanged.\n",
431            "  command_case: {command_case}\n\n",
432            "  # Output casing for recognized keywords and flags: lower, upper, or unchanged.\n",
433            "  keyword_case: {keyword_case}\n\n",
434            "markup:\n",
435            "  # Enable markup-aware comment handling.\n",
436            "  enable_markup: {enable_markup}\n\n",
437            "  # Reflow plain line comments to fit within the configured line width.\n",
438            "  # reflow_comments: true\n\n",
439            "  # Preserve the first comment block in a file literally.\n",
440            "  first_comment_is_literal: {first_comment_is_literal}\n\n",
441            "  # Preserve comments matching a custom regex literally.\n",
442            "  # literal_comment_pattern: '^\\s*NOTE:'\n\n",
443            "  # Preferred bullet character when normalizing markup lists.\n",
444            "  bullet_char: '{bullet_char}'\n\n",
445            "  # Preferred punctuation for numbered lists when normalizing markup.\n",
446            "  enum_char: '{enum_char}'\n\n",
447            "  # Regex describing fenced literal comment blocks.\n",
448            "  fence_pattern: '{fence_pattern}'\n\n",
449            "  # Regex describing ruler-style comments that should be treated specially.\n",
450            "  ruler_pattern: '{ruler_pattern}'\n\n",
451            "  # Minimum ruler length before a hash-only line is treated as a ruler.\n",
452            "  hashruler_min_length: {hashruler_min_length}\n\n",
453            "  # Normalize ruler comments when markup handling is enabled.\n",
454            "  canonicalize_hashrulers: {canonicalize_hashrulers}\n\n",
455            "  # Regex pattern for inline comments that are explicitly trailing their preceding\n",
456            "  # argument (rendered on the same line). Default: '#<'.\n",
457            "  # explicit_trailing_pattern: '#<'\n\n",
458            "# Uncomment and edit a block like this to override formatting knobs\n",
459            "# for a specific command. This changes layout behavior for that\n",
460            "# command name only; it does not define new command syntax.\n",
461            "#\n",
462            "# per_command_overrides:\n",
463            "#   my_custom_command:\n",
464            "#     # Override the line width just for this command.\n",
465            "#     line_width: 120\n",
466            "#\n",
467            "#     # Override command casing just for this command.\n",
468            "#     command_case: unchanged\n",
469            "#\n",
470            "#     # Override keyword casing just for this command.\n",
471            "#     keyword_case: upper\n",
472            "#\n",
473            "#     # Override indentation width just for this command.\n",
474            "#     tab_size: 4\n",
475            "#\n",
476            "#     # Override dangling-paren placement just for this command.\n",
477            "#     dangle_parens: false\n",
478            "#\n",
479            "#     # Override dangling-paren alignment just for this command.\n",
480            "#     dangle_align: prefix\n",
481            "#\n",
482            "#     # Override the positional-argument hanging-wrap threshold just for this command.\n",
483            "#     max_hanging_wrap_positional_args: 8\n",
484            "#\n",
485            "#     # Override the subgroup hanging-wrap threshold just for this command.\n",
486            "#     max_hanging_wrap_groups: 3\n\n",
487            "# YAML custom-command specs live under commands:<name>. Command\n",
488            "# specs tell the formatter which tokens are positional arguments,\n",
489            "# standalone flags, and keyword sections.\n",
490            "#\n",
491            "# Example: one positional argument, one flag, and two keyword\n",
492            "# sections that each take one or more values.\n",
493            "#\n",
494            "# commands:\n",
495            "#   my_custom_command:\n",
496            "#     pargs: 1\n",
497            "#     flags:\n",
498            "#       - QUIET\n",
499            "#     kwargs:\n",
500            "#       SOURCES:\n",
501            "#         nargs: \"+\"\n",
502            "#       LIBRARIES:\n",
503            "#         nargs: \"+\"\n",
504        ),
505        line_width = Config::default().line_width,
506        tab_size = Config::default().tab_size,
507        max_empty_lines = Config::default().max_empty_lines,
508        max_lines_hwrap = Config::default().max_lines_hwrap,
509        max_pargs_hwrap = Config::default().max_pargs_hwrap,
510        max_subgroups_hwrap = Config::default().max_subgroups_hwrap,
511        max_rows_cmdline = Config::default().max_rows_cmdline,
512        dangle_parens = Config::default().dangle_parens,
513        dangle_align = "prefix",
514        min_prefix_chars = Config::default().min_prefix_chars,
515        max_prefix_chars = Config::default().max_prefix_chars,
516        command_case = "lower",
517        keyword_case = "upper",
518        enable_markup = Config::default().enable_markup,
519        first_comment_is_literal = Config::default().first_comment_is_literal,
520        bullet_char = Config::default().bullet_char,
521        enum_char = Config::default().enum_char,
522        fence_pattern = Config::default().fence_pattern,
523        ruler_pattern = Config::default().ruler_pattern,
524        hashruler_min_length = Config::default().hashruler_min_length,
525        canonicalize_hashrulers = Config::default().canonicalize_hashrulers,
526    )
527}
528
529impl Config {
530    /// Load configuration for a file at the given path.
531    ///
532    /// Searches for the nearest supported user config (`.cmakefmt.yaml`,
533    /// `.cmakefmt.yml`, then `.cmakefmt.toml`) starting from the file's
534    /// directory and walking up to the repository/filesystem root. If none is
535    /// found, falls back to the same filenames in the home directory.
536    pub fn for_file(file_path: &Path) -> Result<Self> {
537        let config_paths = find_config_files(file_path);
538        Self::from_files(&config_paths)
539    }
540
541    /// Load configuration from a specific supported config file.
542    pub fn from_file(path: &Path) -> Result<Self> {
543        let paths = [path.to_path_buf()];
544        Self::from_files(&paths)
545    }
546
547    /// Load configuration by merging several supported config files in order.
548    ///
549    /// Later files override earlier files.
550    pub fn from_files(paths: &[PathBuf]) -> Result<Self> {
551        let mut config = Config::default();
552        for path in paths {
553            let file_config = load_config_file(path)?;
554            config.apply(file_config);
555        }
556        Ok(config)
557    }
558
559    /// Return the config files that would be applied for the given file.
560    ///
561    /// When config discovery is used, this is either the nearest
562    /// supported config file found by walking upward from the file, or a home
563    /// directory config if no nearer config exists.
564    pub fn config_sources_for(file_path: &Path) -> Vec<PathBuf> {
565        find_config_files(file_path)
566    }
567
568    fn apply(&mut self, fc: FileConfig) {
569        // Format section
570        if let Some(v) = fc.format.disable {
571            self.disable = v;
572        }
573        if let Some(v) = fc.format.line_ending {
574            self.line_ending = v;
575        }
576        if let Some(v) = fc.format.line_width {
577            self.line_width = v;
578        }
579        if let Some(v) = fc.format.tab_size {
580            self.tab_size = v;
581        }
582        if let Some(v) = fc.format.use_tabs {
583            self.use_tabchars = v;
584        }
585        if let Some(v) = fc.format.fractional_tab_policy {
586            self.fractional_tab_policy = v;
587        }
588        if let Some(v) = fc.format.max_empty_lines {
589            self.max_empty_lines = v;
590        }
591        if let Some(v) = fc.format.max_hanging_wrap_lines {
592            self.max_lines_hwrap = v;
593        }
594        if let Some(v) = fc.format.max_hanging_wrap_positional_args {
595            self.max_pargs_hwrap = v;
596        }
597        if let Some(v) = fc.format.max_hanging_wrap_groups {
598            self.max_subgroups_hwrap = v;
599        }
600        if let Some(v) = fc.format.max_rows_cmdline {
601            self.max_rows_cmdline = v;
602        }
603        if let Some(v) = fc.format.always_wrap {
604            self.always_wrap = v.into_iter().map(|s| s.to_ascii_lowercase()).collect();
605        }
606        if let Some(v) = fc.format.require_valid_layout {
607            self.require_valid_layout = v;
608        }
609        if let Some(v) = fc.format.dangle_parens {
610            self.dangle_parens = v;
611        }
612        if let Some(v) = fc.format.dangle_align {
613            self.dangle_align = v;
614        }
615        if let Some(v) = fc.format.min_prefix_length {
616            self.min_prefix_chars = v;
617        }
618        if let Some(v) = fc.format.max_prefix_length {
619            self.max_prefix_chars = v;
620        }
621        if let Some(v) = fc.format.space_before_control_paren {
622            self.separate_ctrl_name_with_space = v;
623        }
624        if let Some(v) = fc.format.space_before_definition_paren {
625            self.separate_fn_name_with_space = v;
626        }
627
628        // Style section
629        if let Some(v) = fc.style.command_case {
630            self.command_case = v;
631        }
632        if let Some(v) = fc.style.keyword_case {
633            self.keyword_case = v;
634        }
635
636        // Markup section
637        if let Some(v) = fc.markup.enable_markup {
638            self.enable_markup = v;
639        }
640        if let Some(v) = fc.markup.reflow_comments {
641            self.reflow_comments = v;
642        }
643        if let Some(v) = fc.markup.first_comment_is_literal {
644            self.first_comment_is_literal = v;
645        }
646        if let Some(v) = fc.markup.literal_comment_pattern {
647            self.literal_comment_pattern = v;
648        }
649        if let Some(v) = fc.markup.bullet_char {
650            self.bullet_char = v;
651        }
652        if let Some(v) = fc.markup.enum_char {
653            self.enum_char = v;
654        }
655        if let Some(v) = fc.markup.fence_pattern {
656            self.fence_pattern = v;
657        }
658        if let Some(v) = fc.markup.ruler_pattern {
659            self.ruler_pattern = v;
660        }
661        if let Some(v) = fc.markup.hashruler_min_length {
662            self.hashruler_min_length = v;
663        }
664        if let Some(v) = fc.markup.canonicalize_hashrulers {
665            self.canonicalize_hashrulers = v;
666        }
667        if let Some(v) = fc.markup.explicit_trailing_pattern {
668            self.explicit_trailing_pattern = v;
669        }
670
671        // Per-command overrides (merge, don't replace)
672        for (name, overrides) in fc.per_command_overrides {
673            self.per_command_overrides.insert(name, overrides);
674        }
675    }
676}
677
678fn load_config_file(path: &Path) -> Result<FileConfig> {
679    let contents = std::fs::read_to_string(path).map_err(Error::Io)?;
680    let config: FileConfig = match detect_config_format(path)? {
681        ConfigFileFormat::Toml => toml::from_str(&contents).map_err(|source| {
682            let (line, column) = toml_line_col(&contents, source.span().map(|span| span.start));
683            Error::Config {
684                path: path.to_path_buf(),
685                details: FileParseError {
686                    format: ConfigFileFormat::Toml.as_str(),
687                    message: source.to_string().into_boxed_str(),
688                    line,
689                    column,
690                },
691                source_message: source.to_string().into_boxed_str(),
692            }
693        }),
694        ConfigFileFormat::Yaml => serde_yaml::from_str(&contents).map_err(|source| {
695            let location = source.location();
696            let line = location.as_ref().map(|loc| loc.line());
697            let column = location.as_ref().map(|loc| loc.column());
698            Error::Config {
699                path: path.to_path_buf(),
700                details: FileParseError {
701                    format: ConfigFileFormat::Yaml.as_str(),
702                    message: source.to_string().into_boxed_str(),
703                    line,
704                    column,
705                },
706                source_message: source.to_string().into_boxed_str(),
707            }
708        }),
709    }?;
710
711    if !config.legacy_per_command.is_empty() {
712        return Err(Error::Formatter(format!(
713            "{}: `per_command` has been renamed to `per_command_overrides`",
714            path.display()
715        )));
716    }
717
718    Ok(config)
719}
720
721/// Find the config files that apply to `file_path`.
722///
723/// The nearest supported config discovered while walking upward wins. If
724/// multiple supported config filenames exist in the same directory, YAML is
725/// preferred over TOML. If no project-local config is found, the user home
726/// config is returned when present.
727fn find_config_files(file_path: &Path) -> Vec<PathBuf> {
728    let start_dir = if file_path.is_dir() {
729        file_path.to_path_buf()
730    } else {
731        file_path
732            .parent()
733            .map(Path::to_path_buf)
734            .unwrap_or_else(|| PathBuf::from("."))
735    };
736
737    let mut dir = Some(start_dir.as_path());
738    while let Some(d) = dir {
739        if let Some(candidate) = preferred_config_in_dir(d) {
740            return vec![candidate];
741        }
742
743        if d.join(".git").exists() {
744            break;
745        }
746
747        dir = d.parent();
748    }
749
750    if let Some(home) = home_dir() {
751        if let Some(home_config) = preferred_config_in_dir(&home) {
752            return vec![home_config];
753        }
754    }
755
756    Vec::new()
757}
758
759pub(crate) fn detect_config_format(path: &Path) -> Result<ConfigFileFormat> {
760    let file_name = path
761        .file_name()
762        .and_then(|name| name.to_str())
763        .unwrap_or_default();
764    if file_name == CONFIG_FILE_NAME_TOML
765        || path.extension().and_then(|ext| ext.to_str()) == Some("toml")
766    {
767        return Ok(ConfigFileFormat::Toml);
768    }
769    if matches!(file_name, CONFIG_FILE_NAME_YAML | CONFIG_FILE_NAME_YML)
770        || matches!(
771            path.extension().and_then(|ext| ext.to_str()),
772            Some("yaml" | "yml")
773        )
774    {
775        return Ok(ConfigFileFormat::Yaml);
776    }
777
778    Err(Error::Formatter(format!(
779        "{}: unsupported config format; use .cmakefmt.yaml, .cmakefmt.yml, or .cmakefmt.toml",
780        path.display()
781    )))
782}
783
784fn preferred_config_in_dir(dir: &Path) -> Option<PathBuf> {
785    CONFIG_FILE_NAMES
786        .iter()
787        .map(|name| dir.join(name))
788        .find(|candidate| candidate.is_file())
789}
790
791pub(crate) fn toml_line_col(
792    contents: &str,
793    offset: Option<usize>,
794) -> (Option<usize>, Option<usize>) {
795    let Some(offset) = offset else {
796        return (None, None);
797    };
798    let mut line = 1usize;
799    let mut column = 1usize;
800    for (index, ch) in contents.char_indices() {
801        if index >= offset {
802            break;
803        }
804        if ch == '\n' {
805            line += 1;
806            column = 1;
807        } else {
808            column += 1;
809        }
810    }
811    (Some(line), Some(column))
812}
813
814fn home_dir() -> Option<PathBuf> {
815    std::env::var_os("HOME")
816        .or_else(|| std::env::var_os("USERPROFILE"))
817        .map(PathBuf::from)
818}
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823    use std::fs;
824
825    #[test]
826    fn parse_empty_config() {
827        let config: FileConfig = toml::from_str("").unwrap();
828        assert!(config.format.line_width.is_none());
829    }
830
831    #[test]
832    fn parse_full_config() {
833        let toml_str = r#"
834[format]
835line_width = 120
836tab_size = 4
837use_tabs = true
838max_empty_lines = 2
839dangle_parens = true
840dangle_align = "open"
841space_before_control_paren = true
842space_before_definition_paren = true
843max_hanging_wrap_positional_args = 3
844max_hanging_wrap_groups = 1
845
846[style]
847command_case = "upper"
848keyword_case = "lower"
849
850[markup]
851enable_markup = false
852hashruler_min_length = 20
853
854[per_command_overrides.message]
855dangle_parens = true
856line_width = 100
857"#;
858        let config: FileConfig = toml::from_str(toml_str).unwrap();
859        assert_eq!(config.format.line_width, Some(120));
860        assert_eq!(config.format.tab_size, Some(4));
861        assert_eq!(config.format.use_tabs, Some(true));
862        assert_eq!(config.format.dangle_parens, Some(true));
863        assert_eq!(config.format.dangle_align, Some(DangleAlign::Open));
864        assert_eq!(config.style.command_case, Some(CaseStyle::Upper));
865        assert_eq!(config.style.keyword_case, Some(CaseStyle::Lower));
866        assert_eq!(config.markup.enable_markup, Some(false));
867
868        let msg = config.per_command_overrides.get("message").unwrap();
869        assert_eq!(msg.dangle_parens, Some(true));
870        assert_eq!(msg.line_width, Some(100));
871    }
872
873    #[test]
874    fn old_format_key_aliases_are_rejected() {
875        let toml_str = r#"
876[format]
877use_tabchars = true
878max_lines_hwrap = 4
879max_pargs_hwrap = 3
880max_subgroups_hwrap = 2
881min_prefix_chars = 5
882max_prefix_chars = 11
883separate_ctrl_name_with_space = true
884separate_fn_name_with_space = true
885"#;
886        let err = toml::from_str::<FileConfig>(toml_str)
887            .unwrap_err()
888            .to_string();
889        assert!(err.contains("unknown field"));
890    }
891
892    #[test]
893    fn config_from_file_applies_overrides() {
894        let dir = tempfile::tempdir().unwrap();
895        let config_path = dir.path().join(CONFIG_FILE_NAME_TOML);
896        fs::write(
897            &config_path,
898            r#"
899[format]
900line_width = 100
901tab_size = 4
902
903[style]
904command_case = "upper"
905"#,
906        )
907        .unwrap();
908
909        let config = Config::from_file(&config_path).unwrap();
910        assert_eq!(config.line_width, 100);
911        assert_eq!(config.tab_size, 4);
912        assert_eq!(config.command_case, CaseStyle::Upper);
913        // Unspecified values keep defaults
914        assert!(!config.use_tabchars);
915        assert_eq!(config.max_empty_lines, 1);
916    }
917
918    #[test]
919    fn default_yaml_config_template_parses() {
920        let template = default_config_template();
921        let parsed: FileConfig = serde_yaml::from_str(&template).unwrap();
922        assert_eq!(parsed.format.line_width, Some(Config::default().line_width));
923        assert_eq!(
924            parsed.style.command_case,
925            Some(Config::default().command_case)
926        );
927        assert_eq!(
928            parsed.markup.enable_markup,
929            Some(Config::default().enable_markup)
930        );
931    }
932
933    #[test]
934    fn toml_config_template_parses() {
935        let template = default_config_template_for(DumpConfigFormat::Toml);
936        let parsed: FileConfig = toml::from_str(&template).unwrap();
937        assert_eq!(parsed.format.line_width, Some(Config::default().line_width));
938        assert_eq!(
939            parsed.style.command_case,
940            Some(Config::default().command_case)
941        );
942        assert_eq!(
943            parsed.markup.enable_markup,
944            Some(Config::default().enable_markup)
945        );
946    }
947
948    #[test]
949    fn missing_config_file_uses_defaults() {
950        let dir = tempfile::tempdir().unwrap();
951        let fake_file = dir.path().join("CMakeLists.txt");
952        fs::write(&fake_file, "").unwrap();
953
954        let config = Config::for_file(&fake_file).unwrap();
955        assert_eq!(config, Config::default());
956    }
957
958    #[test]
959    fn config_file_in_parent_is_found() {
960        let dir = tempfile::tempdir().unwrap();
961        // Create a .git dir to act as root
962        fs::create_dir(dir.path().join(".git")).unwrap();
963        fs::write(
964            dir.path().join(CONFIG_FILE_NAME_TOML),
965            "[format]\nline_width = 120\n",
966        )
967        .unwrap();
968
969        let subdir = dir.path().join("src");
970        fs::create_dir(&subdir).unwrap();
971        let file = subdir.join("CMakeLists.txt");
972        fs::write(&file, "").unwrap();
973
974        let config = Config::for_file(&file).unwrap();
975        assert_eq!(config.line_width, 120);
976    }
977
978    #[test]
979    fn closer_config_wins() {
980        let dir = tempfile::tempdir().unwrap();
981        fs::create_dir(dir.path().join(".git")).unwrap();
982        fs::write(
983            dir.path().join(CONFIG_FILE_NAME_TOML),
984            "[format]\nline_width = 120\ntab_size = 4\n",
985        )
986        .unwrap();
987
988        let subdir = dir.path().join("src");
989        fs::create_dir(&subdir).unwrap();
990        fs::write(
991            subdir.join(CONFIG_FILE_NAME_TOML),
992            "[format]\nline_width = 100\n",
993        )
994        .unwrap();
995
996        let file = subdir.join("CMakeLists.txt");
997        fs::write(&file, "").unwrap();
998
999        let config = Config::for_file(&file).unwrap();
1000        // Only the nearest config is used automatically.
1001        assert_eq!(config.line_width, 100);
1002        assert_eq!(config.tab_size, Config::default().tab_size);
1003    }
1004
1005    #[test]
1006    fn from_files_merges_in_order() {
1007        let dir = tempfile::tempdir().unwrap();
1008        let first = dir.path().join("first.toml");
1009        let second = dir.path().join("second.toml");
1010        fs::write(&first, "[format]\nline_width = 120\ntab_size = 4\n").unwrap();
1011        fs::write(&second, "[format]\nline_width = 100\n").unwrap();
1012
1013        let config = Config::from_files(&[first, second]).unwrap();
1014        assert_eq!(config.line_width, 100);
1015        assert_eq!(config.tab_size, 4);
1016    }
1017
1018    #[test]
1019    fn yaml_config_from_file_applies_overrides() {
1020        let dir = tempfile::tempdir().unwrap();
1021        let config_path = dir.path().join(CONFIG_FILE_NAME_YAML);
1022        fs::write(
1023            &config_path,
1024            "format:\n  line_width: 100\n  tab_size: 4\nstyle:\n  command_case: upper\n",
1025        )
1026        .unwrap();
1027
1028        let config = Config::from_file(&config_path).unwrap();
1029        assert_eq!(config.line_width, 100);
1030        assert_eq!(config.tab_size, 4);
1031        assert_eq!(config.command_case, CaseStyle::Upper);
1032    }
1033
1034    #[test]
1035    fn yml_config_from_file_applies_overrides() {
1036        let dir = tempfile::tempdir().unwrap();
1037        let config_path = dir.path().join(CONFIG_FILE_NAME_YML);
1038        fs::write(
1039            &config_path,
1040            "style:\n  keyword_case: lower\nformat:\n  line_width: 90\n",
1041        )
1042        .unwrap();
1043
1044        let config = Config::from_file(&config_path).unwrap();
1045        assert_eq!(config.line_width, 90);
1046        assert_eq!(config.keyword_case, CaseStyle::Lower);
1047    }
1048
1049    #[test]
1050    fn yaml_is_preferred_over_toml_during_discovery() {
1051        let dir = tempfile::tempdir().unwrap();
1052        fs::create_dir(dir.path().join(".git")).unwrap();
1053        fs::write(
1054            dir.path().join(CONFIG_FILE_NAME_TOML),
1055            "[format]\nline_width = 120\n",
1056        )
1057        .unwrap();
1058        fs::write(
1059            dir.path().join(CONFIG_FILE_NAME_YAML),
1060            "format:\n  line_width: 90\n",
1061        )
1062        .unwrap();
1063
1064        let file = dir.path().join("CMakeLists.txt");
1065        fs::write(&file, "").unwrap();
1066
1067        let config = Config::for_file(&file).unwrap();
1068        assert_eq!(config.line_width, 90);
1069        assert_eq!(
1070            Config::config_sources_for(&file),
1071            vec![dir.path().join(CONFIG_FILE_NAME_YAML)]
1072        );
1073    }
1074
1075    #[test]
1076    fn invalid_config_returns_error() {
1077        let dir = tempfile::tempdir().unwrap();
1078        let path = dir.path().join(CONFIG_FILE_NAME_TOML);
1079        fs::write(&path, "this is not valid toml {{{").unwrap();
1080
1081        let result = Config::from_file(&path);
1082        assert!(result.is_err());
1083        let err = result.unwrap_err();
1084        assert!(err.to_string().contains("config error"));
1085    }
1086
1087    #[test]
1088    fn config_from_yaml_file_applies_all_sections_and_overrides() {
1089        let dir = tempfile::tempdir().unwrap();
1090        let config_path = dir.path().join(CONFIG_FILE_NAME_YAML);
1091        fs::write(
1092            &config_path,
1093            r#"
1094format:
1095  line_width: 96
1096  tab_size: 3
1097  use_tabs: true
1098  max_empty_lines: 2
1099  max_hanging_wrap_lines: 4
1100  max_hanging_wrap_positional_args: 7
1101  max_hanging_wrap_groups: 5
1102  dangle_parens: true
1103  dangle_align: open
1104  min_prefix_length: 2
1105  max_prefix_length: 12
1106  space_before_control_paren: true
1107  space_before_definition_paren: true
1108style:
1109  command_case: unchanged
1110  keyword_case: lower
1111markup:
1112  enable_markup: false
1113  reflow_comments: true
1114  first_comment_is_literal: false
1115  literal_comment_pattern: '^\\s*KEEP'
1116  bullet_char: '-'
1117  enum_char: ')'
1118  fence_pattern: '^\\s*(```+).*'
1119  ruler_pattern: '^\\s*={5,}\\s*$'
1120  hashruler_min_length: 42
1121  canonicalize_hashrulers: false
1122per_command_overrides:
1123  my_custom_command:
1124    line_width: 101
1125    tab_size: 5
1126    dangle_parens: false
1127    dangle_align: prefix
1128    max_hanging_wrap_positional_args: 8
1129    max_hanging_wrap_groups: 9
1130"#,
1131        )
1132        .unwrap();
1133
1134        let config = Config::from_file(&config_path).unwrap();
1135        assert_eq!(config.line_width, 96);
1136        assert_eq!(config.tab_size, 3);
1137        assert!(config.use_tabchars);
1138        assert_eq!(config.max_empty_lines, 2);
1139        assert_eq!(config.max_lines_hwrap, 4);
1140        assert_eq!(config.max_pargs_hwrap, 7);
1141        assert_eq!(config.max_subgroups_hwrap, 5);
1142        assert!(config.dangle_parens);
1143        assert_eq!(config.dangle_align, DangleAlign::Open);
1144        assert_eq!(config.min_prefix_chars, 2);
1145        assert_eq!(config.max_prefix_chars, 12);
1146        assert!(config.separate_ctrl_name_with_space);
1147        assert!(config.separate_fn_name_with_space);
1148        assert_eq!(config.command_case, CaseStyle::Unchanged);
1149        assert_eq!(config.keyword_case, CaseStyle::Lower);
1150        assert!(!config.enable_markup);
1151        assert!(config.reflow_comments);
1152        assert!(!config.first_comment_is_literal);
1153        assert_eq!(config.literal_comment_pattern, "^\\\\s*KEEP");
1154        assert_eq!(config.bullet_char, "-");
1155        assert_eq!(config.enum_char, ")");
1156        assert_eq!(config.fence_pattern, "^\\\\s*(```+).*");
1157        assert_eq!(config.ruler_pattern, "^\\\\s*={5,}\\\\s*$");
1158        assert_eq!(config.hashruler_min_length, 42);
1159        assert!(!config.canonicalize_hashrulers);
1160        let per_command = config
1161            .per_command_overrides
1162            .get("my_custom_command")
1163            .unwrap();
1164        assert_eq!(per_command.line_width, Some(101));
1165        assert_eq!(per_command.tab_size, Some(5));
1166        assert_eq!(per_command.dangle_parens, Some(false));
1167        assert_eq!(per_command.dangle_align, Some(DangleAlign::Prefix));
1168        assert_eq!(per_command.max_pargs_hwrap, Some(8));
1169        assert_eq!(per_command.max_subgroups_hwrap, Some(9));
1170    }
1171
1172    #[test]
1173    fn detect_config_format_supports_yaml_and_rejects_unknown() {
1174        assert!(matches!(
1175            detect_config_format(Path::new(".cmakefmt.yml")).unwrap(),
1176            ConfigFileFormat::Yaml
1177        ));
1178        assert!(matches!(
1179            detect_config_format(Path::new("tooling/settings.yaml")).unwrap(),
1180            ConfigFileFormat::Yaml
1181        ));
1182        assert!(matches!(
1183            detect_config_format(Path::new("project.toml")).unwrap(),
1184            ConfigFileFormat::Toml
1185        ));
1186        let err = detect_config_format(Path::new("config.json")).unwrap_err();
1187        assert!(err.to_string().contains("unsupported config format"));
1188    }
1189
1190    #[test]
1191    fn yaml_config_with_legacy_per_command_key_is_rejected() {
1192        let dir = tempfile::tempdir().unwrap();
1193        let config_path = dir.path().join(CONFIG_FILE_NAME_YAML);
1194        fs::write(
1195            &config_path,
1196            "per_command:\n  message:\n    line_width: 120\n",
1197        )
1198        .unwrap();
1199        let err = Config::from_file(&config_path).unwrap_err();
1200        assert!(err
1201            .to_string()
1202            .contains("`per_command` has been renamed to `per_command_overrides`"));
1203    }
1204
1205    #[test]
1206    fn invalid_yaml_reports_line_and_column() {
1207        let dir = tempfile::tempdir().unwrap();
1208        let config_path = dir.path().join(CONFIG_FILE_NAME_YAML);
1209        fs::write(&config_path, "format:\n  line_width: [\n").unwrap();
1210
1211        let err = Config::from_file(&config_path).unwrap_err();
1212        match err {
1213            Error::Config { details, .. } => {
1214                assert_eq!(details.format, "YAML");
1215                assert!(details.line.is_some());
1216                assert!(details.column.is_some());
1217            }
1218            other => panic!("expected config parse error, got {other:?}"),
1219        }
1220    }
1221
1222    #[test]
1223    fn toml_line_col_returns_none_when_offset_is_missing() {
1224        assert_eq!(toml_line_col("line = true\n", None), (None, None));
1225    }
1226}