1use crate::diagnostic::{DwCode, ExitTier};
53use std::cmp::Ordering;
54use std::path::{Path, PathBuf};
55
56pub const INDENT: usize = 2;
60
61#[derive(Debug, Clone, PartialEq)]
68pub enum Node {
69 Null,
70 Bool(bool),
71 Number(String),
73 Str(String),
75 Array(Vec<Node>),
77 Object(Vec<(String, Node)>),
79}
80
81#[derive(Debug, Clone, PartialEq)]
83pub struct ParseError {
84 pub code: DwCode,
86 pub line: usize,
88 pub col: usize,
90 pub message: String,
91}
92
93impl std::fmt::Display for ParseError {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 write!(f, "{}:{}: {}", self.line, self.col, self.message)
96 }
97}
98
99crate::dw_code! {
100 pub const DW_FMT_PARSE: DwCode = DwCode::new("DW0770", ExitTier::Build);
102}
103crate::dw_code! {
104 pub const DW_FMT_DUPLICATE_KEY: DwCode = DwCode::new("DW0771", ExitTier::Build);
106}
107crate::dw_code! {
108 pub const DW_FMT_NOT_EQUIVALENT: DwCode = DwCode::new("DW0772", ExitTier::Build);
111}
112crate::dw_code! {
113 pub const DW_FMT_UNFORMATTED: DwCode = DwCode::new("DW0773", ExitTier::Build);
115}
116crate::dw_code! {
117 pub const DW_FMT_NO_BINDING: DwCode = DwCode::new("DW0774", ExitTier::Build);
120}
121
122struct Parser<'a> {
125 src: &'a [u8],
126 pos: usize,
127 line: usize,
128 col: usize,
129}
130
131impl<'a> Parser<'a> {
132 fn new(src: &'a str) -> Self {
133 Parser {
134 src: src.as_bytes(),
135 pos: 0,
136 line: 1,
137 col: 1,
138 }
139 }
140
141 fn err(&self, code: DwCode, message: impl Into<String>) -> ParseError {
142 ParseError {
143 code,
144 line: self.line,
145 col: self.col,
146 message: message.into(),
147 }
148 }
149
150 fn peek(&self) -> Option<u8> {
151 self.src.get(self.pos).copied()
152 }
153
154 fn bump(&mut self) -> Option<u8> {
157 let b = self.src.get(self.pos).copied()?;
158 self.pos += 1;
159 if b == b'\n' {
160 self.line += 1;
161 self.col = 1;
162 } else if b & 0xC0 != 0x80 {
163 self.col += 1;
164 }
165 Some(b)
166 }
167
168 fn skip_ws(&mut self) {
169 while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) {
170 self.bump();
171 }
172 }
173
174 fn expect(&mut self, want: u8) -> Result<(), ParseError> {
175 match self.peek() {
176 Some(b) if b == want => {
177 self.bump();
178 Ok(())
179 }
180 Some(b) => Err(self.err(
181 DW_FMT_PARSE,
182 format!(
183 "expected `{}`, found `{}`",
184 want as char,
185 escape_for_message(b)
186 ),
187 )),
188 None => Err(self.err(
189 DW_FMT_PARSE,
190 format!("expected `{}`, found end of file", want as char),
191 )),
192 }
193 }
194
195 fn literal(&mut self, word: &str, node: Node) -> Result<Node, ParseError> {
196 if self.src[self.pos..].starts_with(word.as_bytes()) {
197 for _ in 0..word.len() {
198 self.bump();
199 }
200 Ok(node)
201 } else {
202 Err(self.err(DW_FMT_PARSE, format!("expected `{word}`")))
203 }
204 }
205
206 fn value(&mut self) -> Result<Node, ParseError> {
207 self.skip_ws();
208 match self.peek() {
209 Some(b'{') => self.object(),
210 Some(b'[') => self.array(),
211 Some(b'"') => Ok(Node::Str(self.string()?)),
212 Some(b't') => self.literal("true", Node::Bool(true)),
213 Some(b'f') => self.literal("false", Node::Bool(false)),
214 Some(b'n') => self.literal("null", Node::Null),
215 Some(b'-' | b'0'..=b'9') => self.number(),
216 Some(b) => Err(self.err(
217 DW_FMT_PARSE,
218 format!("unexpected `{}`", escape_for_message(b)),
219 )),
220 None => Err(self.err(DW_FMT_PARSE, "unexpected end of file")),
221 }
222 }
223
224 fn object(&mut self) -> Result<Node, ParseError> {
225 self.expect(b'{')?;
226 let mut entries: Vec<(String, Node)> = Vec::new();
227 self.skip_ws();
228 if self.peek() == Some(b'}') {
229 self.bump();
230 return Ok(Node::Object(entries));
231 }
232 loop {
233 self.skip_ws();
234 let key_line = self.line;
235 let key_col = self.col;
236 let key = self.string()?;
237 if entries.iter().any(|(k, _)| *k == key) {
241 return Err(ParseError {
242 code: DW_FMT_DUPLICATE_KEY,
243 line: key_line,
244 col: key_col,
245 message: format!(
246 "duplicate object key `{key}`. JSON allows it and the compiler's \
247 parser silently keeps the LAST one, so one of these two values is \
248 already being discarded without a word. Formatting would make that \
249 loss permanent and invisible, so `delvec fmt` refuses: delete or \
250 rename whichever occurrence is wrong."
251 ),
252 });
253 }
254 self.skip_ws();
255 self.expect(b':')?;
256 let value = self.value()?;
257 entries.push((key, value));
258 self.skip_ws();
259 match self.peek() {
260 Some(b',') => {
261 self.bump();
262 self.skip_ws();
263 if self.peek() == Some(b'}') {
264 return Err(self.err(
265 DW_FMT_PARSE,
266 "trailing comma before `}` (JSON has no trailing commas)",
267 ));
268 }
269 }
270 Some(b'}') => {
271 self.bump();
272 return Ok(Node::Object(entries));
273 }
274 Some(b) => {
275 return Err(self.err(
276 DW_FMT_PARSE,
277 format!("expected `,` or `}}`, found `{}`", escape_for_message(b)),
278 ));
279 }
280 None => {
281 return Err(self.err(DW_FMT_PARSE, "unterminated object"));
282 }
283 }
284 }
285 }
286
287 fn array(&mut self) -> Result<Node, ParseError> {
288 self.expect(b'[')?;
289 let mut items = Vec::new();
290 self.skip_ws();
291 if self.peek() == Some(b']') {
292 self.bump();
293 return Ok(Node::Array(items));
294 }
295 loop {
296 items.push(self.value()?);
297 self.skip_ws();
298 match self.peek() {
299 Some(b',') => {
300 self.bump();
301 self.skip_ws();
302 if self.peek() == Some(b']') {
303 return Err(self.err(
304 DW_FMT_PARSE,
305 "trailing comma before `]` (JSON has no trailing commas)",
306 ));
307 }
308 }
309 Some(b']') => {
310 self.bump();
311 return Ok(Node::Array(items));
312 }
313 Some(b) => {
314 return Err(self.err(
315 DW_FMT_PARSE,
316 format!("expected `,` or `]`, found `{}`", escape_for_message(b)),
317 ));
318 }
319 None => {
320 return Err(self.err(DW_FMT_PARSE, "unterminated array"));
321 }
322 }
323 }
324 }
325
326 fn number(&mut self) -> Result<Node, ParseError> {
329 let start = self.pos;
330 if self.peek() == Some(b'-') {
331 self.bump();
332 }
333 match self.peek() {
334 Some(b'0') => {
335 self.bump();
336 }
337 Some(b'1'..=b'9') => {
338 while matches!(self.peek(), Some(b'0'..=b'9')) {
339 self.bump();
340 }
341 }
342 _ => return Err(self.err(DW_FMT_PARSE, "expected a digit after `-`")),
343 }
344 if self.peek() == Some(b'.') {
345 self.bump();
346 if !matches!(self.peek(), Some(b'0'..=b'9')) {
347 return Err(self.err(DW_FMT_PARSE, "expected a digit after `.`"));
348 }
349 while matches!(self.peek(), Some(b'0'..=b'9')) {
350 self.bump();
351 }
352 }
353 if matches!(self.peek(), Some(b'e' | b'E')) {
354 self.bump();
355 if matches!(self.peek(), Some(b'+' | b'-')) {
356 self.bump();
357 }
358 if !matches!(self.peek(), Some(b'0'..=b'9')) {
359 return Err(self.err(DW_FMT_PARSE, "expected a digit in the exponent"));
360 }
361 while matches!(self.peek(), Some(b'0'..=b'9')) {
362 self.bump();
363 }
364 }
365 let raw = std::str::from_utf8(&self.src[start..self.pos])
367 .expect("a JSON number literal is ASCII")
368 .to_string();
369 Ok(Node::Number(raw))
370 }
371
372 fn string(&mut self) -> Result<String, ParseError> {
373 self.expect(b'"')?;
374 let mut out = String::new();
375 loop {
376 let Some(b) = self.peek() else {
377 return Err(self.err(DW_FMT_PARSE, "unterminated string"));
378 };
379 match b {
380 b'"' => {
381 self.bump();
382 return Ok(out);
383 }
384 b'\\' => {
385 self.bump();
386 let Some(esc) = self.bump() else {
387 return Err(self.err(DW_FMT_PARSE, "unterminated escape"));
388 };
389 match esc {
390 b'"' => out.push('"'),
391 b'\\' => out.push('\\'),
392 b'/' => out.push('/'),
393 b'b' => out.push('\u{0008}'),
394 b'f' => out.push('\u{000c}'),
395 b'n' => out.push('\n'),
396 b'r' => out.push('\r'),
397 b't' => out.push('\t'),
398 b'u' => {
399 let hi = self.hex4()?;
400 let ch = if (0xD800..0xDC00).contains(&hi) {
401 if self.peek() != Some(b'\\') {
403 return Err(self.err(
404 DW_FMT_PARSE,
405 "lone high surrogate: `\\uD800`–`\\uDBFF` must be \
406 followed by a low surrogate escape",
407 ));
408 }
409 self.bump();
410 if self.peek() != Some(b'u') {
411 return Err(self.err(
412 DW_FMT_PARSE,
413 "lone high surrogate: expected `\\u` low surrogate",
414 ));
415 }
416 self.bump();
417 let lo = self.hex4()?;
418 if !(0xDC00..0xE000).contains(&lo) {
419 return Err(self.err(
420 DW_FMT_PARSE,
421 "high surrogate not followed by a low surrogate",
422 ));
423 }
424 let cp = 0x1_0000u32
425 + ((hi as u32 - 0xD800) << 10)
426 + (lo as u32 - 0xDC00);
427 char::from_u32(cp)
428 .ok_or_else(|| self.err(DW_FMT_PARSE, "invalid code point"))?
429 } else if (0xDC00..0xE000).contains(&hi) {
430 return Err(self.err(
431 DW_FMT_PARSE,
432 "lone low surrogate escape (`\\uDC00`–`\\uDFFF`)",
433 ));
434 } else {
435 char::from_u32(hi as u32)
436 .ok_or_else(|| self.err(DW_FMT_PARSE, "invalid code point"))?
437 };
438 out.push(ch);
439 }
440 other => {
441 return Err(self.err(
442 DW_FMT_PARSE,
443 format!("invalid escape `\\{}`", escape_for_message(other)),
444 ));
445 }
446 }
447 }
448 0x00..=0x1F => {
449 return Err(self.err(
450 DW_FMT_PARSE,
451 format!("raw control character U+{b:04X} in a string; escape it"),
452 ));
453 }
454 _ => {
455 let start = self.pos;
458 self.bump();
459 while matches!(self.peek(), Some(c) if c & 0xC0 == 0x80) {
460 self.bump();
461 }
462 out.push_str(
463 std::str::from_utf8(&self.src[start..self.pos])
464 .expect("the source was a &str"),
465 );
466 }
467 }
468 }
469 }
470
471 fn hex4(&mut self) -> Result<u16, ParseError> {
472 let mut v: u16 = 0;
473 for _ in 0..4 {
474 let Some(b) = self.bump() else {
475 return Err(self.err(DW_FMT_PARSE, "truncated `\\u` escape"));
476 };
477 let d = match b {
478 b'0'..=b'9' => b - b'0',
479 b'a'..=b'f' => b - b'a' + 10,
480 b'A'..=b'F' => b - b'A' + 10,
481 _ => {
482 return Err(self.err(
483 DW_FMT_PARSE,
484 format!(
485 "`\\u` escape needs 4 hex digits, found `{}`",
486 escape_for_message(b)
487 ),
488 ));
489 }
490 };
491 v = v * 16 + d as u16;
492 }
493 Ok(v)
494 }
495}
496
497fn escape_for_message(b: u8) -> String {
498 if (0x20..0x7F).contains(&b) {
499 (b as char).to_string()
500 } else {
501 format!("\\x{b:02x}")
502 }
503}
504
505pub fn parse(text: &str) -> Result<Node, ParseError> {
508 let mut p = Parser::new(text);
509 if p.src.starts_with(&[0xEF, 0xBB, 0xBF]) {
512 return Err(p.err(
513 DW_FMT_PARSE,
514 "file starts with a UTF-8 BOM; Delvewright JSON is plain UTF-8 with no BOM",
515 ));
516 }
517 let node = p.value()?;
518 p.skip_ws();
519 if p.pos != p.src.len() {
520 return Err(p.err(DW_FMT_PARSE, "trailing content after the top-level value"));
521 }
522 Ok(node)
523}
524
525pub fn canonical(node: &Node) -> String {
529 let mut out = String::new();
530 write_node(node, 0, &mut out);
531 out.push('\n');
532 out
533}
534
535fn write_node(node: &Node, depth: usize, out: &mut String) {
536 match node {
537 Node::Null => out.push_str("null"),
538 Node::Bool(true) => out.push_str("true"),
539 Node::Bool(false) => out.push_str("false"),
540 Node::Number(raw) => out.push_str(raw),
541 Node::Str(s) => write_string(s, out),
542 Node::Array(items) => {
543 if items.is_empty() {
544 out.push_str("[]");
545 return;
546 }
547 out.push_str("[\n");
548 for (i, item) in items.iter().enumerate() {
549 indent(depth + 1, out);
552 write_node(item, depth + 1, out);
553 if i + 1 < items.len() {
554 out.push(',');
555 }
556 out.push('\n');
557 }
558 indent(depth, out);
559 out.push(']');
560 }
561 Node::Object(entries) => {
562 if entries.is_empty() {
563 out.push_str("{}");
564 return;
565 }
566 let mut sorted: Vec<&(String, Node)> = entries.iter().collect();
570 sorted.sort_by(|a, b| cmp_key(&a.0, &b.0));
571 out.push_str("{\n");
572 for (i, (key, value)) in sorted.iter().enumerate() {
573 indent(depth + 1, out);
574 write_string(key, out);
575 out.push_str(": ");
576 write_node(value, depth + 1, out);
577 if i + 1 < sorted.len() {
578 out.push(',');
579 }
580 out.push('\n');
581 }
582 indent(depth, out);
583 out.push('}');
584 }
585 }
586}
587
588fn cmp_key(a: &str, b: &str) -> Ordering {
591 a.cmp(b)
592}
593
594fn indent(depth: usize, out: &mut String) {
595 for _ in 0..depth * INDENT {
596 out.push(' ');
597 }
598}
599
600fn write_string(s: &str, out: &mut String) {
603 out.push('"');
604 for ch in s.chars() {
605 match ch {
606 '"' => out.push_str("\\\""),
607 '\\' => out.push_str("\\\\"),
608 '\u{0008}' => out.push_str("\\b"),
609 '\u{000c}' => out.push_str("\\f"),
610 '\n' => out.push_str("\\n"),
611 '\r' => out.push_str("\\r"),
612 '\t' => out.push_str("\\t"),
613 c if (c as u32) < 0x20 => {
614 use std::fmt::Write as _;
615 let _ = write!(out, "\\u{:04x}", c as u32);
616 }
617 c => out.push(c),
618 }
619 }
620 out.push('"');
621}
622
623pub fn equivalent(before: &Node, after: &Node) -> Result<(), String> {
631 fn walk(a: &Node, b: &Node, path: &str) -> Result<(), String> {
632 match (a, b) {
633 (Node::Null, Node::Null) => Ok(()),
634 (Node::Bool(x), Node::Bool(y)) if x == y => Ok(()),
635 (Node::Number(x), Node::Number(y)) if x == y => Ok(()),
636 (Node::Str(x), Node::Str(y)) if x == y => Ok(()),
637 (Node::Array(x), Node::Array(y)) => {
638 if x.len() != y.len() {
639 return Err(format!(
640 "{path}: array length changed ({} → {})",
641 x.len(),
642 y.len()
643 ));
644 }
645 for (i, (xi, yi)) in x.iter().zip(y.iter()).enumerate() {
646 walk(xi, yi, &format!("{path}/{i}"))?;
649 }
650 Ok(())
651 }
652 (Node::Object(x), Node::Object(y)) => {
653 let mut xs: Vec<&(String, Node)> = x.iter().collect();
654 let mut ys: Vec<&(String, Node)> = y.iter().collect();
655 xs.sort_by(|p, q| cmp_key(&p.0, &q.0));
656 ys.sort_by(|p, q| cmp_key(&p.0, &q.0));
657 if xs.len() != ys.len() {
658 return Err(format!(
659 "{path}: object key count changed ({} → {})",
660 xs.len(),
661 ys.len()
662 ));
663 }
664 for (xe, ye) in xs.iter().zip(ys.iter()) {
665 if xe.0 != ye.0 {
666 return Err(format!("{path}: key `{}` became `{}`", xe.0, ye.0));
667 }
668 walk(&xe.1, &ye.1, &format!("{path}/{}", xe.0))?;
669 }
670 Ok(())
671 }
672 _ => Err(format!("{path}: value kind or content changed")),
673 }
674 }
675 walk(before, after, "")
676}
677
678pub fn format_text(text: &str) -> Result<String, ParseError> {
687 format_with(text, canonical)
688}
689
690fn format_with(text: &str, render: impl Fn(&Node) -> String) -> Result<String, ParseError> {
695 let mut before = parse(text)?;
696 stamp_version(&mut before);
697 let out = render(&before);
698 let after = parse(&out).map_err(|e| ParseError {
699 code: DW_FMT_NOT_EQUIVALENT,
700 line: e.line,
701 col: e.col,
702 message: format!(
703 "the formatter emitted JSON it cannot itself parse: {}",
704 e.message
705 ),
706 })?;
707 if let Err(why) = equivalent(&before, &after) {
708 return Err(ParseError {
709 code: DW_FMT_NOT_EQUIVALENT,
710 line: 1,
711 col: 1,
712 message: format!(
713 "internal error: formatting would change what this document means \
714 ({why}). Nothing was written. This is a compiler bug — arrays are \
715 ordered and must never be reordered; please report it."
716 ),
717 });
718 }
719 Ok(out)
720}
721
722fn stamp_version(node: &mut Node) {
729 if let Node::Object(fields) = node {
730 for (key, value) in fields.iter_mut() {
731 if key == "dsl_version"
732 && let Node::Str(v) = value
733 && v != crate::DSL_VERSION
734 {
735 *v = crate::DSL_VERSION.to_string();
736 }
737 }
738 }
739}
740
741pub const BUILD_OUTPUT_MARKER: &str = "manifest.json";
748
749pub fn discover(root: &Path) -> std::io::Result<Vec<PathBuf>> {
758 let mut out = Vec::new();
759 if root.is_file() {
760 out.push(root.to_path_buf());
761 return Ok(out);
762 }
763 walk(root, &mut out)?;
764 Ok(out)
765}
766
767fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
768 if dir.join(BUILD_OUTPUT_MARKER).is_file() {
769 return Ok(());
770 }
771 let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)?
772 .map(|e| e.map(|e| e.path()))
773 .collect::<Result<_, _>>()?;
774 entries.sort();
775 for path in entries {
776 let name = path
777 .file_name()
778 .and_then(|n| n.to_str())
779 .unwrap_or_default();
780 if name.starts_with('.') {
781 continue;
782 }
783 let meta = std::fs::symlink_metadata(&path)?;
787 if meta.is_dir() {
788 walk(&path, out)?;
789 } else if meta.is_file() && path.extension().and_then(|e| e.to_str()) == Some("json") {
790 out.push(path);
791 }
792 }
793 Ok(())
794}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799
800 #[test]
804 fn the_formatter_writes_the_one_dsl_version() {
805 let src = r#"{"dsl_version":"0.2.0","stage":"world","content":{"dsl_version":"0.2.0"}}"#;
806 let out = format_text(src).unwrap();
807 let stamped = format!(r#" "dsl_version": "{}","#, crate::DSL_VERSION);
808 assert!(out.contains(&stamped), "{out}");
809 assert!(
810 out.contains(r#" "dsl_version": "0.2.0""#),
811 "nested content stayed: {out}"
812 );
813 let already = format_text(&out).unwrap();
814 assert_eq!(already, out, "a stamped document is canonical");
815 }
816
817 #[test]
818 fn object_keys_are_sorted_arrays_are_not() {
819 let src = r#"{"b":1,"a":[3,1,2],"c":{"z":0,"y":0}}"#;
820 assert_eq!(
821 format_text(src).unwrap(),
822 "{\n \"a\": [\n 3,\n 1,\n 2\n ],\n \"b\": 1,\n \"c\": {\n \"y\": 0,\n \"z\": 0\n }\n}\n"
823 );
824 }
825
826 #[test]
827 fn number_literals_survive_verbatim() {
828 let src = r#"{"big":9007199254740993,"exact":1.50,"exp":1e3,"neg":-0.0}"#;
831 let out = format_text(src).unwrap();
832 assert!(out.contains("9007199254740993"), "{out}");
833 assert!(out.contains("1.50"), "{out}");
834 assert!(out.contains("1e3"), "{out}");
835 assert!(out.contains("-0.0"), "{out}");
836 }
837
838 #[test]
839 fn non_ascii_is_never_escaped_and_escapes_are_decoded() {
840 let src = r#"{"zh":"洞中公羊","emoji":"😀"}"#;
841 let out = format_text(src).unwrap();
842 assert!(out.contains("洞中公羊"), "{out}");
843 assert!(out.contains('\u{1F600}'), "{out}");
844 assert!(!out.contains("\\u"), "{out}");
845 }
846
847 #[test]
848 fn control_characters_keep_their_shortest_escape() {
849 let src = "{\"a\":\"x\\ny\\tz\\u0001\"}";
850 let out = format_text(src).unwrap();
851 assert_eq!(out, "{\n \"a\": \"x\\ny\\tz\\u0001\"\n}\n");
852 }
853
854 #[test]
855 fn idempotent() {
856 let src = r#"{"b":[{"q":1,"p":[2,1]}],"a":"é"}"#;
857 let once = format_text(src).unwrap();
858 assert_eq!(format_text(&once).unwrap(), once);
859 }
860
861 #[test]
862 fn duplicate_key_is_refused() {
863 let e = format_text(r#"{"a":1,"a":2}"#).unwrap_err();
864 assert_eq!(e.code, "DW0771");
865 }
866
867 #[test]
868 fn syntax_errors_are_located() {
869 let e = format_text("{\n \"a\": 1,\n}\n").unwrap_err();
870 assert_eq!(e.code, "DW0770");
871 assert_eq!(e.line, 3);
872 }
873
874 #[test]
875 fn empty_containers_stay_on_one_line() {
876 assert_eq!(
877 format_text(r#"{"a":[],"b":{}}"#).unwrap(),
878 "{\n \"a\": [],\n \"b\": {}\n}\n"
879 );
880 }
881
882 #[test]
886 fn the_guard_catches_a_renderer_that_sorts_arrays() {
887 fn sorting_render(node: &Node) -> String {
888 fn sabotage(n: &Node) -> Node {
889 match n {
890 Node::Array(items) => {
891 let mut v: Vec<Node> = items.iter().map(sabotage).collect();
892 v.sort_by_key(|n| match n {
893 Node::Number(raw) => raw.clone(),
894 Node::Str(s) => s.clone(),
895 _ => String::new(),
896 });
897 Node::Array(v)
898 }
899 Node::Object(e) => {
900 Node::Object(e.iter().map(|(k, v)| (k.clone(), sabotage(v))).collect())
901 }
902 other => other.clone(),
903 }
904 }
905 canonical(&sabotage(node))
906 }
907 let src = r#"{"objectives":["3","1","2"]}"#;
909 assert!(format_with(src, canonical).is_ok());
910 let e = format_with(src, sorting_render).unwrap_err();
911 assert_eq!(e.code, "DW0772");
912 assert!(e.message.contains("/objectives/0"), "{}", e.message);
913 }
914
915 #[test]
916 fn equivalent_catches_a_reordered_array() {
917 let a = parse("[1,2,3]").unwrap();
920 let b = parse("[3,2,1]").unwrap();
921 assert!(equivalent(&a, &b).is_err());
922 assert!(equivalent(&a, &a).is_ok());
923 }
924}