#enums #variant #partial #error #generate #partially #proc-macro

nightly macro partial-enum

proc-macro generating partially inhabited enums

4 releases

0.0.4 Nov 23, 2022
0.0.3 Nov 22, 2022
0.0.2 Nov 21, 2022
0.0.1 Nov 18, 2022

#2269 in Procedural macros

MIT license

20KB
299 lines

Experimental proc-macro generating partially inhabited enums from a template enum and valid morphisms between those enums. The goal is to define an enum with all possible variants once and generate the partial enums to constrain different APIs to different variant subsets, without having to redefine new enums for each API. Generated morphisms can then be used to convert between those different enums and easily compose APIs.


lib.rs:

A proc-macro for generating partial enums from a template enum. This partial enum contains the same number of variants as the template but can disable a subset of these variants at compile time. The goal is used specialize enum with finer-grained variant set for each API.

This is useful for handling errors. A common pattern is to define an enum with all possible errors and use this for the entire API surface. Albeit simple, this representation can fail to represent exact error scenarii by allowing errors that can not happen.

Take an API responsible for decoding messages from a socket.

enum Error {
    Connect(ConnectError),
    Read(ReadError),
    Decode(DecodeError),
}

fn connect() -> Result<Socket, Error> {
    Ok(Socket)
}

fn read(sock: &mut Socket) -> Result<Bytes, Error> {
    Ok(Bytes)
}

fn decode(bytes: Bytes) -> Result<Message, Error> {
    Err(Error::Decode(DecodeError))
}

The same error enum is used all over the place and exposes variants that do not match the API: decode returns a DecodeError but nothing prevents from returning a ConnectError. For such low-level API, we could substitute Error by their matching error like ConnectError for connect. The downside is that composing with such functions forces us to redefine custom enums:

enum NextMessageError {
    Read(ReadError),
    Decode(DecodeError),
}

impl From<ReadError> for NextMessageError {
    fn from(err: ReadError) -> Self {
        NextMessageError::Read(err)
    }
}

impl From<DecodeError> for NextMessageError {
    fn from(err: DecodeError) -> Self {
        NextMessageError::Decode(err)
    }
}

fn read(sock: &mut Socket) -> Result<Bytes, ReadError> {
    Ok(Bytes)
}

fn decode(bytes: Bytes) -> Result<Message, DecodeError> {
    Err(DecodeError)
}

fn next_message(sock: &mut Socket) -> Result<Message, NextMessageError> {
    let payload = read(sock)?;
    let message = decode(payload)?;
    Ok(message)
}

This proc-macro intend to ease the composition of APIs that does not share the exact same errors by generating a new generic enum where each variant can be disabled one by one. We can then redefine our API like so:

#[derive(partial_enum::Enum)]
enum Error {
    Connect(ConnectError),
    Read(ReadError),
    Decode(DecodeError),
}

use partial::Error as E;

fn connect() -> Result<Socket, E<ConnectError, !, !>> {
    Ok(Socket)
}

fn read(sock: &mut Socket) -> Result<Bytes, E<!, ReadError, !>> {
    Ok(Bytes)
}

fn decode(bytes: Bytes) -> Result<Message, E<!, !, DecodeError>> {
    Err(DecodeError)?
}

fn next_message(sock: &mut Socket) -> Result<Message, E<!, ReadError, DecodeError>> {
    let payload = read(sock)?;
    let message = decode(payload)?;
    Ok(message)
}

Notice that the next_message implementation is unaltered and the signature clearly states that only ReadError and DecodeError can be returned. The callee would never be able to match on Error::Connect. The decode implementation uses the ? operator to convert DecodeError to the partial enum. By using the nightly feature exhaustive_patterns, the match statement does not even need to write the disabled variants.

#![feature(exhaustive_patterns)]
fn read_one_message() -> Result<Message, Error> {
    let mut socket = connect()?;
    match next_message(&mut socket) {
        Ok(msg) => Ok(msg),
        Err(E::Read(_)) => {
            // Retry...
            next_message(&mut socket).map_err(Error::from)
        }
        Err(E::Decode(err)) => Err(Error::Decode(err)),
    }
}

Rust version

By default, the empty placeholder is the unit type (). The generated code is compatible with the stable compiler. When the never feature is enabled, the never type ! is used instead. This requires a nightly compiler and the nightly feature #![feature(never_type)].

Dependencies

~1.5MB
~37K SLoC