Skip to main content

euv_cli/fmt/
fn.rs

1use crate::*;
2
3/// Formats a single Rust source file by reformatting all euv macro invocations.
4///
5/// Scans the source code for `html!`, `class!`, `vars!`, and `watch!`
6/// macro calls, then applies formatting rules to their token bodies:
7///
8/// - Tag name and `{` separated by exactly one space
9/// - Attribute name immediately followed by `:`, then one space before the value
10/// - `if { expr }` with single spaces around each token
11/// - `match { expr }` with proper alignment
12/// - `for pattern in { expr }` with proper alignment
13/// - `=>` in watch! macros with proper spacing
14///
15/// # Arguments
16///
17/// - `&str` - The Rust source code content.
18///
19/// # Returns
20///
21/// - `FmtResult` - The formatting result indicating whether changes were made.
22pub(crate) fn format_source(source: &str) -> FmtResult {
23    let formatted: String = format_euv_macros(source);
24    let changed: bool = formatted != source;
25    FmtResult::new(changed, formatted)
26}
27
28/// Finds and reformats all euv macro invocations in the source text.
29///
30/// Uses a character-level scanner to locate macro calls (e.g., `html! { ... }`),
31/// tracks brace depth to find the complete macro body, then formats the body
32/// using `format_macro_body`.
33///
34/// # Arguments
35///
36/// - `S: AsRef<str>` - The Rust source code content.
37///
38/// # Returns
39///
40/// - `String` - The source with all euv macros reformatted.
41pub fn format_euv_macros<S>(source: S) -> String
42where
43    S: AsRef<str>,
44{
45    let source: &str = source.as_ref();
46    let mut result: String = String::new();
47    let chars: Vec<char> = source.chars().collect();
48    let len: usize = chars.len();
49    let mut position: usize = 0;
50    while position < len {
51        if is_euv_macro_start(&chars, position, len) {
52            let macro_name_end: usize = find_macro_name_end(&chars, position, len);
53            let name: String = chars[position..macro_name_end].iter().collect::<String>();
54            result.push_str(&name);
55            position = macro_name_end;
56            position = skip_whitespace_and_comments(&chars, position, len, &mut result);
57            if position < len && chars[position] == CHAR_MACRO_BANG {
58                result.push(CHAR_MACRO_BANG);
59                position += 1;
60                position = skip_whitespace_and_comments(&chars, position, len, &mut result);
61                if position < len && chars[position] == CHAR_BRACE_LEFT {
62                    if !result.ends_with(CHAR_SPACE) {
63                        result.push(CHAR_SPACE);
64                    }
65                    let (body_content, end_pos) = extract_brace_content(&chars, position);
66                    let formatted_body: String = format_macro_body(&body_content);
67                    if formatted_body.trim().is_empty() {
68                        result.push(CHAR_BRACE_LEFT);
69                        result.push(CHAR_BRACE_RIGHT);
70                    } else {
71                        let trimmed_body: &str = formatted_body.trim();
72                        let last_newline_pos: usize =
73                            result.rfind(CHAR_NEWLINE).map(|i| i + 1).unwrap_or(0);
74                        let macro_indent: usize = result[last_newline_pos..]
75                            .chars()
76                            .take_while(|&c| c == CHAR_SPACE)
77                            .count();
78                        let min_indent: usize = body_content
79                            .lines()
80                            .filter(|line| !line.trim().is_empty())
81                            .map(|line| line.chars().take_while(|c| c.is_whitespace()).count())
82                            .min()
83                            .unwrap_or(0);
84                        let base_indent: usize = if min_indent > 0 {
85                            min_indent
86                        } else {
87                            macro_indent + 4
88                        };
89                        let indent_str: String = " ".repeat(base_indent);
90                        let indented_body: String = trimmed_body
91                            .lines()
92                            .map(|line| {
93                                if line.trim().is_empty() {
94                                    line.to_string()
95                                } else {
96                                    format!("{}{}", indent_str, line)
97                                }
98                            })
99                            .collect::<Vec<String>>()
100                            .join("\n");
101                        let outer_indent_str: String = " ".repeat(macro_indent);
102                        result.push(CHAR_BRACE_LEFT);
103                        result.push(CHAR_NEWLINE);
104                        result.push_str(&indented_body);
105                        result.push(CHAR_NEWLINE);
106                        result.push_str(&outer_indent_str);
107                        result.push(CHAR_BRACE_RIGHT);
108                    }
109                    position = end_pos;
110                    continue;
111                }
112            }
113            continue;
114        }
115        if chars[position] == CHAR_DOUBLE_QUOTE || chars[position] == CHAR_SINGLE_QUOTE {
116            let (literal, end_pos) = extract_string_literal(&chars, position, len);
117            result.push_str(&literal);
118            position = end_pos;
119            continue;
120        }
121        if position + 1 < len
122            && chars[position] == CHAR_SLASH_FORWARD
123            && chars[position + 1] == CHAR_SLASH_FORWARD
124        {
125            let (comment, end_pos) = extract_line_comment(&chars, position, len);
126            result.push_str(&comment);
127            position = end_pos;
128            continue;
129        }
130        if position + 1 < len
131            && chars[position] == CHAR_SLASH_FORWARD
132            && chars[position + 1] == CHAR_ASTERISK
133        {
134            let (comment, end_pos) = extract_block_comment(&chars, position, len);
135            result.push_str(&comment);
136            position = end_pos;
137            continue;
138        }
139        result.push(chars[position]);
140        position += 1;
141    }
142    result
143}
144
145/// Checks whether the current position starts a known euv macro name.
146///
147/// # Arguments
148///
149/// - `&[char]` - The source character slice.
150/// - `usize` - The current position.
151/// - `usize` - The total length of the source.
152///
153/// # Returns
154///
155/// - `bool` - Whether a euv macro name starts at the given position.
156fn is_euv_macro_start(chars: &[char], pos: usize, len: usize) -> bool {
157    for name in EUV_MACRO_NAMES {
158        let name_len: usize = name.len();
159        if pos + name_len > len {
160            continue;
161        }
162        let candidate: String = chars[pos..pos + name_len].iter().collect::<String>();
163        if candidate != *name {
164            continue;
165        }
166        if pos > 0 && is_ident_char(chars[pos - 1]) {
167            continue;
168        }
169        if pos + name_len < len && is_ident_char(chars[pos + name_len]) {
170            continue;
171        }
172        return true;
173    }
174    false
175}
176
177/// Finds the end position of a macro name starting at the given position.
178///
179/// # Arguments
180///
181/// - `&[char]` - The source character slice.
182/// - `usize` - The start position of the macro name.
183/// - `usize` - The total length of the source.
184///
185/// # Returns
186///
187/// - `usize` - The end position (exclusive) of the macro name.
188fn find_macro_name_end(chars: &[char], pos: usize, _len: usize) -> usize {
189    let mut end: usize = pos;
190    while end < chars.len() && is_ident_char(chars[end]) {
191        end += 1;
192    }
193    end
194}
195
196/// Checks whether the keyword at the given position is preceded by `r#`,
197/// indicating a Rust raw identifier rather than a keyword.
198///
199/// # Arguments
200///
201/// - `&[char]` - The source character slice.
202/// - `usize` - The position of the keyword.
203///
204/// # Returns
205///
206/// - `bool` - Whether the keyword is preceded by `r#`.
207fn is_raw_prefix(chars: &[char], pos: usize) -> bool {
208    if pos < 2 {
209        return false;
210    }
211    chars[pos - 2] == CHAR_LETTER_R && chars[pos - 1] == CHAR_HASH
212}
213
214/// Checks if a character can be part of a Rust identifier.
215///
216/// # Arguments
217///
218/// - `char` - The character to check.
219///
220/// # Returns
221///
222/// - `bool` - `true` if the character is alphanumeric or underscore.
223fn is_ident_char(character: char) -> bool {
224    character.is_alphanumeric() || character == CHAR_UNDERSCORE
225}
226
227/// Skips whitespace and comments, preserving them in the output.
228///
229/// # Arguments
230///
231/// - `&[char]` - The source character slice.
232/// - `usize` - The current position.
233/// - `usize` - The total length.
234/// - `&mut String` - The output string to append preserved whitespace/comments to.
235///
236/// # Returns
237///
238/// - `usize` - The new position after skipping whitespace and comments.
239fn skip_whitespace_and_comments(
240    chars: &[char],
241    mut pos: usize,
242    len: usize,
243    result: &mut String,
244) -> usize {
245    while pos < len {
246        if chars[pos].is_whitespace() {
247            result.push(chars[pos]);
248            pos += 1;
249        } else if pos + 1 < len
250            && chars[pos] == CHAR_SLASH_FORWARD
251            && chars[pos + 1] == CHAR_SLASH_FORWARD
252        {
253            let (comment, end_pos) = extract_line_comment(chars, pos, len);
254            result.push_str(&comment);
255            pos = end_pos;
256        } else if pos + 1 < len
257            && chars[pos] == CHAR_SLASH_FORWARD
258            && chars[pos + 1] == CHAR_ASTERISK
259        {
260            let (comment, end_pos) = extract_block_comment(chars, pos, len);
261            result.push_str(&comment);
262            pos = end_pos;
263        } else {
264            break;
265        }
266    }
267    pos
268}
269
270/// Extracts a brace-enclosed block verbatim (including the braces).
271///
272/// Assumes `chars[start]` is `{`. Returns the full content (including the
273/// outer `{` and `}`) and the position after the closing `}`.
274///
275/// # Arguments
276///
277/// - `&[char]` - The source character slice.
278/// - `usize` - The position of the opening `{`.
279///
280/// # Returns
281///
282/// - `(String, usize)` - The content including braces and the position after the closing `}`.
283fn extract_brace_block(chars: &[char], start: usize) -> (String, usize) {
284    let mut depth: i32 = 0;
285    let mut position: usize = start;
286    while position < chars.len() {
287        if chars[position] == CHAR_DOUBLE_QUOTE || chars[position] == CHAR_SINGLE_QUOTE {
288            let (_, end) = extract_string_literal(chars, position, chars.len());
289            position = end;
290            continue;
291        }
292        if position + 1 < chars.len()
293            && chars[position] == CHAR_SLASH_FORWARD
294            && chars[position + 1] == CHAR_SLASH_FORWARD
295        {
296            let (_, end) = extract_line_comment(chars, position, chars.len());
297            position = end;
298            continue;
299        }
300        if position + 1 < chars.len()
301            && chars[position] == CHAR_SLASH_FORWARD
302            && chars[position + 1] == CHAR_ASTERISK
303        {
304            let (_, end) = extract_block_comment(chars, position, chars.len());
305            position = end;
306            continue;
307        }
308        if chars[position] == CHAR_BRACE_LEFT {
309            depth += 1;
310        } else if chars[position] == CHAR_BRACE_RIGHT {
311            depth -= 1;
312            if depth == 0 {
313                let content: String = chars[start..=position].iter().collect();
314                return (content, position + 1);
315            }
316        }
317        position += 1;
318    }
319    let content: String = chars[start..].iter().collect();
320    (content, chars.len())
321}
322
323/// Extracts the content inside a brace-delimited block (without the outer braces).
324///
325/// Assumes `chars[start]` is `{`. Returns the inner content (without the
326/// outer braces) and the position after the closing `}`.
327///
328/// # Arguments
329///
330/// - `&[char]` - The source character slice.
331/// - `usize` - The position of the opening `{`.
332///
333/// # Returns
334///
335/// - `(String, usize)` - The content inside the braces and the position after the closing `}`.
336fn extract_brace_content(chars: &[char], start: usize) -> (String, usize) {
337    let mut depth: i32 = 0;
338    let mut position: usize = start;
339    let mut content_start: usize = start + 1;
340    while position < chars.len() {
341        if chars[position] == CHAR_DOUBLE_QUOTE || chars[position] == CHAR_SINGLE_QUOTE {
342            let (_, end) = extract_string_literal(chars, position, chars.len());
343            position = end;
344            continue;
345        }
346        if position + 1 < chars.len()
347            && chars[position] == CHAR_SLASH_FORWARD
348            && chars[position + 1] == CHAR_SLASH_FORWARD
349        {
350            let (_, end) = extract_line_comment(chars, position, chars.len());
351            position = end;
352            continue;
353        }
354        if position + 1 < chars.len()
355            && chars[position] == CHAR_SLASH_FORWARD
356            && chars[position + 1] == CHAR_ASTERISK
357        {
358            let (_, end) = extract_block_comment(chars, position, chars.len());
359            position = end;
360            continue;
361        }
362        if chars[position] == CHAR_BRACE_LEFT {
363            if depth == 0 {
364                content_start = position + 1;
365            }
366            depth += 1;
367        } else if chars[position] == CHAR_BRACE_RIGHT {
368            depth -= 1;
369            if depth == 0 {
370                let content: String = chars[content_start..position].iter().collect();
371                return (content, position + 1);
372            }
373        }
374        position += 1;
375    }
376    let content: String = chars[content_start..].iter().collect();
377    (content, chars.len())
378}
379
380/// Extracts a string literal (single-quoted or double-quoted) from the source.
381///
382/// Handles escape sequences (`\"`, `\'`, `\\`).
383///
384/// # Arguments
385///
386/// - `&[char]` - The source character slice.
387/// - `usize` - The start position (at the opening quote).
388/// - `usize` - The total length.
389///
390/// # Returns
391///
392/// - `(String, usize)` - The literal text and the position after the closing quote.
393fn extract_string_literal(chars: &[char], start: usize, len: usize) -> (String, usize) {
394    let quote: char = chars[start];
395    let mut position: usize = start + 1;
396    let mut result: String = String::new();
397    result.push(quote);
398    while position < len {
399        if chars[position] == CHAR_SLASH_BACK && position + 1 < len {
400            result.push(chars[position]);
401            result.push(chars[position + 1]);
402            position += 2;
403            continue;
404        }
405        result.push(chars[position]);
406        if chars[position] == quote {
407            return (result, position + 1);
408        }
409        position += 1;
410    }
411    (result, position)
412}
413
414/// Extracts a line comment (`// ...`) from the source.
415///
416/// # Arguments
417///
418/// - `&[char]` - The source character slice.
419/// - `usize` - The start position (at the first `/`).
420/// - `usize` - The total length.
421///
422/// # Returns
423///
424/// - `(String, usize)` - The comment text and the position after the newline.
425fn extract_line_comment(chars: &[char], start: usize, len: usize) -> (String, usize) {
426    let mut position: usize = start;
427    let mut result: String = String::new();
428    while position < len && chars[position] != CHAR_NEWLINE {
429        result.push(chars[position]);
430        position += 1;
431    }
432    if position < len {
433        result.push(CHAR_NEWLINE);
434        position += 1;
435    }
436    (result, position)
437}
438
439/// Extracts a block comment (`/* ... */`) from the source.
440///
441/// # Arguments
442///
443/// - `&[char]` - The source character slice.
444/// - `usize` - The start position (at the first `/`).
445/// - `usize` - The total length.
446///
447/// # Returns
448///
449/// - `(String, usize)` - The comment text and the position after the closing `*/`.
450fn extract_block_comment(chars: &[char], start: usize, len: usize) -> (String, usize) {
451    let mut position: usize = start + 2;
452    let mut result: String = String::from(BLOCK_COMMENT_START);
453    while position + 1 < len {
454        result.push(chars[position]);
455        if chars[position] == CHAR_ASTERISK && chars[position + 1] == CHAR_SLASH_FORWARD {
456            result.push(CHAR_SLASH_FORWARD);
457            return (result, position + 2);
458        }
459        position += 1;
460    }
461    while position < len {
462        result.push(chars[position]);
463        position += 1;
464    }
465    (result, position)
466}
467
468/// Formats the body of a euv macro invocation.
469///
470/// First applies raw formatting rules, then adds proper indentation.
471///
472/// # Arguments
473///
474/// - `B: AsRef<str>` - The raw macro body text (without outer braces).
475///
476/// # Returns
477///
478/// - `String` - The formatted macro body text.
479pub fn format_macro_body<B>(body: B) -> String
480where
481    B: AsRef<str>,
482{
483    let raw: String = format_macro_body_raw(body);
484    add_indentation(&raw)
485}
486
487/// Formats the body of a euv macro invocation without adding indentation.
488///
489/// Uses a single-pass scanner that tracks context to apply formatting rules:
490///
491/// - Tag name and `{` separated by exactly one space
492/// - Attribute name immediately followed by `:`, then one space before the value
493/// - `if { expr }` / `match { expr }` / `for pattern in { expr }` with proper spacing
494/// - `=>` in watch macros with proper spacing
495///
496/// Content inside expression braces `{ expr }` is preserved verbatim.
497/// Only template-level whitespace is normalized.
498///
499/// # Arguments
500///
501/// - `B: AsRef<str>` - The raw macro body text (without outer braces).
502///
503/// # Returns
504///
505/// - `String` - The formatted macro body text.
506fn format_macro_body_raw<B>(body: B) -> String
507where
508    B: AsRef<str>,
509{
510    let body: &str = body.as_ref();
511    let chars: Vec<char> = body.chars().collect();
512    let len: usize = chars.len();
513    let mut result: String = String::new();
514    let mut position: usize = 0;
515    while position < len {
516        if chars[position] == CHAR_DOUBLE_QUOTE || chars[position] == CHAR_SINGLE_QUOTE {
517            let (literal, end) = extract_string_literal(&chars, position, len);
518            result.push_str(&literal);
519            position = end;
520            continue;
521        }
522        if position + 1 < len
523            && chars[position] == CHAR_SLASH_FORWARD
524            && chars[position + 1] == CHAR_SLASH_FORWARD
525        {
526            let (comment, end) = extract_line_comment(&chars, position, len);
527            result.push_str(&comment);
528            position = end;
529            continue;
530        }
531        if position + 1 < len
532            && chars[position] == CHAR_SLASH_FORWARD
533            && chars[position + 1] == CHAR_ASTERISK
534        {
535            let (comment, end) = extract_block_comment(&chars, position, len);
536            result.push_str(&comment);
537            position = end;
538            continue;
539        }
540        if is_if_keyword(&chars, position, len) {
541            let after_if_colon: usize = skip_spaces_on_same_line(&chars, position + 2, len);
542            if after_if_colon < len
543                && chars[after_if_colon] == CHAR_COLON
544                && (after_if_colon + 1 >= len || chars[after_if_colon + 1] != CHAR_COLON)
545            {
546                result.push_str(KEYWORD_IF);
547                position += 2;
548                if position < len && chars[position] == CHAR_SPACE {
549                    result.push(CHAR_SPACE);
550                    position += 1;
551                }
552                continue;
553            }
554            result.push_str(KEYWORD_IF);
555            position += 2;
556            let after_if: usize = skip_spaces_on_same_line(&chars, position, len);
557            if position < len && chars[after_if] == CHAR_BRACE_LEFT {
558                result.push(CHAR_SPACE);
559                let (block, end) = extract_brace_block(&chars, after_if);
560                result.push_str(&format_brace_block(&block));
561                position = end;
562                position = skip_spaces_on_same_line(&chars, position, len);
563                if position < len && chars[position] == CHAR_BRACE_LEFT {
564                    result.push(CHAR_SPACE);
565                }
566            } else {
567                result.push(CHAR_SPACE);
568                position = after_if;
569            }
570            continue;
571        }
572        if is_else_keyword(&chars, position, len) {
573            if !result.ends_with(CHAR_SPACE)
574                && !result.ends_with(CHAR_NEWLINE)
575                && !result.ends_with(CHAR_TAB)
576            {
577                result.push(CHAR_SPACE);
578            }
579            result.push_str(KEYWORD_ELSE);
580            position += 4;
581            position = skip_spaces_on_same_line(&chars, position, len);
582            if is_if_keyword(&chars, position, len) {
583                result.push(CHAR_SPACE);
584                continue;
585            }
586            if position < len && chars[position] == CHAR_BRACE_LEFT {
587                result.push(CHAR_SPACE);
588            }
589            continue;
590        }
591        if is_match_keyword(&chars, position, len) {
592            let after_match_colon: usize = skip_spaces_on_same_line(&chars, position + 5, len);
593            if after_match_colon < len
594                && chars[after_match_colon] == CHAR_COLON
595                && (after_match_colon + 1 >= len || chars[after_match_colon + 1] != CHAR_COLON)
596            {
597                result.push_str(KEYWORD_MATCH);
598                position += 5;
599                if position < len && chars[position] == CHAR_SPACE {
600                    result.push(CHAR_SPACE);
601                    position += 1;
602                }
603                continue;
604            }
605            result.push_str(KEYWORD_MATCH);
606            position += 5;
607            let after_match: usize = skip_spaces_on_same_line(&chars, position, len);
608            if after_match < len && chars[after_match] == CHAR_BRACE_LEFT {
609                result.push(CHAR_SPACE);
610                let (block, end) = extract_brace_block(&chars, after_match);
611                result.push_str(&format_brace_block(&block));
612                position = end;
613                position = skip_spaces_on_same_line(&chars, position, len);
614                if position < len && chars[position] == CHAR_BRACE_LEFT {
615                    result.push(CHAR_SPACE);
616                }
617            } else {
618                result.push(CHAR_SPACE);
619                position = after_match;
620            }
621            continue;
622        }
623        if is_for_keyword(&chars, position, len) {
624            let after_for: usize = skip_spaces_on_same_line(&chars, position + 3, len);
625            if after_for < len
626                && chars[after_for] == CHAR_COLON
627                && (after_for + 1 >= len || chars[after_for + 1] != CHAR_COLON)
628            {
629                result.push_str(KEYWORD_FOR);
630                position += 3;
631                if position < len && chars[position] == CHAR_SPACE {
632                    result.push(CHAR_SPACE);
633                    position += 1;
634                }
635                continue;
636            }
637            result.push_str(KEYWORD_FOR);
638            position += 3;
639            position = skip_spaces_on_same_line(&chars, position, len);
640            if position < len && !is_in_keyword(&chars, position, len) {
641                result.push(CHAR_SPACE);
642            }
643            while position < len && !is_in_keyword(&chars, position, len) {
644                if chars[position] == CHAR_BRACE_LEFT {
645                    let (block, end) = extract_brace_block(&chars, position);
646                    result.push_str(&format_brace_block(&block));
647                    position = end;
648                    continue;
649                }
650                if chars[position] == CHAR_DOUBLE_QUOTE || chars[position] == CHAR_SINGLE_QUOTE {
651                    let (literal, end) = extract_string_literal(&chars, position, len);
652                    result.push_str(&literal);
653                    position = end;
654                    continue;
655                }
656                result.push(chars[position]);
657                position += 1;
658            }
659            if result.ends_with(CHAR_SPACE) {
660                result.truncate(result.len() - 1);
661            }
662            position = skip_spaces_on_same_line(&chars, position, len);
663            if is_in_keyword(&chars, position, len) {
664                result.push(CHAR_SPACE);
665                result.push_str(KEYWORD_IN);
666                position += 2;
667                position = skip_spaces_on_same_line(&chars, position, len);
668                if position < len && chars[position] == CHAR_BRACE_LEFT {
669                    result.push(CHAR_SPACE);
670                    let (block, end) = extract_brace_block(&chars, position);
671                    result.push_str(&format_brace_block(&block));
672                    position = end;
673                    position = skip_spaces_on_same_line(&chars, position, len);
674                    if position < len && chars[position] == CHAR_BRACE_LEFT {
675                        result.push(CHAR_SPACE);
676                    }
677                } else {
678                    result.push(CHAR_SPACE);
679                    while position < len && chars[position] != CHAR_BRACE_LEFT {
680                        result.push(chars[position]);
681                        position += 1;
682                    }
683                    if result.ends_with(CHAR_SPACE) {
684                        result.truncate(result.len() - 1);
685                    }
686                    if position < len && chars[position] == CHAR_BRACE_LEFT {
687                        result.push(CHAR_SPACE);
688                    }
689                }
690            }
691            continue;
692        }
693        if chars[position] == CHAR_COLON && position + 1 < len && chars[position + 1] != CHAR_COLON
694        {
695            if result.ends_with(CHAR_COLON) {
696                result.push(CHAR_COLON);
697                position += 1;
698                continue;
699            }
700            let colon_prefix: String = find_ident_before(&result);
701            if is_raw_ident_before(&result, &colon_prefix) {
702                result.push(CHAR_COLON);
703                position += 1;
704                continue;
705            }
706            if !colon_prefix.is_empty() {
707                let before_colon: String = remove_trailing_spaces(&result, colon_prefix.len());
708                result = before_colon;
709                result.push_str(&colon_prefix);
710            }
711            result.push(CHAR_COLON);
712            position += 1;
713            while position < len && (chars[position] == CHAR_SPACE || chars[position] == CHAR_TAB) {
714                position += 1;
715            }
716            if position < len
717                && chars[position] != CHAR_NEWLINE
718                && chars[position] != CHAR_CARRIAGE_RETURN
719                && !is_pseudo_selector_after_colon(&chars, position, len)
720            {
721                result.push(CHAR_SPACE);
722            }
723            continue;
724        }
725        if position + 1 < len
726            && chars[position] == CHAR_EQUALS
727            && chars[position + 1] == CHAR_GREATER_THAN
728        {
729            let trailing: String = find_trailing_spaces(&result);
730            if !trailing.is_empty() {
731                result.truncate(result.len() - trailing.len());
732            }
733            result.push(CHAR_SPACE);
734            result.push_str(ARROW_FAT);
735            position += 2;
736            while position < len && (chars[position] == CHAR_SPACE || chars[position] == CHAR_TAB) {
737                position += 1;
738            }
739            result.push(CHAR_SPACE);
740            continue;
741        }
742        if chars[position] == CHAR_BRACE_LEFT {
743            let (inner, end) = extract_brace_content(&chars, position);
744            let trimmed_inner: &str = inner.trim();
745            if trimmed_inner.is_empty() {
746                result.push(CHAR_BRACE_LEFT);
747                result.push(CHAR_BRACE_RIGHT);
748            } else {
749                let formatted_inner: String = format_macro_body_raw(trimmed_inner);
750                result.push(CHAR_BRACE_LEFT);
751                result.push(CHAR_NEWLINE);
752                result.push_str(&formatted_inner);
753                result.push(CHAR_NEWLINE);
754                result.push(CHAR_BRACE_RIGHT);
755            }
756            position = end;
757            continue;
758        }
759        if is_ident_char(chars[position]) {
760            let start: usize = position;
761            while position < len && is_ident_char(chars[position]) {
762                position += 1;
763            }
764            let ident: String = chars[start..position].iter().collect();
765            result.push_str(&ident);
766            let ws_start: usize = position;
767            while position < len && (chars[position] == CHAR_SPACE || chars[position] == CHAR_TAB) {
768                position += 1;
769            }
770            let had_whitespace: bool = position > ws_start;
771            if position < len && chars[position] == CHAR_BRACE_LEFT {
772                result.push(CHAR_SPACE);
773            } else if had_whitespace {
774                let next_pos: usize = position;
775                if next_pos < len && is_ident_char(chars[next_pos]) {
776                    let mut ident_end: usize = next_pos;
777                    while ident_end < len && is_ident_char(chars[ident_end]) {
778                        ident_end += 1;
779                    }
780                    let after_ident: usize = skip_spaces_on_same_line(&chars, ident_end, len);
781                    if after_ident < len
782                        && chars[after_ident] == CHAR_COLON
783                        && (after_ident + 1 >= len || chars[after_ident + 1] != CHAR_COLON)
784                    {
785                        result.push(CHAR_NEWLINE);
786                        continue;
787                    }
788                }
789                let ws: String = chars[ws_start..position].iter().collect();
790                result.push_str(&ws);
791            }
792            continue;
793        }
794        result.push(chars[position]);
795        position += 1;
796    }
797    result
798}
799
800/// Adds 4-space indentation to each line based on brace depth.
801///
802/// String literals and comments are skipped so braces inside them do not affect depth.
803/// The first non-empty line receives a base indent of 4 spaces (depth 1).
804///
805/// # Arguments
806///
807/// - `&str` - The raw formatted macro body text.
808///
809/// # Returns
810///
811/// - `String` - The indented macro body text.
812fn add_indentation(body: &str) -> String {
813    let chars: Vec<char> = body.chars().collect();
814    let len: usize = chars.len();
815    let mut result: String = String::new();
816    let mut depth: i32 = 0;
817    let mut index: usize = 0;
818    while index < len {
819        if chars[index] == CHAR_DOUBLE_QUOTE || chars[index] == CHAR_SINGLE_QUOTE {
820            let quote: char = chars[index];
821            result.push(chars[index]);
822            index += 1;
823            while index < len {
824                if chars[index] == CHAR_SLASH_BACK && index + 1 < len {
825                    result.push(chars[index]);
826                    result.push(chars[index + 1]);
827                    index += 2;
828                    continue;
829                }
830                result.push(chars[index]);
831                if chars[index] == quote {
832                    index += 1;
833                    break;
834                }
835                index += 1;
836            }
837            continue;
838        }
839        if index + 1 < len
840            && chars[index] == CHAR_SLASH_FORWARD
841            && chars[index + 1] == CHAR_SLASH_FORWARD
842        {
843            while index < len && chars[index] != CHAR_NEWLINE {
844                result.push(chars[index]);
845                index += 1;
846            }
847            continue;
848        }
849        if index + 1 < len
850            && chars[index] == CHAR_SLASH_FORWARD
851            && chars[index + 1] == CHAR_ASTERISK
852        {
853            result.push(chars[index]);
854            result.push(chars[index + 1]);
855            index += 2;
856            while index + 1 < len {
857                if chars[index] == CHAR_ASTERISK && chars[index + 1] == CHAR_SLASH_FORWARD {
858                    result.push(chars[index]);
859                    result.push(chars[index + 1]);
860                    index += 2;
861                    break;
862                }
863                result.push(chars[index]);
864                index += 1;
865            }
866            continue;
867        }
868        if chars[index] == CHAR_NEWLINE {
869            result.push(CHAR_NEWLINE);
870            index += 1;
871            while index < len && (chars[index] == CHAR_SPACE || chars[index] == CHAR_TAB) {
872                index += 1;
873            }
874            if index >= len || chars[index] == CHAR_NEWLINE || chars[index] == CHAR_CARRIAGE_RETURN
875            {
876                continue;
877            }
878            let mut closing_count: i32 = 0;
879            let mut peek: usize = index;
880            while peek < len && chars[peek] == CHAR_BRACE_RIGHT {
881                closing_count += 1;
882                peek += 1;
883            }
884            let indent_depth: i32 = (depth - closing_count).max(0);
885            for _ in 0..indent_depth * 4 {
886                result.push(CHAR_SPACE);
887            }
888            continue;
889        }
890        if chars[index] == CHAR_BRACE_LEFT {
891            depth += 1;
892            result.push(CHAR_BRACE_LEFT);
893            index += 1;
894            continue;
895        }
896        if chars[index] == CHAR_BRACE_RIGHT {
897            depth -= 1;
898            result.push(CHAR_BRACE_RIGHT);
899            index += 1;
900            let mut peek: usize = index;
901            while peek < len && (chars[peek] == CHAR_SPACE || chars[peek] == CHAR_TAB) {
902                peek += 1;
903            }
904            if peek < len
905                && chars[peek] != CHAR_BRACE_RIGHT
906                && chars[peek] != CHAR_BRACE_LEFT
907                && chars[peek] != CHAR_NEWLINE
908                && chars[peek] != CHAR_CARRIAGE_RETURN
909                && chars[peek] != CHAR_RIGHT_PAREN
910                && chars[peek] != CHAR_COMMA
911                && chars[peek] != CHAR_SEMICOLON
912            {
913                let next_chars: String = chars[peek..(peek + 4).min(len)].iter().collect();
914                if next_chars != "else" {
915                    result.push(CHAR_NEWLINE);
916                    let indent_depth: i32 = depth.max(0);
917                    for _ in 0..indent_depth * 4 {
918                        result.push(CHAR_SPACE);
919                    }
920                    index = peek;
921                }
922            }
923            continue;
924        }
925        result.push(chars[index]);
926        index += 1;
927    }
928
929    result
930}
931
932/// Checks if the current position is the start of the `if` keyword.
933///
934/// # Arguments
935///
936/// - `&[char]` - The source character slice.
937/// - `usize` - The current position.
938/// - `usize` - The total length.
939///
940/// # Returns
941///
942/// - `bool` - Whether `if` starts at the given position.
943fn is_if_keyword(chars: &[char], pos: usize, len: usize) -> bool {
944    if pos + 2 > len {
945        return false;
946    }
947    chars[pos] == CHAR_LETTER_I
948        && chars[pos + 1] == CHAR_LETTER_F
949        && (pos + 2 >= len || !is_ident_char(chars[pos + 2]))
950        && (pos == 0 || !is_ident_char(chars[pos - 1]))
951        && !is_raw_prefix(chars, pos)
952}
953
954/// Checks if the current position is the start of the `else` keyword.
955///
956/// # Arguments
957///
958/// - `&[char]` - The source character slice.
959/// - `usize` - The current position.
960/// - `usize` - The total length.
961///
962/// # Returns
963///
964/// - `bool` - Whether `else` starts at the given position.
965fn is_else_keyword(chars: &[char], pos: usize, len: usize) -> bool {
966    if pos + 4 > len {
967        return false;
968    }
969    chars[pos] == CHAR_LETTER_E
970        && chars[pos + 1] == CHAR_LETTER_L
971        && chars[pos + 2] == CHAR_LETTER_S
972        && chars[pos + 3] == CHAR_LETTER_E
973        && (pos + 4 >= len || !is_ident_char(chars[pos + 4]))
974        && (pos == 0 || !is_ident_char(chars[pos - 1]))
975        && !is_raw_prefix(chars, pos)
976}
977
978/// Checks if the current position is the start of the `match` keyword.
979///
980/// # Arguments
981///
982/// - `&[char]` - The source character slice.
983/// - `usize` - The current position.
984/// - `usize` - The total length.
985///
986/// # Returns
987///
988/// - `bool` - Whether `match` starts at the given position.
989fn is_match_keyword(chars: &[char], pos: usize, len: usize) -> bool {
990    if pos + 5 > len {
991        return false;
992    }
993    chars[pos] == CHAR_LETTER_M
994        && chars[pos + 1] == CHAR_LETTER_A
995        && chars[pos + 2] == CHAR_LETTER_T
996        && chars[pos + 3] == CHAR_LETTER_C
997        && chars[pos + 4] == CHAR_LETTER_H
998        && (pos + 5 >= len || !is_ident_char(chars[pos + 5]))
999        && (pos == 0 || !is_ident_char(chars[pos - 1]))
1000        && !is_raw_prefix(chars, pos)
1001}
1002
1003/// Checks if the current position is the start of the `for` keyword.
1004///
1005/// # Arguments
1006///
1007/// - `&[char]` - The source character slice.
1008/// - `usize` - The current position.
1009/// - `usize` - The total length.
1010///
1011/// # Returns
1012///
1013/// - `bool` - Whether `for` starts at the given position.
1014fn is_for_keyword(chars: &[char], pos: usize, len: usize) -> bool {
1015    if pos + 3 > len {
1016        return false;
1017    }
1018    chars[pos] == CHAR_LETTER_F
1019        && chars[pos + 1] == CHAR_LETTER_O
1020        && chars[pos + 2] == CHAR_LETTER_R
1021        && (pos + 3 >= len || !is_ident_char(chars[pos + 3]))
1022        && (pos == 0 || !is_ident_char(chars[pos - 1]))
1023        && !is_raw_prefix(chars, pos)
1024}
1025
1026/// Checks if the current position is the start of the `in` keyword.
1027///
1028/// # Arguments
1029///
1030/// - `&[char]` - The source character slice.
1031/// - `usize` - The current position.
1032/// - `usize` - The total length.
1033///
1034/// # Returns
1035///
1036/// - `bool` - Whether `in` starts at the given position.
1037fn is_in_keyword(chars: &[char], pos: usize, len: usize) -> bool {
1038    if pos + 2 > len {
1039        return false;
1040    }
1041    chars[pos] == CHAR_LETTER_I
1042        && chars[pos + 1] == CHAR_LETTER_N
1043        && (pos + 2 >= len || !is_ident_char(chars[pos + 2]))
1044        && (pos == 0 || !is_ident_char(chars[pos - 1]))
1045        && !is_raw_prefix(chars, pos)
1046}
1047
1048/// Formats a brace block by adding spaces inside single-line braces.
1049///
1050/// For blocks that do not contain newlines, trims inner content and adds
1051/// single spaces around it: `{ content }`. Empty blocks remain `{}`.
1052/// For blocks containing newlines, returns the block unchanged.
1053///
1054/// # Arguments
1055///
1056/// - `&str` - The brace block text (including outer `{` and `}`).
1057///
1058/// # Returns
1059///
1060/// - `String` - The formatted brace block text.
1061fn format_brace_block(block: &str) -> String {
1062    let block_chars: Vec<char> = block.chars().collect();
1063    if block_chars.len() < 2
1064        || block_chars[0] != CHAR_BRACE_LEFT
1065        || *block_chars.last().unwrap() != CHAR_BRACE_RIGHT
1066    {
1067        return block.to_string();
1068    }
1069    let inner: String = block_chars[1..block_chars.len() - 1].iter().collect();
1070    if inner.contains(CHAR_NEWLINE) {
1071        return block.to_string();
1072    }
1073    let trimmed_inner: &str = inner.trim();
1074    if trimmed_inner.is_empty() {
1075        let mut empty_result: String = String::new();
1076        empty_result.push(CHAR_BRACE_LEFT);
1077        empty_result.push(CHAR_BRACE_RIGHT);
1078        return empty_result;
1079    }
1080    let mut formatted: String = String::new();
1081    formatted.push(CHAR_BRACE_LEFT);
1082    formatted.push(CHAR_SPACE);
1083    formatted.push_str(trimmed_inner);
1084    formatted.push(CHAR_SPACE);
1085    formatted.push(CHAR_BRACE_RIGHT);
1086    formatted
1087}
1088
1089/// Skips spaces (but not newlines) on the same line.
1090///
1091/// # Arguments
1092///
1093/// - `&[char]` - The source character slice.
1094/// - `usize` - The current position.
1095/// - `usize` - The total length.
1096///
1097/// # Returns
1098///
1099/// - `usize` - The position after skipping same-line spaces.
1100fn skip_spaces_on_same_line(chars: &[char], mut pos: usize, len: usize) -> usize {
1101    while pos < len && (chars[pos] == CHAR_SPACE || chars[pos] == CHAR_TAB) {
1102        pos += 1;
1103    }
1104    pos
1105}
1106
1107/// Checks whether the content after a colon is a CSS pseudo-class or pseudo-element selector.
1108///
1109/// A pseudo-class/pseudo-element selector is identified by looking ahead:
1110/// after the colon, if an identifier is found, and that identifier
1111/// (possibly followed by more hyphen-separated identifiers like `focus-visible`)
1112/// is immediately followed by `{` (not `!` which indicates a macro call like `format!`),
1113/// then this is a selector colon and should not have a space after it.
1114///
1115/// Also handles functional pseudo-classes like `:nth-child(2n+1)`, `:not(.class)`,
1116/// where the identifier is followed by a parenthesized argument list before `{`.
1117///
1118/// Returns `false` if the first identifier after the colon is a Rust keyword
1119/// (e.g., `if`, `match`, `for`) since those are attribute value expressions,
1120/// not CSS selectors.
1121///
1122/// # Arguments
1123///
1124/// - `&[char]` - The source character slice.
1125/// - `usize` - The current position (right after the colon, spaces already skipped).
1126/// - `usize` - The total length of the source.
1127///
1128/// # Returns
1129///
1130/// - `bool` - Whether the content after the colon is a CSS selector.
1131fn is_pseudo_selector_after_colon(chars: &[char], mut pos: usize, len: usize) -> bool {
1132    if pos >= len || !is_ident_char(chars[pos]) {
1133        return false;
1134    }
1135    let ident_start: usize = pos;
1136    while pos < len && is_ident_char(chars[pos]) {
1137        pos += 1;
1138    }
1139    let first_ident: String = chars[ident_start..pos].iter().collect();
1140    if is_rust_keyword(&first_ident) {
1141        return false;
1142    }
1143    while pos < len && chars[pos] == CHAR_HYPHEN && pos + 1 < len && is_ident_char(chars[pos + 1]) {
1144        pos += 1;
1145        while pos < len && is_ident_char(chars[pos]) {
1146            pos += 1;
1147        }
1148    }
1149    let after_ident: usize = skip_spaces_on_same_line(chars, pos, len);
1150    if after_ident < len && chars[after_ident] == CHAR_BRACE_LEFT {
1151        return true;
1152    }
1153    if after_ident < len && chars[after_ident] == CHAR_LEFT_PAREN {
1154        let mut depth: i32 = 0;
1155        let mut paren_pos: usize = after_ident;
1156        while paren_pos < len {
1157            if chars[paren_pos] == CHAR_LEFT_PAREN {
1158                depth += 1;
1159            } else if chars[paren_pos] == CHAR_RIGHT_PAREN {
1160                depth -= 1;
1161                if depth == 0 {
1162                    let after_paren: usize = skip_spaces_on_same_line(chars, paren_pos + 1, len);
1163                    return after_paren < len && chars[after_paren] == CHAR_BRACE_LEFT;
1164                }
1165            }
1166            paren_pos += 1;
1167        }
1168    }
1169    false
1170}
1171
1172/// Checks whether a string is a Rust keyword that can appear after a colon
1173/// in euv macro attribute syntax (e.g., `class: if { ... }`).
1174///
1175/// These keywords indicate attribute value expressions, not CSS selectors.
1176///
1177/// # Arguments
1178///
1179/// - `&str` - The identifier string to check.
1180///
1181/// # Returns
1182///
1183/// - `bool` - Whether the string is a Rust keyword.
1184fn is_rust_keyword(ident: &str) -> bool {
1185    matches!(ident, KEYWORD_IF | KEYWORD_MATCH | KEYWORD_FOR)
1186}
1187
1188/// Checks whether the identifier found before the colon is a Rust raw identifier (r#prefix).
1189///
1190/// Raw identifiers use the `r#` prefix (e.g., `r#for`), and their colons
1191/// should not be formatted as attribute separators.
1192///
1193/// # Arguments
1194///
1195/// - `&str` - The result string built so far.
1196/// - `&str` - The identifier found before the colon.
1197///
1198/// # Returns
1199///
1200/// - `bool` - Whether the identifier is preceded by `r#`.
1201fn is_raw_ident_before(result: &str, ident: &str) -> bool {
1202    result
1203        .trim_end()
1204        .ends_with(&format!("{RAW_IDENT_PREFIX}{ident}"))
1205}
1206
1207/// Finds the identifier immediately before the current position in the result string.
1208///
1209/// # Arguments
1210///
1211/// - `&str` - The result string built so far.
1212///
1213/// # Returns
1214///
1215/// - `String` - The identifier found before the current position, or empty if none.
1216fn find_ident_before(result: &str) -> String {
1217    let chars: Vec<char> = result.chars().collect();
1218    let mut end: usize = chars.len();
1219    while end > 0 && chars[end - 1] == CHAR_SPACE {
1220        end -= 1;
1221    }
1222    let mut start: usize = end;
1223    while start > 0 && is_ident_char(chars[start - 1]) {
1224        start -= 1;
1225    }
1226    if start < end {
1227        chars[start..end].iter().collect()
1228    } else {
1229        String::new()
1230    }
1231}
1232
1233/// Removes trailing spaces and the identified prefix from the result string.
1234///
1235/// # Arguments
1236///
1237/// - `&str` - The result string built so far.
1238/// - `usize` - The length of the prefix to remove.
1239///
1240/// # Returns
1241///
1242/// - `String` - The string with trailing spaces and prefix removed.
1243fn remove_trailing_spaces(result: &str, prefix_len: usize) -> String {
1244    let chars: Vec<char> = result.chars().collect();
1245    let total_len: usize = chars.len();
1246    let mut end: usize = total_len;
1247    while end > 0 && chars[end - 1] == CHAR_SPACE {
1248        end -= 1;
1249    }
1250    if prefix_len > end {
1251        return result.to_string();
1252    }
1253    let new_end: usize = end - prefix_len;
1254    chars[..new_end].iter().collect()
1255}
1256
1257/// Finds trailing spaces in the result string.
1258///
1259/// # Arguments
1260///
1261/// - `&str` - The result string.
1262///
1263/// # Returns
1264///
1265/// - `String` - The trailing spaces.
1266fn find_trailing_spaces(result: &str) -> String {
1267    let mut spaces: String = String::new();
1268    for ch in result.chars().rev() {
1269        if ch == CHAR_SPACE || ch == CHAR_TAB {
1270            spaces.push(ch);
1271        } else {
1272            break;
1273        }
1274    }
1275    spaces.chars().rev().collect()
1276}
1277
1278/// Formats all Rust source files in the given directory that contain euv macros.
1279///
1280/// Recursively walks the directory tree, finds `.rs` files, and formats
1281/// any euv macro invocations found within them.
1282///
1283/// # Arguments
1284///
1285/// - `&Path` - The root directory to search.
1286/// - `FmtMode` - Whether to check or write formatting.
1287///
1288/// # Returns
1289///
1290/// - `Result<(), EuvError>` - Indicates success or failure.
1291pub async fn format_dir(path: &Path, mode: FmtMode) -> Result<(), EuvError> {
1292    if path.is_file() {
1293        let changed: bool = format_file(path, &mode).await?;
1294        match mode {
1295            FmtMode::Check => {
1296                if changed {
1297                    return Err(EuvError::Message(format!(
1298                        "{} needs formatting.",
1299                        path.display()
1300                    )));
1301                }
1302                log::info!("{} is properly formatted.", path.display());
1303            }
1304            FmtMode::Write => {
1305                if changed {
1306                    log::info!("Formatted: {}", path.display());
1307                } else {
1308                    log::info!("Already formatted: {}", path.display());
1309                }
1310            }
1311        }
1312        return Ok(());
1313    }
1314    let mut entries: Vec<PathBuf> = collect_rs_files(path).await?;
1315    entries.sort();
1316    let mut changed_count: usize = 0;
1317    let mut unchanged_count: usize = 0;
1318    for entry in entries {
1319        match format_file(&entry, &mode).await {
1320            Ok(changed) => {
1321                if changed {
1322                    changed_count += 1;
1323                } else {
1324                    unchanged_count += 1;
1325                }
1326            }
1327            Err(error) => {
1328                log::warn!("Failed to format {}: {error}", entry.display());
1329            }
1330        }
1331    }
1332    match mode {
1333        FmtMode::Check => {
1334            if changed_count > 0 {
1335                return Err(EuvError::Message(format!(
1336                    "{} file(s) need formatting. Run `euv fmt` to fix.",
1337                    changed_count
1338                )));
1339            }
1340            log::info!("All {} file(s) are properly formatted.", unchanged_count);
1341        }
1342        FmtMode::Write => {
1343            log::info!(
1344                "Formatted {} file(s), {} unchanged.",
1345                changed_count,
1346                unchanged_count
1347            );
1348        }
1349    }
1350    Ok(())
1351}
1352
1353/// Collects all `.rs` files in a directory recursively.
1354///
1355/// # Arguments
1356///
1357/// - `&Path` - The directory to search.
1358///
1359/// # Returns
1360///
1361/// - `Result<Vec<PathBuf>, EuvError>` - The list of `.rs` file paths.
1362async fn collect_rs_files(path: &Path) -> Result<Vec<PathBuf>, EuvError> {
1363    let mut result: Vec<PathBuf> = Vec::new();
1364    let mut stack: Vec<PathBuf> = vec![path.to_path_buf()];
1365    while let Some(dir) = stack.pop() {
1366        let mut entries: ReadDir =
1367            read_dir(&dir)
1368                .await
1369                .map_err(|error: io::Error| EuvError::IoPath {
1370                    message: String::from("Failed to read directory"),
1371                    path: dir.clone(),
1372                    error,
1373                })?;
1374        while let Some(entry) =
1375            entries
1376                .next_entry()
1377                .await
1378                .map_err(|error: io::Error| EuvError::IoPath {
1379                    message: String::from("Failed to read entry in directory"),
1380                    path: dir.clone(),
1381                    error,
1382                })?
1383        {
1384            let entry_path: PathBuf = entry.path();
1385            if entry_path.is_dir() {
1386                let file_name: String = entry_path
1387                    .file_name()
1388                    .unwrap_or_default()
1389                    .to_string_lossy()
1390                    .to_string();
1391                if file_name != TARGET_DIR_NAME && file_name != NODE_MODULES_DIR_NAME {
1392                    stack.push(entry_path);
1393                }
1394            } else if entry_path
1395                .extension()
1396                .is_some_and(|ext: &std::ffi::OsStr| ext == RS_EXTENSION)
1397            {
1398                result.push(entry_path);
1399            }
1400        }
1401    }
1402    Ok(result)
1403}
1404
1405/// Formats a single Rust source file.
1406///
1407/// # Arguments
1408///
1409/// - `&Path` - The file path.
1410/// - `&FmtMode` - Whether to check or write formatting.
1411///
1412/// # Returns
1413///
1414/// - `Result<bool, EuvError>` - Whether the file was changed.
1415async fn format_file(path: &Path, mode: &FmtMode) -> Result<bool, EuvError> {
1416    let content: String =
1417        read_to_string(path)
1418            .await
1419            .map_err(|error: io::Error| EuvError::IoPath {
1420                message: String::from("Failed to read"),
1421                path: path.to_path_buf(),
1422                error,
1423            })?;
1424    let fmt_result: FmtResult = format_source(&content);
1425    if fmt_result.get_changed() {
1426        match mode {
1427            FmtMode::Write => {
1428                write(path, fmt_result.get_output())
1429                    .await
1430                    .map_err(|error: io::Error| EuvError::IoPath {
1431                        message: String::from("Failed to write"),
1432                        path: path.to_path_buf(),
1433                        error,
1434                    })?;
1435                log::info!("Formatted: {}", path.display());
1436            }
1437            FmtMode::Check => {
1438                log::warn!("Needs formatting: {}", path.display());
1439            }
1440        }
1441    }
1442    Ok(fmt_result.get_changed())
1443}