Skip to main content

cargo/lints/rules/
non_kebab_case_bins.rs

1use std::path::Path;
2
3use annotate_snippets::AnnotationKind;
4use annotate_snippets::Group;
5use annotate_snippets::Level;
6use annotate_snippets::Origin;
7use annotate_snippets::Patch;
8use annotate_snippets::Snippet;
9use cargo_util_schemas::manifest::TomlToolLints;
10
11use crate::CargoResult;
12use crate::GlobalContext;
13use crate::core::Package;
14use crate::core::Workspace;
15use crate::lints::AsIndex;
16use crate::lints::Lint;
17use crate::lints::LintLevel;
18use crate::lints::LintLevelReason;
19use crate::lints::STYLE;
20use crate::lints::get_key_value_span;
21use crate::lints::rel_cwd_manifest_path;
22
23pub static LINT: &Lint = &Lint {
24    name: "non_kebab_case_bins",
25    desc: "binaries should have a kebab-case name",
26    primary_group: &STYLE,
27    msrv: Some(super::CARGO_LINTS_MSRV),
28    edition_lint_opts: None,
29    feature_gate: None,
30    docs: Some(
31        r#"
32### What it does
33
34Detect binary names, explicit and implicit, that are not kebab-case
35
36### Why it is bad
37
38Kebab-case binary names is a common convention among command line tools.
39
40### Drawbacks
41
42It would be disruptive to existing users to change the binary name.
43
44A binary may need to conform to externally controlled conventions which can include a different naming convention.
45
46GUI applications may wish to choose a more user focused naming convention, like "Title Case" or "Sentence case".
47
48### Example
49
50```toml
51[[bin]]
52name = "foo_bar"
53```
54
55Should be written as:
56
57```toml
58[[bin]]
59name = "foo-bar"
60```
61"#,
62    ),
63};
64
65pub fn non_kebab_case_bins(
66    ws: &Workspace<'_>,
67    pkg: &Package,
68    manifest_path: &Path,
69    cargo_lints: &TomlToolLints,
70    error_count: &mut usize,
71    gctx: &GlobalContext,
72) -> CargoResult<()> {
73    let (lint_level, reason) = LINT.level(
74        cargo_lints,
75        pkg.rust_version(),
76        pkg.manifest().edition(),
77        pkg.manifest().unstable_features(),
78    );
79
80    if lint_level == LintLevel::Allow {
81        return Ok(());
82    }
83
84    let manifest_path = rel_cwd_manifest_path(manifest_path, gctx);
85
86    lint_package(
87        ws,
88        pkg,
89        &manifest_path,
90        lint_level,
91        reason,
92        error_count,
93        gctx,
94    )
95}
96
97pub fn lint_package(
98    ws: &Workspace<'_>,
99    pkg: &Package,
100    manifest_path: &str,
101    lint_level: LintLevel,
102    reason: LintLevelReason,
103    error_count: &mut usize,
104    gctx: &GlobalContext,
105) -> CargoResult<()> {
106    let manifest = pkg.manifest();
107
108    for (i, bin) in manifest.normalized_toml().bin.iter().flatten().enumerate() {
109        let Some(original_name) = bin.name.as_deref() else {
110            continue;
111        };
112        let kebab_case = heck::ToKebabCase::to_kebab_case(original_name);
113        if kebab_case == original_name {
114            continue;
115        }
116
117        let document = manifest.document();
118        let contents = manifest.contents();
119        let level = lint_level.to_diagnostic_level();
120        let emitted_source = LINT.emitted_source(lint_level, reason);
121
122        let mut primary_source = ws.target_dir().as_path_unlocked().to_owned();
123        // Elide profile/platform as we don't have that context
124        primary_source.push("...");
125        primary_source.push("");
126        let mut primary_source = primary_source.display().to_string();
127        let primary_span_start = primary_source.len();
128        let primary_span_end = primary_span_start + original_name.len();
129        primary_source.push_str(original_name);
130        primary_source.push_str(std::env::consts::EXE_SUFFIX);
131        let mut primary_group =
132            level
133                .primary_title(LINT.desc)
134                .element(Snippet::source(&primary_source).annotation(
135                    AnnotationKind::Primary.span(primary_span_start..primary_span_end),
136                ));
137        if i == 0 {
138            primary_group = primary_group.element(Level::NOTE.message(emitted_source));
139        }
140        let mut report = vec![primary_group];
141
142        if let Some((i, _target)) = manifest
143            .original_toml()
144            .iter()
145            .flat_map(|m| m.bin.iter().flatten())
146            .enumerate()
147            .find(|(_i, t)| t.name.as_deref() == Some(original_name))
148        {
149            let mut help = Group::with_title(
150                Level::HELP
151                    .secondary_title("to change the binary name to kebab case, convert `bin.name`"),
152            );
153            if let Some(document) = document
154                && let Some(contents) = contents
155                && let Some(span) = get_key_value_span(
156                    document,
157                    &["bin".as_index(), i.as_index(), "name".as_index()],
158                )
159            {
160                help = help.element(
161                    Snippet::source(contents)
162                        .path(manifest_path)
163                        .patch(Patch::new(span.value, format!("\"{kebab_case}\""))),
164                );
165            } else {
166                help = help.element(Origin::path(manifest_path));
167            }
168            report.push(help);
169        } else if is_default_main(bin.path.as_ref())
170            && manifest
171                .original_toml()
172                .iter()
173                .flat_map(|m| m.bin.iter().flatten())
174                .all(|t| t.path != bin.path)
175            && manifest
176                .original_toml()
177                .and_then(|t| t.package.as_ref())
178                .map(|p| p.name.is_some())
179                .unwrap_or(false)
180        {
181            // Showing package in case this is done before first publish to fix the problem at the
182            // root
183            let help_package_name =
184                "to change the binary name to kebab case, convert `package.name`";
185            // Including `[[bin]]` in case it is already published.
186            // Preferring it over moving the file to avoid having to get into moving the
187            // files it `mod`s
188            let help_bin_table = "to change the binary name to kebab case, specify `bin.name`";
189            if let Some(document) = document
190                && let Some(contents) = contents
191                && let Some(span) = get_key_value_span(document, &["package", "name"])
192            {
193                report.push(
194                    Level::HELP.secondary_title(help_package_name).element(
195                        Snippet::source(contents)
196                            .path(manifest_path)
197                            .patch(Patch::new(span.value, format!("\"{kebab_case}\""))),
198                    ),
199                );
200                report.push(
201                    Level::HELP.secondary_title(help_bin_table).element(
202                        Snippet::source(contents)
203                            .path(manifest_path)
204                            .patch(Patch::new(
205                                contents.len()..contents.len(),
206                                format!(
207                                    r#"
208[[bin]]
209name = "{kebab_case}"
210path = "src/main.rs""#
211                                ),
212                            )),
213                    ),
214                );
215            } else {
216                report.push(
217                    Level::HELP
218                        .secondary_title(help_package_name)
219                        .element(Origin::path(manifest_path)),
220                );
221                report.push(
222                    Level::HELP
223                        .secondary_title(help_bin_table)
224                        .element(Origin::path(manifest_path)),
225                );
226            }
227        } else {
228            let path = bin
229                .path
230                .as_ref()
231                .expect("normalized have a path")
232                .0
233                .as_path();
234            let display_path = path.as_os_str().to_string_lossy();
235            let end = display_path.len() - if display_path.ends_with(".rs") { 3 } else { 0 };
236            let start = path
237                .parent()
238                .map(|p| {
239                    let p = p.as_os_str().to_string_lossy();
240                    // Account for trailing slash that was removed
241                    p.len() + if p.is_empty() { 0 } else { 1 }
242                })
243                .unwrap_or(0);
244            let help = Level::HELP
245                .secondary_title("to change the binary name to kebab case, convert the file stem")
246                .element(Snippet::source(display_path).patch(Patch::new(start..end, kebab_case)));
247            report.push(help);
248        }
249
250        if lint_level.is_error() {
251            *error_count += 1;
252        }
253        gctx.shell().print_report(&report, lint_level.force())?;
254    }
255
256    Ok(())
257}
258
259fn is_default_main(path: Option<&cargo_util_schemas::manifest::PathValue>) -> bool {
260    let Some(path) = path else {
261        return false;
262    };
263    path.0 == std::path::Path::new("src/main.rs")
264}