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