Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98addf4d54 | |||
| 2bc5e3c712 | |||
| 2c1972141b | |||
| 0e37f99cd2 | |||
| d9ad91c55d | |||
| 44916a91db | |||
| ce4fd8ccc9 | |||
| 640e9507a1 |
14 changed files with 1002 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
target/
|
||||
180
Cargo.lock
generated
Normal file
180
Cargo.lock
generated
Normal file
|
|
@ -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",
|
||||
]
|
||||
25
Cargo.toml
Normal file
25
Cargo.toml
Normal file
|
|
@ -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"
|
||||
16
README.md
Normal file
16
README.md
Normal file
|
|
@ -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
|
||||
```
|
||||
14
anode-build/Cargo.toml
Normal file
14
anode-build/Cargo.toml
Normal file
|
|
@ -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
|
||||
85
anode-build/src/lib.rs
Normal file
85
anode-build/src/lib.rs
Normal file
|
|
@ -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::<ActionManifest>(&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::<Path, Token![,]>::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::<Vec<_>>();
|
||||
|
||||
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::<Vec<_>>(),
|
||||
);
|
||||
|
||||
with_derive.pop().unwrap()
|
||||
}
|
||||
142
anode-build/src/manifest.rs
Normal file
142
anode-build/src/manifest.rs
Normal file
|
|
@ -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<yaml_serde::Value>,
|
||||
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::<u64>() {
|
||||
Number::from(pos_int)
|
||||
} else if let Ok(neg_int) = lit_int.base10_parse::<i64>() {
|
||||
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::<f64>().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<String, InputManifest>,
|
||||
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::<yaml_serde::Value>(include_str!("runs.yml")).unwrap()
|
||||
}
|
||||
4
anode-build/src/runs.yml
Normal file
4
anode-build/src/runs.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
using: composite
|
||||
steps:
|
||||
- run: ${{ forgejo.action_path }}/main
|
||||
shell: bash
|
||||
12
anode-macros-input/Cargo.toml
Normal file
12
anode-macros-input/Cargo.toml
Normal file
|
|
@ -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
|
||||
246
anode-macros-input/src/lib.rs
Normal file
246
anode-macros-input/src/lib.rs
Normal file
|
|
@ -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<str>),
|
||||
Expr(Box<Expr>),
|
||||
}
|
||||
|
||||
pub struct ActionInput {
|
||||
pub ident: syn::Ident,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub r#default: Option<ActionInputDefault>,
|
||||
pub required: bool,
|
||||
pub ty: Type,
|
||||
pub parser: Option<Path>,
|
||||
}
|
||||
|
||||
pub struct Action {
|
||||
pub name: Ident,
|
||||
pub name_override: Option<String>,
|
||||
pub description: String,
|
||||
pub inputs: Vec<ActionInput>,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn from_struct(
|
||||
syn::ItemStruct {
|
||||
attrs,
|
||||
ident,
|
||||
fields,
|
||||
..
|
||||
}: &syn::ItemStruct,
|
||||
) -> Self {
|
||||
let mut name_override = Option::<String>::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: Spanned>(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::<ActionInputDefault>::None;
|
||||
|
||||
for attr in attrs {
|
||||
if attr.path().is_ident("default") {
|
||||
default = Some(ActionInputDefault::Expr(Box::new(
|
||||
attr.parse_args::<Expr>().unwrap(),
|
||||
)));
|
||||
}
|
||||
|
||||
if attr.path().is_ident("default_str") {
|
||||
default = Some(ActionInputDefault::Str(
|
||||
attr.parse_args::<LitStr>()
|
||||
.unwrap()
|
||||
.value()
|
||||
.into_boxed_str(),
|
||||
));
|
||||
}
|
||||
|
||||
if attr.path().is_ident("parser") {
|
||||
parser = Some(attr.parse_args::<Path>().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<Path> {
|
||||
// ensure that type is a single-length path at the top (that covers both well-known types and any `Option<T>`)
|
||||
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))
|
||||
}
|
||||
17
anode-macros/Cargo.toml
Normal file
17
anode-macros/Cargo.toml
Normal file
|
|
@ -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
|
||||
134
anode-macros/src/lib.rs
Normal file
134
anode-macros/src/lib.rs
Normal file
|
|
@ -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::<Stmt>::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::<Ident>::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((<Token![else]>::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<Self, ::anode::InputError> {
|
||||
#(#body)*
|
||||
}
|
||||
}
|
||||
}
|
||||
8
anode/Cargo.toml
Normal file
8
anode/Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[package]
|
||||
name = "anode"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anode-macros.workspace = true
|
||||
thiserror.workspace = true
|
||||
118
anode/src/lib.rs
Normal file
118
anode/src/lib.rs
Normal file
|
|
@ -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<String, std::string::FromUtf8Error> {
|
||||
let bytes = input.into_vec();
|
||||
String::from_utf8(bytes)
|
||||
}
|
||||
|
||||
pub fn OsString(input: OsString) -> Result<OsString, core::convert::Infallible> {
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
pub fn PathBuf(input: OsString) -> Result<PathBuf, core::convert::Infallible> {
|
||||
Ok(input.into())
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("Unknown boolean value: {}", _0.display())]
|
||||
pub struct UnknownBoolean(OsString);
|
||||
|
||||
pub fn bool(input: OsString) -> Result<bool, UnknownBoolean> {
|
||||
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<T: FromStr> {
|
||||
#[error(transparent)]
|
||||
Uft8(#[from] core::str::Utf8Error),
|
||||
#[error(transparent)]
|
||||
Parse(T::Err),
|
||||
}
|
||||
|
||||
pub fn from_str<T: FromStr>(input: OsString) -> Result<T, InputFromStrError<T>> {
|
||||
let str = core::str::from_utf8(input.as_bytes())?;
|
||||
str.parse::<T>().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<dyn core::error::Error>),
|
||||
}
|
||||
Loading…
Reference in a new issue