Skip to content

Conversation

@sbernauer
Copy link

Duration::from_mins and Duration::from_hours where recently stabilized in Rust 1.91.0.

In our codebase we often times have things like

Duration::from_secs(5 * 60);
// Since Rust 1.91.0 one can use
Duration::from_mins(5)

During the Rust 1.91.0 bump I noticed we can finally switch to Duration::from_mins(5), but many users might not be aware of this. I started manually updating the places, but halfway through I figured "Can't a lint do this for me?", so I added exactly that in this PR. It does it for all stabilized from_XXX time units.

changelog: Add new [duration_suboptimal_units] lint

@rustbot rustbot added needs-fcp S-waiting-on-review Status: Awaiting review from the assignee but also interested parties labels Dec 17, 2025
@rustbot
Copy link
Collaborator

rustbot commented Dec 17, 2025

r? @samueltardieu

rustbot has assigned @samueltardieu.
They will have a look at your PR within the next two weeks and either review your PR or reassign to another reviewer.

Use r? to explicitly pick a reviewer

@sbernauer sbernauer force-pushed the feat/duration-suboptimal-unit-lint branch from 2113a96 to 6f9d615 Compare December 17, 2025 15:05
@rustbot

This comment has been minimized.


// Delay to avoid updating the metadata too aggressively.
thread::sleep(Duration::from_millis(1000));
thread::sleep(Duration::from_secs(1));
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was brought up by the dogfood test

@sbernauer sbernauer force-pushed the feat/duration-suboptimal-unit-lint branch 2 times, most recently from 8eddec5 to 993e4f1 Compare December 17, 2025 19:52
Copy link
Contributor

@ada4a ada4a left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nicely implemented:)

Left some minor suggestions

View changes since this review

Comment on lines 82 to 85
let function_name = func_name.ident.name.as_str();
let Some((promoted_function_name, promoted_value)) = self.promote(cx, function_name, value) else {
return;
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be marginally better to make (promoted_)function_name Symbols:

Currently, we do the somewhat costly symbol-to-str conversion already when checking if the lint is applicable (when creating function_name). This is unfortunate, as a lint runs much more often than it fires.

If, however, we make function_name a Symbol, we'll:

  • be able to have Unit.function_name be a Symbol as well, making the equality checks in DurationSuboptimalUnits::promote a bit faster
  • only need to perform the final conversion of promoted_function_name inside span_lint_and_sugg

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is now using Symbol everywhere

function_name: "from_nanos",
// The value doesn't matter, as there is no previous unit
factor: 0,
const_support_added: msrvs::DURATION_CONSTS,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the rust version where the function was constified is a bit.. surprising? Combined with the fact that the lint doesn't make a distinction between const and non-const contexts, two interesting properties emerge:

  • The lint doesn't suggest Duration::from_mins in non-const context for rust versions 1.3.0 to 1.31.0, even though that would be totally valid. This is admittedly not that big of a problem.
  • The lint's suggestions in const context are valid "for free". I'd argue that this one's an unexpected property, and so it would be nice to have explicit tests for the const context as well (basically all the existing tests, but in a big const block)

Copy link
Author

@sbernauer sbernauer Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I took a shortcut of only suggesting functions that are stable in non-const and const, so that we don't need to distinguish them.
The code now clearly separates between const and non-const contexts.
Also added a const block to the tests

Comment on lines 135 to 165
struct Unit {
/// Name of the function on [`Duration`](std::time::Duration) to construct it from the given
/// unit, e.g. [`Duration::from_secs`](std::time::Duration::from_secs)
function_name: &'static str,

/// The increase factor over the previous (smaller) unit
factor: u128,

/// In what rustc version support for this function in const contexts was added
const_support_added: RustcVersion,
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice documentation here and elsewhere:)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

const_support_added: RustcVersion,
}

const UNITS: &[Unit] = &[
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could make this an array to avoid some copying

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Comment on lines 69 to 85
// We intentionally don't want to evaluate referenced constants, as we don't want to
// recommend a literal value over using constants:
//
// let dur = Duration::from_secs(SIXTY);
// ^^^^^^^^^^^^^^^^^^^^^^^^^^ help: try: `Duration::from_mins(1)`
let Some(Constant::Int(value)) = ConstEvalCtxt::new(cx).eval_local(arg, arg.span.ctxt()) else {
return;
};
// There is no need to promote e.g. 0 seconds to 0 hours
if value == 0 {
return;
}

let function_name = func_name.ident.name.as_str();
let Some((promoted_function_name, promoted_value)) = self.promote(cx, function_name, value) else {
return;
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: all of this could be pulled into the let-chain

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

impl LateLintPass<'_> for DurationSuboptimalUnits {
fn check_expr(&mut self, cx: &LateContext<'_>, expr: &'_ Expr<'_>) {
// Check if a function on std::time::Duration is called
if let ExprKind::Call(func, [arg]) = expr.kind
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it could make sense to prepend a !expr.span.from_expansion() check here, just to stay on the safer side

Copy link
Member

@samueltardieu samueltardieu Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer to check !expr.span.from_external_macro(cx.sess().source_map()), as linting inside local macros is appreciated.

Note: you can emulate code coming from an external macro by adding //@aux-build:proc_macros.rs at the beginning of the test file, and using code like proc_macros::external! { Duration::from_secs(3600) } in your tests.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that I could only find Span::in_external_macro, I will try to use that

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I mistyped

Copy link
Author

@sbernauer sbernauer Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added !expr.span.in_external_macro(cx.sess().source_map()) as well as a test.
I'm not sure of the distinction between an normal/internal(?) macro and an external macro, so please feel free to correct my comments in the tests.

Copy link
Member

@samueltardieu samueltardieu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be quite a long review. First of all, this looks great and I like the structure of the proposed lint.

The changes I'd like to see are mostly:

  • Don't use &str when Symbol are fine. They are lightweight (integers) and can be compared by equality.
  • Use narrower types instead of u128 when possible, especially in constants.
  • You may easily add .from_days() and .from_weeks() to the mix by checking if the feature is enabled. That would make adding them definitely a breeze when they are stabilized.
  • Add more macros checks: code in macros, code coming from macros, etc.

You are lucky in that you don't have to deal with const contexts: if one constructor works in a const context, then later constructors will also work if they exist as they have been const-stabilized at the same time as they have been stabilized, or at the same time their smaller variants have been stabilized.

View changes since this review

Comment on lines 49 to 56
pub struct DurationSuboptimalUnits {
msrv: Msrv,
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrt to including Duration::from_days() and Duration::from_weeks(). You might want to add a units: Vec<Unit> to this struct, and initialize it in the constructor: this would allow you to initialize units from UNITS, and if the duration_constructors feature is enabled to add entries from EXTENDED_UNITS that would reference those two functions. Note that this requires that you derive Clone on Unit.

You can check if features are enabled from a TyCtxt: tcx.features().enabled(sym::duration_constructors). And you'll get one when initializing the lint: Box::new(move |tcx| Box::new(duration_suboptimal_units::DurationSuboptimalUnits::new(tcx, conf))).

Then you can add #![feature(duration_constructors)] in the test file, or in another one if you want to test both situations.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented this alongside with some tests.
I was not able to contain all the tests in a single file with regard to #![feature(duration_constructors)], so there are now two separate test files. I hope that's ok

//
// let dur = Duration::from_secs(SIXTY);
// ^^^^^^^^^^^^^^^^^^^^^^^^^^ help: try: `Duration::from_mins(1)`
let Some(Constant::Int(value)) = ConstEvalCtxt::new(cx).eval_local(arg, arg.span.ctxt()) else {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid linting on duration values coming from macros, you need to use .eval_local(arg, expr.span.ctxt()) so that the following value is considered non-local:

    macro_rules! mac {
        (slow_rythm) => { 3600 };
    }
    let dur = Duration::from_secs(mac!(slow_rythm));

(please add this as a test)

Copy link
Author

@sbernauer sbernauer Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the mentioned test

return;
}

let function_name = func_name.ident.name.as_str();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no need to leave the Symbol interning world to do string comparisons. You should stay with symbols as long as possible, that is until you print a diagnostic (and even there, Symbol implements Display).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code now sticks to Symbols everywhere

}

let function_name = func_name.ident.name.as_str();
let Some((promoted_function_name, promoted_value)) = self.promote(cx, function_name, value) else {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All values for those functions fit in a u64:

Suggested change
let Some((promoted_function_name, promoted_value)) = self.promote(cx, function_name, value) else {
let Some((promoted_function_name, promoted_value)) =
self.promote(cx, func_name.ident.name, u64::try_from(value).unwrap())
else {

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mistakenly though stuff like from_nanos takes an u128. But all constructors we are using here take an u64 and only lower the value, so this is fine (tm).
Updated the code (i used expect instead of unwrap, hope that's ok)

/// function name and value.
///
/// Returns [`None`] in case no promotion could be done.
fn promote<'a>(&self, cx: &LateContext<'_>, function_name: &'a str, value: u128) -> Option<(&'a str, u128)> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
fn promote<'a>(&self, cx: &LateContext<'_>, function_name: &'a str, value: u128) -> Option<(&'a str, u128)> {
fn promote(&self, cx: &LateContext<'_>, function_name: Symbol, value: u64) -> Option<(Symbol, u64)> {

Copy link
Author

@sbernauer sbernauer Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the types as shown in the suggestion


const UNITS: &[Unit] = &[
Unit {
function_name: "from_nanos",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
function_name: "from_nanos",
function_name: sym::from_nanos,

(and similarly for the others)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Comment on lines 54 to 56
pub fn new(conf: &'static Conf) -> Self {
Self { msrv: conf.msrv }
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Others suggestion would lead to something like:

Suggested change
pub fn new(conf: &'static Conf) -> Self {
Self { msrv: conf.msrv }
}
pub fn new(tcx: TyCtxt<'_>, conf: &'static Conf) -> Self {
let mut units = UNITS.to_vec();
if tcx.features().enabled(sym::duration_constructors) {
units.extend(EXTENDED_UNITS.iter().cloned());
}
Self { msrv: conf.msrv, units }
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, picked. Except I use copied instead of cloned

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think .iter().copied() could even be left out completely, so that EXTENDED_UNITS is taken by value?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are absolutely right, now simply went with units.extend(EXTENDED_UNITS);

1,34,0 { TRY_FROM }
1,33,0 { UNDERSCORE_IMPORTS }
1,32,0 { CONST_IS_POWER_OF_TWO }
1,32,0 { CONST_IS_POWER_OF_TWO, DURATION_CONSTS }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
1,32,0 { CONST_IS_POWER_OF_TWO, DURATION_CONSTS }
1,32,0 { CONST_IS_POWER_OF_TWO, DURATION_FROM_MICROS_MILLIS }

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entire const and non-const min version detection has changed, can you please have another look at the new naming please?


// names may refer to stabilized feature flags or library items
msrv_aliases! {
1,91,0 { DURATION_CONSTRUCTORS_LITE }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
1,91,0 { DURATION_CONSTRUCTORS_LITE }
1,91,0 { DURATION_FROM_MINUTES_HOURS }

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entire const and non-const min version detection has changed, can you please have another look at the new naming please?

function_name: &'static str,

/// The increase factor over the previous (smaller) unit
factor: u128,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No factor is larger than 1000.

Suggested change
factor: u128,
factor: u16,

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Picked

@rustbot rustbot added S-waiting-on-author Status: This is awaiting some action from the author. (Use `@rustbot ready` to update this status) and removed S-waiting-on-review Status: Awaiting review from the assignee but also interested parties labels Dec 17, 2025
@rustbot
Copy link
Collaborator

rustbot commented Dec 17, 2025

Reminder, once the PR becomes ready for a review, use @rustbot ready.

@ada4a
Copy link
Contributor

ada4a commented Dec 17, 2025

Oops, sorry for stealing your work @samueltardieu ^^ It looks like our suggestions largely overlap; the only thing that only I have suggested is extending the let-chain

@samueltardieu
Copy link
Member

Oops, sorry for stealing your work @samueltardieu ^^ It looks like our suggestions largely overlap; the only thing that only I have suggested is extending the let-chain

No worries!

@sbernauer
Copy link
Author

Thanks for your quick and very helpful review! I will try my best to address it - this is my first clippy PR after all 😅

@sbernauer sbernauer force-pushed the feat/duration-suboptimal-unit-lint branch 2 times, most recently from b0f1852 to 467969d Compare December 18, 2025 09:37
@sbernauer
Copy link
Author

@rustbot ready

@rustbot rustbot added S-waiting-on-review Status: Awaiting review from the assignee but also interested parties and removed S-waiting-on-author Status: This is awaiting some action from the author. (Use `@rustbot ready` to update this status) labels Dec 18, 2025
expr.span,
"constructing a `Duration` using a smaller unit when a larger unit would be more readable",
"try",
format!("Duration::{promoted_constructor}({promoted_value})"),
Copy link
Contributor

@ada4a ada4a Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could become a problem if the original Duration was used qualified (e.g. as std:: time::Duration) instead of being imported. For this reason, the more correct approach would be to:

  1. replace span_lint_and_sugg with span_lint_and_then
  2. in it, emit a multipart_suggestion_verbose1, which performs 2 separate replacements:
  • the span of the original function name (e.g. from_secs) with promoted_constructor
  • the span of the original arg with promoted_value

You can grep for multipart_suggestion in the repo should you need some inspiration.

Footnotes

  1. theoretically, multipart_suggestion would work just as well, but the verbose version shows the suggestion in a nice diff-like view, which will great thanks to the suggestion actually changing only the relevant things.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Updated the PR to use multipart_suggestion_verbose and added a test for qualified Durations.
Just FYI that in the tests multipart_suggestion and multipart_suggestion_verbose formatted exactly the same way

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Updated the PR to use multipart_suggestion_verbose and added a test for qualified Durations.

Nice, thank you:)

Just FYI that in the tests multipart_suggestion and multipart_suggestion_verbose formatted exactly the same way

Ah, I misremembered, sorry for the confusion -- multipart suggestions are indeed verbose by default; it's span_suggestion (which is used in span_lint_and_sugg) and span_suggestion_verbose for which this distinction matters

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense 👍 Should we leave it as-is or shall I change something?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense 👍 Should we leave it as-is or shall I change something?

No no, leaving it as _verbose is fine.

One more thing: usually, the "try" message is used with non-verbose diagnostics, as the space is limited -- since we're being verbose now anyway, we might as well give a more descriptive message, something like "try using <promoted_constructor>"

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure thing, switched to "try using {promoted_constructor}"

@sbernauer sbernauer force-pushed the feat/duration-suboptimal-unit-lint branch from 467969d to 31740b4 Compare December 18, 2025 10:15
1,34,0 { TRY_FROM }
1,33,0 { UNDERSCORE_IMPORTS }
1,32,0 { CONST_IS_POWER_OF_TWO }
1,32,0 { CONST_IS_POWER_OF_TWO, DURATION_CONST_FROM_NANOS_MICROS_MILLIS_SECS }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the other constants have CONST_ at the start

Suggested change
1,32,0 { CONST_IS_POWER_OF_TWO, DURATION_CONST_FROM_NANOS_MICROS_MILLIS_SECS }
1,32,0 { CONST_IS_POWER_OF_TWO, CONST_DURATION_FROM_NANOS_MICROS_MILLIS_SECS }

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

upsi, fixed

@sbernauer sbernauer force-pushed the feat/duration-suboptimal-unit-lint branch 2 times, most recently from a064750 to c66e4a4 Compare December 18, 2025 11:28
@rustbot

This comment has been minimized.

@sbernauer sbernauer force-pushed the feat/duration-suboptimal-unit-lint branch from c66e4a4 to a3a052d Compare December 18, 2025 14:16
@rustbot
Copy link
Collaborator

rustbot commented Dec 18, 2025

This PR was rebased onto a different master commit. Here's a range-diff highlighting what actually changed.

Rebasing is a normal part of keeping PRs up to date, so no action is needed—this note is just to help reviewers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-fcp S-waiting-on-review Status: Awaiting review from the assignee but also interested parties

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants