In the SQLx Discord last night, I was lamenting the loss of the legacy syntax extensions and thus the ability to just plug extra lints into the compiler. It seems like the "just add it to Clippy" solution is satisfactory for most of the community; however, I feel like there is still a lot of potential in supporting third-party lints.
Motivation(s)
SQLx
Users of SQLx often run into a pitfall that we can't easily fix with API design:
// check out a connection from the pool and begin a SQL transaction
let mut txn = pg_pool.begin().await?;
// locks the row to exclude concurrent updates
let something_to_update = sqlx::query!(
"select * from foo where id = $1 for update",
foo_id
)
.fetch_one(&mut txn)
.await?;
let new_bar = /* calculate from `something_to_update` */;
// perform our update
sqlx::query!(
"update foo set bar = $1 where id = $2",
new_bar,
foo_id
)
.await?;
// tell the database to save the changes
txn.commit().await?;
However, what happens if someone forgets that last line? We can't leave the connection in a transaction indefinitely, because we want to release it back to the pool, so the transaction needs to be cancelled, or rolled back. The transaction type here will do that on-drop (by implicitly spawning a task since this is async).
We can't implicitly commit on-drop if there are changes because execution could have hit one of those early returns from the ?
operator but we weren't able to make all the changes the user expected, so rolling back on-drop is the safer default.
We could fix this issue using a closure, which we do provide as an option, but that has its own usability issues: `Connection::transaction` is not usable · Issue #604 · launchbadge/sqlx · GitHub
And the lack of async traits means a nigh-unreadable signature and boxing the return type (since it uses async fn
internally; even with async traits this might just end up a HRTB nightmare): Connection in sqlx - Rust
What we would really love is to be able to lint this, and warn when a transaction is dropped at the end of normal execution without being committed. #[must_use]
isn't sufficient to catch this case as the transaction will be marked used by the .fetch_one(&mut tx)
call.
There's a bunch more SQL(x)-specific lints that we'd love to be able to provide to users, but obviously they don't make sense being built into Clippy.
Serde
Most of our Rust usage at Launchbadge right now is implementing REST APIs that return JSON. To make it easier to interface with in languages like Dart (for Flutter mobile apps) that prefer camelCase names, we use #[serde(rename_all = "camelCase")]
on all our API return types:
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct FooResponse {
id: Uuid,
name: String,
created_at: time::OffsetDateTime,
updated_at: Option<time::OffsetDateTime>,
}
#[actix_web::get("/v1/foo/{id}")]
async fn get_foo_by_id(id: Path<Uuid>) -> actix_web::Result<Option<Json<FooResponse>>> { ...}
However, to my knowledge, there is no way to globally (or by module) enforce usage of the #[serde(rename_all = "camelCase")]
attribute, and it's embarrassingly easy to forget to add to a response type, even for seasoned developers.
Serde can concievably add something like a serde.toml
that lets you configure this externally, but that's a non-local effect which could be really confusing when you're reading the code, and also we wouldn't want to necessarily apply this for the whole crate (since we use derived Serde impls elsewhere to interact with other web APIs that have different casing rules), so a lint makes the most sense to me.
It makes some sense that this could be integrated into Clippy since Serde is a de-facto standard crate in the Rust ecosystem, but it'd make even more sense for this to be in its own serde-lints
crate along with lints that cover other potential pitfalls.
The Idea
I came to the realization that we basically have the building blocks for pluggable lints that we can stabilize, and we don't have to stabilize the entirety of rustc_hir
to do it.
I'm conceiving of lints that fit into the existing proc-macro scheme and use the existing proc_macro
API (or proc_macro2
and syn
built on top of it):
sqlx-lints/lib.rs
(a proc-macro = true
crate):
// #[warn(sqlx_lints::uncommitted_transaction)]
#[proc_macro_lint]
fn uncommitted_transaction(input: TokenStream) {
}
Where the lint will parse the TokenStream
and then use Span::lint()
(which behaves like Span::error()
but is modified by the configured lint level) to call out the patterns it's looking for.
Notice that the function returns ()
; because lints are idempotent and cannot modify the code, they can be run in parallel.
However, the sqlx_lints::uncommitted_transaction
lint would probably want type information so it can know what each variable is. I'm thinking that it can ask for expanded/desugared input like so:
#[proc_macro_lint(typed, desugar(method_call)]
So the above SQLx example would be presented something like:
let mut txn: ::sqlx::Transaction<'static, ::sqlx::postgres::Postgres> = ::sqlx::Pool::<::sqlx::postgres::Postgres>::begin(&pg_pool).await?;
// not gonna bother typing out the in-between calls
::sqlx::Transaction::commit(txn).await?;
The spans of the pretty-printed tokens here would link back to the original source so Span::lint()
still works correctly. desugar()
could take other options like deref_coercion
or overloaded_ops
if it cares about those sorts of things, but each option that we introduce could be stabilized separately.
The Serde example wouldn't need any of this, of course, it could just be passed the source verbatim.
Additionally, lints will probably want to be able to specify which AST nodes they care about, e.g.:
#[proc_macro_lint(visit(fn))]
fn sqlx_uncommitted_transaction(_: TokenStream) {}
#[proc_macro_lint(visit(struct, enum))]
fn serde_non_camel_case(_: TokenStream) {}
Which simplifies parsing as the lint only has to attempt to parse those specific AST nodes. I think I would actually say that the visit()
meta item would be required as otherwise there's not really a sensible default except maybe the visiting the whole crate at once, which would be pretty resource-intensive. As with desugar()
, other options for visit()
can always be added and stabilized later.
Additionally, it would be really helpful if the lints crate could declare lint groups, which makes sense to be a macro to me:
proc_macro::lint_groups! {
// `#[warn(sqlx::recommended)]`
recommended = [uncommitted_transaction, <...>],
pedantic = [too_many_bind_args, bad_style, <...>],
// should we always have an implicit `all` group unless defined?
all = [...recommended, ...pedantic]
}
Of course, this design has some fundamental flaws; it's probably going to be pretty slow since we're introducing even more pretty-printing and reparsing, although lints can be run in parallel where possible which would help with that.
It also doesn't cover every possible use-case for lints, as we still can only work with the current AST/HIR node being linted whereas lints in Clippy and the compiler can look up anything in the HIR tree.
However, it's something, which is better than what we have now, and quality-of-life improvements for lints can always be added to the proc_macro
API later.