char/sorcery

static-files based git repo viewer

git clone https://git.t4t.associates/char/sorcery

Charlotte Somexperiment: soft line wrapping on source code rendersf92b8f4

main
6.3 KiB206 linesraw
1use std::fs;
2use std::io::Read as _;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, OnceLock};
5
6use anyhow::{Context, Result};
7use arborium_highlight::{HtmlFormat, Span, html_escape, spans_to_flat_tokens, spans_to_html};
8use sha2::{Digest, Sha256};
9
10use crate::grammar_manifest;
11
12static CACHE_DIR: OnceLock<PathBuf> = OnceLock::new();
13
14pub struct Cache {
15    options: arbrisseau::Options,
16}
17
18impl Cache {
19    pub fn new(root: impl AsRef<Path>) -> Self {
20        CACHE_DIR
21            .set(root.as_ref().join(grammar_manifest::VERSION))
22            .expect("grammar cache already initialized");
23        Self {
24            options: arbrisseau::Options::new(load_module),
25        }
26    }
27}
28
29fn load_module(language: &str) -> arbrisseau::ModuleFuture<'_> {
30    Box::pin(async move {
31        let Some((size, hash)) = grammar_manifest::artifact(language) else {
32            return Ok(None);
33        };
34        let path = CACHE_DIR
35            .get()
36            .expect("grammar cache not initialized")
37            .join(format!("{language}.wasm"));
38        if let Ok(bytes) = fs::read(&path)
39            && verified(&bytes, size, hash)
40        {
41            return Ok(Some(bytes));
42        }
43        match download(language, size, hash, &path) {
44            Ok(bytes) => Ok(Some(bytes)),
45            Err(error) => {
46                eprintln!("sorceryd: cannot load {language} grammar: {error:#}");
47                Ok(None)
48            }
49        }
50    })
51}
52
53fn verified(bytes: &[u8], size: usize, hash: &str) -> bool {
54    bytes.len() == size && format!("{:x}", Sha256::digest(bytes)) == hash
55}
56
57fn download(language: &str, size: usize, hash: &str, path: &Path) -> Result<Vec<u8>> {
58    let url = format!(
59        "https://cdn.jsdelivr.net/npm/@arborium/{language}@{}/grammar_bg.wasm",
60        grammar_manifest::VERSION,
61    );
62    let mut response = minreq::get(&url)
63        .with_timeout(30)
64        .with_max_redirects(3)
65        .send_lazy()
66        .with_context(|| format!("downloading {url}"))?;
67    if response.status_code != 200 {
68        anyhow::bail!("downloading {url}: HTTP {}", response.status_code);
69    }
70    let mut bytes = Vec::with_capacity(size);
71    std::io::Read::take(&mut response, size as u64 + 1)
72        .read_to_end(&mut bytes)
73        .with_context(|| format!("downloading {url}"))?;
74    if !verified(&bytes, size, hash) {
75        anyhow::bail!("downloading {url}: size or SHA-256 mismatch");
76    }
77    let dir = path.parent().unwrap();
78    fs::create_dir_all(dir).with_context(|| format!("creating grammar cache {}", dir.display()))?;
79    // Concurrent sorcery processes may race on the same cache; a per-process
80    // temporary and rename keep the final file always complete.
81    let temporary = path.with_extension(format!("wasm.tmp-{}", std::process::id()));
82    fs::write(&temporary, &bytes)
83        .and_then(|()| fs::rename(&temporary, path))
84        .with_context(|| format!("installing grammar cache {}", path.display()))?;
85    Ok(bytes)
86}
87
88pub struct Highlighter {
89    cache: Arc<Cache>,
90    highlighter: arbrisseau::Highlighter,
91}
92
93impl Highlighter {
94    pub fn new(cache: Arc<Cache>) -> Self {
95        Self {
96            cache,
97            highlighter: arbrisseau::Highlighter::new(),
98        }
99    }
100
101    pub fn highlight(&mut self, language: &str, source: &str) -> Result<String> {
102        let spans = pollster::block_on(self.highlighter.highlight(
103            language,
104            source,
105            &self.cache.options,
106        ))?;
107        Ok(spans_to_html(source, spans, &HtmlFormat::default()))
108    }
109
110    pub fn highlight_lines(&mut self, language: &str, source: &str) -> Result<Vec<String>> {
111        let spans = pollster::block_on(self.highlighter.highlight(
112            language,
113            source,
114            &self.cache.options,
115        ))?;
116        Ok(render_lines(source, spans))
117    }
118}
119
120fn render_lines(source: &str, spans: Vec<Span>) -> Vec<String> {
121    if source.is_empty() {
122        return Vec::new();
123    }
124    let tokens = spans_to_flat_tokens(source, spans);
125    let mut token = 0;
126    let mut offset = 0;
127    let mut lines = source.split('\n').collect::<Vec<_>>();
128    if source.ends_with('\n') {
129        lines.pop();
130    }
131
132    lines
133        .into_iter()
134        .map(|raw_line| {
135            let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
136            let start = offset;
137            let end = start + line.len();
138            offset += raw_line.len() + 1;
139            while tokens
140                .get(token)
141                .is_some_and(|span| span.end as usize <= start)
142            {
143                token += 1;
144            }
145
146            let mut html = String::new();
147            let mut position = start;
148            let mut i = token;
149            while let Some(span) = tokens.get(i).filter(|span| (span.start as usize) < end) {
150                let span_start = (span.start as usize).max(start);
151                let span_end = (span.end as usize).min(end);
152                if position < span_start {
153                    html.push_str(&html_escape(&source[position..span_start]));
154                }
155                if span_start < span_end {
156                    html.push_str(&format!(
157                        "<a-{tag}>{text}</a-{tag}>",
158                        tag = span.tag,
159                        text = html_escape(&source[span_start..span_end]),
160                    ));
161                }
162                position = span_end;
163                i += 1;
164            }
165            if position < end {
166                html.push_str(&html_escape(&source[position..end]));
167            }
168            html
169        })
170        .collect()
171}
172
173#[cfg(test)]
174mod tests {
175    use arbrisseau::Span;
176
177    use super::render_lines;
178
179    #[test]
180    fn renders_highlighted_lines_independently() {
181        let source = "/* <\n * x */\r\nlet x = 1;\n";
182        let spans = vec![
183            Span {
184                start: 0,
185                end: 12,
186                capture: "comment".into(),
187                pattern_index: 0,
188            },
189            Span {
190                start: 14,
191                end: 17,
192                capture: "keyword".into(),
193                pattern_index: 0,
194            },
195        ];
196        assert_eq!(
197            render_lines(source, spans),
198            [
199                "<a-c>/* &lt;</a-c>",
200                "<a-c> * x */</a-c>",
201                "<a-k>let</a-k> x = 1;"
202            ]
203        );
204    }
205}
206