Skip to main content

tdsl_parser/
format.rs

1//! AST → DSL ソース再 emit(フォーマッタ)。
2//!
3//! `parse` で得た [`crate::ast::File`] を 2 スペースインデント・ブロック間空行 1 行で
4//! 整形して文字列として返す。コメントは AST に残らないため整形後は消える。
5//!
6//! 公開 API は [`format_source`](ソース文字列を整形)と [`format_file`](AST を直接整形)。
7
8use 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
20/// DSL ソースを整形して返す。パース失敗時は [`ParseError`] を返却する。
21pub fn format_source(source: &str) -> Result<String, ParseError> {
22    let file = crate::parse(source)?;
23    Ok(format_file(&file))
24}
25
26/// AST([`File`])を直接整形して DSL ソース文字列を返す。
27///
28/// `lint --fix` 適用後の AST など、すでにパース済みの AST を再 emit したいときに使う。
29/// 整形ルールは [`format_source`] と同一(2 スペースインデント・ブロック間空行 1 行)。
30pub 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        // インデントして lane を emit する
64        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
73// ─── timeline ────────────────────────────────────────────────────────────────
74
75fn 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
99// ─── lane ────────────────────────────────────────────────────────────────────
100
101fn 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
121// ─── span / event / event_range ──────────────────────────────────────────────
122
123fn 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
199// ─── import ──────────────────────────────────────────────────────────────────
200
201fn 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
278// ─── map / template / apply ──────────────────────────────────────────────────
279
280fn 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    // Build base: "claim(P)" or "claim(P).qualifier(Q)"
352    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    // Append accessor
358    let base = match &c.accessor {
359        Some(acc) => format!("{base}.{acc}"),
360        None => base,
361    };
362    // Append offset
363    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
389// filter_expr の出力は parser の左結合パース(builder.rs L441–456)と整合させるため、
390// And/Or をフラット展開する。Or の子に And が現れる場合は括弧不要、And の子に Or が
391// 現れる場合のみ括弧で囲んで結合方向を保持する。
392fn 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// ─── tests ───────────────────────────────────────────────────────────────────
447
448#[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        // span のフォーマット: ブロック内に tags / source / id が改行で並ぶ
494        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        // ブロック間に空行が 1 行入る
629        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        // 再パース可能
714        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}