@owenlamont/ryl 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/README.md +13 -0
  2. package/bin/ryl.js +195 -1
  3. package/package.json +35 -13
  4. package/.github/CODEOWNERS +0 -1
  5. package/.github/dependabot.yml +0 -13
  6. package/.github/workflows/ci.yml +0 -107
  7. package/.github/workflows/release.yml +0 -613
  8. package/.github/workflows/update_dependencies.yml +0 -61
  9. package/.github/workflows/update_linters.yml +0 -56
  10. package/.pre-commit-config.yaml +0 -87
  11. package/.yamllint +0 -4
  12. package/AGENTS.md +0 -200
  13. package/Cargo.lock +0 -908
  14. package/Cargo.toml +0 -32
  15. package/clippy.toml +0 -1
  16. package/docs/config-presets.md +0 -100
  17. package/img/benchmark-5x5-5runs.svg +0 -2176
  18. package/pyproject.toml +0 -42
  19. package/ruff.toml +0 -107
  20. package/rumdl.toml +0 -20
  21. package/rust-toolchain.toml +0 -3
  22. package/rustfmt.toml +0 -3
  23. package/scripts/benchmark_perf_vs_yamllint.py +0 -400
  24. package/scripts/coverage-missing.ps1 +0 -80
  25. package/scripts/coverage-missing.sh +0 -60
  26. package/src/bin/discover_config_bin.rs +0 -24
  27. package/src/cli_support.rs +0 -33
  28. package/src/conf/mod.rs +0 -85
  29. package/src/config.rs +0 -2099
  30. package/src/decoder.rs +0 -326
  31. package/src/discover.rs +0 -31
  32. package/src/lib.rs +0 -19
  33. package/src/lint.rs +0 -558
  34. package/src/main.rs +0 -535
  35. package/src/migrate.rs +0 -233
  36. package/src/rules/anchors.rs +0 -517
  37. package/src/rules/braces.rs +0 -77
  38. package/src/rules/brackets.rs +0 -77
  39. package/src/rules/colons.rs +0 -475
  40. package/src/rules/commas.rs +0 -372
  41. package/src/rules/comments.rs +0 -299
  42. package/src/rules/comments_indentation.rs +0 -243
  43. package/src/rules/document_end.rs +0 -175
  44. package/src/rules/document_start.rs +0 -84
  45. package/src/rules/empty_lines.rs +0 -152
  46. package/src/rules/empty_values.rs +0 -255
  47. package/src/rules/float_values.rs +0 -259
  48. package/src/rules/flow_collection.rs +0 -562
  49. package/src/rules/hyphens.rs +0 -104
  50. package/src/rules/indentation.rs +0 -803
  51. package/src/rules/key_duplicates.rs +0 -218
  52. package/src/rules/key_ordering.rs +0 -303
  53. package/src/rules/line_length.rs +0 -326
  54. package/src/rules/mod.rs +0 -25
  55. package/src/rules/new_line_at_end_of_file.rs +0 -23
  56. package/src/rules/new_lines.rs +0 -95
  57. package/src/rules/octal_values.rs +0 -121
  58. package/src/rules/quoted_strings.rs +0 -577
  59. package/src/rules/span_utils.rs +0 -37
  60. package/src/rules/trailing_spaces.rs +0 -65
  61. package/src/rules/truthy.rs +0 -420
  62. package/tests/brackets_carriage_return.rs +0 -114
  63. package/tests/build_global_cfg_error.rs +0 -23
  64. package/tests/cli_anchors_rule.rs +0 -143
  65. package/tests/cli_braces_rule.rs +0 -104
  66. package/tests/cli_brackets_rule.rs +0 -104
  67. package/tests/cli_colons_rule.rs +0 -65
  68. package/tests/cli_commas_rule.rs +0 -104
  69. package/tests/cli_comments_indentation_rule.rs +0 -61
  70. package/tests/cli_comments_rule.rs +0 -67
  71. package/tests/cli_config_data_error.rs +0 -30
  72. package/tests/cli_config_flags.rs +0 -66
  73. package/tests/cli_config_migrate.rs +0 -229
  74. package/tests/cli_document_end_rule.rs +0 -92
  75. package/tests/cli_document_start_rule.rs +0 -92
  76. package/tests/cli_empty_lines_rule.rs +0 -87
  77. package/tests/cli_empty_values_rule.rs +0 -68
  78. package/tests/cli_env_config.rs +0 -34
  79. package/tests/cli_exit_and_errors.rs +0 -41
  80. package/tests/cli_file_encoding.rs +0 -203
  81. package/tests/cli_float_values_rule.rs +0 -64
  82. package/tests/cli_format_options.rs +0 -316
  83. package/tests/cli_global_cfg_relaxed.rs +0 -20
  84. package/tests/cli_hyphens_rule.rs +0 -104
  85. package/tests/cli_indentation_rule.rs +0 -65
  86. package/tests/cli_invalid_project_config.rs +0 -39
  87. package/tests/cli_key_duplicates_rule.rs +0 -104
  88. package/tests/cli_key_ordering_rule.rs +0 -59
  89. package/tests/cli_line_length_rule.rs +0 -85
  90. package/tests/cli_list_files.rs +0 -29
  91. package/tests/cli_new_line_rule.rs +0 -141
  92. package/tests/cli_new_lines_rule.rs +0 -119
  93. package/tests/cli_octal_values_rule.rs +0 -60
  94. package/tests/cli_quoted_strings_rule.rs +0 -47
  95. package/tests/cli_toml_config.rs +0 -119
  96. package/tests/cli_trailing_spaces_rule.rs +0 -77
  97. package/tests/cli_truthy_rule.rs +0 -83
  98. package/tests/cli_yaml_files_negation.rs +0 -45
  99. package/tests/colons_rule.rs +0 -303
  100. package/tests/common/compat.rs +0 -114
  101. package/tests/common/fake_env.rs +0 -93
  102. package/tests/common/mod.rs +0 -1
  103. package/tests/conf_builtin.rs +0 -9
  104. package/tests/config_anchors.rs +0 -84
  105. package/tests/config_braces.rs +0 -121
  106. package/tests/config_brackets.rs +0 -127
  107. package/tests/config_commas.rs +0 -79
  108. package/tests/config_comments.rs +0 -65
  109. package/tests/config_comments_indentation.rs +0 -20
  110. package/tests/config_deep_merge_nonstring_key.rs +0 -24
  111. package/tests/config_document_end.rs +0 -54
  112. package/tests/config_document_start.rs +0 -55
  113. package/tests/config_empty_lines.rs +0 -48
  114. package/tests/config_empty_values.rs +0 -35
  115. package/tests/config_env_errors.rs +0 -23
  116. package/tests/config_env_invalid_inline.rs +0 -15
  117. package/tests/config_env_missing.rs +0 -63
  118. package/tests/config_env_shim.rs +0 -301
  119. package/tests/config_explicit_file_parse_error.rs +0 -55
  120. package/tests/config_extended_features.rs +0 -225
  121. package/tests/config_extends_inline.rs +0 -185
  122. package/tests/config_extends_sequence.rs +0 -18
  123. package/tests/config_find_project_home_boundary.rs +0 -54
  124. package/tests/config_find_project_two_files_in_cwd.rs +0 -47
  125. package/tests/config_float_values.rs +0 -34
  126. package/tests/config_from_yaml_paths.rs +0 -32
  127. package/tests/config_hyphens.rs +0 -51
  128. package/tests/config_ignore_errors.rs +0 -243
  129. package/tests/config_ignore_overrides.rs +0 -83
  130. package/tests/config_indentation.rs +0 -65
  131. package/tests/config_invalid_globs.rs +0 -16
  132. package/tests/config_invalid_types.rs +0 -19
  133. package/tests/config_key_duplicates.rs +0 -34
  134. package/tests/config_key_ordering.rs +0 -70
  135. package/tests/config_line_length.rs +0 -65
  136. package/tests/config_locale.rs +0 -111
  137. package/tests/config_merge.rs +0 -26
  138. package/tests/config_new_lines.rs +0 -89
  139. package/tests/config_octal_values.rs +0 -33
  140. package/tests/config_quoted_strings.rs +0 -195
  141. package/tests/config_rule_level.rs +0 -147
  142. package/tests/config_rules_non_string_keys.rs +0 -23
  143. package/tests/config_scalar_overrides.rs +0 -27
  144. package/tests/config_to_toml.rs +0 -110
  145. package/tests/config_toml_coverage.rs +0 -80
  146. package/tests/config_toml_discovery.rs +0 -304
  147. package/tests/config_trailing_spaces.rs +0 -152
  148. package/tests/config_truthy.rs +0 -77
  149. package/tests/config_yaml_files.rs +0 -62
  150. package/tests/config_yaml_files_all_non_string.rs +0 -15
  151. package/tests/config_yaml_files_empty.rs +0 -30
  152. package/tests/coverage_commas.rs +0 -46
  153. package/tests/decoder_decode.rs +0 -338
  154. package/tests/discover_config_bin_all.rs +0 -66
  155. package/tests/discover_config_bin_env_invalid_yaml.rs +0 -26
  156. package/tests/discover_config_bin_project_config_parse_error.rs +0 -24
  157. package/tests/discover_config_bin_user_global_error.rs +0 -26
  158. package/tests/discover_module.rs +0 -30
  159. package/tests/discover_per_file_dir.rs +0 -10
  160. package/tests/discover_per_file_project_config_error.rs +0 -21
  161. package/tests/float_values.rs +0 -43
  162. package/tests/lint_multi_errors.rs +0 -32
  163. package/tests/main_yaml_ok_filtering.rs +0 -30
  164. package/tests/migrate_module.rs +0 -259
  165. package/tests/resolve_ctx_empty_parent.rs +0 -16
  166. package/tests/rule_anchors.rs +0 -442
  167. package/tests/rule_braces.rs +0 -258
  168. package/tests/rule_brackets.rs +0 -217
  169. package/tests/rule_commas.rs +0 -205
  170. package/tests/rule_comments.rs +0 -197
  171. package/tests/rule_comments_indentation.rs +0 -127
  172. package/tests/rule_document_end.rs +0 -118
  173. package/tests/rule_document_start.rs +0 -60
  174. package/tests/rule_empty_lines.rs +0 -96
  175. package/tests/rule_empty_values.rs +0 -102
  176. package/tests/rule_float_values.rs +0 -109
  177. package/tests/rule_hyphens.rs +0 -65
  178. package/tests/rule_indentation.rs +0 -455
  179. package/tests/rule_key_duplicates.rs +0 -76
  180. package/tests/rule_key_ordering.rs +0 -207
  181. package/tests/rule_line_length.rs +0 -200
  182. package/tests/rule_new_lines.rs +0 -51
  183. package/tests/rule_octal_values.rs +0 -53
  184. package/tests/rule_quoted_strings.rs +0 -290
  185. package/tests/rule_trailing_spaces.rs +0 -41
  186. package/tests/rule_truthy.rs +0 -236
  187. package/tests/user_global_invalid_yaml.rs +0 -32
  188. package/tests/yamllint_compat_anchors.rs +0 -280
  189. package/tests/yamllint_compat_braces.rs +0 -411
  190. package/tests/yamllint_compat_brackets.rs +0 -364
  191. package/tests/yamllint_compat_colons.rs +0 -298
  192. package/tests/yamllint_compat_colors.rs +0 -80
  193. package/tests/yamllint_compat_commas.rs +0 -375
  194. package/tests/yamllint_compat_comments.rs +0 -167
  195. package/tests/yamllint_compat_comments_indentation.rs +0 -281
  196. package/tests/yamllint_compat_config.rs +0 -170
  197. package/tests/yamllint_compat_document_end.rs +0 -243
  198. package/tests/yamllint_compat_document_start.rs +0 -136
  199. package/tests/yamllint_compat_empty_lines.rs +0 -117
  200. package/tests/yamllint_compat_empty_values.rs +0 -179
  201. package/tests/yamllint_compat_float_values.rs +0 -216
  202. package/tests/yamllint_compat_hyphens.rs +0 -223
  203. package/tests/yamllint_compat_indentation.rs +0 -398
  204. package/tests/yamllint_compat_key_duplicates.rs +0 -139
  205. package/tests/yamllint_compat_key_ordering.rs +0 -170
  206. package/tests/yamllint_compat_line_length.rs +0 -375
  207. package/tests/yamllint_compat_list.rs +0 -127
  208. package/tests/yamllint_compat_new_line.rs +0 -133
  209. package/tests/yamllint_compat_newline_types.rs +0 -185
  210. package/tests/yamllint_compat_octal_values.rs +0 -172
  211. package/tests/yamllint_compat_quoted_strings.rs +0 -154
  212. package/tests/yamllint_compat_syntax.rs +0 -200
  213. package/tests/yamllint_compat_trailing_spaces.rs +0 -162
  214. package/tests/yamllint_compat_truthy.rs +0 -130
  215. package/tests/yamllint_compat_yaml_files.rs +0 -81
  216. package/typos.toml +0 -2
package/src/config.rs DELETED
@@ -1,2099 +0,0 @@
1
- use std::borrow::Cow;
2
- use std::collections::BTreeMap;
3
- use std::env;
4
- use std::fs;
5
- use std::path::{Path, PathBuf};
6
-
7
- use ignore::gitignore::{Gitignore, GitignoreBuilder};
8
- use regex::Regex;
9
- use saphyr::{LoadableYamlNode, MappingOwned, ScalarOwned, YamlOwned};
10
- use toml::{Table as TomlTable, Value as TomlValue};
11
-
12
- use crate::{conf, decoder};
13
-
14
- /// Abstraction over environment/filesystem to enable full test coverage.
15
- /// Minimal environment abstraction used by tests to cover file system and env-var behavior.
16
- pub trait Env {
17
- /// Current working directory.
18
- fn current_dir(&self) -> PathBuf;
19
- /// Platform configuration directory (e.g., XDG config dir).
20
- fn config_dir(&self) -> Option<PathBuf>;
21
- /// Home directory for tilde expansion.
22
- fn home_dir(&self) -> Option<PathBuf>;
23
- /// Read file contents.
24
- ///
25
- /// # Errors
26
- /// Returns an error string when the file cannot be read.
27
- fn read_to_string(&self, p: &Path) -> Result<String, String>;
28
- fn path_exists(&self, p: &Path) -> bool;
29
- fn env_var(&self, key: &str) -> Option<String>;
30
- }
31
-
32
- #[derive(Debug, Default, Clone, Copy)]
33
- pub struct SystemEnv;
34
-
35
- impl Env for SystemEnv {
36
- fn current_dir(&self) -> PathBuf {
37
- PathBuf::from(".")
38
- }
39
- fn config_dir(&self) -> Option<PathBuf> {
40
- // Check XDG_CONFIG_HOME first (for cross-platform compatibility)
41
- env::var("XDG_CONFIG_HOME")
42
- .ok()
43
- .map(PathBuf::from)
44
- .or_else(dirs_next::config_dir)
45
- }
46
- fn home_dir(&self) -> Option<PathBuf> {
47
- dirs_next::home_dir()
48
- }
49
- fn read_to_string(&self, p: &Path) -> Result<String, String> {
50
- let bytes = match fs::read(p) {
51
- Ok(data) => data,
52
- Err(err) => {
53
- return Err(format!(
54
- "failed to read config file {}: {err}",
55
- p.display()
56
- ));
57
- }
58
- };
59
- match decoder::decode_bytes(&bytes) {
60
- Ok(text) => Ok(text),
61
- Err(err) => {
62
- Err(format!("failed to read config file {}: {err}", p.display()))
63
- }
64
- }
65
- }
66
- fn path_exists(&self, p: &Path) -> bool {
67
- p.exists()
68
- }
69
- fn env_var(&self, key: &str) -> Option<String> {
70
- env::var(key).ok()
71
- }
72
- }
73
-
74
- struct ClosureEnv<'a> {
75
- get: &'a dyn Fn(&str) -> Option<String>,
76
- }
77
-
78
- impl Env for ClosureEnv<'_> {
79
- fn current_dir(&self) -> PathBuf {
80
- SystemEnv.current_dir()
81
- }
82
-
83
- fn config_dir(&self) -> Option<PathBuf> {
84
- SystemEnv.config_dir()
85
- }
86
-
87
- fn home_dir(&self) -> Option<PathBuf> {
88
- (self.get)("HOME")
89
- .or_else(|| (self.get)("USERPROFILE"))
90
- .map(PathBuf::from)
91
- .or_else(|| SystemEnv.home_dir())
92
- }
93
-
94
- fn read_to_string(&self, p: &Path) -> Result<String, String> {
95
- SystemEnv.read_to_string(p)
96
- }
97
-
98
- fn path_exists(&self, p: &Path) -> bool {
99
- SystemEnv.path_exists(p)
100
- }
101
-
102
- fn env_var(&self, key: &str) -> Option<String> {
103
- (self.get)(key)
104
- }
105
- }
106
-
107
- /// Minimal configuration model compatible with yamllint discovery precedence.
108
- #[derive(Debug, Clone)]
109
- pub struct YamlLintConfig {
110
- ignore_patterns: Vec<String>,
111
- ignore_from_files: Vec<String>,
112
- #[allow(clippy::struct_field_names)]
113
- ignore_matcher: Option<Gitignore>,
114
- rule_names: Vec<String>,
115
- rules: std::collections::BTreeMap<String, YamlOwned>,
116
- rule_filters: std::collections::BTreeMap<String, RuleFilter>,
117
- yaml_file_patterns: Vec<String>,
118
- yaml_matcher: Option<Gitignore>,
119
- locale: Option<String>,
120
- }
121
-
122
- const DEFAULT_YAML_FILE_PATTERNS: [&str; 3] = ["*.yaml", "*.yml", ".yamllint"];
123
-
124
- const TRUTHY_ALLOWED_VALUES: [&str; 18] = [
125
- "YES", "Yes", "yes", "NO", "No", "no", "TRUE", "True", "true", "FALSE", "False",
126
- "false", "ON", "On", "on", "OFF", "Off", "off",
127
- ];
128
-
129
- const TRUTHY_ALLOWED_VALUES_DISPLAY: &str = "['YES', 'Yes', 'yes', 'NO', 'No', 'no', 'TRUE', 'True', 'true', 'FALSE', 'False', 'false', 'ON', 'On', 'on', 'OFF', 'Off', 'off']";
130
-
131
- #[derive(Debug, Clone, Default)]
132
- struct RuleFilter {
133
- patterns: Vec<String>,
134
- from_files: Vec<String>,
135
- matcher: Option<Gitignore>,
136
- }
137
-
138
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
139
- pub enum RuleLevel {
140
- Error,
141
- Warning,
142
- }
143
-
144
- impl RuleLevel {
145
- fn parse(value: &str) -> Option<Self> {
146
- match value {
147
- "error" => Some(Self::Error),
148
- "warning" => Some(Self::Warning),
149
- _ => None,
150
- }
151
- }
152
- }
153
-
154
- impl Default for YamlLintConfig {
155
- fn default() -> Self {
156
- Self {
157
- ignore_patterns: Vec::new(),
158
- ignore_from_files: Vec::new(),
159
- ignore_matcher: None,
160
- rule_names: Vec::new(),
161
- rules: std::collections::BTreeMap::new(),
162
- rule_filters: std::collections::BTreeMap::new(),
163
- yaml_file_patterns: DEFAULT_YAML_FILE_PATTERNS
164
- .iter()
165
- .map(|s| (*s).to_string())
166
- .collect(),
167
- yaml_matcher: None,
168
- locale: None,
169
- }
170
- }
171
- }
172
-
173
- #[derive(Debug, Clone, Default)]
174
- pub struct Overrides {
175
- pub config_file: Option<PathBuf>,
176
- pub config_data: Option<String>,
177
- }
178
-
179
- impl YamlLintConfig {
180
- /// Parse configuration data without filesystem access.
181
- ///
182
- /// # Errors
183
- /// Returns an error when `extends` is used and the config requires filesystem access.
184
- pub fn from_yaml_str(s: &str) -> Result<Self, String> {
185
- Self::from_yaml_str_with_env(s, None, None)
186
- }
187
-
188
- fn apply_extends(
189
- &mut self,
190
- node: &YamlOwned,
191
- envx: Option<&dyn Env>,
192
- base_dir: Option<&Path>,
193
- ) -> Result<(), String> {
194
- let base_path = base_dir.unwrap_or_else(|| Path::new(""));
195
-
196
- match node {
197
- YamlOwned::Value(value) => {
198
- if let Some(ext) = value.as_str() {
199
- self.extend_from_entry(ext, envx, base_path)?;
200
- }
201
- }
202
- YamlOwned::Sequence(seq) => {
203
- for item in seq {
204
- if let Some(ext) = item.as_str() {
205
- self.extend_from_entry(ext, envx, base_path)?;
206
- }
207
- }
208
- }
209
- _ => {}
210
- }
211
- Ok(())
212
- }
213
-
214
- fn extend_from_entry(
215
- &mut self,
216
- entry: &str,
217
- envx: Option<&dyn Env>,
218
- base_dir: &Path,
219
- ) -> Result<(), String> {
220
- if let Some(builtin) = conf::builtin(entry) {
221
- let base = Self::from_yaml_str(builtin).expect("builtin preset must parse");
222
- self.merge_from(base);
223
- return Ok(());
224
- }
225
-
226
- let Some(envx) = envx else {
227
- return Err(format!(
228
- "invalid config: extends '{entry}' requires filesystem access for resolution"
229
- ));
230
- };
231
-
232
- let resolved = resolve_extend_path(entry, envx, Some(base_dir));
233
- if is_toml_path(&resolved) {
234
- return Err(format!(
235
- "invalid config: extends cannot reference TOML configuration {}",
236
- resolved.display()
237
- ));
238
- }
239
- let data = match envx.read_to_string(&resolved) {
240
- Ok(text) => text,
241
- Err(err) => {
242
- return Err(format!(
243
- "failed to read extended config {}: {err}",
244
- resolved.display()
245
- ));
246
- }
247
- };
248
- let parent_dir = resolved
249
- .parent()
250
- .map_or_else(|| base_dir.to_path_buf(), Path::to_path_buf);
251
- let base = Self::from_yaml_str_with_env(&data, Some(envx), Some(&parent_dir))?;
252
- self.merge_from(base);
253
- Ok(())
254
- }
255
- #[must_use]
256
- pub fn ignore_patterns(&self) -> &[String] {
257
- &self.ignore_patterns
258
- }
259
-
260
- #[must_use]
261
- pub fn rule_names(&self) -> &[String] {
262
- &self.rule_names
263
- }
264
-
265
- #[must_use]
266
- pub fn rule_level(&self, rule: &str) -> Option<RuleLevel> {
267
- let value = self.rules.get(rule)?;
268
- determine_rule_level(value)
269
- }
270
-
271
- #[must_use]
272
- pub fn rule_option_str(&self, rule: &str, option: &str) -> Option<&str> {
273
- let node = self.rules.get(rule)?;
274
- let map = node.as_mapping()?;
275
- for (key, value) in map {
276
- if key.as_str() == Some(option) {
277
- return value.as_str();
278
- }
279
- }
280
- None
281
- }
282
-
283
- #[must_use]
284
- pub fn rule_option(&self, rule: &str, option: &str) -> Option<&YamlOwned> {
285
- let node = self.rules.get(rule)?;
286
- let map = node.as_mapping()?;
287
- for (key, value) in map {
288
- if key.as_str() == Some(option) {
289
- return Some(value);
290
- }
291
- }
292
- None
293
- }
294
-
295
- #[must_use]
296
- pub fn locale(&self) -> Option<&str> {
297
- self.locale.as_deref()
298
- }
299
-
300
- fn build_yaml_matcher(&mut self, base_dir: &Path) {
301
- if self.yaml_file_patterns.is_empty() {
302
- self.yaml_matcher = None;
303
- return;
304
- }
305
-
306
- let mut builder = GitignoreBuilder::new(base_dir);
307
- builder.allow_unclosed_class(false);
308
- for pat in &self.yaml_file_patterns {
309
- let normalized = pat.trim_end_matches(['\r']);
310
- let _ = builder.add_line(None, normalized);
311
- }
312
-
313
- self.yaml_matcher = builder.build().ok();
314
- }
315
-
316
- fn refresh_rule_filter(&mut self, rule: &str) {
317
- let node = self
318
- .rules
319
- .get(rule)
320
- .expect("refresh_rule_filter should only be called for existing rules");
321
-
322
- if node.as_mapping().is_none() {
323
- self.rule_filters.remove(rule);
324
- return;
325
- }
326
-
327
- let patterns = node
328
- .as_mapping_get("ignore")
329
- .map(|n| {
330
- load_ignore_patterns(n)
331
- .expect("ignore patterns validated during parsing")
332
- })
333
- .unwrap_or_default();
334
- let from_files = node
335
- .as_mapping_get("ignore-from-file")
336
- .map(|n| {
337
- load_ignore_from_files(n)
338
- .expect("ignore-from-file entries validated during parsing")
339
- })
340
- .unwrap_or_default();
341
-
342
- let filter = self.rule_filters.entry(rule.to_owned()).or_default();
343
- filter.patterns = patterns;
344
- filter.from_files = from_files;
345
- filter.matcher = None;
346
- }
347
-
348
- /// Returns true when `path` should be ignored according to config patterns.
349
- /// Matching is performed on the path relative to `base_dir`.
350
- #[must_use]
351
- pub fn is_file_ignored(&self, path: &Path, base_dir: &Path) -> bool {
352
- let Some(matcher) = &self.ignore_matcher else {
353
- return false;
354
- };
355
- let rel = path.strip_prefix(base_dir).unwrap_or(path);
356
- let direct = matcher.matched(rel, false);
357
- if direct.is_whitelist() {
358
- return false;
359
- }
360
- if direct.is_ignore() {
361
- return true;
362
- }
363
- matcher.matched_path_or_any_parents(rel, false).is_ignore()
364
- }
365
-
366
- #[must_use]
367
- pub fn is_rule_ignored(&self, rule: &str, path: &Path, base_dir: &Path) -> bool {
368
- let Some(filter) = self.rule_filters.get(rule) else {
369
- return false;
370
- };
371
- let Some(matcher) = &filter.matcher else {
372
- return false;
373
- };
374
- let rel = path.strip_prefix(base_dir).unwrap_or(path);
375
- let direct = matcher.matched(rel, false);
376
- if direct.is_whitelist() {
377
- return false;
378
- }
379
- if direct.is_ignore() {
380
- return true;
381
- }
382
- matcher.matched_path_or_any_parents(rel, false).is_ignore()
383
- }
384
-
385
- #[must_use]
386
- pub fn is_yaml_candidate(&self, path: &Path, base_dir: &Path) -> bool {
387
- if let Some(matcher) = &self.yaml_matcher {
388
- let rel: Cow<'_, Path> = path.strip_prefix(base_dir).map_or_else(
389
- |_| Cow::Owned(path.file_name().map(PathBuf::from).unwrap_or_default()),
390
- Cow::Borrowed,
391
- );
392
- let matched =
393
- matcher.matched_path_or_any_parents(rel.as_ref(), path.is_dir());
394
- return matched.is_ignore();
395
- }
396
- crate::discover::is_yaml_path(path)
397
- }
398
-
399
- fn from_yaml_str_with_env(
400
- s: &str,
401
- envx: Option<&dyn Env>,
402
- base_dir: Option<&Path>,
403
- ) -> Result<Self, String> {
404
- let docs = YamlOwned::load_from_str(s)
405
- .map_err(|e| format!("failed to parse config data: {e}"))?;
406
- Self::from_doc_with_env(&docs[0], envx, base_dir, true)
407
- }
408
-
409
- fn from_toml_str_with_env(
410
- s: &str,
411
- envx: Option<&dyn Env>,
412
- base_dir: Option<&Path>,
413
- pyproject: bool,
414
- ) -> Result<Option<Self>, String> {
415
- let toml = s
416
- .parse::<TomlTable>()
417
- .map_err(|e| format!("failed to parse config data: {e}"))?;
418
-
419
- if pyproject {
420
- let Some(doc) = toml
421
- .get("tool")
422
- .and_then(|tool| tool.get("ryl"))
423
- .map(toml_value_to_yaml_owned)
424
- else {
425
- return Ok(None);
426
- };
427
- return Self::from_doc_with_env(&doc, envx, base_dir, false).map(Some);
428
- }
429
-
430
- let doc = toml_value_to_yaml_owned(&TomlValue::Table(toml));
431
- Self::from_doc_with_env(&doc, envx, base_dir, false).map(Some)
432
- }
433
-
434
- fn from_doc_with_env(
435
- doc: &YamlOwned,
436
- envx: Option<&dyn Env>,
437
- base_dir: Option<&Path>,
438
- allow_extends: bool,
439
- ) -> Result<Self, String> {
440
- let mut cfg = Self::default();
441
-
442
- if doc.as_mapping().is_none() {
443
- return Err("invalid config: not a mapping".to_string());
444
- }
445
-
446
- if let Some(extends) = doc.as_mapping_get("extends") {
447
- if !allow_extends {
448
- return Err(
449
- "invalid config: extends is not supported in TOML configuration"
450
- .into(),
451
- );
452
- }
453
- cfg.apply_extends(extends, envx, base_dir)?;
454
- }
455
-
456
- let ignore = doc.as_mapping_get("ignore");
457
- let ignore_from_file = doc.as_mapping_get("ignore-from-file");
458
- if ignore.is_some() && ignore_from_file.is_some() {
459
- return Err(
460
- "invalid config: ignore and ignore-from-file keys cannot be used together"
461
- .to_string(),
462
- );
463
- }
464
-
465
- if let Some(node) = ignore {
466
- cfg.ignore_patterns.clear();
467
- cfg.ignore_from_files.clear();
468
- let mut patterns = load_ignore_patterns(node)?;
469
- cfg.ignore_patterns.append(&mut patterns);
470
- }
471
-
472
- if let Some(node) = ignore_from_file {
473
- cfg.ignore_patterns.clear();
474
- cfg.ignore_from_files = load_ignore_from_files(node)?;
475
- }
476
-
477
- let yaml_files = doc.as_mapping_get("yaml-files");
478
- if let Some(yf) = yaml_files
479
- && let Some(seq) = yf.as_sequence()
480
- {
481
- cfg.yaml_file_patterns.clear();
482
- for it in seq {
483
- let Some(s) = it.as_str() else {
484
- return Err(
485
- "invalid config: yaml-files should be a list of file patterns"
486
- .to_string(),
487
- );
488
- };
489
- cfg.yaml_file_patterns.push(s.to_owned());
490
- }
491
- } else if yaml_files.is_some() {
492
- return Err(
493
- "invalid config: yaml-files should be a list of file patterns"
494
- .to_string(),
495
- );
496
- }
497
-
498
- if let Some(locale) = doc.as_mapping_get("locale") {
499
- let Some(loc) = locale.as_str() else {
500
- return Err("invalid config: locale should be a string".to_string());
501
- };
502
- cfg.locale = Some(loc.to_owned());
503
- }
504
-
505
- if let Some(rules) = doc.as_mapping_get("rules")
506
- && let Some(map) = rules.as_mapping()
507
- {
508
- for (k, v) in map {
509
- let Some(name) = k.as_str() else {
510
- continue;
511
- };
512
- validate_rule_value(name, v)?;
513
- if let Some(dst) = cfg.rules.get_mut(name) {
514
- deep_merge_yaml_owned(dst, v);
515
- } else {
516
- cfg.rules.insert(name.to_owned(), v.clone());
517
- }
518
- cfg.refresh_rule_filter(name);
519
- let mut seen = false;
520
- for e in &cfg.rule_names {
521
- if e == name {
522
- seen = true;
523
- break;
524
- }
525
- }
526
- if !seen {
527
- cfg.rule_names.push(name.to_owned());
528
- }
529
- }
530
- }
531
-
532
- Ok(cfg)
533
- }
534
-
535
- fn merge_from(&mut self, mut other: Self) {
536
- // Merge ignore patterns (append, then dedup later during matcher build)
537
- self.ignore_patterns.append(&mut other.ignore_patterns);
538
- self.ignore_from_files.append(&mut other.ignore_from_files);
539
- // Merge rules deeply and accumulate names
540
- for (name, val) in other.rules {
541
- if let Some(dst) = self.rules.get_mut(&name) {
542
- deep_merge_yaml_owned(dst, &val);
543
- } else {
544
- self.rules.insert(name.clone(), val.clone());
545
- }
546
- self.refresh_rule_filter(&name);
547
- if !self.rule_names.iter().any(|e| e == &name) {
548
- self.rule_names.push(name);
549
- }
550
- }
551
- if !other.yaml_file_patterns.is_empty() {
552
- self.yaml_file_patterns = other.yaml_file_patterns;
553
- }
554
- if self.locale.is_none() {
555
- self.locale = other.locale;
556
- }
557
- }
558
-
559
- /// Render the effective configuration as TOML.
560
- ///
561
- /// # Errors
562
- /// Returns an error if a value cannot be represented in TOML.
563
- ///
564
- /// # Panics
565
- /// Panics if serializing a valid TOML value fails unexpectedly.
566
- pub fn to_toml_string(&self) -> Result<String, String> {
567
- let mut root = toml::map::Map::new();
568
-
569
- root.insert(
570
- "yaml-files".to_string(),
571
- TomlValue::Array(
572
- self.yaml_file_patterns
573
- .iter()
574
- .map(|item| TomlValue::String(item.clone()))
575
- .collect(),
576
- ),
577
- );
578
-
579
- if !self.ignore_from_files.is_empty() {
580
- root.insert(
581
- "ignore-from-file".to_string(),
582
- TomlValue::Array(
583
- self.ignore_from_files
584
- .iter()
585
- .map(|item| TomlValue::String(item.clone()))
586
- .collect(),
587
- ),
588
- );
589
- } else if !self.ignore_patterns.is_empty() {
590
- root.insert(
591
- "ignore".to_string(),
592
- TomlValue::Array(
593
- self.ignore_patterns
594
- .iter()
595
- .map(|item| TomlValue::String(item.clone()))
596
- .collect(),
597
- ),
598
- );
599
- }
600
-
601
- if let Some(locale) = &self.locale {
602
- root.insert("locale".to_string(), TomlValue::String(locale.clone()));
603
- }
604
-
605
- let mut rules: BTreeMap<String, TomlValue> = BTreeMap::new();
606
- for (name, value) in &self.rules {
607
- let converted = yaml_owned_to_toml_value(value)?;
608
- rules.insert(name.clone(), converted);
609
- }
610
- if !rules.is_empty() {
611
- root.insert(
612
- "rules".to_string(),
613
- TomlValue::Table(toml::map::Map::from_iter(rules)),
614
- );
615
- }
616
-
617
- Ok(toml::to_string_pretty(&TomlValue::Table(root))
618
- .expect("serializing TOML Value should not fail"))
619
- }
620
-
621
- fn finalize(&mut self, envx: &dyn Env, base_dir: &Path) -> Result<(), String> {
622
- let mut builder = GitignoreBuilder::new(base_dir);
623
- builder.allow_unclosed_class(false);
624
- let mut any_pattern = false;
625
-
626
- for pat in &self.ignore_patterns {
627
- let normalized = pat.trim_end_matches(['\r']);
628
- if let Err(err) = builder.add_line(None, normalized) {
629
- return Err(format!(
630
- "invalid config: ignore pattern '{normalized}' is invalid: {err}"
631
- ));
632
- }
633
- any_pattern = true;
634
- }
635
-
636
- let mut extra_patterns: Vec<String> = Vec::new();
637
- for source in &self.ignore_from_files {
638
- let source_path = Path::new(source);
639
- let resolved = if source_path.is_absolute() {
640
- source_path.to_path_buf()
641
- } else {
642
- base_dir.join(source_path)
643
- };
644
- let data = match envx.read_to_string(&resolved) {
645
- Ok(text) => text,
646
- Err(err) => {
647
- return Err(format!(
648
- "failed to read ignore-from-file {}: {err}",
649
- resolved.display()
650
- ));
651
- }
652
- };
653
- for line in data.lines() {
654
- let normalized = line.trim_end_matches(['\r']);
655
- if normalized.trim().is_empty() {
656
- continue;
657
- }
658
- if let Err(err) = builder.add_line(Some(resolved.clone()), normalized) {
659
- return Err(format!(
660
- "invalid config: ignore-from-file pattern in {} is invalid: {err}",
661
- resolved.display()
662
- ));
663
- }
664
- extra_patterns.push(normalized.to_string());
665
- any_pattern = true;
666
- }
667
- }
668
-
669
- if !extra_patterns.is_empty() {
670
- self.ignore_patterns.extend(extra_patterns);
671
- }
672
-
673
- self.ignore_matcher = if any_pattern {
674
- Some(
675
- builder
676
- .build()
677
- .expect("ignore matcher build should not fail after validation"),
678
- )
679
- } else {
680
- None
681
- };
682
-
683
- self.build_yaml_matcher(base_dir);
684
-
685
- for filter in self.rule_filters.values_mut() {
686
- build_rule_filter(filter, envx, base_dir)?;
687
- }
688
- Ok(())
689
- }
690
- }
691
-
692
- fn build_rule_filter(
693
- filter: &mut RuleFilter,
694
- envx: &dyn Env,
695
- base_dir: &Path,
696
- ) -> Result<(), String> {
697
- if filter.patterns.is_empty() && filter.from_files.is_empty() {
698
- filter.matcher = None;
699
- return Ok(());
700
- }
701
-
702
- let mut builder = GitignoreBuilder::new(base_dir);
703
- builder.allow_unclosed_class(false);
704
- let mut any_pattern = false;
705
-
706
- for pat in &filter.patterns {
707
- let normalized = pat.trim_end_matches(['\r']);
708
- if let Err(err) = builder.add_line(None, normalized) {
709
- return Err(format!(
710
- "invalid config: ignore pattern '{normalized}' is invalid: {err}"
711
- ));
712
- }
713
- any_pattern = true;
714
- }
715
-
716
- let mut extra_patterns: Vec<String> = Vec::new();
717
- for source in &filter.from_files {
718
- let source_path = Path::new(source);
719
- let resolved = if source_path.is_absolute() {
720
- source_path.to_path_buf()
721
- } else {
722
- base_dir.join(source_path)
723
- };
724
- let data = match envx.read_to_string(&resolved) {
725
- Ok(text) => text,
726
- Err(err) => {
727
- return Err(format!(
728
- "failed to read ignore-from-file {}: {err}",
729
- resolved.display()
730
- ));
731
- }
732
- };
733
- for line in data.lines() {
734
- let normalized = line.trim_end_matches(['\r']);
735
- if normalized.trim().is_empty() {
736
- continue;
737
- }
738
- if let Err(err) = builder.add_line(Some(resolved.clone()), normalized) {
739
- return Err(format!(
740
- "invalid config: ignore-from-file pattern in {} is invalid: {err}",
741
- resolved.display()
742
- ));
743
- }
744
- extra_patterns.push(normalized.to_string());
745
- any_pattern = true;
746
- }
747
- }
748
-
749
- if !extra_patterns.is_empty() {
750
- filter.patterns.extend(extra_patterns);
751
- }
752
-
753
- filter.matcher = if any_pattern {
754
- Some(
755
- builder
756
- .build()
757
- .expect("rule ignore matcher build should not fail after validation"),
758
- )
759
- } else {
760
- None
761
- };
762
- Ok(())
763
- }
764
-
765
- fn load_ignore_patterns(node: &YamlOwned) -> Result<Vec<String>, String> {
766
- let mut out = Vec::new();
767
- if let Some(seq) = node.as_sequence() {
768
- for it in seq {
769
- let Some(s) = it.as_str() else {
770
- return Err(
771
- "invalid config: ignore should contain file patterns".to_string()
772
- );
773
- };
774
- out.extend(patterns_from_scalar(s));
775
- }
776
- } else if let Some(s) = node.as_str() {
777
- out.extend(patterns_from_scalar(s));
778
- } else {
779
- return Err("invalid config: ignore should contain file patterns".to_string());
780
- }
781
- Ok(out)
782
- }
783
-
784
- fn load_ignore_from_files(node: &YamlOwned) -> Result<Vec<String>, String> {
785
- if let Some(seq) = node.as_sequence() {
786
- let mut files = Vec::new();
787
- for it in seq {
788
- let Some(s) = it.as_str() else {
789
- return Err(
790
- "invalid config: ignore-from-file should contain filename(s), either as a list or string"
791
- .to_string(),
792
- );
793
- };
794
- files.push(s.to_owned());
795
- }
796
- Ok(files)
797
- } else if let Some(s) = node.as_str() {
798
- Ok(vec![s.to_owned()])
799
- } else {
800
- Err(
801
- "invalid config: ignore-from-file should contain filename(s), either as a list or string"
802
- .to_string(),
803
- )
804
- }
805
- }
806
-
807
- fn patterns_from_scalar(value: &str) -> Vec<String> {
808
- value
809
- .lines()
810
- .map(|line| line.trim_end_matches(['\r']))
811
- .filter(|line| !line.trim().is_empty())
812
- .map(std::string::ToString::to_string)
813
- .collect()
814
- }
815
-
816
- fn determine_rule_level(node: &YamlOwned) -> Option<RuleLevel> {
817
- if let Some(s) = node.as_str() {
818
- return if s == "disable" {
819
- None
820
- } else {
821
- Some(RuleLevel::Error)
822
- };
823
- }
824
-
825
- if let Some(flag) = node.as_bool() {
826
- return flag.then_some(RuleLevel::Error);
827
- }
828
-
829
- node.as_mapping()
830
- .and_then(|map| {
831
- map.iter().find_map(|(key, value)| {
832
- (key.as_str() == Some("level"))
833
- .then(|| value.as_str().and_then(RuleLevel::parse))
834
- })
835
- })
836
- .flatten()
837
- .or(Some(RuleLevel::Error))
838
- }
839
-
840
- fn validate_rule_value(name: &str, value: &YamlOwned) -> Result<(), String> {
841
- if let Some(text) = value.as_str() {
842
- return match text {
843
- "enable" | "disable" => Ok(()),
844
- _ => Err(format!(
845
- "invalid config: rule '{name}' should be 'enable', 'disable', or a mapping"
846
- )),
847
- };
848
- }
849
-
850
- if value.as_bool().is_some() {
851
- return Ok(());
852
- }
853
-
854
- if let Some(map) = value.as_mapping() {
855
- if name == "quoted-strings" {
856
- validate_quoted_strings_rule(map)?;
857
- return Ok(());
858
- }
859
-
860
- for (key, val) in map {
861
- if handle_common_rule_key(name, key, val)? {
862
- continue;
863
- }
864
-
865
- match name {
866
- "anchors" => validate_anchors_option(key, val)?,
867
- "braces" => validate_brace_like_option("braces", key, val)?,
868
- "brackets" => validate_brace_like_option("brackets", key, val)?,
869
- "document-end" => validate_document_end_option(key, val)?,
870
- "document-start" => validate_document_start_option(key, val)?,
871
- "empty-lines" => validate_empty_lines_option(key, val)?,
872
- "commas" => validate_commas_option(key, val)?,
873
- "comments" => validate_comments_option(key, val)?,
874
- "new-lines" => validate_new_lines_option(key, val)?,
875
- "octal-values" => validate_octal_values_option(key, val)?,
876
- "float-values" => validate_float_values_option(key, val)?,
877
- "empty-values" => validate_empty_values_option(key, val)?,
878
- "key-duplicates" => validate_key_duplicates_option(key, val)?,
879
- "hyphens" => validate_hyphens_option(key, val)?,
880
- "truthy" => validate_truthy_option(key, val)?,
881
- "key-ordering" => validate_key_ordering_option(key, val)?,
882
- "indentation" => validate_indentation_option(key, val)?,
883
- "line-length" => validate_line_length_option(key, val)?,
884
- "trailing-spaces" => {
885
- let key_name = describe_rule_option_key(key);
886
- return Err(format!(
887
- "invalid config: unknown option \"{key_name}\" for rule \"trailing-spaces\""
888
- ));
889
- }
890
- "comments-indentation" => {
891
- let key_name = describe_rule_option_key(key);
892
- return Err(format!(
893
- "invalid config: unknown option \"{key_name}\" for rule \"comments-indentation\""
894
- ));
895
- }
896
- _ => {}
897
- }
898
- }
899
- return Ok(());
900
- }
901
-
902
- Err(format!(
903
- "invalid config: rule '{name}' should be 'enable', 'disable', or a mapping"
904
- ))
905
- }
906
-
907
- fn handle_common_rule_key(
908
- rule: &str,
909
- key: &YamlOwned,
910
- val: &YamlOwned,
911
- ) -> Result<bool, String> {
912
- if key.as_str() == Some("level") {
913
- let Some(level_text) = val.as_str() else {
914
- return Err(format!(
915
- "invalid config: rule '{rule}' level should be \"error\" or \"warning\""
916
- ));
917
- };
918
- if RuleLevel::parse(level_text).is_none() {
919
- return Err(format!(
920
- "invalid config: rule '{rule}' level should be \"error\" or \"warning\""
921
- ));
922
- }
923
- return Ok(true);
924
- }
925
-
926
- if key.as_str() == Some("ignore") {
927
- load_ignore_patterns(val)?;
928
- return Ok(true);
929
- }
930
-
931
- if key.as_str() == Some("ignore-from-file") {
932
- load_ignore_from_files(val)?;
933
- return Ok(true);
934
- }
935
-
936
- Ok(false)
937
- }
938
-
939
- fn validate_document_end_option(
940
- key: &YamlOwned,
941
- val: &YamlOwned,
942
- ) -> Result<(), String> {
943
- match key.as_str() {
944
- Some("present") => validate_bool_option(val, "document-end", "present"),
945
- Some(other) => Err(format!(
946
- "invalid config: unknown option \"{other}\" for rule \"document-end\""
947
- )),
948
- None => {
949
- let key_name = describe_rule_option_key(key);
950
- Err(format!(
951
- "invalid config: unknown option \"{key_name}\" for rule \"document-end\""
952
- ))
953
- }
954
- }
955
- }
956
-
957
- fn validate_document_start_option(
958
- key: &YamlOwned,
959
- val: &YamlOwned,
960
- ) -> Result<(), String> {
961
- match key.as_str() {
962
- Some("present") => validate_bool_option(val, "document-start", "present"),
963
- Some(other) => Err(format!(
964
- "invalid config: unknown option \"{other}\" for rule \"document-start\""
965
- )),
966
- None => {
967
- let key_name = describe_rule_option_key(key);
968
- Err(format!(
969
- "invalid config: unknown option \"{key_name}\" for rule \"document-start\""
970
- ))
971
- }
972
- }
973
- }
974
-
975
- fn validate_brace_like_option(
976
- rule: &str,
977
- key: &YamlOwned,
978
- val: &YamlOwned,
979
- ) -> Result<(), String> {
980
- let Some(name) = key.as_str() else {
981
- let key_name = describe_rule_option_key(key);
982
- return Err(format!(
983
- "invalid config: unknown option \"{key_name}\" for rule \"{rule}\""
984
- ));
985
- };
986
-
987
- match name {
988
- "forbid" => {
989
- if val.as_bool().is_some() || matches!(val.as_str(), Some("non-empty")) {
990
- Ok(())
991
- } else {
992
- Err(format!(
993
- "invalid config: option \"forbid\" of \"{rule}\" should be bool or \"non-empty\""
994
- ))
995
- }
996
- }
997
- "min-spaces-inside" => val.as_integer().map(|_| ()).ok_or_else(|| {
998
- format!("invalid config: option \"min-spaces-inside\" of \"{rule}\" should be int")
999
- }),
1000
- "max-spaces-inside" => val.as_integer().map(|_| ()).ok_or_else(|| {
1001
- format!("invalid config: option \"max-spaces-inside\" of \"{rule}\" should be int")
1002
- }),
1003
- "min-spaces-inside-empty" => val.as_integer().map(|_| ()).ok_or_else(|| {
1004
- format!(
1005
- "invalid config: option \"min-spaces-inside-empty\" of \"{rule}\" should be int"
1006
- )
1007
- }),
1008
- "max-spaces-inside-empty" => val.as_integer().map(|_| ()).ok_or_else(|| {
1009
- format!(
1010
- "invalid config: option \"max-spaces-inside-empty\" of \"{rule}\" should be int"
1011
- )
1012
- }),
1013
- other => Err(format!(
1014
- "invalid config: unknown option \"{other}\" for rule \"{rule}\""
1015
- )),
1016
- }
1017
- }
1018
-
1019
- fn validate_anchors_option(key: &YamlOwned, val: &YamlOwned) -> Result<(), String> {
1020
- let Some(name) = key.as_str() else {
1021
- let key_name = describe_rule_option_key(key);
1022
- return Err(format!(
1023
- "invalid config: unknown option \"{key_name}\" for rule \"anchors\""
1024
- ));
1025
- };
1026
-
1027
- match name {
1028
- "forbid-undeclared-aliases"
1029
- | "forbid-duplicated-anchors"
1030
- | "forbid-unused-anchors" => {
1031
- if val.as_bool().is_some() {
1032
- Ok(())
1033
- } else {
1034
- Err(format!(
1035
- "invalid config: option \"{name}\" of \"anchors\" should be bool"
1036
- ))
1037
- }
1038
- }
1039
- other => Err(format!(
1040
- "invalid config: unknown option \"{other}\" for rule \"anchors\""
1041
- )),
1042
- }
1043
- }
1044
-
1045
- fn validate_hyphens_option(key: &YamlOwned, val: &YamlOwned) -> Result<(), String> {
1046
- match key.as_str() {
1047
- Some("max-spaces-after") => val.as_integer().map(|_| ()).ok_or_else(|| {
1048
- "invalid config: option \"max-spaces-after\" of \"hyphens\" should be int".to_string()
1049
- }),
1050
- Some(other) => Err(format!(
1051
- "invalid config: unknown option \"{other}\" for rule \"hyphens\""
1052
- )),
1053
- None => {
1054
- let key_name = describe_rule_option_key(key);
1055
- Err(format!(
1056
- "invalid config: unknown option \"{key_name}\" for rule \"hyphens\""
1057
- ))
1058
- }
1059
- }
1060
- }
1061
-
1062
- fn validate_commas_option(key: &YamlOwned, val: &YamlOwned) -> Result<(), String> {
1063
- let Some(name) = key.as_str() else {
1064
- let key_name = describe_rule_option_key(key);
1065
- return Err(format!(
1066
- "invalid config: unknown option \"{key_name}\" for rule \"commas\""
1067
- ));
1068
- };
1069
-
1070
- match name {
1071
- "max-spaces-before" => val.as_integer().map(|_| ()).ok_or_else(|| {
1072
- "invalid config: option \"max-spaces-before\" of \"commas\" should be int".to_string()
1073
- }),
1074
- "min-spaces-after" => val.as_integer().map(|_| ()).ok_or_else(|| {
1075
- "invalid config: option \"min-spaces-after\" of \"commas\" should be int".to_string()
1076
- }),
1077
- "max-spaces-after" => val.as_integer().map(|_| ()).ok_or_else(|| {
1078
- "invalid config: option \"max-spaces-after\" of \"commas\" should be int".to_string()
1079
- }),
1080
- other => Err(format!(
1081
- "invalid config: unknown option \"{other}\" for rule \"commas\""
1082
- )),
1083
- }
1084
- }
1085
-
1086
- fn validate_comments_option(key: &YamlOwned, val: &YamlOwned) -> Result<(), String> {
1087
- let Some(name) = key.as_str() else {
1088
- // Non-string keys are ignored during deep merge, matching yamllint.
1089
- return Ok(());
1090
- };
1091
-
1092
- match name {
1093
- "require-starting-space" => validate_bool_option(val, "comments", "require-starting-space"),
1094
- "ignore-shebangs" => validate_bool_option(val, "comments", "ignore-shebangs"),
1095
- "min-spaces-from-content" => val.as_integer().map(|_| ()).ok_or_else(|| {
1096
- "invalid config: option \"min-spaces-from-content\" of \"comments\" should be int"
1097
- .to_string()
1098
- }),
1099
- other => Err(format!(
1100
- "invalid config: unknown option \"{other}\" for rule \"comments\""
1101
- )),
1102
- }
1103
- }
1104
-
1105
- fn validate_empty_lines_option(key: &YamlOwned, val: &YamlOwned) -> Result<(), String> {
1106
- match key.as_str() {
1107
- Some("max") => val.as_integer().map(|_| ()).ok_or_else(|| {
1108
- "invalid config: option \"max\" of \"empty-lines\" should be int"
1109
- .to_string()
1110
- }),
1111
- Some("max-start") => val.as_integer().map(|_| ()).ok_or_else(|| {
1112
- "invalid config: option \"max-start\" of \"empty-lines\" should be int"
1113
- .to_string()
1114
- }),
1115
- Some("max-end") => val.as_integer().map(|_| ()).ok_or_else(|| {
1116
- "invalid config: option \"max-end\" of \"empty-lines\" should be int"
1117
- .to_string()
1118
- }),
1119
- Some(other) => Err(format!(
1120
- "invalid config: unknown option \"{other}\" for rule \"empty-lines\""
1121
- )),
1122
- None => {
1123
- let key_name = describe_rule_option_key(key);
1124
- Err(format!(
1125
- "invalid config: unknown option \"{key_name}\" for rule \"empty-lines\""
1126
- ))
1127
- }
1128
- }
1129
- }
1130
-
1131
- fn validate_line_length_option(key: &YamlOwned, val: &YamlOwned) -> Result<(), String> {
1132
- match key.as_str() {
1133
- Some("max") => val.as_integer().map(|_| ()).ok_or_else(|| {
1134
- "invalid config: option \"max\" of \"line-length\" should be int"
1135
- .to_string()
1136
- }),
1137
- Some("allow-non-breakable-words") => {
1138
- validate_bool_option(val, "line-length", "allow-non-breakable-words")
1139
- }
1140
- Some("allow-non-breakable-inline-mappings") => validate_bool_option(
1141
- val,
1142
- "line-length",
1143
- "allow-non-breakable-inline-mappings",
1144
- ),
1145
- Some(other) => Err(format!(
1146
- "invalid config: unknown option \"{other}\" for rule \"line-length\""
1147
- )),
1148
- None => {
1149
- let key_name = describe_rule_option_key(key);
1150
- Err(format!(
1151
- "invalid config: unknown option \"{key_name}\" for rule \"line-length\""
1152
- ))
1153
- }
1154
- }
1155
- }
1156
-
1157
- fn validate_new_lines_option(key: &YamlOwned, val: &YamlOwned) -> Result<(), String> {
1158
- if key.as_str() != Some("type") {
1159
- let key_name = describe_rule_option_key(key);
1160
- return Err(format!(
1161
- "invalid config: unknown option \"{key_name}\" for rule \"new-lines\""
1162
- ));
1163
- }
1164
-
1165
- let Some(kind) = val.as_str() else {
1166
- return Err(
1167
- "invalid config: option \"type\" of \"new-lines\" should be in ('unix', 'dos', 'platform')"
1168
- .to_string(),
1169
- );
1170
- };
1171
-
1172
- if matches!(kind, "unix" | "dos" | "platform") {
1173
- Ok(())
1174
- } else {
1175
- Err(
1176
- "invalid config: option \"type\" of \"new-lines\" should be in ('unix', 'dos', 'platform')"
1177
- .to_string(),
1178
- )
1179
- }
1180
- }
1181
-
1182
- fn validate_octal_values_option(
1183
- key: &YamlOwned,
1184
- val: &YamlOwned,
1185
- ) -> Result<(), String> {
1186
- match key.as_str() {
1187
- Some("forbid-implicit-octal") => {
1188
- validate_bool_option(val, "octal-values", "forbid-implicit-octal")
1189
- }
1190
- Some("forbid-explicit-octal") => {
1191
- validate_bool_option(val, "octal-values", "forbid-explicit-octal")
1192
- }
1193
- Some(other) => Err(format!(
1194
- "invalid config: unknown option \"{other}\" for rule \"octal-values\""
1195
- )),
1196
- None => {
1197
- let key_name = describe_rule_option_key(key);
1198
- Err(format!(
1199
- "invalid config: unknown option \"{key_name}\" for rule \"octal-values\""
1200
- ))
1201
- }
1202
- }
1203
- }
1204
-
1205
- fn validate_empty_values_option(
1206
- key: &YamlOwned,
1207
- val: &YamlOwned,
1208
- ) -> Result<(), String> {
1209
- match key.as_str() {
1210
- Some("forbid-in-block-mappings") => {
1211
- validate_bool_option(val, "empty-values", "forbid-in-block-mappings")
1212
- }
1213
- Some("forbid-in-flow-mappings") => {
1214
- validate_bool_option(val, "empty-values", "forbid-in-flow-mappings")
1215
- }
1216
- Some("forbid-in-block-sequences") => {
1217
- validate_bool_option(val, "empty-values", "forbid-in-block-sequences")
1218
- }
1219
- Some(other) => Err(format!(
1220
- "invalid config: unknown option \"{other}\" for rule \"empty-values\""
1221
- )),
1222
- None => {
1223
- let key_name = describe_rule_option_key(key);
1224
- Err(format!(
1225
- "invalid config: unknown option \"{key_name}\" for rule \"empty-values\""
1226
- ))
1227
- }
1228
- }
1229
- }
1230
-
1231
- fn validate_float_values_option(
1232
- key: &YamlOwned,
1233
- val: &YamlOwned,
1234
- ) -> Result<(), String> {
1235
- match key.as_str() {
1236
- Some("require-numeral-before-decimal") => {
1237
- validate_bool_option(val, "float-values", "require-numeral-before-decimal")
1238
- }
1239
- Some("forbid-scientific-notation") => {
1240
- validate_bool_option(val, "float-values", "forbid-scientific-notation")
1241
- }
1242
- Some("forbid-nan") => validate_bool_option(val, "float-values", "forbid-nan"),
1243
- Some("forbid-inf") => validate_bool_option(val, "float-values", "forbid-inf"),
1244
- Some(other) => Err(format!(
1245
- "invalid config: unknown option \"{other}\" for rule \"float-values\""
1246
- )),
1247
- None => {
1248
- let key_name = describe_rule_option_key(key);
1249
- Err(format!(
1250
- "invalid config: unknown option \"{key_name}\" for rule \"float-values\""
1251
- ))
1252
- }
1253
- }
1254
- }
1255
-
1256
- fn validate_key_duplicates_option(
1257
- key: &YamlOwned,
1258
- val: &YamlOwned,
1259
- ) -> Result<(), String> {
1260
- match key.as_str() {
1261
- Some("forbid-duplicated-merge-keys") => {
1262
- validate_bool_option(val, "key-duplicates", "forbid-duplicated-merge-keys")
1263
- }
1264
- Some(other) => Err(format!(
1265
- "invalid config: unknown option \"{other}\" for rule \"key-duplicates\""
1266
- )),
1267
- None => {
1268
- let key_name = describe_rule_option_key(key);
1269
- Err(format!(
1270
- "invalid config: unknown option \"{key_name}\" for rule \"key-duplicates\""
1271
- ))
1272
- }
1273
- }
1274
- }
1275
-
1276
- fn validate_truthy_option(key: &YamlOwned, val: &YamlOwned) -> Result<(), String> {
1277
- match key.as_str() {
1278
- Some("allowed-values") => {
1279
- let Some(seq) = val.as_sequence() else {
1280
- return Err(format!(
1281
- "invalid config: option \"allowed-values\" of \"truthy\" should only contain values in {TRUTHY_ALLOWED_VALUES_DISPLAY}"
1282
- ));
1283
- };
1284
- for item in seq {
1285
- let Some(text) = item.as_str() else {
1286
- return Err(format!(
1287
- "invalid config: option \"allowed-values\" of \"truthy\" should only contain values in {TRUTHY_ALLOWED_VALUES_DISPLAY}"
1288
- ));
1289
- };
1290
- if !TRUTHY_ALLOWED_VALUES.iter().any(|allowed| allowed == &text) {
1291
- return Err(format!(
1292
- "invalid config: option \"allowed-values\" of \"truthy\" should only contain values in {TRUTHY_ALLOWED_VALUES_DISPLAY}"
1293
- ));
1294
- }
1295
- }
1296
- Ok(())
1297
- }
1298
- Some("check-keys") => {
1299
- if val.as_bool().is_none() {
1300
- Err(
1301
- "invalid config: option \"check-keys\" of \"truthy\" should be bool"
1302
- .to_string(),
1303
- )
1304
- } else {
1305
- Ok(())
1306
- }
1307
- }
1308
- Some(other) => Err(format!(
1309
- "invalid config: unknown option \"{other}\" for rule \"truthy\""
1310
- )),
1311
- None => {
1312
- let key_name = describe_rule_option_key(key);
1313
- Err(format!(
1314
- "invalid config: unknown option \"{key_name}\" for rule \"truthy\""
1315
- ))
1316
- }
1317
- }
1318
- }
1319
-
1320
- fn validate_key_ordering_option(
1321
- key: &YamlOwned,
1322
- val: &YamlOwned,
1323
- ) -> Result<(), String> {
1324
- match key.as_str() {
1325
- Some("ignored-keys") => {
1326
- if let Some(seq) = val.as_sequence() {
1327
- for entry in seq {
1328
- let Some(text) = entry.as_str() else {
1329
- return Err(
1330
- "invalid config: option \"ignored-keys\" of \"key-ordering\" should contain regex strings"
1331
- .to_string(),
1332
- );
1333
- };
1334
- Regex::new(text).map_err(|err| {
1335
- format!(
1336
- "invalid config: option \"ignored-keys\" of \"key-ordering\" contains invalid regex '{text}': {err}"
1337
- )
1338
- })?;
1339
- }
1340
- Ok(())
1341
- } else if let Some(text) = val.as_str() {
1342
- Regex::new(text).map_err(|err| {
1343
- format!(
1344
- "invalid config: option \"ignored-keys\" of \"key-ordering\" contains invalid regex '{text}': {err}"
1345
- )
1346
- })?;
1347
- Ok(())
1348
- } else {
1349
- Err(
1350
- "invalid config: option \"ignored-keys\" of \"key-ordering\" should contain regex strings"
1351
- .to_string(),
1352
- )
1353
- }
1354
- }
1355
- Some(other) => Err(format!(
1356
- "invalid config: unknown option \"{other}\" for rule \"key-ordering\""
1357
- )),
1358
- None => {
1359
- let key_name = describe_rule_option_key(key);
1360
- Err(format!(
1361
- "invalid config: unknown option \"{key_name}\" for rule \"key-ordering\""
1362
- ))
1363
- }
1364
- }
1365
- }
1366
-
1367
- fn validate_indentation_option(key: &YamlOwned, val: &YamlOwned) -> Result<(), String> {
1368
- match key.as_str() {
1369
- Some("spaces") => {
1370
- if val.as_integer().is_some() || val.as_str() == Some("consistent") {
1371
- Ok(())
1372
- } else {
1373
- Err(
1374
- "invalid config: option \"spaces\" of \"indentation\" should be in (<class 'int'>, 'consistent')"
1375
- .to_string(),
1376
- )
1377
- }
1378
- }
1379
- Some("indent-sequences") => {
1380
- if val.as_bool().is_some()
1381
- || matches!(val.as_str(), Some("whatever" | "consistent"))
1382
- {
1383
- Ok(())
1384
- } else {
1385
- Err(
1386
- "invalid config: option \"indent-sequences\" of \"indentation\" should be in (<class 'bool'>, 'whatever', 'consistent')"
1387
- .to_string(),
1388
- )
1389
- }
1390
- }
1391
- Some("check-multi-line-strings") => {
1392
- if val.as_bool().is_some() {
1393
- Ok(())
1394
- } else {
1395
- Err(
1396
- "invalid config: option \"check-multi-line-strings\" of \"indentation\" should be bool"
1397
- .to_string(),
1398
- )
1399
- }
1400
- }
1401
- Some(other) => Err(format!(
1402
- "invalid config: unknown option \"{other}\" for rule \"indentation\""
1403
- )),
1404
- None => {
1405
- let key_name = describe_rule_option_key(key);
1406
- Err(format!(
1407
- "invalid config: unknown option \"{key_name}\" for rule \"indentation\""
1408
- ))
1409
- }
1410
- }
1411
- }
1412
-
1413
- fn validate_quoted_strings_rule(map: &MappingOwned) -> Result<(), String> {
1414
- let mut state = QuotedStringsValidationState::default();
1415
- for (key, val) in map {
1416
- if handle_common_rule_key("quoted-strings", key, val)? {
1417
- continue;
1418
- }
1419
- validate_quoted_strings_option(key, val, &mut state)?;
1420
- }
1421
- state.finish()
1422
- }
1423
-
1424
- #[derive(Default)]
1425
- struct QuotedStringsValidationState {
1426
- required: Option<QuotedStringsRequired>,
1427
- extra_required_count: Option<usize>,
1428
- extra_allowed_count: Option<usize>,
1429
- }
1430
-
1431
- #[derive(Clone, Copy, PartialEq, Eq)]
1432
- enum QuotedStringsRequired {
1433
- True,
1434
- False,
1435
- OnlyWhenNeeded,
1436
- }
1437
-
1438
- impl QuotedStringsValidationState {
1439
- fn finish(&self) -> Result<(), String> {
1440
- let required = self.required.unwrap_or(QuotedStringsRequired::True);
1441
- let extra_required = self.extra_required_count.unwrap_or(0);
1442
- let extra_allowed = self.extra_allowed_count.unwrap_or(0);
1443
-
1444
- if matches!(required, QuotedStringsRequired::True) && extra_allowed > 0 {
1445
- return Err(
1446
- "invalid config: quoted-strings: cannot use both \"required: true\" and \"extra-allowed\""
1447
- .to_string(),
1448
- );
1449
- }
1450
- if matches!(required, QuotedStringsRequired::True) && extra_required > 0 {
1451
- return Err(
1452
- "invalid config: quoted-strings: cannot use both \"required: true\" and \"extra-required\""
1453
- .to_string(),
1454
- );
1455
- }
1456
- if matches!(required, QuotedStringsRequired::False) && extra_allowed > 0 {
1457
- return Err(
1458
- "invalid config: quoted-strings: cannot use both \"required: false\" and \"extra-allowed\""
1459
- .to_string(),
1460
- );
1461
- }
1462
-
1463
- Ok(())
1464
- }
1465
- }
1466
-
1467
- fn validate_quoted_strings_option(
1468
- key: &YamlOwned,
1469
- val: &YamlOwned,
1470
- state: &mut QuotedStringsValidationState,
1471
- ) -> Result<(), String> {
1472
- match key.as_str() {
1473
- Some("quote-type") => validate_quote_type_option(val),
1474
- Some("required") => validate_required_option(val, state),
1475
- Some("extra-required") => validate_regex_list_option(
1476
- val,
1477
- "extra-required",
1478
- &mut state.extra_required_count,
1479
- ),
1480
- Some("extra-allowed") => validate_regex_list_option(
1481
- val,
1482
- "extra-allowed",
1483
- &mut state.extra_allowed_count,
1484
- ),
1485
- Some("allow-quoted-quotes") => {
1486
- validate_bool_option(val, "quoted-strings", "allow-quoted-quotes")
1487
- }
1488
- Some("check-keys") => validate_bool_option(val, "quoted-strings", "check-keys"),
1489
- Some(other) => Err(format!(
1490
- "invalid config: unknown option \"{other}\" for rule \"quoted-strings\""
1491
- )),
1492
- None => {
1493
- let key_name = describe_rule_option_key(key);
1494
- Err(format!(
1495
- "invalid config: unknown option \"{key_name}\" for rule \"quoted-strings\""
1496
- ))
1497
- }
1498
- }
1499
- }
1500
-
1501
- fn validate_quote_type_option(val: &YamlOwned) -> Result<(), String> {
1502
- let Some(text) = val.as_str() else {
1503
- return Err(
1504
- "invalid config: option \"quote-type\" of \"quoted-strings\" should be in ('any', 'single', 'double')"
1505
- .to_string(),
1506
- );
1507
- };
1508
- if matches!(text, "any" | "single" | "double") {
1509
- Ok(())
1510
- } else {
1511
- Err(
1512
- "invalid config: option \"quote-type\" of \"quoted-strings\" should be in ('any', 'single', 'double')"
1513
- .to_string(),
1514
- )
1515
- }
1516
- }
1517
-
1518
- fn validate_required_option(
1519
- val: &YamlOwned,
1520
- state: &mut QuotedStringsValidationState,
1521
- ) -> Result<(), String> {
1522
- if let Some(flag) = val.as_bool() {
1523
- state.required = Some(if flag {
1524
- QuotedStringsRequired::True
1525
- } else {
1526
- QuotedStringsRequired::False
1527
- });
1528
- Ok(())
1529
- } else if val.as_str() == Some("only-when-needed") {
1530
- state.required = Some(QuotedStringsRequired::OnlyWhenNeeded);
1531
- Ok(())
1532
- } else {
1533
- Err(
1534
- "invalid config: option \"required\" of \"quoted-strings\" should be in (True, False, 'only-when-needed')"
1535
- .to_string(),
1536
- )
1537
- }
1538
- }
1539
-
1540
- fn validate_regex_list_option(
1541
- val: &YamlOwned,
1542
- option_name: &str,
1543
- count_slot: &mut Option<usize>,
1544
- ) -> Result<(), String> {
1545
- let Some(seq) = val.as_sequence() else {
1546
- return Err(format!(
1547
- "invalid config: option \"{option_name}\" of \"quoted-strings\" should only contain values in [<class 'str'>]"
1548
- ));
1549
- };
1550
- *count_slot = Some(seq.len());
1551
- for entry in seq {
1552
- let Some(text) = entry.as_str() else {
1553
- return Err(format!(
1554
- "invalid config: option \"{option_name}\" of \"quoted-strings\" should only contain values in [<class 'str'>]"
1555
- ));
1556
- };
1557
- Regex::new(text).map_err(|err| {
1558
- format!(
1559
- "invalid config: regex \"{text}\" in option \"{option_name}\" of \"quoted-strings\" is invalid: {err}"
1560
- )
1561
- })?;
1562
- }
1563
- Ok(())
1564
- }
1565
-
1566
- fn validate_bool_option(
1567
- val: &YamlOwned,
1568
- rule_name: &str,
1569
- option_name: &str,
1570
- ) -> Result<(), String> {
1571
- if val.as_bool().is_some() {
1572
- Ok(())
1573
- } else {
1574
- Err(format!(
1575
- "invalid config: option \"{option_name}\" of \"{rule_name}\" should be bool"
1576
- ))
1577
- }
1578
- }
1579
-
1580
- fn resolve_extend_path(
1581
- entry: &str,
1582
- envx: &dyn Env,
1583
- base_dir: Option<&Path>,
1584
- ) -> PathBuf {
1585
- let candidate = PathBuf::from(entry);
1586
- if candidate.is_absolute() {
1587
- return candidate;
1588
- }
1589
- if let Some(joined) = base_dir
1590
- .map(|base| base.join(&candidate))
1591
- .filter(|candidate| envx.path_exists(candidate))
1592
- {
1593
- return joined;
1594
- }
1595
- let cwd = envx.current_dir();
1596
- let fallback = cwd.join(&candidate);
1597
- if envx.path_exists(&fallback) {
1598
- fallback
1599
- } else {
1600
- candidate
1601
- }
1602
- }
1603
-
1604
- fn deep_merge_yaml_owned(dst: &mut YamlOwned, src: &YamlOwned) {
1605
- if let (Some(_), Some(src_map)) = (dst.as_mapping(), src.as_mapping()) {
1606
- for (k, v) in src_map {
1607
- let Some(key) = k.as_str() else {
1608
- continue;
1609
- };
1610
- let merged = dst.as_mapping_get_mut(key).is_some_and(|dv| {
1611
- deep_merge_yaml_owned(dv, v);
1612
- true
1613
- });
1614
- if !merged {
1615
- let map = dst.as_mapping_mut().expect("checked mapping above");
1616
- map.insert(
1617
- YamlOwned::Value(ScalarOwned::String(key.to_owned())),
1618
- v.clone(),
1619
- );
1620
- }
1621
- }
1622
- } else {
1623
- *dst = src.clone();
1624
- }
1625
- }
1626
-
1627
- fn describe_rule_option_key(key: &YamlOwned) -> String {
1628
- match (
1629
- key.as_integer(),
1630
- key.as_floating_point(),
1631
- key.as_bool(),
1632
- key.is_null(),
1633
- key.as_str(),
1634
- ) {
1635
- (Some(num), _, _, _, _) => num.to_string(),
1636
- (None, Some(float), _, _, _) => float.to_string(),
1637
- (None, None, Some(flag), _, _) => flag.to_string(),
1638
- (None, None, None, true, _) => "None".to_string(),
1639
- (None, None, None, false, Some(text)) => text.to_owned(),
1640
- _ => format!("{key:?}"),
1641
- }
1642
- }
1643
-
1644
- fn toml_value_to_yaml_owned(value: &TomlValue) -> YamlOwned {
1645
- match value {
1646
- TomlValue::String(text) => YamlOwned::Value(ScalarOwned::String(text.clone())),
1647
- TomlValue::Integer(num) => YamlOwned::Value(ScalarOwned::Integer(*num)),
1648
- TomlValue::Float(num) => {
1649
- let rendered = num.to_string();
1650
- YamlOwned::load_from_str(&rendered)
1651
- .ok()
1652
- .and_then(|docs| docs.into_iter().next())
1653
- .unwrap_or(YamlOwned::Value(ScalarOwned::String(rendered)))
1654
- }
1655
- TomlValue::Boolean(flag) => YamlOwned::Value(ScalarOwned::Boolean(*flag)),
1656
- TomlValue::Datetime(dt) => {
1657
- YamlOwned::Value(ScalarOwned::String(dt.to_string()))
1658
- }
1659
- TomlValue::Array(items) => {
1660
- YamlOwned::Sequence(items.iter().map(toml_value_to_yaml_owned).collect())
1661
- }
1662
- TomlValue::Table(table) => {
1663
- let mut map = MappingOwned::new();
1664
- for (key, val) in table {
1665
- map.insert(
1666
- YamlOwned::Value(ScalarOwned::String(key.clone())),
1667
- toml_value_to_yaml_owned(val),
1668
- );
1669
- }
1670
- YamlOwned::Mapping(map)
1671
- }
1672
- }
1673
- }
1674
-
1675
- fn yaml_owned_to_toml_value(value: &YamlOwned) -> Result<TomlValue, String> {
1676
- if let Some(text) = value.as_str() {
1677
- return Ok(TomlValue::String(text.to_string()));
1678
- }
1679
- if let Some(flag) = value.as_bool() {
1680
- return Ok(TomlValue::Boolean(flag));
1681
- }
1682
- if let Some(num) = value.as_integer() {
1683
- return Ok(TomlValue::Integer(num));
1684
- }
1685
- if let Some(num) = value.as_floating_point() {
1686
- return Ok(TomlValue::Float(num));
1687
- }
1688
- if value.is_null() {
1689
- return Err(
1690
- "cannot convert null values to TOML (TOML has no null type)".to_string()
1691
- );
1692
- }
1693
- if let Some(items) = value.as_sequence() {
1694
- let out: Result<Vec<_>, _> =
1695
- items.iter().map(yaml_owned_to_toml_value).collect();
1696
- return out.map(TomlValue::Array);
1697
- }
1698
- if let Some(map) = value.as_mapping() {
1699
- let mut out = toml::map::Map::new();
1700
- for (key, val) in map {
1701
- let Some(key_text) = key.as_str() else {
1702
- return Err(format!("cannot convert non-string TOML key: {key:?}"));
1703
- };
1704
- out.insert(key_text.to_string(), yaml_owned_to_toml_value(val)?);
1705
- }
1706
- return Ok(TomlValue::Table(out));
1707
- }
1708
- Err("cannot convert this YAML node to TOML".to_string())
1709
- }
1710
-
1711
- /// Result of configuration discovery.
1712
- #[derive(Debug, Clone)]
1713
- pub struct ConfigContext {
1714
- pub config: YamlLintConfig,
1715
- pub base_dir: PathBuf,
1716
- pub source: Option<PathBuf>,
1717
- pub notices: Vec<String>,
1718
- }
1719
-
1720
- fn finalize_context(
1721
- envx: &dyn Env,
1722
- mut cfg: YamlLintConfig,
1723
- base_dir: impl Into<PathBuf>,
1724
- source: Option<PathBuf>,
1725
- notices: Vec<String>,
1726
- ) -> Result<ConfigContext, String> {
1727
- let base_dir = base_dir.into();
1728
- cfg.finalize(envx, &base_dir)?;
1729
- Ok(ConfigContext {
1730
- config: cfg,
1731
- base_dir,
1732
- source,
1733
- notices,
1734
- })
1735
- }
1736
-
1737
- /// Discover configuration with precedence:
1738
- /// config-data > config-file > project (TOML-first, YAML fallback) > env var > user-global > defaults.
1739
- ///
1740
- /// # Errors
1741
- /// Returns an error when a config file cannot be read or parsed.
1742
- pub fn discover_config(
1743
- inputs: &[PathBuf],
1744
- overrides: &Overrides,
1745
- ) -> Result<ConfigContext, String> {
1746
- discover_config_with(inputs, overrides, &SystemEnv)
1747
- }
1748
-
1749
- /// Discover configuration using a provided `Env` implementation.
1750
- ///
1751
- /// # Errors
1752
- /// Returns an error when a configuration file cannot be read or parsed.
1753
- ///
1754
- /// # Panics
1755
- /// Panics only if built-in preset YAML cannot be parsed, which indicates a programming error.
1756
- pub fn discover_config_with(
1757
- inputs: &[PathBuf],
1758
- overrides: &Overrides,
1759
- envx: &dyn Env,
1760
- ) -> Result<ConfigContext, String> {
1761
- // Global config resolution: inline > file > project > env var.
1762
- if let Some(ref data) = overrides.config_data {
1763
- let base_dir = envx.current_dir();
1764
- let cfg =
1765
- YamlLintConfig::from_yaml_str_with_env(data, Some(envx), Some(&base_dir))?;
1766
- return finalize_context(envx, cfg, base_dir, None, Vec::new());
1767
- }
1768
- if let Some(ref file) = overrides.config_file {
1769
- return ctx_from_config_path_core(envx, file, false, Vec::new());
1770
- }
1771
- let discovered = find_project_config_core(envx, inputs)?;
1772
- if let Some(discovered) = discovered {
1773
- return ctx_from_config_path_core(
1774
- envx,
1775
- &discovered.cfg_path,
1776
- true,
1777
- discovered.notices,
1778
- );
1779
- }
1780
- if let Some(ctx) = try_env_config_core(envx)? {
1781
- return Ok(ctx);
1782
- }
1783
- let cwd = envx.current_dir();
1784
- try_user_global_core(envx, &cwd)?.map_or_else(
1785
- move || {
1786
- finalize_context(
1787
- envx,
1788
- YamlLintConfig::from_yaml_str(conf::builtin("default").unwrap())
1789
- .expect("builtin preset must parse"),
1790
- cwd,
1791
- None,
1792
- Vec::new(),
1793
- )
1794
- },
1795
- Ok,
1796
- )
1797
- }
1798
-
1799
- /// Variant of `discover_config` with injectable environment access to keep tests safe.
1800
- ///
1801
- /// # Errors
1802
- /// Returns an error when a config file cannot be read or parsed.
1803
- ///
1804
- /// # Panics
1805
- /// Panics only if the built-in default preset is not embedded (programming error).
1806
- pub fn discover_config_with_env(
1807
- inputs: &[PathBuf],
1808
- overrides: &Overrides,
1809
- env_get: &dyn Fn(&str) -> Option<String>,
1810
- ) -> Result<ConfigContext, String> {
1811
- discover_config_with(inputs, overrides, &ClosureEnv { get: env_get })
1812
- }
1813
-
1814
- /// Discover the config for a single file path, ignoring env/global overrides.
1815
- /// Precedence: nearest project config up-tree (TOML-first, YAML fallback),
1816
- /// then user-global, then defaults.
1817
- ///
1818
- /// # Errors
1819
- /// Returns an error when a config file cannot be read or parsed.
1820
- /// Discover the effective config for a single file.
1821
- ///
1822
- /// # Errors
1823
- /// Returns an error when a config file cannot be read or parsed.
1824
- ///
1825
- /// # Panics
1826
- /// Panics only if the built-in default preset is not embedded (programming error).
1827
- pub fn discover_per_file(path: &Path) -> Result<ConfigContext, String> {
1828
- discover_per_file_with(path, &SystemEnv)
1829
- }
1830
-
1831
- /// Discover the effective config for a single file using a provided `Env`.
1832
- ///
1833
- /// # Errors
1834
- /// Returns an error when a configuration file cannot be read or parsed.
1835
- ///
1836
- /// # Panics
1837
- /// Panics only if the built-in default preset cannot be parsed.
1838
- pub fn discover_per_file_with(
1839
- path: &Path,
1840
- envx: &dyn Env,
1841
- ) -> Result<ConfigContext, String> {
1842
- let start_dir = if path.is_dir() {
1843
- path
1844
- } else {
1845
- path.parent().unwrap_or(path)
1846
- };
1847
-
1848
- let discovered = find_project_config_core(envx, &[start_dir.to_path_buf()])?;
1849
- if let Some(discovered) = discovered {
1850
- return ctx_from_config_path_core(
1851
- envx,
1852
- &discovered.cfg_path,
1853
- true,
1854
- discovered.notices,
1855
- );
1856
- }
1857
- try_user_global_core(envx, start_dir)?.map_or_else(
1858
- || {
1859
- finalize_context(
1860
- envx,
1861
- YamlLintConfig::from_yaml_str(conf::builtin("default").unwrap())
1862
- .expect("builtin preset must parse"),
1863
- envx.current_dir(),
1864
- None,
1865
- Vec::new(),
1866
- )
1867
- },
1868
- Ok,
1869
- )
1870
- }
1871
-
1872
- // Testable core helpers below.
1873
- fn ctx_from_config_path_core(
1874
- envx: &dyn Env,
1875
- p: &Path,
1876
- allow_missing_pyproject: bool,
1877
- notices: Vec<String>,
1878
- ) -> Result<ConfigContext, String> {
1879
- let base = p
1880
- .parent()
1881
- .map_or_else(|| envx.current_dir(), Path::to_path_buf);
1882
- let cfg = load_config_from_path_core(envx, p, &base, allow_missing_pyproject)?
1883
- .expect("missing [tool.ryl] should be filtered or returned as an error before this point");
1884
- finalize_context(envx, cfg, base, Some(p.to_path_buf()), notices)
1885
- }
1886
-
1887
- fn expand_user_path(envx: &dyn Env, raw: &str) -> PathBuf {
1888
- if let Some(rest) = raw.strip_prefix('~') {
1889
- let trimmed = rest.trim_start_matches(['/', '\\']);
1890
- return envx
1891
- .home_dir()
1892
- .map_or_else(|| PathBuf::from(raw), |home| home.join(trimmed));
1893
- }
1894
- PathBuf::from(raw)
1895
- }
1896
-
1897
- fn try_env_config_core(envx: &dyn Env) -> Result<Option<ConfigContext>, String> {
1898
- envx.env_var("YAMLLINT_CONFIG_FILE")
1899
- .map(|raw| expand_user_path(envx, &raw))
1900
- .filter(|p| envx.path_exists(p))
1901
- .map(|p| ctx_from_config_path_core(envx, &p, false, Vec::new()))
1902
- .transpose()
1903
- }
1904
-
1905
- // no separate try_env_config_with; discover_config_with_env uses ClosureEnv + discover_config_with
1906
-
1907
- fn try_user_global_core(
1908
- envx: &dyn Env,
1909
- base_dir: &Path,
1910
- ) -> Result<Option<ConfigContext>, String> {
1911
- envx.config_dir()
1912
- .map(|base| base.join("yamllint").join("config"))
1913
- .filter(|p| envx.path_exists(p))
1914
- .map(|p| {
1915
- let data = envx.read_to_string(&p)?;
1916
- let cfg = YamlLintConfig::from_yaml_str_with_env(
1917
- &data,
1918
- Some(envx),
1919
- Some(base_dir),
1920
- )?;
1921
- finalize_context(envx, cfg, base_dir.to_path_buf(), Some(p), Vec::new())
1922
- })
1923
- .transpose()
1924
- }
1925
-
1926
- const TOML_PROJECT_CONFIG_CANDIDATES: [&str; 3] =
1927
- [".ryl.toml", "ryl.toml", "pyproject.toml"];
1928
- const YAML_PROJECT_CONFIG_CANDIDATES: [&str; 3] =
1929
- [".yamllint", ".yamllint.yaml", ".yamllint.yml"];
1930
-
1931
- #[derive(Debug, Clone)]
1932
- struct ProjectConfigDiscovery {
1933
- cfg_path: PathBuf,
1934
- notices: Vec<String>,
1935
- }
1936
-
1937
- fn load_config_from_path_core(
1938
- envx: &dyn Env,
1939
- path: &Path,
1940
- base_dir: &Path,
1941
- allow_missing_pyproject: bool,
1942
- ) -> Result<Option<YamlLintConfig>, String> {
1943
- let data = envx.read_to_string(path)?;
1944
- if path
1945
- .file_name()
1946
- .is_some_and(|name| name == "pyproject.toml")
1947
- {
1948
- let cfg = YamlLintConfig::from_toml_str_with_env(
1949
- &data,
1950
- Some(envx),
1951
- Some(base_dir),
1952
- true,
1953
- )?;
1954
- if cfg.is_none() && !allow_missing_pyproject {
1955
- return Err(format!(
1956
- "failed to parse config file {}: missing [tool.ryl] section",
1957
- path.display()
1958
- ));
1959
- }
1960
- return Ok(cfg);
1961
- }
1962
- if is_toml_path(path) {
1963
- let cfg = YamlLintConfig::from_toml_str_with_env(
1964
- &data,
1965
- Some(envx),
1966
- Some(base_dir),
1967
- false,
1968
- )?;
1969
- return Ok(cfg);
1970
- }
1971
- let cfg =
1972
- YamlLintConfig::from_yaml_str_with_env(&data, Some(envx), Some(base_dir))?;
1973
- Ok(Some(cfg))
1974
- }
1975
-
1976
- fn is_toml_path(path: &Path) -> bool {
1977
- path.extension().is_some_and(|ext| ext == "toml")
1978
- }
1979
-
1980
- fn build_project_search_starts(envx: &dyn Env, inputs: &[PathBuf]) -> Vec<PathBuf> {
1981
- let cwd = envx.current_dir();
1982
- let mut starts = Vec::new();
1983
- if inputs.is_empty() {
1984
- starts.push(cwd.clone());
1985
- return starts;
1986
- }
1987
- for path in inputs {
1988
- let start = if path.is_dir() {
1989
- path.clone()
1990
- } else {
1991
- path.parent().map_or_else(|| cwd.clone(), Path::to_path_buf)
1992
- };
1993
- let abs = if start.is_absolute() {
1994
- start
1995
- } else {
1996
- cwd.join(start)
1997
- };
1998
- if !starts.iter().any(|existing| existing == &abs) {
1999
- starts.push(abs);
2000
- }
2001
- }
2002
- starts
2003
- }
2004
-
2005
- fn find_first_yaml_candidate(
2006
- envx: &dyn Env,
2007
- start: &Path,
2008
- home_abs: Option<&PathBuf>,
2009
- ) -> Option<PathBuf> {
2010
- let mut dir = start.to_path_buf();
2011
- loop {
2012
- for name in YAML_PROJECT_CONFIG_CANDIDATES {
2013
- let candidate = dir.join(name);
2014
- if envx.path_exists(&candidate) {
2015
- return Some(candidate);
2016
- }
2017
- }
2018
- if home_abs.is_some_and(|home| home == &dir) {
2019
- break;
2020
- }
2021
- match dir.parent() {
2022
- Some(parent) if parent != dir => dir = parent.to_path_buf(),
2023
- _ => break,
2024
- }
2025
- }
2026
- None
2027
- }
2028
-
2029
- fn find_project_config_core(
2030
- envx: &dyn Env,
2031
- inputs: &[PathBuf],
2032
- ) -> Result<Option<ProjectConfigDiscovery>, String> {
2033
- let starts = build_project_search_starts(envx, inputs);
2034
- let cwd = envx.current_dir();
2035
- let home_abs = envx
2036
- .env_var("HOME")
2037
- .map(PathBuf::from)
2038
- .or_else(dirs_next::home_dir)
2039
- .map(|home| {
2040
- if home.is_absolute() {
2041
- home
2042
- } else {
2043
- cwd.join(home)
2044
- }
2045
- });
2046
-
2047
- for start in &starts {
2048
- let mut dir = start.clone();
2049
- loop {
2050
- for name in TOML_PROJECT_CONFIG_CANDIDATES {
2051
- let candidate = dir.join(name);
2052
- if !envx.path_exists(&candidate) {
2053
- continue;
2054
- }
2055
- if name == "pyproject.toml" {
2056
- let loaded =
2057
- load_config_from_path_core(envx, &candidate, &dir, true)?;
2058
- if loaded.is_none() {
2059
- continue;
2060
- }
2061
- }
2062
- let notices = find_first_yaml_candidate(envx, start, home_abs.as_ref())
2063
- .map(|yaml_path| {
2064
- format!(
2065
- "warning: ignoring legacy YAML config discovery because TOML config {} was found (legacy candidate: {})",
2066
- candidate.display(),
2067
- yaml_path.display()
2068
- )
2069
- })
2070
- .into_iter()
2071
- .collect();
2072
- return Ok(Some(ProjectConfigDiscovery {
2073
- cfg_path: candidate,
2074
- notices,
2075
- }));
2076
- }
2077
- if home_abs.as_ref().is_some_and(|home| home == &dir) {
2078
- break;
2079
- }
2080
- match dir.parent() {
2081
- Some(parent) if parent != dir => dir = parent.to_path_buf(),
2082
- _ => break,
2083
- }
2084
- }
2085
- }
2086
-
2087
- for start in starts {
2088
- if let Some(candidate) =
2089
- find_first_yaml_candidate(envx, &start, home_abs.as_ref())
2090
- {
2091
- return Ok(Some(ProjectConfigDiscovery {
2092
- cfg_path: candidate,
2093
- notices: Vec::new(),
2094
- }));
2095
- }
2096
- }
2097
-
2098
- Ok(None)
2099
- }