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(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/// - `&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                } else {
656                    result.push(CHAR_SPACE);
657                    while position < len && chars[position] != CHAR_BRACE_LEFT {
658                        result.push(chars[position]);
659                        position += 1;
660                    }
661                    if result.ends_with(CHAR_SPACE) {
662                        result.truncate(result.len() - 1);
663                    }
664                    if position < len && chars[position] == CHAR_BRACE_LEFT {
665                        result.push(CHAR_SPACE);
666                    }
667                }
668            }
669            continue;
670        }
671        if chars[position] == CHAR_COLON && position + 1 < len && chars[position + 1] != CHAR_COLON
672        {
673            if result.ends_with(CHAR_COLON) {
674                result.push(CHAR_COLON);
675                position += 1;
676                continue;
677            }
678            let colon_prefix: String = find_ident_before(&result);
679            if is_raw_ident_before(&result, &colon_prefix) {
680                result.push(CHAR_COLON);
681                position += 1;
682                continue;
683            }
684            if !colon_prefix.is_empty() {
685                let before_colon: String = remove_trailing_spaces(&result, colon_prefix.len());
686                result = before_colon;
687                result.push_str(&colon_prefix);
688            }
689            result.push(CHAR_COLON);
690            position += 1;
691            while position < len && (chars[position] == CHAR_SPACE || chars[position] == CHAR_TAB) {
692                position += 1;
693            }
694            if position < len
695                && chars[position] != CHAR_NEWLINE
696                && chars[position] != CHAR_CARRIAGE_RETURN
697                && !is_pseudo_selector_after_colon(&chars, position, len)
698            {
699                result.push(CHAR_SPACE);
700            }
701            continue;
702        }
703        if position + 1 < len
704            && chars[position] == CHAR_EQUALS
705            && chars[position + 1] == CHAR_GREATER_THAN
706        {
707            let trailing: String = find_trailing_spaces(&result);
708            if !trailing.is_empty() {
709                result.truncate(result.len() - trailing.len());
710            }
711            result.push(CHAR_SPACE);
712            result.push_str(ARROW_FAT);
713            position += 2;
714            while position < len && (chars[position] == CHAR_SPACE || chars[position] == CHAR_TAB) {
715                position += 1;
716            }
717            result.push(CHAR_SPACE);
718            continue;
719        }
720        if chars[position] == CHAR_BRACE_LEFT {
721            let (inner, end) = extract_brace_content(&chars, position);
722            let trimmed_inner: &str = inner.trim();
723            if trimmed_inner.is_empty() {
724                result.push(CHAR_BRACE_LEFT);
725                result.push(CHAR_BRACE_RIGHT);
726            } else {
727                let formatted_inner: String = format_macro_body_raw(trimmed_inner);
728                result.push(CHAR_BRACE_LEFT);
729                result.push(CHAR_NEWLINE);
730                result.push_str(&formatted_inner);
731                result.push(CHAR_NEWLINE);
732                result.push(CHAR_BRACE_RIGHT);
733            }
734            position = end;
735            continue;
736        }
737        if is_ident_char(chars[position]) {
738            let start: usize = position;
739            while position < len && is_ident_char(chars[position]) {
740                position += 1;
741            }
742            let ident: String = chars[start..position].iter().collect();
743            result.push_str(&ident);
744            let ws_start: usize = position;
745            while position < len && (chars[position] == CHAR_SPACE || chars[position] == CHAR_TAB) {
746                position += 1;
747            }
748            let had_whitespace: bool = position > ws_start;
749            if position < len && chars[position] == CHAR_BRACE_LEFT {
750                result.push(CHAR_SPACE);
751            } else if had_whitespace {
752                let next_pos: usize = position;
753                if next_pos < len && is_ident_char(chars[next_pos]) {
754                    let mut ident_end: usize = next_pos;
755                    while ident_end < len && is_ident_char(chars[ident_end]) {
756                        ident_end += 1;
757                    }
758                    let after_ident: usize = skip_spaces_on_same_line(&chars, ident_end, len);
759                    if after_ident < len
760                        && chars[after_ident] == CHAR_COLON
761                        && (after_ident + 1 >= len || chars[after_ident + 1] != CHAR_COLON)
762                    {
763                        result.push(CHAR_NEWLINE);
764                        continue;
765                    }
766                }
767                let ws: String = chars[ws_start..position].iter().collect();
768                result.push_str(&ws);
769            }
770            continue;
771        }
772        result.push(chars[position]);
773        position += 1;
774    }
775    result
776}
777
778/// Adds 4-space indentation to each line based on brace depth.
779///
780/// String literals and comments are skipped so braces inside them do not affect depth.
781/// The first non-empty line receives a base indent of 4 spaces (depth 1).
782///
783/// # Arguments
784///
785/// - `&str` - The raw formatted macro body text.
786///
787/// # Returns
788///
789/// - `String` - The indented macro body text.
790fn add_indentation(body: &str) -> String {
791    let chars: Vec<char> = body.chars().collect();
792    let len: usize = chars.len();
793    let mut result: String = String::new();
794    let mut depth: i32 = 0;
795    let mut index: usize = 0;
796    while index < len {
797        if chars[index] == CHAR_DOUBLE_QUOTE || chars[index] == CHAR_SINGLE_QUOTE {
798            let quote: char = chars[index];
799            result.push(chars[index]);
800            index += 1;
801            while index < len {
802                if chars[index] == CHAR_SLASH_BACK && index + 1 < len {
803                    result.push(chars[index]);
804                    result.push(chars[index + 1]);
805                    index += 2;
806                    continue;
807                }
808                result.push(chars[index]);
809                if chars[index] == quote {
810                    index += 1;
811                    break;
812                }
813                index += 1;
814            }
815            continue;
816        }
817        if index + 1 < len
818            && chars[index] == CHAR_SLASH_FORWARD
819            && chars[index + 1] == CHAR_SLASH_FORWARD
820        {
821            while index < len && chars[index] != CHAR_NEWLINE {
822                result.push(chars[index]);
823                index += 1;
824            }
825            continue;
826        }
827        if index + 1 < len
828            && chars[index] == CHAR_SLASH_FORWARD
829            && chars[index + 1] == CHAR_ASTERISK
830        {
831            result.push(chars[index]);
832            result.push(chars[index + 1]);
833            index += 2;
834            while index + 1 < len {
835                if chars[index] == CHAR_ASTERISK && chars[index + 1] == CHAR_SLASH_FORWARD {
836                    result.push(chars[index]);
837                    result.push(chars[index + 1]);
838                    index += 2;
839                    break;
840                }
841                result.push(chars[index]);
842                index += 1;
843            }
844            continue;
845        }
846        if chars[index] == CHAR_NEWLINE {
847            result.push(CHAR_NEWLINE);
848            index += 1;
849            while index < len && (chars[index] == CHAR_SPACE || chars[index] == CHAR_TAB) {
850                index += 1;
851            }
852            if index >= len || chars[index] == CHAR_NEWLINE || chars[index] == CHAR_CARRIAGE_RETURN
853            {
854                continue;
855            }
856            let mut closing_count: i32 = 0;
857            let mut peek: usize = index;
858            while peek < len && chars[peek] == CHAR_BRACE_RIGHT {
859                closing_count += 1;
860                peek += 1;
861            }
862            let indent_depth: i32 = (depth - closing_count).max(0);
863            for _ in 0..indent_depth * 4 {
864                result.push(CHAR_SPACE);
865            }
866            continue;
867        }
868        if chars[index] == CHAR_BRACE_LEFT {
869            depth += 1;
870            result.push(CHAR_BRACE_LEFT);
871            index += 1;
872            continue;
873        }
874        if chars[index] == CHAR_BRACE_RIGHT {
875            depth -= 1;
876            result.push(CHAR_BRACE_RIGHT);
877            index += 1;
878            let mut peek: usize = index;
879            while peek < len && (chars[peek] == CHAR_SPACE || chars[peek] == CHAR_TAB) {
880                peek += 1;
881            }
882            if peek < len
883                && chars[peek] != CHAR_BRACE_RIGHT
884                && chars[peek] != CHAR_BRACE_LEFT
885                && chars[peek] != CHAR_NEWLINE
886                && chars[peek] != CHAR_CARRIAGE_RETURN
887                && chars[peek] != CHAR_RIGHT_PAREN
888                && chars[peek] != CHAR_COMMA
889                && chars[peek] != CHAR_SEMICOLON
890            {
891                let next_chars: String = chars[peek..(peek + 4).min(len)].iter().collect();
892                if next_chars != "else" {
893                    result.push(CHAR_NEWLINE);
894                    let indent_depth: i32 = depth.max(0);
895                    for _ in 0..indent_depth * 4 {
896                        result.push(CHAR_SPACE);
897                    }
898                    index = peek;
899                }
900            }
901            continue;
902        }
903        result.push(chars[index]);
904        index += 1;
905    }
906
907    result
908}
909
910/// Checks if the current position is the start of the `if` keyword.
911///
912/// # Arguments
913///
914/// - `&[char]` - The source character slice.
915/// - `usize` - The current position.
916/// - `usize` - The total length.
917///
918/// # Returns
919///
920/// - `bool` - Whether `if` starts at the given position.
921fn is_if_keyword(chars: &[char], pos: usize, len: usize) -> bool {
922    if pos + 2 > len {
923        return false;
924    }
925    chars[pos] == CHAR_LETTER_I
926        && chars[pos + 1] == CHAR_LETTER_F
927        && (pos + 2 >= len || !is_ident_char(chars[pos + 2]))
928        && (pos == 0 || !is_ident_char(chars[pos - 1]))
929        && !is_raw_prefix(chars, pos)
930}
931
932/// Checks if the current position is the start of the `else` keyword.
933///
934/// # Arguments
935///
936/// - `&[char]` - The source character slice.
937/// - `usize` - The current position.
938/// - `usize` - The total length.
939///
940/// # Returns
941///
942/// - `bool` - Whether `else` starts at the given position.
943fn is_else_keyword(chars: &[char], pos: usize, len: usize) -> bool {
944    if pos + 4 > len {
945        return false;
946    }
947    chars[pos] == CHAR_LETTER_E
948        && chars[pos + 1] == CHAR_LETTER_L
949        && chars[pos + 2] == CHAR_LETTER_S
950        && chars[pos + 3] == CHAR_LETTER_E
951        && (pos + 4 >= len || !is_ident_char(chars[pos + 4]))
952        && (pos == 0 || !is_ident_char(chars[pos - 1]))
953        && !is_raw_prefix(chars, pos)
954}
955
956/// Checks if the current position is the start of the `match` keyword.
957///
958/// # Arguments
959///
960/// - `&[char]` - The source character slice.
961/// - `usize` - The current position.
962/// - `usize` - The total length.
963///
964/// # Returns
965///
966/// - `bool` - Whether `match` starts at the given position.
967fn is_match_keyword(chars: &[char], pos: usize, len: usize) -> bool {
968    if pos + 5 > len {
969        return false;
970    }
971    chars[pos] == CHAR_LETTER_M
972        && chars[pos + 1] == CHAR_LETTER_A
973        && chars[pos + 2] == CHAR_LETTER_T
974        && chars[pos + 3] == CHAR_LETTER_C
975        && chars[pos + 4] == CHAR_LETTER_H
976        && (pos + 5 >= len || !is_ident_char(chars[pos + 5]))
977        && (pos == 0 || !is_ident_char(chars[pos - 1]))
978        && !is_raw_prefix(chars, pos)
979}
980
981/// Checks if the current position is the start of the `for` keyword.
982///
983/// # Arguments
984///
985/// - `&[char]` - The source character slice.
986/// - `usize` - The current position.
987/// - `usize` - The total length.
988///
989/// # Returns
990///
991/// - `bool` - Whether `for` starts at the given position.
992fn is_for_keyword(chars: &[char], pos: usize, len: usize) -> bool {
993    if pos + 3 > len {
994        return false;
995    }
996    chars[pos] == CHAR_LETTER_F
997        && chars[pos + 1] == CHAR_LETTER_O
998        && chars[pos + 2] == CHAR_LETTER_R
999        && (pos + 3 >= len || !is_ident_char(chars[pos + 3]))
1000        && (pos == 0 || !is_ident_char(chars[pos - 1]))
1001        && !is_raw_prefix(chars, pos)
1002}
1003
1004/// Checks if the current position is the start of the `in` keyword.
1005///
1006/// # Arguments
1007///
1008/// - `&[char]` - The source character slice.
1009/// - `usize` - The current position.
1010/// - `usize` - The total length.
1011///
1012/// # Returns
1013///
1014/// - `bool` - Whether `in` starts at the given position.
1015fn is_in_keyword(chars: &[char], pos: usize, len: usize) -> bool {
1016    if pos + 2 > len {
1017        return false;
1018    }
1019    chars[pos] == CHAR_LETTER_I
1020        && chars[pos + 1] == CHAR_LETTER_N
1021        && (pos + 2 >= len || !is_ident_char(chars[pos + 2]))
1022        && (pos == 0 || !is_ident_char(chars[pos - 1]))
1023        && !is_raw_prefix(chars, pos)
1024}
1025
1026/// Formats a brace block by adding spaces inside single-line braces.
1027///
1028/// For blocks that do not contain newlines, trims inner content and adds
1029/// single spaces around it: `{ content }`. Empty blocks remain `{}`.
1030/// For blocks containing newlines, returns the block unchanged.
1031///
1032/// # Arguments
1033///
1034/// - `&str` - The brace block text (including outer `{` and `}`).
1035///
1036/// # Returns
1037///
1038/// - `String` - The formatted brace block text.
1039fn format_brace_block(block: &str) -> String {
1040    let block_chars: Vec<char> = block.chars().collect();
1041    if block_chars.len() < 2
1042        || block_chars[0] != CHAR_BRACE_LEFT
1043        || *block_chars.last().unwrap() != CHAR_BRACE_RIGHT
1044    {
1045        return block.to_string();
1046    }
1047    let inner: String = block_chars[1..block_chars.len() - 1].iter().collect();
1048    if inner.contains(CHAR_NEWLINE) {
1049        return block.to_string();
1050    }
1051    let trimmed_inner: &str = inner.trim();
1052    if trimmed_inner.is_empty() {
1053        let mut empty_result: String = String::new();
1054        empty_result.push(CHAR_BRACE_LEFT);
1055        empty_result.push(CHAR_BRACE_RIGHT);
1056        return empty_result;
1057    }
1058    let mut formatted: String = String::new();
1059    formatted.push(CHAR_BRACE_LEFT);
1060    formatted.push(CHAR_SPACE);
1061    formatted.push_str(trimmed_inner);
1062    formatted.push(CHAR_SPACE);
1063    formatted.push(CHAR_BRACE_RIGHT);
1064    formatted
1065}
1066
1067/// Skips spaces (but not newlines) on the same line.
1068///
1069/// # Arguments
1070///
1071/// - `&[char]` - The source character slice.
1072/// - `usize` - The current position.
1073/// - `usize` - The total length.
1074///
1075/// # Returns
1076///
1077/// - `usize` - The position after skipping same-line spaces.
1078fn skip_spaces_on_same_line(chars: &[char], mut pos: usize, len: usize) -> usize {
1079    while pos < len && (chars[pos] == CHAR_SPACE || chars[pos] == CHAR_TAB) {
1080        pos += 1;
1081    }
1082    pos
1083}
1084
1085/// Checks whether the content after a colon is a CSS pseudo-class or pseudo-element selector.
1086///
1087/// A pseudo-class/pseudo-element selector is identified by looking ahead:
1088/// after the colon, if an identifier is found, and that identifier
1089/// (possibly followed by more hyphen-separated identifiers like `focus-visible`)
1090/// is immediately followed by `{` (not `!` which indicates a macro call like `format!`),
1091/// then this is a selector colon and should not have a space after it.
1092///
1093/// Also handles functional pseudo-classes like `:nth-child(2n+1)`, `:not(.class)`,
1094/// where the identifier is followed by a parenthesized argument list before `{`.
1095///
1096/// Returns `false` if the first identifier after the colon is a Rust keyword
1097/// (e.g., `if`, `match`, `for`) since those are attribute value expressions,
1098/// not CSS selectors.
1099///
1100/// # Arguments
1101///
1102/// - `&[char]` - The source character slice.
1103/// - `usize` - The current position (right after the colon, spaces already skipped).
1104/// - `usize` - The total length of the source.
1105///
1106/// # Returns
1107///
1108/// - `bool` - Whether the content after the colon is a CSS selector.
1109fn is_pseudo_selector_after_colon(chars: &[char], mut pos: usize, len: usize) -> bool {
1110    if pos >= len || !is_ident_char(chars[pos]) {
1111        return false;
1112    }
1113    let ident_start: usize = pos;
1114    while pos < len && is_ident_char(chars[pos]) {
1115        pos += 1;
1116    }
1117    let first_ident: String = chars[ident_start..pos].iter().collect();
1118    if is_rust_keyword(&first_ident) {
1119        return false;
1120    }
1121    while pos < len && chars[pos] == CHAR_HYPHEN && pos + 1 < len && is_ident_char(chars[pos + 1]) {
1122        pos += 1;
1123        while pos < len && is_ident_char(chars[pos]) {
1124            pos += 1;
1125        }
1126    }
1127    let after_ident: usize = skip_spaces_on_same_line(chars, pos, len);
1128    if after_ident < len && chars[after_ident] == CHAR_BRACE_LEFT {
1129        return true;
1130    }
1131    if after_ident < len && chars[after_ident] == CHAR_LEFT_PAREN {
1132        let mut depth: i32 = 0;
1133        let mut paren_pos: usize = after_ident;
1134        while paren_pos < len {
1135            if chars[paren_pos] == CHAR_LEFT_PAREN {
1136                depth += 1;
1137            } else if chars[paren_pos] == CHAR_RIGHT_PAREN {
1138                depth -= 1;
1139                if depth == 0 {
1140                    let after_paren: usize = skip_spaces_on_same_line(chars, paren_pos + 1, len);
1141                    return after_paren < len && chars[after_paren] == CHAR_BRACE_LEFT;
1142                }
1143            }
1144            paren_pos += 1;
1145        }
1146    }
1147    false
1148}
1149
1150/// Checks whether a string is a Rust keyword that can appear after a colon
1151/// in euv macro attribute syntax (e.g., `class: if { ... }`).
1152///
1153/// These keywords indicate attribute value expressions, not CSS selectors.
1154///
1155/// # Arguments
1156///
1157/// - `&str` - The identifier string to check.
1158///
1159/// # Returns
1160///
1161/// - `bool` - Whether the string is a Rust keyword.
1162fn is_rust_keyword(ident: &str) -> bool {
1163    matches!(ident, KEYWORD_IF | KEYWORD_MATCH | KEYWORD_FOR)
1164}
1165
1166/// Checks whether the identifier found before the colon is a Rust raw identifier (r#prefix).
1167///
1168/// Raw identifiers use the `r#` prefix (e.g., `r#for`), and their colons
1169/// should not be formatted as attribute separators.
1170///
1171/// # Arguments
1172///
1173/// - `&str` - The result string built so far.
1174/// - `&str` - The identifier found before the colon.
1175///
1176/// # Returns
1177///
1178/// - `bool` - Whether the identifier is preceded by `r#`.
1179fn is_raw_ident_before(result: &str, ident: &str) -> bool {
1180    result
1181        .trim_end()
1182        .ends_with(&format!("{RAW_IDENT_PREFIX}{ident}"))
1183}
1184
1185/// Finds the identifier immediately before the current position in the result string.
1186///
1187/// # Arguments
1188///
1189/// - `&str` - The result string built so far.
1190///
1191/// # Returns
1192///
1193/// - `String` - The identifier found before the current position, or empty if none.
1194fn find_ident_before(result: &str) -> String {
1195    let chars: Vec<char> = result.chars().collect();
1196    let mut end: usize = chars.len();
1197    while end > 0 && chars[end - 1] == CHAR_SPACE {
1198        end -= 1;
1199    }
1200    let mut start: usize = end;
1201    while start > 0 && is_ident_char(chars[start - 1]) {
1202        start -= 1;
1203    }
1204    if start < end {
1205        chars[start..end].iter().collect()
1206    } else {
1207        String::new()
1208    }
1209}
1210
1211/// Removes trailing spaces and the identified prefix from the result string.
1212///
1213/// # Arguments
1214///
1215/// - `&str` - The result string built so far.
1216/// - `usize` - The length of the prefix to remove.
1217///
1218/// # Returns
1219///
1220/// - `String` - The string with trailing spaces and prefix removed.
1221fn remove_trailing_spaces(result: &str, prefix_len: usize) -> String {
1222    let chars: Vec<char> = result.chars().collect();
1223    let total_len: usize = chars.len();
1224    let mut end: usize = total_len;
1225    while end > 0 && chars[end - 1] == CHAR_SPACE {
1226        end -= 1;
1227    }
1228    if prefix_len > end {
1229        return result.to_string();
1230    }
1231    let new_end: usize = end - prefix_len;
1232    chars[..new_end].iter().collect()
1233}
1234
1235/// Finds trailing spaces in the result string.
1236///
1237/// # Arguments
1238///
1239/// - `&str` - The result string.
1240///
1241/// # Returns
1242///
1243/// - `String` - The trailing spaces.
1244fn find_trailing_spaces(result: &str) -> String {
1245    let mut spaces: String = String::new();
1246    for ch in result.chars().rev() {
1247        if ch == CHAR_SPACE || ch == CHAR_TAB {
1248            spaces.push(ch);
1249        } else {
1250            break;
1251        }
1252    }
1253    spaces.chars().rev().collect()
1254}
1255
1256/// Formats all Rust source files in the given directory that contain euv macros.
1257///
1258/// Recursively walks the directory tree, finds `.rs` files, and formats
1259/// any euv macro invocations found within them.
1260///
1261/// # Arguments
1262///
1263/// - `&Path` - The root directory to search.
1264/// - `FmtMode` - Whether to check or write formatting.
1265///
1266/// # Returns
1267///
1268/// - `Result<(), EuvError>` - Indicates success or failure.
1269pub async fn format_dir(path: &Path, mode: FmtMode) -> Result<(), EuvError> {
1270    if path.is_file() {
1271        let changed: bool = format_file(path, &mode).await?;
1272        match mode {
1273            FmtMode::Check => {
1274                if changed {
1275                    return Err(EuvError::Message(format!(
1276                        "{} needs formatting.",
1277                        path.display()
1278                    )));
1279                }
1280                log::info!("{} is properly formatted.", path.display());
1281            }
1282            FmtMode::Write => {
1283                if changed {
1284                    log::info!("Formatted: {}", path.display());
1285                } else {
1286                    log::info!("Already formatted: {}", path.display());
1287                }
1288            }
1289        }
1290        return Ok(());
1291    }
1292    let mut entries: Vec<PathBuf> = collect_rs_files(path).await?;
1293    entries.sort();
1294    let mut changed_count: usize = 0;
1295    let mut unchanged_count: usize = 0;
1296    for entry in entries {
1297        match format_file(&entry, &mode).await {
1298            Ok(changed) => {
1299                if changed {
1300                    changed_count += 1;
1301                } else {
1302                    unchanged_count += 1;
1303                }
1304            }
1305            Err(error) => {
1306                log::warn!("Failed to format {}: {error}", entry.display());
1307            }
1308        }
1309    }
1310    match mode {
1311        FmtMode::Check => {
1312            if changed_count > 0 {
1313                return Err(EuvError::Message(format!(
1314                    "{} file(s) need formatting. Run `euv fmt` to fix.",
1315                    changed_count
1316                )));
1317            }
1318            log::info!("All {} file(s) are properly formatted.", unchanged_count);
1319        }
1320        FmtMode::Write => {
1321            log::info!(
1322                "Formatted {} file(s), {} unchanged.",
1323                changed_count,
1324                unchanged_count
1325            );
1326        }
1327    }
1328    Ok(())
1329}
1330
1331/// Collects all `.rs` files in a directory recursively.
1332///
1333/// # Arguments
1334///
1335/// - `&Path` - The directory to search.
1336///
1337/// # Returns
1338///
1339/// - `Result<Vec<PathBuf>, EuvError>` - The list of `.rs` file paths.
1340async fn collect_rs_files(path: &Path) -> Result<Vec<PathBuf>, EuvError> {
1341    let mut result: Vec<PathBuf> = Vec::new();
1342    let mut stack: Vec<PathBuf> = vec![path.to_path_buf()];
1343    while let Some(dir) = stack.pop() {
1344        let mut entries: ReadDir =
1345            read_dir(&dir)
1346                .await
1347                .map_err(|error: io::Error| EuvError::IoPath {
1348                    message: String::from("Failed to read directory"),
1349                    path: dir.clone(),
1350                    error,
1351                })?;
1352        while let Some(entry) =
1353            entries
1354                .next_entry()
1355                .await
1356                .map_err(|error: io::Error| EuvError::IoPath {
1357                    message: String::from("Failed to read entry in directory"),
1358                    path: dir.clone(),
1359                    error,
1360                })?
1361        {
1362            let entry_path: PathBuf = entry.path();
1363            if entry_path.is_dir() {
1364                let file_name: String = entry_path
1365                    .file_name()
1366                    .unwrap_or_default()
1367                    .to_string_lossy()
1368                    .to_string();
1369                if file_name != TARGET_DIR_NAME && file_name != NODE_MODULES_DIR_NAME {
1370                    stack.push(entry_path);
1371                }
1372            } else if entry_path
1373                .extension()
1374                .is_some_and(|ext: &std::ffi::OsStr| ext == RS_EXTENSION)
1375            {
1376                result.push(entry_path);
1377            }
1378        }
1379    }
1380    Ok(result)
1381}
1382
1383/// Formats a single Rust source file.
1384///
1385/// # Arguments
1386///
1387/// - `&Path` - The file path.
1388/// - `&FmtMode` - Whether to check or write formatting.
1389///
1390/// # Returns
1391///
1392/// - `Result<bool, EuvError>` - Whether the file was changed.
1393async fn format_file(path: &Path, mode: &FmtMode) -> Result<bool, EuvError> {
1394    let content: String =
1395        read_to_string(path)
1396            .await
1397            .map_err(|error: io::Error| EuvError::IoPath {
1398                message: String::from("Failed to read"),
1399                path: path.to_path_buf(),
1400                error,
1401            })?;
1402    let fmt_result: FmtResult = format_source(&content);
1403    if fmt_result.get_changed() {
1404        match mode {
1405            FmtMode::Write => {
1406                write(path, fmt_result.get_output())
1407                    .await
1408                    .map_err(|error: io::Error| EuvError::IoPath {
1409                        message: String::from("Failed to write"),
1410                        path: path.to_path_buf(),
1411                        error,
1412                    })?;
1413                log::info!("Formatted: {}", path.display());
1414            }
1415            FmtMode::Check => {
1416                log::warn!("Needs formatting: {}", path.display());
1417            }
1418        }
1419    }
1420    Ok(fmt_result.get_changed())
1421}