An idea. Not even pre-pre-pre-RFC yet. Posting here to collect feedback before writing a more formal proposal.
Pull target environment into Rust typechecker
There's an issue: when a project is refactored (moved, renamed, signatures changed), it fails to compile in different target environment. The fix is usually trivial, but time to discover is significant:
- need to wait for CI to finish
- setting CI for all possible target environments is not always easy
Usually these breakages are caused by one of three things:
- test vs non-test builds: the library is patched, but test is forgotten, and test fails to compile. This happens more often when working on multi-crate projects.
- operating system differences: the code is checked on Linux, but fails to compile on Windows
- features: code is checked with current enabled set of features, but fails to compile with a different set of features. This is especially problematic when the number of features is more then two, and combinatorial explosion prevents checking all possible combinations of features.
- std vs no-std builds
This document proposes how to fix these problems: pull conditional environment checks into Rust typechecker. This would be an alternative/complement to current #[cfg]
filters in AST.
This won't guarantee that code will work correctly in different than current environments, but it will at least will be compiled. And with current Rust strong type system, that will often be enough.
Outline of the idea
Environment
First we define an environment. And environment can be any simple Rust object comprised of:
- boolean or integer
- struct
- enum, the most common case
Ror example:
/// Defined in Rust std
enum EnvOs {
Linux,
Macos,
Windows,
...
}
// `env` is similar to `const`, but can be referenced in `env_` modifiers
env CURRENT_OS: EnvOs = if cfg!(windows) {
EnvOs::Windows
} else if cfg!(linux) {
EnvOs::Linux
} else if cfg!(macos) {
EnvOs::Windows
} else {
...
}
Or:
env CURRENT_RUST_VERSION: u32 = ...;
Simple example use case: cross-platform file open API
Let's assume we can open file only on Windows, Linux and macOS.
// First, we define native functions
extern "C" {
// `open` function is available to typechecker, everywhere
// but when generating machine code, this is equivalent of
// `#[cfg(or(linux, macos))]`
env_if(CURRENT_OS in [EnvOs::Linux, EnvOs::Macos])
fn open(...) -> c_int;
env_if(CURRENT_OS == EnvOs::Windows)
fn CreateFileW(...) -> HANDLE;
}
// Then we define our file handle
env_if(CURRENT_OS in [EnvOs::Linux, EnvOs::Macos, EnvOs::Windows])
struct FileHandle {
env_if(CURRENT_OS == Os::Windows)
windows_handle: HANDLE,
env_if(CURRENT_OS != Os::Windows)
// of equivalently env_if(CURRENT_OS in [EnvOs::Linux, EnvOs::Macos])
posix_handle: c_int,
}
env_if(CURRENT_OS in [EnvOs::Linux, EnvOs::Windows, EnvOs::Macos])
fn open_file(path: &str) -> FileHandle
// note no curly braces before `env_match`:
// it is not a regular match expression,
// it cannot be used inside the function body;
// code after `=>` is the actual function bodies
env_match CURRENT_OS {
EnvOs::Windows => {
FileHandle {
// this field is available when `CURRENT_OS == EnvOs::Windows`
windows_handle: CreateFileW(...),
// there's no `posix_handle` field on Windows, so code typechecks
}
}
_ => {
// here compiler knows that
// CURRENT_OS is `EnvOs::Linux` or `EnvOs::Macos`
FileHandle {
// `posix_handle` field is availble when current os is not windows
posix_handle: open(...),
// and there's no `windows_handle` field
}
}
}
Example with feature
env FEATURE_EXTRA_ASSERTIONS = cfg!(feature = "extra-assertions");
struct Data {
data: Vec<u8>,
env_if(FEATURE_EXTRA_ASSERTIONS == true)
checksum: u64,
}
impl Data {
// This function is runtime no-op when extra-assertions feature is off
// but it is typechecked regardless of whether feature is on or off
fn verify(&self)
env_match FEATURE_EXTRA_ASSERTIONS {
true => {
// `checksum` field is available here
assert!(self.checksum == self.compute_checksum());
}
false => {
// there's no `checksum` field, but we don't use it
}
}
}
Example with test
env TEST: bool = cfg!(test);
#[test]
env_if(TEST == true)
fn my_test() {
// this function is always typechecked, but not compiled
// when we are not compiling crate as test
}
Environments can be mixed
env_if(CURRENT_OS == EnvOs::Linux)
fn connect_unix_socket(...) {}
#[test]
env_if(CURRENT_OS == EnvOs::Linux && TEST == true)
fn test_connect_unix_socket() {
// this function is typechecked even if we are not on Linux,
// and even if we are not building a test
connect_unix_socket(...);
}
How to deal with native dependencies
Use case: windows-only native library wo
, which depends on a binding to the actual native library wo-sys
.
wo
crate should be available even on non-windows, but so-sys
cannot be compiled on non-Windows.
This issue can be solved by mixing env_
attributes with current #[cfg()]
attributes. Like this:
// `wo` crate is available everywhere,
// but depending on/compiling `wo_sys` only on actual Windows.
#[cfg(windows)]
extern crate wo_sys;
// `do_it` function is available everywhere
// On Windows we provide actual function calling native function.
// We still expose `env_if`, so when compiling on Windows,
// we need to check this function is not called in Linux environment.
#[cfg(windows)]
env_if(CURRENT_OS == EnvOs::Windows)
fn do_it() {
wo_sys::do_it();
}
// This is a stub function to be used on non-Windows.
#[cfg(not(windows))]
env_if(CURRENT_OS == EnvOs::Windows)
fn do_it() {
// This function cannot be instantiated,
// `unreachable!()` is just a precaution.
unreachable!();
}
A little more formal explanation
Single environment type predicate is a subset of possible values for given environment type. For example,
- os != windows
- test == true
Environment predicate is a inersection of single environment predicates. For example:
- (os != windows) && (test == true)
env_if
modifier (e. g. at function) defines an environment predicate.
Each function is typechecked with an environment predicate.
env_match
block is used to split a function body into several bodies typechecked with different environment predicates.
Finally, function body can only access elements (types, functions, fields etc) which have wider predicate. For example, function with (os == linux) can call a function (os != windows), but not vice versa.
Full formal specification would be quite lengthy (for example, it need to specify how traits and implementations work). This document is just a sketch of the idea.