char/arbrisseau

use wasmi to download and execute arborium grammars

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

Charlotte Somheavy simplify; throw out initialized flag + runtime heap impls + gc59ec5cd

main
6.7 KiB227 linesraw
1//! Async Arborium WASM highlighting powered by Wasmi.
2//!
3//! Callers own grammar retrieval and integrity policy through [`ModuleLoader`].
4//! Arbrisseau caches initialized grammars in [`Options`] and resolves injections recursively.
5
6mod runtime;
7
8use std::collections::HashMap;
9use std::error::Error as StdError;
10use std::future::Future;
11use std::pin::Pin;
12use std::sync::{Arc, Mutex};
13
14use arborium_highlight::{Injection, ParseResult};
15use async_lock::OnceCell;
16
17pub use arborium_highlight::Span;
18
19const MAX_INJECTION_DEPTH: u32 = 3;
20
21pub type LoaderError = Box<dyn StdError + Send + Sync + 'static>;
22pub type Result<T> = std::result::Result<T, Error>;
23
24#[derive(Debug, thiserror::Error)]
25pub enum Error {
26    #[error("unsupported language {language:?}")]
27    UnsupportedLanguage { language: String },
28
29    #[error("failed to load {language} grammar")]
30    LoadModule {
31        language: String,
32        #[source]
33        source: LoaderError,
34    },
35
36    #[error("failed to initialize {language} grammar")]
37    Initialize {
38        language: String,
39        #[source]
40        source: wasmi::Error,
41    },
42
43    #[error("failed to parse {language} source")]
44    Parse {
45        language: String,
46        #[source]
47        source: wasmi::Error,
48    },
49}
50
51/// The asynchronous result of loading a grammar's raw WebAssembly bytes.
52/// `None` means that the requested language is unsupported.
53pub type ModuleFuture<'a> =
54    Pin<Box<dyn Future<Output = std::result::Result<Option<Vec<u8>>, LoaderError>> + Send + 'a>>;
55
56/// Loads a grammar from a caller-defined source.
57pub type ModuleLoader = fn(language: &str) -> ModuleFuture;
58
59/// Shared configuration and initialized-grammar cache for highlighting calls.
60pub struct Options {
61    /// Callback used when a language's module is first needed.
62    pub load_module: ModuleLoader,
63    /// A cached `None` marks a language the loader reported as unsupported.
64    grammars: Mutex<HashMap<String, Arc<OnceCell<Option<runtime::Grammar>>>>>,
65}
66
67impl Options {
68    /// Creates options with an initially empty in-memory grammar cache.
69    pub fn new(load_module: ModuleLoader) -> Self {
70        Self {
71            load_module,
72            grammars: Mutex::new(HashMap::new()),
73        }
74    }
75
76    async fn runtime(&self, language: &str) -> Result<Option<runtime::Runtime>> {
77        // Cloning the cell out releases the map lock, so unrelated languages
78        // stay loadable while this one initializes.
79        let cell = self
80            .grammars
81            .lock()
82            .unwrap_or_else(|error| error.into_inner())
83            .entry(language.to_owned())
84            .or_default()
85            .clone();
86        let grammar = cell
87            .get_or_try_init(|| async {
88                let bytes =
89                    (self.load_module)(language)
90                        .await
91                        .map_err(|source| Error::LoadModule {
92                            language: language.to_owned(),
93                            source,
94                        })?;
95                bytes
96                    .as_deref()
97                    .map(runtime::Grammar::new)
98                    .transpose()
99                    .map_err(|source| Error::Initialize {
100                        language: language.to_owned(),
101                        source,
102                    })
103            })
104            .await?;
105        grammar
106            .as_ref()
107            .map(runtime::Grammar::runtime)
108            .transpose()
109            .map_err(|source| Error::Initialize {
110                language: language.to_owned(),
111                source,
112            })
113    }
114}
115
116/// Reuses Wasmi instances and parser sessions across highlighting calls.
117#[derive(Default)]
118pub struct Highlighter {
119    runtimes: HashMap<String, runtime::Runtime>,
120}
121
122impl Highlighter {
123    pub fn new() -> Self {
124        Self::default()
125    }
126
127    async fn parse(
128        &mut self,
129        language: &str,
130        source: &str,
131        options: &Options,
132    ) -> Result<Option<ParseResult>> {
133        if !self.runtimes.contains_key(language) {
134            let Some(runtime) = options.runtime(language).await? else {
135                return Ok(None);
136            };
137            self.runtimes.insert(language.to_owned(), runtime);
138        }
139        match self.runtimes.get_mut(language).unwrap().parse(source) {
140            Ok(result) => Ok(Some(result)),
141            Err(source) => {
142                // A trap leaves guest state arbitrary, so discard the session.
143                self.runtimes.remove(language);
144                Err(Error::Parse {
145                    language: language.to_owned(),
146                    source,
147                })
148            }
149        }
150    }
151
152    /// Highlights a source block and recursively includes spans from injected languages.
153    pub async fn highlight(
154        &mut self,
155        language: &str,
156        source: &str,
157        options: &Options,
158    ) -> Result<Vec<Span>> {
159        let result = self
160            .parse(language, source, options)
161            .await?
162            .ok_or_else(|| Error::UnsupportedLanguage {
163                language: language.to_owned(),
164            })?;
165        let mut spans = result.spans;
166        let mut pending = Vec::new();
167        push_injections(
168            &mut pending,
169            source,
170            result.injections,
171            0,
172            MAX_INJECTION_DEPTH,
173        );
174
175        while let Some(injection) = pending.pop() {
176            let Some(result) = self
177                .parse(&injection.language, injection.text, options)
178                .await?
179            else {
180                continue;
181            };
182            spans.extend(result.spans.into_iter().map(|mut span| {
183                span.start += injection.offset;
184                span.end += injection.offset;
185                span
186            }));
187            push_injections(
188                &mut pending,
189                injection.text,
190                result.injections,
191                injection.offset,
192                injection.depth,
193            );
194        }
195
196        Ok(spans)
197    }
198}
199
200struct PendingInjection<'a> {
201    language: String,
202    text: &'a str,
203    offset: u32,
204    depth: u32,
205}
206
207/// Queues injections in reverse so that popping the stack visits them in source order.
208fn push_injections<'a>(
209    pending: &mut Vec<PendingInjection<'a>>,
210    source: &'a str,
211    injections: Vec<Injection>,
212    offset: u32,
213    depth: u32,
214) {
215    if depth == 0 {
216        return;
217    }
218    pending.extend(injections.into_iter().rev().filter_map(|injection| {
219        let text = source.get(injection.start as usize..injection.end as usize)?;
220        (!text.is_empty()).then_some(PendingInjection {
221            language: injection.language,
222            text,
223            offset: offset + injection.start,
224            depth: depth - 1,
225        })
226    }));
227}