@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,562 @@
1
+ use std::ops::Range;
2
+
3
+ use saphyr_parser::{Event, Parser, Span, SpannedEventReceiver};
4
+
5
+ use crate::config::YamlLintConfig;
6
+ use crate::rules::span_utils::ranges_to_char_indices;
7
+
8
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
9
+ pub enum Forbid {
10
+ None,
11
+ All,
12
+ NonEmpty,
13
+ }
14
+
15
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
16
+ pub struct Config {
17
+ forbid: Forbid,
18
+ min_spaces_inside: i64,
19
+ max_spaces_inside: i64,
20
+ min_spaces_inside_empty: i64,
21
+ max_spaces_inside_empty: i64,
22
+ }
23
+
24
+ impl Config {
25
+ const DEFAULT_MIN_SPACES_INSIDE: i64 = 0;
26
+ const DEFAULT_MAX_SPACES_INSIDE: i64 = 0;
27
+ const DEFAULT_MIN_SPACES_INSIDE_EMPTY: i64 = -1;
28
+ const DEFAULT_MAX_SPACES_INSIDE_EMPTY: i64 = -1;
29
+
30
+ #[must_use]
31
+ pub fn resolve_for(cfg: &YamlLintConfig, rule_id: &str) -> Self {
32
+ let forbid = cfg
33
+ .rule_option(rule_id, "forbid")
34
+ .map_or(Forbid::None, |node| match (node.as_bool(), node.as_str()) {
35
+ (Some(true), _) => Forbid::All,
36
+ (None, Some("non-empty")) => Forbid::NonEmpty,
37
+ _ => Forbid::None,
38
+ });
39
+
40
+ let min_spaces_inside = cfg
41
+ .rule_option(rule_id, "min-spaces-inside")
42
+ .and_then(saphyr::YamlOwned::as_integer)
43
+ .unwrap_or(Self::DEFAULT_MIN_SPACES_INSIDE);
44
+ let max_spaces_inside = cfg
45
+ .rule_option(rule_id, "max-spaces-inside")
46
+ .and_then(saphyr::YamlOwned::as_integer)
47
+ .unwrap_or(Self::DEFAULT_MAX_SPACES_INSIDE);
48
+ let min_spaces_inside_empty = cfg
49
+ .rule_option(rule_id, "min-spaces-inside-empty")
50
+ .and_then(saphyr::YamlOwned::as_integer)
51
+ .unwrap_or(Self::DEFAULT_MIN_SPACES_INSIDE_EMPTY);
52
+ let max_spaces_inside_empty = cfg
53
+ .rule_option(rule_id, "max-spaces-inside-empty")
54
+ .and_then(saphyr::YamlOwned::as_integer)
55
+ .unwrap_or(Self::DEFAULT_MAX_SPACES_INSIDE_EMPTY);
56
+
57
+ Self {
58
+ forbid,
59
+ min_spaces_inside,
60
+ max_spaces_inside,
61
+ min_spaces_inside_empty,
62
+ max_spaces_inside_empty,
63
+ }
64
+ }
65
+
66
+ #[must_use]
67
+ pub const fn new_for_tests(
68
+ forbid: Forbid,
69
+ min_spaces_inside: i64,
70
+ max_spaces_inside: i64,
71
+ min_spaces_inside_empty: i64,
72
+ max_spaces_inside_empty: i64,
73
+ ) -> Self {
74
+ Self {
75
+ forbid,
76
+ min_spaces_inside,
77
+ max_spaces_inside,
78
+ min_spaces_inside_empty,
79
+ max_spaces_inside_empty,
80
+ }
81
+ }
82
+
83
+ #[must_use]
84
+ pub const fn effective_min_empty(&self) -> i64 {
85
+ if self.min_spaces_inside_empty >= 0 {
86
+ self.min_spaces_inside_empty
87
+ } else {
88
+ self.min_spaces_inside
89
+ }
90
+ }
91
+
92
+ #[must_use]
93
+ pub const fn effective_max_empty(&self) -> i64 {
94
+ if self.max_spaces_inside_empty >= 0 {
95
+ self.max_spaces_inside_empty
96
+ } else {
97
+ self.max_spaces_inside
98
+ }
99
+ }
100
+
101
+ #[must_use]
102
+ pub const fn forbid(&self) -> Forbid {
103
+ self.forbid
104
+ }
105
+
106
+ #[must_use]
107
+ pub const fn min_spaces_inside(&self) -> i64 {
108
+ self.min_spaces_inside
109
+ }
110
+
111
+ #[must_use]
112
+ pub const fn max_spaces_inside(&self) -> i64 {
113
+ self.max_spaces_inside
114
+ }
115
+ }
116
+
117
+ #[derive(Debug, Clone, PartialEq, Eq)]
118
+ pub struct Violation {
119
+ pub line: usize,
120
+ pub column: usize,
121
+ pub message: String,
122
+ }
123
+
124
+ pub struct FlowCollectionDescriptor {
125
+ pub open: char,
126
+ pub close: char,
127
+ pub forbid_message: &'static str,
128
+ pub min_message: &'static str,
129
+ pub max_message: &'static str,
130
+ pub min_empty_message: &'static str,
131
+ pub max_empty_message: &'static str,
132
+ }
133
+
134
+ struct ScalarRangeCollector {
135
+ ranges: Vec<(usize, usize)>,
136
+ }
137
+
138
+ impl ScalarRangeCollector {
139
+ const fn new() -> Self {
140
+ Self { ranges: Vec::new() }
141
+ }
142
+
143
+ fn into_sorted(mut self) -> Vec<Range<usize>> {
144
+ self.ranges.sort_by(|a, b| a.0.cmp(&b.0));
145
+ self.ranges
146
+ .into_iter()
147
+ .filter_map(|(start, end)| (start <= end).then_some(start..end))
148
+ .collect()
149
+ }
150
+ }
151
+
152
+ impl SpannedEventReceiver<'_> for ScalarRangeCollector {
153
+ fn on_event(&mut self, ev: Event<'_>, span: Span) {
154
+ if matches!(ev, Event::Scalar(..)) {
155
+ let start = span.start.index();
156
+ let end = span.end.index();
157
+ self.ranges.push((start, end));
158
+ }
159
+ }
160
+ }
161
+
162
+ #[derive(Debug, Clone, Copy)]
163
+ struct CollectionState {
164
+ is_empty: bool,
165
+ }
166
+
167
+ #[derive(Debug, PartialEq, Eq)]
168
+ enum AfterResult {
169
+ SameLine { spaces: usize, next_idx: usize },
170
+ Ignored,
171
+ }
172
+
173
+ #[derive(Clone, Copy)]
174
+ struct SpacingMessages<'a> {
175
+ min: &'a str,
176
+ max: &'a str,
177
+ }
178
+
179
+ #[must_use]
180
+ pub fn check(
181
+ buffer: &str,
182
+ cfg: &Config,
183
+ desc: &FlowCollectionDescriptor,
184
+ ) -> Vec<Violation> {
185
+ if buffer.is_empty() {
186
+ return Vec::new();
187
+ }
188
+
189
+ let mut parser = Parser::new_from_str(buffer);
190
+ let mut collector = ScalarRangeCollector::new();
191
+ let _ = parser.load(&mut collector, true);
192
+ let scalar_ranges = collector.into_sorted();
193
+
194
+ let chars: Vec<(usize, char)> = buffer.char_indices().collect();
195
+ let buffer_len = buffer.len();
196
+ let scalar_ranges = ranges_to_char_indices(scalar_ranges, &chars, buffer_len);
197
+ let line_starts = build_line_starts(buffer);
198
+
199
+ let mut range_idx = 0usize;
200
+ let mut idx = 0usize;
201
+ let mut stack: Vec<CollectionState> = Vec::new();
202
+ let mut violations = Vec::new();
203
+
204
+ while idx < chars.len() {
205
+ while range_idx < scalar_ranges.len() && scalar_ranges[range_idx].end <= idx {
206
+ range_idx += 1;
207
+ }
208
+
209
+ if let Some(range) = scalar_ranges.get(range_idx)
210
+ && idx >= range.start
211
+ && idx < range.end
212
+ {
213
+ if idx == range.start
214
+ && let Some(state) = stack.last_mut()
215
+ {
216
+ state.is_empty = false;
217
+ }
218
+ idx = range.end;
219
+ continue;
220
+ }
221
+
222
+ let ch = chars[idx].1;
223
+ if desc.open == '{'
224
+ && let Some(next_idx) = template_double_curly_end(&chars, idx)
225
+ {
226
+ idx = next_idx;
227
+ continue;
228
+ }
229
+ if ch == desc.open {
230
+ if let Some(state) = stack.last_mut() {
231
+ state.is_empty = false;
232
+ }
233
+ handle_open(
234
+ cfg,
235
+ desc,
236
+ &chars,
237
+ idx,
238
+ &line_starts,
239
+ &mut stack,
240
+ &mut violations,
241
+ );
242
+ } else if ch == desc.close {
243
+ handle_close(
244
+ cfg,
245
+ desc,
246
+ &chars,
247
+ idx,
248
+ &line_starts,
249
+ &mut stack,
250
+ &mut violations,
251
+ );
252
+ } else {
253
+ match ch {
254
+ '#' => {
255
+ idx = skip_comment(&chars, idx);
256
+ continue;
257
+ }
258
+ ',' | ' ' | '\t' | '\n' => {}
259
+ '\r' => {
260
+ if idx + 1 < chars.len() && chars[idx + 1].1 == '\n' {
261
+ idx += 1;
262
+ }
263
+ }
264
+ _ => {
265
+ if let Some(state) = stack.last_mut() {
266
+ state.is_empty = false;
267
+ }
268
+ }
269
+ }
270
+ }
271
+
272
+ idx += 1;
273
+ }
274
+
275
+ violations
276
+ }
277
+
278
+ fn handle_open(
279
+ cfg: &Config,
280
+ desc: &FlowCollectionDescriptor,
281
+ chars: &[(usize, char)],
282
+ idx: usize,
283
+ line_starts: &[usize],
284
+ stack: &mut Vec<CollectionState>,
285
+ violations: &mut Vec<Violation>,
286
+ ) {
287
+ let open_byte = chars[idx].0;
288
+ let (line, column) = line_and_column(line_starts, open_byte);
289
+ let next_significant = next_significant_index(chars, idx);
290
+
291
+ let mut skip_open_check = false;
292
+ match cfg.forbid() {
293
+ Forbid::All => {
294
+ violations.push(Violation {
295
+ line,
296
+ column: column + 1,
297
+ message: desc.forbid_message.to_string(),
298
+ });
299
+ skip_open_check = true;
300
+ }
301
+ Forbid::NonEmpty => {
302
+ let is_empty = matches!(next_significant.map(|j| chars[j].1), Some(close) if close == desc.close);
303
+ if !is_empty {
304
+ violations.push(Violation {
305
+ line,
306
+ column: column + 1,
307
+ message: desc.forbid_message.to_string(),
308
+ });
309
+ skip_open_check = true;
310
+ }
311
+ }
312
+ Forbid::None => {}
313
+ }
314
+
315
+ let mut state = CollectionState {
316
+ is_empty: matches!(next_significant.map(|j| chars[j].1), Some(close) if close == desc.close),
317
+ };
318
+
319
+ if !skip_open_check
320
+ && let AfterResult::SameLine { spaces, next_idx } =
321
+ compute_spaces_after_open(chars, idx)
322
+ {
323
+ let next_byte = chars[next_idx].0;
324
+ let (line, next_column) = line_and_column(line_starts, next_byte);
325
+ if state.is_empty && chars[next_idx].1 == desc.close {
326
+ record_after_spacing(
327
+ cfg.effective_min_empty(),
328
+ cfg.effective_max_empty(),
329
+ spaces,
330
+ line,
331
+ next_column,
332
+ SpacingMessages {
333
+ min: desc.min_empty_message,
334
+ max: desc.max_empty_message,
335
+ },
336
+ violations,
337
+ );
338
+ } else {
339
+ state.is_empty = false;
340
+ record_after_spacing(
341
+ cfg.min_spaces_inside(),
342
+ cfg.max_spaces_inside(),
343
+ spaces,
344
+ line,
345
+ next_column,
346
+ SpacingMessages {
347
+ min: desc.min_message,
348
+ max: desc.max_message,
349
+ },
350
+ violations,
351
+ );
352
+ }
353
+ }
354
+
355
+ stack.push(state);
356
+ }
357
+
358
+ fn handle_close(
359
+ cfg: &Config,
360
+ desc: &FlowCollectionDescriptor,
361
+ chars: &[(usize, char)],
362
+ idx: usize,
363
+ line_starts: &[usize],
364
+ stack: &mut Vec<CollectionState>,
365
+ violations: &mut Vec<Violation>,
366
+ ) {
367
+ let Some(state) = stack.pop() else {
368
+ return;
369
+ };
370
+
371
+ if state.is_empty {
372
+ return;
373
+ }
374
+
375
+ if let Some(spaces) = compute_spaces_before_close(chars, idx) {
376
+ let spaces_i64 = i64::try_from(spaces).unwrap_or(i64::MAX);
377
+ let close_byte = chars[idx].0;
378
+ let (line, close_column) = line_and_column(line_starts, close_byte);
379
+ if cfg.max_spaces_inside() >= 0 && spaces_i64 > cfg.max_spaces_inside() {
380
+ let highlight = close_column.saturating_sub(1).max(1);
381
+ violations.push(Violation {
382
+ line,
383
+ column: highlight,
384
+ message: desc.max_message.to_string(),
385
+ });
386
+ }
387
+ if cfg.min_spaces_inside() >= 0 && spaces_i64 < cfg.min_spaces_inside() {
388
+ violations.push(Violation {
389
+ line,
390
+ column: close_column,
391
+ message: desc.min_message.to_string(),
392
+ });
393
+ }
394
+ }
395
+ }
396
+
397
+ fn record_after_spacing(
398
+ min: i64,
399
+ max: i64,
400
+ spaces: usize,
401
+ line: usize,
402
+ next_column: usize,
403
+ messages: SpacingMessages<'_>,
404
+ violations: &mut Vec<Violation>,
405
+ ) {
406
+ let spaces_i64 = i64::try_from(spaces).unwrap_or(i64::MAX);
407
+ if max >= 0 && spaces_i64 > max {
408
+ let highlight = next_column.saturating_sub(1).max(1);
409
+ violations.push(Violation {
410
+ line,
411
+ column: highlight,
412
+ message: messages.max.to_string(),
413
+ });
414
+ }
415
+ if min >= 0 && spaces_i64 < min {
416
+ violations.push(Violation {
417
+ line,
418
+ column: next_column,
419
+ message: messages.min.to_string(),
420
+ });
421
+ }
422
+ }
423
+
424
+ fn compute_spaces_after_open(chars: &[(usize, char)], open_idx: usize) -> AfterResult {
425
+ let mut spaces = 0usize;
426
+ let mut idx = open_idx + 1;
427
+ while idx < chars.len() {
428
+ match chars[idx].1 {
429
+ ' ' | '\t' => {
430
+ spaces += 1;
431
+ idx += 1;
432
+ }
433
+ '\n' | '\r' | '#' => return AfterResult::Ignored,
434
+ _ => {
435
+ return AfterResult::SameLine {
436
+ spaces,
437
+ next_idx: idx,
438
+ };
439
+ }
440
+ }
441
+ }
442
+ AfterResult::Ignored
443
+ }
444
+
445
+ fn compute_spaces_before_close(
446
+ chars: &[(usize, char)],
447
+ close_idx: usize,
448
+ ) -> Option<usize> {
449
+ let mut spaces = 0usize;
450
+ let mut idx = close_idx;
451
+ loop {
452
+ idx = idx
453
+ .checked_sub(1)
454
+ .expect("closing delimiter should have a preceding opening delimiter");
455
+ match chars[idx].1 {
456
+ ' ' | '\t' => spaces += 1,
457
+ '\n' | '\r' | '#' => return None,
458
+ _ => return Some(spaces),
459
+ }
460
+ }
461
+ }
462
+
463
+ fn next_significant_index(chars: &[(usize, char)], open_idx: usize) -> Option<usize> {
464
+ let mut idx = open_idx + 1;
465
+ while idx < chars.len() {
466
+ match chars[idx].1 {
467
+ ' ' | '\t' | '\n' => idx += 1,
468
+ '\r' => {
469
+ if idx + 1 < chars.len() && chars[idx + 1].1 == '\n' {
470
+ idx += 2;
471
+ } else {
472
+ idx += 1;
473
+ }
474
+ }
475
+ '#' => {
476
+ idx = skip_comment(chars, idx);
477
+ if idx >= chars.len() {
478
+ continue;
479
+ }
480
+ idx += 1;
481
+ }
482
+ _ => return Some(idx),
483
+ }
484
+ }
485
+ None
486
+ }
487
+
488
+ fn skip_comment(chars: &[(usize, char)], mut idx: usize) -> usize {
489
+ idx += 1;
490
+ while idx < chars.len() {
491
+ let ch = chars[idx].1;
492
+ if ch == '\n' {
493
+ break;
494
+ }
495
+ if ch == '\r' {
496
+ if idx + 1 < chars.len() && chars[idx + 1].1 == '\n' {
497
+ idx += 1;
498
+ }
499
+ break;
500
+ }
501
+ idx += 1;
502
+ }
503
+ idx
504
+ }
505
+
506
+ fn template_double_curly_end(chars: &[(usize, char)], idx: usize) -> Option<usize> {
507
+ if idx + 1 >= chars.len() || chars[idx].1 != '{' || chars[idx + 1].1 != '{' {
508
+ return None;
509
+ }
510
+ let mut cursor = idx + 2;
511
+ while cursor + 1 < chars.len() {
512
+ if chars[cursor].1 == '}' && chars[cursor + 1].1 == '}' {
513
+ let inner_contains_mapping =
514
+ chars[idx + 2..cursor].iter().any(|(_, ch)| *ch == ':');
515
+ return (!inner_contains_mapping).then_some(cursor + 2);
516
+ }
517
+ cursor += 1;
518
+ }
519
+ let inner_contains_mapping = chars[idx + 2..].iter().any(|(_, ch)| *ch == ':');
520
+ (!inner_contains_mapping).then_some(chars.len())
521
+ }
522
+
523
+ fn build_line_starts(buffer: &str) -> Vec<usize> {
524
+ let mut starts = Vec::new();
525
+ starts.push(0);
526
+ let bytes = buffer.as_bytes();
527
+ let mut idx = 0usize;
528
+ while idx < bytes.len() {
529
+ match bytes[idx] {
530
+ b'\n' => {
531
+ starts.push(idx + 1);
532
+ idx += 1;
533
+ }
534
+ b'\r' => {
535
+ if idx + 1 < bytes.len() && bytes[idx + 1] == b'\n' {
536
+ starts.push(idx + 2);
537
+ idx += 2;
538
+ } else {
539
+ starts.push(idx + 1);
540
+ idx += 1;
541
+ }
542
+ }
543
+ _ => idx += 1,
544
+ }
545
+ }
546
+ starts
547
+ }
548
+
549
+ fn line_and_column(line_starts: &[usize], byte_idx: usize) -> (usize, usize) {
550
+ let mut left = 0usize;
551
+ let mut right = line_starts.len();
552
+ while left + 1 < right {
553
+ let mid = usize::midpoint(left, right);
554
+ if line_starts[mid] <= byte_idx {
555
+ left = mid;
556
+ } else {
557
+ right = mid;
558
+ }
559
+ }
560
+ let line_start = line_starts[left];
561
+ (left + 1, byte_idx - line_start + 1)
562
+ }
@@ -0,0 +1,104 @@
1
+ use crate::config::YamlLintConfig;
2
+
3
+ pub const ID: &str = "hyphens";
4
+ pub const MESSAGE: &str = "too many spaces after hyphen";
5
+
6
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
7
+ pub struct Config {
8
+ max_spaces_after: i64,
9
+ }
10
+
11
+ impl Config {
12
+ const DEFAULT_MAX: i64 = 1;
13
+
14
+ #[must_use]
15
+ pub fn resolve(cfg: &YamlLintConfig) -> Self {
16
+ let max_spaces_after = cfg
17
+ .rule_option(ID, "max-spaces-after")
18
+ .and_then(saphyr::YamlOwned::as_integer)
19
+ .unwrap_or(Self::DEFAULT_MAX);
20
+ Self { max_spaces_after }
21
+ }
22
+
23
+ #[must_use]
24
+ pub const fn new_for_tests(max_spaces_after: i64) -> Self {
25
+ Self { max_spaces_after }
26
+ }
27
+
28
+ #[must_use]
29
+ pub const fn max_spaces_after(&self) -> i64 {
30
+ self.max_spaces_after
31
+ }
32
+ }
33
+
34
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
35
+ pub struct Violation {
36
+ pub line: usize,
37
+ pub column: usize,
38
+ }
39
+
40
+ #[must_use]
41
+ pub fn check(buffer: &str, cfg: &Config) -> Vec<Violation> {
42
+ let mut violations = Vec::new();
43
+
44
+ for (idx, raw_line) in buffer.lines().enumerate() {
45
+ let line = raw_line.trim_end_matches('\r');
46
+ if line.is_empty() {
47
+ continue;
48
+ }
49
+
50
+ let chars = line.char_indices();
51
+ let mut indent_chars = 0usize;
52
+ let mut hyphen_byte = None;
53
+
54
+ for (byte_idx, ch) in chars {
55
+ match ch {
56
+ ' ' | '\t' => {
57
+ indent_chars += 1;
58
+ }
59
+ '-' => {
60
+ hyphen_byte = Some(byte_idx);
61
+ break;
62
+ }
63
+ _ => break,
64
+ }
65
+ }
66
+
67
+ let Some(hyphen_pos) = hyphen_byte else {
68
+ continue;
69
+ };
70
+
71
+ let mut offset = hyphen_pos + 1;
72
+ let mut spaces_after = 0usize;
73
+
74
+ while let Some(ch) = line[offset..].chars().next() {
75
+ if matches!(ch, ' ' | '\t') {
76
+ spaces_after += 1;
77
+ offset += ch.len_utf8();
78
+ } else {
79
+ break;
80
+ }
81
+ }
82
+
83
+ if offset >= line.len() {
84
+ continue;
85
+ }
86
+
87
+ let next_byte = line.as_bytes()[offset];
88
+ if next_byte == b'#' {
89
+ continue;
90
+ }
91
+
92
+ let spaces_count = i64::try_from(spaces_after).unwrap_or(i64::MAX);
93
+
94
+ if spaces_count > cfg.max_spaces_after {
95
+ let column = indent_chars + 1 + spaces_after;
96
+ violations.push(Violation {
97
+ line: idx + 1,
98
+ column,
99
+ });
100
+ }
101
+ }
102
+
103
+ violations
104
+ }