Skip to main content

texl_core/
engine.rs

1//! The texl engine: documents, loaders and imports.
2//!
3//! An [`Engine`] renders templates and loads *documents*: templates identified
4//! by a URI. It owns
5//!
6//! - a **base scope** with the Swan built-ins and any globals added by the host
7//!   with [`Engine::add_builtin`];
8//! - a set of **loaders** that fetch the source of a document from its URI,
9//!   one per URI scheme ([`Engine::add_loader`]) plus a default loader for
10//!   paths without a scheme ([`Engine::add_default_loader`]);
11//! - a **cache** of documents, so that each document is loaded and rendered at
12//!   most once.
13//!
14//! # Documents
15//!
16//! Loading a document produces a namespace with four fields:
17//!
18//! | Field       | Type        | Content                                          |
19//! |-------------|-------------|--------------------------------------------------|
20//! | `uri`       | `Text`      | The normalized URI of the document               |
21//! | `source`    | `Text`      | The template source, as returned by the loader   |
22//! | `data`      | `Namespace` | The scope the template was rendered in           |
23//! | `rendering` | `Text`      | The rendered text                                |
24//!
25//! The `data` field holds every name the template assigned, so a document can
26//! act as a library of values and functions.
27//!
28//! # Imports
29//!
30//! Every scope created by the engine has a `texl` namespace containing
31//! `texl.import`. Templates call `texl.import(uri)` to load another document.
32//! It always returns a promise of the document namespace (or of `Nothing`, if
33//! the document cannot be loaded). Relative URIs resolve against the URI of the
34//! importing document, or against the base URL given to [`Engine::create_scope`];
35//! in a scope without a document, they resolve against the root path `/`. The imported document is rendered in a child of the
36//! importing scope, so it can read the names of its importer.
37//!
38//! # Example
39//!
40//! ```
41//! use swan_core::value::Value;
42//! use texl_core::{Engine, loader_fn};
43//!
44//! let engine = Engine::new(None);
45//! engine.add_builtin("reader", Value::Text("Ada".into()));
46//! engine.add_default_loader(loader_fn(|uri| async move {
47//!     match uri.as_str() {
48//!         "/greeting.texl" => Ok("Hello <% reader %>!".to_string()),
49//!         _ => Err(format!("{uri}: not found")),
50//!     }
51//! }));
52//!
53//! let text = block_on(engine.render(
54//!     r#"<% texl.import("greeting.texl") ~> (doc -> doc.rendering) %>"#
55//! ));
56//! assert_eq!(text, "Hello Ada!");
57//! # use std::{future::Future, pin::pin, task::{Context, Poll, Waker}};
58//! # fn block_on<F: Future>(fut: F) -> F::Output {
59//! #     let mut cx = Context::from_waker(Waker::noop());
60//! #     let mut fut = pin!(fut);
61//! #     loop {
62//! #         texl_core::helpers::run_pending();
63//! #         if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) { return v; }
64//! #     }
65//! # }
66//! ```
67
68use std::cell::RefCell;
69use std::collections::HashMap;
70use std::future::Future;
71use std::pin::Pin;
72use std::rc::Rc;
73use url::Url;
74
75use swan_core::builtins::root_scope;
76use swan_core::value::{BuiltinFn, Scope, Value};
77
78use crate::helpers::{promise_async, SpawnFn};
79use crate::template;
80
81/// A host-provided function that fetches the source of a document.
82///
83/// The engine calls it with the normalized URI of the document: a full URL
84/// (`https://host/dir/doc.texl`) for scheme loaders, or an absolute,
85/// percent-encoded path (`/dir/doc.texl`) for the default loader. The future
86/// resolves to the template source, or to an error message. The engine
87/// discards the message: a failed load gives `Nothing`.
88///
89/// Build one from an async closure with [`loader_fn`].
90pub type LoaderFn = Rc<dyn Fn(&str) -> Pin<Box<dyn Future<Output = Result<String, String>>>>>;
91
92/// Builds a [`LoaderFn`] from a closure that takes the URI and returns a future.
93///
94/// # Examples
95///
96/// ```
97/// use texl_core::loader_fn;
98///
99/// let loader = loader_fn(|uri| async move {
100///     Ok(format!("This is <% \"{uri}\" %>"))
101/// });
102/// ```
103pub fn loader_fn<F, Fut>(f: F) -> LoaderFn
104where
105    F: Fn(String) -> Fut + 'static,
106    Fut: Future<Output = Result<String, String>> + 'static,
107{
108    Rc::new(move |uri: &str| Box::pin(f(uri.to_string())))
109}
110
111/// Renders templates and loads documents. See the [module documentation](self).
112///
113/// `Engine` is a cheap handle: clones share the same base scope, loaders and
114/// cache. It is single-threaded (not `Send`).
115#[derive(Clone)]
116pub struct Engine {
117    base_scope: Scope,
118    loaders: Rc<RefCell<HashMap<String, LoaderFn>>>,
119    default_loader: Rc<RefCell<Option<LoaderFn>>>,
120    cache: Rc<RefCell<HashMap<String, Value>>>,
121    spawner: Rc<RefCell<Option<SpawnFn>>>,
122}
123
124impl Engine {
125    /// Creates an engine with no loaders and an empty cache.
126    ///
127    /// Imports run as background tasks. `spawner` hands those tasks to the
128    /// host's async runtime (for example `tokio::task::spawn_local`, or
129    /// `wasm_bindgen_futures::spawn_local`). With `None`, tasks go onto a
130    /// thread-local queue, and the host must call
131    /// [`helpers::run_pending`](crate::helpers::run_pending) to run them.
132    pub fn new(spawner: Option<SpawnFn>) -> Self {
133        Self {
134            base_scope: root_scope(),
135            loaders: Rc::new(RefCell::new(HashMap::new())),
136            default_loader: Rc::new(RefCell::new(None)),
137            cache: Rc::new(RefCell::new(HashMap::new())),
138            spawner: Rc::new(RefCell::new(spawner)),
139        }
140    }
141
142    /// Binds `name` to `value` in the base scope.
143    ///
144    /// The name is visible in every scope and document of this engine,
145    /// including those created earlier, unless a template shadows it. Use it to
146    /// pass data and host functions ([`Value::Builtin`]) to templates.
147    pub fn add_builtin(&self, name: &str, value: Value) {
148        self.base_scope.set(name, value);
149    }
150
151    /// Registers `loader` for the URIs whose scheme is `protocol` (for example
152    /// `"https"` or `"file"`, without the colon). The scheme is
153    /// case-insensitive. A later call for the same scheme replaces the loader.
154    pub fn add_loader(&self, protocol: &str, loader: LoaderFn) {
155        let scheme = protocol.to_lowercase();
156        self.loaders.borrow_mut().insert(scheme, loader);
157    }
158
159    /// Registers the loader for URIs without a scheme, such as `/dir/doc.texl`
160    /// or `doc.texl`. A later call replaces it.
161    pub fn add_default_loader(&self, loader: LoaderFn) {
162        *self.default_loader.borrow_mut() = Some(loader);
163    }
164
165    /// Loads and renders the document at `uri`.
166    ///
167    /// The URI is normalized (a scheme-less path becomes absolute, so `doc.texl`
168    /// is `/doc.texl`), then passed to the loader of its scheme, or to the
169    /// default loader if it has none. The source is rendered in a fresh child of
170    /// the base scope, and imports inside it resolve against the URI.
171    ///
172    /// This method always loads the document, but it stores the result in the
173    /// cache, so later imports of the same URI reuse it.
174    ///
175    /// # Returns
176    ///
177    /// - The document namespace `{uri, source, data, rendering}` (see the
178    ///   [module documentation](self)).
179    /// - `Nothing` if no loader matches the URI or the loader fails.
180    /// - `Undefined` if the URI cannot be normalized.
181    ///
182    /// # Examples
183    ///
184    /// ```
185    /// use swan_core::value::Value;
186    /// use texl_core::{Engine, loader_fn};
187    ///
188    /// let engine = Engine::new(None);
189    /// engine.add_loader("mem", loader_fn(|_uri| async { Ok("<% x = 1 %>x is <% x %>".to_string()) }));
190    ///
191    /// let Value::Namespace(doc) = block_on(engine.load("mem://doc")) else { panic!() };
192    /// assert_eq!(doc.get("rendering"), Value::Text("x is 1".into()));
193    /// assert_eq!(doc.get("uri"), Value::Text("mem://doc".into()));
194    /// # use std::{future::Future, pin::pin, task::{Context, Poll, Waker}};
195    /// # fn block_on<F: Future>(fut: F) -> F::Output {
196    /// #     let mut cx = Context::from_waker(Waker::noop());
197    /// #     let mut fut = pin!(fut);
198    /// #     loop {
199    /// #         texl_core::helpers::run_pending();
200    /// #         if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) { return v; }
201    /// #     }
202    /// # }
203    /// ```
204    pub async fn load(&self, uri: &str) -> Value {
205        let norm_url = match self.normalize_url(uri) {
206            Ok(url) => url,
207            Err(err) => return Value::undef_unary("import.invalid_uri", Value::Text(err.into()), 0),
208        };
209        // Relative imports inside the loaded document resolve against its own URL
210        let doc_scope = self.create_scope(Some(norm_url.clone()));
211        self.load_internal(doc_scope, norm_url).await
212    }
213
214    /// Renders `template` in a fresh scope (see [`Engine::create_scope`]) and
215    /// returns the rendered text.
216    ///
217    /// Names assigned by the template are discarded afterwards. To keep them,
218    /// create a scope and call [`template::render`] directly.
219    ///
220    /// # Examples
221    ///
222    /// ```
223    /// use texl_core::Engine;
224    ///
225    /// let engine = Engine::new(None);
226    /// assert_eq!(block_on(engine.render("1 + 1 = <% 1 + 1 %>")), "1 + 1 = 2");
227    /// # use std::{future::Future, pin::pin, task::{Context, Poll, Waker}};
228    /// # fn block_on<F: Future>(fut: F) -> F::Output {
229    /// #     let mut cx = Context::from_waker(Waker::noop());
230    /// #     let mut fut = pin!(fut);
231    /// #     loop {
232    /// #         texl_core::helpers::run_pending();
233    /// #         if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) { return v; }
234    /// #     }
235    /// # }
236    /// ```
237    pub async fn render(&self, template: &str) -> String {
238        let scope = self.create_scope(None);
239        template::render(template, &scope).await
240    }
241
242    /// Creates a new child of the base scope with its own `texl` namespace.
243    ///
244    /// `base_url` is the URI of the document the scope stands for: `texl.import`
245    /// resolves relative URIs against it, as in a document loaded with
246    /// [`Engine::load`]. It is normalized first, so `docs/main.texl` is
247    /// `/docs/main.texl`. With `None`, the scope has no document and relative
248    /// URIs resolve against the root path `/`.
249    ///
250    /// Use it to render a document whose source the host already has, or to
251    /// render several templates in the same scope with [`template::render`] and
252    /// share names between them. The scope is not a loaded document: nothing is
253    /// loaded or cached for `base_url`.
254    ///
255    /// # Examples
256    ///
257    /// ```
258    /// use texl_core::{Engine, loader_fn, template};
259    ///
260    /// let engine = Engine::new(None);
261    /// engine.add_default_loader(loader_fn(|uri| async move {
262    ///     match uri.as_str() {
263    ///         "/docs/name.texl" => Ok("Ada".to_string()),
264    ///         _ => Err(format!("{uri}: not found")),
265    ///     }
266    /// }));
267    ///
268    /// let scope = engine.create_scope(Some("docs/main.texl".to_string()));
269    /// let source = r#"Hello <% texl.import("name.texl") ~> (doc -> doc.rendering) %>!"#;
270    /// assert_eq!(block_on(template::render(source, &scope)), "Hello Ada!");
271    /// # use std::{future::Future, pin::pin, task::{Context, Poll, Waker}};
272    /// # fn block_on<F: Future>(fut: F) -> F::Output {
273    /// #     let mut cx = Context::from_waker(Waker::noop());
274    /// #     let mut fut = pin!(fut);
275    /// #     loop {
276    /// #         texl_core::helpers::run_pending();
277    /// #         if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) { return v; }
278    /// #     }
279    /// # }
280    /// ```
281    pub fn create_scope(&self, base_url: Option<String>) -> Scope {
282        // Create a child so document executions do not pollute the base_scope
283        let child = self.base_scope.new_child();
284
285        // Without a document, relative imports resolve against the root `/`
286        let base_url = base_url.map(|url| self.normalize_url(&url).unwrap_or(url));
287        self.bind_texl_namespace(&child, base_url);
288
289        child
290    }
291
292    // --- Internal Helpers ---
293
294    /// Attaches a standalone `texl` namespace to `scope`, with an `import` builtin
295    /// that resolves relative paths against `base_url` (the URL of the document owning `scope`)
296    fn bind_texl_namespace(&self, scope: &Scope, base_url: Option<String>) {
297        let texl_ns = Scope::new();
298        texl_ns.set("import", self.create_import_builtin(scope.clone(), base_url));
299        scope.set("texl", Value::Namespace(texl_ns));
300    }
301
302    /// Creates the `texl.import` builtin bound to a specific document scope
303    fn create_import_builtin(&self, parent_scope: Scope, base_url: Option<String>) -> Value {
304        let engine = self.clone();
305        Value::Builtin(BuiltinFn::new("texl.import", move |arg| {
306            match arg {
307                Value::Text(uri) => {
308                    let norm_url = match engine.resolve_url(&uri, base_url.as_deref()) {
309                        Ok(url) => url,
310                        Err(err) => return Value::undef_unary("import.invalid_uri", Value::Text(err.into()), 0),
311                    };
312
313                    // Deduplicate in-flight and already loaded requests
314                    if let Some(cached) = engine.cache.borrow().get(&norm_url) {
315                        // ALWAYS return a Promise so the `~>` operator works consistently.
316                        // If it's already a resolved Namespace, wrap it in a resolved Promise.
317                        return if matches!(cached, Value::Promise(_)) {
318                            cached.clone()
319                        } else {
320                            let p = swan_core::value::Promise::new();
321                            p.resolve(cached.clone());
322                            Value::Promise(p)
323                        };
324                    }
325
326                    let engine_clone = engine.clone();
327                    let norm_url_key = norm_url.clone();
328                    let parent_scope_clone = parent_scope.clone();
329
330                    let spawner = engine.spawner.borrow().clone();
331                    let promise = promise_async(
332                        spawner.as_ref(),
333                        async move {
334                            // 1. Create a child scope so the document inherits the caller's variables
335                            let doc_scope = parent_scope_clone.new_child();
336                            
337                            // 2. Bind the texl namespace to the new document scope, using the
338                            //    imported document's URL as base for its own relative imports
339                            engine_clone.bind_texl_namespace(&doc_scope, Some(norm_url_key.clone()));
340
341                            engine_clone.load_internal(doc_scope, norm_url_key).await
342                        },
343                    );
344
345                    engine.cache.borrow_mut().insert(norm_url, promise.clone());
346                    promise
347                }
348                other => Value::undef_unary("texl.import", other, 0),
349            }
350        }))
351    }
352
353    /// Core async loading logic used by both `load` and `texl.import`
354    /// `norm_url` is the resolved and normalized URL: it is what the loader receives and the cache key.
355    async fn load_internal(&self, doc_scope: Scope, norm_url: String) -> Value {
356        let loader = if let Ok(parsed) = Url::parse(&norm_url) {
357            let scheme = parsed.scheme().to_lowercase();
358            let l = self.loaders.borrow().get(&scheme).cloned();
359            match l {
360                Some(loader) => loader,
361                None => {
362                    self.cache.borrow_mut().remove(&norm_url);
363                    return Value::Nothing; // Silent failure per previous import logic
364                }
365            }
366        } else {
367            let l = self.default_loader.borrow().clone();
368            match l {
369                Some(loader) => loader,
370                None => {
371                    self.cache.borrow_mut().remove(&norm_url);
372                    return Value::Nothing;
373                }
374            }
375        };
376
377        match loader(&norm_url).await {
378            Ok(source) => {
379                let rendering = template::render(&source, &doc_scope).await;
380
381                let result = Scope::new();
382                result.set("uri", Value::Text(norm_url.clone().into()));
383                result.set("source", Value::Text(source.into()));
384                result.set("data", Value::Namespace(doc_scope));
385                result.set("rendering", Value::Text(rendering.into()));
386
387                let result_val = Value::Namespace(result);
388                
389                // Overwrite the pending Promise in the cache with the resolved Namespace
390                self.cache.borrow_mut().insert(norm_url, result_val.clone());
391                result_val
392            }
393            Err(_) => {
394                self.cache.borrow_mut().remove(&norm_url);
395                Value::Nothing
396            }
397        }
398    }
399    /// Resolves `uri` against `base_url` (the URL of the importing document) and normalizes it.
400    /// URIs with a scheme ignore the base. Absolute paths (starting with `/`) resolve against
401    /// the root of a full-URI base (`/p` from `http://host/dir/doc` is `http://host/p`), and
402    /// stay scheme-less otherwise. Without a base, relative paths resolve against the root `/`.
403    fn resolve_url(&self, uri: &str, base_url: Option<&str>) -> Result<String, String> {
404        let base = match base_url {
405            Some(base) if Url::parse(uri).is_err() => base,
406            _ => return self.normalize_url(uri),
407        };
408
409        if let Ok(base) = Url::parse(base) {
410            // The base is a full URI (e.g. `http://host/dir/doc.texl`)
411            base.join(uri).map(|u| u.to_string()).map_err(|e| e.to_string())
412        } else if uri.starts_with('/') {
413            self.normalize_url(uri)
414        } else {
415            // The base is a scheme-less path: resolve it under a dummy scheme, then strip it
416            let dummy_base = Url::parse(&format!("app:///{}", base.trim_start_matches('/')))
417                .map_err(|e| e.to_string())?;
418            let joined = dummy_base.join(uri).map_err(|e| e.to_string())?;
419            let mut norm = joined.path().to_string();
420            if let Some(q) = joined.query() {
421                norm.push('?');
422                norm.push_str(q);
423            }
424            Ok(norm)
425        }
426    }
427
428    fn normalize_url(&self, uri: &str) -> Result<String, String> {
429        if let Ok(parsed) = Url::parse(uri) {
430            Ok(parsed.to_string())
431        } else {
432            if let Ok(dummy) = Url::parse(&format!("app:///{}", uri.trim_start_matches('/'))) {
433                let mut norm = dummy.path().to_string();
434                if let Some(q) = dummy.query() {
435                    norm.push('?');
436                    norm.push_str(q);
437                }
438                Ok(norm)
439            } else {
440                Ok(uri.trim().to_string())
441            }
442        }
443    }
444}
445
446
447
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use std::cell::Cell;
453    use std::pin::pin;
454    use std::sync::Arc;
455    use std::task::{Context, Poll, Wake, Waker};
456    use swan_core::value::Value;
457
458    // --- Async Test Helpers ---
459
460    struct NoopWake;
461    impl Wake for NoopWake {
462        fn wake(self: Arc<Self>) {}
463    }
464
465    /// Minimal busy-polling executor that drives a future to completion on the current thread.
466    fn block_on<F: Future>(fut: F) -> F::Output {
467        let waker = Waker::from(Arc::new(NoopWake));
468        let mut cx = Context::from_waker(&waker);
469        let mut fut = pin!(fut);
470        loop {
471            // Also pump the fallback spawner queue so nested promises resolve
472            crate::helpers::run_pending();
473            
474            if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) {
475                return v;
476            }
477        }
478    }
479
480    /// Test helper to extract a namespace from a Value or panic
481    fn unwrap_namespace(val: Value) -> Scope {
482        match val {
483            Value::Namespace(ns) => ns,
484            other => panic!("Expected Namespace, got {:?}", other),
485        }
486    }
487
488    // --- Tests ---
489
490    #[test]
491    fn test_engine_renders_basic_template() {
492        let engine = Engine::new(None);
493        let out = block_on(engine.render("Hello <% 10 + 20 %>!"));
494        assert_eq!(out, "Hello 30!");
495    }
496
497    #[test]
498    fn test_engine_injects_builtins() {
499        let engine = Engine::new(None);
500        engine.add_builtin("system_name", Value::Text("TexlCore".into()));
501        engine.add_builtin("version", Value::Numb(2.0));
502
503        let out = block_on(engine.render("<% system_name %> v<% version %>"));
504        assert_eq!(out, "TexlCore v2");
505    }
506
507    #[test]
508    fn test_engine_routes_loaders_by_scheme() {
509        let engine = Engine::new(None);
510        
511        engine.add_loader("http", loader_fn(|uri| async move {
512            Ok(format!("HTTP source for {}", uri))
513        }));
514        
515        engine.add_default_loader(loader_fn(|uri| async move {
516            Ok(format!("Default source for {}", uri))
517        }));
518
519        // HTTP scheme routed to the 'http' loader
520        let http_res = block_on(engine.load("http://example.com/doc.texl"));
521        let http_ns = unwrap_namespace(http_res);
522        assert_eq!(http_ns.get("source"), Value::Text("HTTP source for http://example.com/doc.texl".into()));
523
524        // Scheme-less URI routed to the default loader
525        let file_res = block_on(engine.load("/local/file.texl"));
526        let file_ns = unwrap_namespace(file_res);
527        assert_eq!(file_ns.get("source"), Value::Text("Default source for /local/file.texl".into()));
528    }
529
530    #[test]
531    fn test_engine_load_returns_complete_namespace() {
532        let engine = Engine::new(None);
533        engine.add_builtin("multiplier", Value::Numb(5.0));
534        
535        engine.add_default_loader(loader_fn(|_| async move {
536            Ok("Result is <% result = 10 * multiplier, result %>".to_string())
537        }));
538
539        let load_result = block_on(engine.load("/math.texl"));
540        let ns = unwrap_namespace(load_result);
541
542        assert_eq!(ns.get("uri"), Value::Text("/math.texl".into()));
543        assert_eq!(ns.get("rendering"), Value::Text("Result is 50".into()));
544        
545        // Ensure the document data contains the assignments
546        let doc_data = unwrap_namespace(ns.get("data"));
547        assert_eq!(doc_data.get("result"), Value::Numb(50.0));
548    }
549
550    #[test]
551    fn test_engine_caches_internal_imports_and_deduplicates() {
552        let engine = Engine::new(None);
553        let call_count = Rc::new(Cell::new(0));
554        let c = call_count.clone();
555
556        engine.add_default_loader(loader_fn(move |uri| {
557            c.set(c.get() + 1);
558            let content = if uri.contains("main") {
559                // CORRECTED: Use Swan's `~>` operator to chain the promise and extract the `.rendering` field
560                r#"Main loads <% texl.import("/shared.texl") ~> (ns -> ns.rendering) %> twice: <% texl.import("/shared.texl") ~> (ns -> ns.rendering) %>"#
561            } else {
562                "Shared!"
563            };
564            let content_str = content.to_string();
565            async move { Ok(content_str) }
566        }));
567
568        // Render the main template, also using the `~>` operator
569        let rendered = block_on(engine.render(r#"<% texl.import("/main.texl") ~> (ns -> ns.rendering) %>"#));
570        
571        assert_eq!(rendered, "Main loads Shared! twice: Shared!");
572        
573        // main.texl loaded once, shared.texl loaded once, despite two `texl.import` calls
574        assert_eq!(call_count.get(), 2);
575    }
576
577    /// Builds an engine whose loaders serve sources from `docs` (keyed by the URI the loader
578    /// receives) and record every URI they are asked to load.
579    fn engine_with_docs(docs: &[(&str, &str)]) -> (Engine, Rc<RefCell<Vec<String>>>) {
580        let engine = Engine::new(None);
581        let docs: Rc<HashMap<String, String>> = Rc::new(
582            docs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
583        );
584        let requested = Rc::new(RefCell::new(Vec::new()));
585
586        let make_loader = || {
587            let docs = docs.clone();
588            let requested = requested.clone();
589            loader_fn(move |uri| {
590                requested.borrow_mut().push(uri.clone());
591                let res = docs.get(&uri).cloned().ok_or(format!("not found: {}", uri));
592                async move { res }
593            })
594        };
595        engine.add_default_loader(make_loader());
596        engine.add_loader("http", make_loader());
597        (engine, requested)
598    }
599
600    const RENDERING: &str = "~> (ns -> ns.rendering)";
601
602    #[test]
603    fn test_engine_resolves_relative_imports_against_importing_document() {
604        let main = format!(r#"[main <% texl.import("parts/header.texl") {RENDERING} %>]"#);
605        let header = format!(r#"[header <% texl.import("../footer.texl") {RENDERING} %>]"#);
606        let footer = format!(r#"[footer <% texl.import("./parts/../parts/note.texl") {RENDERING} %>]"#);
607        let (engine, requested) = engine_with_docs(&[
608            ("/docs/main.texl", &main),
609            ("/docs/parts/header.texl", &header),
610            ("/docs/footer.texl", &footer),
611            ("/docs/parts/note.texl", "note"),
612        ]);
613
614        let out = block_on(engine.render(&format!(r#"<% texl.import("/docs/main.texl") {RENDERING} %>"#)));
615
616        assert_eq!(out, "[main [header [footer note]]]");
617        assert_eq!(
618            *requested.borrow(),
619            vec!["/docs/main.texl", "/docs/parts/header.texl", "/docs/footer.texl", "/docs/parts/note.texl"]
620        );
621    }
622
623    #[test]
624    fn test_engine_load_resolves_relative_imports_against_loaded_document() {
625        let main = format!(r#"<% texl.import("lib.texl") {RENDERING} %>"#);
626        let (engine, requested) = engine_with_docs(&[("/a/b/main.texl", &main), ("/a/b/lib.texl", "lib")]);
627
628        let ns = unwrap_namespace(block_on(engine.load("/a/b/main.texl")));
629
630        assert_eq!(ns.get("rendering"), Value::Text("lib".into()));
631        assert_eq!(*requested.borrow(), vec!["/a/b/main.texl", "/a/b/lib.texl"]);
632    }
633
634    #[test]
635    fn test_engine_absolute_paths_and_uris_ignore_importing_document() {
636        let main = format!(
637            r#"<% texl.import("/top.texl") {RENDERING} %>,<% texl.import("http://example.com/x/remote.texl") {RENDERING} %>"#
638        );
639        let (engine, requested) = engine_with_docs(&[
640            ("/docs/main.texl", &main),
641            ("/top.texl", "top"),
642            ("http://example.com/x/remote.texl", "remote"),
643        ]);
644
645        let out = block_on(engine.render(&format!(r#"<% texl.import("/docs/main.texl") {RENDERING} %>"#)));
646
647        assert_eq!(out, "top,remote");
648        assert_eq!(
649            *requested.borrow(),
650            vec!["/docs/main.texl", "/top.texl", "http://example.com/x/remote.texl"]
651        );
652    }
653
654    #[test]
655    fn test_engine_resolves_relative_imports_against_uri_documents() {
656        let main = format!(r#"<% texl.import("../lib/util.texl?v=1") {RENDERING} %>"#);
657        let (engine, requested) = engine_with_docs(&[
658            ("http://example.com/site/pages/main.texl", &main),
659            ("http://example.com/site/lib/util.texl?v=1", "util"),
660        ]);
661
662        let ns = unwrap_namespace(block_on(engine.load("http://example.com/site/pages/main.texl")));
663
664        assert_eq!(ns.get("rendering"), Value::Text("util".into()));
665        assert_eq!(
666            *requested.borrow(),
667            vec!["http://example.com/site/pages/main.texl", "http://example.com/site/lib/util.texl?v=1"]
668        );
669    }
670
671    #[test]
672    fn test_engine_resolves_absolute_paths_against_the_root_of_uri_documents() {
673        let main = format!(r#"<% texl.import("/lib/util.texl") {RENDERING} %>"#);
674        let (engine, requested) = engine_with_docs(&[
675            ("http://example.com/site/pages/main.texl", &main),
676            ("http://example.com/lib/util.texl", "util"),
677        ]);
678
679        let ns = unwrap_namespace(block_on(engine.load("http://example.com/site/pages/main.texl")));
680
681        assert_eq!(ns.get("rendering"), Value::Text("util".into()));
682        assert_eq!(
683            *requested.borrow(),
684            vec!["http://example.com/site/pages/main.texl", "http://example.com/lib/util.texl"]
685        );
686    }
687
688    #[test]
689    fn test_engine_relative_imports_without_document_resolve_against_root() {
690        let (engine, requested) = engine_with_docs(&[("/dir/doc.texl", "doc")]);
691
692        let out = block_on(engine.render(&format!(r#"<% texl.import("dir/doc.texl") {RENDERING} %>"#)));
693
694        assert_eq!(out, "doc");
695        assert_eq!(*requested.borrow(), vec!["/dir/doc.texl"]);
696    }
697
698    #[test]
699    fn test_engine_scope_with_base_url_resolves_relative_imports_against_it() {
700        let (engine, requested) = engine_with_docs(&[
701            ("/docs/lib.texl", "lib"),
702            ("/top.texl", "top"),
703            ("http://example.com/site/lib.texl", "remote lib"),
704            ("http://example.com/top.texl", "remote top"),
705        ]);
706        let source = format!(r#"<% texl.import("lib.texl") {RENDERING} %>,<% texl.import("/top.texl") {RENDERING} %>"#);
707
708        // A scheme-less base URL is normalized like the URI of a loaded document
709        let scope = engine.create_scope(Some("docs/main.texl".to_string()));
710        assert_eq!(block_on(template::render(&source, &scope)), "lib,top");
711
712        let scope = engine.create_scope(Some("http://example.com/site/main.texl".to_string()));
713        assert_eq!(block_on(template::render(&source, &scope)), "remote lib,remote top");
714
715        assert_eq!(
716            *requested.borrow(),
717            vec!["/docs/lib.texl", "/top.texl", "http://example.com/site/lib.texl", "http://example.com/top.texl"]
718        );
719    }
720
721    #[test]
722    fn test_engine_scope_with_base_url_renders_like_the_loaded_document() {
723        let main = format!(r#"<% x = 1 %><% texl.import("lib.texl") {RENDERING} %>"#);
724        let (engine, requested) = engine_with_docs(&[("/a/main.texl", &main), ("/a/lib.texl", "lib <% x %>")]);
725
726        let scope = engine.create_scope(Some("/a/main.texl".to_string()));
727        let rendering = block_on(template::render(&main, &scope));
728        let ns = unwrap_namespace(block_on(engine.load("/a/main.texl")));
729
730        assert_eq!(rendering, "lib 1");
731        assert_eq!(ns.get("rendering"), Value::Text(rendering.into()));
732        // The scope does not load or cache the document it stands for; the import is cached
733        assert_eq!(*requested.borrow(), vec!["/a/lib.texl", "/a/main.texl"]);
734    }
735
736    #[test]
737    fn test_engine_scope_without_base_url_resolves_relative_imports_against_root() {
738        let (engine, requested) = engine_with_docs(&[("/dir/doc.texl", "doc")]);
739
740        let scope = engine.create_scope(None);
741        let out = block_on(template::render(&format!(r#"<% texl.import("dir/doc.texl") {RENDERING} %>"#), &scope));
742
743        assert_eq!(out, "doc");
744        assert_eq!(*requested.borrow(), vec!["/dir/doc.texl"]);
745    }
746
747    #[test]
748    fn test_engine_caches_relative_imports_by_resolved_url() {
749        // Same relative name from two different folders: two distinct documents
750        let a = format!(r#"<% texl.import("common.texl") {RENDERING} %>"#);
751        let b = format!(r#"<% texl.import("common.texl") {RENDERING} %>"#);
752        // Different relative names resolving to the same document: one load
753        let c = format!(r#"<% texl.import("../a/common.texl") {RENDERING} %>"#);
754        let (engine, requested) = engine_with_docs(&[
755            ("/a/main.texl", &a),
756            ("/b/main.texl", &b),
757            ("/c/main.texl", &c),
758            ("/a/common.texl", "A"),
759            ("/b/common.texl", "B"),
760        ]);
761
762        let out = block_on(engine.render(&format!(
763            r#"<% texl.import("/a/main.texl") {RENDERING} %><% texl.import("/b/main.texl") {RENDERING} %><% texl.import("/c/main.texl") {RENDERING} %>"#
764        )));
765
766        assert_eq!(out, "ABA");
767        assert_eq!(
768            *requested.borrow(),
769            vec!["/a/main.texl", "/a/common.texl", "/b/main.texl", "/b/common.texl", "/c/main.texl"]
770        );
771    }
772
773    #[test]
774    fn test_engine_handles_missing_loader_gracefully() {
775        let engine = Engine::new(None);
776        // We purposefully do not add an ftp loader or a default loader
777        
778        let out = block_on(engine.load("ftp://example.com/file.texl"));
779        
780        // Should gracefully fail to Value::Nothing per the import protocol
781        assert_eq!(out, Value::Nothing);
782    }
783}