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