Nudging users off 2015 edition

In general I agree that the edition setting locks you to higher MSRV.

However, 1.30 is absolutely ancient in Rust's history. Nobody is using it. It has nothing useful to offer to anybody. It has the oldest most primitive borrow checker that had soundness bugs and annoying limitations. There is absolutely no value in supporting it. Trying to be speculatively nice about it is actually harmful by reviving obsoleted syntax, awkward workarounds for the old dumb borrow checker, and needless code reinventing things that have been added to libstd since. You're also getting old LLVM with its own share of bugs before Rust-specific problems were ironed out.

Rust isn't like C where support for old standards versions may let you support more compilers that are useful to some users. You're just wasting time on supporting the same compiler in a less developed worse buggier version that nobody has any more.

It makes even less sense than trying to compile code with gcc 2.6. There isn't even a retrocomputing platform where 1.30 was meaningful. Back then Rust had even worse platform support.

13 Likes

Yes – I'd accidentally written PhantomData<fn (&'a ()) -> &'a T> when I meant PhantomData<(fn (&'a ()) -> &'a (), T)>. The former contains an implicit T: 'a, which on 1.26 has to be explicit, but I didn't want it.

I don't understand – MSRV is in Cargo.toml, it has nothing to do with crates.io. When working in other languages, I would normally set an explicit minimum version even if I didn't plan to distribute the program at all (so that if I did try to accidentally run the program on too old a version of the language, I would get a clear error message).

The usual way to copy software onto a system without Internet access nowadays is via a USB stick, and I see no reason why that wouldn't work for Rust source code. (Although I experimented with crates.io when I first started using Rust, nowadays I release my Rust projects as a source code tarball that includes all dependencies.) It might be possible to do it with rustc binaries too, but it'd be obnoxious. (And from my point of view, a common reason to not have Internet access is "this is an old computer that hasn't had any security updates for several years"; I consider it too dangerous to allow such computers to go online, and it's not completely unheard of for me to fall back to an old computer if something goes wrong with the current one.)

Nowadays, I generally only use dependencies I've written myself, so the situation of paying other people to add support for old versions in their crates doesn't come up.

The code I wrote didn't need any of that, though – the only syntax complexity was adding the leading :: on ::core, and otherwise everything just worked (apart from the bugs), including the borrow checker.

The advantage of the code running on 1.26 it that it should, presumably, run on every compiler released since then too, and thus this saves the trouble of having to pick the right arbitrary version as the MSRV. It's hard to come up with a sensible value for that other than "the oldest thing that works" or "whatever I have available" (which likely too new for many people – I'm currently on Rust 1.91 on my main computer, which is fairly recent, but one system I'm actively using at the moment is currently on Rust 1.75). For anything in between, you would have to make an arbitrary decision, and when a crate is simple enough, making that decision (which would involve trying to figure out what versions of Rust are still in common use) is harder than just trying it on old versions to see whether it works.

I'm not planning to avoid using useful modern features just to increase the range of support (e.g. one of my crates is MSRV 1.83 to allow mutable-reference-in-const, and argument-position impl Trait is just a nicety and yet I used it anyway to produce very slightly clearer documentation). It's just that I find it hard to come up with a sensible way to choose MSRV other than "the oldest version that supports the code I would be writing naturally".

That is not backed by an observable need, and I'm arguing it's a wrong way to decide MSRV. Such vacuous speculative desire to support oldest version may cross into deprecated, unsupported dead language versions which helps nobody, but indirectly has real downsides that hurt real users.

The Rust project doesn't support any old versions and currently 1.98 is the oldest. In practice, it's reasonable to support 1.85+ only - this is Debian stable baseline, which is already the OS for users who don't like upgrading anything.

If you want to be very generous, 1.60 covers basically everyone up to a rounding error, and you're already in a practically unusable territory due to being stuck with the old slow git crates.io protocol (current size of the registry makes it painful). Going few more versions down to 1.55 makes you more backwards compatible than almost all crates.io crates, at a cost of losing a bunch of good language features. Edition 2018 still lets you go way down, below the floor, and then digging far lower. Going back through time back the 1.4x, losing async, fallible allocations, and resurrecting UB bugs in libstd. And you can still regress worse adding more compiler and LLVM bugs, losing proper uninit handling by going down to 1.31. That version is already useless, beyond ancient and doesn't need Edition 2015.

If your code is compatible with 1.26 either it's a "left-pad" trivial code (which isn't important enough to outweigh needs of the entire rest of the ecosystem), or it's actually bad by modern Rust's standards: not using newer safer idioms, not compatible with const or async, not handling fallible alloc, not fully working with fixed-size arrays, not using many newer libstd functions that improve efficiency, not having access to proc macros, not reusing standard ecosystem-wide shared dependencies, etc. If a non-toy project works with such ancient compiler it's an island of NIH, old bugs, and deprecated syntax that Rust project wanted to bury. It is a disservice to support such old compilers.

Old compilers won't tell you anything useful about the code. That's a wrong direction! The latest compilers have much better warnings, better clippy, more lints, more APIs that make old unsafe hacks unnecessary, and lots of soundness fixes in the compiler, libstd, and LLVM.

5 Likes

That isn't how code written for old versions works: it's still compatible with newer language features when run on a newer compiler. (For example, code that's written to be compatible with 1.26 can't itself use async, but it can still be used as a dependency of code that uses async.) There's a difference between "doesn't need to use new features" and "isn't compatible with new features" (the latter is pretty unlikely, it would require somehow falling afoul of an intentional breaking change). Likewise, if code that's compatible with an old version is built with a recent version, you get all the soundness fixes (and soundness bugs) in the recent version. As such, there seems to be no downside to supporting old versions as well as the current version unless you happen to need a language feature (not just a bugfix) that was added more recently; it isn't like that prevents you supporting the current version too (and the aim is to allow people to build with whatever version of Rust they happen to have on hand, not to allow them to build with a specific old version).

Rust takes a lot of care to remain backwards-compatible and to be able to mix code written against different versions and different editions. You can (and should) test code written for old versions on recent compilers (this is probably more convenient than testing it on old compilers).

(Also, your comment makes it sound like Rust was entirely unusable in 2018. I'm pretty sure that it was an entirely usable language back then; plenty of people were using it for productive programming.)

Using newer features allows you to write more idiomatic code, and integrate better in the modern rust ecosystem, where people expect code to use modern idiomatic features. For example 1.75 stabilised impl trait (and async fn) in traits. Before there was a crate with a proc macro that sort of emulated this. If you keep using the old solution it is not going to integrate well into modern Rust code, and your downstreams will be annoyed.

Another one was I/O safety in 1.63. You don't need to use it to work with file descriptors, but it was quite annoying for a few months when half the crates had migrated and the other half had not. It made it hard to combine a crate for inotify with another crate wrapping epoll as I remember it.

There have been several features like that over the years where even if you don't need a newer version it actively benefits everyone downstream if you do use the more modern language and libs features.

It also makes your code harder to reuse (if I just want to take a function or module and reuse it under an applicable open source license), since I would have to modernise it to work inside my crate.

So yes, staying on ludicrously old MSRV just because you don't need something is doing everyone a disservice.

1 Like

What does "NIH" stand for/mean here?

1 Like

I'm not arguing for avoiding using these features in order to be compatible with older versions of Rust. If I have a program that happens to need an RPITIT, I am happy to write the RPITIT and target 1.75; this is obviously superior to adding a proc-macro dependency (or indeed any dependency). (However, my experience is that RPITIT on its own is not really usable, because there is in current Rust no way to name the resulting type. This is something that is planned to be fixed in the future, with the implementation of TAIT and/or ATPIT. Until it is, though, there's little reason to adopt RPITIT because it makes life harder for your downstream, rather than easier.)

I'm arguing that if the program naturally ends up not using those features (as most of the programs I write seem to), there's no reason to arbitrarily make it incompatible with older versions of Rust. Most newer features don't actually come up very often (e.g. generic associated types are hugely important in expanding what Rust can do, but most programs will never need one), and most of the features that would be useful aren't in Rust yet (which is part of what drew me to IRLO). Some I consider to make programs harder to read, e.g. match ergonomics and lifetime elision, and thus I would not want to use those features in code intended for other people to read anyway.

As for I/O safety, this implies the use of Rust std. Making a library no_std is usually possible (unless it communicates with the OS), and this is a much more important factor than targeting a particular MSRV or keeping to a particular edition. As such, I program in no_std by default, and typically use std only for test code and for the outermost levels of an application.

(I guess there's an argument that Rust 2018 syntax is easier to read than Rust 2015. This is true in some cases, e.g. dyn, but most programs don't use dyn, and the only common difference is in the import syntax.)

1 Like

This feels like a split between "minimum usable Rust version" (MURV) and "minimum supported Rust version" (MSRV), where the distinction is that you will actively avoid using newer features to keep an MSRV down, but you're happy if the consequence of writing code for an MSRV of 1.98.1 is code with an MURV of 1.26.

The distinction I'm making is that with an MSRV target, you're actively avoiding newer Rust features even when they'll make your code clearer and easier to read, while with an MURV, you're happy to raise it because a feature not supported in the current MURV makes your code better, as long as it doesn't also raise the MSRV.

So, if the lowest Rust toolchain version in your fleet of machines is 1.74, you might target an MSRV of 1.74 (and be cautious about features that need newer toolchains), but you're still happy with the lowest MURV you can get.

Note that I've just tested, and Rust 1.0 faced with an explicit edition key in Cargo.toml says (with the path replaced by :

$ cargo +1.0 run
unused manifest key: package.edition
   Compiling edition_test v0.1.0 (…)
     Running `target/debug/edition_test`
Hello, world!

Thus, erroring out in new toolchains if you don't explicitly set an edition is almost certainly safe, even for the use case where you want an MURV of 1.0 - you can set an explicit edition of 2015, and pre-edition compilers will ignore the key, and you can allow/ignore any "old edition in use" warning.

2 Likes

I feel like it might be good to have a look at some real-world code that would be affected. The first crate that came to my mind that uses the 2015 edition without explicitly specifying so is hound, which is rather widely used, and neither trivial nor particularly unidiomatic (besides the use of try! instead of ?). They have a MSRV of 1.40 and could therefore update to the 2018 edition, but haven't done so because it's "a mostly mechanical change" and it "would pollute the Git history (especially the blame view) with churn". Breaking such code would be very unfortunate in my opinion.

Carge features go through phas:s

  1. unused manifest key warning
  2. unstable feature error
  3. feature works

Testing the oldest version possible is not representative.

1 Like

I've bisected a bit, manually on the back of that.

As expected, 1.31 (the version that introduced 2018 edition) is fine with an edition = "2015" line. No complaints, no warnings, nothing.

1.20 gives the same warning as 1.0, as does 1.25 and 1.26. 1.27 through 1.29 complains about an unstable feature, and errors out. 1.30 works with edition = "2015", but complains about edition = "2018", as the 2018 edition is unstable at that point in time.

So, @ais523, to correct myself with the extra information: if Rust makes an explicit edition a requirement in future, you will not be able to use Rust 1.27, Rust 1.28 or Rust 1.29 as your MURV without opting-out of versions that require an explicit version. Rust 1.30 or above becomes your new MURV. I'd also guess that, given that 1.0 and 1.26 have the same complaint, that you could use 1.0 to 1.26 inclusive, but I've not checked.

That, in turn, means that you could only use Rust compilers from October 2018 onwards, or up until June 2018, if an explicit edition was required by a newer toolchain. If the change is simply to make unspecified edition deny-by-default (and thus there's a way to tell cargo that you want it to treat unspecified as 2015 instead of giving an error - maybe the new cargo lints infrastructure?), then it doesn't even block that much, it just makes it harder for you to use both ancient compilers and modern ones.

Personally, I think that's a good trade off - people who are unfamiliar with the project's decisions before 2019 don't get surprised when accidentally deleting the wrong line in Cargo.toml causes code that worked before to break (or when copying-and-pasting something that's ancient). Code like hound can update to explicitly request the 2015 edition and then needs no further changes (I'd be very opposed to removing the 2015 edition, or even warning against it, as long as it's explicitly requested).

2 Likes

The MSRV/MURV split does seem like a good one (although if like me you don't decide the MSRV until after writing the code, the amount of difference it makes may be small for some crates: the only situation I can think of where it matters is where you need to make a bugfix, and the bugfix would like to use newer Rust features than the code already uses, because it is reasonable to increase the MSRV when adding new features). Presumably Cargo.toml contains a MURV, and the MSRV is specified in documentation when talking about the crate's support status.

This does actually give a good non-arbitrary baseline for deciding a MURV (either 1.30 in that it is the oldest version which understands the modern edition syntax needed for forward compatibility, or 1.31 on the basis that any specific old version is unlikely and it would save the need to use the old import syntax).

The rust-version in Cargo.toml can be either - you can always override it with --ignore-rust-version, after all. There's no reason why a crate author should bother checking if their crate can be used with versions older than they specify, and it's entirely possible that a crate specifies 1.98 in that field, but is usable back to 1.31 (for example).

1 Like

rust-version in the Cargo.toml is orthogonal, it says this exact crate version is known to work with this rust version, but nothing about what your policy on minimum versions is, which is the main thing an MSRV is useful for (you can't really provide "support" for an immutable published artifact).

1 Like

Here's a fun example for you: fmt2io - Rust

That thing supports literally Rust 1.0.0 (yes, I tested it, when I released it) and is itself considered "done" - there is no feature I can think of that can be added and no bug that I believe could be found in such tiny crate. So why should I bother ever touching it again just to arbitrarily remove support for old compilers?

The rustc defaulting to 2015 argument is not applicable here - I wrote it that way on purpose, module system is also not a applicable - my crate needs no modules. Traits either. syn should be able to parse it since I don't use nameless function arguments. But you don't need edition change for that - just name the arguments!

As @ais523 very well explained, any version bump would be to an arbitrary number and I would have to think about it and justify it. For what benefit?

Tbh, that seems really close to a left-pad situation. If there's a need for it then I feel it should just be in std rather than being a separate crate.

2 Likes

That's syn's choice to make, but I don't think it should push rustc towards breaking its backwards compatibility promise in such a fundamental way.

I can live with emitting a warning when no edition is set, for the other reasons you mention. I don't think edition 2015 should be used for actually maintained code. If some people want to have fun doing Retro-Rust by writing code that is compatible with version 1.26, then I don't mind much as long as they don't expect anyone else to invest any time or effort into this -- though they will have to live with the warnings (de-confusing new users is more important than supporting this rather niche use-case). But I am very strongly opposed to making it a hard error. That just looks too much like breaking our word (because it is, in fact, breaking our word).

If for some reason we think syn is more important than our backwards compatibility promise, then the most extreme step I could consider acceptable is to emit an FCW for nameless function arguments and eventually make them a hard error. I would find it rather odd for us to break backwards compatibility because syn couldn't be bothered to parse the full Rust grammar, but at least that would limit the breakage to those crates syn actually has trouble with. If crater finds 0 regressions and this simplifies the parser, then -- well, we've done other crater-neutral regressions in the past for cleanup reasons.

But really, I think this is a mismatch between what syn intends to be, and how some tooling uses it. In its current form, syn is simply not suited for ecosystem analysis. That seems to be intentional from the side of the syn authors, so if you are using syn in a tool that is meant for ecosystem analysis, you are holding it wrong. I don't think "let's make this other people's problem by doing a breaking change to the language" is an adequate answer to this. At some point syn might drop more deprecated old syntax, are you then going to suggest more breaking changes? What you need for such a tool is a parser that is committed to parsing all valid Rust code. Don't use the wrong tool for the job and then ask others to pay the price for this inadequate choice. (I'm aware that the right tool for the job may simply not exist at the moment, but that doesn't make your request any more reasonable IMO.)

5 Likes

This thread started with "we should make sure people aren't actively using edition 2015", but it seems pretty inherent that crates may be passively using edition 2015—the crates that, as Kixunil brought up, are "complete". If the goal is really "drop edition 2015 from the compiler altogether", we'd all be in the mindset of "that breaks the 1.x semver commitment". Which is not a "we can never do this"—indeed, Rust and non-Rust languages alike have broken things on the margins for a long time, usually code that compiled but never had sensible operating semantics—but I think everyone would be on the same page about that being a Big change. So if that's not what's proposed…it starts to feel like "edition 2015 continues to work for executables written in edition 2015, and their dependencies, but not necessarily for libraries written in edition 2015, that may be used by later executables" (because we want to run non-compiler tooling on our dependencies). And to me that still feels pretty Big.

I know the ideal being pursued here is "forward-compat, not backward-compat" (from a library's point of view; it's reversed for the compiler), where anything written in 2016 should still compile in 2026, but anything written in 2026 should not worry about a 2016 compiler. But the edition system doesn't make that distinction, and asking libraries in maintenance mode to get off edition 2015 as a Rust Policy doesn't seem very productive.

Has this come up in practice with your dependency analyses? Did you try asking the library owner to update? What did they say?

4 Likes