Skip to main content

euv_cli/fmt/
fn.rs

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