Skip to main content

oxiplate_derive/
lib.rs

1#![cfg_attr(feature = "better-internal-errors", feature(proc_macro_diagnostic))]
2#![cfg_attr(feature = "external-template-spans", feature(proc_macro_expand))]
3#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
4#![doc(issue_tracker_base_url = "https://github.com/0b10011/Oxiplate/issues/")]
5#![doc(test(no_crate_inject))]
6#![doc(test(attr(deny(warnings))))]
7#![doc = include_str!("../README.md")]
8
9mod config;
10mod parser;
11mod source;
12mod state;
13mod template;
14mod tokenizer;
15
16use std::collections::{HashMap, VecDeque};
17#[cfg(not(feature = "external-template-spans"))]
18use std::fs;
19use std::io;
20use std::path::{Path, PathBuf};
21
22use proc_macro::TokenStream;
23use proc_macro2::Span;
24use quote::{quote, quote_spanned};
25use syn::parse::Parse;
26use syn::spanned::Spanned;
27use syn::token::Colon;
28use syn::{
29    Attribute, Data, DeriveInput, Expr, ExprLit, Ident, Lit, LitStr, MetaList, MetaNameValue,
30};
31
32use crate::config::OptimizedRenderer;
33pub(crate) use crate::source::Source;
34use crate::source::SourceOwned;
35pub(crate) use crate::state::State;
36use crate::state::{LocalVariables, build_config};
37use crate::template::{TokenSlice, parse, tokens_and_eof};
38
39type BuiltTokens = (proc_macro2::TokenStream, usize);
40
41/// Derives the `::std::fmt::Display` implementation for a template's struct.
42///
43/// # Usage
44///
45/// See the [getting started docs](https://0b10011.io/oxiplate/getting-started.html) for more information.
46///
47/// ```
48/// # use oxiplate_derive::Oxiplate;
49/// #[derive(Oxiplate)]
50/// #[oxiplate = "example.html.oxip"]
51/// struct Homepage {
52///     // ...
53/// #    site_name: &'static str,
54/// #    title: &'static str,
55/// #    message: &'static str,
56/// }
57///
58/// fn main() {
59///     let homepage = Homepage {
60///         // ...
61/// #        site_name: "Oxiplate Documentation",
62/// #        title: "Derive Macro Description",
63/// #        message: "Hello world!",
64///     };
65///     print!("{}", homepage);
66/// }
67/// ```
68///
69/// or:
70///
71/// ```
72/// # use oxiplate_derive::Oxiplate;
73/// #[derive(Oxiplate)]
74/// #[oxiplate_inline(
75///     "{-}
76/// <!DOCTYPE html>
77/// <title>{{ title }} - {{ site_name }}</title>
78/// <h1>{{ title }}</h1>
79/// <p>{{ message }}</p>
80/// "
81/// )]
82/// struct Homepage {
83///     // ...
84/// #    site_name: &'static str,
85/// #    title: &'static str,
86/// #    message: &'static str,
87/// }
88///
89/// fn main() {
90///     let homepage = Homepage {
91///         // ...
92/// #        site_name: "Oxiplate Documentation",
93/// #        title: "Derive Macro Description",
94/// #        message: "Hello world!",
95///     };
96///     print!("{}", homepage);
97/// }
98/// ```
99#[proc_macro_derive(
100    Oxiplate,
101    attributes(oxiplate, oxiplate_inline, oxiplate_extends, oxiplate_include)
102)]
103pub fn oxiplate(input: TokenStream) -> TokenStream {
104    #[cfg(feature = "_unreachable")]
105    let input = {
106        // Compare tokens as a string to avoid having to parse unnecessarily.
107        if input.to_string()
108            == r#"#[oxiplate_inline("hello world")] struct UnreachableUnparseableInput;"#
109                .to_string()
110        {
111            // Unparseable code that would otherwise fail before reaching this point.
112            quote! { struct 19foo; }.into()
113        } else {
114            input
115        }
116    };
117
118    oxiplate_internal(input, &VecDeque::from([&HashMap::new()])).0
119}
120
121/// Internal derive function that allows for block token streams to be passed in.
122pub(crate) fn oxiplate_internal(
123    input: TokenStream,
124    blocks: &VecDeque<&HashMap<&str, (BuiltTokens, Option<BuiltTokens>)>>,
125) -> (TokenStream, usize) {
126    let input = match syn::parse(input) {
127        Ok(input) => input,
128        Err(err) => return (err.to_compile_error().into(), 0),
129    };
130    parse_input(&input, blocks)
131}
132
133/// Parses the template information from the attributes
134/// and data information from the associated struct.
135/// Returns the token stream for the `::std::fmt::Display` implementation for the struct.
136fn parse_input(
137    input: &DeriveInput,
138    blocks: &VecDeque<&HashMap<&str, (BuiltTokens, Option<BuiltTokens>)>>,
139) -> (TokenStream, usize) {
140    let DeriveInput {
141        ident, generics, ..
142    } = &input;
143
144    let (template, estimated_length, template_type, optimized_renderer): (
145        proc_macro2::TokenStream,
146        usize,
147        TemplateType,
148        OptimizedRenderer,
149    ) = match parse_template_and_data(input, blocks) {
150        Ok(data) => data,
151        Err((err, template_type, optimized_renderer)) => (
152            err.to_compile_error(),
153            0,
154            template_type.unwrap_or(TemplateType::Inline),
155            optimized_renderer,
156        ),
157    };
158
159    // Internally, the template is used directly instead of via `Display`/`Render`.
160    if let TemplateType::Extends | TemplateType::Include = template_type {
161        return (template.into(), estimated_length);
162    }
163
164    let where_clause = &generics.where_clause;
165    let expanded = if *optimized_renderer {
166        #[cfg(not(feature = "_oxiplate"))]
167        quote! {
168            compile_error!(
169                "`optimized_renderer` config option specified in `/oxiplate.toml` is only available when using `oxiplate`. It looks like `oxiplate-derive` is being used directly instead."
170            );
171            impl #generics ::core::fmt::Display for #ident #generics #where_clause {
172                fn fmt(&self, oxiplate_formatter: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
173                    let string = {
174                        extern crate alloc;
175
176                        use ::core::fmt::Write as _;
177                        let mut string = alloc::string::String::with_capacity(#estimated_length);
178                        let oxiplate_formatter = &mut string;
179                        #template
180                        string
181                    };
182                    oxiplate_formatter.write_str(&string)
183                }
184            }
185        }
186
187        #[cfg(feature = "_oxiplate")]
188        quote! {
189            impl #generics ::core::fmt::Display for #ident #generics #where_clause {
190                fn fmt(&self, oxiplate_formatter: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
191                    ::oxiplate::Render::render_into(self, oxiplate_formatter)
192                }
193            }
194            impl #generics ::oxiplate::Render for #ident #generics #where_clause {
195                const ESTIMATED_LENGTH: usize = #estimated_length;
196
197                #[inline]
198                fn render_into<W: ::core::fmt::Write>(&self, oxiplate_formatter: &mut W) -> ::core::fmt::Result {
199                    extern crate alloc;
200
201                    use ::core::fmt::Write as _;
202                    use ::oxiplate::{ToCowStr as _, UnescapedText as _};
203                    #template
204                    Ok(())
205                }
206            }
207        }
208    } else {
209        quote! {
210            impl #generics ::core::fmt::Display for #ident #generics #where_clause {
211                fn fmt(&self, oxiplate_formatter: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
212                    let string = {
213                        extern crate alloc;
214
215                        use ::core::fmt::Write as _;
216                        let mut string = alloc::string::String::with_capacity(#estimated_length);
217                        let oxiplate_formatter = &mut string;
218                        #template
219                        string
220                    };
221                    oxiplate_formatter.write_str(&string)
222                }
223            }
224        }
225    };
226
227    (TokenStream::from(expanded), estimated_length)
228}
229
230type ParsedTemplate = (
231    proc_macro2::TokenStream,
232    usize,
233    TemplateType,
234    OptimizedRenderer,
235);
236
237fn parse_template_and_data(
238    input: &DeriveInput,
239    blocks: &VecDeque<&HashMap<&str, (BuiltTokens, Option<BuiltTokens>)>>,
240) -> Result<ParsedTemplate, (syn::Error, Option<TemplateType>, OptimizedRenderer)> {
241    // Build the shared config from the `oxiplate.toml` file.
242    let config =
243        build_config(input).map_err(|(err, optimized_renderer)| (err, None, optimized_renderer))?;
244
245    let DeriveInput {
246        attrs, ident, data, ..
247    } = &input;
248
249    // Ensure the data is a struct
250    match data {
251        Data::Struct(_struct_item) => (),
252        _ => {
253            return Err((
254                syn::Error::new(input.span(), "Expected a struct"),
255                None,
256                config.optimized_renderer,
257            ));
258        }
259    }
260
261    let (attr, template_type) = parse_template_type(attrs, ident.span())
262        .map_err(|err: syn::Error| (err, None, config.optimized_renderer.clone()))?;
263
264    let optimized_renderer = config.optimized_renderer.clone();
265
266    let mut state = State {
267        local_variables: LocalVariables::new(),
268        inferred_escaper_group: None,
269        default_escaper_group: None,
270        failed_to_set_default_escaper_group: false,
271        config,
272        blocks,
273        has_content: false,
274    };
275
276    let parsed_tokens = parse_source_tokens(attr, &template_type, &mut state);
277    let (template, estimated_length): BuiltTokens = process_parsed_tokens(
278        parsed_tokens,
279        &mut state,
280        #[cfg(any(feature = "_oxiplate", feature = "external-template-spans"))]
281        &template_type,
282    )
283    .map_err(|err: syn::Error| (err, Some(template_type.clone()), optimized_renderer.clone()))?;
284
285    Ok((
286        template,
287        estimated_length,
288        template_type,
289        optimized_renderer,
290    ))
291}
292
293type ParsedTokens = Result<
294    (
295        Span,
296        proc_macro2::TokenStream,
297        Option<PathBuf>,
298        Option<String>,
299    ),
300    ParsedEscaperError,
301>;
302
303fn process_parsed_tokens<'a>(
304    parsed_tokens: ParsedTokens,
305    state: &'a mut State<'a>,
306    #[cfg(any(feature = "_oxiplate", feature = "external-template-spans"))]
307    template_type: &TemplateType,
308) -> Result<BuiltTokens, syn::Error> {
309    match parsed_tokens {
310        #[cfg(feature = "_oxiplate")]
311        Err(ParsedEscaperError::EscaperNotFound((escaper, span))) => {
312            let mut available_escaper_groups = state
313                .config
314                .escaper_groups
315                .keys()
316                .map(|key| &**key)
317                .collect::<Vec<&str>>();
318            available_escaper_groups.sort_unstable();
319            let available_escaper_groups = available_escaper_groups.join(", ");
320            let template = match template_type {
321                TemplateType::Path | TemplateType::Extends | TemplateType::Include => {
322                    internal_error!(
323                        span.unwrap(),
324                        "Unregistered file extension causing `EscaperNotFound` error",
325                        .help(format!("Extension found: {escaper}"))
326                        .help(format!("Registered escaper groups: {available_escaper_groups}"))
327                    );
328                }
329                TemplateType::Inline => {
330                    let available_escaper_groups = LitStr::new(&available_escaper_groups, span);
331                    quote_spanned! {span=> compile_error!(concat!("The specified escaper group `", #escaper, "` is not registered in `/oxiplate.toml`. Registered escaper groups: ", #available_escaper_groups)); }
332                }
333            };
334            Ok((template, 0))
335        }
336        Err(ParsedEscaperError::ParseError(compile_error)) => Ok((compile_error, 0)),
337        Ok((span, input, origin, inferred_escaper_group_name)) => {
338            let code = parse_code_literal(
339                &input.into(),
340                #[cfg(feature = "external-template-spans")]
341                template_type,
342                #[cfg(feature = "external-template-spans")]
343                span,
344            )?;
345
346            if let Some(inferred_escaper_group_name) = &inferred_escaper_group_name {
347                state.inferred_escaper_group = Some((
348                    inferred_escaper_group_name.to_owned(),
349                    state
350                        .config
351                        .escaper_groups
352                        .get(inferred_escaper_group_name)
353                        .expect("Escaper group should have already been checked for existence")
354                        .clone(),
355                ));
356            }
357
358            // Build the source.
359            let owned_source = SourceOwned::new(&code, span, origin);
360            let source = Source::new(&owned_source);
361            let (tokens, eof) = tokens_and_eof(source);
362            let tokens = TokenSlice::new(&tokens, &eof);
363
364            // Build the `::std::fmt::Display` implementation for the struct.
365            // (This is where the template is actually parsed.)
366            Ok(parse(state, tokens))
367        }
368    }
369}
370
371#[derive(Clone)]
372enum TemplateType {
373    Path,
374    Inline,
375    Extends,
376    Include,
377}
378
379/// Parse the attributes to figure out what type of template this struct references.
380fn parse_template_type(
381    attrs: &Vec<Attribute>,
382    span: Span,
383) -> Result<(&Attribute, TemplateType), syn::Error> {
384    for attr in attrs {
385        let path = attr.path();
386        let template_type = if path.is_ident("oxiplate_inline") {
387            TemplateType::Inline
388        } else if path.is_ident("oxiplate_extends") {
389            TemplateType::Extends
390        } else if path.is_ident("oxiplate_include") {
391            TemplateType::Include
392        } else if path.is_ident("oxiplate") {
393            TemplateType::Path
394        } else {
395            continue;
396        };
397
398        return Ok((attr, template_type));
399    }
400
401    Err(syn::Error::new(
402        span,
403        r#"Expected an attribute named `oxiplate_inline` or `oxiplate` to specify the template:
404External: #[oxiplate = "path/to/template/from/templates/directory.html.oxip"]
405Internal: #[oxiplate_inline(html: "{{ your_var }}")]"#,
406    ))
407}
408
409fn parse_code_literal(
410    input: &TokenStream,
411    #[cfg(feature = "external-template-spans")] template_type: &TemplateType,
412    #[cfg(feature = "external-template-spans")] span: Span,
413) -> Result<LitStr, syn::Error> {
414    #[cfg(feature = "external-template-spans")]
415    let input = {
416        let invalid_attribute_message = match template_type {
417            TemplateType::Path | TemplateType::Inline => {
418                r#"Must provide either an external or internal template:
419External: #[oxiplate = "path/to/template/from/templates/directory.html.oxip"]
420Internal: #[oxiplate_inline(html: "{{ your_var }}")]"#
421            }
422            TemplateType::Extends => {
423                r#"Must provide a path to a template that exists. E.g., `{% extends "path/to/template.html.oxip" %}`"#
424            }
425            TemplateType::Include => {
426                r#"Must provide a path to a template that exists. E.g., `{% include "path/to/template.html.oxip" %}`"#
427            }
428        };
429
430        // Expand macros
431        let input = input.expand_expr();
432        if input.is_err() {
433            return Err(syn::Error::new(span, invalid_attribute_message));
434        }
435        input.unwrap()
436    };
437
438    #[cfg(not(feature = "external-template-spans"))]
439    let input = input.clone();
440
441    // Parse the string and token out of the expanded expression
442    let parser = |input: syn::parse::ParseStream| input.parse::<LitStr>();
443    let code = syn::parse::Parser::parse(parser, input)?;
444    Ok(code)
445}
446
447fn parse_source_tokens(
448    attr: &Attribute,
449    template_type: &TemplateType,
450    #[cfg_attr(not(feature = "_oxiplate"), allow(unused_variables))] state: &mut State,
451) -> ParsedTokens {
452    match template_type {
453        TemplateType::Inline => parse_source_tokens_for_inline(attr, state),
454        TemplateType::Path | TemplateType::Extends | TemplateType::Include => {
455            parse_source_tokens_for_path(attr, state)
456        }
457    }
458}
459
460/// An inline template, with or without escaper information.
461enum Template {
462    WithEscaper(TemplateWithEscaper),
463    WithoutEscaper(TemplateWithoutEscaper),
464}
465
466impl Parse for Template {
467    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
468        let lookahead = input.lookahead1();
469        if lookahead.peek(Ident) {
470            input.parse().map(Template::WithEscaper)
471        } else {
472            input.parse().map(Template::WithoutEscaper)
473        }
474    }
475}
476
477/// An inline template with escaper information.
478struct TemplateWithEscaper {
479    #[cfg_attr(not(feature = "_oxiplate"), allow(dead_code))]
480    escaper: Ident,
481    #[allow(dead_code)]
482    colon: Colon,
483    #[cfg_attr(not(feature = "_oxiplate"), allow(dead_code))]
484    template: Expr,
485}
486
487impl Parse for TemplateWithEscaper {
488    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
489        Ok(TemplateWithEscaper {
490            escaper: input.parse()?,
491            colon: input.parse()?,
492            template: input.parse()?,
493        })
494    }
495}
496
497/// An inline template without escaper information.
498struct TemplateWithoutEscaper {
499    template: Expr,
500}
501
502impl Parse for TemplateWithoutEscaper {
503    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
504        Ok(TemplateWithoutEscaper {
505            template: input.parse()?,
506        })
507    }
508}
509
510#[cfg_attr(not(feature = "external-template-spans"), derive(Debug))]
511enum ParsedEscaperError {
512    #[cfg(feature = "_oxiplate")]
513    EscaperNotFound((String, Span)),
514    ParseError(proc_macro2::TokenStream),
515}
516
517#[cfg_attr(not(feature = "_oxiplate"), allow(clippy::unnecessary_wraps))]
518fn parse_source_tokens_for_inline(
519    attr: &Attribute,
520    #[cfg_attr(not(feature = "_oxiplate"), allow(unused_variables))] state: &mut State,
521) -> ParsedTokens {
522    match &attr.meta {
523        syn::Meta::Path(path) => {
524            let span = path.span();
525            Err(ParsedEscaperError::ParseError(quote_spanned! {span=>
526                compile_error!(r#"Must provide either an external or internal template:
527External: #[oxiplate = "/path/to/template/from/templates/directory.txt.oxip"]
528Internal: #[oxiplate_inline(html: "{{ your_var }}")]"#);
529            }))
530        }
531        syn::Meta::List(MetaList {
532            path: _,
533            delimiter: _,
534            tokens,
535        }) => match syn::parse2::<Template>(tokens.clone()) {
536            #[cfg(not(feature = "_oxiplate"))]
537            Ok(Template::WithEscaper(template)) => {
538                let span = template.escaper.span();
539                Err(ParsedEscaperError::ParseError(quote_spanned! {span=>
540                    compile_error!("Escaping requires the `oxiplate` library, but you appear to be using \
541                 `oxiplate-derive` directly. Replacing `oxiplate-derive` with `oxiplate` in the \
542                 dependencies should fix this issue, although you may need to turn off some \
543                 default features if you want it to work the same way.");
544                }))
545            }
546            #[cfg(feature = "_oxiplate")]
547            Ok(Template::WithEscaper(TemplateWithEscaper {
548                escaper,
549                colon: _,
550                template,
551            })) => {
552                let span = template.span();
553
554                let escaper_name = escaper.to_string();
555                if !state.config.escaper_groups.contains_key(&escaper_name) {
556                    return Err(ParsedEscaperError::EscaperNotFound((
557                        escaper_name,
558                        escaper.span(),
559                    )));
560                }
561
562                Ok((
563                    span,
564                    quote::quote_spanned!(span=> #template),
565                    None,
566                    Some(escaper_name),
567                ))
568            }
569            Ok(Template::WithoutEscaper(TemplateWithoutEscaper { template })) => {
570                let span = template.span();
571                Ok((span, quote::quote_spanned!(span=> #template), None, None))
572            }
573            Err(error) => {
574                let span = error.span();
575                let compile_error = error.to_compile_error();
576                Err(ParsedEscaperError::ParseError(quote_spanned! {span=>
577                    compile_error!("Failed to parse inline template. Should look something like:\n#[oxiplate_inline(html: \"{{ your_var }}\")]");
578                    #compile_error
579                }))
580            }
581        },
582        syn::Meta::NameValue(meta) => {
583            let span = meta.span();
584            Err(ParsedEscaperError::ParseError(quote_spanned! {span=>
585                compile_error!("Incorrect syntax for inline template. Should look something like:\n#[oxiplate_inline(html: \"{{ your_var }}\")]");
586            }))
587        }
588    }
589}
590
591/// Build the absolute template directory
592/// from the package's directory and provided relative template directory.
593fn templates_dir(span: Span) -> Result<PathBuf, ParsedEscaperError> {
594    let default_template_dir = String::from("templates");
595
596    let (specified_templates_dir, using_default_template_dir) =
597        if let Ok(templates_dir) = ::std::env::var("OXIP_TEMPLATE_DIR") {
598            (templates_dir, false)
599        } else {
600            (default_template_dir, true)
601        };
602    let root = PathBuf::from(
603        ::std::env::var("CARGO_MANIFEST_DIR_OVERRIDE")
604            .or(::std::env::var("CARGO_MANIFEST_DIR"))
605            .expect("`CARGO_MANIFEST_DIR` should be present"),
606    );
607
608    // Path::join() doesn't play well with absolute paths (for our use case).
609    root
610        .append_path(&specified_templates_dir, false)
611        .map_err(|err| -> ParsedEscaperError {
612            match err {
613                AppendPathError::DoesNotExist(path_buf) => {
614                    let path_buf = path_buf.to_string_lossy();
615                    ParsedEscaperError::ParseError(quote_spanned! {span=>
616                        compile_error!(concat!("Template directory `", #path_buf, "` not found."));
617                    })
618                },
619                AppendPathError::IsSymlink(path_buf) => {
620                    let path_buf = path_buf.to_string_lossy();
621                    ParsedEscaperError::ParseError(quote_spanned! {span=>
622                        compile_error!(concat!("Template directory `", #path_buf, "` cannot be a symlink."));
623                    })
624                },
625                AppendPathError::CanonicalizeError(path_buf, error) => {
626                    if using_default_template_dir {
627                        unreachable!(
628                            "Failed to normalize default template directory. Original error: {error}",
629                        );
630                    } else {
631                        let path_buf = path_buf.to_string_lossy();
632                        let error = error.to_string();
633                        ParsedEscaperError::ParseError(quote_spanned! {span=>
634                            compile_error!(concat!("Failed to normalize `", #path_buf, "`. Original error: ", #error));
635                        })
636                    }
637                },
638                AppendPathError::PrefixNotPresent { prefix, final_path } => {
639                    if using_default_template_dir {
640                        let _ = prefix;
641                        let _ = final_path;
642                        unreachable!(
643                            "`default_template_dir` variable in `oxiplate-derive` code must be a relative \
644                            path; example: 'templates' instead of '/templates'. Provided: {specified_templates_dir}",
645                        );
646                    } else {
647                        let prefix = prefix.to_string_lossy();
648                        let final_path = final_path.to_string_lossy();
649                        ParsedEscaperError::ParseError(quote_spanned! {span=>
650                            compile_error!(concat!(
651                                "`OXIP_TEMPLATE_DIR` environment variable must be a relative path that resolves under `",
652                                #prefix,
653                                "`; example: 'templates' instead of '/templates'. Provided: ",
654                                #final_path
655                            ));
656                        })
657                    }
658                },
659                AppendPathError::NotDirectory(path_buf) => {
660                    let path_buf = path_buf.to_string_lossy();
661                    ParsedEscaperError::ParseError(quote_spanned! {span=>
662                        compile_error!(concat!("Template directory `", #path_buf, "` was not a directory."));
663                    })
664                },
665                AppendPathError::NotFile(_path_buf) => unreachable!("Directory is expected, not a file"),
666            }
667        })
668}
669
670/// Build the template path.
671fn template_path(path: &LitStr, attr_span: Span) -> Result<PathBuf, ParsedEscaperError> {
672    let templates_dir = templates_dir(attr_span)?;
673
674    // Path::join() doesn't play well with absolute paths (for our use case).
675    let span = path.span();
676
677    templates_dir
678            .append_path(path.value(), true)
679            .map_err(|err| -> ParsedEscaperError {
680                match err {
681                    AppendPathError::DoesNotExist(path_buf) => {
682                        let path_buf = path_buf.to_string_lossy();
683                        ParsedEscaperError::ParseError(quote_spanned! {span=>
684                            compile_error!(concat!("Path does not exist: `", #path_buf, "`"));
685                        })
686                    },
687                    AppendPathError::IsSymlink(path_buf) => {
688                        let path_buf = path_buf.to_string_lossy();
689                        ParsedEscaperError::ParseError(quote_spanned! {span=>
690                            compile_error!(concat!("Symlinks are not allowed for template paths: `", #path_buf, "`"));
691                        })
692                    },
693                    AppendPathError::CanonicalizeError(path_buf, error) => {
694                        let path_buf = path_buf.to_string_lossy();
695                        let error = error.to_string();
696                        ParsedEscaperError::ParseError(quote_spanned! {span=>
697                            compile_error!(concat!("Failed to canonicalize path: `", #path_buf, "`. Original error: ", #error));
698                        })
699                    },
700                    AppendPathError::PrefixNotPresent { prefix, final_path } => {
701                        let prefix = prefix.to_string_lossy();
702                        let final_path = final_path.to_string_lossy();
703                        ParsedEscaperError::ParseError(quote_spanned! {span=>
704                            compile_error!(concat!("Template path `", #final_path, "` not within template directory `", #prefix, "`"));
705                        })
706                    },
707                    AppendPathError::NotDirectory(_path_buf) => unreachable!("File is expected, not a directory"),
708                    AppendPathError::NotFile(path_buf) => {
709                        let path_buf = path_buf.to_string_lossy();
710                        ParsedEscaperError::ParseError(quote_spanned! {span=>
711                            compile_error!(concat!("Path is not a file: `", #path_buf, "`"));
712                        })
713                    },
714                }
715            })
716}
717
718fn parse_source_tokens_for_path(
719    attr: &Attribute,
720    #[cfg_attr(not(feature = "_oxiplate"), allow(unused_variables))] state: &mut State,
721) -> ParsedTokens {
722    let syn::Meta::NameValue(MetaNameValue {
723        path: _,
724        eq_token: _,
725        value: Expr::Lit(ExprLit {
726            attrs: _,
727            lit: Lit::Str(path),
728        }),
729    }) = &attr.meta
730    else {
731        let span = attr.span();
732        return Err(ParsedEscaperError::ParseError(quote_spanned! {span=>
733            compile_error!("Incorrect syntax for external template. Should look something like:\n#[oxiplate = \"/path/to/template/from/templates/directory.txt.oxip\"]");
734        }));
735    };
736
737    let full_path = template_path(path, attr.span())?;
738
739    let span = path.span();
740
741    #[cfg(feature = "_oxiplate")]
742    let mut escaper_name: Option<String> = None;
743
744    // Infer the escaper from the template's file extension.
745    // Only works when using `oxiplate` rather than `oxiplate-derive` directly.
746    #[cfg(feature = "_oxiplate")]
747    if *state.config.infer_escaper_group_from_file_extension {
748        // Get the template's file extension,
749        // but ignore `.oxip`.
750        let path_value = full_path.to_string_lossy();
751        let mut extensions = path_value.split('.');
752        let mut extension = extensions.next_back();
753        if extension == Some("oxip") {
754            extension = extensions.next_back();
755        }
756
757        // `raw` is a special keyword that should be ignored.
758        if extension == Some("raw") {
759            extension = None;
760        }
761
762        // Set the inferred escaper group if the extension mapped to one.
763        if let Some(extension) = extension {
764            if state.config.escaper_groups.contains_key(extension) {
765                escaper_name = Some(extension.to_owned());
766            } else {
767                // `None` will normally be returned for the escaper,
768                // but there's a match arm that is unreachable because of it.
769                #[cfg(feature = "_unreachable")]
770                return Err(ParsedEscaperError::EscaperNotFound((
771                    extension.to_string(),
772                    span,
773                )));
774            }
775        }
776    }
777
778    #[cfg(feature = "external-template-spans")]
779    let tokens = {
780        let path = syn::LitStr::new(&full_path.to_string_lossy(), span);
781        quote::quote_spanned!(span=> include_str!(#path))
782    };
783
784    #[cfg(not(feature = "external-template-spans"))]
785    let tokens = {
786        let template_string = fs::read_to_string(&full_path)
787            .expect("Template has already been checked to exist; perhaps a permissions issue?");
788
789        quote_spanned! {span=> #template_string }
790    };
791
792    #[cfg(feature = "_oxiplate")]
793    return Ok((span, tokens, Some(full_path), escaper_name));
794
795    #[cfg(not(feature = "_oxiplate"))]
796    Ok((span, tokens, Some(full_path), None))
797}
798
799/// Error when attempting to append one path onto another one.
800enum AppendPathError {
801    /// Path does not exist.
802    DoesNotExist(PathBuf),
803    /// Path is a symlink instead of a file or directory.
804    IsSymlink(PathBuf),
805    /// Canonicalizing the path failed.
806    /// More information in the IO error.
807    CanonicalizeError(PathBuf, io::Error),
808    /// Final path is outside the directory being appended to.
809    /// Absolute paths (`/templates`) or `..` directories can cause this.
810    PrefixNotPresent {
811        prefix: PathBuf,
812        final_path: PathBuf,
813    },
814    /// Path is not a directory (probably a file).
815    NotDirectory(PathBuf),
816    /// Path is not a file (probably a directory).
817    NotFile(PathBuf),
818}
819
820/// Trait to append one path onto another.
821trait AppendPath<P: AsRef<Path>> {
822    /// Append a path onto an existing path.
823    /// Will fail if the new path is outside of the existing path.
824    fn append_path(&self, suffix: P, expecting_file: bool) -> Result<Self, AppendPathError>
825    where
826        Self: Sized;
827}
828impl<P: AsRef<Path>> AppendPath<P> for PathBuf {
829    fn append_path(&self, suffix: P, expecting_file: bool) -> Result<Self, AppendPathError> {
830        // Append the suffix to the main path
831        let new_path = self.join(suffix);
832
833        // Do some checks before canonicalizing
834        // in order to return better error messages.
835        if !new_path.starts_with(self) {
836            return Err(AppendPathError::PrefixNotPresent {
837                prefix: self.clone(),
838                final_path: new_path,
839            });
840        } else if !new_path.exists() {
841            return Err(AppendPathError::DoesNotExist(new_path));
842        } else if new_path.is_symlink() {
843            return Err(AppendPathError::IsSymlink(new_path));
844        }
845
846        // Canonicalize to ensure prefix check later is against final path.
847        let new_path = new_path
848            .canonicalize()
849            .map_err(|error| AppendPathError::CanonicalizeError(new_path, error))?;
850
851        // Ensure path is within the original directory
852        // and the new path is a file/directory.
853        if !new_path.starts_with(self) {
854            return Err(AppendPathError::PrefixNotPresent {
855                prefix: self.clone(),
856                final_path: new_path,
857            });
858        } else if !expecting_file && !new_path.is_dir() {
859            return Err(AppendPathError::NotDirectory(new_path));
860        } else if expecting_file && !new_path.is_file() {
861            return Err(AppendPathError::NotFile(new_path));
862        }
863
864        Ok(new_path)
865    }
866}
867
868macro_rules! internal_error {
869    ($span:expr, $message:expr) => {{
870        internal_error!($span, $message, );
871    }};
872    ($span:expr, $message:expr, $($help:tt)*) => {{
873        let message = $message;
874
875        #[cfg(not(feature = "better-internal-errors"))]
876        unreachable!(
877            "Internal Oxiplate error. Enable `better-internal-errors` feature for an \
878             easier-to-debug error message. Error: {}",
879            message
880        );
881
882        #[cfg(feature = "better-internal-errors")]
883        {
884            let url = format!(
885                "https://github.com/0b10011/oxiplate/issues/new?title={}&labels=internal+error&body={}",
886                crate::encode_query_value(&message),
887                crate::encode_query_value(
888                    r"Error:
889
890```text
891PASTE_FULL_ERROR_HERE
892```
893
894Template:
895
896```
897PASTE_TEMPLATE_HERE
898```"
899                ),
900            );
901
902            ::proc_macro::Diagnostic::spanned(
903                $span,
904                ::proc_macro::Level::Error,
905                format!("Internal Oxiplate error: {}", &message),
906            )
907            .help(format!("Please open an issue: {url}"))
908            $($help)*
909            .emit();
910
911            unreachable!("Internal Oxiplate error. See previous error for more information.");
912        }
913    }};
914}
915
916/// Hacky URL query value encoder for internal errors based on:
917/// <https://github.com/servo/rust-url/blob/b381851473a5a516f58f2cfc9db300abf7a4eaa7/form_urlencoded/src/lib.rs#L138-L163>
918#[cfg(feature = "better-internal-errors")]
919fn encode_query_value(input: &str) -> String {
920    let mut output = String::with_capacity(input.len());
921
922    for byte in input.as_bytes() {
923        match byte {
924            b' ' => output.push('+'),
925            b'*' | b'-' | b'.' | b'0'..=b'9' | b'A'..=b'Z' | b'_' | b'a'..=b'z' => output.push_str(
926                ::std::str::from_utf8(&[*byte]).expect("Error messages should always be UTF8-safe"),
927            ),
928            _ => output.push_str(percent_encode_byte(*byte)),
929        }
930    }
931
932    output
933}
934
935/// Return the percent-encoding of the given byte taken from:
936/// <https://github.com/servo/rust-url/blob/a66f4220895c3cc84ae623c218466710eb3a812f/percent_encoding/src/lib.rs#L60-L97>
937#[inline]
938#[cfg(feature = "better-internal-errors")]
939fn percent_encode_byte(byte: u8) -> &'static str {
940    static ENC_TABLE: &[u8; 768] = b"\
941        %00%01%02%03%04%05%06%07%08%09%0A%0B%0C%0D%0E%0F\
942        %10%11%12%13%14%15%16%17%18%19%1A%1B%1C%1D%1E%1F\
943        %20%21%22%23%24%25%26%27%28%29%2A%2B%2C%2D%2E%2F\
944        %30%31%32%33%34%35%36%37%38%39%3A%3B%3C%3D%3E%3F\
945        %40%41%42%43%44%45%46%47%48%49%4A%4B%4C%4D%4E%4F\
946        %50%51%52%53%54%55%56%57%58%59%5A%5B%5C%5D%5E%5F\
947        %60%61%62%63%64%65%66%67%68%69%6A%6B%6C%6D%6E%6F\
948        %70%71%72%73%74%75%76%77%78%79%7A%7B%7C%7D%7E%7F\
949        %80%81%82%83%84%85%86%87%88%89%8A%8B%8C%8D%8E%8F\
950        %90%91%92%93%94%95%96%97%98%99%9A%9B%9C%9D%9E%9F\
951        %A0%A1%A2%A3%A4%A5%A6%A7%A8%A9%AA%AB%AC%AD%AE%AF\
952        %B0%B1%B2%B3%B4%B5%B6%B7%B8%B9%BA%BB%BC%BD%BE%BF\
953        %C0%C1%C2%C3%C4%C5%C6%C7%C8%C9%CA%CB%CC%CD%CE%CF\
954        %D0%D1%D2%D3%D4%D5%D6%D7%D8%D9%DA%DB%DC%DD%DE%DF\
955        %E0%E1%E2%E3%E4%E5%E6%E7%E8%E9%EA%EB%EC%ED%EE%EF\
956        %F0%F1%F2%F3%F4%F5%F6%F7%F8%F9%FA%FB%FC%FD%FE%FF\
957        ";
958
959    let index = usize::from(byte) * 3;
960    // SAFETY: ENC_TABLE is ascii-only, so any subset of it should be
961    // ascii-only too, which is valid utf8.
962    unsafe { str::from_utf8_unchecked(&ENC_TABLE[index..index + 3]) }
963}
964
965pub(crate) use internal_error;