Initial implementation #1

Merged
dzu merged 7 commits from forgejo-openapi-generator into master 2026-02-11 06:36:51 +01:00
12 changed files with 789 additions and 0 deletions
Showing only changes of commit a69815f5df - Show all commits

initial implementation

Artem Diachenko 2026-02-11 05:15:09 +00:00

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
target/

View file

@ -1,8 +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"

14
anode-build/Cargo.toml Normal file
View 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
View 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
View 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
View file

@ -0,0 +1,5 @@
runs:
using: composite
steps:
- run: ${{ forgejo.action_path }}/main
shell: bash

View 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

View 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
View 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
View 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
View 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
View 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>),
}