rustyfi 0.1.4

SATySFi command line interface: compile .saty documents to PDF
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
//! `--format html` — the reflowable, semantic backend — end-to-end, driven
//! through the *built* `rustyfi` binary ("CLI"), mirroring `tests/cache.rs`'s
//! process-spawn harness style.
//!
//! Also the additivity guard (design doc §8): the SAME fixture compiled with
//! the default `--format pdf` must still behave exactly as `tests/e2e.rs`
//! already expects — the reflow backend is reached only through its own match
//! arm (`main.rs`'s `format::OutputFormat::Html`), so it cannot have touched
//! that path's own code.
//!
//! `--format html-reflow`, the name this backend had while `html` meant a
//! second, layout-faithful backend, is still accepted as an alias; both
//! spellings appear below deliberately.

use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};

fn bin() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_rustyfi"))
}

fn repo_lib_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("../../lib-rustyfi")
}

/// A narrower `--lib-root`, pointing directly at `dist-v01/packages/`
/// (mirroring `crates/rustyfi-lang/tests/v01_itemize_proof_mdja.rs`'s own
/// `lib_root()`) — needed for `itemize_fixture` specifically, to sidestep a
/// PRE-EXISTING, S4-unrelated loader gap: `v006::resolve::resolve_require`
/// tries `<lib_root>/dist/packages/<name>` (candidate 1, the 0.0.6 corpus)
/// BEFORE `<lib_root>/dist-v01/packages/<name>` (candidate 4), so under the
/// full `repo_lib_root()`, `itemize` -> `inline` -> `@require: deco`
/// resolves to the 0.0.6 `dist/packages/deco.satyh` (which exists there
/// too) instead of the 0.1 `dist-v01/packages/deco.satyh` — that 0.0.6
/// `deco.satyh` then `@require: gr`s the 0.0.6 `graphics` builtin, which
/// the X3 cross-version-import check correctly rejects as version-forked.
/// Pointing `--lib-root` straight at `dist-v01/packages/` makes candidate 2
/// (`<lib_root>/<name>`) resolve every `itemize`/`v01-mini` dependency
/// directly, never touching the 0.0.6 corpus at all. Not an S4 fix (this
/// resolver gap predates and is orthogonal to this slice, which does not
/// touch the loader), just how this ONE fixture avoids tripping over it.
fn repo_lib_root_v01_only() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("../../lib-rustyfi/dist-v01/packages")
}

/// `@require: stdja-mini`, three `+p` paragraphs (`\bracket`/`\announce`
/// let-inline forms plus a `match`-computed `#chosen;` embed) — enough
/// structure to exercise paragraph splitting/inline-run emission without
/// pulling in math/graphics/tables (out of Slice 1's scope).
fn phase2_fixture() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/phase2.saty")
}

/// S4: a real 0.1 document `@require:`ing `itemize` (a nested
/// `Itemize.listing?(break=true)` + `Itemize.enumerate`) and `v01-mini`'s
/// `\V01Mini.emph` — exercises BOTH S4 levers (list markers, emphasis
/// markers) through the real loader, the SAME fixture used both for the
/// reflow structural assertions below and for the byte-identity guards
/// (this is the fixture that actually proves the markers are inert, unlike
/// `phase2_fixture` which never touches either modified code path).
fn itemize_fixture() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/v01-itemize.saty")
}

fn tmpdir(tag: &str) -> PathBuf {
    static COUNTER: AtomicU64 = AtomicU64::new(0);
    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
    let p = std::env::temp_dir().join(format!(
        "rustyfi-format-html-reflow-{tag}-{}-{}-{}",
        std::process::id(),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos(),
        n
    ));
    std::fs::create_dir_all(&p).unwrap();
    p
}

fn assert_ok(out: &Output, ctx: &str) {
    assert!(
        out.status.success(),
        "{ctx}: compile failed (code {:?})\nstdout:\n{}\nstderr:\n{}",
        out.status.code(),
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    );
}

fn compile(fixture: &Path, work: &Path, fmt: &str, out_ext: &str) -> PathBuf {
    let out = work.join(format!("out.{out_ext}"));
    let result = Command::new(bin())
        .arg(fixture)
        .args(["-o".as_ref(), out.as_os_str()])
        .args(["--lib-root".as_ref(), repo_lib_root().as_os_str()])
        .args(["--cache-dir".as_ref(), work.join("cache").as_os_str()])
        .args(["--format", fmt])
        .output()
        .expect("spawn rustyfi");
    assert_ok(&result, &format!("compile --format {fmt}"));
    out
}

/// Same as [`compile`], but pins Axis A to 0.1 explicitly
/// (`--lang 0.1`) — `itemize_fixture` only has `@require:`
/// headers (transparent to the sniffer, `sniff_headers`'s own doc comment:
/// "pins neither axis"), no `use`-shaped header, so the CLI's version
/// sniffer would otherwise default to 0.0.6 (`resolve_version_and_mode`'s
/// `RustyfiVersion::DEFAULT` fallback) and reject the fixture's `?(break =
/// true)` optional-argument syntax outright.
fn compile_v01(fixture: &Path, work: &Path, fmt: &str, out_ext: &str) -> PathBuf {
    let out = work.join(format!("out.{out_ext}"));
    let result = Command::new(bin())
        .arg(fixture)
        .args(["-o".as_ref(), out.as_os_str()])
        .args(["--lib-root".as_ref(), repo_lib_root_v01_only().as_os_str()])
        .args(["--cache-dir".as_ref(), work.join("cache").as_os_str()])
        .args(["--format", fmt])
        .args(["--lang", "0.1"])
        .output()
        .expect("spawn rustyfi");
    assert_ok(&result, &format!("compile --format {fmt} --lang 0.1"));
    out
}

/// The page's rendered TEXT: tags stripped and whitespace dropped.
///
/// A word is not one `<span>`. Hyphenation is on by default (upstream loads
/// `english.satysfi-hyph` into every initial context), and UAX#14 break
/// opportunities apply to ASCII, so a word reaches the HTML split across
/// several adjacent elements on separate source lines. Assertions about what
/// the page SAYS must look at the text; assertions about how it is MARKED UP
/// still read `html` directly.
fn rendered_text(html: &str) -> String {
    let mut out = String::new();
    let mut depth = 0usize;
    for ch in html.chars() {
        match ch {
            '<' => depth += 1,
            '>' => depth = depth.saturating_sub(1),
            c if depth == 0 && !c.is_whitespace() => out.push(c),
            _ => {}
        }
    }
    out
}

/// The invariant that defines this backend: nothing in the reflowed
/// document's CONTENT is positioned. `top:`/`left:` may appear only as the
/// tail of a flow-safe longhand — `margin-top`, `border-left`,
/// `padding-left` — never as the bare positioned property.
///
/// The check is on the content, not the whole file: the stylesheet carries
/// one absolute rule for a framed block's DRAWING layer (`css.rs`'s
/// `svg.frame-deco`), which is the same licence math and inline graphics
/// already have and is not page positioning. `rustyfi-html`'s own
/// `reflow_output_never_uses_absolute_positioning` pins that rule by count.
fn assert_no_positioned_offsets(full: &str) {
    let html = body_of_doc(full);
    assert!(
        !html.contains("position:absolute") && !html.contains("position: absolute"),
        "reflow content must never use position:absolute:\n{html}"
    );
    for prop in ["top:", "left:"] {
        for (idx, _) in html.match_indices(prop) {
            let before = &html[..idx];
            assert!(
                ["margin-", "border-", "padding-", "-"]
                    .iter()
                    .any(|p| before.ends_with(p)),
                "found a bare `{prop}` CSS declaration at byte {idx}:\n{html}"
            );
        }
    }
}

/// `--format html` writes real flowing `<p>` paragraphs (one per `+p`), in
/// reading order, with their text HTML-escaped-but-intact — and NO
/// absolute positioning anywhere in the document's own stylesheet/inline
/// styles, and no page divs at all.
#[test]
fn format_html_writes_flowing_paragraphs_in_reading_order() {
    let work = tmpdir("basic");
    let out = compile(&phase2_fixture(), &work, "html", "html");

    let html = std::fs::read_to_string(&out).expect("--format html must write the output file");
    assert!(
        !html.contains("class=\"page\""),
        "the reflowed document must have no pages at all:\n{html}"
    );
    assert!(
        html.starts_with("<!doctype html>"),
        "missing doctype:\n{html}"
    );

    let para_count = html.matches("<p class=\"para\"").count();
    assert!(
        para_count >= 3,
        "expected at least 3 <p> paragraphs (one per +p), got {para_count}:\n{html}"
    );

    // Reading order: bracketed paragraph, then announced paragraph, then
    // the match-computed "Countdown complete." (finished = count-down 5,
    // which recurses down to 0 -> true -> the `true` arm of `chosen`). Each
    // `+p`'s text is tokenized into one `InnerString` PER WORD, so this
    // checks each word individually rather
    // than a contiguous sentence substring — the words are still adjacent
    // in the flowing text, just each in its own `<span>`.
    // Checked against the TEXT, with tags stripped, not against raw markup: a
    // word is not one `InnerString` in general. An explicit ASCII hyphen is a
    // UAX#14 break opportunity (`text_to_boxes`), so `let-inline.` is carried as
    // the two fragments `let-` and `inline.` either side of a zero-width
    // discretionary — adjacent in the flowing text and rendered identically,
    // but two `<span>`s in the markup.
    let text = rendered_text(&html);
    for word in ["Bracketed", "text", "via", "let-inline."] {
        assert!(
            text.contains(word),
            "missing word {word:?} from the first paragraph:\n{html}"
        );
    }
    for word in ["Announced", "lightweight", "let-inline", "form."] {
        assert!(
            text.contains(word),
            "missing word {word:?} from the second paragraph:\n{html}"
        );
    }
    for word in ["Countdown", "complete."] {
        assert!(
            text.contains(word),
            "missing word {word:?} from the third paragraph:\n{html}"
        );
    }
    let pos_bracket = text
        .find("Bracketed")
        .expect("missing first paragraph's text");
    let pos_announce = text
        .find("Announced")
        .expect("missing second paragraph's text");
    let pos_chosen = text
        .find("Countdown")
        .expect("missing third paragraph's (match-computed) text");
    assert!(
        pos_bracket < pos_announce && pos_announce < pos_chosen,
        "paragraphs are out of reading order:\n{html}"
    );

    assert_no_positioned_offsets(&html);

    std::fs::remove_dir_all(&work).ok();
}

/// Additivity guard: the default (`pdf`) format is unaffected.
#[test]
fn default_pdf_format_is_unaffected_by_the_new_reflow_format() {
    let work = tmpdir("pdf");
    let out = compile(&phase2_fixture(), &work, "pdf", "pdf");

    let bytes = std::fs::read(&out).expect("--format pdf must write the output file");
    assert!(
        bytes.starts_with(b"%PDF-"),
        "default --format must still produce a PDF"
    );

    std::fs::remove_dir_all(&work).ok();
}

// ============================================================================
// S4: semantic lists + emphasis, driven end to end through the real loader
// (`itemize_fixture`) — nested `Itemize.listing?(break=true)`,
// `Itemize.enumerate`, and `\V01Mini.emph`.
// ============================================================================

/// `--format html-reflow` on a document that actually uses `itemize` must
/// render real, NESTED `<ul>`/`<li>` for `Itemize.listing` (one top-level
/// item + one nested child item), a real `<ol>`/`<li>` for
/// `Itemize.enumerate` (two flat entries), and a real `<em>` for
/// `\V01Mini.emph` — never absolute positioning (same invariant as the
/// basic reflow test above).
#[test]
fn format_html_reflow_renders_nested_lists_and_emphasis_for_itemize() {
    let work = tmpdir("itemize-reflow");
    let out = compile_v01(&itemize_fixture(), &work, "html-reflow", "html");

    let html =
        std::fs::read_to_string(&out).expect("--format html-reflow must write the output file");
    assert!(
        html.starts_with("<!doctype html>"),
        "missing doctype:\n{html}"
    );

    // `Itemize.listing?(break=true)`: one top-level `<li>` with one nested
    // child `<li>` inside its OWN nested `<ul>` — two `<ul>`s total.
    assert_eq!(
        html.matches("<ul").count(),
        2,
        "expected outer + one nested <ul>:\n{html}"
    );
    assert_eq!(
        html.matches("</ul>").count(),
        2,
        "expected outer + one nested </ul>:\n{html}"
    );

    // `Itemize.enumerate`: one flat `<ol>` with two `<li>`s, no nesting.
    assert_eq!(
        html.matches("<ol").count(),
        1,
        "expected exactly one <ol>:\n{html}"
    );
    assert_eq!(
        html.matches("</ol>").count(),
        1,
        "expected exactly one </ol>:\n{html}"
    );

    // Three `<li>`s total: the listing's top item, its nested child, and
    // (separately, twice for the enumerate — counted together here) the two
    // enumerate entries: 2 (listing) + 2 (enumerate) = 4.
    assert_eq!(
        html.matches("<li").count(),
        4,
        "expected 4 <li>s total:\n{html}"
    );

    // Each `+p`/item's text is tokenized into one `InnerString` PER WORD
    // (same granularity `format_html_reflow_writes_flowing_paragraphs_in_
    // reading_order` above checks for), so — as that test does — check each
    // word individually rather than a contiguous phrase. `item`/`entry` are
    // distinctive enough not to collide with the stylesheet's own CSS
    // vocabulary (unlike, say, "top", which trivially substring-matches
    // `margin-top:`/`border-top:` everywhere).
    let text = rendered_text(&html);
    for word in ["nested", "item", "first", "entry", "second"] {
        assert!(text.contains(word), "missing item word {word:?}:\n{html}");
    }
    assert_eq!(
        text.matches("item").count(),
        2,
        "expected \"item\" exactly twice (the top item + the nested item):\n{html}"
    );

    // `\V01Mini.emph{emphasized}` -> a real `<em>`, never `<strong>`.
    assert!(
        html.contains("<em>") && html.contains("</em>"),
        "missing <em>:\n{html}"
    );
    assert!(
        !html.contains("<strong>"),
        "must not render <strong> for \\emph:\n{html}"
    );
    assert!(
        text.contains("emphasized"),
        "missing emphasized text:\n{html}"
    );

    // The drawn bullet/number glyph run itself (`enumerate`'s arabic
    // numeral, `Itemize.listing`'s circle) must not leak in as its own
    // rendered run — `crates/rustyfi-html/tests/reflow.rs`'s
    // `bullet_fence_is_suppressed` proves this precisely at the box-tree
    // level (a raw substring scan here would be unreliable: margin/style
    // attributes on `<ul>`/`<li>` legitimately contain digits too).

    // Still no absolute positioning anywhere, same invariant as the basic
    // reflow test.
    assert_no_positioned_offsets(&html);

    std::fs::remove_dir_all(&work).ok();
}

/// THE inert-marker proof (design doc §4.3, the whole premise of this
/// slice): a document that actually `@require:`s `itemize` and calls
/// `\V01Mini.emph` — i.e. one that genuinely exercises the modified
/// `itemize.satyh`/`v01-mini.satyh` — must still produce a real PDF under
/// the default `--format pdf`. Unlike `default_pdf_format_is_unaffected_by_
/// the_new_reflow_format` above (which uses `phase2_fixture`, a fixture
/// that never touches either S4-modified stdlib), THIS is the fixture that
/// actually proves the `VertBox::ListMark`/`PureHorzBox::InlineMark`
/// markers are truly inert for PDF, not merely additive on paper.
#[test]
fn itemize_fixture_still_produces_a_valid_pdf() {
    let work = tmpdir("itemize-pdf");
    let out = compile_v01(&itemize_fixture(), &work, "pdf", "pdf");

    let bytes = std::fs::read(&out).expect("--format pdf must write the output file");
    assert!(
        bytes.starts_with(b"%PDF-"),
        "itemize fixture must still produce a valid PDF"
    );
    assert!(
        bytes.len() > 200,
        "PDF unexpectedly tiny ({} bytes)",
        bytes.len()
    );

    std::fs::remove_dir_all(&work).ok();
}

/// `--format html-reflow` is the name the reflowable backend had while
/// `html` meant a second, layout-faithful backend. It still parses, as an
/// alias, so the rename breaks no existing script — and it must select the
/// SAME backend, not merely be accepted.
#[test]
fn html_reflow_is_still_accepted_as_an_alias_of_html() {
    let work = tmpdir("alias");
    let via_alias =
        std::fs::read_to_string(compile(&phase2_fixture(), &work, "html-reflow", "html"))
            .expect("--format html-reflow must still write the output file");
    assert!(
        via_alias.contains("<p class=\"para\"") && !via_alias.contains("class=\"page\""),
        "the alias must select the REFLOW backend:\n{via_alias}"
    );

    std::fs::remove_dir_all(&work).ok();
}

/// An unknown format names the real spellings, so a mistyped flag says
/// what to type instead.
#[test]
fn an_unknown_format_is_rejected_by_name() {
    let work = tmpdir("badfmt");
    let result = Command::new(bin())
        .arg(phase2_fixture())
        .args(["-o".as_ref(), work.join("out.html").as_os_str()])
        .args(["--lib-root".as_ref(), repo_lib_root().as_os_str()])
        .args(["--format", "htlm"])
        .output()
        .expect("spawn rustyfi");
    assert!(!result.status.success(), "a bogus --format must fail");
    let msg = String::from_utf8_lossy(&result.stderr);
    assert!(
        msg.contains("pdf") && msg.contains("html"),
        "the rejection should name the available formats:\n{msg}"
    );
    assert!(
        !msg.contains("html-fixed"),
        "the removed faithful backend must not be offered:\n{msg}"
    );

    std::fs::remove_dir_all(&work).ok();
}

/// The document body — everything after `<body>`. The stylesheet is excluded
/// deliberately; see `assert_no_positioned_offsets`.
fn body_of_doc(html: &str) -> &str {
    html.split("<body>").nth(1).unwrap_or(html)
}