include_utils_macro/
lib.rs

1//! Internal proc macro for the `include-utils` crate.
2
3#![allow(missing_docs)]
4
5use std::path::{Path, PathBuf};
6
7use itertools::Itertools;
8use manyhow::manyhow;
9use proc_macro2::TokenStream as TokenStream2;
10use quote::quote;
11use syn::LitStr;
12
13use crate::include_location::{IncludeLocation, IncludeRange};
14
15mod include_location;
16
17#[manyhow]
18#[proc_macro]
19pub fn include_str_part(file: LitStr) -> manyhow::Result<TokenStream2> {
20    let file = file.value();
21    let location = IncludeLocation::parse(&file)?;
22    let file_content = read_file(location.path)?;
23
24    let processed_content = process_file(file_content, &location.range, |_content, _name| {
25        Err(manyhow::error_message!("Anchors is not supported for the plain string").into())
26    })?;
27
28    Ok(quote!(#processed_content))
29}
30
31#[manyhow]
32#[proc_macro]
33pub fn include_md(file: LitStr) -> manyhow::Result<TokenStream2> {
34    let file = file.value();
35    let location = IncludeLocation::parse(&file)?;
36    let file_content = read_file(location.path)?;
37
38    // TODO Use markdown parser to analyze comments.
39    let processed_content = process_file(file_content, &location.range, |content, anchor_name| {
40        let anchor_begin = format!("<!-- ANCHOR: {anchor_name}");
41        let anchor_end = format!("<!-- ANCHOR_END: {anchor_name}");
42
43        let mut has_anchor = false;
44
45        let output = content
46            .lines()
47            .skip_while(|line| !line.starts_with(&anchor_begin))
48            .skip(1)
49            .take_while(|line| {
50                let is_end = line.starts_with(&anchor_end);
51                if is_end {
52                    has_anchor = true;
53                }
54                !is_end
55            })
56            .join("\n");
57
58        manyhow::ensure!(
59            has_anchor,
60            "Included file doesn't contain anchor with name: {anchor_name}"
61        );
62        Ok(output)
63    })?;
64
65    Ok(quote!(#processed_content))
66}
67
68fn cargo_manifest_dir() -> manyhow::Result<PathBuf> {
69    let dir = std::env::var("CARGO_MANIFEST_DIR").map_err(|err| {
70        manyhow::error_message!("Unable to read `CARGO_MANIFEST_DIR` environment variable. {err}")
71    })?;
72    Ok(dir.into())
73}
74
75/// Search for file path relative cargo manifest directory and workspace root directory if `workspace`
76/// feature is enabled.
77fn search_file(file_path: &Path) -> manyhow::Result<PathBuf> {
78    let manifest_dir = cargo_manifest_dir()?;
79
80    let search_paths = [
81        manifest_dir.clone(),
82        // Searching for a file also relative the workspace root directory.
83        #[cfg(feature = "workspace")]
84        {
85            let metadata = cargo_metadata::MetadataCommand::new()
86                .manifest_path(manifest_dir.join("Cargo.toml"))
87                .exec()
88                .map_err(|err| {
89                    manyhow::error_message!("Unable to execute cargo metadata command. {err}")
90                })?;
91
92            metadata.workspace_root.into_std_path_buf()
93        },
94    ];
95
96    for search_path in search_paths {
97        let full_path = search_path.join(file_path);
98
99        if full_path.exists() {
100            return full_path.canonicalize().map_err(|err| {
101                manyhow::error_message!("Unable to canonicalize file path. {err}").into()
102            });
103        }
104    }
105
106    Err(manyhow::error_message!("File path `{}` not found", file_path.display()).into())
107}
108
109fn read_file(file_path: impl AsRef<Path>) -> manyhow::Result<String> {
110    let full_path = {
111        let file_path = file_path.as_ref();
112        if file_path.is_absolute() {
113            file_path.to_owned()
114        } else {
115            search_file(file_path)?
116        }
117    };
118
119    std::fs::read_to_string(full_path)
120        .map_err(|err| manyhow::error_message!("Unable to read file. {err}").into())
121}
122
123fn process_file<F: FnOnce(String, &str) -> manyhow::Result<String>>(
124    content: String,
125    range: &IncludeRange<'_>,
126    anchor_processor: F,
127) -> manyhow::Result<String> {
128    let content = match range {
129        // Just copy the entire file content.
130        IncludeRange::Full => content,
131        IncludeRange::Range { from, to } => {
132            // To avoid confuses we just count line numbers from the one instead of zero.
133            let from = from.unwrap_or_default().saturating_sub(1);
134            // Just skip the file lines before the `from` line.
135            let mut lines = content.lines().skip(from);
136            if let Some(to) = to {
137                // In this case we have an explicit end of file inclusion.
138                // So we have to take `N` lines, where `N = to - from`.
139                let n = to - from;
140                lines.take(n).join("\n")
141            } else {
142                // Just include the whole file tail.
143                lines.join("\n")
144            }
145        }
146        IncludeRange::Anchor { name } => anchor_processor(content, name)?,
147    };
148
149    Ok(content.trim().to_owned())
150}