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` - 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` - 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` - 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 != KEYWORD_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.
940///
941/// # Arguments
942///
943/// - `&str` - The macro body text to indent.
944/// - `&str` - The indentation string to prepend to each line.
945///
946/// # Returns
947///
948/// - `String` - The indented macro body text.
949fn indented_body_skipping_block_comments(body: &str, indent_str: &str) -> String {
950    let chars: Vec<char> = body.chars().collect();
951    let len: usize = chars.len();
952    let mut in_block_comment: bool = false;
953    let mut line_starts_in_comment: bool = false;
954    let mut result_lines: Vec<String> = Vec::new();
955    let mut current_line: String = String::new();
956    let mut index: usize = 0;
957    while index < len {
958        if !in_block_comment
959            && index + 1 < len
960            && chars[index] == CHAR_SLASH_FORWARD
961            && chars[index + 1] == CHAR_ASTERISK
962        {
963            in_block_comment = true;
964            line_starts_in_comment = current_line.is_empty()
965                || current_line
966                    .chars()
967                    .all(|c: char| c == CHAR_SPACE || c == CHAR_TAB);
968            current_line.push(chars[index]);
969            current_line.push(chars[index + 1]);
970            index += 2;
971            continue;
972        }
973        if in_block_comment {
974            current_line.push(chars[index]);
975            if chars[index] == CHAR_ASTERISK
976                && index + 1 < len
977                && chars[index + 1] == CHAR_SLASH_FORWARD
978            {
979                current_line.push(chars[index + 1]);
980                index += 2;
981                in_block_comment = false;
982                continue;
983            }
984            index += 1;
985            continue;
986        }
987        if chars[index] == CHAR_NEWLINE {
988            current_line.push(CHAR_NEWLINE);
989            let line_owned: String = std::mem::take(&mut current_line);
990            let line_ref: &str = line_owned.as_str();
991            let indented: String = if line_starts_in_comment || line_ref.trim().is_empty() {
992                line_owned
993            } else {
994                let mut s: String = String::with_capacity(indent_str.len() + line_owned.len());
995                s.push_str(indent_str);
996                s.push_str(line_owned.as_str());
997                s
998            };
999            result_lines.push(indented);
1000            // Only reset for lines that are NOT continuations of an open
1001            // `/* ... */` region. If the newline fell inside an open block
1002            // comment, the next line is also part of the comment and must
1003            // not get `indent_str` prepended (that is exactly the
1004            // compounding bug we are fixing).
1005            line_starts_in_comment = in_block_comment;
1006            index += 1;
1007            continue;
1008        }
1009        current_line.push(chars[index]);
1010        index += 1;
1011    }
1012    let line_owned: String = current_line;
1013    let indented: String = if line_starts_in_comment || line_owned.trim().is_empty() {
1014        line_owned
1015    } else {
1016        let mut s: String = String::with_capacity(indent_str.len() + line_owned.len());
1017        s.push_str(indent_str);
1018        s.push_str(line_owned.as_str());
1019        s
1020    };
1021    result_lines.push(indented);
1022    result_lines.join("")
1023}
1024
1025/// Checks if the current position is the start of the `if` keyword.
1026///
1027/// # Arguments
1028///
1029/// - `&[char]` - The source character slice.
1030/// - `usize` - The current position.
1031/// - `usize` - The total length.
1032///
1033/// # Returns
1034///
1035/// - `bool` - Whether `if` starts at the given position.
1036fn is_if_keyword(chars: &[char], pos: usize, len: usize) -> bool {
1037    if pos + 2 > len {
1038        return false;
1039    }
1040    chars[pos] == CHAR_LETTER_I
1041        && chars[pos + 1] == CHAR_LETTER_F
1042        && (pos + 2 >= len || !is_ident_char(chars[pos + 2]))
1043        && (pos == 0 || !is_ident_char(chars[pos - 1]))
1044        && !is_raw_prefix(chars, pos)
1045}
1046
1047/// Checks if the current position is the start of the `else` keyword.
1048///
1049/// # Arguments
1050///
1051/// - `&[char]` - The source character slice.
1052/// - `usize` - The current position.
1053/// - `usize` - The total length.
1054///
1055/// # Returns
1056///
1057/// - `bool` - Whether `else` starts at the given position.
1058fn is_else_keyword(chars: &[char], pos: usize, len: usize) -> bool {
1059    if pos + 4 > len {
1060        return false;
1061    }
1062    chars[pos] == CHAR_LETTER_E
1063        && chars[pos + 1] == CHAR_LETTER_L
1064        && chars[pos + 2] == CHAR_LETTER_S
1065        && chars[pos + 3] == CHAR_LETTER_E
1066        && (pos + 4 >= len || !is_ident_char(chars[pos + 4]))
1067        && (pos == 0 || !is_ident_char(chars[pos - 1]))
1068        && !is_raw_prefix(chars, pos)
1069}
1070
1071/// Checks if the current position is the start of the `match` keyword.
1072///
1073/// # Arguments
1074///
1075/// - `&[char]` - The source character slice.
1076/// - `usize` - The current position.
1077/// - `usize` - The total length.
1078///
1079/// # Returns
1080///
1081/// - `bool` - Whether `match` starts at the given position.
1082fn is_match_keyword(chars: &[char], pos: usize, len: usize) -> bool {
1083    if pos + 5 > len {
1084        return false;
1085    }
1086    chars[pos] == CHAR_LETTER_M
1087        && chars[pos + 1] == CHAR_LETTER_A
1088        && chars[pos + 2] == CHAR_LETTER_T
1089        && chars[pos + 3] == CHAR_LETTER_C
1090        && chars[pos + 4] == CHAR_LETTER_H
1091        && (pos + 5 >= len || !is_ident_char(chars[pos + 5]))
1092        && (pos == 0 || !is_ident_char(chars[pos - 1]))
1093        && !is_raw_prefix(chars, pos)
1094}
1095
1096/// Checks if the current position is the start of the `for` keyword.
1097///
1098/// # Arguments
1099///
1100/// - `&[char]` - The source character slice.
1101/// - `usize` - The current position.
1102/// - `usize` - The total length.
1103///
1104/// # Returns
1105///
1106/// - `bool` - Whether `for` starts at the given position.
1107fn is_for_keyword(chars: &[char], pos: usize, len: usize) -> bool {
1108    if pos + 3 > len {
1109        return false;
1110    }
1111    chars[pos] == CHAR_LETTER_F
1112        && chars[pos + 1] == CHAR_LETTER_O
1113        && chars[pos + 2] == CHAR_LETTER_R
1114        && (pos + 3 >= len || !is_ident_char(chars[pos + 3]))
1115        && (pos == 0 || !is_ident_char(chars[pos - 1]))
1116        && !is_raw_prefix(chars, pos)
1117}
1118
1119/// Checks if the current position is the start of the `in` keyword.
1120///
1121/// # Arguments
1122///
1123/// - `&[char]` - The source character slice.
1124/// - `usize` - The current position.
1125/// - `usize` - The total length.
1126///
1127/// # Returns
1128///
1129/// - `bool` - Whether `in` starts at the given position.
1130fn is_in_keyword(chars: &[char], pos: usize, len: usize) -> bool {
1131    if pos + 2 > len {
1132        return false;
1133    }
1134    chars[pos] == CHAR_LETTER_I
1135        && chars[pos + 1] == CHAR_LETTER_N
1136        && (pos + 2 >= len || !is_ident_char(chars[pos + 2]))
1137        && (pos == 0 || !is_ident_char(chars[pos - 1]))
1138        && !is_raw_prefix(chars, pos)
1139}
1140
1141/// Formats a brace block by adding spaces inside single-line braces.
1142///
1143/// For blocks that do not contain newlines, trims inner content and adds
1144/// single spaces around it: `{ content }`. Empty blocks remain `{}`.
1145/// For blocks containing newlines, returns the block unchanged.
1146///
1147/// # Arguments
1148///
1149/// - `&str` - The brace block text (including outer `{` and `}`).
1150///
1151/// # Returns
1152///
1153/// - `String` - The formatted brace block text.
1154fn format_brace_block(block: &str) -> String {
1155    let block_chars: Vec<char> = block.chars().collect();
1156    if block_chars.len() < 2
1157        || block_chars[0] != CHAR_BRACE_LEFT
1158        || block_chars.last() != Some(&CHAR_BRACE_RIGHT)
1159    {
1160        return block.to_string();
1161    }
1162    let inner: String = block_chars[1..block_chars.len() - 1].iter().collect();
1163    if inner.contains(CHAR_NEWLINE) {
1164        return block.to_string();
1165    }
1166    let trimmed_inner: &str = inner.trim();
1167    if trimmed_inner.is_empty() {
1168        let mut empty_result: String = String::new();
1169        empty_result.push(CHAR_BRACE_LEFT);
1170        empty_result.push(CHAR_BRACE_RIGHT);
1171        return empty_result;
1172    }
1173    let mut formatted: String = String::new();
1174    formatted.push(CHAR_BRACE_LEFT);
1175    formatted.push(CHAR_SPACE);
1176    formatted.push_str(trimmed_inner);
1177    formatted.push(CHAR_SPACE);
1178    formatted.push(CHAR_BRACE_RIGHT);
1179    formatted
1180}
1181
1182/// Skips spaces (but not newlines) on the same line.
1183///
1184/// # Arguments
1185///
1186/// - `&[char]` - The source character slice.
1187/// - `usize` - The current position.
1188/// - `usize` - The total length.
1189///
1190/// # Returns
1191///
1192/// - `usize` - The position after skipping same-line spaces.
1193fn skip_spaces_on_same_line(chars: &[char], mut pos: usize, len: usize) -> usize {
1194    while pos < len && (chars[pos] == CHAR_SPACE || chars[pos] == CHAR_TAB) {
1195        pos += 1;
1196    }
1197    pos
1198}
1199
1200/// Checks whether the content after a colon is a CSS pseudo-class or pseudo-element selector.
1201///
1202/// A pseudo-class/pseudo-element selector is identified by looking ahead:
1203/// after the colon, if an identifier is found, and that identifier
1204/// (possibly followed by more hyphen-separated identifiers like `focus-visible`)
1205/// is immediately followed by `{` (not `!` which indicates a macro call like `format!`),
1206/// then this is a selector colon and should not have a space after it.
1207///
1208/// Also handles functional pseudo-classes like `:nth-child(2n+1)`, `:not(.class)`,
1209/// where the identifier is followed by a parenthesized argument list before `{`.
1210///
1211/// Returns `false` if the first identifier after the colon is a Rust keyword
1212/// (e.g., `if`, `match`, `for`) since those are attribute value expressions,
1213/// not CSS selectors.
1214///
1215/// # Arguments
1216///
1217/// - `&[char]` - The source character slice.
1218/// - `usize` - The current position (right after the colon, spaces already skipped).
1219/// - `usize` - The total length of the source.
1220///
1221/// # Returns
1222///
1223/// - `bool` - Whether the content after the colon is a CSS selector.
1224fn is_pseudo_selector_after_colon(chars: &[char], mut pos: usize, len: usize) -> bool {
1225    if pos >= len || !is_ident_char(chars[pos]) {
1226        return false;
1227    }
1228    let ident_start: usize = pos;
1229    while pos < len && is_ident_char(chars[pos]) {
1230        pos += 1;
1231    }
1232    let first_ident: String = chars[ident_start..pos].iter().collect();
1233    if is_rust_keyword(&first_ident) {
1234        return false;
1235    }
1236    while pos < len && chars[pos] == CHAR_HYPHEN && pos + 1 < len && is_ident_char(chars[pos + 1]) {
1237        pos += 1;
1238        while pos < len && is_ident_char(chars[pos]) {
1239            pos += 1;
1240        }
1241    }
1242    let after_ident: usize = skip_spaces_on_same_line(chars, pos, len);
1243    if after_ident < len && chars[after_ident] == CHAR_BRACE_LEFT {
1244        return true;
1245    }
1246    if after_ident < len && chars[after_ident] == CHAR_LEFT_PAREN {
1247        let mut depth: i32 = 0;
1248        let mut paren_pos: usize = after_ident;
1249        while paren_pos < len {
1250            if chars[paren_pos] == CHAR_LEFT_PAREN {
1251                depth += 1;
1252            } else if chars[paren_pos] == CHAR_RIGHT_PAREN {
1253                depth -= 1;
1254                if depth == 0 {
1255                    let after_paren: usize = skip_spaces_on_same_line(chars, paren_pos + 1, len);
1256                    return after_paren < len && chars[after_paren] == CHAR_BRACE_LEFT;
1257                }
1258            }
1259            paren_pos += 1;
1260        }
1261    }
1262    false
1263}
1264
1265/// Checks whether a string is a Rust keyword that can appear after a colon
1266/// in euv macro attribute syntax (e.g., `class: if { ... }`).
1267///
1268/// These keywords indicate attribute value expressions, not CSS selectors.
1269///
1270/// # Arguments
1271///
1272/// - `&str` - The identifier string to check.
1273///
1274/// # Returns
1275///
1276/// - `bool` - Whether the string is a Rust keyword.
1277fn is_rust_keyword(ident: &str) -> bool {
1278    ATTRIBUTE_VALUE_KEYWORDS.contains(&ident)
1279}
1280
1281/// Checks whether the identifier found before the colon is a Rust raw identifier (r#prefix).
1282///
1283/// Raw identifiers use the `r#` prefix (e.g., `r#for`), and their colons
1284/// should not be formatted as attribute separators.
1285///
1286/// # Arguments
1287///
1288/// - `&str` - The result string built so far.
1289/// - `&str` - The identifier found before the colon.
1290///
1291/// # Returns
1292///
1293/// - `bool` - Whether the identifier is preceded by `r#`.
1294fn is_raw_ident_before(result: &str, ident: &str) -> bool {
1295    result
1296        .trim_end()
1297        .ends_with(&format!("{RAW_IDENT_PREFIX}{ident}"))
1298}
1299
1300/// Finds the identifier immediately before the current position in the result string.
1301///
1302/// # Arguments
1303///
1304/// - `&str` - The result string built so far.
1305///
1306/// # Returns
1307///
1308/// - `String` - The identifier found before the current position, or empty if none.
1309fn find_ident_before(result: &str) -> String {
1310    let chars: Vec<char> = result.chars().collect();
1311    let mut end: usize = chars.len();
1312    while end > 0 && chars[end - 1] == CHAR_SPACE {
1313        end -= 1;
1314    }
1315    let mut start: usize = end;
1316    while start > 0 && is_ident_char(chars[start - 1]) {
1317        start -= 1;
1318    }
1319    if start < end {
1320        chars[start..end].iter().collect()
1321    } else {
1322        String::new()
1323    }
1324}
1325
1326/// Removes trailing spaces and the identified prefix from the result string.
1327///
1328/// # Arguments
1329///
1330/// - `&str` - The result string built so far.
1331/// - `usize` - The length of the prefix to remove.
1332///
1333/// # Returns
1334///
1335/// - `String` - The string with trailing spaces and prefix removed.
1336fn remove_trailing_spaces(result: &str, prefix_len: usize) -> String {
1337    let chars: Vec<char> = result.chars().collect();
1338    let total_len: usize = chars.len();
1339    let mut end: usize = total_len;
1340    while end > 0 && chars[end - 1] == CHAR_SPACE {
1341        end -= 1;
1342    }
1343    if prefix_len > end {
1344        return result.to_string();
1345    }
1346    let new_end: usize = end - prefix_len;
1347    chars[..new_end].iter().collect()
1348}
1349
1350/// Finds trailing spaces in the result string.
1351///
1352/// # Arguments
1353///
1354/// - `&str` - The result string.
1355///
1356/// # Returns
1357///
1358/// - `String` - The trailing spaces.
1359fn find_trailing_spaces(result: &str) -> String {
1360    let mut spaces: String = String::new();
1361    for ch in result.chars().rev() {
1362        if ch == CHAR_SPACE || ch == CHAR_TAB {
1363            spaces.push(ch);
1364        } else {
1365            break;
1366        }
1367    }
1368    spaces.chars().rev().collect()
1369}
1370
1371/// Formats all Rust source files in the given directory that contain euv macros.
1372///
1373/// Recursively walks the directory tree, finds `.rs` files, and formats
1374/// any euv macro invocations found within them.
1375///
1376/// # Arguments
1377///
1378/// - `&Path` - The root directory to search.
1379/// - `FmtMode` - Whether to check or write formatting.
1380///
1381/// # Returns
1382///
1383/// - `Result<(), EuvError>` - Indicates success or failure.
1384pub async fn format_dir(path: &Path, mode: FmtMode) -> Result<(), EuvError> {
1385    if path.is_file() {
1386        let changed: bool = format_file(path, &mode).await?;
1387        match mode {
1388            FmtMode::Check => {
1389                if changed {
1390                    return Err(EuvError::Message(format!(
1391                        "{} needs formatting.",
1392                        path.display()
1393                    )));
1394                }
1395                log::info!("{} is properly formatted.", path.display());
1396            }
1397            FmtMode::Write => {
1398                if changed {
1399                    log::info!("Formatted: {}", path.display());
1400                } else {
1401                    log::info!("Already formatted: {}", path.display());
1402                }
1403            }
1404        }
1405        return Ok(());
1406    }
1407    let mut entries: Vec<PathBuf> = collect_rs_files(path).await?;
1408    entries.sort();
1409    let mut changed_count: usize = 0;
1410    let mut unchanged_count: usize = 0;
1411    for entry in entries {
1412        match format_file(&entry, &mode).await {
1413            Ok(changed) => {
1414                if changed {
1415                    changed_count += 1;
1416                } else {
1417                    unchanged_count += 1;
1418                }
1419            }
1420            Err(error) => {
1421                log::warn!("Failed to format {}: {error}", entry.display());
1422            }
1423        }
1424    }
1425    match mode {
1426        FmtMode::Check => {
1427            if changed_count > 0 {
1428                return Err(EuvError::Message(format!(
1429                    "{} file(s) need formatting. Run `euv fmt` to fix.",
1430                    changed_count
1431                )));
1432            }
1433            log::info!("All {} file(s) are properly formatted.", unchanged_count);
1434        }
1435        FmtMode::Write => {
1436            log::info!(
1437                "Formatted {} file(s), {} unchanged.",
1438                changed_count,
1439                unchanged_count
1440            );
1441        }
1442    }
1443    Ok(())
1444}
1445
1446/// Collects all `.rs` files in a directory recursively.
1447///
1448/// # Arguments
1449///
1450/// - `&Path` - The directory to search.
1451///
1452/// # Returns
1453///
1454/// - `Result<Vec<PathBuf>, EuvError>` - The list of `.rs` file paths.
1455async fn collect_rs_files(path: &Path) -> Result<Vec<PathBuf>, EuvError> {
1456    let mut result: Vec<PathBuf> = Vec::new();
1457    let mut stack: Vec<PathBuf> = vec![path.to_path_buf()];
1458    while let Some(dir) = stack.pop() {
1459        let mut entries: ReadDir =
1460            read_dir(&dir)
1461                .await
1462                .map_err(|error: io::Error| EuvError::IoPath {
1463                    message: ERROR_READ_DIRECTORY.to_string(),
1464                    path: dir.clone(),
1465                    error,
1466                })?;
1467        while let Some(entry) =
1468            entries
1469                .next_entry()
1470                .await
1471                .map_err(|error: io::Error| EuvError::IoPath {
1472                    message: ERROR_READ_ENTRY_IN_DIRECTORY.to_string(),
1473                    path: dir.clone(),
1474                    error,
1475                })?
1476        {
1477            let entry_path: PathBuf = entry.path();
1478            if entry_path.is_dir() {
1479                let file_name: String = entry_path
1480                    .file_name()
1481                    .unwrap_or_default()
1482                    .to_string_lossy()
1483                    .to_string();
1484                if file_name != TARGET_DIR_NAME && file_name != NODE_MODULES_DIR_NAME {
1485                    stack.push(entry_path);
1486                }
1487            } else if entry_path
1488                .extension()
1489                .is_some_and(|ext: &OsStr| ext == RS_EXTENSION)
1490            {
1491                result.push(entry_path);
1492            }
1493        }
1494    }
1495    Ok(result)
1496}
1497
1498/// Formats a single Rust source file.
1499///
1500/// # Arguments
1501///
1502/// - `&Path` - The file path.
1503/// - `&FmtMode` - Whether to check or write formatting.
1504///
1505/// # Returns
1506///
1507/// - `Result<bool, EuvError>` - Whether the file was changed.
1508async fn format_file(path: &Path, mode: &FmtMode) -> Result<bool, EuvError> {
1509    let content: String =
1510        read_to_string(path)
1511            .await
1512            .map_err(|error: io::Error| EuvError::IoPath {
1513                message: ERROR_READ_FILE.to_string(),
1514                path: path.to_path_buf(),
1515                error,
1516            })?;
1517    let fmt_result: FmtResult = format_source(&content);
1518    if fmt_result.get_changed() {
1519        match mode {
1520            FmtMode::Write => {
1521                write(path, fmt_result.get_output())
1522                    .await
1523                    .map_err(|error: io::Error| EuvError::IoPath {
1524                        message: ERROR_WRITE_FILE.to_string(),
1525                        path: path.to_path_buf(),
1526                        error,
1527                    })?;
1528                log::info!("Formatted: {}", path.display());
1529            }
1530            FmtMode::Check => {
1531                log::warn!("Needs formatting: {}", path.display());
1532            }
1533        }
1534    }
1535    Ok(fmt_result.get_changed())
1536}