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.
- checksums.yaml +7 -0
- data/CONFORMANCE.md +109 -0
- data/Cargo.lock +975 -0
- data/Cargo.toml +40 -0
- data/LICENSE +21 -0
- data/NOTICE +7 -0
- data/README.ja.md +161 -0
- data/README.md +173 -0
- data/config/default.yml +6305 -0
- data/exe/sonicop +7 -0
- data/ext/sonicop/extconf.rb +38 -0
- data/lib/sonicop/runner.rb +67 -0
- data/lib/sonicop/version.rb +6 -0
- data/lib/sonicop.rb +8 -0
- data/licenses/RUBOCOP.txt +20 -0
- data/licenses/TREE_SITTER_RUBY.txt +21 -0
- data/src/cli.rs +940 -0
- data/src/config/inheritance.rs +332 -0
- data/src/config/loader.rs +117 -0
- data/src/config/mod.rs +461 -0
- data/src/config/paths.rs +436 -0
- data/src/config/plugin.rs +247 -0
- data/src/config/store.rs +141 -0
- data/src/cop_name.rs +71 -0
- data/src/diagnostic.rs +205 -0
- data/src/directives.rs +305 -0
- data/src/engine.rs +734 -0
- data/src/formatter.rs +684 -0
- data/src/lib.rs +42 -0
- data/src/main.rs +3 -0
- data/src/ruby_version.rs +393 -0
- data/src/rules/layout/empty_line_after_magic_comment.rs +49 -0
- data/src/rules/layout/end_of_line.rs +35 -0
- data/src/rules/layout/line_length.rs +167 -0
- data/src/rules/layout/mod.rs +13 -0
- data/src/rules/layout/space_after_comma.rs +32 -0
- data/src/rules/layout/space_around_operators.rs +104 -0
- data/src/rules/layout/space_inside_parens.rs +100 -0
- data/src/rules/layout/support.rs +23 -0
- data/src/rules/layout/trailing_empty_lines.rs +38 -0
- data/src/rules/layout/trailing_whitespace.rs +26 -0
- data/src/rules/lint/duplicate_methods.rs +72 -0
- data/src/rules/lint/mod.rs +7 -0
- data/src/rules/lint/syntax.rs +200 -0
- data/src/rules/lint/unused_block_argument.rs +74 -0
- data/src/rules/lint/useless_assignment.rs +104 -0
- data/src/rules/metrics/block_length.rs +39 -0
- data/src/rules/metrics/class_length.rs +34 -0
- data/src/rules/metrics/method_length.rs +10 -0
- data/src/rules/metrics/mod.rs +10 -0
- data/src/rules/metrics/module_length.rs +17 -0
- data/src/rules/metrics/parameter_lists.rs +21 -0
- data/src/rules/metrics/support.rs +105 -0
- data/src/rules/mod.rs +380 -0
- data/src/rules/naming/ascii_identifiers.rs +17 -0
- data/src/rules/naming/constant_name.rs +49 -0
- data/src/rules/naming/method_name.rs +51 -0
- data/src/rules/naming/mod.rs +9 -0
- data/src/rules/naming/support.rs +22 -0
- data/src/rules/naming/variable_name.rs +50 -0
- data/src/rules/security/eval.rs +170 -0
- data/src/rules/security/mod.rs +6 -0
- data/src/rules/style/frozen_string_literal_comment.rs +56 -0
- data/src/rules/style/hash_syntax.rs +63 -0
- data/src/rules/style/mod.rs +9 -0
- data/src/rules/style/numeric_literals.rs +59 -0
- data/src/rules/style/redundant_return.rs +143 -0
- data/src/rules/style/semicolon.rs +48 -0
- data/src/rules/style/string_literals.rs +74 -0
- data/src/rules/support.rs +31 -0
- data/src/source.rs +118 -0
- 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
data/src/ruby_version.rs
ADDED
|
@@ -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
|
+
}
|