@owenlamont/ryl 0.4.1

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 (217) hide show
  1. package/.github/CODEOWNERS +1 -0
  2. package/.github/dependabot.yml +13 -0
  3. package/.github/workflows/ci.yml +107 -0
  4. package/.github/workflows/release.yml +613 -0
  5. package/.github/workflows/update_dependencies.yml +61 -0
  6. package/.github/workflows/update_linters.yml +56 -0
  7. package/.pre-commit-config.yaml +87 -0
  8. package/.yamllint +4 -0
  9. package/AGENTS.md +200 -0
  10. package/Cargo.lock +908 -0
  11. package/Cargo.toml +32 -0
  12. package/LICENSE +21 -0
  13. package/README.md +230 -0
  14. package/bin/ryl.js +1 -0
  15. package/clippy.toml +1 -0
  16. package/docs/config-presets.md +100 -0
  17. package/img/benchmark-5x5-5runs.svg +2176 -0
  18. package/package.json +28 -0
  19. package/pyproject.toml +42 -0
  20. package/ruff.toml +107 -0
  21. package/rumdl.toml +20 -0
  22. package/rust-toolchain.toml +3 -0
  23. package/rustfmt.toml +3 -0
  24. package/scripts/benchmark_perf_vs_yamllint.py +400 -0
  25. package/scripts/coverage-missing.ps1 +80 -0
  26. package/scripts/coverage-missing.sh +60 -0
  27. package/src/bin/discover_config_bin.rs +24 -0
  28. package/src/cli_support.rs +33 -0
  29. package/src/conf/mod.rs +85 -0
  30. package/src/config.rs +2099 -0
  31. package/src/decoder.rs +326 -0
  32. package/src/discover.rs +31 -0
  33. package/src/lib.rs +19 -0
  34. package/src/lint.rs +558 -0
  35. package/src/main.rs +535 -0
  36. package/src/migrate.rs +233 -0
  37. package/src/rules/anchors.rs +517 -0
  38. package/src/rules/braces.rs +77 -0
  39. package/src/rules/brackets.rs +77 -0
  40. package/src/rules/colons.rs +475 -0
  41. package/src/rules/commas.rs +372 -0
  42. package/src/rules/comments.rs +299 -0
  43. package/src/rules/comments_indentation.rs +243 -0
  44. package/src/rules/document_end.rs +175 -0
  45. package/src/rules/document_start.rs +84 -0
  46. package/src/rules/empty_lines.rs +152 -0
  47. package/src/rules/empty_values.rs +255 -0
  48. package/src/rules/float_values.rs +259 -0
  49. package/src/rules/flow_collection.rs +562 -0
  50. package/src/rules/hyphens.rs +104 -0
  51. package/src/rules/indentation.rs +803 -0
  52. package/src/rules/key_duplicates.rs +218 -0
  53. package/src/rules/key_ordering.rs +303 -0
  54. package/src/rules/line_length.rs +326 -0
  55. package/src/rules/mod.rs +25 -0
  56. package/src/rules/new_line_at_end_of_file.rs +23 -0
  57. package/src/rules/new_lines.rs +95 -0
  58. package/src/rules/octal_values.rs +121 -0
  59. package/src/rules/quoted_strings.rs +577 -0
  60. package/src/rules/span_utils.rs +37 -0
  61. package/src/rules/trailing_spaces.rs +65 -0
  62. package/src/rules/truthy.rs +420 -0
  63. package/tests/brackets_carriage_return.rs +114 -0
  64. package/tests/build_global_cfg_error.rs +23 -0
  65. package/tests/cli_anchors_rule.rs +143 -0
  66. package/tests/cli_braces_rule.rs +104 -0
  67. package/tests/cli_brackets_rule.rs +104 -0
  68. package/tests/cli_colons_rule.rs +65 -0
  69. package/tests/cli_commas_rule.rs +104 -0
  70. package/tests/cli_comments_indentation_rule.rs +61 -0
  71. package/tests/cli_comments_rule.rs +67 -0
  72. package/tests/cli_config_data_error.rs +30 -0
  73. package/tests/cli_config_flags.rs +66 -0
  74. package/tests/cli_config_migrate.rs +229 -0
  75. package/tests/cli_document_end_rule.rs +92 -0
  76. package/tests/cli_document_start_rule.rs +92 -0
  77. package/tests/cli_empty_lines_rule.rs +87 -0
  78. package/tests/cli_empty_values_rule.rs +68 -0
  79. package/tests/cli_env_config.rs +34 -0
  80. package/tests/cli_exit_and_errors.rs +41 -0
  81. package/tests/cli_file_encoding.rs +203 -0
  82. package/tests/cli_float_values_rule.rs +64 -0
  83. package/tests/cli_format_options.rs +316 -0
  84. package/tests/cli_global_cfg_relaxed.rs +20 -0
  85. package/tests/cli_hyphens_rule.rs +104 -0
  86. package/tests/cli_indentation_rule.rs +65 -0
  87. package/tests/cli_invalid_project_config.rs +39 -0
  88. package/tests/cli_key_duplicates_rule.rs +104 -0
  89. package/tests/cli_key_ordering_rule.rs +59 -0
  90. package/tests/cli_line_length_rule.rs +85 -0
  91. package/tests/cli_list_files.rs +29 -0
  92. package/tests/cli_new_line_rule.rs +141 -0
  93. package/tests/cli_new_lines_rule.rs +119 -0
  94. package/tests/cli_octal_values_rule.rs +60 -0
  95. package/tests/cli_quoted_strings_rule.rs +47 -0
  96. package/tests/cli_toml_config.rs +119 -0
  97. package/tests/cli_trailing_spaces_rule.rs +77 -0
  98. package/tests/cli_truthy_rule.rs +83 -0
  99. package/tests/cli_yaml_files_negation.rs +45 -0
  100. package/tests/colons_rule.rs +303 -0
  101. package/tests/common/compat.rs +114 -0
  102. package/tests/common/fake_env.rs +93 -0
  103. package/tests/common/mod.rs +1 -0
  104. package/tests/conf_builtin.rs +9 -0
  105. package/tests/config_anchors.rs +84 -0
  106. package/tests/config_braces.rs +121 -0
  107. package/tests/config_brackets.rs +127 -0
  108. package/tests/config_commas.rs +79 -0
  109. package/tests/config_comments.rs +65 -0
  110. package/tests/config_comments_indentation.rs +20 -0
  111. package/tests/config_deep_merge_nonstring_key.rs +24 -0
  112. package/tests/config_document_end.rs +54 -0
  113. package/tests/config_document_start.rs +55 -0
  114. package/tests/config_empty_lines.rs +48 -0
  115. package/tests/config_empty_values.rs +35 -0
  116. package/tests/config_env_errors.rs +23 -0
  117. package/tests/config_env_invalid_inline.rs +15 -0
  118. package/tests/config_env_missing.rs +63 -0
  119. package/tests/config_env_shim.rs +301 -0
  120. package/tests/config_explicit_file_parse_error.rs +55 -0
  121. package/tests/config_extended_features.rs +225 -0
  122. package/tests/config_extends_inline.rs +185 -0
  123. package/tests/config_extends_sequence.rs +18 -0
  124. package/tests/config_find_project_home_boundary.rs +54 -0
  125. package/tests/config_find_project_two_files_in_cwd.rs +47 -0
  126. package/tests/config_float_values.rs +34 -0
  127. package/tests/config_from_yaml_paths.rs +32 -0
  128. package/tests/config_hyphens.rs +51 -0
  129. package/tests/config_ignore_errors.rs +243 -0
  130. package/tests/config_ignore_overrides.rs +83 -0
  131. package/tests/config_indentation.rs +65 -0
  132. package/tests/config_invalid_globs.rs +16 -0
  133. package/tests/config_invalid_types.rs +19 -0
  134. package/tests/config_key_duplicates.rs +34 -0
  135. package/tests/config_key_ordering.rs +70 -0
  136. package/tests/config_line_length.rs +65 -0
  137. package/tests/config_locale.rs +111 -0
  138. package/tests/config_merge.rs +26 -0
  139. package/tests/config_new_lines.rs +89 -0
  140. package/tests/config_octal_values.rs +33 -0
  141. package/tests/config_quoted_strings.rs +195 -0
  142. package/tests/config_rule_level.rs +147 -0
  143. package/tests/config_rules_non_string_keys.rs +23 -0
  144. package/tests/config_scalar_overrides.rs +27 -0
  145. package/tests/config_to_toml.rs +110 -0
  146. package/tests/config_toml_coverage.rs +80 -0
  147. package/tests/config_toml_discovery.rs +304 -0
  148. package/tests/config_trailing_spaces.rs +152 -0
  149. package/tests/config_truthy.rs +77 -0
  150. package/tests/config_yaml_files.rs +62 -0
  151. package/tests/config_yaml_files_all_non_string.rs +15 -0
  152. package/tests/config_yaml_files_empty.rs +30 -0
  153. package/tests/coverage_commas.rs +46 -0
  154. package/tests/decoder_decode.rs +338 -0
  155. package/tests/discover_config_bin_all.rs +66 -0
  156. package/tests/discover_config_bin_env_invalid_yaml.rs +26 -0
  157. package/tests/discover_config_bin_project_config_parse_error.rs +24 -0
  158. package/tests/discover_config_bin_user_global_error.rs +26 -0
  159. package/tests/discover_module.rs +30 -0
  160. package/tests/discover_per_file_dir.rs +10 -0
  161. package/tests/discover_per_file_project_config_error.rs +21 -0
  162. package/tests/float_values.rs +43 -0
  163. package/tests/lint_multi_errors.rs +32 -0
  164. package/tests/main_yaml_ok_filtering.rs +30 -0
  165. package/tests/migrate_module.rs +259 -0
  166. package/tests/resolve_ctx_empty_parent.rs +16 -0
  167. package/tests/rule_anchors.rs +442 -0
  168. package/tests/rule_braces.rs +258 -0
  169. package/tests/rule_brackets.rs +217 -0
  170. package/tests/rule_commas.rs +205 -0
  171. package/tests/rule_comments.rs +197 -0
  172. package/tests/rule_comments_indentation.rs +127 -0
  173. package/tests/rule_document_end.rs +118 -0
  174. package/tests/rule_document_start.rs +60 -0
  175. package/tests/rule_empty_lines.rs +96 -0
  176. package/tests/rule_empty_values.rs +102 -0
  177. package/tests/rule_float_values.rs +109 -0
  178. package/tests/rule_hyphens.rs +65 -0
  179. package/tests/rule_indentation.rs +455 -0
  180. package/tests/rule_key_duplicates.rs +76 -0
  181. package/tests/rule_key_ordering.rs +207 -0
  182. package/tests/rule_line_length.rs +200 -0
  183. package/tests/rule_new_lines.rs +51 -0
  184. package/tests/rule_octal_values.rs +53 -0
  185. package/tests/rule_quoted_strings.rs +290 -0
  186. package/tests/rule_trailing_spaces.rs +41 -0
  187. package/tests/rule_truthy.rs +236 -0
  188. package/tests/user_global_invalid_yaml.rs +32 -0
  189. package/tests/yamllint_compat_anchors.rs +280 -0
  190. package/tests/yamllint_compat_braces.rs +411 -0
  191. package/tests/yamllint_compat_brackets.rs +364 -0
  192. package/tests/yamllint_compat_colons.rs +298 -0
  193. package/tests/yamllint_compat_colors.rs +80 -0
  194. package/tests/yamllint_compat_commas.rs +375 -0
  195. package/tests/yamllint_compat_comments.rs +167 -0
  196. package/tests/yamllint_compat_comments_indentation.rs +281 -0
  197. package/tests/yamllint_compat_config.rs +170 -0
  198. package/tests/yamllint_compat_document_end.rs +243 -0
  199. package/tests/yamllint_compat_document_start.rs +136 -0
  200. package/tests/yamllint_compat_empty_lines.rs +117 -0
  201. package/tests/yamllint_compat_empty_values.rs +179 -0
  202. package/tests/yamllint_compat_float_values.rs +216 -0
  203. package/tests/yamllint_compat_hyphens.rs +223 -0
  204. package/tests/yamllint_compat_indentation.rs +398 -0
  205. package/tests/yamllint_compat_key_duplicates.rs +139 -0
  206. package/tests/yamllint_compat_key_ordering.rs +170 -0
  207. package/tests/yamllint_compat_line_length.rs +375 -0
  208. package/tests/yamllint_compat_list.rs +127 -0
  209. package/tests/yamllint_compat_new_line.rs +133 -0
  210. package/tests/yamllint_compat_newline_types.rs +185 -0
  211. package/tests/yamllint_compat_octal_values.rs +172 -0
  212. package/tests/yamllint_compat_quoted_strings.rs +154 -0
  213. package/tests/yamllint_compat_syntax.rs +200 -0
  214. package/tests/yamllint_compat_trailing_spaces.rs +162 -0
  215. package/tests/yamllint_compat_truthy.rs +130 -0
  216. package/tests/yamllint_compat_yaml_files.rs +81 -0
  217. package/typos.toml +2 -0
@@ -0,0 +1,577 @@
1
+ use regex::Regex;
2
+ use saphyr::Yaml;
3
+ use saphyr_parser::{Event, Parser, ScalarStyle, Span, SpannedEventReceiver, Tag};
4
+
5
+ use crate::config::YamlLintConfig;
6
+
7
+ pub const ID: &str = "quoted-strings";
8
+
9
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
10
+ enum QuoteType {
11
+ Any,
12
+ Single,
13
+ Double,
14
+ }
15
+
16
+ impl QuoteType {
17
+ const fn matches(self, style: Option<QuoteStyle>) -> bool {
18
+ match self {
19
+ Self::Any => style.is_some(),
20
+ Self::Single => matches!(style, Some(QuoteStyle::Single)),
21
+ Self::Double => matches!(style, Some(QuoteStyle::Double)),
22
+ }
23
+ }
24
+ }
25
+
26
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
27
+ enum QuoteStyle {
28
+ Single,
29
+ Double,
30
+ }
31
+
32
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
33
+ enum RequiredMode {
34
+ Always,
35
+ Never,
36
+ OnlyWhenNeeded,
37
+ }
38
+
39
+ #[derive(Debug, Clone)]
40
+ pub struct Config {
41
+ quote_type: QuoteType,
42
+ quote_type_label: &'static str,
43
+ required: RequiredMode,
44
+ extra_required: Vec<Regex>,
45
+ extra_allowed: Vec<Regex>,
46
+ allow_quoted_quotes: bool,
47
+ pub check_keys: bool,
48
+ }
49
+
50
+ impl Config {
51
+ /// Resolve the rule configuration from the parsed yamllint configuration.
52
+ ///
53
+ /// # Panics
54
+ ///
55
+ /// Panics when option types are invalid. Configuration parsing validates
56
+ /// options before resolution, so this only occurs when constructing configs
57
+ /// manually in tests.
58
+ #[must_use]
59
+ pub fn resolve(cfg: &YamlLintConfig) -> Self {
60
+ let (quote_type, quote_type_label) = match cfg.rule_option_str(ID, "quote-type")
61
+ {
62
+ Some("single") => (QuoteType::Single, "single"),
63
+ Some("double") => (QuoteType::Double, "double"),
64
+ _ => (QuoteType::Any, "any"),
65
+ };
66
+
67
+ let required =
68
+ cfg.rule_option(ID, "required")
69
+ .map_or(RequiredMode::Always, |node| {
70
+ if node.as_bool() == Some(false) {
71
+ RequiredMode::Never
72
+ } else if node.as_str() == Some("only-when-needed") {
73
+ RequiredMode::OnlyWhenNeeded
74
+ } else {
75
+ RequiredMode::Always
76
+ }
77
+ });
78
+
79
+ let mut extra_required: Vec<Regex> = Vec::new();
80
+ if let Some(node) = cfg.rule_option(ID, "extra-required")
81
+ && let Some(seq) = node.as_sequence()
82
+ {
83
+ for item in seq {
84
+ let pattern = item
85
+ .as_str()
86
+ .expect("quoted-strings extra-required entries should be strings");
87
+ let regex = Regex::new(pattern)
88
+ .expect("quoted-strings extra-required should contain valid regex");
89
+ extra_required.push(regex);
90
+ }
91
+ }
92
+
93
+ let mut extra_allowed: Vec<Regex> = Vec::new();
94
+ if let Some(node) = cfg.rule_option(ID, "extra-allowed")
95
+ && let Some(seq) = node.as_sequence()
96
+ {
97
+ for item in seq {
98
+ let pattern = item
99
+ .as_str()
100
+ .expect("quoted-strings extra-allowed entries should be strings");
101
+ let regex = Regex::new(pattern)
102
+ .expect("quoted-strings extra-allowed should contain valid regex");
103
+ extra_allowed.push(regex);
104
+ }
105
+ }
106
+
107
+ let allow_quoted_quotes = cfg
108
+ .rule_option(ID, "allow-quoted-quotes")
109
+ .and_then(saphyr::YamlOwned::as_bool)
110
+ .unwrap_or(false);
111
+
112
+ let check_keys = cfg
113
+ .rule_option(ID, "check-keys")
114
+ .and_then(saphyr::YamlOwned::as_bool)
115
+ .unwrap_or(false);
116
+
117
+ Self {
118
+ quote_type,
119
+ quote_type_label,
120
+ required,
121
+ extra_required,
122
+ extra_allowed,
123
+ allow_quoted_quotes,
124
+ check_keys,
125
+ }
126
+ }
127
+ }
128
+
129
+ #[derive(Debug, Clone, PartialEq, Eq)]
130
+ pub struct Violation {
131
+ pub line: usize,
132
+ pub column: usize,
133
+ pub message: String,
134
+ }
135
+
136
+ #[must_use]
137
+ pub fn check(buffer: &str, cfg: &Config) -> Vec<Violation> {
138
+ let mut parser = Parser::new_from_str(buffer);
139
+ let mut receiver = QuotedStringsReceiver::new(cfg, buffer);
140
+ let _ = parser.load(&mut receiver, true);
141
+ receiver.diagnostics
142
+ }
143
+
144
+ struct QuotedStringsReceiver<'cfg> {
145
+ state: QuotedStringsState<'cfg>,
146
+ diagnostics: Vec<Violation>,
147
+ }
148
+
149
+ impl<'cfg> QuotedStringsReceiver<'cfg> {
150
+ const fn new(cfg: &'cfg Config, buffer: &'cfg str) -> Self {
151
+ Self {
152
+ state: QuotedStringsState::new(cfg, buffer),
153
+ diagnostics: Vec::new(),
154
+ }
155
+ }
156
+ }
157
+
158
+ impl SpannedEventReceiver<'_> for QuotedStringsReceiver<'_> {
159
+ fn on_event(&mut self, event: Event<'_>, span: Span) {
160
+ match event {
161
+ Event::StreamStart => self.state.reset_stream(),
162
+ Event::DocumentStart(_) => self.state.document_start(),
163
+ Event::DocumentEnd => self.state.document_end(),
164
+ Event::SequenceStart(_, _) => {
165
+ let flow = is_flow_sequence(self.state.buffer, span);
166
+ self.state.enter_sequence(flow);
167
+ }
168
+ Event::SequenceEnd | Event::MappingEnd => self.state.exit_container(),
169
+ Event::MappingStart(_, _) => {
170
+ let flow = is_flow_mapping(self.state.buffer, span);
171
+ self.state.enter_mapping(flow);
172
+ }
173
+ Event::Scalar(value, style, _, tag) => {
174
+ self.state.handle_scalar(
175
+ style,
176
+ value.as_ref(),
177
+ tag.as_deref(),
178
+ span,
179
+ &mut self.diagnostics,
180
+ );
181
+ }
182
+ Event::Alias(_) | Event::StreamEnd | Event::Nothing => {}
183
+ }
184
+ }
185
+ }
186
+
187
+ struct QuotedStringsState<'cfg> {
188
+ config: &'cfg Config,
189
+ buffer: &'cfg str,
190
+ containers: Vec<ContainerState>,
191
+ key_depth: usize,
192
+ }
193
+
194
+ impl<'cfg> QuotedStringsState<'cfg> {
195
+ const fn new(config: &'cfg Config, buffer: &'cfg str) -> Self {
196
+ Self {
197
+ config,
198
+ buffer,
199
+ containers: Vec::new(),
200
+ key_depth: 0,
201
+ }
202
+ }
203
+
204
+ fn reset_stream(&mut self) {
205
+ self.containers.clear();
206
+ self.key_depth = 0;
207
+ }
208
+
209
+ fn document_start(&mut self) {
210
+ self.containers.clear();
211
+ self.key_depth = 0;
212
+ }
213
+
214
+ fn document_end(&mut self) {
215
+ self.containers.clear();
216
+ self.key_depth = 0;
217
+ }
218
+
219
+ fn enter_mapping(&mut self, flow: bool) {
220
+ let active_key = self.begin_node();
221
+ self.containers.push(ContainerState {
222
+ kind: ContainerKind::Mapping { expect_key: true },
223
+ key_context: active_key,
224
+ flow,
225
+ });
226
+ }
227
+
228
+ fn enter_sequence(&mut self, flow: bool) {
229
+ let active_key = self.begin_node();
230
+ self.containers.push(ContainerState {
231
+ kind: ContainerKind::Sequence,
232
+ key_context: active_key,
233
+ flow,
234
+ });
235
+ }
236
+
237
+ fn exit_container(&mut self) {
238
+ if let Some(container) = self.containers.pop()
239
+ && container.key_context
240
+ && self.key_depth > 0
241
+ {
242
+ self.key_depth -= 1;
243
+ }
244
+ }
245
+
246
+ fn handle_scalar(
247
+ &mut self,
248
+ style: ScalarStyle,
249
+ value: &str,
250
+ tag: Option<&Tag>,
251
+ span: Span,
252
+ diagnostics: &mut Vec<Violation>,
253
+ ) {
254
+ let active_key = self.begin_node();
255
+ let resolves_to_string = matches!(
256
+ Yaml::value_from_str(value),
257
+ Yaml::Value(saphyr::Scalar::String(_))
258
+ );
259
+
260
+ if self.should_skip_scalar(style, tag, active_key, resolves_to_string) {
261
+ self.finish_scalar(active_key);
262
+ return;
263
+ }
264
+
265
+ if let Some(violation) =
266
+ self.evaluate_scalar(style, value, active_key, resolves_to_string, span)
267
+ {
268
+ diagnostics.push(violation);
269
+ }
270
+
271
+ self.finish_scalar(active_key);
272
+ }
273
+
274
+ fn in_flow(&self) -> bool {
275
+ self.containers.iter().any(|container| container.flow)
276
+ }
277
+
278
+ fn begin_node(&mut self) -> bool {
279
+ let mut is_key_node = false;
280
+ if let Some(ContainerState {
281
+ kind: ContainerKind::Mapping { expect_key },
282
+ ..
283
+ }) = self.containers.last_mut()
284
+ {
285
+ if *expect_key {
286
+ is_key_node = true;
287
+ *expect_key = false;
288
+ } else {
289
+ *expect_key = true;
290
+ }
291
+ }
292
+
293
+ let active_key = is_key_node || self.key_depth > 0;
294
+ if active_key {
295
+ self.key_depth += 1;
296
+ }
297
+ active_key
298
+ }
299
+
300
+ const fn finish_scalar(&mut self, active_key: bool) {
301
+ if active_key && self.key_depth > 0 {
302
+ self.key_depth -= 1;
303
+ }
304
+ }
305
+
306
+ fn should_skip_scalar(
307
+ &self,
308
+ style: ScalarStyle,
309
+ tag: Option<&Tag>,
310
+ active_key: bool,
311
+ resolves_to_string: bool,
312
+ ) -> bool {
313
+ if matches!(style, ScalarStyle::Literal | ScalarStyle::Folded) {
314
+ return true;
315
+ }
316
+
317
+ if active_key && !self.config.check_keys {
318
+ return true;
319
+ }
320
+
321
+ if let Some(tag) = tag
322
+ && is_core_tag(tag)
323
+ {
324
+ return true;
325
+ }
326
+
327
+ matches!(style, ScalarStyle::Plain) && !resolves_to_string
328
+ }
329
+
330
+ fn evaluate_scalar(
331
+ &self,
332
+ style: ScalarStyle,
333
+ value: &str,
334
+ active_key: bool,
335
+ resolves_to_string: bool,
336
+ span: Span,
337
+ ) -> Option<Violation> {
338
+ let node_label = if active_key { "key" } else { "value" };
339
+ let quote_style = match style {
340
+ ScalarStyle::SingleQuoted => Some(QuoteStyle::Single),
341
+ ScalarStyle::DoubleQuoted => Some(QuoteStyle::Double),
342
+ ScalarStyle::Plain | ScalarStyle::Literal | ScalarStyle::Folded => None,
343
+ };
344
+
345
+ let has_quoted_quotes = match style {
346
+ ScalarStyle::SingleQuoted => value.contains('"'),
347
+ ScalarStyle::DoubleQuoted => value.contains('\''),
348
+ _ => false,
349
+ };
350
+
351
+ let extra_required = self
352
+ .config
353
+ .extra_required
354
+ .iter()
355
+ .any(|re| re.is_match(value));
356
+ let extra_allowed = self
357
+ .config
358
+ .extra_allowed
359
+ .iter()
360
+ .any(|re| re.is_match(value));
361
+ let quotes_needed =
362
+ matches!(style, ScalarStyle::SingleQuoted | ScalarStyle::DoubleQuoted)
363
+ && quotes_are_needed(style, value, self.in_flow(), self.buffer, span);
364
+
365
+ let message = match self.config.required {
366
+ RequiredMode::Always => {
367
+ if quote_style.is_none()
368
+ || quote_style.is_some_and(|style_kind| {
369
+ self.mismatched_quote(style_kind, has_quoted_quotes)
370
+ })
371
+ {
372
+ Some(format!(
373
+ "string {node_label} is not quoted with {} quotes",
374
+ self.config.quote_type_label
375
+ ))
376
+ } else {
377
+ None
378
+ }
379
+ }
380
+ RequiredMode::Never => quote_style.map_or_else(
381
+ || {
382
+ if extra_required {
383
+ Some(format!("string {node_label} is not quoted"))
384
+ } else {
385
+ None
386
+ }
387
+ },
388
+ |style_kind| {
389
+ if self.mismatched_quote(style_kind, has_quoted_quotes) {
390
+ Some(format!(
391
+ "string {node_label} is not quoted with {} quotes",
392
+ self.config.quote_type_label
393
+ ))
394
+ } else {
395
+ None
396
+ }
397
+ },
398
+ ),
399
+ RequiredMode::OnlyWhenNeeded => quote_style.map_or_else(
400
+ || {
401
+ if extra_required {
402
+ Some(format!("string {node_label} is not quoted"))
403
+ } else {
404
+ None
405
+ }
406
+ },
407
+ |style_kind| {
408
+ if resolves_to_string && !value.is_empty() && !quotes_needed {
409
+ if extra_required || extra_allowed {
410
+ None
411
+ } else {
412
+ Some(format!(
413
+ "string {node_label} is redundantly quoted with {} quotes",
414
+ self.config.quote_type_label
415
+ ))
416
+ }
417
+ } else if self.mismatched_quote(style_kind, has_quoted_quotes) {
418
+ Some(format!(
419
+ "string {node_label} is not quoted with {} quotes",
420
+ self.config.quote_type_label
421
+ ))
422
+ } else {
423
+ None
424
+ }
425
+ },
426
+ ),
427
+ }?;
428
+
429
+ Some(build_violation(span, message))
430
+ }
431
+
432
+ const fn mismatched_quote(
433
+ &self,
434
+ style_kind: QuoteStyle,
435
+ has_quoted_quotes: bool,
436
+ ) -> bool {
437
+ !(self.config.quote_type.matches(Some(style_kind))
438
+ || (self.config.allow_quoted_quotes && has_quoted_quotes))
439
+ }
440
+ }
441
+
442
+ struct ContainerState {
443
+ kind: ContainerKind,
444
+ key_context: bool,
445
+ flow: bool,
446
+ }
447
+
448
+ enum ContainerKind {
449
+ Mapping { expect_key: bool },
450
+ Sequence,
451
+ }
452
+
453
+ fn build_violation(span: Span, message: String) -> Violation {
454
+ Violation {
455
+ line: span.start.line(),
456
+ column: span.start.col() + 1,
457
+ message,
458
+ }
459
+ }
460
+
461
+ fn is_flow_sequence(buffer: &str, span: Span) -> bool {
462
+ matches!(
463
+ next_non_whitespace_char(buffer, span.start.index()),
464
+ Some('[')
465
+ )
466
+ }
467
+
468
+ fn is_flow_mapping(buffer: &str, span: Span) -> bool {
469
+ matches!(
470
+ next_non_whitespace_char(buffer, span.start.index()),
471
+ Some('{')
472
+ )
473
+ }
474
+
475
+ fn next_non_whitespace_char(text: &str, byte_idx: usize) -> Option<char> {
476
+ text.get(byte_idx..)
477
+ .and_then(|tail| tail.chars().find(|ch| !ch.is_whitespace()))
478
+ }
479
+
480
+ fn is_core_tag(tag: &Tag) -> bool {
481
+ tag.handle == "tag:yaml.org,2002:"
482
+ }
483
+
484
+ fn quotes_are_needed(
485
+ style: ScalarStyle,
486
+ value: &str,
487
+ is_inside_flow: bool,
488
+ buffer: &str,
489
+ span: Span,
490
+ ) -> bool {
491
+ if is_inside_flow
492
+ && value
493
+ .chars()
494
+ .any(|c| matches!(c, ',' | '[' | ']' | '{' | '}'))
495
+ {
496
+ return true;
497
+ }
498
+
499
+ if matches!(style, ScalarStyle::DoubleQuoted) {
500
+ if contains_non_printable(value) {
501
+ return true;
502
+ }
503
+ if has_backslash_line_ending(buffer, span) {
504
+ return true;
505
+ }
506
+ }
507
+
508
+ plain_scalar_equivalent(value).is_none_or(|result| !result)
509
+ }
510
+
511
+ fn plain_scalar_equivalent(value: &str) -> Option<bool> {
512
+ let snippet = format!("key: {value}\n");
513
+ let mut parser = Parser::new_from_str(&snippet);
514
+ let mut checker = PlainScalarChecker::new(value);
515
+ if parser.load(&mut checker, true).is_err() {
516
+ return Some(false);
517
+ }
518
+ checker.result.or(Some(false))
519
+ }
520
+
521
+ struct PlainScalarChecker<'a> {
522
+ expected: &'a str,
523
+ seen_key: bool,
524
+ result: Option<bool>,
525
+ }
526
+
527
+ impl<'a> PlainScalarChecker<'a> {
528
+ const fn new(expected: &'a str) -> Self {
529
+ Self {
530
+ expected,
531
+ seen_key: false,
532
+ result: None,
533
+ }
534
+ }
535
+ }
536
+
537
+ impl SpannedEventReceiver<'_> for PlainScalarChecker<'_> {
538
+ fn on_event(&mut self, event: Event<'_>, _span: Span) {
539
+ if let Event::Scalar(value, style, _, _) = event {
540
+ if !self.seen_key {
541
+ self.seen_key = true;
542
+ } else if self.result.is_none() {
543
+ self.result = Some(
544
+ matches!(style, ScalarStyle::Plain)
545
+ && value.as_ref() == self.expected,
546
+ );
547
+ }
548
+ }
549
+ }
550
+ }
551
+
552
+ fn contains_non_printable(value: &str) -> bool {
553
+ value.chars().any(|ch| {
554
+ let code = ch as u32;
555
+ !(matches!(ch, '\u{9}' | '\u{A}' | '\u{D}')
556
+ || (0x20..=0x7E).contains(&code)
557
+ || code == 0x85
558
+ || (0xA0..=0xD7FF).contains(&code)
559
+ || (0xE000..=0xFFFD).contains(&code)
560
+ || (0x1_0000..=0x10_FFFF).contains(&code))
561
+ })
562
+ }
563
+
564
+ fn has_backslash_line_ending(buffer: &str, span: Span) -> bool {
565
+ if span.start.line() == span.end.line() {
566
+ return false;
567
+ }
568
+
569
+ let slice_start = span.start.index().saturating_add(1).min(buffer.len());
570
+ let mut slice_end = span.end.index().saturating_sub(1);
571
+ slice_end = slice_end.min(buffer.len());
572
+ slice_end = slice_end.max(slice_start);
573
+ let content = &buffer[slice_start..slice_end];
574
+ let has_unix_backslash = content.contains("\\\n");
575
+ let has_windows_backslash = content.contains("\\\r\n");
576
+ has_unix_backslash || has_windows_backslash
577
+ }
@@ -0,0 +1,37 @@
1
+ use std::ops::Range;
2
+
3
+ pub fn ranges_to_char_indices(
4
+ ranges: Vec<Range<usize>>,
5
+ chars: &[(usize, char)],
6
+ buffer_len: usize,
7
+ ) -> Vec<Range<usize>> {
8
+ ranges
9
+ .into_iter()
10
+ .map(|range| {
11
+ let start = byte_index_to_char(chars, range.start, buffer_len);
12
+ let end = byte_index_to_char(chars, range.end, buffer_len);
13
+ start..end
14
+ })
15
+ .collect()
16
+ }
17
+
18
+ pub fn span_char_index_to_byte(
19
+ chars: &[(usize, char)],
20
+ char_idx: usize,
21
+ buffer_len: usize,
22
+ ) -> usize {
23
+ if char_idx >= chars.len() {
24
+ buffer_len
25
+ } else {
26
+ chars[char_idx].0
27
+ }
28
+ }
29
+
30
+ fn byte_index_to_char(
31
+ chars: &[(usize, char)],
32
+ byte_idx: usize,
33
+ buffer_len: usize,
34
+ ) -> usize {
35
+ let clamped = byte_idx.min(buffer_len);
36
+ chars.partition_point(|(offset, _)| *offset < clamped)
37
+ }
@@ -0,0 +1,65 @@
1
+ pub const ID: &str = "trailing-spaces";
2
+ pub const MESSAGE: &str = "trailing spaces";
3
+
4
+ #[derive(Debug, Clone, PartialEq, Eq)]
5
+ pub struct Violation {
6
+ pub line: usize,
7
+ pub column: usize,
8
+ }
9
+
10
+ #[must_use]
11
+ pub fn check(buffer: &str) -> Vec<Violation> {
12
+ let mut violations = Vec::new();
13
+ let bytes = buffer.as_bytes();
14
+ let mut line_no = 1usize;
15
+ let mut line_start = 0usize;
16
+ let mut idx = 0usize;
17
+
18
+ while idx < bytes.len() {
19
+ if bytes[idx] == b'\n' {
20
+ let line_end = if idx > line_start && bytes[idx - 1] == b'\r' {
21
+ idx - 1
22
+ } else {
23
+ idx
24
+ };
25
+ process_line(buffer, line_no, line_start, line_end, &mut violations);
26
+ idx += 1;
27
+ line_start = idx;
28
+ line_no += 1;
29
+ } else {
30
+ idx += 1;
31
+ }
32
+ }
33
+
34
+ process_line(buffer, line_no, line_start, bytes.len(), &mut violations);
35
+ violations
36
+ }
37
+
38
+ fn process_line(
39
+ buffer: &str,
40
+ line_no: usize,
41
+ start: usize,
42
+ end: usize,
43
+ out: &mut Vec<Violation>,
44
+ ) {
45
+ if start == end {
46
+ return;
47
+ }
48
+
49
+ let bytes = buffer.as_bytes();
50
+ let mut trim_pos = end;
51
+ while trim_pos > start {
52
+ match bytes[trim_pos - 1] {
53
+ b' ' | b'\t' => trim_pos -= 1,
54
+ _ => break,
55
+ }
56
+ }
57
+
58
+ if trim_pos < end {
59
+ let column = buffer[start..trim_pos].chars().count() + 1;
60
+ out.push(Violation {
61
+ line: line_no,
62
+ column,
63
+ });
64
+ }
65
+ }