Skip to main content

leptos_hot_reload/
lib.rs

1extern crate proc_macro;
2
3use anyhow::Result;
4use camino::Utf8PathBuf;
5use diff::Patches;
6use node::LNode;
7use or_poisoned::OrPoisoned;
8use serde::{Deserialize, Serialize};
9use std::{
10    collections::HashMap,
11    fs::File,
12    io::Read,
13    path::{Path, PathBuf},
14    sync::{Arc, RwLock},
15};
16use syn::{
17    spanned::Spanned,
18    visit::{self, Visit},
19    Macro,
20};
21use walkdir::WalkDir;
22
23pub mod diff;
24pub mod node;
25pub mod parsing;
26
27pub const HOT_RELOAD_JS: &str = include_str!("patch.js");
28
29#[derive(Debug, Clone, Default)]
30pub struct ViewMacros {
31    // keyed by original location identifier
32    views: Arc<RwLock<HashMap<Utf8PathBuf, Vec<MacroInvocation>>>>,
33}
34
35impl ViewMacros {
36    #[must_use]
37    pub fn new() -> Self {
38        Self::default()
39    }
40
41    /// # Errors
42    ///
43    /// Will return `Err` if the path is not UTF-8 path or the contents of the file cannot be parsed.
44    pub fn update_from_paths<T: AsRef<Path>>(&self, paths: &[T]) -> Result<()> {
45        let mut views = HashMap::new();
46
47        for path in paths {
48            for entry in WalkDir::new(path).into_iter().flatten() {
49                if entry.file_type().is_file() {
50                    let path: PathBuf = entry.path().into();
51                    let path = Utf8PathBuf::try_from(path)?;
52                    if path.extension() == Some("rs") || path.ends_with(".rs") {
53                        let macros = Self::parse_file(&path)?;
54                        let entry = views.entry(path.clone()).or_default();
55                        *entry = macros;
56                    }
57                }
58            }
59        }
60
61        *self.views.write().or_poisoned() = views;
62
63        Ok(())
64    }
65
66    /// # Errors
67    ///
68    /// Will return `Err` if the contents of the file cannot be parsed.
69    pub fn parse_file(path: &Utf8PathBuf) -> Result<Vec<MacroInvocation>> {
70        let mut file = File::open(path)?;
71        let mut content = String::new();
72        file.read_to_string(&mut content)?;
73        let ast = syn::parse_file(&content)?;
74
75        let mut visitor = ViewMacroVisitor::default();
76        visitor.visit_file(&ast);
77        let mut views = Vec::new();
78        for view in visitor.views {
79            let span = view.span();
80            let id = span_to_stable_id(path, span.start().line);
81            if view.tokens.is_empty() {
82                views.push(MacroInvocation {
83                    id,
84                    template: LNode::Fragment(Vec::new()),
85                });
86            } else {
87                let tokens = view.tokens.clone().into_iter();
88                // TODO handle class = ...
89                let rsx = rstml::parse2(
90                    tokens.collect::<proc_macro2::TokenStream>(),
91                )?;
92                let template = LNode::parse_view(rsx)?;
93                views.push(MacroInvocation { id, template });
94            }
95        }
96        Ok(views)
97    }
98
99    /// # Errors
100    ///
101    /// Will return `Err` if the contents of the file cannot be parsed.
102    pub fn patch(&self, path: &Utf8PathBuf) -> Result<Option<Patches>> {
103        let new_views = Self::parse_file(path)?;
104        let mut lock = self.views.write().or_poisoned();
105        let diffs = match lock.get(path) {
106            None => return Ok(None),
107            Some(current_views) => {
108                if current_views.len() == new_views.len() {
109                    let mut diffs = Vec::new();
110                    for (current_view, new_view) in
111                        current_views.iter().zip(&new_views)
112                    {
113                        if current_view.id == new_view.id
114                            && current_view.template != new_view.template
115                        {
116                            diffs.push((
117                                current_view.id.clone(),
118                                current_view.template.diff(&new_view.template),
119                            ));
120                        }
121                    }
122                    diffs
123                } else {
124                    // TODO: instead of simply returning no patches, when number of views differs,
125                    // we can compare views content to determine which views were shifted
126                    // or come up with another idea that will allow to send patches when views were shifted/removed/added
127                    lock.insert(path.clone(), new_views);
128                    return Ok(None);
129                }
130            }
131        };
132
133        // update the status to the new views
134        lock.insert(path.clone(), new_views);
135
136        Ok(Some(Patches(diffs)))
137    }
138}
139
140#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
141pub struct MacroInvocation {
142    id: String,
143    template: LNode,
144}
145
146impl core::fmt::Debug for MacroInvocation {
147    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
148        f.debug_struct("MacroInvocation")
149            .field("id", &self.id)
150            .finish_non_exhaustive()
151    }
152}
153
154#[derive(Default, Debug)]
155pub struct ViewMacroVisitor<'a> {
156    views: Vec<&'a Macro>,
157}
158
159impl<'ast> Visit<'ast> for ViewMacroVisitor<'ast> {
160    fn visit_macro(&mut self, node: &'ast Macro) {
161        let ident = node.path.get_ident().map(ToString::to_string);
162        if ident == Some("view".to_string()) {
163            self.views.push(node);
164        }
165
166        // Delegate to the default impl to visit any nested functions.
167        visit::visit_macro(self, node);
168    }
169}
170
171pub fn span_to_stable_id(path: impl AsRef<Path>, line: usize) -> String {
172    let file = path
173        .as_ref()
174        .to_str()
175        .unwrap_or_default()
176        .replace(['/', '\\'], "-");
177    format!("{file}-{line}")
178}