From c13e7b5aade032c2c3d2d24849558224937ade89 Mon Sep 17 00:00:00 2001 From: Julia Date: Tue, 16 Sep 2025 15:54:29 -0300 Subject: [PATCH 1/6] Make compile-time macros configurable Makes it so that dotenvy_macro::dotenv follows the same convention as dotenvy::load Also adds dotenvy_macro::option_dotenv --- .env.alternative | 1 + dotenv_codegen/Cargo.toml | 4 +- dotenv_codegen/src/lib.rs | 269 +++++++++++++++++---- dotenv_codegen/tests/basic_dotenv_macro.rs | 74 +++++- 4 files changed, 291 insertions(+), 57 deletions(-) create mode 100644 .env.alternative diff --git a/.env.alternative b/.env.alternative new file mode 100644 index 0000000..4ad21f4 --- /dev/null +++ b/.env.alternative @@ -0,0 +1 @@ +CODEGEN_TEST_VAR1="bye!" diff --git a/dotenv_codegen/Cargo.toml b/dotenv_codegen/Cargo.toml index 415362d..d8b40bc 100644 --- a/dotenv_codegen/Cargo.toml +++ b/dotenv_codegen/Cargo.toml @@ -26,7 +26,7 @@ proc-macro = true dotenvy = { path = "../dotenvy" } proc-macro2.workspace = true quote.workspace = true -syn.workspace = true +syn = { version = "2", features = ["full"] } [lints] -workspace = true \ No newline at end of file +workspace = true diff --git a/dotenv_codegen/src/lib.rs b/dotenv_codegen/src/lib.rs index 9f52d43..7da08ce 100644 --- a/dotenv_codegen/src/lib.rs +++ b/dotenv_codegen/src/lib.rs @@ -1,68 +1,241 @@ -use dotenvy::EnvLoader; +use dotenvy::{EnvLoader, EnvSequence}; use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; use quote::quote; -use std::env::{self, VarError}; -use syn::{parse::Parser, punctuated::Punctuated, spanned::Spanned, LitStr, Token}; +use std::io; +use syn::{parse::Parse, Ident, LitBool, LitStr, Token}; +struct DotenvInput { + path: Option, + override_: bool, + var_name: LitStr, +} + +impl Parse for DotenvInput { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let mut path = None; + let mut var_name = None; + let mut override_ = None; + + while !input.is_empty() { + if let Ok(ident) = input.parse::() { + input.parse::()?; + match ident.to_string().as_str() { + "path" => { + if path.is_some() { + return Err(syn::Error::new( + ident.span(), + "each attribute must be set only once", + )); + } + path = Some(input.parse::()?); + } + "override_" => { + if override_.is_some() { + return Err(syn::Error::new( + ident.span(), + "each attribute must be set only once", + )); + } + override_ = Some(input.parse::()?.value); + } + "var" => { + if var_name.is_some() { + return Err(syn::Error::new( + ident.span(), + "variable name must be set only once", + )); + } + var_name = Some(input.parse::()?); + } + _ => { + return Err(syn::Error::new( + ident.span(), + format!("unkown attribute: {ident}"), + )) + } + } + } else if let Ok(s) = input.parse::() { + if var_name.is_some() { + return Err(syn::Error::new( + s.span(), + "unexpected token in macro input (variable name must be set only once)", + )); + } + var_name = Some(s); + } else { + return Err(syn::Error::new( + input.span(), + "unexpected token in macro input", + )); + } + if !input.is_empty() { + input.parse::()?; + } + } + + let Some(var_name) = var_name else { + return Err(syn::Error::new( + input.span(), + "environment variable name not defined", + )); + }; + + Ok(Self { + path, + override_: override_.unwrap_or(true), + var_name, + }) + } +} + +/// Loads an environment variable from a file at compile time. +/// +/// This macro will expand to the value of the named environment variable at compile +/// time, yielding an expression of type `&'static str`. Use +/// [`dotenvy::var`] instead if you want to read the value at runtime. +/// +/// If the environment variable is not defined, then a compilation error will be +/// emitted. To not emit a compile error, use the [`option_dotenv!`](option_dotenv) +/// macro instead. A compilation error will also be emitted if the environment +/// variable is not a valid Unicode string or if reading the .env file failed. +/// +/// # Examples +/// +/// Basic usage: +/// +/// ```rust ignore +/// # use dotenvy_macro::dotenv; +/// assert_eq!(dotenv!("VARIABLE_1"), "value"); +/// ``` +/// +/// ```rust compile_fail +/// # use dotenvy_macro::dotenv; +/// const UNSET_VAR: &str = dotenv!("UNSET_VAR"); +/// ``` +/// +/// Custom attributes: +/// +/// ```rust ignore +/// # use dotenvy_macro::dotenv; +/// // Does not override current env with .env contents +/// const NOT_OVERRIDEN: &str = dotenv!("VARIABLE_1", override_ = false); +/// // Reads from custom file path +/// const CUSTOM_PATH: &str = dotenv!("VARIABLE_PROD", path = ".env.prod"); +/// // Specifying the `var` attribute +/// const CUSTOM_PATH: &str = dotenv!(var = "VARIABLE_1"); +/// ``` #[proc_macro] -/// TODO: add safety warning pub fn dotenv(input: TokenStream) -> TokenStream { let input = input.into(); - unsafe { dotenv_inner(input) }.into() + dotenv_inner(input).into() } -unsafe fn dotenv_inner(input: TokenStream2) -> TokenStream2 { - let loader = EnvLoader::new(); - if let Err(e) = unsafe { loader.load_and_modify() } { - let msg = e.to_string(); - return quote! { - compile_error!(#msg); - }; - } +fn dotenv_inner(input: TokenStream2) -> TokenStream2 { + let args = match syn::parse2::(input) { + Ok(a) => a, + Err(e) => return e.into_compile_error(), + }; + + let sequence = if args.override_ { + EnvSequence::EnvThenInput + } else { + EnvSequence::InputThenEnv + }; + + let path = args.path.map_or_else(|| "./.env".to_owned(), |p| p.value()); + let var_name = args.var_name.value(); + + let loader = EnvLoader::new().path(&path).sequence(sequence).load(); - match expand_env(input) { - Ok(stream) => stream, - Err(e) => e.to_compile_error(), + match loader.as_ref().map(|l| l.get(&var_name)) { + Ok(Some(v)) => quote!(#v), + Ok(None) => { + let msg = format!("environment variable `{var_name}` not set"); + quote! { + compile_error!(#msg) + } + } + Err(e) => { + let msg = e.to_string(); + quote! { + compile_error!(#msg) + } + } } } -fn expand_env(input_raw: TokenStream2) -> syn::Result { - let args = >::parse_terminated - .parse(input_raw.into()) - .expect("expected macro to be called with a comma-separated list of string literals"); +/// Optionally loads an environment variable from a file at compile time. +/// +/// If the named environment variable is present at compile time, this will expand +/// into an expression of type `Option<&'static str>` whose value is `Some` of the +/// value of the environment variable (a compilation error will be emitted if the +/// environment variable is not a valid Unicode string or if reading the .env file +/// failed). If either the environment variable or the .env file is not present, +/// then this will expand to `None`. Use [`dotenvy::var`] instead if +/// you want to read the value at runtime. +/// +/// A compile time error is only emitted when using this macro if the environment +/// variable exists and is not a valid Unicode string or if reading the .env file +/// failed. To also emit a compile error if the environment variable is not present, +/// use the [`dotenv!`](dotenv) macro instead. +/// +/// # Examples +/// +/// Basic usage: +/// +/// ```rust no_run +/// # use dotenvy_macro::option_dotenv; +/// assert_eq!(option_dotenv!("UNSET_VAR"), None); +/// assert_eq!(option_dotenv!("SET_VAR"), Some("value")); +/// ``` +/// +/// Custom attributes: +/// +/// ```rust no_run +/// # use dotenvy_macro::option_dotenv; +/// // Does not override current env with .env contents +/// const NOT_OVERRIDEN: Option<&str> = option_dotenv!("VARIABLE_1", override_ = false); +/// // Reads from custom file path +/// const CUSTOM_PATH: Option<&str> = option_dotenv!("VARIABLE_PROD", path = ".env.prod"); +/// // Specifying the `var` attribute +/// const VAR_ATTR: Option<&str> = option_dotenv!(var = "VARIABLE_1"); +/// ``` +#[proc_macro] +pub fn option_dotenv(input: TokenStream) -> TokenStream { + let input = input.into(); + option_dotenv_inner(input).into() +} - let mut iter = args.iter(); +fn option_dotenv_inner(input: TokenStream2) -> TokenStream2 { + let args = match syn::parse2::(input) { + Ok(a) => a, + Err(e) => return e.into_compile_error(), + }; - let var_name = iter - .next() - .ok_or_else(|| syn::Error::new(args.span(), "dotenv! takes 1 or 2 arguments"))? - .value(); - let err_msg = iter.next(); + let sequence = if args.override_ { + EnvSequence::EnvThenInput + } else { + EnvSequence::InputThenEnv + }; - if iter.next().is_some() { - return Err(syn::Error::new( - args.span(), - "dotenv! takes 1 or 2 arguments", - )); - } + let path = args.path.map_or_else(|| "./.env".to_owned(), |p| p.value()); - match env::var(&var_name) { - Ok(val) => Ok(quote!(#val)), - Err(e) => Err(syn::Error::new( - var_name.span(), - err_msg.map_or_else( - || match e { - VarError::NotPresent => { - format!("environment variable `{var_name}` not defined") - } + let loader = EnvLoader::new().path(&path).sequence(sequence).load(); - VarError::NotUnicode(s) => { - format!("environment variable `{var_name}` was not valid Unicode: {s:?}",) - } - }, - LitStr::value, - ), - )), + match loader.as_ref().map(|l| l.get(&args.var_name.value())) { + Ok(Some(v)) => quote!(Some(#v)), + Ok(None) => quote!(None::<&str>), + Err(e) => { + if let dotenvy::Error::Io(ioe, _) = e { + if ioe.kind() == io::ErrorKind::NotFound { + return quote!(None::<&str>); + } + } + let msg = e.to_string(); + quote! { + compile_error!(#msg) + } + } } } diff --git a/dotenv_codegen/tests/basic_dotenv_macro.rs b/dotenv_codegen/tests/basic_dotenv_macro.rs index f48c4a7..b0e9e5d 100644 --- a/dotenv_codegen/tests/basic_dotenv_macro.rs +++ b/dotenv_codegen/tests/basic_dotenv_macro.rs @@ -1,16 +1,76 @@ #[test] fn dotenv_works() { + // basic operation assert_eq!(dotenvy_macro::dotenv!("CODEGEN_TEST_VAR1"), "hello!"); + assert_eq!( + dotenvy_macro::dotenv!("CODEGEN_TEST_VAR2"), + "'quotes within quotes'" + ); + + // basic operation specifying var attribute + assert_eq!(dotenvy_macro::dotenv!(var = "CODEGEN_TEST_VAR1"), "hello!"); + + // overriding works + assert_eq!( + dotenvy_macro::dotenv!("CODEGEN_TEST_VAR1", override_ = true), + "hello!" + ); + + // not overriding also works, requires env vars to be set upon running test + assert_eq!( + dotenvy_macro::dotenv!(var = "CODEGEN_TEST_VAR1", override_ = false), + "goodbye!", + "in order for dotenvy_macro tests to pass, the variable `CODEGEN_TEST_VAR1` must be set to `goodbye!`" + ); + + // custom .env path works + assert_eq!( + dotenvy_macro::dotenv!("CODEGEN_TEST_VAR1", path = ".env.alternative"), + "bye!" + ); + + // custom .env path works while not overriding + assert_eq!( + dotenvy_macro::dotenv!("CODEGEN_TEST_VAR1", path = ".env.alternative", override_ = false), + "goodbye!", + "in order for dotenvy_macro tests to pass, the variable `CODEGEN_TEST_VAR1` must be set to `goodbye!`" + ); } #[test] -fn two_argument_form_works() { +fn dotenv_option_works() { + // basic operation assert_eq!( - dotenvy_macro::dotenv!( - "CODEGEN_TEST_VAR2", - "err, you should be running this in the 'dotenv_codegen' \ - directory to pick up the right .env file." - ), - "'quotes within quotes'" + dotenvy_macro::option_dotenv!("CODEGEN_TEST_VAR1"), + Some("hello!") + ); + assert_eq!( + dotenvy_macro::option_dotenv!("CODEGEN_TEST_VAR_UNSET"), + None + ); + + // overriding works + assert_eq!( + dotenvy_macro::option_dotenv!("CODEGEN_TEST_VAR1", override_ = true), + Some("hello!") + ); + + // not overriding also works, requires env vars to be set upon running test + assert_eq!( + dotenvy_macro::option_dotenv!("CODEGEN_TEST_VAR1", override_ = false), + Some("goodbye!"), + "in order for dotenvy_macro tests to pass, the variable `CODEGEN_TEST_VAR1` must be set to `goodbye!`" + ); + + // custom .env path works + assert_eq!( + dotenvy_macro::option_dotenv!("CODEGEN_TEST_VAR1", path = "./.env.alternative"), + Some("bye!") + ); + + // missing custom .env path returns None + assert_eq!( + dotenvy_macro::option_dotenv!("CODEGEN_TEST_VAR1", path = "./.doesnt_exist"), + None ); } From 35b17836291955b96619babb5810f29002acd287 Mon Sep 17 00:00:00 2001 From: Julia Date: Tue, 16 Sep 2025 16:44:21 -0300 Subject: [PATCH 2/6] make CI pass for dotenvy_macro --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1d0bd3..33f542a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,9 @@ on: branches: - master +env: + CODEGEN_TEST_VAR1: goodbye! + jobs: tests: strategy: From 5d721e93307501be6fbf18579349790d31fb6b7d Mon Sep 17 00:00:00 2001 From: Julia Date: Tue, 16 Sep 2025 16:46:26 -0300 Subject: [PATCH 3/6] update README to add new dotenvy_macro macro --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f36d8fc..630a235 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ For more advanced usage, `EnvLoader::load_and_modify` can be used. ## Compile-time loading -The `dotenv!` macro provided by `dotenvy_macro` crate can be used. +The `dotenv!` and `option_dotenv!` macros' provided by `dotenvy_macro` crate can be used. ## Minimum Supported Rust Version From ce6e28b7cdbd2c8030d682a27e22585a8d2f9a22 Mon Sep 17 00:00:00 2001 From: Julia Date: Tue, 16 Sep 2025 17:19:00 -0300 Subject: [PATCH 4/6] update CHANGELOG.md entry --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 725cf8e..1b0c339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Added + +- added `path` and `override_` to `dotenvy_macro::dotenv` ([PR #159](https://github.com/allan2/dotenvy/pull/159)) +- added `dotenvy_macro::option_dotenv` that evaluates to an `Option<&'static str>` ([PR #159](https://github.com/allan2/dotenvy/pull/159)) + ### Changed - update to 2021 edition - update MSRV to 1.74.0 From 7e7aaa2baf9dbfead18a4befae007f4bc9db400b Mon Sep 17 00:00:00 2001 From: Julia Date: Tue, 16 Sep 2025 18:18:08 -0300 Subject: [PATCH 5/6] fix `ci-check.sh` for dotenvy_macro --- ci-check.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ci-check.sh b/ci-check.sh index de95e43..e7366db 100755 --- a/ci-check.sh +++ b/ci-check.sh @@ -3,6 +3,9 @@ set -e MSRV="1.74.0" +# For dotenvy_macro tests +export CODEGEN_TEST_VAR1="goodbye!" + echo "MSRV set to $MSRV" echo "cargo fmt" From 968b816e8790404b52e9526326cfb59218e6e087 Mon Sep 17 00:00:00 2001 From: Julia Date: Tue, 16 Sep 2025 20:30:08 -0300 Subject: [PATCH 6/6] fix missing env file erroring if var present --- dotenv_codegen/src/lib.rs | 17 ++++++++++++++++- dotenv_codegen/tests/basic_dotenv_macro.rs | 15 ++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/dotenv_codegen/src/lib.rs b/dotenv_codegen/src/lib.rs index 7da08ce..472da0e 100644 --- a/dotenv_codegen/src/lib.rs +++ b/dotenv_codegen/src/lib.rs @@ -157,6 +157,17 @@ fn dotenv_inner(input: TokenStream2) -> TokenStream2 { } } Err(e) => { + if let dotenvy::Error::Io(ioe, _) = e { + if ioe.kind() == io::ErrorKind::NotFound { + if let Ok(var) = std::env::var(&var_name) { + return quote!(#var); + } else { + return quote! { + compile_error!("environment variable not set and env file missing") + }; + } + } + } let msg = e.to_string(); quote! { compile_error!(#msg) @@ -220,15 +231,19 @@ fn option_dotenv_inner(input: TokenStream2) -> TokenStream2 { }; let path = args.path.map_or_else(|| "./.env".to_owned(), |p| p.value()); + let var_name = args.var_name.value(); let loader = EnvLoader::new().path(&path).sequence(sequence).load(); - match loader.as_ref().map(|l| l.get(&args.var_name.value())) { + match loader.as_ref().map(|l| l.get(&var_name)) { Ok(Some(v)) => quote!(Some(#v)), Ok(None) => quote!(None::<&str>), Err(e) => { if let dotenvy::Error::Io(ioe, _) = e { if ioe.kind() == io::ErrorKind::NotFound { + if let Ok(var) = std::env::var(&var_name) { + return quote!(Some(#var)); + } return quote!(None::<&str>); } } diff --git a/dotenv_codegen/tests/basic_dotenv_macro.rs b/dotenv_codegen/tests/basic_dotenv_macro.rs index b0e9e5d..da238e7 100644 --- a/dotenv_codegen/tests/basic_dotenv_macro.rs +++ b/dotenv_codegen/tests/basic_dotenv_macro.rs @@ -29,6 +29,13 @@ fn dotenv_works() { "bye!" ); + // custom .env path works if var present but .env missing + assert_eq!( + dotenvy_macro::dotenv!("CODEGEN_TEST_VAR1", path = ".env.missing"), + "goodbye!", + "in order for dotenvy_macro tests to pass, the variable `CODEGEN_TEST_VAR1` must be set to `goodbye!`" + ); + // custom .env path works while not overriding assert_eq!( dotenvy_macro::dotenv!("CODEGEN_TEST_VAR1", path = ".env.alternative", override_ = false), @@ -68,9 +75,15 @@ fn dotenv_option_works() { Some("bye!") ); + // custom .env path works if var present but .env missing + assert_eq!( + dotenvy_macro::option_dotenv!("CODEGEN_TEST_VAR1", path = "./.env.missing"), + Some("goodbye!") + ); + // missing custom .env path returns None assert_eq!( - dotenvy_macro::option_dotenv!("CODEGEN_TEST_VAR1", path = "./.doesnt_exist"), + dotenvy_macro::option_dotenv!("NOT_SET", path = "./.env.missing"), None ); }