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#![doc = include_str!("../README.md")]
5
6mod markdown;
7#[cfg(test)]
8mod tests;
9
10use proc_macro2::{Span, TokenStream};
11use std::{
12    fmt, fs,
13    io::{self, BufRead},
14    path::PathBuf,
15};
16use syn::{
17    parse::{Parse, ParseStream},
18    parse2, LitStr, Token,
19};
20
21/// Include code from within a code fence in a markdown file.
22///
23/// Two arguments are required: a file path relative to the current source file,
24/// and a name defined within the code fence as shown below.
25///
26/// All CommonMark [code fences](https://spec.commonmark.org/current/#fenced-code-blocks) are supported.
27///
28/// # Examples
29///
30/// Consider the following code fence in a crate `README.md` markdown file:
31///
32/// ````markdown
33/// ```rust example
34/// let m = example()?;
35/// assert_eq!(format!("{m:?}"), r#"Model { name: "example" }"#);
36/// ```
37/// ````
38///
39/// In Rust documentation comments, we can use `# line` to hide setup code.
40/// That's not possible in markdown, so we can include only the code we want to demonstrate;
41/// however, we can still compile and even run it in Rust tests:
42///
43/// ```no_run
44/// struct Model {
45///     name: String,
46/// }
47///
48/// fn example() -> Result<Model, Box<dyn std::error::Error>> {
49///     Ok(Model { name: "example".into() })
50/// }
51///
52/// #[test]
53/// fn test_example() -> Result<(), Box<dyn std::error::Error>> {
54///     include_markdown!("../README.md", "example");
55///     Ok(())
56/// }
57/// ```
58#[proc_macro]
59pub fn include_markdown(item: proc_macro::TokenStream) -> proc_macro::TokenStream {
60    markdown::include_markdown(item.into())
61        .unwrap_or_else(syn::Error::into_compile_error)
62        .into()
63}
64
65struct MarkdownArgs {
66    path: LitStr,
67    name: LitStr,
68}
69
70impl fmt::Debug for MarkdownArgs {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        f.debug_struct("MarkdownArgs")
73            .field("path", &self.path.value())
74            .field("name", &self.name.value())
75            .finish()
76    }
77}
78
79impl Parse for MarkdownArgs {
80    fn parse(input: ParseStream) -> syn::Result<Self> {
81        let path = input.parse()?;
82        input.parse::<Token![,]>()?;
83        let name = input.parse()?;
84        Ok(Self { path, name })
85    }
86}
87
88fn include_file<F>(item: TokenStream, f: F) -> syn::Result<TokenStream>
89where
90    F: FnOnce(&str, io::Lines<io::BufReader<fs::File>>) -> io::Result<Vec<String>>,
91{
92    let args: MarkdownArgs = parse2(item).map_err(|_| {
93        syn::Error::new(
94            Span::call_site(),
95            "expected (path, name) literal string arguments",
96        )
97    })?;
98    let file = open(&args.path.value()).map_err(|err| syn::Error::new(args.path.span(), err))?;
99    let content = extract(file, &args.name.value(), f)
100        .map_err(|err| syn::Error::new(args.name.span(), err))?;
101
102    Ok(content.parse()?)
103}
104
105fn open(path: &str) -> io::Result<fs::File> {
106    let file_path = PathBuf::from(file!());
107    let path = file_path
108        .parent()
109        .ok_or_else(|| {
110            io::Error::new(
111                io::ErrorKind::NotFound,
112                "could not get parent of current source file",
113            )
114        })?
115        .join(path);
116    fs::File::open(path)
117}
118
119fn extract<R, F>(buffer: R, name: &str, f: F) -> io::Result<String>
120where
121    R: io::Read,
122    F: FnOnce(&str, io::Lines<io::BufReader<R>>) -> io::Result<Vec<String>>,
123{
124    let reader = io::BufReader::new(buffer);
125    let lines = f(name, reader.lines())?;
126    if lines.is_empty() {
127        return Err(io::Error::new(
128            io::ErrorKind::NotFound,
129            format!("code fence '{}' not found", name),
130        ));
131    }
132
133    Ok(lines.join("\n"))
134}