cargo_remark/
render.rs

1use std::borrow::Cow;
2use std::fmt::Write;
3use std::fs::File;
4use std::io::BufWriter;
5use std::path::{Component, Path, Prefix, MAIN_SEPARATOR};
6
7use anyhow::Context;
8use askama::Template;
9use html_escape::{encode_safe, encode_safe_to_string};
10use rayon::iter::IntoParallelIterator;
11use rayon::prelude::*;
12use rust_embed::RustEmbed;
13
14use crate::remark::{Line, Location, MessagePart, Remark};
15use crate::utils::callback::LoadCallback;
16use crate::utils::data_structures::{Map, Set};
17
18pub const INDEX_FILE_PATH: &str = "index.html";
19const REMARK_LIST_FILE_PATH: &str = "remarks.html";
20
21/// Directory where sources will be stored.
22/// Relative to the output directory.
23const SRC_DIR_NAME: &str = "src";
24
25#[derive(RustEmbed)]
26#[folder = "templates/assets"]
27struct StaticAssets;
28
29#[derive(serde::Serialize)]
30struct RemarkIndexEntry<'a> {
31    name: &'a str,
32    location: Option<String>,
33    function: Cow<'a, str>,
34    message: String,
35    hotness: Option<i32>,
36}
37
38#[derive(serde::Serialize, PartialEq, Eq, Hash)]
39struct RemarkSourceEntry<'a> {
40    name: &'a str,
41    function: &'a str,
42    line: Line,
43    message: String,
44    hotness: Option<i32>,
45}
46
47#[derive(Template)]
48#[template(path = "remark-list.jinja")]
49pub struct RemarkListTemplate {
50    remarks_json: String,
51}
52
53#[derive(serde::Serialize)]
54struct SourceFileLink<'a> {
55    name: &'a str,
56    file: String,
57    remark_count: u64,
58}
59
60#[derive(Template)]
61#[template(path = "index.jinja")]
62pub struct IndexTemplate<'a> {
63    source_links: Vec<SourceFileLink<'a>>,
64}
65
66#[derive(Template)]
67#[template(path = "source-file.jinja")]
68pub struct SourceFileTemplate<'a> {
69    path: &'a str,
70    remarks: Set<RemarkSourceEntry<'a>>,
71    file_content: String,
72}
73
74pub fn render_remarks(
75    remarks: Vec<Remark>,
76    source_dir: &Path,
77    output_dir: &Path,
78    callback: Option<&(dyn LoadCallback + Sync)>,
79) -> anyhow::Result<()> {
80    let _ = std::fs::remove_dir_all(output_dir);
81    std::fs::create_dir_all(output_dir).context("Cannot create output directory")?;
82
83    // Copy all static assets to the output directory
84    for asset_path in StaticAssets::iter() {
85        let data = StaticAssets::get(&asset_path).unwrap().data;
86        let path = output_dir.join("assets").join(asset_path.as_ref());
87        if let Some(parent) = path.parent() {
88            std::fs::create_dir_all(parent).context("Cannot create output asset directory")?;
89        }
90        std::fs::write(path, data).context("Cannot copy asset file to output directory")?;
91    }
92
93    let mut file_to_remarks: Map<&str, Set<RemarkSourceEntry>> = Map::default();
94
95    // Create remark list page
96    let remark_entries = remarks
97        .iter()
98        .map(|r| {
99            let Remark {
100                pass: _,
101                name,
102                function,
103                message,
104                hotness,
105            } = r;
106
107            let entry = RemarkIndexEntry {
108                name,
109                location: function.location.as_ref().map(|location| {
110                    let mut buffer = String::new();
111                    render_remark_link(&mut buffer, location, Some(SRC_DIR_NAME), None);
112                    buffer
113                }),
114                function: encode_safe(&function.name),
115                message: format_message(message, Some(SRC_DIR_NAME)),
116                hotness: *hotness,
117            };
118            if let Some(ref location) = function.location {
119                file_to_remarks
120                    .entry(&location.file)
121                    .or_default()
122                    .insert(RemarkSourceEntry {
123                        name,
124                        function: &function.name,
125                        line: location.line,
126                        // Inside the file, the link should be relative to the src directory
127                        message: format_message(message, None),
128                        hotness: *hotness,
129                    });
130            }
131            // We also need to create file mappings for all referenced files, not just for files
132            // with a remark.
133            for msg_part in &r.message {
134                if let MessagePart::AnnotatedString { location, .. } = msg_part {
135                    file_to_remarks.entry(&location.file).or_default();
136                }
137            }
138            entry
139        })
140        .collect::<Vec<_>>();
141
142    let serialized_remarks = serde_json::to_string(&remark_entries)?;
143    let remark_list_page = RemarkListTemplate {
144        remarks_json: serialized_remarks,
145    };
146    render_to_file(&remark_list_page, &output_dir.join(REMARK_LIST_FILE_PATH))?;
147
148    let mut source_links: Vec<SourceFileLink> = file_to_remarks
149        .iter()
150        .filter(|(_, remarks)| !remarks.is_empty())
151        .map(|(name, remarks)| {
152            let mut file = String::new();
153            path_to_relative_url(&mut file, Some(SRC_DIR_NAME), name);
154            SourceFileLink {
155                name,
156                file,
157                remark_count: remarks.len() as u64,
158            }
159        })
160        .collect();
161
162    // Sort by relative files first, then in descending order by remark count
163    source_links.sort_by_key(|link| (link.name.starts_with('/'), -(link.remark_count as i64)));
164
165    let index_page = IndexTemplate { source_links };
166    render_to_file(&index_page, &output_dir.join(INDEX_FILE_PATH))?;
167
168    if let Some(callback) = callback {
169        callback.start(file_to_remarks.len() as u64);
170    }
171
172    // Render all found source files
173    let results = file_to_remarks
174        .into_par_iter()
175        .map(|(source_file, remarks)| -> anyhow::Result<()> {
176            let original_path = resolve_path(source_dir, Path::new(source_file));
177            let file_content = std::fs::read_to_string(&original_path)
178                .with_context(|| format!("Cannot read source file {}", original_path.display()))?;
179
180            if let Some(callback) = callback {
181                callback.advance();
182            }
183
184            // TODO: deduplicate links to "self" (the same source file)
185            let mut buffer = String::new();
186            path_to_relative_url(&mut buffer, Some(SRC_DIR_NAME), source_file);
187            let output_path = output_dir.join(buffer);
188            let source_file_page = SourceFileTemplate {
189                path: source_file,
190                remarks,
191                file_content,
192            };
193            render_to_file(&source_file_page, Path::new(&output_path))
194                .with_context(|| anyhow::anyhow!("Failed to render {source_file}"))?;
195            Ok(())
196        })
197        .collect::<Vec<anyhow::Result<()>>>();
198
199    let failed = results.into_iter().filter(|r| r.is_err()).count();
200    if failed > 0 {
201        log::warn!("Failed to write {failed} source file(s)");
202    }
203
204    if let Some(callback) = callback {
205        callback.finish();
206    }
207
208    Ok(())
209}
210
211fn format_message(parts: &[MessagePart], prefix: Option<&str>) -> String {
212    let mut buffer = String::with_capacity(32);
213    for part in parts {
214        match part {
215            MessagePart::String(string) => {
216                encode_safe_to_string(string, &mut buffer);
217            }
218            MessagePart::AnnotatedString { message, location } => {
219                render_remark_link(&mut buffer, location, prefix, Some(message));
220            }
221        }
222    }
223    buffer
224}
225
226fn render_remark_link(
227    buffer: &mut String,
228    location: &Location,
229    prefix: Option<&str>,
230    label: Option<&str>,
231) {
232    buffer.push_str("<a href='");
233    path_to_relative_url(buffer, prefix, &location.file);
234    buffer.push_str("#L");
235    buffer.write_fmt(format_args!("{}", location.line)).unwrap();
236    buffer.push_str("'>");
237
238    let label = label.map(Cow::from).unwrap_or_else(|| {
239        format!("{}:{}:{}", location.file, location.line, location.column).into()
240    });
241    encode_safe_to_string(label, buffer);
242
243    buffer.push_str("</a>");
244}
245
246/// Transforms `path` into a (hopefully unique) relative path that is normalized.
247/// Slashes and path prefixes (e.g. C:) are removed from the paths and replaced with placeholders.
248fn path_to_relative_url(buffer: &mut String, prefix: Option<&str>, path: &str) {
249    if let Some(prefix) = prefix {
250        buffer.push_str(prefix);
251        buffer.push(MAIN_SEPARATOR);
252    }
253
254    let path = Path::new(path);
255    let mut first = true;
256    for component in path.components() {
257        if !first {
258            buffer.push('_');
259        }
260        first = false;
261
262        match component {
263            Component::Prefix(prefix) => {
264                match prefix.kind() {
265                    Prefix::Disk(disk) => buffer.push(disk as char),
266                    _ => buffer.push_str("_prefix_"),
267                };
268                first = true;
269            }
270            Component::CurDir | Component::ParentDir => buffer.push('_'),
271            Component::Normal(component) => {
272                buffer.push_str(&component.to_string_lossy());
273            }
274            Component::RootDir => {}
275        }
276    }
277    buffer.push_str(".html");
278}
279
280fn resolve_path<'a>(root_dir: &Path, path: &'a Path) -> Cow<'a, Path> {
281    if path.is_absolute() {
282        path.into()
283    } else {
284        root_dir.join(path).into()
285    }
286}
287
288fn render_to_file<T: askama::Template>(template: &T, path: &Path) -> anyhow::Result<()> {
289    if let Some(parent) = path.parent() {
290        std::fs::create_dir_all(parent)
291            .context("Cannot create directory for storing rendered file")?;
292    }
293
294    let file = File::create(path)
295        .with_context(|| format!("Cannot create template file {}", path.display()))?;
296    let mut writer = BufWriter::new(file);
297    template
298        .write_into(&mut writer)
299        .with_context(|| format!("Cannot render template into {}", path.display()))?;
300    Ok(())
301}
302
303#[cfg(test)]
304mod tests {
305    use crate::render::path_to_relative_url;
306
307    #[cfg(windows)]
308    #[test]
309    fn relative_path_c_prefix() {
310        check_path(r#"C:\foo\bar"#, "C_foo_bar.html");
311    }
312
313    #[test]
314    fn relative_path_absolute() {
315        check_path("/tmp/foo/bar", "_tmp_foo_bar.html");
316    }
317
318    #[test]
319    fn relative_path_relative() {
320        check_path("foo/bar", "foo_bar.html");
321    }
322
323    fn check_path(path: &str, expected: &str) {
324        let mut buffer = String::new();
325        path_to_relative_url(&mut buffer, None, path);
326        assert_eq!(buffer, expected);
327    }
328}