Initial implementation #1
14 changed files with 1951 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
target/
|
||||||
1138
Cargo.lock
generated
Normal file
1138
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
27
Cargo.toml
Normal file
27
Cargo.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
[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" }
|
||||||
|
# git
|
||||||
|
forgejo-openapi = { git = "https://git.dzuchun.ing/dzu/forgejo-openapi.git", default-features = false }
|
||||||
|
# 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
|
||||||
84
anode-build/src/lib.rs
Normal file
84
anode-build/src/lib.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
5
anode-build/src/runs.yml
Normal file
5
anode-build/src/runs.yml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
runs:
|
||||||
|
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().replace('-', "_")),
|
||||||
|
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 {
|
||||||
|
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)*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
anode/Cargo.toml
Normal file
12
anode/Cargo.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[package]
|
||||||
|
name = "anode"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anode-macros.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
forgejo-openapi = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
forge-forgejo = [ "dep:forgejo-openapi" ]
|
||||||
103
anode/src/lib.rs
Normal file
103
anode/src/lib.rs
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "forge-forgejo")]
|
||||||
|
pub use forgejo_openapi as forgejo;
|
||||||
|
|
||||||
|
#[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