Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_sbj/tests/cli.rs

16.7 KiB, 24 runs

created by r1870400018:22230, 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//! The authoring toolchain: the text form, the key file, and the `sbj` binary that uses them.
2//!
3//! The claim the round trip makes is that a document's text form is its source and its bytes are its
4//! artefact, and that neither loses anything to the other. It is checked against the conformance
5//! fixtures rather than against a document invented here, so that the compiler is held to the same
6//! artefacts `SPEC.md` §7 holds the reader to: every fixture's `doc.jdat`, compiled with the
7//! committed key at the time its `meta.jdat` declares, must give back the committed `doc.sbj`, byte
8//! for byte. Dumped back to text and compiled again, it must give the same bytes once more.
9//!
10//! The binary is then run as a shell runs it, on a document it wrote itself, since a toolchain that
11//! only works when called as a library is not a toolchain.
12
13mod common;
14
15use common::{
16 Keys,
17 Meta,
18 DOC_JDAT,
19 DOC_SBJ,
20 META_JDAT,
21};
22
23use oxedyne_fe2o3_sbj::{
24 doc,
25 text::{
26 self,
27 KindDecl,
28 },
29 validate,
30 SCHEMA_DOC,
31};
32
33use oxedyne_fe2o3_core::prelude::*;
34use oxedyne_fe2o3_jdat::prelude::*;
35
36use std::{
37 fs,
38 path::{
39 Path,
40 PathBuf,
41 },
42 process::Command,
43};
44
45/// The label the fixtures give the one node kind the v0 vocabulary does not know.
46///
47/// A label a document invents for an unknown kind is declared rather than guessed, which is what the
48/// compiler's `--kind` option passes. The conventional `sbj_k99` needs no declaring, and is what a
49/// dump writes.
50fn alien() -> Vec<KindDecl> {
51 vec![KindDecl {
52 label: common::ALIEN_LABEL.to_string(),
53 code: common::ALIEN_CODE,
54 }]
55}
56
57/// Every fixture's `doc.jdat` compiles to its `doc.sbj`, and dumps back to a document that does too.
58#[test]
59fn test_round_trip_every_fixture() -> Outcome<()> {
60 // The JDAT text decoder is generous with a stack, and a fixture may nest to the depth limit of
61 // SPEC.md §5. The limit is the format's and does not move to suit a test, so the test moves.
62 let thread = match std::thread::Builder::new()
63 .name("sbj_round_trip".to_string())
64 .stack_size(common::STACK_BYTES)
65 .spawn(round_trip)
66 {
67 Ok(thread) => thread,
68 Err(e) => return Err(err!(e,
69 "Could not spawn the thread the deepest fixture is compiled on."; Test, Init)),
70 };
71 match thread.join() {
72 Ok(outcome) => outcome,
73 Err(_) => Err(err!(
74 "The thread compiling the fixtures did not return."; Test, Panic)),
75 }
76}
77
78/// Compiles, reads and dumps every acceptance fixture.
79fn round_trip() -> Outcome<()> {
80 let root = common::fixtures_dir();
81 let keys = res!(Keys::load(&root));
82 let mut run = 0;
83 // Acceptance fixtures the compiler has no business with, counted so the number can be asserted.
84 let mut skipped = 0;
85
86 for entry in res!(fs::read_dir(&root), IO, File) {
87 let entry = res!(entry, IO, File);
88 let dir = entry.path();
89 // A rejection fixture carries no `meta.jdat`, and is the conformance suite's business.
90 if !dir.is_dir() || !dir.join(META_JDAT).is_file() {
91 continue;
92 }
93 let name = match dir.file_name().and_then(|s| s.to_str()) {
94 Some(name) => name.to_string(),
95 None => return Err(err!(
96 "The fixture directory holds {}, whose name is not UTF-8.", dir.display();
97 Test, Invalid)),
98 };
99 let meta = res!(Meta::from_dat(&res!(common::from_jdat_plain(
100 &res!(common::read_text(&dir.join(META_JDAT)))
101 ))));
102 let committed = res!(common::read_bytes(&dir.join(DOC_SBJ)));
103
104 // The compiler is a DOCUMENT tool: it reads the JDAT text of a node tree and writes an
105 // oxeweb document. A post and a card travel in the same container and are not node trees,
106 // so there is nothing here for it to compile and this is not the suite that tests them.
107 //
108 // Excluded by an assertion rather than by a `continue`, because a fixture skipped in
109 // silence is a fixture that could stop being run without anybody noticing. Every fixture
110 // this loop meets is either compiled or is required to be one the compiler refuses.
111 if meta.schema != SCHEMA_DOC {
112 // The claim is not that the TEXT is unreadable: a flat record is perfectly good JDAT,
113 // and the decoder parses it happily into a map. The claim is that the compiler cannot
114 // WRITE it. `doc::write` puts every schema through the node-tree validator, so the one
115 // route this tool has to an artefact is closed to a record, and it says so.
116 let src = res!(common::read_text(&dir.join(DOC_JDAT)));
117 let parsed = res!(text::decode(&src, &alien()));
118 match compile(&parsed, &meta, &keys) {
119 Ok(_) => return Err(err!(
120 "The fixture '{}' declares the schema '{}', which is not a node tree, and the \
121 document compiler wrote an artefact for it anyway. A record put through the \
122 tree writer is a document nobody wrote.", name, meta.schema;
123 Test, Invalid, Unexpected)),
124 Err(_) => (),
125 }
126 res!(eq(&name, "the node count of a payload that is not a tree", &meta.nodes, &None));
127 skipped += 1;
128 continue;
129 }
130
131 // The text form of the document is its source: it compiles to the artefact that was
132 // committed, byte for byte, or the compiler is not the writer the fixtures were written by.
133 let src = res!(common::read_text(&dir.join(DOC_JDAT)));
134 let tree = match text::decode(&src, &alien()) {
135 Ok(tree) => tree,
136 Err(e) => return Err(err!(e,
137 "The '{}' of the fixture '{}' is not readable by the compiler.", DOC_JDAT, name;
138 Test, Invalid)),
139 };
140 let built = res!(compile(&tree, &meta, &keys));
141 res!(same(&name, "the compiled document", &built, &committed));
142
143 // And what it compiles to is what a reader reads.
144 let read = res!(doc::read(&built));
145 res!(eq(&name, "the tree that was compiled", read.tree(), &tree));
146 let stats = res!(validate::validate(read.tree(), &read.env().schema));
147 res!(eq(&name, "the node count", &Some(try_into!(u64, stats.nodes)), &meta.nodes));
148 res!(eq(&name, "the depth", &Some(try_into!(u64, stats.depth)), &meta.depth));
149 res!(eq(&name, "the address", &read.env().hash, &meta.hash));
150
151 // Dumped back to text, the document is the document: the tree survives, and the text it is
152 // written as compiles to the same bytes at the same address. A dump needs no declaration of
153 // the unknown kinds it carries, since it writes them under the conventional label.
154 let dumped = res!(text::encode(read.tree()));
155 let again = match text::decode(&dumped, &[]) {
156 Ok(again) => again,
157 Err(e) => return Err(err!(e,
158 "The dump of the fixture '{}' is not readable by the compiler.", name;
159 Test, Invalid)),
160 };
161 res!(eq(&name, "the dumped tree", &again, &tree));
162 let rebuilt = res!(compile(&again, &meta, &keys));
163 res!(same(&name, "the recompiled dump", &rebuilt, &committed));
164
165 // The dump is stable: a document dumped, compiled and dumped again is the same text.
166 let dumped_again = res!(text::encode(res!(doc::read(&rebuilt)).tree()));
167 res!(eq(&name, "the text form", &dumped_again, &dumped));
168
169 run += 1;
170 }
171
172 if run == 0 {
173 return Err(err!(
174 "No acceptance fixture was found in {}, so nothing was round tripped.",
175 common::fixtures_dir().display();
176 Test, Missing));
177 }
178 // The four fixtures of the two record schemas. Named as a number rather than left open, so that
179 // a fixture which quietly stopped being a document -- or a document that quietly stopped being
180 // compiled -- shows up here instead of passing as an exclusion.
181 if skipped != 6 {
182 return Err(err!(
183 "{} acceptance fixtures were excluded from the compiler, and six are expected: the \
184 two posts, the two cards and the two shares. A different number means a fixture \
185 changed schema, or one was added without this count being thought about.", skipped;
186 Test, Invalid, Mismatch));
187 }
188 Ok(())
189}
190
191/// Compiles a tree as its fixture declares it was written.
192fn compile(
193 tree: &Dat,
194 meta: &Meta,
195 keys: &Keys,
196)
197 -> Outcome<Vec<u8>>
198{
199 let signer = res!(keys.author.signer());
200 if meta.index {
201 doc::write_with_index(tree, &meta.schema, &signer, meta.time)
202 } else {
203 doc::write(tree, &meta.schema, &signer, meta.time)
204 }
205}
206
207/// The dump of a fixture is the source it was written from, character for character.
208///
209/// This is not required of the format, which cares only that the tree survives, but it is what makes
210/// a dump worth reading: a document a reader holds and an author's source are the same text, so the
211/// two can be compared with a diff rather than a decoder.
212#[test]
213fn test_a_dump_is_the_source() -> Outcome<()> {
214 let thread = match std::thread::Builder::new()
215 .name("sbj_dump".to_string())
216 .stack_size(common::STACK_BYTES)
217 .spawn(dump_is_source)
218 {
219 Ok(thread) => thread,
220 Err(e) => return Err(err!(e,
221 "Could not spawn the thread the fixture is dumped on."; Test, Init)),
222 };
223 match thread.join() {
224 Ok(outcome) => outcome,
225 Err(_) => Err(err!("The thread dumping the fixture did not return."; Test, Panic)),
226 }
227}
228
229/// Dumps the fixture that uses every node kind, and compares the text with the fixture's source.
230fn dump_is_source() -> Outcome<()> {
231 let dir = common::fixtures_dir().join("every_kind");
232 let buf = res!(common::read_bytes(&dir.join(DOC_SBJ)));
233 let src = res!(common::read_text(&dir.join(DOC_JDAT)));
234 let dumped = res!(text::encode(res!(doc::read(&buf)).tree()));
235 if dumped != src {
236 return Err(err!(
237 "The dump of 'every_kind' is not its source. The source is {} characters and the dump \
238 is {}:\n{}", src.len(), dumped.len(), dumped;
239 Test, Invalid, Mismatch));
240 }
241 Ok(())
242}
243
244/// The binary runs, and does to a document of its own what it does to a fixture.
245#[test]
246fn test_the_binary_runs() -> Outcome<()> {
247 let dir = res!(scratch("binary"));
248 let source = dir.join("doc.jdat");
249 let keyfile = dir.join("key.jdat");
250 let artefact = dir.join("doc.sbj");
251 let dumped = dir.join("dumped.jdat");
252 let rebuilt = dir.join("rebuilt.sbj");
253 res!(write_text(&source, SOURCE));
254
255 // Compile. The key file is not there, so it is generated and saved, and the author is told where.
256 let out = res!(sbj(&[
257 "compile", &path(&source),
258 "-o", &path(&artefact),
259 "--key", &path(&keyfile),
260 "--time", "1752000000000",
261 ]));
262 res!(says(&out, "Generated a signing key"));
263 res!(says(&out, SCHEMA_DOC));
264 // The doc, its heading and that heading's text, its paragraph and that paragraph's text.
265 res!(says(&out, "nodes 5, depth 3"));
266 if !keyfile.is_file() {
267 return Err(err!(
268 "The compiler signed a document and saved no key at {}.", keyfile.display();
269 Test, Missing));
270 }
271
272 // Verify, which touches no content.
273 let out = res!(sbj(&["verify", &path(&artefact)]));
274 res!(says(&out, "signature good"));
275 res!(says(&out, "not decoded"));
276
277 // Inspect, which reads the document whole.
278 let out = res!(sbj(&["inspect", &path(&artefact)]));
279 res!(says(&out, "kinds doc 1, para 1, heading 1, text 2"));
280 res!(says(&out, "styles lede: size = 1"));
281 res!(says(&out, "index none"));
282
283 // Dump, and compile the dump: the same key, the same time, the same document, the same bytes.
284 res!(sbj(&["dump", &path(&artefact), "-o", &path(&dumped)]));
285 res!(sbj(&[
286 "compile", &path(&dumped),
287 "-o", &path(&rebuilt),
288 "--key", &path(&keyfile),
289 "--time", "1752000000000",
290 ]));
291 let first = res!(common::read_bytes(&artefact));
292 let second = res!(common::read_bytes(&rebuilt));
293 res!(same("the binary", "the recompiled dump", &second, &first));
294
295 res!(clean(&dir));
296 Ok(())
297}
298
299/// A document that fails is refused, the shell hears about it, and the refusal names the rule.
300#[test]
301fn test_the_binary_refuses_and_says_why() -> Outcome<()> {
302 let dir = res!(scratch("refuses"));
303 let source = dir.join("doc.jdat");
304 let keyfile = dir.join("key.jdat");
305 let artefact = dir.join("doc.sbj");
306 let corrupt = dir.join("corrupt.sbj");
307
308 // A heading of level 7 never reaches a file, so it never gets an address.
309 res!(write_text(&source, &SOURCE.replace("(u8|2)", "(u8|7)")));
310 let (code, _, err) = res!(sbj_raw(&[
311 "compile", &path(&source),
312 "-o", &path(&artefact),
313 "--key", &path(&keyfile),
314 ]));
315 res!(exits(code, 1, "compiling a heading of level 7"));
316 res!(says(&err, "Node 1"));
317 res!(says(&err, "1..=6"));
318
319 // A corrupted byte of the tree region is caught by the hash, before anything decodes it.
320 res!(write_text(&source, SOURCE));
321 res!(sbj(&[
322 "compile", &path(&source),
323 "-o", &path(&artefact),
324 "--key", &path(&keyfile),
325 ]));
326 let mut buf = res!(common::read_bytes(&artefact));
327 let last = buf.len() - 1;
328 buf[last] ^= 0x01;
329 res!(common::write_bytes(&corrupt, &buf));
330
331 let (code, _, err) = res!(sbj_raw(&["verify", &path(&corrupt)]));
332 res!(exits(code, 1, "verifying a corrupted tree"));
333 res!(says(&err, "hashes to"));
334
335 // And a subcommand that is not one, and an option that is not one.
336 let (code, _, err) = res!(sbj_raw(&["render", &path(&artefact)]));
337 res!(exits(code, 1, "a subcommand that is not one"));
338 res!(says(&err, "is not an sbj subcommand"));
339 let (code, _, err) = res!(sbj_raw(&["verify", &path(&artefact), "--quickly"]));
340 res!(exits(code, 1, "an option that is not one"));
341 res!(says(&err, "is not an sbj option"));
342
343 res!(clean(&dir));
344 Ok(())
345}
346
347/// The document the binary is run on: a heading, a paragraph, and a style the paragraph names.
348const SOURCE: &'static str = "\
349(sbj_doc|(map|{
350 (str|\"children\"): (list|[
351 (sbj_heading|(map|{
352 (str|\"children\"): (list|[(sbj_text|(str|\"Style without a cascade\"))]),
353 (str|\"level\"): (u8|2),
354 })),
355 (sbj_para|(map|{
356 (str|\"children\"): (list|[(sbj_text|(str|\"A lede paragraph.\"))]),
357 (str|\"style\"): (str|\"lede\"),
358 })),
359 ]),
360 (str|\"lang\"): (str|\"en\"),
361 (str|\"styles\"): (map|{
362 (str|\"lede\"): (map|{
363 (str|\"size\"): (i8|1),
364 }),
365 }),
366 (str|\"title\"): (str|\"A document\"),
367}))
368";
369
370/// Runs the binary, refusing a run that fails.
371fn sbj(args: &[&str]) -> Outcome<String> {
372 let (code, out, err) = res!(sbj_raw(args));
373 if code != 0 {
374 return Err(err!(
375 "`sbj {}` exited {}, and says: {}", args.join(" "), code, err;
376 Test, Invalid));
377 }
378 Ok(out)
379}
380
381/// Runs the binary, returning its exit code, its output, and what it said to the shell.
382fn sbj_raw(args: &[&str]) -> Outcome<(i32, String, String)> {
383 let out = match Command::new(env!("CARGO_BIN_EXE_sbj")).args(args).output() {
384 Ok(out) => out,
385 Err(e) => return Err(err!(e,
386 "Could not run the sbj binary at {}.", env!("CARGO_BIN_EXE_sbj");
387 Test, IO)),
388 };
389 let code = match out.status.code() {
390 Some(code) => code,
391 None => return Err(err!(
392 "`sbj {}` was killed by a signal rather than exiting.", args.join(" ");
393 Test, Invalid)),
394 };
395 Ok((
396 code,
397 String::from_utf8_lossy(&out.stdout).to_string(),
398 String::from_utf8_lossy(&out.stderr).to_string(),
399 ))
400}
401
402/// Requires an exit code, naming what was run.
403fn exits(
404 code: i32,
405 want: i32,
406 what: &str,
407)
408 -> Outcome<()>
409{
410 if code == want {
411 Ok(())
412 } else {
413 Err(err!(
414 "{} exited {}, where {} was required. A document that fails is refused, and the shell \
415 hears about it.", what, code, want;
416 Test, Invalid, Mismatch))
417 }
418}
419
420/// Requires the tool to have said something, naming what it said instead.
421fn says(
422 out: &str,
423 what: &str,
424)
425 -> Outcome<()>
426{
427 if out.contains(what) {
428 Ok(())
429 } else {
430 Err(err!(
431 "The tool was required to say '{}', and says:\n{}", what, out;
432 Test, Invalid, Mismatch))
433 }
434}
435
436/// Requires two byte strings to be equal, naming the first byte at which they part.
437fn same(
438 name: &str,
439 what: &str,
440 got: &[u8],
441 want: &[u8],
442)
443 -> Outcome<()>
444{
445 if got == want {
446 return Ok(());
447 }
448 Err(err!(
449 "For '{}', {} is {} bytes against the {} required{}. A document written twice is the same \
450 document.",
451 name, what, got.len(), want.len(),
452 match common::first_diff(got, want) {
453 Some(at) => fmt!(", first differing at byte {}", at),
454 None => String::new(),
455 };
456 Test, Invalid, Mismatch))
457}
458
459/// Requires two things to be equal, naming the fixture and what was compared.
460fn eq<T: PartialEq + std::fmt::Debug>(
461 name: &str,
462 what: &str,
463 got: &T,
464 want: &T,
465)
466 -> Outcome<()>
467{
468 if got == want {
469 Ok(())
470 } else {
471 Err(err!(
472 "For '{}', {} is {:?}, where {:?} was required.", name, what, got, want;
473 Test, Invalid, Mismatch))
474 }
475}
476
477/// A directory to work in, emptied of whatever a previous run left there.
478fn scratch(what: &str) -> Outcome<PathBuf> {
479 let dir = std::env::temp_dir().join(fmt!("sbj_cli_{}_{}", what, std::process::id()));
480 if dir.exists() {
481 res!(clean(&dir));
482 }
483 match fs::create_dir_all(&dir) {
484 Ok(()) => Ok(dir),
485 Err(e) => Err(err!(e,
486 "Could not make the working directory {}.", dir.display();
487 Test, IO, File)),
488 }
489}
490
491/// Removes a working directory.
492fn clean(dir: &Path) -> Outcome<()> {
493 match fs::remove_dir_all(dir) {
494 Ok(()) => Ok(()),
495 Err(e) => Err(err!(e,
496 "Could not remove the working directory {}.", dir.display();
497 Test, IO, File)),
498 }
499}
500
501/// Writes a text file, naming it if it will not.
502fn write_text(
503 path: &Path,
504 src: &str,
505)
506 -> Outcome<()>
507{
508 common::write_bytes(path, src.as_bytes())
509}
510
511/// A path as the shell takes it.
512fn path(p: &Path) -> String {
513 fmt!("{}", p.display())
514}