1use std::fmt::Write;
9
10use crate::ast::{
11 ApplyBlock, ClaimExpr, CompareOp, EventDecl, EventRangeDecl, FieldPriorityConfig,
12 FieldStrategy, File, FilterExpr, FilterOperand, GroupDecl, ImportBlock, ImportItem, ItemProps,
13 LabelExpr, LaneDecl, MapBlock, MapExpr, MapFallback, MapProp, MapTargetType, ReimportPolicy,
14 SourceRef, SpanDecl, Statement, StringMatchOp, TemplateBlock, TimelineBlock,
15};
16use crate::error::ParseError;
17
18const INDENT: &str = " ";
19
20pub fn format_source(source: &str) -> Result<String, ParseError> {
22 let file = crate::parse(source)?;
23 Ok(format_file(&file))
24}
25
26pub fn format_file(file: &File) -> String {
31 let mut out = String::new();
32 for (i, stmt) in file.statements.iter().enumerate() {
33 if i > 0 {
34 out.push('\n');
35 }
36 write_statement(&mut out, &stmt.node);
37 }
38 out
39}
40
41fn escape_string(s: &str) -> String {
42 s.replace('\\', "\\\\").replace('"', "\\\"")
43}
44
45fn write_statement(out: &mut String, stmt: &Statement) {
46 match stmt {
47 Statement::Timeline(b) => write_timeline(out, b),
48 Statement::Lane(b) => write_lane(out, b),
49 Statement::Group(b) => write_group(out, b),
50 Statement::Span(b) => write_span(out, b),
51 Statement::Event(b) => write_event(out, b),
52 Statement::EventRange(b) => write_event_range(out, b),
53 Statement::Import(b) => write_import(out, b),
54 Statement::Map(b) => write_map(out, b),
55 Statement::Template(b) => write_template(out, b),
56 Statement::Apply(b) => write_apply(out, b),
57 }
58}
59
60fn write_group(out: &mut String, b: &GroupDecl) {
61 writeln!(out, r#"group "{}" {{"#, escape_string(&b.label)).unwrap();
62 for lane in &b.lanes {
63 let mut lane_out = String::new();
65 write_lane(&mut lane_out, lane);
66 for line in lane_out.lines() {
67 writeln!(out, "{INDENT}{line}").unwrap();
68 }
69 }
70 writeln!(out, "}}").unwrap();
71}
72
73fn write_timeline(out: &mut String, b: &TimelineBlock) {
76 writeln!(out, r#"timeline "{}" {{"#, escape_string(&b.name)).unwrap();
77 if let Some(title) = &b.title {
78 writeln!(out, r#"{INDENT}title "{}";"#, escape_string(title)).unwrap();
79 }
80 if let Some(unit) = &b.unit {
81 writeln!(out, "{INDENT}unit {unit};").unwrap();
82 }
83 if let Some(range) = &b.range {
84 writeln!(out, "{INDENT}range {}..{};", range.start, range.end).unwrap();
85 }
86 if let Some(cal) = &b.calendar {
87 writeln!(out, "{INDENT}calendar {cal};").unwrap();
88 }
89 if !b.color_map.is_empty() {
90 writeln!(out, "{INDENT}color_map {{").unwrap();
91 for (k, v) in &b.color_map {
92 writeln!(out, r#"{INDENT}{INDENT}{k}: "{}";"#, escape_string(v)).unwrap();
93 }
94 writeln!(out, "{INDENT}}}").unwrap();
95 }
96 writeln!(out, "}}").unwrap();
97}
98
99fn write_lane(out: &mut String, b: &LaneDecl) {
102 write!(out, r#"lane "{}""#, escape_string(&b.label)).unwrap();
103 if let Some(alias) = &b.alias {
104 write!(out, " as {alias}").unwrap();
105 }
106 let has_body = b.kind.is_some() || b.order.is_some();
107 if !has_body {
108 writeln!(out, " {{}}").unwrap();
109 return;
110 }
111 writeln!(out, " {{").unwrap();
112 if let Some(kind) = &b.kind {
113 writeln!(out, "{INDENT}kind {kind};").unwrap();
114 }
115 if let Some(order) = b.order {
116 writeln!(out, "{INDENT}order {order};").unwrap();
117 }
118 writeln!(out, "}}").unwrap();
119}
120
121fn write_span(out: &mut String, b: &SpanDecl) {
124 write!(
125 out,
126 r#"span {} {}..{} "{}" "#,
127 b.lane_ref,
128 b.start,
129 b.end,
130 escape_string(&b.label)
131 )
132 .unwrap();
133 write_item_props(out, &b.props);
134 writeln!(out, ";").unwrap();
135}
136
137fn write_event(out: &mut String, b: &EventDecl) {
138 write!(
139 out,
140 r#"event {} {} "{}" "#,
141 b.lane_ref,
142 b.time,
143 escape_string(&b.label)
144 )
145 .unwrap();
146 write_item_props(out, &b.props);
147 writeln!(out, ";").unwrap();
148}
149
150fn write_event_range(out: &mut String, b: &EventRangeDecl) {
151 write!(
152 out,
153 r#"event_range {} {}..{} "{}" "#,
154 b.lane_ref,
155 b.start,
156 b.end,
157 escape_string(&b.label)
158 )
159 .unwrap();
160 write_item_props(out, &b.props);
161 writeln!(out, ";").unwrap();
162}
163
164fn item_has_body(p: &ItemProps) -> bool {
165 !p.tags.is_empty() || p.source.is_some() || p.id.is_some() || p.origin.is_some()
166}
167
168fn write_item_props(out: &mut String, p: &ItemProps) {
169 if !item_has_body(p) {
170 out.push_str("{}");
171 return;
172 }
173 writeln!(out, "{{").unwrap();
174 if !p.tags.is_empty() {
175 let joined = p
176 .tags
177 .iter()
178 .map(|t| format!(r#""{}""#, escape_string(t)))
179 .collect::<Vec<_>>()
180 .join(", ");
181 writeln!(out, "{INDENT}tags [{joined}];").unwrap();
182 }
183 if let Some(s) = &p.source {
184 writeln!(out, "{INDENT}source {};", format_source_ref(s)).unwrap();
185 }
186 if let Some(id) = &p.id {
187 writeln!(out, r#"{INDENT}id "{}";"#, escape_string(id)).unwrap();
188 }
189 if let Some(origin) = &p.origin {
190 writeln!(out, "{INDENT}origin {origin};").unwrap();
191 }
192 write!(out, "}}").unwrap();
193}
194
195fn format_source_ref(s: &SourceRef) -> String {
196 format!("{}:{}", s.prefix, s.qid)
197}
198
199fn write_import(out: &mut String, b: &ImportBlock) {
202 write!(out, "import {}", b.source_type).unwrap();
203 if let Some(alias) = &b.alias {
204 write!(out, " as {alias}").unwrap();
205 }
206 let has_body = !b.items.is_empty() || b.policy.is_some();
207 if !has_body {
208 writeln!(out, " {{}}").unwrap();
209 return;
210 }
211 writeln!(out, " {{").unwrap();
212 for item in &b.items {
213 match item {
214 ImportItem::Entity { qid, alias } => {
215 write!(out, "{INDENT}entity {qid}").unwrap();
216 if let Some(a) = alias {
217 write!(out, " as {a}").unwrap();
218 }
219 writeln!(out, ";").unwrap();
220 }
221 ImportItem::Query { query, alias } => {
222 write!(out, r#"{INDENT}query "{}""#, escape_string(query)).unwrap();
223 if let Some(a) = alias {
224 write!(out, " as {a}").unwrap();
225 }
226 writeln!(out, ";").unwrap();
227 }
228 }
229 }
230 if let Some(policy) = &b.policy {
231 write_reimport_policy(out, policy);
232 }
233 writeln!(out, "}}").unwrap();
234}
235
236fn write_reimport_policy(out: &mut String, p: &ReimportPolicy) {
237 match p {
238 ReimportPolicy::MergeBySource => writeln!(out, "{INDENT}policy merge_by_source;").unwrap(),
239 ReimportPolicy::OverwriteImported => {
240 writeln!(out, "{INDENT}policy overwrite_imported;").unwrap();
241 }
242 ReimportPolicy::KeepManual => writeln!(out, "{INDENT}policy keep_manual;").unwrap(),
243 ReimportPolicy::FieldPriority(cfg) => write_field_priority(out, cfg),
244 }
245}
246
247fn write_field_priority(out: &mut String, cfg: &FieldPriorityConfig) {
248 writeln!(out, "{INDENT}policy field_priority {{").unwrap();
249 writeln!(
250 out,
251 "{INDENT}{INDENT}label: {};",
252 field_strategy_str(cfg.label)
253 )
254 .unwrap();
255 writeln!(
256 out,
257 "{INDENT}{INDENT}time: {};",
258 field_strategy_str(cfg.time)
259 )
260 .unwrap();
261 writeln!(
262 out,
263 "{INDENT}{INDENT}tags: {};",
264 field_strategy_str(cfg.tags)
265 )
266 .unwrap();
267 writeln!(out, "{INDENT}}}").unwrap();
268}
269
270fn field_strategy_str(s: FieldStrategy) -> &'static str {
271 match s {
272 FieldStrategy::Manual => "manual",
273 FieldStrategy::Wikidata => "wikidata",
274 FieldStrategy::Merge => "merge",
275 }
276}
277
278fn write_map(out: &mut String, b: &MapBlock) {
281 let tt = target_type_str(b.target_type);
282 if b.props.is_empty() {
283 writeln!(out, "map {} to {tt} {{}}", b.source_ref).unwrap();
284 return;
285 }
286 writeln!(out, "map {} to {tt} {{", b.source_ref).unwrap();
287 write_map_props(out, &b.props);
288 writeln!(out, "}}").unwrap();
289}
290
291fn write_template(out: &mut String, b: &TemplateBlock) {
292 write!(out, r#"template "{}""#, escape_string(&b.name)).unwrap();
293 if let Some(alias) = &b.alias {
294 write!(out, " as {alias}").unwrap();
295 }
296 let tt = target_type_str(b.target_type);
297 if b.props.is_empty() {
298 writeln!(out, " to {tt} {{}}").unwrap();
299 return;
300 }
301 writeln!(out, " to {tt} {{").unwrap();
302 write_map_props(out, &b.props);
303 writeln!(out, "}}").unwrap();
304}
305
306fn write_apply(out: &mut String, b: &ApplyBlock) {
307 if b.overrides.is_empty() {
308 writeln!(out, "apply {} to {} {{}}", b.template_alias, b.import_alias).unwrap();
309 return;
310 }
311 writeln!(out, "apply {} to {} {{", b.template_alias, b.import_alias).unwrap();
312 write_map_props(out, &b.overrides);
313 writeln!(out, "}}").unwrap();
314}
315
316fn target_type_str(t: MapTargetType) -> &'static str {
317 match t {
318 MapTargetType::Span => "span",
319 MapTargetType::Event => "event",
320 MapTargetType::EventRange => "event_range",
321 }
322}
323
324fn write_map_props(out: &mut String, props: &[MapProp]) {
325 for p in props {
326 match p {
327 MapProp::Lane(id) => writeln!(out, "{INDENT}lane {id};").unwrap(),
328 MapProp::Start(e) => writeln!(out, "{INDENT}start {};", format_map_expr(e)).unwrap(),
329 MapProp::End(e) => writeln!(out, "{INDENT}end {};", format_map_expr(e)).unwrap(),
330 MapProp::Time(e) => writeln!(out, "{INDENT}time {};", format_map_expr(e)).unwrap(),
331 MapProp::Label(l) => writeln!(out, "{INDENT}label {};", format_label_expr(l)).unwrap(),
332 MapProp::Tags(tags) => {
333 let joined = tags
334 .iter()
335 .map(|t| format!(r#""{}""#, escape_string(t)))
336 .collect::<Vec<_>>()
337 .join(", ");
338 writeln!(out, "{INDENT}tags [{joined}];").unwrap();
339 }
340 MapProp::Filter(f) => {
341 writeln!(out, "{INDENT}filter {};", format_filter_expr(f)).unwrap();
342 }
343 MapProp::Expand(call) => {
344 writeln!(out, "{INDENT}expand claim({});", call.property).unwrap();
345 }
346 }
347 }
348}
349
350fn format_claim_expr(c: &ClaimExpr) -> String {
351 let base = if let Some(qual) = &c.qualifier {
353 format!("claim({}).qualifier({qual})", c.claim.property)
354 } else {
355 format!("claim({})", c.claim.property)
356 };
357 let base = match &c.accessor {
359 Some(acc) => format!("{base}.{acc}"),
360 None => base,
361 };
362 match c.offset {
364 Some(off) if off >= 0 => format!("{base} +{off}"),
365 Some(off) => format!("{base} {off}"),
366 None => base,
367 }
368}
369
370fn format_map_expr(e: &MapExpr) -> String {
371 e.fallbacks
372 .iter()
373 .map(|fb| match fb {
374 MapFallback::Claim(c) => format_claim_expr(c),
375 MapFallback::Literal(n) => n.to_string(),
376 })
377 .collect::<Vec<_>>()
378 .join(" ?? ")
379}
380
381fn format_label_expr(l: &LabelExpr) -> String {
382 l.fallbacks
383 .iter()
384 .map(|r| format!("label@{}", r.lang))
385 .collect::<Vec<_>>()
386 .join(" ?? ")
387}
388
389fn format_filter_expr(f: &FilterExpr) -> String {
393 match f {
394 FilterExpr::Or(a, b) => format!("{} || {}", format_filter_expr(a), format_filter_expr(b)),
395 FilterExpr::And(a, b) => format!(
396 "{} && {}",
397 paren_if_or(format_filter_expr(a), a),
398 paren_if_or(format_filter_expr(b), b)
399 ),
400 FilterExpr::Not(inner) => format!("!({})", format_filter_expr(inner)),
401 FilterExpr::Compare { lhs, op, rhs } => format!(
402 "{} {} {}",
403 format_filter_operand(lhs),
404 compare_op_str(*op),
405 format_filter_operand(rhs)
406 ),
407 FilterExpr::StringMatch { lhs, op, rhs } => format!(
408 "label@{} {} \"{}\"",
409 lhs.lang,
410 match op {
411 StringMatchOp::Contains => "contains",
412 StringMatchOp::StartsWith => "startswith",
413 },
414 rhs.replace('\\', "\\\\").replace('"', "\\\"")
415 ),
416 }
417}
418
419fn paren_if_or(rendered: String, expr: &FilterExpr) -> String {
420 if matches!(expr, FilterExpr::Or(_, _)) {
421 format!("({rendered})")
422 } else {
423 rendered
424 }
425}
426
427fn format_filter_operand(op: &FilterOperand) -> String {
428 match op {
429 FilterOperand::Claim(c) => format_claim_expr(c),
430 FilterOperand::Int(i) => i.to_string(),
431 FilterOperand::Null => "null".to_string(),
432 }
433}
434
435fn compare_op_str(op: CompareOp) -> &'static str {
436 match op {
437 CompareOp::Eq => "==",
438 CompareOp::NotEq => "!=",
439 CompareOp::Lt => "<",
440 CompareOp::Le => "<=",
441 CompareOp::Gt => ">",
442 CompareOp::Ge => ">=",
443 }
444}
445
446#[cfg(test)]
449mod tests {
450 use super::*;
451 use crate::parse;
452
453 fn fmt(src: &str) -> String {
454 format_source(src).expect("format succeeded")
455 }
456
457 #[test]
458 fn format_basic_timeline() {
459 let src =
460 r#"timeline "T"{title "T";unit year;range 1900..2000;calendar proleptic_gregorian;}"#;
461 let out = fmt(src);
462 assert_eq!(
463 out,
464 "timeline \"T\" {\n title \"T\";\n unit year;\n range 1900..2000;\n calendar proleptic_gregorian;\n}\n"
465 );
466 }
467
468 #[test]
469 fn format_lane_with_alias_and_props() {
470 let src = r#"lane "漢" as han { kind dynasty; order 10; }"#;
471 let out = fmt(src);
472 assert_eq!(
473 out,
474 "lane \"漢\" as han {\n kind dynasty;\n order 10;\n}\n"
475 );
476 }
477
478 #[test]
479 fn format_lane_empty_body() {
480 let src = r#"lane "Simple" {}"#;
481 let out = fmt(src);
482 assert_eq!(out, "lane \"Simple\" {}\n");
483 }
484
485 #[test]
486 fn format_static_items() {
487 let src = r#"
488 span han -206..220 "漢" { tags ["dynasty"]; source wd:Q7209; id "span:han"; };
489 event han -209 "陳勝・呉広の乱" {};
490 event_range han 184..204 "黄巾の乱" { tags ["war"]; };
491 "#;
492 let out = fmt(src);
493 assert!(out.contains("span han -206..220 \"漢\" {\n tags [\"dynasty\"];\n source wd:Q7209;\n id \"span:han\";\n};"));
495 assert!(out.contains("event han -209 \"陳勝・呉広の乱\" {};"));
496 assert!(out.contains("event_range han 184..204 \"黄巾の乱\" {\n tags [\"war\"];\n};"));
497 }
498
499 #[test]
500 fn format_time_value_precisions() {
501 let src = r#"
502 event han 1969-07-20 "moon" {};
503 event han 1969-07 "month" {};
504 event han -206 "year" {};
505 "#;
506 let out = fmt(src);
507 assert!(out.contains("event han 1969-07-20 \"moon\" {};"));
508 assert!(out.contains("event han 1969-07 \"month\" {};"));
509 assert!(out.contains("event han -206 \"year\" {};"));
510 }
511
512 #[test]
513 fn format_import_and_policy() {
514 let src = r#"
515 import wikidata as wd {
516 entity Q7209 as han_dynasty;
517 query "SELECT ?item WHERE { ?item wdt:P31 wd:Q28171280 . }" as dynasties;
518 policy merge_by_source;
519 }
520 "#;
521 let out = fmt(src);
522 assert!(out.contains("import wikidata as wd {\n"));
523 assert!(out.contains(" entity Q7209 as han_dynasty;\n"));
524 assert!(out.contains(" query \"SELECT ?item WHERE"));
525 assert!(out.contains(" policy merge_by_source;\n"));
526 }
527
528 #[test]
529 fn format_field_priority_policy() {
530 let src = r#"
531 import wikidata as wd {
532 entity Q123 as foo;
533 policy field_priority {
534 label: manual;
535 time: wikidata;
536 tags: merge;
537 }
538 }
539 "#;
540 let out = fmt(src);
541 assert!(out.contains(" policy field_priority {\n label: manual;\n time: wikidata;\n tags: merge;\n }\n"));
542 }
543
544 #[test]
545 fn format_map_block_with_fallbacks() {
546 let src = r#"
547 map wd.han to span {
548 lane han;
549 start claim(P580).year ?? claim(P571).year;
550 end claim(P582).year ?? claim(P576).year;
551 label label@ja ?? label@en;
552 tags ["dynasty", "china"];
553 }
554 "#;
555 let out = fmt(src);
556 assert!(out.contains("map wd.han to span {\n"));
557 assert!(out.contains(" lane han;\n"));
558 assert!(out.contains(" start claim(P580).year ?? claim(P571).year;\n"));
559 assert!(out.contains(" end claim(P582).year ?? claim(P576).year;\n"));
560 assert!(out.contains(" label label@ja ?? label@en;\n"));
561 assert!(out.contains(" tags [\"dynasty\", \"china\"];\n"));
562 }
563
564 #[test]
565 fn format_map_block_with_literal_fallback() {
566 let src = r#"
567 map wd.x to span {
568 lane a;
569 start claim(P580).year ?? 0;
570 end claim(P582).year ?? 9999;
571 label label@ja;
572 }
573 "#;
574 let out = fmt(src);
575 assert!(out.contains(" start claim(P580).year ?? 0;\n"));
576 assert!(out.contains(" end claim(P582).year ?? 9999;\n"));
577 }
578
579 #[test]
580 fn format_map_with_filter() {
581 let src = r#"
582 map wd.x to span {
583 lane a;
584 filter claim(P580).year > 1000 && claim(P582).year < 2000;
585 start claim(P580).year;
586 end claim(P582).year;
587 label label@ja;
588 }
589 "#;
590 let out = fmt(src);
591 assert!(out.contains(" filter claim(P580).year > 1000 && claim(P582).year < 2000;\n"));
592 }
593
594 #[test]
595 fn format_template_and_apply() {
596 let src = r#"
597 template "人物の生涯" as person_life to event_range {
598 start claim(P569).year;
599 end claim(P570).year;
600 label label@ja ?? label@en;
601 }
602 apply person_life to emperors {
603 lane imperial;
604 }
605 "#;
606 let out = fmt(src);
607 assert!(out.contains("template \"人物の生涯\" as person_life to event_range {\n"));
608 assert!(out.contains(" start claim(P569).year;\n"));
609 assert!(out.contains("apply person_life to emperors {\n lane imperial;\n}\n"));
610 }
611
612 #[test]
613 fn format_color_map_block() {
614 let src = r##"timeline "T" { unit year; range 0..2000; color_map { dynasty: "#3366cc"; war: "#cc0000"; } }"##;
615 let out = fmt(src);
616 assert!(out.contains(" color_map {\n"));
617 assert!(out.contains(" dynasty: \"#3366cc\";\n"));
618 assert!(out.contains(" war: \"#cc0000\";\n"));
619 }
620
621 #[test]
622 fn format_blank_line_between_statements() {
623 let src = r#"
624 lane "A" as a {}
625 lane "B" as b {}
626 "#;
627 let out = fmt(src);
628 assert!(out.contains("lane \"A\" as a {}\n\nlane \"B\" as b {}\n"));
630 }
631
632 #[test]
633 fn format_is_idempotent_simple() {
634 let src = r#"
635 timeline "T" { title "T"; unit year; range 1900..2000; calendar proleptic_gregorian; }
636 lane "A" as a { kind custom; order 1; }
637 event a 1950 "X" { tags ["foo", "bar"]; id "evt:a:1950"; };
638 "#;
639 let once = format_source(src).unwrap();
640 let twice = format_source(&once).unwrap();
641 assert_eq!(once, twice, "format must be idempotent");
642 }
643
644 #[test]
645 fn format_is_idempotent_full() {
646 let src = r#"
647 timeline "中国王朝" { title "中国王朝"; unit year; range -500..2000; calendar proleptic_gregorian; }
648 lane "漢" as han { kind dynasty; order 10; }
649 lane "秦" as qin { kind dynasty; order 20; }
650 span han -206..220 "漢" { tags ["dynasty"]; source wd:Q7209; id "span:han"; };
651 event han -209 "陳勝・呉広の乱" {};
652 event_range han 184..204 "黄巾の乱" { tags ["war"]; };
653 import wikidata as wd {
654 entity Q7209 as han_dynasty;
655 policy merge_by_source;
656 }
657 map wd.han_dynasty to span {
658 lane han;
659 start claim(P571).year ?? claim(P580).year;
660 end claim(P576).year ?? claim(P582).year;
661 label label@ja ?? label@en;
662 }
663 "#;
664 let once = format_source(src).unwrap();
665 let twice = format_source(&once).unwrap();
666 assert_eq!(once, twice, "format must be idempotent for full example");
667 }
668
669 #[test]
670 fn format_idempotent_with_filter_and_or() {
671 let src = r#"
672 map wd.x to span {
673 lane a;
674 filter claim(P580).year > 1
675 || (claim(P582).year < 0 && claim(P571).year > 2);
676 start claim(P580).year;
677 end claim(P582).year;
678 label label@ja;
679 }
680 "#;
681 let once = format_source(src).unwrap();
682 let twice = format_source(&once).unwrap();
683 assert_eq!(once, twice, "filter Or/And must round-trip");
684 }
685
686 #[test]
687 fn format_output_is_parseable_full() {
688 let src = r#"
689 timeline "T" { title "T"; unit year; range 1900..2000; calendar proleptic_gregorian; }
690 lane "A" as a { kind custom; order 1; }
691 span a 1900..1950 "X" { id "x"; };
692 event a 1925 "Y" {};
693 event_range a 1930..1940 "Z" {};
694 import wikidata as wd { entity Q1 as foo; policy keep_manual; }
695 map wd.foo to span { lane a; start claim(P580).year; end claim(P582).year; label label@ja; }
696 "#;
697 let formatted = format_source(src).unwrap();
698 let reparsed = parse(&formatted).expect("formatted output must reparse");
699 assert_eq!(reparsed.statements.len(), 7);
700 }
701
702 #[test]
703 fn format_returns_parse_error_on_invalid_input() {
704 let result = format_source("this is not valid tdsl !!!");
705 assert!(result.is_err());
706 }
707
708 #[test]
709 fn format_escapes_quotes_in_strings() {
710 let src = r#"lane "He said \"hello\"" as x {}"#;
711 let out = fmt(src);
712 assert!(out.contains(r#"lane "He said \"hello\"" as x"#));
713 parse(&out).expect("escaped output must reparse");
715 }
716
717 #[test]
718 fn format_negative_year_in_range() {
719 let src = r#"timeline "T" { unit year; range -500..2000; }"#;
720 let out = fmt(src);
721 assert!(out.contains("range -500..2000;"));
722 }
723
724 #[test]
725 fn format_mixed_precision_span() {
726 let src = r#"span ww 1939-09-01..1945-09-02 "WW2" {};"#;
727 let out = fmt(src);
728 assert!(out.contains("span ww 1939-09-01..1945-09-02 \"WW2\" {};"));
729 }
730
731 #[test]
732 fn format_empty_import_block() {
733 let src = r#"import wikidata as wd {}"#;
734 let out = fmt(src);
735 assert_eq!(out, "import wikidata as wd {}\n");
736 }
737
738 #[test]
739 fn format_empty_apply_overrides() {
740 let src = r#"apply t to imports {}"#;
741 let out = fmt(src);
742 assert_eq!(out, "apply t to imports {}\n");
743 }
744}