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