1use std::fs;
12use std::io::{self, Read};
13use std::path::Path;
14
15#[derive(Debug, Clone)]
17pub struct FormatConfig {
18 pub indent_width: usize,
20 pub use_tabs: bool,
22 pub max_line_width: usize,
24 pub trailing_commas: bool,
26 pub space_after_colon: bool,
28 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 pub fn load() -> Self {
48 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
92pub struct Formatter {
94 config: FormatConfig,
95}
96
97impl Formatter {
98 pub fn new(config: FormatConfig) -> Self {
99 Self { config }
100 }
101
102 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 if trimmed.is_empty() {
112 output.push('\n');
113 continue;
114 }
115
116 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 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 let formatted_line = self.format_line(trimmed);
135
136 output.push_str(&self.make_indent(indent_level));
138 output.push_str(&formatted_line);
139 output.push('\n');
140
141 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 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 if !in_string && !in_char {
157 match ch {
158 '{' | '(' | '[' => depth_change += 1,
159 '}' | ')' | ']' => {
160 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 if !output.ends_with('\n') {
180 output.push('\n');
181 }
182
183 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 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 if in_string || in_char {
227 result.push(ch);
228 prev_char = ch;
229 last_was_space = false;
230 continue;
231 }
232
233 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 if self.config.space_around_ops {
247 match ch {
248 '+' | '-' | '*' | '/' | '%' | '=' | '<' | '>' | '!' | '&' | '|' | '^' => {
249 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 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 if ch == ':' && next == Some(':') {
283 result.push(ch);
284 prev_char = ch;
285 continue;
286 }
287
288 if ch == '-' && next == Some('>') {
289 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 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 if ch == ':' {
327 let next = chars.peek().copied();
328 if next == Some(':') {
329 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 if ch == ',' {
347 result.push(',');
348 result.push(' ');
349 last_was_space = true;
350 prev_char = ch;
351 continue;
352 }
353
354 if ch == ';' {
356 if result.ends_with(' ') {
358 result.pop();
359 }
360 result.push(';');
361 prev_char = ch;
362 continue;
363 }
364
365 if ch == '{' {
367 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 result.trim_end().to_string()
382 }
383}
384
385pub 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); }
395
396 fs::write(path, &formatted).map_err(|e| format!("Failed to write file: {}", e))?;
397
398 Ok(true)
399}
400
401pub 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
411pub 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
422pub 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#[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")); }
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}