Skip to main content

cargo/diagnostics/rules/
non_kebab_case_bins.rs

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