diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..40617b5 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,180 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anode" +version = "0.1.0" +dependencies = [ + "anode-macros", + "thiserror", +] + +[[package]] +name = "anode-build" +version = "0.1.0" +dependencies = [ + "anode-macros-input", + "quote", + "serde", + "syn", + "yaml_serde", +] + +[[package]] +name = "anode-macros" +version = "0.1.0" +dependencies = [ + "anode-macros-input", + "proc-macro2", + "quote", + "syn", + "yaml_serde", +] + +[[package]] +name = "anode-macros-input" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "yaml_serde" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a7f5270edc6fab0529a772a772b3e505dfd883a8de5cc5b464e35fabe586411" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6d43f1a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,25 @@ +[workspace] +resolver = "3" +members = [ + "anode", + "anode-macros-input", + "anode-build", + "anode-macros", +] + +[workspace.dependencies] +# workspace +anode = { path = "./anode" } +anode-macros-input = { path = "./anode-macros-input" } +anode-macros = { path = "./anode-macros" } +anode-build = { path = "./anode-build" } +# crates.io +syn = { version = "2", default-features = false } +quote = { version = "1", default-features = false } +proc-macro2 = "1.0.102" +serde = { version = "1", default-features = false } +yaml_serde = { version = "0.10", default-features = false } +thiserror = { version = "2", default-features = false } + +[workspace.lints.clippy] +missing_panics_doc = "allow" diff --git a/README.md b/README.md new file mode 100644 index 0000000..aca7fba --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +Rust toolkit for CI actions + +anode -- action without node + +# Compile + +install +- libunwind-static +- xz-static + +get C runtime libraries from somewhere (I literally copied them from whatever `gcc` found them in on my system; I think that on actual alpine system they will be found with no problem) + +test is compiled with the following command: +```sh +RUSTFLAGS="-Lunwind -L/lib/musl/lib -Lcrt -Lxz -llzma" cargo +nightly b -Z build-std -p tests --release --target x86_64-unknown-linux-musl -v +``` diff --git a/anode-build/Cargo.toml b/anode-build/Cargo.toml new file mode 100644 index 0000000..e0ec5de --- /dev/null +++ b/anode-build/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "anode-build" +version = "0.1.0" +edition = "2024" + +[dependencies] +anode-macros-input.workspace = true +serde = {workspace = true, features = ["derive"]} +syn.workspace = true +quote.workspace = true +yaml_serde.workspace = true + +[lints] +workspace = true diff --git a/anode-build/src/lib.rs b/anode-build/src/lib.rs new file mode 100644 index 0000000..e2d1f97 --- /dev/null +++ b/anode-build/src/lib.rs @@ -0,0 +1,85 @@ +// WARN: be aware that both source code file path and output path and hard-coded. they might not work for you; if you have any suggestion for heuristics would determine these paths in a universal way, please let me know +use syn::{File, punctuated::Punctuated}; + +use anode_macros_input::pretty_span; + +use crate::manifest::ActionManifest; + +mod manifest; + +pub fn build() { + let main_src = std::fs::read_to_string("src/main.rs").unwrap(); + let main_ast = syn::parse_file(&main_src).unwrap(); + let input = parse_file(main_ast); + let action = anode_macros_input::Action::from_struct(&input); + let manifest = ActionManifest::from_action(&action); + + // panic!("{manifest:#?}"); + std::fs::write( + "action.yml", + yaml_serde::to_string::(&manifest).unwrap(), + ) + .unwrap(); +} + +pub fn parse_file(input: File) -> syn::ItemStruct { + use syn::{Fields, FieldsNamed, Generics, Item, Path, Token}; + let structs = input.items.into_iter().filter_map(|item| { + if let Item::Struct(struct_) = item { + Some(struct_) + } else { + None + } + }); + let with_fields = structs.filter(|str| { + if str.generics != Generics::default() { + // struct has generics -- not an input + println!( + "cargo:warn=generics at {} are not allowed in the input struct", + pretty_span(&str.generics) + ); + return false; + } + + let Fields::Named(FieldsNamed { .. }) = &str.fields else { + println!( + "cargo:warn=fields at {} must be named for the action input", + pretty_span(&str.fields) + ); + return false; + }; + true + }); + let mut with_derive = with_fields + .filter(|str| { + str.attrs.iter().any(|attr| { + if !attr.meta.path().is_ident("derive") { + return false; + } + let Ok(traits) = + attr.parse_args_with(Punctuated::::parse_terminated) + else { + return false; + }; + traits.iter().any(|tr| { + let input: syn::Path = syn::parse_quote!(Input); + let anode_input: syn::Path = syn::parse_quote!(anode::Input); + tr == &input || tr == &anode_input + }) + }) + }) + .collect::>(); + + assert!( + !with_derive.is_empty(), + "no valid input definition in the main module" + ); + + assert!( + with_derive.len() <= 1, + "input module contains multiple valid input definintions: {:#?}", + with_derive.iter().map(pretty_span).collect::>(), + ); + + with_derive.pop().unwrap() +} diff --git a/anode-build/src/manifest.rs b/anode-build/src/manifest.rs new file mode 100644 index 0000000..2006458 --- /dev/null +++ b/anode-build/src/manifest.rs @@ -0,0 +1,142 @@ +use std::collections::BTreeMap; + +use quote::ToTokens; +use syn::{Expr, Ident, parse::Parser}; + +use anode_macros_input::{Action, ActionInput, pretty_span}; + +#[derive(Debug, serde::Serialize)] +pub struct InputManifest { + #[serde(skip_serializing_if = "String::is_empty")] + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub r#default: Option, + pub required: bool, +} + +impl InputManifest { + pub fn from_input(input: &ActionInput) -> (&str, InputManifest) { + #[expect( + clippy::manual_map, + reason = "this expression will optionally produce an error in the future" + )] + let default = if let Some(default) = &input.default { + Some(match default { + anode_macros_input::ActionInputDefault::Str(s) => { + yaml_serde::Value::String(s.clone().into_string()) + } + anode_macros_input::ActionInputDefault::Expr(expr) => parse_default(expr), + }) + } else { + None + }; + ( + &input.name, + InputManifest { + description: input.description.clone(), + default, + required: input.required, + }, + ) + } +} + +fn parse_default(expr: &Expr) -> yaml_serde::Value { + use syn::Lit; + use yaml_serde::{Number, Value}; + let lit = { + use syn::parse::Parse; + let parser = Lit::parse; + #[expect( + clippy::match_wild_err_arm, + reason = "`_err` can be used for debugging" + )] + match parser.parse2(expr.to_token_stream()) { + Ok(ok) => ok, + Err(_err) => panic!( + "to allow automatic manifest generation, default value must be a literal. non-literal default for field at {}", + pretty_span(expr), + ), + } + }; + match lit { + Lit::Str(lit_str) => Value::String(lit_str.value()), + Lit::Bool(lit_bool) => Value::Bool(lit_bool.value()), + Lit::Byte(lit_byte) => Value::Number(Number::from(u64::from(lit_byte.value()))), + Lit::Char(lit_char) => Value::String(format!("'{}'", lit_char.value())), + Lit::Int(lit_int) => { + let num = if let Ok(pos_int) = lit_int.base10_parse::() { + Number::from(pos_int) + } else if let Ok(neg_int) = lit_int.base10_parse::() { + Number::from(neg_int) + } else { + panic!("failed to parse int into yaml value: {}", lit_int,); + }; + Value::Number(num) + } + Lit::Float(lit_float) => { + let float = lit_float.base10_parse::().unwrap(); + Value::Number(float.into()) + } + Lit::ByteStr(_) => { + unimplemented!("bytestr is not a valid yaml value. please use regular str") + } + Lit::CStr(_) => { + unimplemented!("cstr is not a valid yaml literal. please use regular str") + } + Lit::Verbatim(_) => unimplemented!("uninterpreted literal"), + _ => unimplemented!("unknown literal kind"), + } +} + +#[derive(Debug, serde::Serialize)] +pub struct ActionManifest { + pub name: String, + pub description: String, + pub inputs: BTreeMap, + pub runs: yaml_serde::Value, +} + +impl ActionManifest { + pub fn from_action(action: &Action) -> Self { + let name = action + .name_override + .clone() + .unwrap_or_else(|| action.name.to_string()); + // collect both input manifests AND their idents, to provide informative error messages + let mut inputs = BTreeMap::<&str, (InputManifest, Ident)>::new(); + for input in &action.inputs { + use std::collections::btree_map::Entry; + let (name, mani) = InputManifest::from_input(input); + match inputs.entry(name) { + Entry::Vacant(vacant_entry) => { + vacant_entry.insert((mani, input.ident.clone())); + } + Entry::Occupied(occupied_entry) => { + // input mapping to the same name already exists + let (_, prev_ident) = occupied_entry.get(); + panic!( + "Fields at {} and {} map to the same name ({})", + pretty_span(&input.ident), + pretty_span(prev_ident), + name, + ); + } + } + } + ActionManifest { + name, + description: action.description.clone(), + // strip the idents out + inputs: inputs + .into_iter() + .map(|(k, (v, _))| (k.to_owned(), v)) + .collect(), + runs: runs(), + } + } +} + +fn runs() -> yaml_serde::Value { + yaml_serde::from_str::(include_str!("runs.yml")).unwrap() +} diff --git a/anode-build/src/runs.yml b/anode-build/src/runs.yml new file mode 100644 index 0000000..4d00ba3 --- /dev/null +++ b/anode-build/src/runs.yml @@ -0,0 +1,4 @@ +using: composite +steps: + - run: ${{ forgejo.action_path }}/main + shell: bash diff --git a/anode-macros-input/Cargo.toml b/anode-macros-input/Cargo.toml new file mode 100644 index 0000000..3b26000 --- /dev/null +++ b/anode-macros-input/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "anode-macros-input" +version = "0.1.0" +edition = "2024" + +[dependencies] +proc-macro2 = { workspace = true, features = [ "span-locations" ] } +syn = { workspace = true, features = [ "parsing", "printing", "full", "extra-traits", "clone-impls"] } +quote.workspace = true + +[lints] +workspace = true diff --git a/anode-macros-input/src/lib.rs b/anode-macros-input/src/lib.rs new file mode 100644 index 0000000..75c8623 --- /dev/null +++ b/anode-macros-input/src/lib.rs @@ -0,0 +1,246 @@ +use syn::{ + AngleBracketedGenericArguments, Attribute, Expr, ExprLit, Field, GenericArgument, Ident, Lit, + LitStr, Meta, MetaNameValue, Path, PathArguments, PathSegment, Type, TypePath, + spanned::Spanned, +}; + +pub enum ActionInputDefault { + Str(Box), + Expr(Box), +} + +pub struct ActionInput { + pub ident: syn::Ident, + pub name: String, + pub description: String, + pub r#default: Option, + pub required: bool, + pub ty: Type, + pub parser: Option, +} + +pub struct Action { + pub name: Ident, + pub name_override: Option, + pub description: String, + pub inputs: Vec, +} + +impl Action { + pub fn from_struct( + syn::ItemStruct { + attrs, + ident, + fields, + .. + }: &syn::ItemStruct, + ) -> Self { + let mut name_override = Option::::None; + let description = parse_desc(attrs); + for attr in attrs { + if attr.path().is_ident("name") + && let Meta::NameValue(MetaNameValue { + value: + Expr::Lit(ExprLit { + lit: Lit::Str(rename), + .. + }), + .. + }) = &attr.meta + { + let token = rename.token(); + let mut literal = token.to_string(); + literal.pop().unwrap(); + literal.remove(0); + name_override = Some(literal); + } + } + + let mut inputs = Vec::new(); + for field in fields { + inputs.push(parse_input(field)); + } + Action { + name: ident.clone(), + name_override, + description, + inputs, + } + } +} + +pub fn pretty_span(t: &T) -> String { + let span2 = t.span(); + let start = span2.start(); + let end = span2.end(); + let at = if start.line == end.line { + format_args!("line {}:{}-{}", start.line, start.column, end.column) + } else { + format_args!("lines {}-{}", start.line, end.line) + }; + format!("at {at} in main.rs") +} + +fn parse_input(field: &Field) -> ActionInput { + let Field { + attrs, ident, ty, .. + } = field; + let name = to_kebab(&ident.as_ref().unwrap().to_string()); + let description = parse_desc(attrs); + let required = if let Type::Path(TypePath { + qself: None, + path: Path { segments, .. }, + }) = ty + && let Some(PathSegment { ident, .. }) = segments.first() + && ident == "Option" + { + false + } else { + true + }; + + // first, assign the default parser, if it can be inferred from the type + let mut parser = infer_parser(ty); + + let mut default = Option::::None; + + for attr in attrs { + if attr.path().is_ident("default") { + default = Some(ActionInputDefault::Expr(Box::new( + attr.parse_args::().unwrap(), + ))); + } + + if attr.path().is_ident("default_str") { + default = Some(ActionInputDefault::Str( + attr.parse_args::() + .unwrap() + .value() + .into_boxed_str(), + )); + } + + if attr.path().is_ident("parser") { + parser = Some(attr.parse_args::().unwrap()); + } + } + + ActionInput { + description, + default, + required, + parser, + ident: field.ident.clone().expect("field must have an ident"), + name, + ty: ty.clone(), + } +} + +fn parse_desc(attrs: &[Attribute]) -> String { + let mut description = String::new(); + for attr in attrs { + if attr.path().is_ident("doc") + && let Meta::NameValue(MetaNameValue { + value: + Expr::Lit(ExprLit { + lit: Lit::Str(doc), .. + }), + .. + }) = &attr.meta + { + let token = doc.token(); + let mut literal = token.to_string(); + literal.pop().unwrap(); + literal.remove(0); + if literal.starts_with(' ') { + literal.remove(0); + } + if !description.is_empty() { + description.push('\n'); + } + description.push_str(&literal); + } + } + description +} + +pub fn to_kebab(name: &str) -> String { + let mut name = name.trim_start_matches(['-', '_']).replace('_', "-"); + let mut name = name.as_mut_str(); + assert!(!name.is_empty()); // TODO that's an invalid ident error, use proper signaling + let mut res = String::new(); + while !name.is_empty() { + let next_upper = name + .find(|ch: char| ch.is_ascii_uppercase()) + .unwrap_or(name.len()); + let (this, rest) = name.split_at_mut(next_upper); + name = rest; + if !res.is_empty() { + res.push('-'); + } + res.push_str(this); + if let Some((ind, _)) = name.char_indices().nth(1) { + name[..ind].make_ascii_lowercase(); + } else { + name.make_ascii_lowercase(); + } + } + res +} + +macro_rules! mch { + ($p:pat = $e:expr) => { + let $p = $e else { + return None; + }; + }; +} + +fn infer_parser(ty: &Type) -> Option { + // ensure that type is a single-length path at the top (that covers both well-known types and any `Option`) + mch!( + Type::Path(TypePath { + qself: None, + path: Path { segments, .. }, + }) = ty + ); + if segments.len() != 1 { + return None; + } + let PathSegment { ident, arguments } = segments.first()?; + + if let PathArguments::None = arguments { + // no arguments and simple ident -- use well-known parser + return Some(syn::parse_quote!(::anode::__default_parser::#ident)); + } + + // at this point, if the parser can be inferred, type must be an option + if ident != "Option" { + return None; + } + mch!( + PathArguments::AngleBracketed(AngleBracketedGenericArguments { + args: arguments, + .. + }) = arguments + ); + if arguments.len() != 1 { + // real `Option` has only 1 argument + return None; + } + mch!( + Some(GenericArgument::Type(Type::Path(TypePath { + qself: None, + path: Path { segments, .. }, + }))) = arguments.first() + ); + if segments.len() != 1 { + // simple type must be just an ident + return None; + } + + let PathSegment { ident, .. } = segments.first()?; + + // well-known parser for the inner type + Some(syn::parse_quote!(::anode::__default_parser::#ident)) +} diff --git a/anode-macros/Cargo.toml b/anode-macros/Cargo.toml new file mode 100644 index 0000000..4536798 --- /dev/null +++ b/anode-macros/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "anode-macros" +version = "0.1.0" +edition = "2024" + +[lib] +proc-macro = true + +[dependencies] +anode-macros-input = { workspace = true } +yaml_serde.workspace = true +proc-macro2.workspace = true +syn = { workspace = true, features = [ "parsing", "proc-macro" ] } +quote.workspace = true + +[lints] +workspace = true diff --git a/anode-macros/src/lib.rs b/anode-macros/src/lib.rs new file mode 100644 index 0000000..063d732 --- /dev/null +++ b/anode-macros/src/lib.rs @@ -0,0 +1,134 @@ +use anode_macros_input::{Action, ActionInput, ActionInputDefault}; +use proc_macro::TokenStream as TStream1; +use proc_macro2::Span; +use quote::{ToTokens, format_ident}; +use syn::{ + ExprStruct, FieldValue, Ident, ItemFn, LitStr, Member, Stmt, Token, + punctuated::Punctuated, + token::{Brace, Colon}, +}; + +#[proc_macro_derive(Input, attributes(name, default, default_str, parser))] +pub fn input(item: TStream1) -> TStream1 { + let input = syn::parse_macro_input!(item as syn::ItemStruct); + let ident = &input.ident; + let action = Action::from_struct(&input); + let try_get_impl = try_get(&action); + + let get_impl: syn::ItemFn = syn::parse_quote! { + fn get() -> Self { + Self::try_get().expect("Failed to parse the input") + } + }; + + let impl_block: syn::ItemImpl = syn::parse_quote! { + impl #ident { + #try_get_impl + #get_impl + } + }; + impl_block.into_token_stream().into() +} + +fn try_get(action: &Action) -> ItemFn { + let mut body = Vec::::new(); + let errors = format_ident!("errors"); + body.push( + syn::parse_quote! { let mut #errors = ::std::vec::Vec::<(&'static str, ::anode::InputErrorMessage)>::new(); }, + ); + + let mut idents = Vec::<(Ident, Ident)>::new(); + let mut required_idents = Vec::::new(); + for ( + ActionInput { + name, + parser, + required, + r#default, + ident: struct_ident, + .. + }, + i, + ) in action.inputs.iter().zip(0..) + { + let input_ident = format_ident!("f{i}"); + body.push(syn::parse_quote! { let mut #input_ident: Option<_> = None; }); + + let input_lit = LitStr::new(name, proc_macro2::Span::call_site()); + let input_envvar = LitStr::new( + &format!("INPUT_{}", name.to_uppercase()), + proc_macro2::Span::call_site(), + ); + let mut envvar: syn::Expr = syn::parse_quote!( ::std::env::var_os(#input_envvar) ); + if let Some(ActionInputDefault::Str(default_str)) = default { + // has default str value -- use it as a fallback + let def = LitStr::new(&default_str, Span::call_site()); + envvar = syn::parse_quote!( #envvar.or_else(|| Some(::std::ffi::OsString::from(String::from(#def)))) ); + } + let mut parse_input: syn::ExprIf = syn::parse_quote! { + if let Some(envvar) = #envvar && !envvar.is_empty() { + match #parser(envvar) { + Ok(ok) => #input_ident = Some(ok), + Err(error) => #errors.push((#input_lit, ::anode::InputErrorMessage::Parsing(::std::boxed::Box::new(error)))), + } + } + }; + // else branch, if this input is required + if *required { + let else_branch = if let Some(ActionInputDefault::Expr(default_expr)) = default { + // has a default expression value -- assign it instead + syn::parse_quote! {{ + #input_ident = Some(#default_expr); + }} + } else { + // no default - error + syn::parse_quote! {{ + #errors.push((#input_lit, ::anode::InputErrorMessage::NotPresent)); + }} + }; + + parse_input.else_branch = Some((::default(), Box::new(else_branch))); + } + body.push(syn::parse_quote! (#parse_input)); + idents.push((input_ident.clone(), struct_ident.clone())); + if *required { + required_idents.push(input_ident); + } + } + + // assert there are no errors, and all required fields are present + body.push(syn::parse_quote! { + let (#(Some(#required_idents)),*, &[]) = (#(#required_idents),*, &*#errors) else { + return Err(::anode::InputError(#errors.into_boxed_slice())); + }; + }); + + // return the struct + let mut fields = Punctuated::new(); + for (val, member) in idents { + fields.push(FieldValue { + attrs: Vec::new(), + member: Member::Named(member), + colon_token: Some(Colon::default()), + expr: syn::parse_quote!(#val), + }); + } + let str = ExprStruct { + attrs: Vec::new(), + qself: None, + path: syn::parse_quote!(Self), + brace_token: Brace::default(), + fields, + dot2_token: None, + rest: None, + }; + body.push(syn::parse_quote! { + return Ok(#str); + }); + + syn::parse_quote! { + fn try_get() -> Result { + #(#body)* + } + } +} diff --git a/anode/Cargo.toml b/anode/Cargo.toml new file mode 100644 index 0000000..ca29a33 --- /dev/null +++ b/anode/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "anode" +version = "0.1.0" +edition = "2024" + +[dependencies] +anode-macros.workspace = true +thiserror.workspace = true diff --git a/anode/src/lib.rs b/anode/src/lib.rs new file mode 100644 index 0000000..953740c --- /dev/null +++ b/anode/src/lib.rs @@ -0,0 +1,118 @@ +pub use anode_macros::Input; + +#[doc(hidden)] +pub mod __default_parser { + #![allow(non_snake_case)] + + use std::{ + ffi::OsString, + os::unix::ffi::{OsStrExt, OsStringExt}, + path::PathBuf, + }; + + #[derive(Debug, thiserror::Error)] + pub enum InputIntError { + #[error(transparent)] + Uft8(#[from] core::str::Utf8Error), + #[error(transparent)] + Parse(#[from] core::num::ParseIntError), + } + + macro_rules! int { + ($t:ident) => { + pub fn $t(input: OsString) -> Result<$t, InputIntError> { + let str = core::str::from_utf8(input.as_bytes())?; + Ok(str.parse::<$t>()?) + } + }; + } + + int!(i8); + int!(u8); + int!(i16); + int!(u16); + int!(i32); + int!(u32); + int!(i64); + int!(u64); + int!(i128); + int!(u128); + + #[derive(Debug, thiserror::Error)] + pub enum InputFloatError { + #[error(transparent)] + Uft8(#[from] core::str::Utf8Error), + #[error(transparent)] + Parse(#[from] core::num::ParseFloatError), + } + + macro_rules! flt { + ($t:ident) => { + pub fn $t(input: OsString) -> Result<$t, InputFloatError> { + let str = core::str::from_utf8(input.as_bytes())?; + Ok(str.parse::<$t>()?) + } + }; + } + + flt!(f32); + flt!(f64); + + pub fn String(input: OsString) -> Result { + let bytes = input.into_vec(); + String::from_utf8(bytes) + } + + pub fn OsString(input: OsString) -> Result { + Ok(input) + } + + pub fn PathBuf(input: OsString) -> Result { + Ok(input.into()) + } + + #[derive(Debug, thiserror::Error)] + #[error("Unknown boolean value: {}", _0.display())] + pub struct UnknownBoolean(OsString); + + pub fn bool(input: OsString) -> Result { + const VALID_TRUE: &[&[u8]] = &[b"TRUE", b"True", b"true", b"1"]; + const VALID_FALSE: &[&[u8]] = &[b"FALSE", b"False", b"false", b"0"]; + if VALID_TRUE.contains(&input.as_bytes()) { + return Ok(true); + } + + if VALID_FALSE.contains(&input.as_bytes()) { + return Ok(false); + } + + Err(UnknownBoolean(input)) + } +} + +pub mod parse { + use std::{ffi::OsString, os::unix::ffi::OsStrExt, str::FromStr}; + + #[derive(Debug, thiserror::Error)] + pub enum InputFromStrError { + #[error(transparent)] + Uft8(#[from] core::str::Utf8Error), + #[error(transparent)] + Parse(T::Err), + } + + pub fn from_str(input: OsString) -> Result> { + let str = core::str::from_utf8(input.as_bytes())?; + str.parse::().map_err(InputFromStrError::Parse) + } +} + +#[derive(Debug)] +pub struct InputError(#[doc(hidden)] pub Box<[(&'static str, InputErrorMessage)]>); + +#[derive(Debug)] +#[doc(hidden)] +pub enum InputErrorMessage { + NotPresent, + Parsing(Box), +}