Skip to main content

struct_path/
lib.rs

1//! A helper macros implementation to build a string that represents struct fields path at compile time.
2//!
3//! Library provides a tiny macro implementation to reference Rust struct fields at compile time to represent its string format.
4//! This is needed to work with JSON paths, and some others protocols when we still want to rely on the compiler to avoid inconsistent changes.
5//!
6//! Features:
7//! - Fast and no macro parsing without huge deps;
8//! - Macro produces the code to verify if the specified path really exists;
9//! - Multiple fields/arrays support
10//! - Optional camelCase and PascalCase conversion support;
11//! - Optional delimiter parameter;
12//!
13//! Example:
14//!
15//! ```rust,no_run
16//! use struct_path::*;
17//!
18//! fn example() {
19//!
20//! pub struct TestStructParent {
21//!     pub value_str: String,
22//!     pub value_num: u64,
23//!     pub value_child: TestStructChild,
24//!     pub opt_value_child: Option<TestStructChild>,
25//! }
26//!
27//! pub struct TestStructChild {
28//!     pub child_value_str: String,
29//!     pub child_value_num: u64,
30//! }
31//!
32//!// returns "value_str"
33//!let s1: &str = path!(TestStructParent::value_str);
34//!
35//!// returns "value_child.child_value_str"
36//!let s2: &str = path!(TestStructParent::value_child.child_value_str) ;
37//!
38//!// returns also "value_child.child_value_str"
39//!let s3: &str = path!(TestStructParent::value_child,TestStructChild::child_value_str);
40//!
41//!// options, returns "valueChild/childValueStr"
42//!let s4: &str = path!(TestStructParent::value_child.child_value_str; delim="/", case="camel") ;
43//!
44//!// `~` steps through a collection field (Vec, Option, ...) while still
45//!// verifying the path; returns "optValueChild/childValueStr"
46//!let s5: &str = path!(TestStructParent::opt_value_child~child_value_str; delim="/", case="camel") ;
47//!
48//!// returns ["value_str", "value_num"]
49//!let arr: [&str; 2] = paths!(TestStructParent::{ value_str, value_num });
50//!
51//! }
52//!
53//! ```
54//!
55
56use convert_case::{Case, Casing};
57use proc_macro::{Delimiter, Group, Span, TokenStream, TokenTree};
58use std::collections::HashMap;
59
60/// Converts a parse-time error into the token stream for a `compile_error!`
61/// invocation, so malformed macro input surfaces as a normal compiler error
62/// carrying the same message instead of a proc-macro panic.
63fn compile_error_for(message: String) -> TokenStream {
64    format!("compile_error!({:?})", message)
65        .parse()
66        .expect("a compile_error! invocation with an escaped string literal always parses")
67}
68
69/// The type path and field paths found for one `Type::field[, field...]`
70/// group, kept as the user's own tokens so the generated check reports a
71/// type error on the exact token that is wrong. A field path holds its
72/// idents, `.` and `~` puncts as written; it is the only record kept of the
73/// path, so the check and the returned string can never disagree.
74type FoundStructs = Vec<(Vec<TokenTree>, Vec<Vec<TokenTree>>)>;
75
76#[proc_macro]
77pub fn paths(struct_path_stream: TokenStream) -> TokenStream {
78    match paths_impl(struct_path_stream) {
79        Ok(stream) => stream,
80        Err(message) => compile_error_for(message),
81    }
82}
83
84fn paths_impl(struct_path_stream: TokenStream) -> Result<TokenStream, String> {
85    let mut current_struct_name_tokens: Vec<TokenTree> = Vec::new();
86    let mut current_struct_fields: Vec<Vec<TokenTree>> = Vec::with_capacity(16);
87
88    let mut opened_struct = false;
89    let mut colons_counter = 0;
90    let mut options_opened = false;
91
92    let mut current_field_tokens: Vec<TokenTree> = Vec::new();
93
94    let mut current_option_name: Option<String> = None;
95    let mut expect_option_value: bool = false;
96
97    let mut options: HashMap<String, String> = HashMap::new();
98    let mut found_structs: FoundStructs = Vec::new();
99
100    for token_tree in struct_path_stream.into_iter() {
101        match token_tree {
102            TokenTree::Ident(id) if current_struct_name_tokens.is_empty() => {
103                current_struct_name_tokens.push(TokenTree::Ident(id));
104            }
105            TokenTree::Punct(punct)
106                if !current_struct_name_tokens.is_empty()
107                    && !opened_struct
108                    && punct == ':'
109                    && colons_counter < 2 =>
110            {
111                colons_counter += 1;
112                if colons_counter > 1 {
113                    opened_struct = true;
114                }
115            }
116            TokenTree::Ident(id) if opened_struct => {
117                colons_counter = 0;
118                current_field_tokens.push(TokenTree::Ident(id));
119            }
120            TokenTree::Punct(punct)
121                if !current_struct_name_tokens.is_empty()
122                    && opened_struct
123                    && punct == ':'
124                    && colons_counter < 2 =>
125            {
126                colons_counter += 1;
127                opened_struct = false;
128                let field_tokens = std::mem::take(&mut current_field_tokens);
129                if !fold_field_into_struct_name(field_tokens, &mut current_struct_name_tokens) {
130                    return Err(format!(
131                        "Unexpected punctuation input for struct path group parameters: {:?}",
132                        punct
133                    ));
134                }
135            }
136            TokenTree::Punct(punct) if opened_struct && (punct == '.' || punct == '~') => {
137                if current_field_tokens.is_empty() {
138                    return Err(format!(
139                        "Unexpected punctuation input for struct path group parameters: {:?}",
140                        punct
141                    ));
142                }
143                current_field_tokens.push(TokenTree::Punct(punct));
144            }
145            TokenTree::Group(group) if opened_struct && current_field_tokens.is_empty() => {
146                parse_multiple_fields(group.stream(), &mut current_struct_fields)?
147            }
148            TokenTree::Punct(punct) if !options_opened && opened_struct && punct == ',' => {
149                opened_struct = false;
150                colons_counter = 0;
151                if current_struct_name_tokens.is_empty() {
152                    return Err("Unexpected comma with empty definitions!".to_string());
153                }
154                if !current_field_tokens.is_empty() {
155                    current_struct_fields.push(std::mem::take(&mut current_field_tokens));
156                }
157                if current_struct_fields.is_empty() {
158                    return Err(format!(
159                        "Unexpected comma with empty fields for {}!",
160                        render_text(&current_struct_name_tokens)
161                    ));
162                }
163                found_structs.push((
164                    std::mem::take(&mut current_struct_name_tokens),
165                    std::mem::take(&mut current_struct_fields),
166                ));
167            }
168            TokenTree::Punct(punct) if punct == ';' && opened_struct && !options_opened => {
169                options_opened = true;
170                opened_struct = false;
171            }
172            TokenTree::Ident(id) if options_opened && !expect_option_value => {
173                current_option_name = Some(id.to_string())
174            }
175            TokenTree::Ident(id) if options_opened && expect_option_value => {
176                expect_option_value = false;
177                match current_option_name.take() {
178                    Some(option_name) => {
179                        options.insert(option_name, id.to_string());
180                    }
181                    _ => {
182                        return Err("Wrong options format".to_string());
183                    }
184                }
185            }
186            TokenTree::Literal(lit) if options_opened && expect_option_value => {
187                expect_option_value = false;
188                match current_option_name.take() {
189                    Some(option_name) => {
190                        options.insert(option_name, unquote_literal(&lit)?);
191                    }
192                    _ => {
193                        return Err("Wrong options format".to_string());
194                    }
195                }
196            }
197            TokenTree::Punct(punct) if options_opened && punct == '=' => {
198                expect_option_value = true;
199            }
200            TokenTree::Punct(punct) if options_opened && punct == ',' => {
201                expect_option_value = false;
202            }
203            others => {
204                return Err(format!(
205                    "Unexpected input for struct path parameters: {:?}",
206                    others
207                ));
208            }
209        }
210    }
211
212    if !current_field_tokens.is_empty() {
213        current_struct_fields.push(std::mem::take(&mut current_field_tokens));
214    }
215    if current_struct_name_tokens.is_empty() {
216        return Err("Unexpected comma with empty definitions!".to_string());
217    }
218    if current_struct_fields.is_empty() {
219        return Err(format!(
220            "Unexpected comma with empty fields for {}!",
221            render_text(&current_struct_name_tokens)
222        ));
223    }
224    found_structs.push((current_struct_name_tokens, current_struct_fields));
225
226    let all_check_functions = generate_checks_code_for(&found_structs);
227
228    let mut all_final_fields: Vec<String> = Vec::with_capacity(16);
229
230    for (_, fields) in &found_structs {
231        for field_tokens in fields {
232            let mut final_field_path = render_text(field_tokens).replace('~', ".");
233            if !options.is_empty() {
234                final_field_path = apply_options(&options, final_field_path)?;
235            }
236            all_final_fields.push(format!("\"{}\"", final_field_path))
237        }
238    }
239
240    let fields_str = format!("[{}]", all_final_fields.join(","));
241    let fields_tokens: TokenStream = fields_str
242        .parse()
243        .map_err(|_| format!("Generated code failed to parse: {}", fields_str))?;
244    let mut block_contents = all_check_functions;
245    block_contents.extend(fields_tokens);
246    Ok(TokenStream::from(TokenTree::Group(Group::new(
247        Delimiter::Brace,
248        block_contents,
249    ))))
250}
251
252/// Reclassifies a field-path segment that turned out to be a further
253/// `::`-qualified part of the type (`tests` in `crate::tests::Struct::field`)
254/// back into the type path. Returns `false` when the pending segment is not
255/// exactly one identifier, which has no valid representation as a type-path
256/// segment; the caller then reports the input as malformed instead of
257/// building a check around it.
258fn fold_field_into_struct_name(
259    field_tokens: Vec<TokenTree>,
260    struct_name_tokens: &mut Vec<TokenTree>,
261) -> bool {
262    let is_single_ident = matches!(field_tokens.as_slice(), [TokenTree::Ident(_)]);
263    if is_single_ident {
264        struct_name_tokens.extend("::".parse::<TokenStream>().expect("`::` always parses"));
265        struct_name_tokens.extend(field_tokens);
266    }
267    is_single_ident
268}
269
270/// Renders parsed path tokens back to their text as written, `~` included,
271/// so a caller replacing it with a delimiter still sees each collection step.
272fn render_text(tokens: &[TokenTree]) -> String {
273    tokens.iter().map(|token| token.to_string()).collect()
274}
275
276fn parse_multiple_fields(
277    group_stream: TokenStream,
278    found_struct_fields: &mut Vec<Vec<TokenTree>>,
279) -> Result<(), String> {
280    let mut current_field_tokens: Vec<TokenTree> = Vec::new();
281
282    for token_tree in group_stream.into_iter() {
283        match token_tree {
284            TokenTree::Ident(id) => current_field_tokens.push(TokenTree::Ident(id)),
285            TokenTree::Punct(punct) if punct == ',' => {
286                if current_field_tokens.is_empty() {
287                    return Err(format!(
288                        "Unexpected punctuation input for struct path group parameters: {:?}",
289                        punct
290                    ));
291                }
292                found_struct_fields.push(std::mem::take(&mut current_field_tokens));
293            }
294            TokenTree::Punct(punct) if punct == '.' => {
295                if current_field_tokens.is_empty() {
296                    return Err(format!(
297                        "Unexpected punctuation input for struct path group parameters: {:?}",
298                        punct
299                    ));
300                }
301                current_field_tokens.push(TokenTree::Punct(punct));
302            }
303            others => {
304                return Err(format!(
305                    "Unexpected input for struct path group parameters: {:?}",
306                    others
307                ));
308            }
309        }
310    }
311
312    if !current_field_tokens.is_empty() {
313        found_struct_fields.push(current_field_tokens);
314    }
315    Ok(())
316}
317
318#[proc_macro]
319pub fn path(struct_path_stream: TokenStream) -> TokenStream {
320    match path_impl(struct_path_stream) {
321        Ok(stream) => stream,
322        Err(message) => compile_error_for(message),
323    }
324}
325
326fn path_impl(struct_path_stream: TokenStream) -> Result<TokenStream, String> {
327    let mut current_struct_name_tokens: Vec<TokenTree> = Vec::new();
328
329    let mut opened_struct = false;
330    let mut colons_counter = 0;
331    let mut options_opened = false;
332
333    let mut current_field_tokens: Vec<TokenTree> = Vec::new();
334
335    let mut current_option_name: Option<String> = None;
336    let mut expect_option_value: bool = false;
337
338    let mut options: HashMap<String, String> = HashMap::new();
339    let mut found_structs: FoundStructs = Vec::new();
340
341    for token_tree in struct_path_stream.into_iter() {
342        match token_tree {
343            TokenTree::Ident(id) if current_struct_name_tokens.is_empty() => {
344                current_struct_name_tokens.push(TokenTree::Ident(id));
345            }
346            TokenTree::Punct(punct)
347                if !current_struct_name_tokens.is_empty()
348                    && !opened_struct
349                    && punct == ':'
350                    && colons_counter < 2 =>
351            {
352                colons_counter += 1;
353                if colons_counter > 1 {
354                    opened_struct = true;
355                }
356            }
357            TokenTree::Ident(id) if opened_struct => {
358                colons_counter = 0;
359                current_field_tokens.push(TokenTree::Ident(id));
360            }
361            TokenTree::Punct(punct)
362                if !current_struct_name_tokens.is_empty()
363                    && opened_struct
364                    && punct == ':'
365                    && colons_counter < 2 =>
366            {
367                colons_counter += 1;
368                opened_struct = false;
369                let field_tokens = std::mem::take(&mut current_field_tokens);
370                if !fold_field_into_struct_name(field_tokens, &mut current_struct_name_tokens) {
371                    return Err(format!(
372                        "Unexpected punctuation input for struct path group parameters: {:?}",
373                        punct
374                    ));
375                }
376            }
377            TokenTree::Punct(punct) if opened_struct && (punct == '.' || punct == '~') => {
378                if current_field_tokens.is_empty() {
379                    return Err(format!(
380                        "Unexpected punctuation input for struct path group parameters: {:?}",
381                        punct
382                    ));
383                }
384                current_field_tokens.push(TokenTree::Punct(punct));
385            }
386            TokenTree::Punct(punct) if !options_opened && opened_struct && punct == ',' => {
387                opened_struct = false;
388                colons_counter = 0;
389                if current_struct_name_tokens.is_empty() {
390                    return Err("Unexpected comma with empty definitions!".to_string());
391                }
392                if current_field_tokens.is_empty() {
393                    return Err(format!(
394                        "Unexpected comma with empty fields for {}!",
395                        render_text(&current_struct_name_tokens)
396                    ));
397                }
398                found_structs.push((
399                    std::mem::take(&mut current_struct_name_tokens),
400                    vec![std::mem::take(&mut current_field_tokens)],
401                ));
402            }
403            TokenTree::Punct(punct) if punct == ';' && opened_struct && !options_opened => {
404                options_opened = true;
405                opened_struct = false;
406            }
407            TokenTree::Ident(id) if options_opened && !expect_option_value => {
408                current_option_name = Some(id.to_string())
409            }
410            TokenTree::Ident(id) if options_opened && expect_option_value => {
411                expect_option_value = false;
412                match current_option_name.take() {
413                    Some(option_name) => {
414                        options.insert(option_name, id.to_string());
415                    }
416                    _ => {
417                        return Err("Wrong options format".to_string());
418                    }
419                }
420            }
421            TokenTree::Literal(lit) if options_opened && expect_option_value => {
422                expect_option_value = false;
423                match current_option_name.take() {
424                    Some(option_name) => {
425                        options.insert(option_name, unquote_literal(&lit)?);
426                    }
427                    _ => {
428                        return Err("Wrong options format".to_string());
429                    }
430                }
431            }
432            TokenTree::Punct(punct) if options_opened && punct == '=' => {
433                expect_option_value = true;
434            }
435            TokenTree::Punct(punct) if options_opened && punct == ',' => {
436                expect_option_value = false;
437            }
438            others => {
439                return Err(format!(
440                    "Unexpected input for struct path parameters: {:?}",
441                    others
442                ));
443            }
444        }
445    }
446
447    if !current_struct_name_tokens.is_empty() && !current_field_tokens.is_empty() {
448        found_structs.push((current_struct_name_tokens, vec![current_field_tokens]));
449    }
450
451    if found_structs.is_empty() {
452        return Err("Unexpected empty path definition!".to_string());
453    }
454
455    let all_check_functions = generate_checks_code_for(&found_structs);
456    // `~` marks a collection step and must become the path separator before
457    // options are applied, or a segment joined by `~` is treated as one
458    // field name instead of two and never gets the chosen delim or
459    // per-segment case conversion.
460    let full_field_path = found_structs
461        .iter()
462        .flat_map(|(_, fields)| fields.iter())
463        .map(|tokens| render_text(tokens))
464        .collect::<Vec<_>>()
465        .join(".")
466        .replace('~', ".");
467    let final_field_path = apply_options(&options, full_field_path)?;
468    let fields_str = format!("\"{}\"", final_field_path);
469    let fields_tokens: TokenStream = fields_str
470        .parse()
471        .map_err(|_| format!("Generated code failed to parse: {}", fields_str))?;
472    let mut block_contents = all_check_functions;
473    block_contents.extend(fields_tokens);
474    Ok(TokenStream::from(TokenTree::Group(Group::new(
475        Delimiter::Brace,
476        block_contents,
477    ))))
478}
479
480/// Builds the `const _: fn(&Type) = |t: &Type| { let _ = &t.field; };`
481/// check for every discovered `Type::field` pair. The user's type and field
482/// tokens keep their spans, so a type error is reported on the exact token
483/// that is wrong; each `~` becomes `.iter().next().unwrap().` spanned at that
484/// `~`, so a stepped-into type without `iter()` is blamed on the `~`.
485fn generate_checks_code_for(found_structs: &FoundStructs) -> TokenStream {
486    let mut all_check_functions = TokenStream::new();
487    for (struct_name_tokens, struct_fields) in found_structs {
488        let struct_name: TokenStream = struct_name_tokens.iter().cloned().collect();
489        for field_tokens in struct_fields {
490            let field_path: TokenStream = field_tokens
491                .iter()
492                .map(|token| match token {
493                    TokenTree::Punct(punct) if *punct == '~' => {
494                        fill(".iter().next().unwrap().", punct.span(), &[])
495                    }
496                    token => token.clone().into(),
497                })
498                .collect();
499            all_check_functions.extend(fill(
500                "const _: fn(&STRUCT) = |t: &STRUCT| { let _ = &t.FIELD; };",
501                Span::call_site(),
502                &[("STRUCT", &struct_name), ("FIELD", &field_path)],
503            ));
504        }
505    }
506    all_check_functions
507}
508
509/// Parses `template` into tokens spanned at `span`, replacing each ident
510/// named in `substitutions` with the given tokens, which keep their own
511/// spans. `template` must be valid Rust tokens.
512fn fill(template: &str, span: Span, substitutions: &[(&str, &TokenStream)]) -> TokenStream {
513    fn walk(stream: TokenStream, span: Span, subs: &[(&str, &TokenStream)]) -> TokenStream {
514        stream
515            .into_iter()
516            .map(|mut token| {
517                match &token {
518                    TokenTree::Ident(ident) => {
519                        let name = ident.to_string();
520                        if let Some((_, tokens)) = subs.iter().find(|(n, _)| *n == name) {
521                            return (*tokens).clone();
522                        }
523                    }
524                    TokenTree::Group(group) => {
525                        let stream = walk(group.stream(), span, subs);
526                        token = TokenTree::Group(Group::new(group.delimiter(), stream));
527                    }
528                    _ => {}
529                }
530                token.set_span(span);
531                token.into()
532            })
533            .collect()
534    }
535    walk(
536        template.parse().expect("templates are valid tokens"),
537        span,
538        substitutions,
539    )
540}
541
542/// Strips the surrounding quotes from a plain string (`"..."`) or char
543/// (`'.'`) literal. Escapes keep their source spelling because the value is
544/// pasted back into a string literal in the generated code; decoding them
545/// would change the output. Any other literal kind (numeric, byte string,
546/// byte char, raw string) is rejected, since its text has no such quotes to
547/// strip and would produce invalid generated code.
548fn unquote_literal(lit: &proc_macro::Literal) -> Result<String, String> {
549    let text = lit.to_string();
550    let bytes = text.as_bytes();
551    let is_quoted = bytes.len() >= 2
552        && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
553            || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''));
554    if is_quoted {
555        Ok(text[1..text.len() - 1].to_string())
556    } else {
557        Err(format!(
558            "Unsupported option value {}: expected a string (\"...\") or char ('.') literal",
559            text
560        ))
561    }
562}
563
564fn apply_options(options: &HashMap<String, String>, field_path: String) -> Result<String, String> {
565    let delim = options
566        .get("delim")
567        .as_ref()
568        .map(|s| s.as_str())
569        .unwrap_or(".");
570    let case = options.get("case");
571    let segments = field_path
572        .split('.')
573        .map(|field_name| {
574            if let Some(case_value) = case {
575                match case_value.as_str() {
576                    "camel" => Ok(field_name.from_case(Case::Snake).to_case(Case::Camel)),
577                    "pascal" => Ok(field_name.from_case(Case::Snake).to_case(Case::Pascal)),
578                    another => Err(format!("Unknown case is specified: {}", another)),
579                }
580            } else {
581                Ok(field_name.to_string())
582            }
583        })
584        .collect::<Result<Vec<String>, String>>()?;
585    Ok(segments.join(delim))
586}