Skip to main content

include_file/
lib.rs

1// Copyright 2025 Heath Stewart.
2// Licensed under the MIT License. See LICENSE.txt in the project root for license information.
3
4#![cfg_attr(docsrs, feature(doc_cfg))]
5#![doc = include_str!("../README.md")]
6
7#[cfg(feature = "asciidoc")]
8mod asciidoc;
9mod markdown;
10#[cfg(feature = "org")]
11mod org;
12#[cfg(test)]
13mod tests;
14#[cfg(feature = "textile")]
15mod textile;
16
17use proc_macro2::{Delimiter, Group, Ident, Span, TokenStream, TokenTree};
18use quote::quote;
19use std::{
20    env, fs,
21    io::{self, BufRead},
22    path::PathBuf,
23    sync::atomic::{AtomicU64, Ordering},
24};
25use syn::{
26    parse::{Parse, ParseStream},
27    parse2,
28    spanned::Spanned,
29    LitStr, Meta, Token,
30};
31
32static INCLUDE_COUNTER: AtomicU64 = AtomicU64::new(0);
33
34/// Include code from within a source block in an AsciiDoc file.
35///
36/// All AsciiDoc [source blocks](https://docs.asciidoctor.org/asciidoc/latest/verbatim/source-blocks/)
37/// with delimited [listing blocks](https://docs.asciidoctor.org/asciidoc/latest/verbatim/listing-blocks/) are supported.
38///
39/// # Arguments
40///
41/// * `path` (*Required*) Path relative to the crate root directory.
42/// * `name` (*Required*) Name of the code fence to include.
43/// * `scope` Include the snippet in braces `{ .. }`.
44/// * `relative` (*Requires rustc 1.88 or newer*) Path is relative to the source file calling the macro.
45///
46/// # Examples
47///
48/// Consider the following source block in a crate `README.adoc` AsciiDoc file:
49///
50/// ```asciidoc
51/// [,rust,id="example"]
52/// ----
53/// let m = example()?;
54/// assert_eq!(format!("{m:?}"), r#"Model { name: "example" }"#);
55/// ----
56/// ```
57///
58/// We can include this code block in our Rust tests:
59///
60/// ```no_run
61/// struct Model {
62///     name: String,
63/// }
64///
65/// fn example() -> Result<Model, Box<dyn std::error::Error>> {
66///     Ok(Model { name: "example".into() })
67/// }
68///
69/// #[test]
70/// fn test_example() -> Result<(), Box<dyn std::error::Error>> {
71///     include_asciidoc!("README.adoc", "example");
72///     Ok(())
73/// }
74/// ```
75#[cfg(feature = "asciidoc")]
76#[proc_macro]
77pub fn include_asciidoc(item: proc_macro::TokenStream) -> proc_macro::TokenStream {
78    asciidoc::include_asciidoc(item.into())
79        .unwrap_or_else(syn::Error::into_compile_error)
80        .into()
81}
82
83/// Include code from within a code fence in a Markdown file.
84///
85/// All CommonMark [code fences](https://spec.commonmark.org/current/#fenced-code-blocks) are supported.
86///
87/// # Arguments
88///
89/// * `path` (*Required*) Path relative to the crate root directory.
90/// * `name` (*Required*) Name of the code fence to include.
91/// * `scope` Include the snippet in braces `{ .. }`.
92/// * `relative` (*Requires rustc 1.88 or newer*) Path is relative to the source file calling the macro.
93///
94/// # Examples
95///
96/// Consider the following code fence in a crate `README.md` Markdown file:
97///
98/// ````markdown
99/// ```rust example
100/// let m = example()?;
101/// assert_eq!(format!("{m:?}"), r#"Model { name: "example" }"#);
102/// ```
103/// ````
104///
105/// In Rust documentation comments, we can use `# line` to hide setup code.
106/// That's not possible in Markdown, so we can include only the code we want to demonstrate;
107/// however, we can still compile and even run it in Rust tests:
108///
109/// ```no_run
110/// struct Model {
111///     name: String,
112/// }
113///
114/// fn example() -> Result<Model, Box<dyn std::error::Error>> {
115///     Ok(Model { name: "example".into() })
116/// }
117///
118/// #[test]
119/// fn test_example() -> Result<(), Box<dyn std::error::Error>> {
120///     include_markdown!("README.md", "example");
121///     Ok(())
122/// }
123/// ```
124#[proc_macro]
125pub fn include_markdown(item: proc_macro::TokenStream) -> proc_macro::TokenStream {
126    markdown::include_markdown(item.into())
127        .unwrap_or_else(syn::Error::into_compile_error)
128        .into()
129}
130
131/// Include code from within a code block in a Textile file.
132///
133/// All Textile [code blocks](https://textile-lang.com/doc/block-code) are supported.
134///
135/// # Arguments
136///
137/// * `path` (*Required*) Path relative to the crate root directory.
138/// * `name` (*Required*) Name of the code fence to include.
139/// * `scope` Include the snippet in braces `{ .. }`.
140/// * `relative` (*Requires rustc 1.88 or newer*) Path is relative to the source file calling the macro.
141///
142/// # Examples
143///
144/// Consider the following code block in a crate `README.textile` Textile file:
145///
146/// ```textile
147/// bc(rust#example). let m = example()?;
148/// assert_eq!(format!("{m:?}"), r#"Model { name: "example" }"#);
149/// ```
150///
151/// In Rust documentation comments, we can use `# line` to hide setup code.
152/// That's not possible in Textile, so we can include only the code we want to demonstrate;
153/// however, we can still compile and even run it in Rust tests:
154///
155/// ```no_run
156/// struct Model {
157///     name: String,
158/// }
159///
160/// fn example() -> Result<Model, Box<dyn std::error::Error>> {
161///     Ok(Model { name: "example".into() })
162/// }
163///
164/// #[test]
165/// fn test_example() -> Result<(), Box<dyn std::error::Error>> {
166///     include_textile!("README.textile", "example");
167///     Ok(())
168/// }
169/// ```
170#[cfg(feature = "textile")]
171#[proc_macro]
172pub fn include_textile(item: proc_macro::TokenStream) -> proc_macro::TokenStream {
173    textile::include_textile(item.into())
174        .unwrap_or_else(syn::Error::into_compile_error)
175        .into()
176}
177
178/// Include code from within a source block in an Org file.
179///
180/// All Org [source code blocks](https://orgmode.org/manual/Structure-of-Code-Blocks.html) are supported.
181///
182/// # Arguments
183///
184/// * `path` (*Required*) Path relative to the crate root directory.
185/// * `name` (*Required*) Name of the code fence to include.
186/// * `scope` Include the snippet in braces `{ .. }`.
187/// * `relative` (*Requires rustc 1.88 or newer*) Path is relative to the source file calling the macro.
188///
189/// # Examples
190///
191/// Consider the following source block in a crate `README.org` Org file:
192///
193/// ```org
194/// #+NAME: example
195/// #+BEGIN_SRC rust
196/// let m = example()?;
197/// assert_eq!(format!("{m:?}"), r#"Model { name: "example" }"#);
198/// #+END_SRC
199/// ```
200///
201/// In Rust documentation comments, we can use `# line` to hide setup code.
202/// That's not possible in Org, so we can include only the code we want to demonstrate;
203/// however, we can still compile and even run it in Rust tests:
204///
205/// ```no_run
206/// struct Model {
207///     name: String,
208/// }
209///
210/// fn example() -> Result<Model, Box<dyn std::error::Error>> {
211///     Ok(Model { name: "example".into() })
212/// }
213///
214/// #[test]
215/// fn test_example() -> Result<(), Box<dyn std::error::Error>> {
216///     include_org!("README.org", "example");
217///     Ok(())
218/// }
219/// ```
220#[cfg(feature = "org")]
221#[proc_macro]
222pub fn include_org(item: proc_macro::TokenStream) -> proc_macro::TokenStream {
223    org::include_org(item.into())
224        .unwrap_or_else(syn::Error::into_compile_error)
225        .into()
226}
227
228struct MarkdownArgs {
229    path: LitStr,
230    name: LitStr,
231    scope: Option<Span>,
232    relative: Option<Span>,
233}
234
235impl Parse for MarkdownArgs {
236    fn parse(input: ParseStream) -> syn::Result<Self> {
237        const REQ_PARAMS: &str = r#"missing required string parameters ("path", "name")"#;
238
239        let path = input
240            .parse()
241            .map_err(|err| syn::Error::new(err.span(), REQ_PARAMS))?;
242        input.parse::<Token![,]>()?;
243        let name = input
244            .parse()
245            .map_err(|err| syn::Error::new(err.span(), REQ_PARAMS))?;
246
247        let mut scope = None;
248        let mut relative = None;
249
250        if input.parse::<Token![,]>().is_ok() {
251            let params = input.parse_terminated(Meta::parse, Token![,])?;
252            for param in params {
253                if param.path().is_ident("scope") {
254                    scope = Some(param.span());
255                } else if param.path().is_ident("relative") {
256                    relative = Some(param.span());
257                } else {
258                    return Err(syn::Error::new(param.span(), "unsupported parameter"));
259                }
260            }
261        } else if !input.is_empty() {
262            return Err(syn::Error::new(input.span(), "unexpected token"));
263        }
264
265        Ok(Self {
266            path,
267            name,
268            scope,
269            relative,
270        })
271    }
272}
273
274fn include_file<F>(item: TokenStream, f: F) -> syn::Result<TokenStream>
275where
276    F: FnOnce(&str, io::Lines<io::BufReader<fs::File>>) -> io::Result<(u32, Vec<String>)>,
277{
278    let args: MarkdownArgs = parse2(item)?;
279    let root = match args.relative {
280        #[cfg(span_locations)]
281        Some(span) => span.local_file(),
282        #[cfg(not(span_locations))]
283        Some(span) => return Err(syn::Error::new(span, "requires rustc 1.88 or newer")),
284        None => None,
285    };
286    let (file, display_path) =
287        open(root, &args.path.value()).map_err(|err| syn::Error::new(args.path.span(), err))?;
288    let (start_line, content) = extract(file, &args.name.value(), f)
289        .map_err(|err| syn::Error::new(args.name.span(), err))?;
290
291    let n = INCLUDE_COUNTER.fetch_add(1, Ordering::Relaxed);
292    let guard_type = Ident::new(&format!("__IncludeFileGuard{n}"), Span::call_site());
293    let guard_var = Ident::new(&format!("__include_file_guard{n}"), Span::call_site());
294
295    // Compute the file expression for the guard based on whether `relative` was passed.
296    // Use Location::caller().file() to resolve paths consistently with panic messages.
297    let file_expr: TokenStream = if args.relative.is_some() {
298        // Path is relative to the source file.
299        // Resolve against caller's directory and normalize.
300        let path_str = args.path.value();
301        quote! {
302            {
303                let __caller = ::std::panic::Location::caller().file();
304                let __caller_dir = ::std::path::Path::new(__caller)
305                    .parent()
306                    .unwrap_or(::std::path::Path::new(""));
307                let __resolved = __caller_dir.join(#path_str);
308                let mut __parts: ::std::vec::Vec<::std::path::Component<'_>> =
309                    ::std::vec::Vec::new();
310                for __c in __resolved.components() {
311                    match __c {
312                        ::std::path::Component::ParentDir => { __parts.pop(); }
313                        ::std::path::Component::CurDir => {}
314                        _ => __parts.push(__c),
315                    }
316                }
317                let __normalized: ::std::path::PathBuf = __parts.iter().collect();
318                __normalized.to_string_lossy().into_owned()
319            }
320        }
321    } else {
322        // Path is relative to CARGO_MANIFEST_DIR.
323        // Find the crate-relative portion of the caller path by testing
324        // progressively shorter suffixes against CARGO_MANIFEST_DIR. The
325        // prefix that remains is whatever the compiler prepended (e.g., a
326        // workspace-relative directory), which we prepend to display_path
327        // so the reported path matches what panic messages use.
328        let path_str = &display_path;
329        quote! {
330            {
331                let __caller = ::std::panic::Location::caller().file();
332                let __manifest = ::std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
333                let __path: &str = #path_str;
334                let __caller_path = ::std::path::Path::new(__caller);
335                let __components: ::std::vec::Vec<::std::path::Component<'_>> =
336                    __caller_path.components().collect();
337                let mut __prefix_len = 0usize;
338                for __skip in 0..__components.len() {
339                    let __suffix: ::std::path::PathBuf =
340                        __components[__skip..].iter().collect();
341                    if __manifest.join(&__suffix).is_file() {
342                        __prefix_len = __skip;
343                        break;
344                    }
345                }
346                if __prefix_len == 0 {
347                    ::std::string::String::from(__path)
348                } else {
349                    let __prefix: ::std::path::PathBuf =
350                        __components[..__prefix_len].iter().collect();
351                    __prefix.join(__path).to_string_lossy().into_owned()
352                }
353            }
354        }
355    };
356
357    let guard = quote! {
358        struct #guard_type {
359            file: ::std::string::String,
360            line: u32,
361        }
362        impl ::std::ops::Drop for #guard_type {
363            fn drop(&mut self) {
364                if ::std::thread::panicking() {
365                    ::std::eprintln!(
366                        "note: panicked in code included from {}:{}",
367                        self.file,
368                        self.line
369                    );
370                }
371            }
372        }
373        let #guard_var = #guard_type {
374            file: #file_expr,
375            line: #start_line,
376        };
377    };
378
379    let body: TokenStream = content.parse()?;
380    let mut output = guard;
381    output.extend(body);
382    // Explicitly drop the guard right after the included body so that, when
383    // multiple macros are used in the same scope, prior guards are already gone
384    // before the next snippet starts.  Without this, a panic in snippet N would
385    // unwind all N guards and print N "note:" lines instead of one.
386    output.extend(quote! { ::std::mem::drop(#guard_var); });
387
388    if args.scope.is_some() {
389        output = TokenTree::Group(Group::new(Delimiter::Brace, output)).into();
390    }
391
392    Ok(output)
393}
394
395fn open(root: Option<PathBuf>, path: &str) -> io::Result<(fs::File, String)> {
396    let manifest_dir: PathBuf = env::var("CARGO_MANIFEST_DIR")
397        .map_err(|_| io::Error::other("no manifest directory"))?
398        .into();
399    let root_dir = match root {
400        Some(ref src) => src
401            .parent()
402            .map(|dir| manifest_dir.join(dir))
403            .ok_or_else(|| io::Error::other("no source parent directory"))?,
404        None => manifest_dir.clone(),
405    };
406    let full_path = root_dir.join(path);
407    let file = fs::File::open(&full_path)?;
408    let display_path = {
409        // Canonicalize to resolve any `..` components; fall back to the
410        // unresolved paths if canonicalization fails (e.g., a race with
411        // deletion), which is acceptable since we already opened the file.
412        let canonical_full = fs::canonicalize(&full_path).unwrap_or_else(|_| full_path.clone());
413        let canonical_manifest =
414            fs::canonicalize(&manifest_dir).unwrap_or_else(|_| manifest_dir.clone());
415        let rel = canonical_full
416            .strip_prefix(&canonical_manifest)
417            .unwrap_or(std::path::Path::new(path));
418        rel.to_string_lossy().into_owned()
419    };
420    Ok((file, display_path))
421}
422
423fn extract<R, F>(buffer: R, name: &str, f: F) -> io::Result<(u32, String)>
424where
425    R: io::Read,
426    F: FnOnce(&str, io::Lines<io::BufReader<R>>) -> io::Result<(u32, Vec<String>)>,
427{
428    let reader = io::BufReader::new(buffer);
429    let (start_line, lines) = f(name, reader.lines())?;
430    if lines.is_empty() {
431        return Err(io::Error::new(
432            io::ErrorKind::NotFound,
433            format!("code fence '{}' not found", name),
434        ));
435    }
436
437    Ok((start_line, lines.join("\n")))
438}