sigil_parser/
fmt.rs

1//! Code Formatter for Sigil Language
2//!
3//! Provides consistent code formatting with configurable style options.
4//!
5//! Usage:
6//!   sigil fmt <file>           Format a single file
7//!   sigil fmt <dir>            Format all .sg/.sigil files in directory
8//!   sigil fmt --check <path>   Check formatting without modifying
9//!   sigil fmt --stdin          Read from stdin, write to stdout
10
11use std::fs;
12use std::io::{self, Read};
13use std::path::Path;
14
15/// Formatting configuration
16#[derive(Debug, Clone)]
17pub struct FormatConfig {
18    /// Indentation width (spaces)
19    pub indent_width: usize,
20    /// Use tabs instead of spaces
21    pub use_tabs: bool,
22    /// Maximum line width
23    pub max_line_width: usize,
24    /// Add trailing commas
25    pub trailing_commas: bool,
26    /// Space after colons in type annotations
27    pub space_after_colon: bool,
28    /// Space around binary operators
29    pub space_around_ops: bool,
30}
31
32impl Default for FormatConfig {
33    fn default() -> Self {
34        Self {
35            indent_width: 4,
36            use_tabs: false,
37            max_line_width: 100,
38            trailing_commas: true,
39            space_after_colon: true,
40            space_around_ops: true,
41        }
42    }
43}
44
45impl FormatConfig {
46    /// Load config from sigil.toml or .sigilfmt.toml
47    pub fn load() -> Self {
48        // Try to load from config files
49        if let Ok(content) = fs::read_to_string("sigil.toml") {
50            if let Ok(parsed) = toml::from_str::<toml::Value>(&content) {
51                if let Some(fmt) = parsed.get("fmt") {
52                    return Self::from_toml(fmt);
53                }
54            }
55        }
56
57        if let Ok(content) = fs::read_to_string(".sigilfmt.toml") {
58            if let Ok(parsed) = toml::from_str::<toml::Value>(&content) {
59                return Self::from_toml(&parsed);
60            }
61        }
62
63        Self::default()
64    }
65
66    fn from_toml(value: &toml::Value) -> Self {
67        let mut config = Self::default();
68
69        if let Some(width) = value.get("indent_width").and_then(|v| v.as_integer()) {
70            config.indent_width = width as usize;
71        }
72        if let Some(tabs) = value.get("use_tabs").and_then(|v| v.as_bool()) {
73            config.use_tabs = tabs;
74        }
75        if let Some(width) = value.get("max_line_width").and_then(|v| v.as_integer()) {
76            config.max_line_width = width as usize;
77        }
78        if let Some(trailing) = value.get("trailing_commas").and_then(|v| v.as_bool()) {
79            config.trailing_commas = trailing;
80        }
81        if let Some(space) = value.get("space_after_colon").and_then(|v| v.as_bool()) {
82            config.space_after_colon = space;
83        }
84        if let Some(space) = value.get("space_around_ops").and_then(|v| v.as_bool()) {
85            config.space_around_ops = space;
86        }
87
88        config
89    }
90}
91
92/// Line-based code formatter
93pub struct Formatter {
94    config: FormatConfig,
95}
96
97impl Formatter {
98    pub fn new(config: FormatConfig) -> Self {
99        Self { config }
100    }
101
102    /// Format source code string
103    pub fn format_source(&self, source: &str) -> Result<String, String> {
104        let mut output = String::new();
105        let mut indent_level: i32 = 0;
106
107        for line in source.lines() {
108            let trimmed = line.trim();
109
110            // Skip empty lines but preserve them
111            if trimmed.is_empty() {
112                output.push('\n');
113                continue;
114            }
115
116            // Handle comment lines
117            if trimmed.starts_with("//") {
118                output.push_str(&self.make_indent(indent_level));
119                output.push_str(trimmed);
120                output.push('\n');
121                continue;
122            }
123
124            // Adjust indent for closing braces at start of line
125            let starts_with_close = trimmed.starts_with('}')
126                || trimmed.starts_with(')')
127                || trimmed.starts_with(']');
128
129            if starts_with_close && indent_level > 0 {
130                indent_level -= 1;
131            }
132
133            // Format the line
134            let formatted_line = self.format_line(trimmed);
135
136            // Write with proper indentation
137            output.push_str(&self.make_indent(indent_level));
138            output.push_str(&formatted_line);
139            output.push('\n');
140
141            // Count braces for next line's indentation
142            let mut depth_change: i32 = 0;
143            let mut in_string = false;
144            let mut in_char = false;
145            let mut prev_char = '\0';
146
147            for ch in trimmed.chars() {
148                // Track string/char literals
149                if ch == '"' && prev_char != '\\' && !in_char {
150                    in_string = !in_string;
151                } else if ch == '\'' && prev_char != '\\' && !in_string {
152                    in_char = !in_char;
153                }
154
155                // Count braces outside of strings
156                if !in_string && !in_char {
157                    match ch {
158                        '{' | '(' | '[' => depth_change += 1,
159                        '}' | ')' | ']' => {
160                            // Only decrement if this isn't at start (already handled)
161                            if !starts_with_close || depth_change > 0 {
162                                depth_change -= 1;
163                            }
164                        }
165                        _ => {}
166                    }
167                }
168
169                prev_char = ch;
170            }
171
172            indent_level += depth_change;
173            if indent_level < 0 {
174                indent_level = 0;
175            }
176        }
177
178        // Ensure file ends with newline
179        if !output.ends_with('\n') {
180            output.push('\n');
181        }
182
183        // Remove trailing whitespace from each line
184        let cleaned: String = output
185            .lines()
186            .map(|line| line.trim_end())
187            .collect::<Vec<_>>()
188            .join("\n");
189
190        Ok(if cleaned.is_empty() {
191            String::new()
192        } else {
193            cleaned + "\n"
194        })
195    }
196
197    fn make_indent(&self, level: i32) -> String {
198        if level <= 0 {
199            return String::new();
200        }
201        let level = level as usize;
202        if self.config.use_tabs {
203            "\t".repeat(level)
204        } else {
205            " ".repeat(level * self.config.indent_width)
206        }
207    }
208
209    fn format_line(&self, line: &str) -> String {
210        let mut result = String::new();
211        let mut chars = line.chars().peekable();
212        let mut in_string = false;
213        let mut in_char = false;
214        let mut prev_char = '\0';
215        let mut last_was_space = false;
216
217        while let Some(ch) = chars.next() {
218            // Track string/char literals
219            if ch == '"' && prev_char != '\\' && !in_char {
220                in_string = !in_string;
221            } else if ch == '\'' && prev_char != '\\' && !in_string {
222                in_char = !in_char;
223            }
224
225            // Inside strings/chars, preserve exactly
226            if in_string || in_char {
227                result.push(ch);
228                prev_char = ch;
229                last_was_space = false;
230                continue;
231            }
232
233            // Normalize whitespace
234            if ch.is_whitespace() {
235                if !last_was_space && !result.is_empty() {
236                    result.push(' ');
237                    last_was_space = true;
238                }
239                prev_char = ch;
240                continue;
241            }
242
243            last_was_space = false;
244
245            // Handle operators with spacing
246            if self.config.space_around_ops {
247                match ch {
248                    '+' | '-' | '*' | '/' | '%' | '=' | '<' | '>' | '!' | '&' | '|' | '^' => {
249                        // Check for compound operators
250                        let next = chars.peek().copied();
251                        let is_compound = matches!(
252                            (ch, next),
253                            ('+', Some('+'))
254                                | ('-', Some('-'))
255                                | ('*', Some('*'))
256                                | ('/', Some('/'))
257                                | ('=', Some('='))
258                                | ('!', Some('='))
259                                | ('<', Some('='))
260                                | ('>', Some('='))
261                                | ('<', Some('<'))
262                                | ('>', Some('>'))
263                                | ('&', Some('&'))
264                                | ('|', Some('|'))
265                                | ('|', Some('>'))
266                                | ('-', Some('>'))
267                                | ('=', Some('>'))
268                        );
269
270                        // Don't add space before unary operators
271                        let is_unary = prev_char == '('
272                            || prev_char == '['
273                            || prev_char == ','
274                            || prev_char == '='
275                            || prev_char == '<'
276                            || prev_char == '>'
277                            || prev_char == '{'
278                            || prev_char == '\0'
279                            || result.is_empty();
280
281                        // Special case: don't space around :: or ->
282                        if ch == ':' && next == Some(':') {
283                            result.push(ch);
284                            prev_char = ch;
285                            continue;
286                        }
287
288                        if ch == '-' && next == Some('>') {
289                            // Return type arrow
290                            if !result.ends_with(' ') {
291                                result.push(' ');
292                            }
293                            result.push('-');
294                            result.push(chars.next().unwrap());
295                            result.push(' ');
296                            prev_char = '>';
297                            continue;
298                        }
299
300                        if !is_unary {
301                            if !result.ends_with(' ') {
302                                result.push(' ');
303                            }
304                        }
305
306                        result.push(ch);
307
308                        if is_compound {
309                            result.push(chars.next().unwrap());
310                        }
311
312                        // Add space after binary operators
313                        if !is_unary && !matches!(next, Some('=') | Some('>') | Some('<')) {
314                            result.push(' ');
315                            last_was_space = true;
316                        }
317
318                        prev_char = ch;
319                        continue;
320                    }
321                    _ => {}
322                }
323            }
324
325            // Handle colons with optional spacing
326            if ch == ':' {
327                let next = chars.peek().copied();
328                if next == Some(':') {
329                    // Path separator ::
330                    result.push(':');
331                    result.push(chars.next().unwrap());
332                    prev_char = ':';
333                    continue;
334                }
335
336                result.push(':');
337                if self.config.space_after_colon && next != Some(':') {
338                    result.push(' ');
339                    last_was_space = true;
340                }
341                prev_char = ':';
342                continue;
343            }
344
345            // Handle commas
346            if ch == ',' {
347                result.push(',');
348                result.push(' ');
349                last_was_space = true;
350                prev_char = ch;
351                continue;
352            }
353
354            // Handle semicolons
355            if ch == ';' {
356                // Remove trailing space before semicolon
357                if result.ends_with(' ') {
358                    result.pop();
359                }
360                result.push(';');
361                prev_char = ch;
362                continue;
363            }
364
365            // Handle opening braces
366            if ch == '{' {
367                // Add space before { if not already present
368                if !result.is_empty() && !result.ends_with(' ') && !result.ends_with('(') {
369                    result.push(' ');
370                }
371                result.push('{');
372                prev_char = ch;
373                continue;
374            }
375
376            result.push(ch);
377            prev_char = ch;
378        }
379
380        // Trim trailing whitespace
381        result.trim_end().to_string()
382    }
383}
384
385/// Format a file in place
386pub fn format_file(path: &Path, config: &FormatConfig) -> Result<bool, String> {
387    let source = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
388
389    let formatter = Formatter::new(config.clone());
390    let formatted = formatter.format_source(&source)?;
391
392    if formatted == source {
393        return Ok(false); // No changes
394    }
395
396    fs::write(path, &formatted).map_err(|e| format!("Failed to write file: {}", e))?;
397
398    Ok(true)
399}
400
401/// Check if a file is formatted
402pub fn check_file(path: &Path, config: &FormatConfig) -> Result<bool, String> {
403    let source = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
404
405    let formatter = Formatter::new(config.clone());
406    let formatted = formatter.format_source(&source)?;
407
408    Ok(formatted == source)
409}
410
411/// Format source from stdin
412pub fn format_stdin(config: &FormatConfig) -> Result<String, String> {
413    let mut source = String::new();
414    io::stdin()
415        .read_to_string(&mut source)
416        .map_err(|e| format!("Failed to read stdin: {}", e))?;
417
418    let formatter = Formatter::new(config.clone());
419    formatter.format_source(&source)
420}
421
422/// Format all Sigil files in a directory
423pub fn format_directory(
424    dir: &Path,
425    config: &FormatConfig,
426    check_only: bool,
427) -> Result<FormatResult, String> {
428    let mut result = FormatResult::default();
429
430    for entry in walkdir::WalkDir::new(dir)
431        .into_iter()
432        .filter_map(|e| e.ok())
433        .filter(|e| {
434            let path = e.path();
435            path.is_file()
436                && (path.extension().map_or(false, |ext| ext == "sg" || ext == "sigil"))
437        })
438    {
439        let path = entry.path();
440        result.total += 1;
441
442        if check_only {
443            match check_file(path, config) {
444                Ok(true) => result.formatted += 1,
445                Ok(false) => {
446                    result.unformatted.push(path.to_path_buf());
447                }
448                Err(e) => {
449                    result.errors.push((path.to_path_buf(), e));
450                }
451            }
452        } else {
453            match format_file(path, config) {
454                Ok(true) => {
455                    result.formatted += 1;
456                    result.changed.push(path.to_path_buf());
457                }
458                Ok(false) => result.formatted += 1,
459                Err(e) => {
460                    result.errors.push((path.to_path_buf(), e));
461                }
462            }
463        }
464    }
465
466    Ok(result)
467}
468
469/// Result of formatting operation
470#[derive(Debug, Default)]
471pub struct FormatResult {
472    pub total: usize,
473    pub formatted: usize,
474    pub changed: Vec<std::path::PathBuf>,
475    pub unformatted: Vec<std::path::PathBuf>,
476    pub errors: Vec<(std::path::PathBuf, String)>,
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn test_basic_formatting() {
485        let config = FormatConfig::default();
486        let formatter = Formatter::new(config);
487
488        let input = "fn main(){let x=1+2;}";
489        let formatted = formatter.format_source(input).unwrap();
490        assert!(formatted.contains("fn main()"));
491    }
492
493    #[test]
494    fn test_indentation() {
495        let config = FormatConfig::default();
496        let formatter = Formatter::new(config);
497
498        let input = "fn main() {\nlet x = 1;\n}";
499        let formatted = formatter.format_source(input).unwrap();
500        assert!(formatted.contains("    let x")); // 4 spaces indent
501    }
502
503    #[test]
504    fn test_preserves_strings() {
505        let config = FormatConfig::default();
506        let formatter = Formatter::new(config);
507
508        let input = r#"let s = "hello   world";"#;
509        let formatted = formatter.format_source(input).unwrap();
510        assert!(formatted.contains("\"hello   world\""));
511    }
512}