commit e69223d3c2d3a9b60b9e236ddfcbd7ae24d1d60a Author: rowan Date: Wed Jul 9 11:24:33 2025 -0400 initial commit 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..aa228ca --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,52 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "osstr_traits" +version = "0.1.0" + +[[package]] +name = "osstr_traits_derive" +version = "0.1.0" +dependencies = [ + "osstr_traits", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3741cfb --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] +resolver = "3" +members = ["crates/osstr_traits", "crates/osstr_traits_derive"] + + diff --git a/crates/osstr_traits/.gitignore b/crates/osstr_traits/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/crates/osstr_traits/.gitignore @@ -0,0 +1 @@ +/target diff --git a/crates/osstr_traits/Cargo.lock b/crates/osstr_traits/Cargo.lock new file mode 100644 index 0000000..85639e5 --- /dev/null +++ b/crates/osstr_traits/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "traits" +version = "0.1.0" diff --git a/crates/osstr_traits/Cargo.toml b/crates/osstr_traits/Cargo.toml new file mode 100644 index 0000000..468143d --- /dev/null +++ b/crates/osstr_traits/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "osstr_traits" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/crates/osstr_traits/src/impls.rs b/crates/osstr_traits/src/impls.rs new file mode 100644 index 0000000..5ad12d4 --- /dev/null +++ b/crates/osstr_traits/src/impls.rs @@ -0,0 +1,44 @@ +use crate::{OsDisplay, OsStringFormatter}; + +macro_rules! impl_os_from_str { + ($type:ty) => { + impl $crate::OsDisplay for $type { + fn fmt_os(&self, f: &mut OsStringFormatter) -> std::fmt::Result { + f.write_str(&self.to_string()) + } + } + }; +} + +impl_os_from_str!(bool); + +impl_os_from_str!(u8); +impl_os_from_str!(u16); +impl_os_from_str!(u32); +impl_os_from_str!(u64); +impl_os_from_str!(u128); +impl_os_from_str!(usize); +impl_os_from_str!(i8); +impl_os_from_str!(i16); +impl_os_from_str!(i32); +impl_os_from_str!(i64); +impl_os_from_str!(i128); +impl_os_from_str!(f32); +impl_os_from_str!(f64); + +impl_os_from_str!(char); + +impl_os_from_str!(&str); +impl_os_from_str!(String); + +impl OsDisplay for std::ffi::OsString { + fn fmt_os(&self, f: &mut OsStringFormatter) -> std::fmt::Result { + f.write_os_str(self.as_os_str()) + } +} + +impl OsDisplay for &std::ffi::OsStr { + fn fmt_os(&self, f: &mut OsStringFormatter) -> std::fmt::Result { + f.write_os_str(self) + } +} diff --git a/crates/osstr_traits/src/lib.rs b/crates/osstr_traits/src/lib.rs new file mode 100644 index 0000000..28debbe --- /dev/null +++ b/crates/osstr_traits/src/lib.rs @@ -0,0 +1,56 @@ +pub mod impls; + +use std::ffi::{OsStr, OsString}; +use std::fmt; + +pub trait FromOsStr: Sized { + type Err; + + fn from_os_str(s: &OsStr) -> Result; +} + +pub trait ToOsString { + fn to_os_string(&self) -> OsString; +} + +pub struct OsStringFormatter { + inner: OsString, +} + +impl OsStringFormatter { + pub fn new() -> Self { + OsStringFormatter { + inner: OsString::new(), + } + } + + pub fn write_os_str(&mut self, s: &OsStr) -> fmt::Result { + self.inner.push(s); + Ok(()) + } + + pub fn write_str(&mut self, s: &str) -> fmt::Result { + self.inner.push(s); + Ok(()) + } +} + +impl ToOsString for OsStringFormatter { + fn to_os_string(&self) -> OsString { + self.inner.clone() + } +} + +pub trait OsDisplay { + fn fmt_os(&self, f: &mut OsStringFormatter) -> fmt::Result; +} + +impl ToOsString for T { + fn to_os_string(&self) -> OsString { + let mut formatter = OsStringFormatter::new(); + match self.fmt_os(&mut formatter) { + Ok(_) => formatter.to_os_string(), + Err(_) => OsString::new(), + } + } +} diff --git a/crates/osstr_traits_derive/.gitignore b/crates/osstr_traits_derive/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/crates/osstr_traits_derive/.gitignore @@ -0,0 +1 @@ +/target diff --git a/crates/osstr_traits_derive/Cargo.lock b/crates/osstr_traits_derive/Cargo.lock new file mode 100644 index 0000000..567f2dc --- /dev/null +++ b/crates/osstr_traits_derive/Cargo.lock @@ -0,0 +1,52 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "os_trait_derive" +version = "0.1.0" +dependencies = [ + "os_traits", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "os_traits" +version = "0.1.0" + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" diff --git a/crates/osstr_traits_derive/Cargo.toml b/crates/osstr_traits_derive/Cargo.toml new file mode 100644 index 0000000..38a548a --- /dev/null +++ b/crates/osstr_traits_derive/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "osstr_traits_derive" +version = "0.1.0" +edition = "2024" + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "2.0", features = ["full", "extra-traits"] } +quote = "1.0" +proc-macro2 = "1.0" +osstr_traits = { path = "../osstr_traits" } diff --git a/crates/osstr_traits_derive/src/attr_args.rs b/crates/osstr_traits_derive/src/attr_args.rs new file mode 100644 index 0000000..5b2711f --- /dev/null +++ b/crates/osstr_traits_derive/src/attr_args.rs @@ -0,0 +1,71 @@ +use syn::parse::{Parse, ParseStream}; +use syn::{Expr, Ident, LitStr, Token, custom_keyword}; + +custom_keyword!(transparent); + +pub enum OsDisplayAttribute { + Transparent, + Format(FormatArgs), +} + +pub struct FormatArgs { + pub format_string: LitStr, + pub positional_args: Vec, + pub named_args: Vec<(Ident, Expr)>, +} + +impl Parse for OsDisplayAttribute { + fn parse(input: ParseStream) -> syn::Result { + let lookahead = input.lookahead1(); + + if lookahead.peek(transparent) { + input.parse::()?; + if !input.is_empty() { + return Err(input.error("Unexpected tokens after `transparent` attribute.")); + } + return Ok(OsDisplayAttribute::Transparent); + } else if lookahead.peek(LitStr) { + let format_string = input.parse()?; + + let mut positional_args = Vec::new(); + let mut named_args = Vec::new(); + + while input.peek(Token![,]) { + input.parse::()?; + + if input.is_empty() { + break; + } + + let lookahead_arg = input.fork(); + if lookahead_arg.peek(Ident) && lookahead_arg.peek2(Token![=]) { + let name: Ident = input.parse()?; + input.parse::()?; + let value: Expr = input.parse()?; + named_args.push((name, value)); + } else { + let expr: Expr = input.parse()?; + positional_args.push(expr); + } + } + + Ok(OsDisplayAttribute::Format(FormatArgs { + format_string, + positional_args, + named_args, + })) + } else { + Err(lookahead.error()) + } + } +} + +impl Default for FormatArgs { + fn default() -> Self { + FormatArgs { + format_string: LitStr::new("", proc_macro2::Span::call_site()), + positional_args: Vec::new(), + named_args: Vec::new(), + } + } +} diff --git a/crates/osstr_traits_derive/src/lib.rs b/crates/osstr_traits_derive/src/lib.rs new file mode 100644 index 0000000..d7c9e95 --- /dev/null +++ b/crates/osstr_traits_derive/src/lib.rs @@ -0,0 +1,351 @@ +mod attr_args; + +extern crate proc_macro; + +use crate::attr_args::{OsDisplayAttribute}; +use proc_macro::TokenStream; +use quote::{quote, quote_spanned}; +use syn::parse::Parse; +use syn::spanned::Spanned; +use syn::{Data, DeriveInput, Expr, Fields, Ident, LitStr, parse_macro_input}; +use std::collections::HashMap; + +#[proc_macro_derive(OsDisplay, attributes(os_display))] +pub fn os_display_derive(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + + let name = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let os_display_impl = match &input.data { + Data::Enum(data_enum) => { + let variant_arms: Vec<_> = data_enum + .variants + .iter() + .map(|variant| { + let variant_name = &variant.ident; + let os_display_attr = variant + .attrs + .iter() + .find(|attr| attr.path().is_ident("os_display")); + + let format_tokens = if let Some(attr) = os_display_attr { + let parsed_attr = attr + .parse_args_with(OsDisplayAttribute::parse) + .expect("Failed to parse #[os_display] attribute arguments"); + + match parsed_attr { + OsDisplayAttribute::Transparent => { + match &variant.fields { + Fields::Unnamed(fields) if fields.unnamed.len() == 1 => { + quote_spanned! {variant.span() => + match self { + #name::#variant_name(value) => value.fmt_os(f), + _ => unreachable!(), + } + } + } + Fields::Named(fields) if fields.named.len() == 1 => { + let field_ident = fields.named.first().unwrap().ident.as_ref().unwrap(); + quote_spanned! {variant.span() => + match self { + #name::#variant_name{#field_ident} => #field_ident.fmt_os(f), + _ => unreachable!(), + } + } + } + _ => { + return quote_spanned! {variant.span() => + compile_error!("#[os_display(transparent)] can only be used on single-field enum variants."); + }; + } + } + } + OsDisplayAttribute::Format(format_args) => { + let format_str_value = format_args.format_string.value(); + + let positional_expressions: Vec<&Expr> = format_args.positional_args.iter().collect(); + + let named_expressions: HashMap = format_args.named_args + .into_iter() + .map(|(ident, expr)| (ident.to_string(), expr)) + .collect(); + + parse_os_display_format_string( + &format_str_value, + &positional_expressions, + &named_expressions, + variant.span(), + ) + } + } + } else { + let variant_name_str = format!("{}", variant_name); + quote! { f.write_str(#variant_name_str)?; } + }; + + match &variant.fields { + Fields::Unit => { + quote! { + #name::#variant_name => { #format_tokens } + } + } + Fields::Unnamed(fields) => { + let field_idents: Vec = fields + .unnamed + .iter() + .enumerate() + .map(|(i, _)| Ident::new(&format!("_{}", i), variant.span())) + .collect(); + if let Some(attr) = os_display_attr { + if let Ok(OsDisplayAttribute::Transparent) = attr.parse_args_with(OsDisplayAttribute::parse) { + quote! { + #name::#variant_name(value) => { #format_tokens } + } + } else { + quote! { + #name::#variant_name(#(#field_idents),*) => { #format_tokens } + } + } + } else { + quote! { + #name::#variant_name(#(#field_idents),*) => { #format_tokens } + } + } + } + Fields::Named(fields) => { + let field_idents: Vec = fields + .named + .iter() + .map(|f| f.ident.as_ref().unwrap().clone()) + .collect(); + if let Some(attr) = os_display_attr { + if let Ok(OsDisplayAttribute::Transparent) = attr.parse_args_with(OsDisplayAttribute::parse) { + let field_ident = fields.named.first().unwrap().ident.as_ref().unwrap(); + quote! { + #name::#variant_name{#field_ident} => { #format_tokens } + } + } else { + quote! { + #name::#variant_name{#(#field_idents),*} => { #format_tokens } + } + } + } else { + quote! { + #name::#variant_name{#(#field_idents),*} => { #format_tokens } + } + } + } + } + }) + .collect(); + + quote! { + impl #impl_generics os_traits::OsDisplay for #name #ty_generics #where_clause { + fn fmt_os(&self, f: &mut os_traits::OsStringFormatter) -> std::fmt::Result { + match self { + #(#variant_arms),* + } + Ok(()) + } + } + } + } + Data::Struct(data_struct) => { + let os_display_attr = input + .attrs + .iter() + .find(|attr| attr.path().is_ident("os_display")); + + if let Some(attr) = os_display_attr { + let parsed_attr = attr + .parse_args_with(OsDisplayAttribute::parse) + .expect("Failed to parse #[os_display] attribute arguments"); + + match parsed_attr { + OsDisplayAttribute::Transparent => { + match &data_struct.fields { + Fields::Unnamed(fields) if fields.unnamed.len() == 1 => { + quote! { + impl #impl_generics os_traits::OsDisplay for #name #ty_generics #where_clause { + fn fmt_os(&self, f: &mut os_traits::OsStringFormatter) -> std::fmt::Result { + self.0.fmt_os(f) + } + } + } + } + Fields::Named(fields) if fields.named.len() == 1 => { + let field_ident = fields.named.first().unwrap().ident.as_ref().unwrap(); + quote! { + impl #impl_generics os_traits::OsDisplay for #name #ty_generics #where_clause { + fn fmt_os(&self, f: &mut os_traits::OsStringFormatter) -> std::fmt::Result { + self.#field_ident.fmt_os(f) + } + } + } + } + _ => { + quote_spanned! {name.span() => + compile_error!("#[os_display(transparent)] can only be used on single-field structs (newtypes)."); + } + } + } + } + OsDisplayAttribute::Format(format_args) => { + let format_str_value = format_args.format_string.value(); + let positional_expressions: Vec<&Expr> = format_args.positional_args.iter().collect(); + let named_expressions: HashMap = format_args.named_args + .into_iter() + .map(|(ident, expr)| (ident.to_string(), expr)) + .collect(); + + let generated_code = parse_os_display_format_string( + &format_str_value, + &positional_expressions, + &named_expressions, + name.span(), + ); + + let field_bindings = match &data_struct.fields { + Fields::Named(fields) => { + let idents: Vec<&Ident> = fields.named.iter().filter_map(|f| f.ident.as_ref()).collect(); + quote! { + let Self { #(#idents),* } = self; + } + }, + Fields::Unnamed(fields) => { + let idents: Vec = fields.unnamed.iter().enumerate().map(|(i, _)| Ident::new(&format!("_{}", i), name.span())).collect(); + quote! { + let Self(#(#idents),*) = self; + } + }, + Fields::Unit => quote!{}, + }; + + quote! { + impl #impl_generics os_traits::OsDisplay for #name #ty_generics #where_clause { + fn fmt_os(&self, f: &mut os_traits::OsStringFormatter) -> std::fmt::Result { + #field_bindings + #generated_code + Ok(()) + } + } + } + } + } + } else { + quote_spanned! {name.span() => + compile_error!("OsDisplay derive macro is not yet implemented for structs without #[os_display] attribute. Consider adding #[os_display(transparent)] for newtypes or specifying a format string using #[os_display(\"...\")] syntax."); + } + } + } + Data::Union(_) => { + quote_spanned! {name.span() => + compile_error!("OsDisplay derive macro does not support unions"); + } + } + }; + + os_display_impl.into() +} + +fn parse_os_display_format_string( + format_str: &str, + positional_expressions: &[&syn::Expr], + named_expressions: &HashMap, + span: proc_macro2::Span, +) -> proc_macro2::TokenStream { + let mut generated_code_parts = Vec::new(); + let mut current_literal = String::new(); + let mut chars = format_str.chars().peekable(); + let mut positional_arg_index = 0; + + while let Some(c) = chars.next() { + if c == '{' { + if let Some('{') = chars.peek() { + current_literal.push(c); + chars.next(); + } else { + if !current_literal.is_empty() { + let lit_str = LitStr::new(¤t_literal, span); + generated_code_parts.push(quote! { f.write_str(#lit_str)?; }); + current_literal.clear(); + } + + let mut placeholder_content = String::new(); + + while let Some(p) = chars.next() { + if p == '}' { + break; + } + placeholder_content.push(p); + } + + let expr_to_format: syn::Expr = if placeholder_content.is_empty() { + if let Some(expr_ref) = positional_expressions.get(positional_arg_index) { + positional_arg_index += 1; + (**expr_ref).clone() + } else { + return quote_spanned! {span => + compile_error!("Not enough positional arguments for format string: missing argument for empty '{}' placeholder.", #placeholder_content); + }; + } + } else { + let parsed_ident_res: syn::Result = syn::parse_str(&placeholder_content); + + if let Ok(ident) = parsed_ident_res { + if let Some(expr) = named_expressions.get(&ident.to_string()) { + expr.clone() + } else { + match syn::parse_str(&placeholder_content) { + Ok(e) => e, + Err(e) => { + let error_message = e.to_string(); + return quote_spanned! {span => + compile_error!(format!("Invalid placeholder content '{}'. Error: {}. Named arguments must be simple identifiers provided in the attribute, or full expressions.", #placeholder_content, #error_message)); + }; + } + } + } + } else { + match syn::parse_str(&placeholder_content) { + Ok(e) => e, + Err(e) => { + let error_message = e.to_string(); + return quote_spanned! {span => + compile_error!(format!("Invalid expression in os_display attribute: {}. Error: {}", #placeholder_content, #error_message)); + }; + } + } + } + }; + + generated_code_parts.push(quote! { (#expr_to_format).fmt_os(f)?; }); + } + } else if c == '}' { + if let Some('}') = chars.peek() { + current_literal.push(c); + chars.next(); + } else { + return quote_spanned! {span => + compile_error!("Mismatched closing brace `}}` in os_display attribute."); + }; + } + } else { + current_literal.push(c); + } + } + + if !current_literal.is_empty() { + let lit_str = LitStr::new(¤t_literal, span); + generated_code_parts.push(quote! { f.write_str(#lit_str)?; }); + } + + if positional_arg_index < positional_expressions.len() { + return quote_spanned! {span => + compile_error!("Too many positional arguments for format string: unused arguments provided."); + }; + } + quote! { #(#generated_code_parts)* } +} + diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..a25e7e0 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "nightly" +