Skip to main content

euv_cli/fmt/
fn.rs

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