Skip to content

Commit 981e960

Browse files
committed
new lint: path_ends_with_ext
1 parent eaf640d commit 981e960

File tree

11 files changed

+216
-0
lines changed

11 files changed

+216
-0
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -5245,6 +5245,7 @@ Released 2018-09-13
52455245
[`partialeq_ne_impl`]: https://rust-lang.github.io/rust-clippy/master/index.html#partialeq_ne_impl
52465246
[`partialeq_to_none`]: https://rust-lang.github.io/rust-clippy/master/index.html#partialeq_to_none
52475247
[`path_buf_push_overwrite`]: https://rust-lang.github.io/rust-clippy/master/index.html#path_buf_push_overwrite
5248+
[`path_ends_with_ext`]: https://rust-lang.github.io/rust-clippy/master/index.html#path_ends_with_ext
52485249
[`pattern_type_mismatch`]: https://rust-lang.github.io/rust-clippy/master/index.html#pattern_type_mismatch
52495250
[`permissions_set_readonly_false`]: https://rust-lang.github.io/rust-clippy/master/index.html#permissions_set_readonly_false
52505251
[`positional_named_format_parameters`]: https://rust-lang.github.io/rust-clippy/master/index.html#positional_named_format_parameters
@@ -5575,5 +5576,6 @@ Released 2018-09-13
55755576
[`allow-one-hash-in-raw-strings`]: https://doc.rust-lang.org/clippy/lint_configuration.html#allow-one-hash-in-raw-strings
55765577
[`absolute-paths-max-segments`]: https://doc.rust-lang.org/clippy/lint_configuration.html#absolute-paths-max-segments
55775578
[`absolute-paths-allowed-crates`]: https://doc.rust-lang.org/clippy/lint_configuration.html#absolute-paths-allowed-crates
5579+
[`allowed-dotfiles`]: https://doc.rust-lang.org/clippy/lint_configuration.html#allowed-dotfiles
55785580
[`enforce-iter-loop-reborrow`]: https://doc.rust-lang.org/clippy/lint_configuration.html#enforce-iter-loop-reborrow
55795581
<!-- end autogenerated links to configuration documentation -->

book/src/lint_configuration.md

+10
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,16 @@ Which crates to allow absolute paths from
751751
* [`absolute_paths`](https://rust-lang.github.io/rust-clippy/master/index.html#absolute_paths)
752752

753753

754+
## `allowed-dotfiles`
755+
Additional dotfiles (files or directories starting with a dot) to allow
756+
757+
**Default Value:** `{}` (`rustc_data_structures::fx::FxHashSet<String>`)
758+
759+
---
760+
**Affected lints:**
761+
* [`path_ends_with_ext`](https://rust-lang.github.io/rust-clippy/master/index.html#path_ends_with_ext)
762+
763+
754764
## `enforce-iter-loop-reborrow`
755765
#### Example
756766
```

clippy_lints/src/declared_lints.rs

+1
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ pub(crate) static LINTS: &[&crate::LintInfo] = &[
399399
crate::methods::OR_FUN_CALL_INFO,
400400
crate::methods::OR_THEN_UNWRAP_INFO,
401401
crate::methods::PATH_BUF_PUSH_OVERWRITE_INFO,
402+
crate::methods::PATH_ENDS_WITH_EXT_INFO,
402403
crate::methods::RANGE_ZIP_WITH_LEN_INFO,
403404
crate::methods::READONLY_WRITE_LOCK_INFO,
404405
crate::methods::READ_LINE_WITHOUT_TRIM_INFO,

clippy_lints/src/lib.rs

+7
Original file line numberDiff line numberDiff line change
@@ -662,12 +662,19 @@ pub fn register_plugins(store: &mut rustc_lint::LintStore, sess: &Session, conf:
662662
let allow_unwrap_in_tests = conf.allow_unwrap_in_tests;
663663
let suppress_restriction_lint_in_const = conf.suppress_restriction_lint_in_const;
664664
store.register_late_pass(move |_| Box::new(approx_const::ApproxConstant::new(msrv())));
665+
let allowed_dotfiles = conf
666+
.allowed_dotfiles
667+
.iter()
668+
.cloned()
669+
.chain(methods::DEFAULT_ALLOWED_DOTFILES.iter().copied().map(ToOwned::to_owned))
670+
.collect::<FxHashSet<_>>();
665671
store.register_late_pass(move |_| {
666672
Box::new(methods::Methods::new(
667673
avoid_breaking_exported_api,
668674
msrv(),
669675
allow_expect_in_tests,
670676
allow_unwrap_in_tests,
677+
allowed_dotfiles.clone(),
671678
))
672679
});
673680
store.register_late_pass(move |_| Box::new(matches::Matches::new(msrv())));

clippy_lints/src/methods/mod.rs

+47
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ mod option_map_unwrap_or;
7474
mod or_fun_call;
7575
mod or_then_unwrap;
7676
mod path_buf_push_overwrite;
77+
mod path_ends_with_ext;
7778
mod range_zip_with_len;
7879
mod read_line_without_trim;
7980
mod readonly_write_lock;
@@ -120,6 +121,8 @@ use clippy_utils::msrvs::{self, Msrv};
120121
use clippy_utils::ty::{contains_ty_adt_constructor_opaque, implements_trait, is_copy, is_type_diagnostic_item};
121122
use clippy_utils::{contains_return, is_bool, is_trait_method, iter_input_pats, peel_blocks, return_ty};
122123
use if_chain::if_chain;
124+
pub use path_ends_with_ext::DEFAULT_ALLOWED_DOTFILES;
125+
use rustc_data_structures::fx::FxHashSet;
123126
use rustc_hir as hir;
124127
use rustc_hir::{Expr, ExprKind, Node, Stmt, StmtKind, TraitItem, TraitItemKind};
125128
use rustc_hir_analysis::hir_ty_to_ty;
@@ -3563,11 +3566,51 @@ declare_clippy_lint! {
35633566
"calls to `.take()` or `.skip()` that are out of bounds"
35643567
}
35653568

3569+
declare_clippy_lint! {
3570+
/// ### What it does
3571+
/// Looks for calls to `Path::ends_with` calls where the argument looks like a file extension.
3572+
///
3573+
/// By default, Clippy has a short list of known filenames that start with a dot
3574+
/// but aren't necessarily file extensions (e.g. the `.git` folder), which are allowed by default.
3575+
/// The `allowed-dotfiles` configuration can be used to allow additional
3576+
/// file extensions that Clippy should not lint.
3577+
///
3578+
/// ### Why is this bad?
3579+
/// This doesn't actually compare file extensions. Rather, `ends_with` compares the given argument
3580+
/// to the last **component** of the path and checks if it matches exactly.
3581+
///
3582+
/// ### Known issues
3583+
/// File extensions are often at most three characters long, so this only lints in those cases
3584+
/// in an attempt to avoid false positives.
3585+
/// Any extension names longer than that are assumed to likely be real path components and are
3586+
/// therefore ignored.
3587+
///
3588+
/// ### Example
3589+
/// ```rust
3590+
/// # use std::path::Path;
3591+
/// fn is_markdown(path: &Path) -> bool {
3592+
/// path.ends_with(".md")
3593+
/// }
3594+
/// ```
3595+
/// Use instead:
3596+
/// ```rust
3597+
/// # use std::path::Path;
3598+
/// fn is_markdown(path: &Path) -> bool {
3599+
/// path.extension().is_some_and(|ext| ext == "md")
3600+
/// }
3601+
/// ```
3602+
#[clippy::version = "1.74.0"]
3603+
pub PATH_ENDS_WITH_EXT,
3604+
suspicious,
3605+
"attempting to compare file extensions using `Path::ends_with`"
3606+
}
3607+
35663608
pub struct Methods {
35673609
avoid_breaking_exported_api: bool,
35683610
msrv: Msrv,
35693611
allow_expect_in_tests: bool,
35703612
allow_unwrap_in_tests: bool,
3613+
allowed_dotfiles: FxHashSet<String>,
35713614
}
35723615

35733616
impl Methods {
@@ -3577,12 +3620,14 @@ impl Methods {
35773620
msrv: Msrv,
35783621
allow_expect_in_tests: bool,
35793622
allow_unwrap_in_tests: bool,
3623+
allowed_dotfiles: FxHashSet<String>,
35803624
) -> Self {
35813625
Self {
35823626
avoid_breaking_exported_api,
35833627
msrv,
35843628
allow_expect_in_tests,
35853629
allow_unwrap_in_tests,
3630+
allowed_dotfiles,
35863631
}
35873632
}
35883633
}
@@ -3703,6 +3748,7 @@ impl_lint_pass!(Methods => [
37033748
FILTER_MAP_BOOL_THEN,
37043749
READONLY_WRITE_LOCK,
37053750
ITER_OUT_OF_BOUNDS,
3751+
PATH_ENDS_WITH_EXT,
37063752
]);
37073753

37083754
/// Extracts a method call name, args, and `Span` of the method name.
@@ -3978,6 +4024,7 @@ impl Methods {
39784024
if let ExprKind::MethodCall(.., span) = expr.kind {
39794025
case_sensitive_file_extension_comparisons::check(cx, expr, span, recv, arg);
39804026
}
4027+
path_ends_with_ext::check(cx, recv, arg, expr, &self.msrv, &self.allowed_dotfiles);
39814028
},
39824029
("expect", [_]) => {
39834030
match method_call(recv) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
use super::PATH_ENDS_WITH_EXT;
2+
use clippy_utils::diagnostics::span_lint_and_sugg;
3+
use clippy_utils::msrvs;
4+
use clippy_utils::msrvs::Msrv;
5+
use clippy_utils::source::snippet;
6+
use clippy_utils::ty::is_type_diagnostic_item;
7+
use rustc_ast::{LitKind, StrStyle};
8+
use rustc_data_structures::fx::FxHashSet;
9+
use rustc_errors::Applicability;
10+
use rustc_hir::{Expr, ExprKind};
11+
use rustc_lint::LateContext;
12+
use rustc_span::sym;
13+
use std::fmt::Write;
14+
15+
pub const DEFAULT_ALLOWED_DOTFILES: &[&str] = &[
16+
"git", "svn", "gem", "npm", "vim", "env", "rnd", "ssh", "vnc", "smb", "nvm", "bin",
17+
];
18+
19+
pub(super) fn check(
20+
cx: &LateContext<'_>,
21+
recv: &Expr<'_>,
22+
path: &Expr<'_>,
23+
expr: &Expr<'_>,
24+
msrv: &Msrv,
25+
allowed_dotfiles: &FxHashSet<String>,
26+
) {
27+
if is_type_diagnostic_item(cx, cx.typeck_results().expr_ty(recv).peel_refs(), sym::Path)
28+
&& !path.span.from_expansion()
29+
&& let ExprKind::Lit(lit) = path.kind
30+
&& let LitKind::Str(path, StrStyle::Cooked) = lit.node
31+
&& let Some(path) = path.as_str().strip_prefix('.')
32+
&& (1..=3).contains(&path.len())
33+
&& !allowed_dotfiles.contains(path)
34+
&& path.chars().all(char::is_alphanumeric)
35+
{
36+
let mut sugg = snippet(cx, recv.span, "..").into_owned();
37+
if msrv.meets(msrvs::OPTION_IS_SOME_AND) {
38+
let _ = write!(sugg, r#".extension().is_some_and(|ext| ext == "{path}")"#);
39+
} else {
40+
let _ = write!(sugg, r#".extension().map_or(false, |ext| ext == "{path}")"#);
41+
};
42+
43+
span_lint_and_sugg(
44+
cx,
45+
PATH_ENDS_WITH_EXT,
46+
expr.span,
47+
"this looks like a failed attempt at checking for the file extension",
48+
"try",
49+
sugg,
50+
Applicability::MaybeIncorrect
51+
);
52+
}
53+
}

clippy_lints/src/utils/conf.rs

+5
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,11 @@ define_Conf! {
561561
/// Which crates to allow absolute paths from
562562
(absolute_paths_allowed_crates: rustc_data_structures::fx::FxHashSet<String> =
563563
rustc_data_structures::fx::FxHashSet::default()),
564+
/// Lint: PATH_ENDS_WITH_EXT.
565+
///
566+
/// Additional dotfiles (files or directories starting with a dot) to allow
567+
(allowed_dotfiles: rustc_data_structures::fx::FxHashSet<String> =
568+
rustc_data_structures::fx::FxHashSet::default()),
564569
/// Lint: EXPLICIT_ITER_LOOP
565570
///
566571
/// Whether to recommend using implicit into iter for reborrowed values.

tests/ui-toml/toml_unknown_key/conf_unknown_key.stderr

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ error: error reading Clippy's configuration file: unknown field `foobar`, expect
1010
allow-print-in-tests
1111
allow-private-module-inception
1212
allow-unwrap-in-tests
13+
allowed-dotfiles
1314
allowed-idents-below-min-chars
1415
allowed-scripts
1516
arithmetic-side-effects-allowed
@@ -82,6 +83,7 @@ error: error reading Clippy's configuration file: unknown field `barfoo`, expect
8283
allow-print-in-tests
8384
allow-private-module-inception
8485
allow-unwrap-in-tests
86+
allowed-dotfiles
8587
allowed-idents-below-min-chars
8688
allowed-scripts
8789
arithmetic-side-effects-allowed

tests/ui/path_ends_with_ext.fixed

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#![warn(clippy::path_ends_with_ext)]
2+
use std::path::Path;
3+
4+
macro_rules! arg {
5+
() => {
6+
".md"
7+
};
8+
}
9+
10+
fn test(path: &Path) {
11+
path.extension().is_some_and(|ext| ext == "md");
12+
//~^ ERROR: this looks like a failed attempt at checking for the file extension
13+
14+
// some "extensions" are allowed by default
15+
path.ends_with(".git");
16+
17+
// most legitimate "dotfiles" are longer than 3 chars, so we allow them as well
18+
path.ends_with(".bashrc");
19+
20+
// argument from expn shouldn't trigger
21+
path.ends_with(arg!());
22+
23+
path.ends_with("..");
24+
path.ends_with("./a");
25+
path.ends_with(".");
26+
path.ends_with("");
27+
}
28+
29+
// is_some_and was stabilized in 1.70, so suggest map_or(false, ..) if under that
30+
#[clippy::msrv = "1.69"]
31+
fn under_msv(path: &Path) -> bool {
32+
path.extension().map_or(false, |ext| ext == "md")
33+
//~^ ERROR: this looks like a failed attempt at checking for the file extension
34+
}
35+
36+
fn main() {}

tests/ui/path_ends_with_ext.rs

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#![warn(clippy::path_ends_with_ext)]
2+
use std::path::Path;
3+
4+
macro_rules! arg {
5+
() => {
6+
".md"
7+
};
8+
}
9+
10+
fn test(path: &Path) {
11+
path.ends_with(".md");
12+
//~^ ERROR: this looks like a failed attempt at checking for the file extension
13+
14+
// some "extensions" are allowed by default
15+
path.ends_with(".git");
16+
17+
// most legitimate "dotfiles" are longer than 3 chars, so we allow them as well
18+
path.ends_with(".bashrc");
19+
20+
// argument from expn shouldn't trigger
21+
path.ends_with(arg!());
22+
23+
path.ends_with("..");
24+
path.ends_with("./a");
25+
path.ends_with(".");
26+
path.ends_with("");
27+
}
28+
29+
// is_some_and was stabilized in 1.70, so suggest map_or(false, ..) if under that
30+
#[clippy::msrv = "1.69"]
31+
fn under_msv(path: &Path) -> bool {
32+
path.ends_with(".md")
33+
//~^ ERROR: this looks like a failed attempt at checking for the file extension
34+
}
35+
36+
fn main() {}

tests/ui/path_ends_with_ext.stderr

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
error: this looks like a failed attempt at checking for the file extension
2+
--> $DIR/path_ends_with_ext.rs:11:5
3+
|
4+
LL | path.ends_with(".md");
5+
| ^^^^^^^^^^^^^^^^^^^^^ help: try: `path.extension().is_some_and(|ext| ext == "md")`
6+
|
7+
= note: `-D clippy::path-ends-with-ext` implied by `-D warnings`
8+
= help: to override `-D warnings` add `#[allow(clippy::path_ends_with_ext)]`
9+
10+
error: this looks like a failed attempt at checking for the file extension
11+
--> $DIR/path_ends_with_ext.rs:32:5
12+
|
13+
LL | path.ends_with(".md")
14+
| ^^^^^^^^^^^^^^^^^^^^^ help: try: `path.extension().map_or(false, |ext| ext == "md")`
15+
16+
error: aborting due to 2 previous errors
17+

0 commit comments

Comments
 (0)