Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_austenite/tests/content_bindings.rs

16.3 KiB, 9 runs

created by r1870400018:58920, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Do general `#let` content bindings evaluate, and does `#import` resolve on every compile path -- and do
2//! the pathological forms a live editor can type get refused rather than hang, lose words, or leak raw `#`?
3//!
4//! These tests drive the SAME [`compile::assemble`] the wasm surface calls, over an injected [`crate::vfs`]
5//! source map, so they are the native proof of the browser behaviour -- exactly as `font_inject.rs` is for
6//! injected fonts. The source-map global is process-wide, so a [`Mutex`] serialises the compiles (the
7//! harness's own guard against two installs racing); a non-existent `/__vfs__/` prefix keeps a map miss from
8//! falling through to a real file on the build host.
9
10use oxedyne_fe2o3_austenite::compile;
11use oxedyne_fe2o3_austenite::doc::{
12 Block,
13 Segment,
14};
15use oxedyne_fe2o3_austenite::fonts;
16use oxedyne_fe2o3_austenite::vfs;
17
18use oxedyne_fe2o3_core::prelude::*;
19
20use std::collections::HashMap;
21use std::path::PathBuf;
22use std::sync::{
23 Arc,
24 Mutex,
25};
26
27/// Serialises access to the process-wide source-map global, so the tests in this binary compile one at a
28/// time rather than racing on the shared map.
29static VFS_LOCK: Mutex<()> = Mutex::new(());
30
31/// The plain text of a block -- a paragraph's text, or a heading's segments flattened -- for a content
32/// assertion. A block that carries no running text (a rule, an image) yields the empty string.
33fn block_text(block: &Block) -> String {
34 match block {
35 Block::Paragraph { text } => text.clone(),
36 Block::RichParagraph { segments }
37 | Block::Heading { segments, .. } => segments_text(segments),
38 _ => String::new(),
39 }
40}
41
42/// The plain text of a run of segments, keeping the words and dropping the styling.
43fn segments_text(segments: &[Segment]) -> String {
44 let mut out = String::new();
45 for seg in segments {
46 match seg {
47 Segment::Text(t)
48 | Segment::Strong(t)
49 | Segment::Emph(t)
50 | Segment::BoldItalic(t)
51 | Segment::Super(t)
52 | Segment::Sub(t)
53 | Segment::Code(t) => out.push_str(t),
54 _ => {},
55 }
56 }
57 out
58}
59
60/// Assembles the document rooted at `/__vfs__/main.typ` from an injected source map, returning its body
61/// blocks and the names of every construct the reader refused. Mirrors the wasm surface: the map is
62/// installed, [`compile::assemble`] runs with the embedded Libertinus as the lone-file reading set, and the
63/// map is cleared afterwards whatever the outcome. Serialised on [`VFS_LOCK`].
64fn assemble_full(sources: &[(&str, &str)]) -> Outcome<(Vec<Block>, Vec<String>)> {
65 let _guard = VFS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
66 let mut files: HashMap<PathBuf, Vec<u8>> = HashMap::new();
67 for (path, src) in sources {
68 files.insert(PathBuf::from(path), src.as_bytes().to_vec());
69 }
70 res!(vfs::install(files));
71 let fonts = Arc::new(res!(fonts::libertinus()));
72 let outcome = compile::assemble(&PathBuf::from("/__vfs__/main.typ"), || Ok(fonts.clone()));
73 let _ = vfs::clear();
74 let (assembled, refusals, _skip) = res!(outcome);
75 let names = refusals.entries().into_iter().map(|(n, _)| n).collect();
76 Ok((assembled.blocks, names))
77}
78
79/// [`assemble_full`] keeping only the blocks.
80fn assemble_blocks(sources: &[(&str, &str)]) -> Outcome<Vec<Block>> {
81 Ok(res!(assemble_full(sources)).0)
82}
83
84/// Flattens the block tree into a leaf sequence, descending through the `Scoped`/`Box` wrappers the rule
85/// engine and furniture set around a heading or body -- so an assertion sees the heading itself, not the
86/// scope that carries its re-asserted size.
87fn flatten(blocks: &[Block]) -> Vec<&Block> {
88 let mut out = Vec::new();
89 for b in blocks {
90 match b {
91 Block::Scoped { blocks, .. }
92 | Block::Box { blocks, .. } => out.extend(flatten(blocks)),
93 _ => out.push(b),
94 }
95 }
96 out
97}
98
99/// Does any (flattened) block yield exactly `text`?
100fn has_block_text(blocks: &[Block], text: &str) -> bool {
101 flatten(blocks).into_iter().any(|b| block_text(b) == text)
102}
103
104/// Does any (flattened) block yield `text` and carry heading level `level`?
105fn has_heading(blocks: &[Block], text: &str, level: u8) -> bool {
106 flatten(blocks).into_iter().any(|b| matches!(b,
107 Block::Heading { level: l, segments, .. } if *l == level && segments_text(segments) == text))
108}
109
110/// No (flattened) block leaks raw markup beginning with `#`.
111fn assert_no_hash_leak(blocks: &[Block]) -> Outcome<()> {
112 for b in flatten(blocks) {
113 let t = block_text(b);
114 if t.trim_start().starts_with('#') {
115 return Err(err!("a block leaked raw markup beginning with `#`: {:?}", t; Test, Mismatch));
116 }
117 }
118 Ok(())
119}
120
121/// Content bindings evaluate and imports resolve on both paths. Fixture 1 is daimond-a's repro (a doc root
122/// with an `#include`, importing a content function); fixture 2 is the lone path (a bare `#one` against an
123/// imported value binding) -- the two things the lone path could not do before. Without this lane's change
124/// fixture 1 yields only "Doc".
125#[test]
126fn content_bindings_evaluate_and_imports_resolve_on_every_path() -> Outcome<()> {
127 let blocks1 = res!(assemble_blocks(&[
128 ("/__vfs__/main.typ",
129 "#import \"tmpl.typ\": greet\n\n= Doc\n\n#greet(\"world\")\n\n#include \"ch1.typ\"\n"),
130 ("/__vfs__/tmpl.typ",
131 "#let greet(who) = [Hello, #who.]\n"),
132 ("/__vfs__/ch1.typ",
133 "== Chapter one\n\nBody of chapter one.\n"),
134 ]));
135
136 assert!(has_heading(&blocks1, "Doc", 1),
137 "the root heading `= Doc` must set, got: {:?}", blocks1);
138 assert!(has_block_text(&blocks1, "Hello, world."),
139 "`#greet(\"world\")` must expand to the paragraph `Hello, world.`, got: {:?}", blocks1);
140 assert!(has_heading(&blocks1, "Chapter one", 2),
141 "the included chapter's `== Chapter one` heading must set, got: {:?}", blocks1);
142 assert!(has_block_text(&blocks1, "Body of chapter one."),
143 "the included chapter's body must set, got: {:?}", blocks1);
144 res!(assert_no_hash_leak(&blocks1));
145
146 let blocks2 = res!(assemble_blocks(&[
147 ("/__vfs__/main.typ",
148 "#import \"one.typ\": one\n\n#one\n"),
149 ("/__vfs__/one.typ",
150 "#let one = [ == Chapter one\n\nBody.\n ]\n"),
151 ]));
152
153 assert!(has_heading(&blocks2, "Chapter one", 2),
154 "the lone path must walk `#import` and the bare `#one` must expand to its heading, got: {:?}", blocks2);
155 assert!(has_block_text(&blocks2, "Body."),
156 "the bare `#one` must expand to the binding's body, got: {:?}", blocks2);
157 res!(assert_no_hash_leak(&blocks2));
158 Ok(())
159}
160
161/// A self- or mutually-referential binding must be refused at the expansion-depth cap, not recurse until the
162/// stack overflows: reaching this assertion at all proves it terminated. (Risk 1 -- the blocker.)
163#[test]
164fn content_binding_cycle_is_refused_and_terminates() -> Outcome<()> {
165 // Self-cycle: `#let a = [#a]` referenced as a bare `#a`.
166 let (blocks, names) = res!(assemble_full(&[
167 ("/__vfs__/main.typ", "#import \"a.typ\": a\n\n#a\n"),
168 ("/__vfs__/a.typ", "#let a = [#a]\n"),
169 ]));
170 assert!(names.iter().any(|n| n.contains("cycle")),
171 "a self-referential binding must record a cycle refusal, got refusals: {:?}", names);
172 res!(assert_no_hash_leak(&blocks));
173
174 // Mutual cycle: `#let a = [#b]` and `#let b = [#a]`.
175 let (blocks, names) = res!(assemble_full(&[
176 ("/__vfs__/main.typ", "#import \"m.typ\": a\n\n#a\n"),
177 ("/__vfs__/m.typ", "#let a = [#b]\n#let b = [#a]\n"),
178 ]));
179 assert!(names.iter().any(|n| n.contains("cycle")),
180 "a mutually-referential pair must record a cycle refusal, got refusals: {:?}", names);
181 res!(assert_no_hash_leak(&blocks));
182 Ok(())
183}
184
185/// An inline-shaped content function used mid-line -- `#em[Note] the rest.` -- must not discard the trailing
186/// prose: the only-whitespace-after rule keeps the reference from being taken as a standalone splice, so the
187/// sentence's tail survives in the paragraph. (Risk 3 -- silent word loss.) The binding body wraps its
188/// parameter in an `#emph[ ... ]` call: a bare `_#x_` reads its trailing `_` as part of the identifier `x_`
189/// (`_` is a valid identifier character), which Typst 0.15.1 itself rejects as an unclosed delimiter -- so
190/// the emphasis is expressed the way Typst accepts, now that the inline path genuinely expands the body.
191#[test]
192fn inline_shaped_content_fn_keeps_trailing_prose() -> Outcome<()> {
193 let blocks = res!(assemble_blocks(&[
194 ("/__vfs__/main.typ",
195 "#import \"e.typ\": em\n\n#em[Note] the rest of this sentence survives.\n"),
196 ("/__vfs__/e.typ",
197 "#let em(x) = [#emph[#x]]\n"),
198 ]));
199 let joined: String = flatten(&blocks).into_iter().map(block_text).collect::<Vec<_>>().join(" ");
200 assert!(joined.contains("the rest of this sentence survives."),
201 "the trailing prose after `#em[Note]` must survive, got: {:?}", blocks);
202 res!(assert_no_hash_leak(&blocks));
203 Ok(())
204}
205
206/// A content-binding reference used INLINE within a running paragraph -- prose before AND after it on the
207/// same line -- splices its argument-substituted body into the sentence at the position it stood, keeping
208/// both stretches of surrounding prose. This is reader-completeness item 3: before it, an inline reference
209/// leaked its raw `#name` (a bare reference) or folded only a `[...]` body while dropping paren arguments,
210/// never expanding the binding the way an own-line reference does.
211#[test]
212fn inline_mid_prose_content_call_splices_with_prose_either_side() -> Outcome<()> {
213 // An argument-taking call mid-sentence: prose before, the call, prose after.
214 let blocks = res!(assemble_blocks(&[
215 ("/__vfs__/main.typ",
216 "#import \"s.typ\": stamp\n\nText before #stamp(\"v2\") and text after.\n"),
217 ("/__vfs__/s.typ",
218 "#let stamp(ver) = [version #ver]\n"),
219 ]));
220 let joined: String = flatten(&blocks).into_iter().map(block_text).collect::<Vec<_>>().join(" ");
221 assert!(joined.contains("Text before version v2 and text after."),
222 "the inline `#stamp(\"v2\")` must expand to `version v2` between its surrounding prose, got: {:?}",
223 blocks);
224 res!(assert_no_hash_leak(&blocks));
225
226 // A bare (no-argument) reference wedged between two words with no spaces -- the `M#oxe` shape -- must
227 // expand in place, keeping the character before and the words after.
228 let blocks = res!(assemble_blocks(&[
229 ("/__vfs__/main.typ",
230 "#import \"m.typ\": mark\n\nWritten as pre#mark and read aloud.\n"),
231 ("/__vfs__/m.typ",
232 "#let mark = [OK]\n"),
233 ]));
234 let joined: String = flatten(&blocks).into_iter().map(block_text).collect::<Vec<_>>().join(" ");
235 assert!(joined.contains("Written as preOK and read aloud."),
236 "the bare inline `#mark` must expand to `OK` in place, keeping `pre` before and the words after, \
237 got: {:?}", blocks);
238 res!(assert_no_hash_leak(&blocks));
239 Ok(())
240}
241
242/// An UNKNOWN call used inline mid-prose -- a name no binding defines -- stays a VISIBLE refusal, recorded by
243/// name, with the surrounding prose kept: the inline expander must not swallow it silently, and it must not
244/// leak its raw `#name` onto the page.
245#[test]
246fn inline_unknown_call_is_refused_and_prose_kept() -> Outcome<()> {
247 // A bound binding is in scope, so the inline expander runs, but the unknown call is not it.
248 let (blocks, names) = res!(assemble_full(&[
249 ("/__vfs__/main.typ",
250 "#import \"s.typ\": stamp\n\nSee #widget(3) between the words here.\n"),
251 ("/__vfs__/s.typ",
252 "#let stamp(ver) = [version #ver]\n"),
253 ]));
254 assert!(names.iter().any(|n| n == "#widget"),
255 "the unknown inline `#widget(3)` must be recorded as a refusal, got refusals: {:?}", names);
256 let joined: String = flatten(&blocks).into_iter().map(block_text).collect::<Vec<_>>().join(" ");
257 assert!(joined.contains("See") && joined.contains("between the words here."),
258 "the prose around the refused inline call must survive, got: {:?}", blocks);
259 res!(assert_no_hash_leak(&blocks));
260 Ok(())
261}
262
263/// A self-referential binding referenced INLINE (not own-line) must be refused as a cycle and terminate, with
264/// the surrounding prose kept -- the inline path carries the same name-stack guard the own-line path does.
265#[test]
266fn inline_content_call_cycle_is_refused_and_terminates() -> Outcome<()> {
267 let (blocks, names) = res!(assemble_full(&[
268 ("/__vfs__/main.typ", "#import \"a.typ\": a\n\nPrefix #a suffix here.\n"),
269 ("/__vfs__/a.typ", "#let a = [loop #a end]\n"),
270 ]));
271 assert!(names.iter().any(|n| n.contains("cycle")),
272 "an inline self-referential binding must record a cycle refusal, got refusals: {:?}", names);
273 let joined: String = flatten(&blocks).into_iter().map(block_text).collect::<Vec<_>>().join(" ");
274 assert!(joined.contains("Prefix") && joined.contains("suffix here."),
275 "the prose around the refused inline cycle must survive, got: {:?}", blocks);
276 res!(assert_no_hash_leak(&blocks));
277 Ok(())
278}
279
280/// A content function whose body is wrapped in a STYLED BOX -- `#let stamp(s) = box(fill: ..)[*v: #s*]` --
281/// must SET its inner text, never drop it silently: the box's styling this reader cannot draw is recorded as
282/// a visible skip, but "v: v2" appears on the page. This is the silent-content-loss SEV: before the fix the
283/// definition was captured by neither the furniture nor the content reader, so the call rendered an empty
284/// gap. Both the inline call (prose either side) and the own-line call are proved, for `box`, `rect` and
285/// `block` wrappers alike. NON-VACUOUS at render level: the assertions read the SET text, not merely "no
286/// error" -- reverting the fix leaves the text absent and reddens them.
287#[test]
288fn styled_box_content_fn_sets_its_text_and_flags_the_styling() -> Outcome<()> {
289 // INLINE, `box`: prose before, the call, prose after. The inner `*v: #s*` sets bold "v: v2" in place.
290 let (blocks, names) = res!(assemble_full(&[
291 ("/__vfs__/main.typ",
292 "#import \"s.typ\": stamp\n\nText before #stamp(\"v2\") and text after.\n"),
293 ("/__vfs__/s.typ",
294 "#let stamp(s) = box(fill: luma(240), outset: 2pt, radius: 3pt)[*v: #s*]\n"),
295 ]));
296 let joined: String = flatten(&blocks).into_iter().map(block_text).collect::<Vec<_>>().join(" ");
297 assert!(joined.contains("Text before v: v2 and text after."),
298 "the inline styled-box `#stamp(\"v2\")` must set `v: v2` between its surrounding prose, got: {:?}",
299 blocks);
300 assert!(names.iter().any(|n| n.contains("#box")),
301 "the dropped box styling must record a visible skip, got refusals: {:?}", names);
302 res!(assert_no_hash_leak(&blocks));
303
304 // OWN-LINE, `box`: the call stands alone on its line and still sets its inner text.
305 let (blocks, names) = res!(assemble_full(&[
306 ("/__vfs__/main.typ",
307 "#import \"s.typ\": stamp\n\n#stamp(\"v2\")\n"),
308 ("/__vfs__/s.typ",
309 "#let stamp(s) = box(fill: luma(240), outset: 2pt, radius: 3pt)[*v: #s*]\n"),
310 ]));
311 assert!(has_block_text(&blocks, "v: v2"),
312 "the own-line styled-box `#stamp(\"v2\")` must set `v: v2` as a block, got: {:?}", blocks);
313 assert!(names.iter().any(|n| n.contains("#box")),
314 "the own-line dropped box styling must record a visible skip, got refusals: {:?}", names);
315 res!(assert_no_hash_leak(&blocks));
316
317 // `rect` and `block` wrappers behave the same: the text is kept, the wrapper flagged.
318 for (wrap, defn) in [
319 ("#rect", "#let stamp(s) = rect(stroke: 1pt)[*v: #s*]\n"),
320 ("#block", "#let stamp(s) = block(inset: 6pt)[*v: #s*]\n"),
321 ] {
322 let (blocks, names) = res!(assemble_full(&[
323 ("/__vfs__/main.typ", "#import \"s.typ\": stamp\n\nSee #stamp(\"v2\") here.\n"),
324 ("/__vfs__/s.typ", defn),
325 ]));
326 let joined: String = flatten(&blocks).into_iter().map(block_text).collect::<Vec<_>>().join(" ");
327 assert!(joined.contains("See v: v2 here."),
328 "the {} wrapper must keep its inner text, got: {:?}", wrap, blocks);
329 assert!(names.iter().any(|n| n.contains(wrap)),
330 "the {} wrapper's dropped styling must record a visible skip, got refusals: {:?}", wrap, names);
331 res!(assert_no_hash_leak(&blocks));
332 }
333 Ok(())
334}
335
336/// A code-mode form surfacing in a re-read content-binding body -- `#if`/`#for` -- must be refused with a
337/// span, not leaked onto the page with its leading `#`. (Risk 4 -- code-mode leak.)
338#[test]
339fn code_mode_form_in_expanded_body_is_refused_not_leaked() -> Outcome<()> {
340 let (blocks, names) = res!(assemble_full(&[
341 ("/__vfs__/main.typ", "#import \"c.typ\": cond\n\n#cond\n"),
342 ("/__vfs__/c.typ", "#let cond = [#if x [yes] else [no]]\n"),
343 ]));
344 assert!(names.iter().any(|n| n.starts_with("#if")),
345 "an expanded body's `#if` must be recorded as a refusal, got refusals: {:?}", names);
346 res!(assert_no_hash_leak(&blocks));
347
348 // A `#for` loop likewise.
349 let (blocks, names) = res!(assemble_full(&[
350 ("/__vfs__/main.typ", "#import \"f.typ\": loop\n\n#loop\n"),
351 ("/__vfs__/f.typ", "#let loop = [#for i in range(3) [item]]\n"),
352 ]));
353 assert!(names.iter().any(|n| n.starts_with("#for")),
354 "an expanded body's `#for` must be recorded as a refusal, got refusals: {:?}", names);
355 res!(assert_no_hash_leak(&blocks));
356 Ok(())
357}