Skip to main content

zyn_core/ast/
pipe_node.rs

1use proc_macro2::Ident;
2use proc_macro2::Span;
3use proc_macro2::TokenStream;
4use proc_macro2::TokenTree;
5
6use quote::ToTokens;
7use quote::quote;
8
9use syn::Token;
10use syn::parse::Parse;
11use syn::parse::ParseStream;
12
13use crate::Expand;
14use crate::Pipe;
15use crate::pascal;
16use crate::pipes;
17
18/// A single pipe stage in a `{{ expr | pipe }}` interpolation.
19///
20/// At expand time, the name is matched against the built-in pipe list. Unrecognised
21/// names are assumed to be custom pipe structs and are PascalCase-converted.
22///
23/// ```text
24/// {{ name | snake }}          → PipeNode { name: "snake", args: [] }
25/// {{ name | ident:"get_{}" }} → PipeNode { name: "ident", args: ["get_{}"] }
26/// ```
27pub struct PipeNode {
28    /// Source span of the pipe name.
29    pub span: Span,
30    /// The pipe name as written, e.g. `snake`, `ident`, `my_custom_pipe`.
31    pub name: syn::Ident,
32    /// Colon-separated arguments following the name.
33    pub args: Vec<TokenStream>,
34}
35
36impl PipeNode {
37    pub fn span(&self) -> Span {
38        self.span
39    }
40
41    /// Apply this pipe to `input` at proc-macro time, returning the transformed string.
42    /// Used for debug display with static injection. Custom pipes pass through unchanged.
43    pub fn apply_display(&self, input: String) -> String {
44        let name = self.name.to_string();
45
46        let arg = |i: usize| -> String {
47            self.args
48                .get(i)
49                .map(|a| a.to_string().trim_matches('"').to_string())
50                .unwrap_or_default()
51        };
52
53        match name.as_str() {
54            "upper" => pipes::Upper.pipe(input).to_string(),
55            "lower" => pipes::Lower.pipe(input).to_string(),
56            "snake" => pipes::Snake.pipe(input).to_string(),
57            "camel" => pipes::Camel.pipe(input).to_string(),
58            "pascal" => pipes::Pascal.pipe(input).to_string(),
59            "kebab" => pipes::Kebab.pipe(input).value(),
60            "screaming" => pipes::Screaming.pipe(input).to_string(),
61            "str" => pipes::Str.pipe(input).value(),
62            "plural" => pipes::Plural.pipe(input).to_string(),
63            "singular" => pipes::Singular.pipe(input).to_string(),
64            "ident" | "fmt" => arg(0).replace("{}", &input),
65            "trim" => {
66                let start = arg(0);
67                let end = if self.args.len() > 1 {
68                    arg(1)
69                } else {
70                    start.clone()
71                };
72                input
73                    .trim_start_matches(|c: char| start.contains(c))
74                    .trim_end_matches(|c: char| end.contains(c))
75                    .to_string()
76            }
77            // Custom pipes are not available at proc-macro time — pass through unchanged
78            _ => input,
79        }
80    }
81}
82
83impl Parse for PipeNode {
84    fn parse(input: ParseStream) -> syn::Result<Self> {
85        let name: syn::Ident = input.parse()?;
86        let span = name.span();
87
88        let mut args = Vec::new();
89
90        while input.peek(Token![:]) {
91            input.parse::<Token![:]>()?;
92
93            let mut arg = TokenStream::new();
94
95            while !input.is_empty() && !input.peek(Token![:]) && !input.peek(Token![|]) {
96                let tt: TokenTree = input.parse()?;
97                tt.to_tokens(&mut arg);
98            }
99
100            args.push(arg);
101        }
102
103        Ok(Self { span, name, args })
104    }
105}
106
107fn is_builtin(name: &str) -> bool {
108    matches!(
109        name,
110        "upper"
111            | "lower"
112            | "snake"
113            | "camel"
114            | "pascal"
115            | "kebab"
116            | "screaming"
117            | "ident"
118            | "fmt"
119            | "str"
120            | "trim"
121            | "plural"
122            | "singular"
123    )
124}
125
126impl Expand for PipeNode {
127    fn expand(&self, _output: &Ident, _idents: &mut crate::ident::Iter) -> TokenStream {
128        let pascal_name = pascal!(self.name => ident);
129        let name_str = self.name.to_string();
130
131        if is_builtin(&name_str) {
132            if name_str == "trim" {
133                match self.args.as_slice() {
134                    [] => {
135                        quote! { let __zyn_val = ::zyn::Pipe::pipe(&(::zyn::pipes::Trim(" ", " ")), __zyn_val); }
136                    }
137                    [a] => {
138                        quote! { let __zyn_val = ::zyn::Pipe::pipe(&(::zyn::pipes::Trim(#a, #a)), __zyn_val); }
139                    }
140                    [a, b] => {
141                        quote! { let __zyn_val = ::zyn::Pipe::pipe(&(::zyn::pipes::Trim(#a, #b)), __zyn_val); }
142                    }
143                    _ => quote! { compile_error!("trim pipe accepts at most 2 arguments"); },
144                }
145            } else if self.args.is_empty() {
146                quote! { let __zyn_val = ::zyn::Pipe::pipe(&(::zyn::pipes::#pascal_name), __zyn_val); }
147            } else {
148                let args = &self.args;
149                quote! { let __zyn_val = ::zyn::Pipe::pipe(&(::zyn::pipes::#pascal_name(#(#args),*)), __zyn_val); }
150            }
151        } else if self.args.is_empty() {
152            quote! { let __zyn_val = ::zyn::Pipe::pipe(&(#pascal_name), __zyn_val); }
153        } else {
154            let args = &self.args;
155            quote! { let __zyn_val = ::zyn::Pipe::pipe(&(#pascal_name(#(#args),*)), __zyn_val); }
156        }
157    }
158}