Skip to main content

aam_core/pipeline/
formatter.rs

1#![allow(clippy::struct_excessive_bools)]
2
3//! Formatter trait for LSP integration.
4//!
5//! The Formatter provides an API for formatting AAML documents without requiring
6//! full pipeline execution. This is critical for LSP support (document formatting,
7//! range formatting, on-save hooks).
8
9use crate::error::AamlError;
10use crate::pipeline::parser::AstNode;
11
12/// Configuration options for formatting behavior.
13#[derive(Debug, Clone)]
14pub struct FormattingOptions {
15    /// Number of spaces per indentation level
16    pub indent_size: usize,
17
18    /// Use tabs instead of spaces
19    pub use_tabs: bool,
20
21    /// Line width for wrapping (0 = no wrapping)
22    pub line_width: usize,
23
24    /// Sort keys alphabetically
25    pub sort_keys: bool,
26
27    /// Add trailing newline
28    pub trailing_newline: bool,
29
30    /// Preserve blank lines
31    pub preserve_blank_lines: bool,
32}
33
34impl Default for FormattingOptions {
35    fn default() -> Self {
36        Self {
37            indent_size: 4,
38            use_tabs: false,
39            line_width: 100,
40            sort_keys: false,
41            trailing_newline: true,
42            preserve_blank_lines: true,
43        }
44    }
45}
46
47/// Range for document range formatting.
48#[derive(Debug, Clone, Copy)]
49pub struct FormatRange {
50    /// Start line (1-based)
51    pub start_line: usize,
52    /// End line (1-based, inclusive)
53    pub end_line: usize,
54}
55
56pub trait Formatter: Send + Sync {
57    /// Formats an entire document from AST nodes.
58    ///
59    /// # Errors
60    ///
61    /// Returns an error if the AST is invalid or formatting cannot be completed.
62    fn format_document(
63        &self,
64        nodes: &[AstNode],
65        options: &FormattingOptions,
66    ) -> Result<String, AamlError>;
67
68    /// Formats only a selected AST range.
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if the AST is invalid, the range is out of bounds, or formatting fails.
73    fn format_range(
74        &self,
75        nodes: &[AstNode],
76        range: FormatRange,
77        options: &FormattingOptions,
78    ) -> Result<String, AamlError>;
79
80    /// Formats a single AST node.
81    ///
82    /// # Errors
83    ///
84    /// Returns an error if the node cannot be rendered with the provided indentation or options.
85    fn format_node(
86        &self,
87        node: &AstNode,
88        indent_level: usize,
89        options: &FormattingOptions,
90    ) -> Result<String, AamlError>;
91
92    /// Normalizes comments in a document.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if the input cannot be processed into a normalized comment layout.
97    fn normalize_comments(
98        &self,
99        content: &str,
100        options: &FormattingOptions,
101    ) -> Result<String, AamlError>;
102
103    /// Normalizes whitespace in a document.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if whitespace normalization cannot be completed.
108    fn normalize_whitespace(&self, content: &str) -> Result<String, AamlError>;
109}
110
111pub struct DefaultFormatter;
112
113impl DefaultFormatter {
114    #[must_use]
115    pub const fn new() -> Self {
116        Self
117    }
118
119    /// Creates indentation string based on level and options.
120    fn create_indent(level: usize, options: &FormattingOptions) -> String {
121        if options.use_tabs {
122            "\t".repeat(level)
123        } else {
124            " ".repeat(level * options.indent_size)
125        }
126    }
127
128    /// Checks if the directive should be hoisted to the top (imports, derives)
129    fn is_hoistable(node: &AstNode) -> bool {
130        if let AstNode::Directive { name, .. } = node {
131            matches!(name.as_ref(), "import" | "derive")
132        } else {
133            false
134        }
135    }
136
137    /// Жестко контролируем пробелы вокруг знака равенства.
138    fn format_assignment(
139        key: &str,
140        value: &str,
141        indent_level: usize,
142        options: &FormattingOptions,
143    ) -> String {
144        let indent = Self::create_indent(indent_level, options);
145        // Trim just in case the raw value/key carries garbage whitespace
146        format!("{}{} = {}", indent, key.trim(), value.trim())
147    }
148
149    /// Умное форматирование алиасов типов (@type name = alias)
150    fn format_type_alias(args: &str, indent_level: usize, options: &FormattingOptions) -> String {
151        let indent = Self::create_indent(indent_level, options);
152        if let Some((name, alias)) = args.split_once('=') {
153            format!("{}@type {} = {}", indent, name.trim(), alias.trim())
154        } else {
155            format!("{}@type {}", indent, args.trim())
156        }
157    }
158
159    fn format_schema(args: &str, indent_level: usize, options: &FormattingOptions) -> String {
160        let indent = Self::create_indent(indent_level, options);
161
162        let Some((name_part, body_part)) = args.split_once('{') else {
163            return Self::format_directive("schema", args, indent_level, options);
164        };
165
166        let schema_name = name_part.trim();
167        let body = body_part.trim_end_matches('}').trim();
168
169        let pairs: Vec<_> = body
170            .split([',', '\n'])
171            .map(str::trim)
172            .filter(|s| !s.is_empty())
173            .collect();
174
175        let mut formatted_pairs = Vec::new();
176        for pair in pairs {
177            if let Some((k, v)) = pair.split_once(':') {
178                formatted_pairs.push(format!("{}: {}", k.trim(), v.trim()));
179            } else {
180                formatted_pairs.push(pair.to_string());
181            }
182        }
183
184        let single_line = format!(
185            "{}@schema {} {{ {} }}",
186            indent,
187            schema_name,
188            formatted_pairs.join(", ")
189        );
190
191        if options.line_width == 0 || single_line.len() <= options.line_width {
192            return single_line;
193        }
194
195        let inner_indent = Self::create_indent(indent_level + 1, options);
196        let mut lines = vec![format!("{}@schema {} {{", indent, schema_name)];
197
198        for pair in formatted_pairs {
199            lines.push(format!("{inner_indent}{pair}"));
200        }
201        lines.push(format!("{indent}}}"));
202
203        lines.join("\n")
204    }
205
206    fn format_directive(
207        name: &str,
208        args: &str,
209        indent_level: usize,
210        options: &FormattingOptions,
211    ) -> String {
212        let indent = Self::create_indent(indent_level, options);
213        if args.trim().is_empty() {
214            format!("{}@{}", indent, name.trim())
215        } else {
216            format!("{}@{} {}", indent, name.trim(), args.trim())
217        }
218    }
219}
220
221impl Default for DefaultFormatter {
222    fn default() -> Self {
223        Self::new()
224    }
225}
226
227impl Formatter for DefaultFormatter {
228    fn format_document(
229        &self,
230        nodes: &[AstNode],
231        options: &FormattingOptions,
232    ) -> Result<String, AamlError> {
233        let (hoistable, others): (Vec<_>, Vec<_>) =
234            nodes.iter().partition(|n| Self::is_hoistable(n));
235
236        let mut header: Vec<String> = hoistable
237            .into_iter()
238            .map(|n| self.format_node(n, 0, options))
239            .collect::<Result<_, _>>()?;
240
241        let body: Vec<String> = others
242            .into_iter()
243            .map(|n| self.format_node(n, 0, options))
244            .collect::<Result<_, _>>()?;
245
246        if !header.is_empty() && !body.is_empty() && options.preserve_blank_lines {
247            header.push(String::new());
248        }
249
250        let mut result = [header, body].concat().join("\n");
251
252        if options.trailing_newline && !result.is_empty() && !result.ends_with('\n') {
253            result.push('\n');
254        }
255
256        Ok(result)
257    }
258
259    fn format_range(
260        &self,
261        nodes: &[AstNode],
262        range: FormatRange,
263        options: &FormattingOptions,
264    ) -> Result<String, AamlError> {
265        let mut output = Vec::new();
266
267        for node in nodes {
268            let line = node.line(); // Предполагаем, что у AstNode есть метод .line()
269            if line >= range.start_line && line <= range.end_line {
270                let formatted = self.format_node(node, 0, options)?;
271                output.push(formatted);
272            } else {
273                output.push(format!("(original line {line})"));
274            }
275        }
276
277        Ok(output.join("\n"))
278    }
279
280    fn format_node(
281        &self,
282        node: &AstNode,
283        indent_level: usize,
284        options: &FormattingOptions,
285    ) -> Result<String, AamlError> {
286        let formatted = match node {
287            AstNode::Assignment { key, value, .. } => {
288                Self::format_assignment(key, &value.to_string(), indent_level, options)
289            }
290            AstNode::Directive { name, args, .. } => match name.as_ref() {
291                "schema" => Self::format_schema(args, indent_level, options),
292                "type" => Self::format_type_alias(args, indent_level, options),
293                _ => Self::format_directive(name.as_ref(), args, indent_level, options),
294            },
295        };
296
297        Ok(formatted)
298    }
299
300    fn normalize_comments(
301        &self,
302        content: &str,
303        _options: &FormattingOptions,
304    ) -> Result<String, AamlError> {
305        let lines: Vec<&str> = content.lines().collect();
306        let normalized: Vec<String> = lines
307            .iter()
308            .map(|line| {
309                if let Some(pos) = line.find('#') {
310                    let before = &line[..pos];
311                    let after = &line[pos + 1..];
312
313                    if pos > 0
314                        && pos < line.len() - 1
315                        && before.ends_with(' ')
316                        && !after.starts_with('#')
317                    {
318                        let comment = after.trim_start();
319                        return format!("{}# {}", before.trim_end(), comment);
320                    }
321                }
322                line.to_string()
323            })
324            .collect();
325
326        Ok(normalized.join("\n"))
327    }
328
329    fn normalize_whitespace(&self, content: &str) -> Result<String, AamlError> {
330        let lines: Vec<&str> = content.lines().collect();
331        let normalized: Vec<String> = lines
332            .iter()
333            .map(|line| line.trim_end().to_string())
334            .collect();
335
336        Ok(normalized.join("\n"))
337    }
338}