use crate::ast::ClassMember;
use crate::config::Config;
use crate::fixer;
use crate::lexer::Lexer;
use crate::linter;
use crate::parser::Parser;
use crate::token::TokenKind;
pub fn format_source(source: &str, config: &Config) -> String {
let mut result = crate::linter::normalize_line_endings(source);
for _ in 0..5 {
let formatted = format_pass(&result, config);
if formatted == result {
break;
}
result = formatted;
}
result
}
fn format_pass(source: &str, config: &Config) -> String {
let mut result = source.to_string();
result = safe_reorder_class_members(&result);
result = reorder_inner_classes(&result);
result = normalize_indentation(&result, config);
result = strip_trailing_whitespace(&result);
result = normalize_member_spacing(&result);
result = collapse_blank_lines(&result);
result = normalize_boolean_operators(&result);
result = normalize_quotes(&result);
result = normalize_comment_spacing(&result);
result = normalize_float_literals(&result);
result = normalize_hex_literals(&result);
result = ensure_trailing_newline(&result);
result = lint_then_fix(&result, config);
result = break_long_lines(&result, config);
result
}
fn normalize_indentation(source: &str, config: &Config) -> String {
let lines: Vec<&str> = source.split('\n').collect();
let mut result = Vec::new();
for line in &lines {
if line.is_empty() {
result.push(String::new());
continue;
}
let indent: String = line
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.collect();
let rest = &line[indent.len()..];
if indent.is_empty() {
result.push(line.to_string());
continue;
}
let new_indent = if config.use_tabs {
let total_spaces: usize = indent.chars().map(|c| if c == '\t' { 4 } else { 1 }).sum();
"\t".repeat(total_spaces / 4)
} else {
indent.replace('\t', " ")
};
result.push(format!("{}{}", new_indent, rest));
}
result.join("\n")
}
fn strip_trailing_whitespace(source: &str) -> String {
source
.split('\n')
.map(|line| line.trim_end().to_string())
.collect::<Vec<_>>()
.join("\n")
}
fn canonical_blank_lines_between(prev: usize, curr: usize) -> usize {
if prev <= 3 && curr <= 3 {
return 0;
}
if curr == 11 || curr == 12 || curr == 13 {
return 2;
}
if prev == 11 || prev == 12 || prev == 13 {
return 2;
}
if prev == curr {
return 0;
}
1
}
fn leading_annotation_line(member: &ClassMember) -> Option<usize> {
let annotations = match member {
ClassMember::Variable { annotations, .. }
| ClassMember::Function { annotations, .. }
| ClassMember::StaticVariable { annotations, .. } => annotations.as_slice(),
_ => return None,
};
annotations.iter().map(|a| a.span.line).min()
}
struct MemberUnit {
category: usize,
start: usize, decl_start: usize, }
fn normalize_member_spacing(source: &str) -> String {
let mut lexer = Lexer::new(source);
let tokens = lexer.tokenize();
let members = Parser::new(&tokens).parse();
if members.is_empty() {
return source.to_string();
}
let lines: Vec<&str> = source.split('\n').collect();
let mut units: Vec<MemberUnit> = Vec::new();
let mut pending_doc_start: Option<usize> = None;
let mut pending_doc_last: Option<usize> = None;
let flush_standalone_docs =
|units: &mut Vec<MemberUnit>, start: &mut Option<usize>, last: &mut Option<usize>| {
if let (Some(s), Some(_)) = (*start, *last) {
units.push(MemberUnit {
category: 3,
start: s,
decl_start: s,
});
}
*start = None;
*last = None;
};
for member in &members {
match member {
ClassMember::DocComment { span, .. } => {
if let Some(prev_last) = pending_doc_last {
if span.line != prev_last + 1 {
flush_standalone_docs(
&mut units,
&mut pending_doc_start,
&mut pending_doc_last,
);
}
}
if pending_doc_start.is_none() {
pending_doc_start = Some(span.line);
}
pending_doc_last = Some(span.line);
}
ClassMember::Comment { .. } | ClassMember::BlankLine { .. } => {
}
_ => {
let kw_line = member.span().line;
let annotation_start = leading_annotation_line(member);
let decl_start = match annotation_start {
Some(a) => a.min(kw_line),
None => kw_line,
};
if let Some(last_doc_line) = pending_doc_last {
if decl_start > last_doc_line + 1 {
flush_standalone_docs(
&mut units,
&mut pending_doc_start,
&mut pending_doc_last,
);
}
}
let start = pending_doc_start
.map(|d| d.min(decl_start))
.unwrap_or(decl_start);
pending_doc_start = None;
pending_doc_last = None;
units.push(MemberUnit {
category: member.ordering_category(),
start,
decl_start,
});
}
}
}
flush_standalone_docs(&mut units, &mut pending_doc_start, &mut pending_doc_last);
if units.is_empty() {
return source.to_string();
}
let mut result: Vec<String> = Vec::new();
let first_start = units[0].start;
for i in 1..first_start {
if let Some(line) = lines.get(i - 1) {
result.push(line.to_string());
}
}
while result.last().is_some_and(|l| l.trim().is_empty()) {
result.pop();
}
let had_preamble = !result.is_empty();
let mut prev_category: Option<usize> = None;
let total_lines = lines.len();
for (idx, unit) in units.iter().enumerate() {
let blanks = if let Some(prev) = prev_category {
canonical_blank_lines_between(prev, unit.category)
} else if had_preamble {
1
} else {
0
};
for _ in 0..blanks {
result.push(String::new());
}
let end_excl = units
.get(idx + 1)
.map(|u| u.start)
.unwrap_or(total_lines + 1);
let mut doc_lines: Vec<String> = Vec::new();
for i in unit.start..unit.decl_start {
if let Some(line) = lines.get(i - 1) {
doc_lines.push(line.to_string());
}
}
while doc_lines.last().is_some_and(|l| l.trim().is_empty()) {
doc_lines.pop();
}
result.extend(doc_lines);
let mut decl_lines: Vec<String> = Vec::new();
for i in unit.decl_start..end_excl {
if let Some(line) = lines.get(i - 1) {
decl_lines.push(line.to_string());
}
}
while decl_lines.last().is_some_and(|l| l.trim().is_empty()) {
decl_lines.pop();
}
result.extend(decl_lines);
prev_category = Some(unit.category);
}
while result.last().is_some_and(|l| l.is_empty()) {
result.pop();
}
result.push(String::new());
result.join("\n")
}
fn collapse_blank_lines(source: &str) -> String {
let lines: Vec<&str> = source.split('\n').collect();
let mut result = Vec::new();
let mut blank_count = 0;
for line in &lines {
if line.trim().is_empty() {
blank_count += 1;
if blank_count <= 2 {
result.push(String::new());
}
} else {
blank_count = 0;
result.push(line.to_string());
}
}
result.join("\n")
}
fn normalize_boolean_operators(source: &str) -> String {
let mut lexer = crate::lexer::Lexer::new(source);
let tokens = lexer.tokenize();
let mut replacements: Vec<(usize, usize, &str)> = Vec::new();
for token in &tokens {
match &token.kind {
crate::token::TokenKind::AmpersandAmpersand => {
replacements.push((token.span.offset, token.span.length, "and"));
}
crate::token::TokenKind::PipePipe => {
replacements.push((token.span.offset, token.span.length, "or"));
}
crate::token::TokenKind::Bang => {
replacements.push((token.span.offset, token.span.length, "not "));
}
_ => {}
}
}
if replacements.is_empty() {
return source.to_string();
}
let mut result = source.to_string();
for (offset, length, new_text) in replacements.into_iter().rev() {
let end = (offset + length).min(result.len());
result.replace_range(offset..end, new_text);
}
result
}
fn normalize_quotes(source: &str) -> String {
let mut lexer = crate::lexer::Lexer::new(source);
let tokens = lexer.tokenize();
let mut replacements: Vec<(usize, usize, String)> = Vec::new();
for token in &tokens {
if let crate::token::TokenKind::String(ref info) = token.kind {
if info.quote_style == crate::token::QuoteStyle::Single
&& info.prefix == crate::token::StringPrefix::None
&& !info.is_multiline
&& !info.value.contains('"')
{
let new_text = format!("\"{}\"", info.value);
replacements.push((token.span.offset, token.span.length, new_text));
}
}
}
if replacements.is_empty() {
return source.to_string();
}
let mut result = source.to_string();
for (offset, length, new_text) in replacements.into_iter().rev() {
let end = (offset + length).min(result.len());
result.replace_range(offset..end, &new_text);
}
result
}
fn normalize_comment_spacing(source: &str) -> String {
let mut lexer = crate::lexer::Lexer::new(source);
let tokens = lexer.tokenize();
let mut insertions: Vec<(usize, &str)> = Vec::new();
for token in &tokens {
match &token.kind {
crate::token::TokenKind::Comment(content) => {
if !content.is_empty()
&& !content.starts_with(' ')
&& !content.starts_with('!')
&& !content.starts_with("region")
&& !content.starts_with("endregion")
{
let first_char = content.chars().next().unwrap();
if first_char.is_ascii_alphabetic() || first_char == '_' || first_char == '\t' {
continue;
}
insertions.push((token.span.offset + 1, " "));
}
}
crate::token::TokenKind::DocComment(content) => {
if !content.is_empty() && !content.starts_with(' ') && !content.starts_with('#') {
insertions.push((token.span.offset + 2, " "));
}
}
_ => {}
}
}
if insertions.is_empty() {
return source.to_string();
}
let mut result = source.to_string();
for (offset, text) in insertions.into_iter().rev() {
result.insert_str(offset, text);
}
result
}
fn normalize_float_literals(source: &str) -> String {
let mut lexer = crate::lexer::Lexer::new(source);
let tokens = lexer.tokenize();
let mut replacements: Vec<(usize, usize, String)> = Vec::new();
for token in &tokens {
if let crate::token::TokenKind::Float(_) = &token.kind {
let text = &token.text;
if text.starts_with('.') {
replacements.push((token.span.offset, token.span.length, format!("0{}", text)));
} else if text.ends_with('.') {
replacements.push((token.span.offset, token.span.length, format!("{}0", text)));
}
}
}
if replacements.is_empty() {
return source.to_string();
}
let mut result = source.to_string();
for (offset, length, new_text) in replacements.into_iter().rev() {
let end = (offset + length).min(result.len());
result.replace_range(offset..end, &new_text);
}
result
}
fn normalize_hex_literals(source: &str) -> String {
let mut lexer = crate::lexer::Lexer::new(source);
let tokens = lexer.tokenize();
let mut replacements: Vec<(usize, usize, String)> = Vec::new();
for token in &tokens {
if let crate::token::TokenKind::Integer(_) = &token.kind {
let text = &token.text;
if (text.starts_with("0x") || text.starts_with("0X"))
&& text[2..]
.chars()
.any(|c| c.is_ascii_uppercase() && c != '_')
{
let fixed = format!("0x{}", text[2..].to_lowercase());
replacements.push((token.span.offset, token.span.length, fixed));
}
}
}
if replacements.is_empty() {
return source.to_string();
}
let mut result = source.to_string();
for (offset, length, new_text) in replacements.into_iter().rev() {
let end = (offset + length).min(result.len());
result.replace_range(offset..end, &new_text);
}
result
}
fn ensure_trailing_newline(source: &str) -> String {
if source.is_empty() {
return String::new();
}
let trimmed = source.trim_end_matches('\n');
format!("{}\n", trimmed)
}
fn lint_then_fix(source: &str, config: &Config) -> String {
let diagnostics = linter::lint_source(source, "<fmt>", config);
fixer::apply_fixes(source, &diagnostics, true)
}
fn break_long_lines(source: &str, config: &Config) -> String {
const TAB_WIDTH: usize = 4;
let max_len = config.max_line_length;
let lines: Vec<&str> = source.split('\n').collect();
let mut result: Vec<String> = Vec::new();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let visual_len = visual_line_len(line, TAB_WIDTH);
if visual_len <= max_len {
result.push(line.to_string());
i += 1;
continue;
}
if let Some(broken) = try_break_line(line, config) {
result.extend(broken);
} else if let Some(broken) = try_break_comment(line, max_len) {
result.extend(broken);
} else if let Some(broken) = try_break_at_bool_operators(line, config) {
result.extend(broken);
} else {
result.push(line.to_string());
}
i += 1;
}
result.join("\n")
}
fn visual_line_len(line: &str, tab_width: usize) -> usize {
let mut col = 0;
for ch in line.chars() {
if ch == '\t' {
col = (col / tab_width + 1) * tab_width;
} else {
col += 1;
}
}
col
}
fn try_break_line(line: &str, config: &Config) -> Option<Vec<String>> {
let indent: String = line
.chars()
.take_while(|c| *c == '\t' || *c == ' ')
.collect();
let content = &line[indent.len()..];
let (open_byte, open_ch, close_ch) = find_first_delimiter(content)?;
let close_byte = find_matching_close(content, open_byte, open_ch, close_ch)?;
let inner = &content[open_byte + 1..close_byte];
let items = split_top_level_commas(inner);
if items.len() < 2 {
return None;
}
let prefix = &content[..open_byte + 1]; let suffix = &content[close_byte..];
if open_ch == '[' {
let prior = content[..open_byte]
.chars()
.rev()
.find(|c| !c.is_whitespace());
if matches!(prior, Some(c) if c.is_ascii_alphanumeric() || c == '_' || c == ']' || c == ')')
{
return None;
}
}
let allows_trailing_comma = true;
let item_indent = if config.use_tabs {
format!("{}\t", indent)
} else {
format!("{} ", indent)
};
let mut broken: Vec<String> = Vec::new();
broken.push(format!("{}{}", indent, prefix));
let last = items.len() - 1;
for (idx, item) in items.iter().enumerate() {
let trimmed = item.trim();
if trimmed.is_empty() {
continue;
}
let is_last = idx == last;
if is_last && !allows_trailing_comma {
broken.push(format!("{}{}", item_indent, trimmed));
} else {
broken.push(format!("{}{},", item_indent, trimmed));
}
}
broken.push(format!("{}{}", indent, suffix));
Some(broken)
}
struct StringScanner {
in_string: bool,
quote: char,
escaped: bool,
raw: bool,
}
impl StringScanner {
fn new() -> Self {
Self {
in_string: false,
quote: '"',
escaped: false,
raw: false,
}
}
fn step(&mut self, prior: Option<char>, ch: char) -> bool {
if self.in_string {
if !self.raw {
if self.escaped {
self.escaped = false;
return true;
}
if ch == '\\' {
self.escaped = true;
return true;
}
}
if ch == self.quote {
self.in_string = false;
self.raw = false;
}
return true;
}
if ch == '"' || ch == '\'' {
self.in_string = true;
self.quote = ch;
self.escaped = false;
self.raw = matches!(prior, Some('r' | 'R'));
return true;
}
false
}
}
fn find_first_delimiter(content: &str) -> Option<(usize, char, char)> {
let mut scanner = StringScanner::new();
let chars: Vec<(usize, char)> = content.char_indices().collect();
for window in 0..chars.len() {
let (i, ch) = chars[window];
let prior = if window == 0 {
None
} else {
Some(chars[window - 1].1)
};
if scanner.step(prior, ch) {
continue;
}
if ch == '#' {
return None; }
match ch {
'(' => return Some((i, '(', ')')),
'[' => return Some((i, '[', ']')),
'{' => return Some((i, '{', '}')),
_ => {}
}
}
None
}
fn find_matching_close(content: &str, open_pos: usize, open: char, close: char) -> Option<usize> {
let mut depth = 1;
let mut scanner = StringScanner::new();
let mut prior = Some(content[..open_pos + 1].chars().next_back().unwrap_or(' '));
for (i, ch) in content[open_pos + 1..].char_indices() {
let abs_pos = open_pos + 1 + i;
if scanner.step(prior, ch) {
prior = Some(ch);
continue;
}
prior = Some(ch);
if ch == open {
depth += 1;
} else if ch == close {
depth -= 1;
if depth == 0 {
return Some(abs_pos);
}
}
}
None
}
fn split_top_level_commas(content: &str) -> Vec<String> {
let mut items: Vec<String> = Vec::new();
let mut current = String::new();
let mut depth = 0;
let mut scanner = StringScanner::new();
let mut prior: Option<char> = None;
for ch in content.chars() {
let in_string = scanner.step(prior, ch);
prior = Some(ch);
if in_string {
current.push(ch);
continue;
}
match ch {
'(' | '[' | '{' => {
depth += 1;
current.push(ch);
}
')' | ']' | '}' => {
depth -= 1;
current.push(ch);
}
',' if depth == 0 => {
items.push(current.clone());
current.clear();
}
_ => current.push(ch),
}
}
if !current.trim().is_empty() {
items.push(current);
}
items
}
fn try_break_comment(line: &str, max_len: usize) -> Option<Vec<String>> {
const TAB_WIDTH: usize = 4;
let indent: String = line
.chars()
.take_while(|c| *c == '\t' || *c == ' ')
.collect();
let content = &line[indent.len()..];
if !content.starts_with('#') {
return None;
}
let (prefix, text) = if let Some(rest) = content.strip_prefix("## ") {
("## ", rest)
} else if let Some(rest) = content.strip_prefix("# ") {
("# ", rest)
} else {
return None; };
let prefix_visual_len = visual_line_len(&format!("{}{}", indent, prefix), TAB_WIDTH);
let words: Vec<&str> = text.split_whitespace().collect();
if words.is_empty() {
return None;
}
let mut lines_out: Vec<String> = Vec::new();
let mut current_line = format!("{}{}", indent, prefix);
let mut current_visual = prefix_visual_len;
for word in &words {
let word_len = word.len();
if current_visual > prefix_visual_len && current_visual + 1 + word_len > max_len {
lines_out.push(current_line);
current_line = format!("{}{}{}", indent, prefix, word);
current_visual = prefix_visual_len + word_len;
} else {
if current_visual > prefix_visual_len {
current_line.push(' ');
current_visual += 1;
}
current_line.push_str(word);
current_visual += word_len;
}
}
lines_out.push(current_line);
if lines_out.len() < 2 {
return None;
}
Some(lines_out)
}
fn try_break_at_bool_operators(line: &str, config: &Config) -> Option<Vec<String>> {
let indent: String = line
.chars()
.take_while(|c| *c == '\t' || *c == ' ')
.collect();
let content = &line[indent.len()..];
let keyword = if content.starts_with("if ") {
"if"
} else if content.starts_with("elif ") {
"elif"
} else if content.starts_with("while ") {
"while"
} else {
return None;
};
let after_kw = &content[keyword.len() + 1..];
let colon_pos = find_statement_colon(after_kw)?;
let condition = after_kw[..colon_pos].trim();
let after_colon = &after_kw[colon_pos..];
let parts = split_at_bool_operators(condition);
if parts.len() < 2 {
return None;
}
let cont_indent = if config.use_tabs {
format!("{}\t\t", indent)
} else {
format!("{} ", indent)
};
let mut broken: Vec<String> = Vec::new();
broken.push(format!("{}{} ({}", indent, keyword, parts[0].trim()));
for part in &parts[1..] {
broken.push(format!("{}{}", cont_indent, part.trim()));
}
let last = broken.last_mut().unwrap();
*last = format!("{}){}", last, after_colon);
Some(broken)
}
fn find_statement_colon(text: &str) -> Option<usize> {
let mut scanner = StringScanner::new();
let mut depth: i32 = 0;
let mut found: Option<usize> = None;
let mut prior: Option<char> = None;
let bytes = text.as_bytes();
for (i, ch) in text.char_indices() {
if scanner.step(prior, ch) {
prior = Some(ch);
continue;
}
match ch {
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth -= 1,
'#' => break, ':' if depth == 0 => {
if bytes.get(i + 1) != Some(&b'=') {
found = Some(i);
}
}
_ => {}
}
prior = Some(ch);
}
found
}
fn split_at_bool_operators(condition: &str) -> Vec<String> {
let mut parts: Vec<String> = Vec::new();
let mut current = String::new();
let mut depth = 0;
let mut in_string = false;
let mut string_char = '"';
let mut escaped = false;
let chars: Vec<char> = condition.chars().collect();
let mut i = 0;
while i < chars.len() {
if in_string {
current.push(chars[i]);
if escaped {
escaped = false;
i += 1;
continue;
}
if chars[i] == '\\' {
escaped = true;
i += 1;
continue;
}
if chars[i] == string_char {
in_string = false;
}
i += 1;
continue;
}
if chars[i] == '"' || chars[i] == '\'' {
in_string = true;
string_char = chars[i];
current.push(chars[i]);
i += 1;
continue;
}
match chars[i] {
'(' | '[' | '{' => {
depth += 1;
current.push(chars[i]);
}
')' | ']' | '}' => {
depth -= 1;
current.push(chars[i]);
}
_ if depth == 0 => {
let remaining = &condition[i..];
if remaining.starts_with(" and ") {
parts.push(current.clone());
current = String::from("and ");
i += 5; continue;
} else if remaining.starts_with(" or ") {
parts.push(current.clone());
current = String::from("or ");
i += 4; continue;
}
current.push(chars[i]);
}
_ => current.push(chars[i]),
}
i += 1;
}
if !current.trim().is_empty() {
parts.push(current);
}
parts
}
fn safe_reorder_class_members(source: &str) -> String {
let original_members = parse_members(source);
if has_cross_referencing_initializer(source, &original_members) {
return source.to_string();
}
let reordered = reorder_class_members(source);
if reordered == source {
return reordered;
}
let reordered_members = parse_members(&reordered);
if !same_member_signatures(&original_members, &reordered_members) {
return source.to_string();
}
reordered
}
fn parse_members(source: &str) -> Vec<ClassMember> {
let mut lexer = Lexer::new(source);
let tokens = lexer.tokenize();
Parser::new(&tokens).parse()
}
fn has_cross_referencing_initializer(source: &str, members: &[ClassMember]) -> bool {
use std::collections::HashSet;
let module_names: HashSet<&str> = members
.iter()
.filter_map(|m| match m {
ClassMember::Variable { name, .. }
| ClassMember::StaticVariable { name, .. }
| ClassMember::Constant { name, .. } => {
if name.is_empty() {
None
} else {
Some(name.as_str())
}
}
_ => None,
})
.collect();
if module_names.len() < 2 {
return false;
}
let lines: Vec<&str> = source.split('\n').collect();
let line_start = |line_1based: usize| -> usize {
lines[..line_1based.saturating_sub(1)]
.iter()
.map(|l| l.len() + 1)
.sum()
};
for (idx, member) in members.iter().enumerate() {
let (own_name, member_line) = match member {
ClassMember::Variable { name, span, .. }
| ClassMember::StaticVariable { name, span, .. }
| ClassMember::Constant { name, span, .. } => (name.as_str(), span.line),
_ => continue,
};
let start = line_start(member_line);
let end = members
.get(idx + 1)
.map(|m| line_start(m.span().line))
.unwrap_or(source.len());
let slice = &source[start..end.min(source.len())];
let mut lexer = Lexer::new(slice);
let tokens = lexer.tokenize();
for (i, tok) in tokens.iter().enumerate() {
let TokenKind::Identifier(ref n) = tok.kind else {
continue;
};
if n.as_str() == own_name {
continue;
}
if !module_names.contains(n.as_str()) {
continue;
}
let prev_is_dot = i > 0 && matches!(tokens[i - 1].kind, TokenKind::Dot);
if !prev_is_dot {
return true;
}
}
}
false
}
fn same_member_signatures(a: &[ClassMember], b: &[ClassMember]) -> bool {
type Sig<'a> = (Vec<&'a str>, &'static str, &'a str);
fn collect<'a>(members: &'a [ClassMember], scope: &[&'a str], out: &mut Vec<Sig<'a>>) {
for m in members {
let (kind_tag, name): (&'static str, &str) = match m {
ClassMember::Function { name, .. } => ("func", name.as_str()),
ClassMember::Variable { name, .. } => ("var", name.as_str()),
ClassMember::StaticVariable { name, .. } => ("svar", name.as_str()),
ClassMember::Constant { name, .. } => ("const", name.as_str()),
ClassMember::Signal { name, .. } => ("signal", name.as_str()),
ClassMember::Enum {
name, members: ems, ..
} => {
let n = name.as_deref().unwrap_or("");
out.push((scope.to_vec(), "enum", n));
let mut enum_scope = scope.to_vec();
enum_scope.push(n);
for em in ems {
out.push((enum_scope.clone(), "enum_member", em.name.as_str()));
}
continue;
}
ClassMember::ClassNameDecl { name, .. } => ("class", name.as_str()),
ClassMember::ExtendsDecl { base, .. } => ("extends", base.as_str()),
ClassMember::InnerClass {
name,
members: inner,
..
} => {
out.push((scope.to_vec(), "inner", name.as_str()));
let mut inner_scope = scope.to_vec();
inner_scope.push(name.as_str());
collect(inner, &inner_scope, out);
continue;
}
_ => continue,
};
out.push((scope.to_vec(), kind_tag, name));
}
}
let mut a_sigs: Vec<Sig> = Vec::new();
let mut b_sigs: Vec<Sig> = Vec::new();
collect(a, &[], &mut a_sigs);
collect(b, &[], &mut b_sigs);
a_sigs.sort();
b_sigs.sort();
a_sigs == b_sigs
}
struct Block {
start: usize,
end: usize,
category: usize,
original_index: usize,
}
type Anchor = (usize, usize, usize);
fn collect_member_anchors(members: &[ClassMember], source_lines: &[&str]) -> Vec<Anchor> {
let is_top_level_line = |line: usize| -> bool {
match source_lines.get(line) {
None => true,
Some(text) if text.trim().is_empty() => true,
Some(text) => !text.starts_with(['\t', ' ']),
}
};
let mut anchors: Vec<Anchor> = Vec::new();
for (orig_idx, member) in members.iter().enumerate() {
if matches!(
member,
ClassMember::DocComment { .. }
| ClassMember::Comment { .. }
| ClassMember::BlankLine { .. }
) {
continue;
}
let line = member.span().line.saturating_sub(1); if !is_top_level_line(line) {
continue;
}
anchors.push((line, member.ordering_category(), orig_idx));
}
let mut merged: Vec<Anchor> = Vec::new();
for &anchor in &anchors {
if let Some(last) = merged.last_mut() {
if last.0 == anchor.0 {
last.1 = last.1.min(anchor.1);
continue;
}
}
merged.push(anchor);
}
merged
}
fn compute_attached_starts(merged: &[Anchor], lines: &[&str]) -> Vec<usize> {
let mut attached_starts: Vec<usize> = Vec::new();
for (i, &(decl_line, _, _)) in merged.iter().enumerate() {
let prev_boundary = if i > 0 { merged[i - 1].0 + 1 } else { 0 };
let mut start = decl_line;
let mut j = decl_line;
while j > prev_boundary {
j -= 1;
let content = if j < lines.len() { lines[j].trim() } else { "" };
if content.starts_with('#') || content.starts_with('@') {
start = j;
} else if content.is_empty() {
let above = if j > prev_boundary && j - 1 < lines.len() {
lines[j - 1].trim()
} else {
""
};
if !above.starts_with('#') && !above.starts_with('@') {
break;
}
} else {
break;
}
}
attached_starts.push(start);
}
attached_starts
}
fn reorder_class_members(source: &str) -> String {
let mut lexer = Lexer::new(source);
let tokens = lexer.tokenize();
let members = Parser::new(&tokens).parse();
let lines: Vec<&str> = source.split('\n').collect();
let merged = collect_member_anchors(&members, &lines);
if merged.is_empty() {
return source.to_string();
}
let already_ordered = merged.windows(2).all(|w| w[0].1 <= w[1].1);
if already_ordered {
return move_class_decl_before_doc_comments(source);
}
let attached_starts = compute_attached_starts(&merged, &lines);
let mut blocks: Vec<Block> = Vec::new();
for (i, &(_, cat, orig)) in merged.iter().enumerate() {
let start = attached_starts[i];
let end = if i + 1 < merged.len() {
attached_starts[i + 1].saturating_sub(1)
} else {
lines.len().saturating_sub(1)
};
blocks.push(Block {
start,
end,
category: cat,
original_index: orig,
});
}
blocks.sort_by(|a, b| {
a.category
.cmp(&b.category)
.then(a.original_index.cmp(&b.original_index))
});
emit_reordered_blocks(&blocks, &lines, &attached_starts)
}
fn emit_reordered_blocks(blocks: &[Block], lines: &[&str], attached_starts: &[usize]) -> String {
let mut result: Vec<String> = Vec::new();
let first_block_start = attached_starts.iter().copied().min().unwrap_or(0);
for line in &lines[..first_block_start] {
result.push(line.to_string());
}
let mut prev_category: Option<usize> = None;
let mut deferred_doc_lines: Vec<String> = Vec::new();
for block in blocks {
let block_end = block.end.min(lines.len().saturating_sub(1));
let mut block_lines: Vec<&str> = lines[block.start..=block_end].to_vec();
while block_lines.last().is_some_and(|l| l.trim().is_empty()) {
block_lines.pop();
}
if block_lines.is_empty() {
continue;
}
if block.category <= 2 {
let doc_end = block_lines
.iter()
.position(|l| {
let t = l.trim();
!t.starts_with("##") && !t.is_empty()
})
.unwrap_or(0);
if doc_end > 0 {
let decl_lines: Vec<&str> = block_lines[doc_end..].to_vec();
if prev_category.is_some() {
while result.last().is_some_and(|l| l.trim().is_empty()) {
result.pop();
}
result.push(String::new());
result.push(String::new());
}
for line in &decl_lines {
result.push(line.to_string());
}
deferred_doc_lines.extend(block_lines[..doc_end].iter().map(|l| l.to_string()));
prev_category = Some(block.category);
continue;
}
}
if block.category > 2 && !deferred_doc_lines.is_empty() {
while result.last().is_some_and(|l| l.trim().is_empty()) {
result.pop();
}
result.push(String::new());
result.push(String::new());
result.append(&mut deferred_doc_lines);
}
if prev_category.is_some() {
while result.last().is_some_and(|l| l.trim().is_empty()) {
result.pop();
}
result.push(String::new());
result.push(String::new());
}
for line in &block_lines {
result.push(line.to_string());
}
prev_category = Some(block.category);
}
if !deferred_doc_lines.is_empty() {
while result.last().is_some_and(|l| l.trim().is_empty()) {
result.pop();
}
result.push(String::new());
result.push(String::new());
result.extend(deferred_doc_lines);
}
while result.last().is_some_and(|l| l.is_empty()) {
result.pop();
}
result.push(String::new());
result.join("\n")
}
fn move_class_decl_before_doc_comments(source: &str) -> String {
let lines: Vec<&str> = source.split('\n').collect();
let doc_start = lines.iter().position(|l| l.trim().starts_with("##"));
if doc_start.is_none() {
return source.to_string();
}
let doc_start = doc_start.unwrap();
let mut doc_end = doc_start;
for (i, line) in lines.iter().enumerate().skip(doc_start) {
let t = line.trim();
if t.starts_with("##") || t.is_empty() {
doc_end = i;
} else {
break;
}
}
let decl_start = doc_end + 1;
if decl_start >= lines.len() {
return source.to_string();
}
let decl_trimmed = lines[decl_start].trim();
if !decl_trimmed.starts_with("class_name") && !decl_trimmed.starts_with("extends") {
return source.to_string();
}
let mut decl_end = decl_start;
for (i, line) in lines.iter().enumerate().skip(decl_start) {
let t = line.trim();
if t.starts_with("class_name") || t.starts_with("extends") {
decl_end = i;
} else {
break;
}
}
let mut result: Vec<String> = Vec::new();
for line in &lines[..doc_start] {
result.push(line.to_string());
}
for line in &lines[decl_start..=decl_end] {
result.push(line.to_string());
}
result.push(String::new());
result.push(String::new());
for line in &lines[doc_start..=doc_end] {
if !line.trim().is_empty() {
result.push(line.to_string());
}
}
for line in &lines[(decl_end + 1)..] {
result.push(line.to_string());
}
result.join("\n")
}
fn reorder_inner_classes(source: &str) -> String {
let lines: Vec<&str> = source.split('\n').collect();
let mut result: Vec<String> = Vec::new();
let mut i = 0;
while i < lines.len() {
let trimmed = lines[i].trim();
let indent: String = lines[i]
.chars()
.take_while(|c| *c == '\t' || *c == ' ')
.collect();
if trimmed.starts_with("class ") && trimmed.ends_with(':') {
result.push(lines[i].to_string());
let class_indent_len = indent.len();
i += 1;
let body_start = i;
while i < lines.len() {
let line = lines[i];
if line.trim().is_empty() {
let mut next_nonblank = i + 1;
while next_nonblank < lines.len() && lines[next_nonblank].trim().is_empty() {
next_nonblank += 1;
}
if next_nonblank < lines.len() {
let next_indent: String = lines[next_nonblank]
.chars()
.take_while(|c| *c == '\t' || *c == ' ')
.collect();
if next_indent.len() > class_indent_len {
i += 1;
continue;
}
}
break;
}
let line_indent: String = line
.chars()
.take_while(|c| *c == '\t' || *c == ' ')
.collect();
if line_indent.len() <= class_indent_len {
break;
}
i += 1;
}
let body_end = i;
if body_start < body_end {
let body_lines: Vec<String> = lines[body_start..body_end]
.iter()
.map(|l| {
if l.trim().is_empty() {
String::new()
} else if l.starts_with(&format!("{}\t", indent)) {
l[indent.len() + 1..].to_string()
} else if l.starts_with(&format!("{} ", indent)) {
l[indent.len() + 4..].to_string()
} else if l.len() > indent.len() {
l[indent.len()..].to_string()
} else {
l.to_string()
}
})
.collect();
let dedented_body = body_lines.join("\n");
let reordered = safe_reorder_class_members(&dedented_body);
let member_indent = format!("{}\t", indent);
for line in reordered.split('\n') {
if line.trim().is_empty() {
result.push(String::new());
} else {
result.push(format!("{}{}", member_indent, line));
}
}
while result.last().is_some_and(|l| l.is_empty()) {
result.pop();
}
}
} else {
result.push(lines[i].to_string());
i += 1;
}
}
result.join("\n")
}
pub fn format_file(path: &std::path::Path, config: &Config) -> Result<bool, String> {
let source = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
let formatted = format_source(&source, config);
if formatted == source {
return Ok(false);
}
std::fs::write(path, &formatted)
.map_err(|e| format!("cannot write {}: {}", path.display(), e))?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_trailing_whitespace() {
let source = "var x = 5 \nvar y = 10\t\t\n";
let result = strip_trailing_whitespace(source);
assert_eq!(result, "var x = 5\nvar y = 10\n");
}
#[test]
fn find_statement_colon_skips_dict_and_string_colons() {
assert_eq!(find_statement_colon("a == {\"k\": 1}:"), Some(13));
assert_eq!(find_statement_colon("x == \"a: b\":"), Some(11));
assert_eq!(find_statement_colon("arr[1:2]:"), Some(8));
assert_eq!(find_statement_colon("y := 3"), None);
assert_eq!(find_statement_colon("{\"k\": 1}"), None);
}
#[test]
fn break_at_bool_operators_keeps_dict_literal_intact() {
let config = Config {
max_line_length: 60,
..Config::default()
};
let line = "\tif lookup == {\"key\": 1} and condition_two and condition_three:";
let broken = try_break_at_bool_operators(line, &config).expect("should break");
assert!(
broken[0].contains("{\"key\": 1}"),
"dict literal must stay intact, got: {:?}",
broken
);
assert!(
broken.last().unwrap().trim_end().ends_with("):"),
"must close with `):`, got: {:?}",
broken
);
}
#[test]
fn test_collapse_blank_lines() {
let source = "a\n\n\n\n\nb\n";
let result = collapse_blank_lines(source);
assert_eq!(result, "a\n\n\nb\n");
}
#[test]
fn test_normalize_boolean_operators() {
let source = "if a && b || !c:\n\tpass\n";
let result = normalize_boolean_operators(source);
assert!(result.contains("and"));
assert!(result.contains("or"));
assert!(result.contains("not "));
assert!(!result.contains("&&"));
assert!(!result.contains("||"));
}
#[test]
fn test_normalize_quotes() {
let source = "var x = 'hello'\n";
let result = normalize_quotes(source);
assert_eq!(result, "var x = \"hello\"\n");
}
#[test]
fn test_normalize_quotes_preserves_double_inside() {
let source = "var x = 'he said \"hi\"'\n";
let result = normalize_quotes(source);
assert_eq!(result, source); }
#[test]
fn test_ensure_trailing_newline() {
assert_eq!(ensure_trailing_newline("hello"), "hello\n");
assert_eq!(ensure_trailing_newline("hello\n"), "hello\n");
assert_eq!(ensure_trailing_newline("hello\n\n\n"), "hello\n");
}
#[test]
fn test_normalize_hex() {
let source = "var x = 0xFF\n";
let result = normalize_hex_literals(source);
assert_eq!(result, "var x = 0xff\n");
}
#[test]
fn test_normalize_float_trailing_zero() {
let source = "var x = 1.\n";
let result = normalize_float_literals(source);
assert_eq!(result, "var x = 1.0\n");
}
#[test]
fn test_format_idempotent() {
let source = r#"class_name Player
extends CharacterBody2D
signal health_changed(old_value: int, new_value: int)
const MAX_SPEED: float = 200.0
@export var speed: float = 100.0
var health: int = 100
@onready var label: Label = $Label
func _ready() -> void:
pass
func take_damage(amount: int) -> void:
pass
"#;
let config = Config::default();
let first = format_source(source, &config);
let second = format_source(&first, &config);
assert_eq!(first, second, "formatter must be idempotent");
}
}