Skip to main content

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
76/// `workspace` 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                .current_dir(manifest_dir)
88                .no_deps()
89                .exec()
90                .map_err(|err| {
91                    manyhow::error_message!("Unable to execute cargo metadata command. {err}")
92                })?;
93
94            metadata.workspace_root.into_std_path_buf()
95        },
96    ];
97
98    for search_path in search_paths {
99        let full_path = search_path.join(file_path);
100
101        if full_path.exists() {
102            return full_path.canonicalize().map_err(|err| {
103                manyhow::error_message!("Unable to canonicalize file path. {err}").into()
104            });
105        }
106    }
107
108    Err(manyhow::error_message!("File path `{}` not found", file_path.display()).into())
109}
110
111fn read_file(file_path: impl AsRef<Path>) -> manyhow::Result<String> {
112    let full_path = {
113        let file_path = file_path.as_ref();
114        if file_path.is_absolute() {
115            file_path.to_owned()
116        } else {
117            search_file(file_path)?
118        }
119    };
120
121    std::fs::read_to_string(full_path)
122        .map_err(|err| manyhow::error_message!("Unable to read file. {err}").into())
123}
124
125fn process_file<F: FnOnce(String, &str) -> manyhow::Result<String>>(
126    content: String,
127    range: &IncludeRange<'_>,
128    anchor_processor: F,
129) -> manyhow::Result<String> {
130    let content = match range {
131        // Just copy the entire file content.
132        IncludeRange::Full => content,
133        IncludeRange::Range { from, to } => {
134            // To avoid confuses we just count line numbers from the one instead of zero.
135            let from = from.unwrap_or_default().saturating_sub(1);
136            // Just skip the file lines before the `from` line.
137            let mut lines = content.lines().skip(from);
138            if let Some(to) = to {
139                // In this case we have an explicit end of file inclusion.
140                // So we have to take `N` lines, where `N = to - from`.
141                let n = to - from;
142                lines.take(n).join("\n")
143            } else {
144                // Just include the whole file tail.
145                lines.join("\n")
146            }
147        }
148        IncludeRange::Anchor { name } => anchor_processor(content, name)?,
149    };
150
151    Ok(content.trim().to_owned())
152}