Skip to main content

fd_core/
format.rs

1//! Document formatting pipeline: parse → transforms → emit.
2//!
3//! Combines lint auto-fixes, transform passes, and canonical emit into a
4//! single idempotent entry point consumed by the LSP `textDocument/formatting`
5//! handler and the VS Code extension.
6
7use crate::emitter::emit_document;
8use crate::parser::parse_document;
9use crate::transform::{dedup_use_styles, sort_nodes};
10
11// ─── Config ───────────────────────────────────────────────────────────────
12
13/// Configuration for `format_document`.
14///
15/// All transforms default to a safe, non-destructive subset.
16/// Opt-in to structural transforms via explicit `true` fields.
17#[derive(Debug, Clone)]
18pub struct FormatConfig {
19    /// Remove duplicate `use:` references on each node. Default: **true**.
20    pub dedup_use: bool,
21
22    /// Promote repeated identical inline styles to top-level `style {}` blocks.
23    /// This is *structurally destructive* (adds new style names, rewrites nodes),
24    /// so it defaults to **false** — users must explicitly opt-in.
25    pub hoist_styles: bool,
26
27    /// Reorder top-level nodes by kind: Group/Frame → Rect → Ellipse → Text →
28    /// Path → Generic. Relative order within each kind is preserved. Default: **true**.
29    pub sort_nodes: bool,
30}
31
32impl Default for FormatConfig {
33    fn default() -> Self {
34        Self {
35            dedup_use: true,
36            hoist_styles: false,
37            sort_nodes: true,
38        }
39    }
40}
41
42// ─── Pipeline ─────────────────────────────────────────────────────────────
43
44/// Parse an FD document, apply configured transforms, and re-emit canonical text.
45///
46/// The output is idempotent: `format_document(format_document(s, c), c) == format_document(s, c)`.
47///
48/// # Errors
49/// Returns the parser error string if the input is not valid FD syntax.
50pub fn format_document(text: &str, config: &FormatConfig) -> Result<String, String> {
51    let mut scene = parse_document(text)?;
52
53    if config.dedup_use {
54        dedup_use_styles(&mut scene);
55    }
56
57    if config.hoist_styles {
58        crate::transform::hoist_styles(&mut scene);
59    }
60
61    if config.sort_nodes {
62        sort_nodes(&mut scene);
63    }
64
65    Ok(emit_document(&scene))
66}
67
68// ─── Tests ────────────────────────────────────────────────────────────────
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn format_document_default_is_idempotent() {
76        let input = r#"
77# FD v1
78
79theme accent {
80  fill: #6C5CE7
81  corner: 10
82}
83
84rect @primary_btn {
85  w: 200 h: 48
86  use: accent
87}
88"#;
89        let config = FormatConfig::default();
90        let first = format_document(input, &config).expect("first format failed");
91        let second = format_document(&first, &config).expect("second format failed");
92        assert_eq!(first, second, "format must be idempotent");
93    }
94
95    #[test]
96    fn format_document_dedupes_use_styles() {
97        let input = r#"
98theme card {
99  fill: #FFF
100}
101rect @box {
102  w: 100 h: 50
103  use: card
104  use: card
105}
106"#;
107        let config = FormatConfig::default();
108        let output = format_document(input, &config).expect("format failed");
109        // Should appear only once
110        let use_count = output.matches("use: card").count();
111        assert_eq!(use_count, 1, "duplicate use: should be removed");
112    }
113
114    #[test]
115    fn format_document_preserves_comments() {
116        let input = r#"
117# This is a section header
118rect @box {
119  w: 100 h: 50
120  fill: #FF0000
121}
122"#;
123        let config = FormatConfig::default();
124        let output = format_document(input, &config).expect("format failed");
125        assert!(
126            output.contains("# This is a section header"),
127            "comments must survive formatting"
128        );
129    }
130
131    #[test]
132    fn format_document_sorts_nodes_by_kind() {
133        let input = r#"
134text @label "World" {
135  font: "Inter" regular 14
136}
137rect @box {
138  w: 100 h: 50
139}
140group @wrapper {
141  rect @child {
142    w: 50 h: 50
143  }
144}
145"#;
146        let config = FormatConfig::default();
147        let output = format_document(input, &config).expect("format failed");
148        // In the output, group should appear before rect, rect before text
149        let group_pos = output.find("group @wrapper").expect("group not found");
150        let rect_pos = output.find("rect @box").expect("rect not found");
151        let text_pos = output.find("text @label").expect("text not found");
152        assert!(
153            group_pos < rect_pos,
154            "group should come before rect in formatted output"
155        );
156        assert!(
157            rect_pos < text_pos,
158            "rect should come before text in formatted output"
159        );
160    }
161
162    #[test]
163    fn format_document_sort_is_idempotent() {
164        let input = r#"
165text @label "Hello" {
166  font: "Inter" regular 14
167}
168ellipse @circle {
169  w: 60 h: 60
170}
171rect @box {
172  w: 100 h: 50
173}
174group @container {
175  rect @inner {
176    w: 50 h: 50
177  }
178}
179"#;
180        let config = FormatConfig::default();
181        let first = format_document(input, &config).expect("first format failed");
182        let second = format_document(&first, &config).expect("second format failed");
183        assert_eq!(first, second, "sort + format must be idempotent");
184    }
185}