sonicop 26.8.101

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 (72) hide show
  1. checksums.yaml +7 -0
  2. data/CONFORMANCE.md +109 -0
  3. data/Cargo.lock +975 -0
  4. data/Cargo.toml +40 -0
  5. data/LICENSE +21 -0
  6. data/NOTICE +7 -0
  7. data/README.ja.md +161 -0
  8. data/README.md +173 -0
  9. data/config/default.yml +6305 -0
  10. data/exe/sonicop +7 -0
  11. data/ext/sonicop/extconf.rb +38 -0
  12. data/lib/sonicop/runner.rb +67 -0
  13. data/lib/sonicop/version.rb +6 -0
  14. data/lib/sonicop.rb +8 -0
  15. data/licenses/RUBOCOP.txt +20 -0
  16. data/licenses/TREE_SITTER_RUBY.txt +21 -0
  17. data/src/cli.rs +940 -0
  18. data/src/config/inheritance.rs +332 -0
  19. data/src/config/loader.rs +117 -0
  20. data/src/config/mod.rs +461 -0
  21. data/src/config/paths.rs +436 -0
  22. data/src/config/plugin.rs +247 -0
  23. data/src/config/store.rs +141 -0
  24. data/src/cop_name.rs +71 -0
  25. data/src/diagnostic.rs +205 -0
  26. data/src/directives.rs +305 -0
  27. data/src/engine.rs +734 -0
  28. data/src/formatter.rs +684 -0
  29. data/src/lib.rs +42 -0
  30. data/src/main.rs +3 -0
  31. data/src/ruby_version.rs +393 -0
  32. data/src/rules/layout/empty_line_after_magic_comment.rs +49 -0
  33. data/src/rules/layout/end_of_line.rs +35 -0
  34. data/src/rules/layout/line_length.rs +167 -0
  35. data/src/rules/layout/mod.rs +13 -0
  36. data/src/rules/layout/space_after_comma.rs +32 -0
  37. data/src/rules/layout/space_around_operators.rs +104 -0
  38. data/src/rules/layout/space_inside_parens.rs +100 -0
  39. data/src/rules/layout/support.rs +23 -0
  40. data/src/rules/layout/trailing_empty_lines.rs +38 -0
  41. data/src/rules/layout/trailing_whitespace.rs +26 -0
  42. data/src/rules/lint/duplicate_methods.rs +72 -0
  43. data/src/rules/lint/mod.rs +7 -0
  44. data/src/rules/lint/syntax.rs +200 -0
  45. data/src/rules/lint/unused_block_argument.rs +74 -0
  46. data/src/rules/lint/useless_assignment.rs +104 -0
  47. data/src/rules/metrics/block_length.rs +39 -0
  48. data/src/rules/metrics/class_length.rs +34 -0
  49. data/src/rules/metrics/method_length.rs +10 -0
  50. data/src/rules/metrics/mod.rs +10 -0
  51. data/src/rules/metrics/module_length.rs +17 -0
  52. data/src/rules/metrics/parameter_lists.rs +21 -0
  53. data/src/rules/metrics/support.rs +105 -0
  54. data/src/rules/mod.rs +380 -0
  55. data/src/rules/naming/ascii_identifiers.rs +17 -0
  56. data/src/rules/naming/constant_name.rs +49 -0
  57. data/src/rules/naming/method_name.rs +51 -0
  58. data/src/rules/naming/mod.rs +9 -0
  59. data/src/rules/naming/support.rs +22 -0
  60. data/src/rules/naming/variable_name.rs +50 -0
  61. data/src/rules/security/eval.rs +170 -0
  62. data/src/rules/security/mod.rs +6 -0
  63. data/src/rules/style/frozen_string_literal_comment.rs +56 -0
  64. data/src/rules/style/hash_syntax.rs +63 -0
  65. data/src/rules/style/mod.rs +9 -0
  66. data/src/rules/style/numeric_literals.rs +59 -0
  67. data/src/rules/style/redundant_return.rs +143 -0
  68. data/src/rules/style/semicolon.rs +48 -0
  69. data/src/rules/style/string_literals.rs +74 -0
  70. data/src/rules/support.rs +31 -0
  71. data/src/source.rs +118 -0
  72. metadata +117 -0
data/src/lib.rs ADDED
@@ -0,0 +1,42 @@
1
+ //! The modules left public are the ones an embedder -- today, the integration tests -- drives the
2
+ //! linter through: load a [`config::Config`], inspect with [`engine`], read [`diagnostic`] types
3
+ //! back. Everything else is an implementation detail, kept private so that the cop registry and
4
+ //! the output layer stay free to change.
5
+
6
+ mod cli;
7
+ pub mod config;
8
+ pub mod cop_name;
9
+ pub mod diagnostic;
10
+ mod directives;
11
+ pub mod engine;
12
+ mod formatter;
13
+ mod ruby_version;
14
+ pub mod rules;
15
+ pub mod source;
16
+
17
+ pub const VERSION: &str = env!("CARGO_PKG_VERSION");
18
+ /// Full RuboCop release Sonicop mirrors; the JSON metadata reports this verbatim.
19
+ pub const RUBOCOP_COMPAT_FULL_VERSION: &str = "1.89.0";
20
+ /// `MAJOR.MINOR` form used where RuboCop itself omits the patch level (docs URLs, `-V`). Spelled
21
+ /// out rather than sliced from the full version because `&str` slicing is not const at this
22
+ /// crate's MSRV; the test below is what keeps the two from drifting apart.
23
+ pub const RUBOCOP_COMPAT_VERSION: &str = "1.89";
24
+
25
+ pub use cli::run;
26
+ /// Re-exported because [`config::Config::target_ruby_version`] hands it out.
27
+ pub use ruby_version::RubyVersion;
28
+
29
+ #[cfg(test)]
30
+ mod tests {
31
+ use super::{RUBOCOP_COMPAT_FULL_VERSION, RUBOCOP_COMPAT_VERSION};
32
+
33
+ #[test]
34
+ fn both_compat_versions_name_the_same_release() {
35
+ assert_eq!(
36
+ RUBOCOP_COMPAT_FULL_VERSION
37
+ .rsplit_once('.')
38
+ .map(|(short, _)| short),
39
+ Some(RUBOCOP_COMPAT_VERSION)
40
+ );
41
+ }
42
+ }
data/src/main.rs ADDED
@@ -0,0 +1,3 @@
1
+ fn main() {
2
+ std::process::exit(sonicop::run());
3
+ }
@@ -0,0 +1,393 @@
1
+ use std::fmt;
2
+ use std::fs;
3
+ use std::path::{Path, PathBuf};
4
+ use std::sync::LazyLock;
5
+
6
+ use anyhow::{Context, Result, bail};
7
+ use regex::Regex;
8
+ use tree_sitter::{Node, Parser};
9
+
10
+ const KNOWN_RUBIES: &[RubyVersion] = &[
11
+ RubyVersion::new(2, 0),
12
+ RubyVersion::new(2, 1),
13
+ RubyVersion::new(2, 2),
14
+ RubyVersion::new(2, 3),
15
+ RubyVersion::new(2, 4),
16
+ RubyVersion::new(2, 5),
17
+ RubyVersion::new(2, 6),
18
+ RubyVersion::new(2, 7),
19
+ RubyVersion::new(3, 0),
20
+ RubyVersion::new(3, 1),
21
+ RubyVersion::new(3, 2),
22
+ RubyVersion::new(3, 3),
23
+ RubyVersion::new(3, 4),
24
+ RubyVersion::new(4, 0),
25
+ RubyVersion::new(4, 1),
26
+ ];
27
+
28
+ const DEFAULT_TARGET_RUBY: RubyVersion = RubyVersion::new(2, 7);
29
+
30
+ #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
31
+ pub struct RubyVersion {
32
+ major: u16,
33
+ minor: u16,
34
+ }
35
+
36
+ impl RubyVersion {
37
+ pub const fn new(major: u16, minor: u16) -> Self {
38
+ Self { major, minor }
39
+ }
40
+
41
+ pub fn parse(value: &str) -> Option<Self> {
42
+ let mut parts = value.trim().trim_start_matches("ruby-").split('.');
43
+ let major = parts.next()?.parse().ok()?;
44
+ let minor = parts.next()?.parse().ok()?;
45
+ Some(Self::new(major, minor))
46
+ }
47
+ }
48
+
49
+ impl fmt::Display for RubyVersion {
50
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51
+ write!(formatter, "{}.{}", self.major, self.minor)
52
+ }
53
+ }
54
+
55
+ #[derive(Clone, Debug, Eq, PartialEq)]
56
+ pub(crate) enum TargetRubySource {
57
+ Environment,
58
+ Configuration,
59
+ Gemspec(PathBuf),
60
+ RubyVersionFile(PathBuf),
61
+ MiseToml(PathBuf),
62
+ ToolVersions(PathBuf),
63
+ BundlerLock(PathBuf),
64
+ Default,
65
+ }
66
+
67
+ #[derive(Clone, Debug, Eq, PartialEq)]
68
+ pub(crate) struct ResolvedTargetRuby {
69
+ pub(crate) version: RubyVersion,
70
+ pub(crate) source: TargetRubySource,
71
+ }
72
+
73
+ pub(crate) fn resolve_target_ruby(
74
+ configured: Option<RubyVersion>,
75
+ base_directory: &Path,
76
+ ) -> Result<ResolvedTargetRuby> {
77
+ if let Some(value) = std::env::var_os("RUBOCOP_TARGET_RUBY_VERSION") {
78
+ let value = value
79
+ .into_string()
80
+ .map_err(|_| anyhow::anyhow!("RUBOCOP_TARGET_RUBY_VERSION is not valid UTF-8"))?;
81
+ let version = RubyVersion::parse(&value)
82
+ .with_context(|| format!("invalid RUBOCOP_TARGET_RUBY_VERSION: {value}"))?;
83
+ return Ok(ResolvedTargetRuby {
84
+ version,
85
+ source: TargetRubySource::Environment,
86
+ });
87
+ }
88
+ if let Some(version) = configured {
89
+ return Ok(ResolvedTargetRuby {
90
+ version,
91
+ source: TargetRubySource::Configuration,
92
+ });
93
+ }
94
+ if let Some(path) = find_single_gemspec(base_directory)
95
+ && let Some(version) = target_from_gemspec(&path)?
96
+ {
97
+ return Ok(ResolvedTargetRuby {
98
+ version,
99
+ source: TargetRubySource::Gemspec(path),
100
+ });
101
+ }
102
+ if let Some(path) = find_upwards(base_directory, ".ruby-version")
103
+ && let Some(version) = version_file_value(&path, None)?
104
+ {
105
+ return Ok(ResolvedTargetRuby {
106
+ version,
107
+ source: TargetRubySource::RubyVersionFile(path),
108
+ });
109
+ }
110
+ if let Some(path) = find_upwards(base_directory, "mise.toml")
111
+ && let Some(version) = version_file_value(&path, Some("ruby ="))?
112
+ {
113
+ return Ok(ResolvedTargetRuby {
114
+ version,
115
+ source: TargetRubySource::MiseToml(path),
116
+ });
117
+ }
118
+ if let Some(path) = find_upwards(base_directory, ".tool-versions")
119
+ && let Some(version) = version_file_value(&path, Some("ruby "))?
120
+ {
121
+ return Ok(ResolvedTargetRuby {
122
+ version,
123
+ source: TargetRubySource::ToolVersions(path),
124
+ });
125
+ }
126
+ for filename in ["Gemfile.lock", "gems.locked"] {
127
+ if let Some(path) = find_upwards(base_directory, filename)
128
+ && let Some(version) = target_from_lockfile(&path)?
129
+ {
130
+ return Ok(ResolvedTargetRuby {
131
+ version,
132
+ source: TargetRubySource::BundlerLock(path),
133
+ });
134
+ }
135
+ }
136
+ Ok(ResolvedTargetRuby {
137
+ version: DEFAULT_TARGET_RUBY,
138
+ source: TargetRubySource::Default,
139
+ })
140
+ }
141
+
142
+ fn find_upwards(start: &Path, filename: &str) -> Option<PathBuf> {
143
+ start.ancestors().find_map(|directory| {
144
+ let candidate = directory.join(filename);
145
+ candidate.is_file().then_some(candidate)
146
+ })
147
+ }
148
+
149
+ fn find_single_gemspec(start: &Path) -> Option<PathBuf> {
150
+ for directory in start.ancestors() {
151
+ // An unreadable ancestor must not abort the walk: a gemspec may still live above it.
152
+ let Ok(entries) = fs::read_dir(directory) else {
153
+ continue;
154
+ };
155
+ let mut candidates = entries
156
+ .filter_map(Result::ok)
157
+ .map(|entry| entry.path())
158
+ .filter(|path| {
159
+ path.extension()
160
+ .is_some_and(|extension| extension == "gemspec")
161
+ });
162
+ let first = candidates.next();
163
+ if first.is_some() && candidates.next().is_none() {
164
+ return first;
165
+ }
166
+ }
167
+ None
168
+ }
169
+
170
+ fn target_from_gemspec(path: &Path) -> Result<Option<RubyVersion>> {
171
+ let source = fs::read_to_string(path)
172
+ .with_context(|| format!("failed to read {} as UTF-8", path.display()))?;
173
+ let mut parser = Parser::new();
174
+ parser
175
+ .set_language(&tree_sitter_ruby::LANGUAGE.into())
176
+ .context("failed to initialize the Ruby parser")?;
177
+ let tree = parser
178
+ .parse(&source, None)
179
+ .context("Ruby parser returned no syntax tree for gemspec")?;
180
+ if tree.root_node().has_error() {
181
+ return Ok(None);
182
+ }
183
+
184
+ let Some(value) = required_ruby_version_value(tree.root_node(), &source) else {
185
+ return Ok(None);
186
+ };
187
+ let Some(requirements) = literal_requirements(value, &source) else {
188
+ return Ok(None);
189
+ };
190
+ let parsed = requirements
191
+ .iter()
192
+ .map(|requirement| Requirement::parse(requirement))
193
+ .collect::<Option<Vec<_>>>();
194
+ Ok(parsed.and_then(|requirements| {
195
+ KNOWN_RUBIES.iter().copied().find(|version| {
196
+ requirements
197
+ .iter()
198
+ .all(|requirement| requirement.matches(*version))
199
+ })
200
+ }))
201
+ }
202
+
203
+ fn required_ruby_version_value<'tree>(node: Node<'tree>, source: &str) -> Option<Node<'tree>> {
204
+ if node.kind() == "assignment"
205
+ && let Some(left) = node.child_by_field_name("left")
206
+ && left.kind() == "call"
207
+ && let Some(method) = left.child_by_field_name("method")
208
+ && &source[method.byte_range()] == "required_ruby_version"
209
+ {
210
+ return node.child_by_field_name("right");
211
+ }
212
+ let mut cursor = node.walk();
213
+ node.named_children(&mut cursor)
214
+ .find_map(|child| required_ruby_version_value(child, source))
215
+ }
216
+
217
+ fn literal_requirements(node: Node<'_>, source: &str) -> Option<Vec<String>> {
218
+ if contains_kind(node, "interpolation") {
219
+ return None;
220
+ }
221
+ let mut strings = Vec::new();
222
+ collect_string_literals(node, source, &mut strings);
223
+ (!strings.is_empty()).then_some(strings)
224
+ }
225
+
226
+ fn collect_string_literals(node: Node<'_>, source: &str, strings: &mut Vec<String>) {
227
+ if node.kind() == "string" {
228
+ let mut cursor = node.walk();
229
+ let contents = node
230
+ .named_children(&mut cursor)
231
+ .filter(|child| child.kind() == "string_content")
232
+ .map(|child| &source[child.byte_range()])
233
+ .collect::<String>();
234
+ strings.push(contents);
235
+ return;
236
+ }
237
+ let mut cursor = node.walk();
238
+ for child in node.named_children(&mut cursor) {
239
+ collect_string_literals(child, source, strings);
240
+ }
241
+ }
242
+
243
+ fn contains_kind(node: Node<'_>, kind: &str) -> bool {
244
+ if node.kind() == kind {
245
+ return true;
246
+ }
247
+ let mut cursor = node.walk();
248
+ node.named_children(&mut cursor)
249
+ .any(|child| contains_kind(child, kind))
250
+ }
251
+
252
+ #[derive(Clone, Copy, Debug)]
253
+ enum RequirementOperator {
254
+ Equal,
255
+ Greater,
256
+ GreaterOrEqual,
257
+ Less,
258
+ LessOrEqual,
259
+ Pessimistic,
260
+ }
261
+
262
+ #[derive(Clone, Debug)]
263
+ struct Requirement {
264
+ operator: RequirementOperator,
265
+ version: Vec<u16>,
266
+ }
267
+
268
+ static REQUIREMENT_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
269
+ Regex::new(r"^\s*(~>|>=|<=|>|<|=)?\s*(\d+(?:\.\d+){0,2})\s*$")
270
+ .expect("the requirement pattern is a valid constant regex")
271
+ });
272
+
273
+ impl Requirement {
274
+ fn parse(value: &str) -> Option<Self> {
275
+ let captures = REQUIREMENT_PATTERN.captures(value)?;
276
+ let operator = match captures.get(1).map(|capture| capture.as_str()) {
277
+ Some("~>") => RequirementOperator::Pessimistic,
278
+ Some(">=") => RequirementOperator::GreaterOrEqual,
279
+ Some("<=") => RequirementOperator::LessOrEqual,
280
+ Some(">") => RequirementOperator::Greater,
281
+ Some("<") => RequirementOperator::Less,
282
+ Some("=") | None => RequirementOperator::Equal,
283
+ Some(_) => return None,
284
+ };
285
+ let version = captures[2]
286
+ .split('.')
287
+ .map(str::parse)
288
+ .collect::<std::result::Result<Vec<_>, _>>()
289
+ .ok()?;
290
+ Some(Self { operator, version })
291
+ }
292
+
293
+ fn matches(&self, candidate: RubyVersion) -> bool {
294
+ let candidate = [candidate.major, candidate.minor, 99];
295
+ let required = [
296
+ self.version[0],
297
+ self.version.get(1).copied().unwrap_or(0),
298
+ self.version.get(2).copied().unwrap_or(0),
299
+ ];
300
+ match self.operator {
301
+ RequirementOperator::Equal => candidate == required,
302
+ RequirementOperator::Greater => candidate > required,
303
+ RequirementOperator::GreaterOrEqual => candidate >= required,
304
+ RequirementOperator::Less => candidate < required,
305
+ RequirementOperator::LessOrEqual => candidate <= required,
306
+ RequirementOperator::Pessimistic => {
307
+ let upper = if self.version.len() <= 2 {
308
+ [required[0] + 1, 0, 0]
309
+ } else {
310
+ [required[0], required[1] + 1, 0]
311
+ };
312
+ candidate >= required && candidate < upper
313
+ }
314
+ }
315
+ }
316
+ }
317
+
318
+ fn version_file_value(path: &Path, prefix: Option<&str>) -> Result<Option<RubyVersion>> {
319
+ let source = fs::read_to_string(path)
320
+ .with_context(|| format!("failed to read {} as UTF-8", path.display()))?;
321
+ let value = match prefix {
322
+ None => source.lines().next().map(str::trim),
323
+ Some(prefix) => source.lines().find_map(|line| {
324
+ let line = line.trim();
325
+ line.strip_prefix(prefix).map(|value| {
326
+ value
327
+ .trim()
328
+ .trim_matches(|character| matches!(character, '\'' | '"'))
329
+ })
330
+ }),
331
+ };
332
+ Ok(value.and_then(RubyVersion::parse))
333
+ }
334
+
335
+ fn target_from_lockfile(path: &Path) -> Result<Option<RubyVersion>> {
336
+ let source = fs::read_to_string(path)
337
+ .with_context(|| format!("failed to read {} as UTF-8", path.display()))?;
338
+ let mut in_ruby_version = false;
339
+ for line in source.lines() {
340
+ if line.trim() == "RUBY VERSION" {
341
+ in_ruby_version = true;
342
+ continue;
343
+ }
344
+ if in_ruby_version {
345
+ let value = line.trim().strip_prefix("ruby ");
346
+ return Ok(value.and_then(RubyVersion::parse));
347
+ }
348
+ }
349
+ Ok(None)
350
+ }
351
+
352
+ pub(crate) fn validate_supported(version: RubyVersion) -> Result<()> {
353
+ if KNOWN_RUBIES.contains(&version) {
354
+ Ok(())
355
+ } else {
356
+ bail!("unsupported TargetRubyVersion: {version}")
357
+ }
358
+ }
359
+
360
+ #[cfg(test)]
361
+ mod tests {
362
+ use std::fs;
363
+
364
+ use tempfile::tempdir;
365
+
366
+ use super::{RubyVersion, TargetRubySource, resolve_target_ruby};
367
+
368
+ #[test]
369
+ fn resolves_minimum_known_version_from_gemspec_requirements() {
370
+ let directory = tempdir().unwrap();
371
+ fs::write(
372
+ directory.path().join("example.gemspec"),
373
+ "Gem::Specification.new do |spec|\n spec.required_ruby_version = Gem::Requirement.new(['>= 2.6.0', '< 4.0'])\nend\n",
374
+ )
375
+ .unwrap();
376
+
377
+ let resolved = resolve_target_ruby(None, directory.path()).unwrap();
378
+
379
+ assert_eq!(resolved.version, RubyVersion::new(2, 6));
380
+ assert!(matches!(resolved.source, TargetRubySource::Gemspec(_)));
381
+ }
382
+
383
+ #[test]
384
+ fn explicit_configuration_precedes_project_files() {
385
+ let directory = tempdir().unwrap();
386
+ fs::write(directory.path().join(".ruby-version"), "2.6.10\n").unwrap();
387
+
388
+ let resolved = resolve_target_ruby(Some(RubyVersion::new(3, 3)), directory.path()).unwrap();
389
+
390
+ assert_eq!(resolved.version, RubyVersion::new(3, 3));
391
+ assert_eq!(resolved.source, TargetRubySource::Configuration);
392
+ }
393
+ }
@@ -0,0 +1,49 @@
1
+ use crate::diagnostic::{Edit, Offense};
2
+ use crate::rules::RuleContext;
3
+
4
+ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
5
+ let mut last_magic = None;
6
+ for line_number in 1..=context.source.line_count().min(4) {
7
+ let line = context.source.line(line_number).trim();
8
+ if line_number == 1 && line.starts_with("#!") {
9
+ continue;
10
+ }
11
+ if is_magic_comment(line) {
12
+ last_magic = Some(line_number);
13
+ continue;
14
+ }
15
+ if line.is_empty() {
16
+ continue;
17
+ }
18
+ break;
19
+ }
20
+ let Some(line_number) = last_magic else {
21
+ return;
22
+ };
23
+ let next = line_number + 1;
24
+ if next > context.source.line_count() || context.source.line(next).trim().is_empty() {
25
+ return;
26
+ }
27
+ let insertion = context.source.line_start(next);
28
+ offenses.push(
29
+ context
30
+ .offense(
31
+ "Add an empty line after magic comments.",
32
+ insertion..insertion,
33
+ )
34
+ .corrected_by(Edit {
35
+ start: insertion,
36
+ end: insertion,
37
+ replacement: "\n".to_owned(),
38
+ safe: true,
39
+ }),
40
+ );
41
+ }
42
+
43
+ fn is_magic_comment(line: &str) -> bool {
44
+ let lower = line.to_ascii_lowercase();
45
+ lower.starts_with("# frozen_string_literal:")
46
+ || lower.starts_with("# encoding:")
47
+ || lower.starts_with("# coding:")
48
+ || (lower.starts_with("# -") && lower.contains("coding:"))
49
+ }
@@ -0,0 +1,35 @@
1
+ use crate::diagnostic::Offense;
2
+ use crate::rules::RuleContext;
3
+
4
+ /// Reports only, like RuboCop: this cop has no autocorrector upstream.
5
+ ///
6
+ /// Rewriting line endings here would fight `Layout/TrailingEmptyLines`, which normalizes the end
7
+ /// of the file to `\n`. On Windows, where `native` means CRLF, the two would undo each other on
8
+ /// every pass and autocorrect would never settle.
9
+ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
10
+ let style: String = context
11
+ .setting("EnforcedStyle")
12
+ .unwrap_or_else(|| "native".to_owned());
13
+ let crlf_expected = style == "crlf" || (style == "native" && cfg!(windows));
14
+ let bytes = context.source.text().as_bytes();
15
+ for (index, byte) in bytes.iter().enumerate() {
16
+ if *byte != b'\n' {
17
+ continue;
18
+ }
19
+ let has_cr = index > 0 && bytes[index - 1] == b'\r';
20
+ if has_cr == crlf_expected {
21
+ continue;
22
+ }
23
+ let (start, end) = if crlf_expected {
24
+ (index, index)
25
+ } else {
26
+ (index - 1, index + 1)
27
+ };
28
+ let message = if crlf_expected {
29
+ "Carriage return character missing."
30
+ } else {
31
+ "Carriage return character detected."
32
+ };
33
+ offenses.push(context.offense(message, start..end));
34
+ }
35
+ }
@@ -0,0 +1,167 @@
1
+ use std::collections::{HashMap, HashSet};
2
+ use std::sync::LazyLock;
3
+
4
+ use regex::Regex;
5
+ use tree_sitter::Node;
6
+ use unicode_width::UnicodeWidthStr;
7
+
8
+ use crate::diagnostic::{Edit, Offense};
9
+ use crate::rules::RuleContext;
10
+
11
+ pub(super) fn check(context: &RuleContext<'_>, offenses: &mut Vec<Offense>) {
12
+ let max: usize = context.setting("Max").unwrap_or(120);
13
+ let allow_uri: bool = context.setting("AllowURI").unwrap_or(true);
14
+ let allow_directives: bool = context.setting("AllowCopDirectives").unwrap_or(true);
15
+ let allow_qualified_name: bool = context.setting("AllowQualifiedName").unwrap_or(true);
16
+ let break_edits = line_break_edits(context, max);
17
+ for line_number in 1..=context.source.line_count() {
18
+ let raw = context.source.line(line_number);
19
+ let line = raw.trim_end_matches(['\r', '\n']);
20
+ let width = UnicodeWidthStr::width(line);
21
+ let line_start = context.source.line_start(line_number);
22
+ let line_range = line_start..line_start + line.len();
23
+ if width <= max
24
+ || context.in_heredoc(line_range)
25
+ || (allow_uri && (line.contains("http://") || line.contains("https://")))
26
+ || (allow_qualified_name && qualified_name_exempts_line(line, max))
27
+ || (allow_directives && line.contains("rubocop:"))
28
+ {
29
+ continue;
30
+ }
31
+ let start = line_start
32
+ + line
33
+ .char_indices()
34
+ .nth(max)
35
+ .map_or(line.len(), |(index, _)| index);
36
+ let offense = context.offense(
37
+ format!("Line is too long. [{width}/{max}]"),
38
+ start..line_start + line.len(),
39
+ );
40
+ offenses.push(match break_edits.get(&line_number) {
41
+ Some(edit) => offense.corrected_by(edit.clone()),
42
+ None => offense,
43
+ });
44
+ }
45
+ }
46
+
47
+ fn qualified_name_exempts_line(line: &str, max: usize) -> bool {
48
+ static QUALIFIED_NAME: LazyLock<Regex> = LazyLock::new(|| {
49
+ Regex::new(r"\b(?:[A-Z][A-Za-z0-9_]*::)+[A-Za-z_][A-Za-z0-9_]*\b").unwrap()
50
+ });
51
+ let Some(name) = QUALIFIED_NAME.find_iter(line).last() else {
52
+ return false;
53
+ };
54
+ let start = line[..name.start()].chars().count();
55
+ if start >= max {
56
+ return false;
57
+ }
58
+
59
+ let suffix = &line[name.end()..];
60
+ suffix.chars().all(|character| !character.is_whitespace())
61
+ || (line.contains('{') && line.ends_with('}'))
62
+ }
63
+
64
+ fn line_break_edits(context: &RuleContext<'_>, max: usize) -> HashMap<usize, Edit> {
65
+ let comments: HashSet<usize> = context
66
+ .nodes_of("comment")
67
+ .map(|node| node.start_position().row + 1)
68
+ .collect();
69
+ let mut edits = HashMap::new();
70
+
71
+ // RuboCop gives a single-line block precedence over the call that owns it.
72
+ // Breaking immediately after `{` / `do` is syntax preserving even when the
73
+ // line has a trailing comment.
74
+ for node in context
75
+ .nodes_of_any(&["block", "do_block"])
76
+ .filter(|node| node.start_position().row == node.end_position().row)
77
+ {
78
+ let start = node
79
+ .child_by_field_name("parameters")
80
+ .map_or_else(
81
+ || node.start_byte() + if node.kind() == "block" { 1 } else { 2 },
82
+ |parameters| parameters.end_byte(),
83
+ )
84
+ .min(node.end_byte());
85
+ edits.entry(node.start_position().row + 1).or_insert(Edit {
86
+ start,
87
+ end: start,
88
+ replacement: "\n".to_owned(),
89
+ safe: true,
90
+ });
91
+ }
92
+
93
+ for node in context
94
+ .nodes_of_any(&["call", "array", "hash", "method", "singleton_method"])
95
+ .filter(|node| breakable_collection_on_one_line(*node))
96
+ {
97
+ let line_number = node.start_position().row + 1;
98
+ if edits.contains_key(&line_number) || comments.contains(&line_number) {
99
+ continue;
100
+ }
101
+
102
+ let Some(mut elements) = breakable_elements(node, context) else {
103
+ continue;
104
+ };
105
+ if elements.len() < 2 {
106
+ continue;
107
+ }
108
+
109
+ if node.kind() == "call" && !call_parenthesized(node, context) {
110
+ elements.remove(0);
111
+ }
112
+ let Some(element) = elements
113
+ .iter()
114
+ .position(|element| element.start_position().column > max)
115
+ .map_or_else(
116
+ || elements.last().copied(),
117
+ |index| elements.get(index.saturating_sub(1)).copied(),
118
+ )
119
+ else {
120
+ continue;
121
+ };
122
+ let start = element.start_byte();
123
+ edits.insert(
124
+ line_number,
125
+ Edit {
126
+ start,
127
+ end: start,
128
+ replacement: "\n".to_owned(),
129
+ safe: true,
130
+ },
131
+ );
132
+ }
133
+
134
+ edits
135
+ }
136
+
137
+ fn breakable_collection_on_one_line(node: Node<'_>) -> bool {
138
+ if node.kind() == "call" {
139
+ return node
140
+ .child_by_field_name("arguments")
141
+ .is_some_and(|arguments| {
142
+ node.start_position().row == arguments.start_position().row
143
+ && arguments.start_position().row == arguments.end_position().row
144
+ });
145
+ }
146
+ node.start_position().row == node.end_position().row
147
+ }
148
+
149
+ fn breakable_elements<'tree>(
150
+ node: Node<'tree>,
151
+ context: &RuleContext<'_>,
152
+ ) -> Option<Vec<Node<'tree>>> {
153
+ let container = match node.kind() {
154
+ "call" => node.child_by_field_name("arguments")?,
155
+ "method" | "singleton_method" => node.child_by_field_name("parameters")?,
156
+ "array" => node,
157
+ "hash" if context.source.node_text(node).starts_with('{') => node,
158
+ _ => return None,
159
+ };
160
+ let mut cursor = container.walk();
161
+ Some(container.named_children(&mut cursor).collect())
162
+ }
163
+
164
+ fn call_parenthesized(node: Node<'_>, context: &RuleContext<'_>) -> bool {
165
+ node.child_by_field_name("arguments")
166
+ .is_some_and(|arguments| context.source.node_text(arguments).starts_with('('))
167
+ }