use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq)]
pub enum IndentStyle {
Tabs,
Spaces(u8),
}
impl Default for IndentStyle {
fn default() -> Self {
Self::Spaces(2)
}
}
#[derive(Debug, Clone, Default)]
pub struct StyleProfile {
pub indent: IndentStyle,
pub quote_style: QuoteStyle,
pub jsx_quote_style: QuoteStyle,
pub semicolons: bool,
pub line_endings: LineEnding,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub enum QuoteStyle {
#[default]
Double,
Single,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub enum LineEnding {
#[default]
Lf,
Crlf,
Cr,
}
pub fn normalize_newlines(source: &str) -> String {
source.replace("\r\n", "\n").replace("\r", "\n")
}
pub fn detect_indentation(source: &str) -> (IndentStyle, bool) {
let mut tab_count = 0;
let mut space_counts: HashMap<usize, usize> = HashMap::new();
for line in source.lines() {
let leading = line.len() - line.trim_start().len();
if leading == 0 {
continue;
}
if line.starts_with('\t') {
tab_count += 1;
} else if line.starts_with(' ') {
let spaces = leading;
*space_counts.entry(spaces).or_insert(0) += 1;
}
}
if tab_count > 0
&& (space_counts.is_empty() || tab_count >= space_counts.values().sum::<usize>() / 2)
{
return (IndentStyle::Tabs, false);
}
if let Some((&spaces, _)) = space_counts.iter().max_by_key(|(_, c)| *c) {
let changed = spaces != 2 && spaces != 4;
return (IndentStyle::Spaces(spaces as u8), changed);
}
(IndentStyle::default(), false)
}
pub fn detect_quote_style(source: &str) -> QuoteStyle {
let double_count = source.matches('"').count();
let single_count = source.matches('\'').count();
if double_count > single_count {
QuoteStyle::Double
} else if single_count > double_count {
QuoteStyle::Single
} else {
QuoteStyle::Unknown
}
}
pub fn detect_semicolons(source: &str) -> bool {
let mut with_semicolon = 0;
let mut without_semicolon = 0;
let mut in_comment = false;
for line in source.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if in_comment {
if trimmed.contains("*/") {
in_comment = false;
}
continue;
}
if trimmed.starts_with("/*") {
if !trimmed.contains("*/") {
in_comment = true;
}
continue;
}
if trimmed.starts_with("//") || trimmed.starts_with('*') {
continue;
}
let mut line_content = trimmed;
if let Some(idx) = trimmed.find("//") {
line_content = trimmed[..idx].trim();
}
if line_content.is_empty() {
continue;
}
if line_content.ends_with(';') {
with_semicolon += 1;
} else {
let last_char = line_content.chars().last().unwrap();
if last_char.is_alphanumeric()
|| last_char == '"'
|| last_char == '\''
|| last_char == '`'
|| last_char == ')'
|| last_char == ']'
{
without_semicolon += 1;
}
}
}
if with_semicolon == 0 && without_semicolon == 0 {
source.contains(';')
} else {
with_semicolon >= without_semicolon
}
}
pub fn detect_line_endings(source: &str) -> LineEnding {
if source.contains("\r\n") {
LineEnding::Crlf
} else if source.contains('\r') {
LineEnding::Cr
} else {
LineEnding::Lf
}
}
pub fn analyze_style(source: &str) -> StyleProfile {
let mut double_count = 0;
let mut single_count = 0;
let mut jsx_double_count = 0;
let mut jsx_single_count = 0;
let mut in_single_comment = false;
let mut in_multi_comment = false;
let mut in_string: Option<char> = None;
let mut escape = false;
let mut jsx_tag_depth = 0;
let mut curly_depth = 0;
let mut jsx_curly_stack: Vec<usize> = Vec::new();
let chars: Vec<char> = source.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if escape {
escape = false;
i += 1;
continue;
}
if in_single_comment {
if c == '\n' || c == '\r' {
in_single_comment = false;
}
i += 1;
continue;
}
if in_multi_comment {
if c == '*' && i + 1 < chars.len() && chars[i + 1] == '/' {
in_multi_comment = false;
i += 2;
} else {
i += 1;
}
continue;
}
if let Some(quote_char) = in_string {
if c == '\\' {
escape = true;
} else if c == quote_char {
in_string = None;
}
i += 1;
continue;
}
if c == '/' && i + 1 < chars.len() {
if chars[i + 1] == '/' {
in_single_comment = true;
i += 2;
continue;
} else if chars[i + 1] == '*' {
in_multi_comment = true;
i += 2;
continue;
}
}
if c == '\'' || c == '"' || c == '`' {
in_string = Some(c);
let is_jsx_attr = jsx_tag_depth > 0 && jsx_curly_stack.last().map(|&d| d == curly_depth).unwrap_or(false);
if is_jsx_attr {
if c == '\'' {
jsx_single_count += 1;
} else if c == '"' {
jsx_double_count += 1;
}
} else if c == '\'' {
single_count += 1;
} else if c == '"' {
double_count += 1;
}
i += 1;
continue;
}
if c == '{' {
curly_depth += 1;
i += 1;
continue;
}
if c == '}' {
if curly_depth > 0 {
curly_depth -= 1;
}
i += 1;
continue;
}
if c == '<' {
let mut is_tag_start = false;
if i + 1 < chars.len() {
let next = chars[i + 1];
if next.is_ascii_alphabetic() || next == '/' || next == '>' {
is_tag_start = if jsx_tag_depth > 0 {
true
} else {
let mut prev_idx = i;
let mut found_prev = None;
while prev_idx > 0 {
prev_idx -= 1;
let pc = chars[prev_idx];
if !pc.is_whitespace() {
found_prev = Some(pc);
break;
}
}
match found_prev {
Some(pc) => {
let is_operator_or_bracket = matches!(pc, '=' | '(' | ',' | '{' | '}' | ':' | '?' | '&' | '|');
if is_operator_or_bracket {
true
} else {
let mut word = String::new();
let mut w_idx = prev_idx + 1;
while w_idx > 0 {
w_idx -= 1;
let wc = chars[w_idx];
if wc.is_ascii_alphabetic() {
word.insert(0, wc);
} else {
break;
}
}
matches!(word.as_str(), "return" | "yield" | "default")
}
}
None => true,
}
};
}
}
if is_tag_start {
jsx_tag_depth += 1;
jsx_curly_stack.push(curly_depth);
}
i += 1;
continue;
}
if c == '>' {
if jsx_tag_depth > 0 {
if jsx_curly_stack.last().map(|&d| d == curly_depth).unwrap_or(false) {
jsx_tag_depth -= 1;
jsx_curly_stack.pop();
}
}
i += 1;
continue;
}
i += 1;
}
let quote_style = if double_count > single_count {
QuoteStyle::Double
} else if single_count > double_count {
QuoteStyle::Single
} else {
QuoteStyle::Unknown
};
let jsx_quote_style = if jsx_double_count > jsx_single_count {
QuoteStyle::Double
} else if jsx_single_count > jsx_double_count {
QuoteStyle::Single
} else {
QuoteStyle::Unknown
};
let indent = detect_indentation(source).0;
let semicolons = detect_semicolons(source);
let line_endings = detect_line_endings(source);
StyleProfile {
indent,
quote_style,
jsx_quote_style,
semicolons,
line_endings,
}
}
pub fn preserve_style(source: &str, profile: &StyleProfile) -> String {
let mut result = source.to_string();
result = adjust_indentation(&result, &profile.indent);
result = process_quotes(&result, profile.quote_style.clone(), profile.jsx_quote_style.clone());
if !profile.semicolons {
result = strip_trailing_semicolons(&result);
}
match profile.line_endings {
LineEnding::Crlf => {
result = result.replace("\r\n", "\n").replace('\r', "\n").replace('\n', "\r\n");
}
LineEnding::Cr => {
result = result.replace("\r\n", "\n").replace('\r', "\n").replace('\n', "\r");
}
LineEnding::Lf => {
result = result.replace("\r\n", "\n").replace('\r', "\n");
}
}
result
}
pub fn adjust_indentation(source: &str, indent_style: &IndentStyle) -> String {
let mut result = String::new();
for line in source.lines() {
if line.trim().is_empty() {
result.push_str(line);
result.push('\n');
continue;
}
let leading_spaces = line.len() - line.trim_start().len();
let content = line.trim_start();
if leading_spaces > 0 && line[..leading_spaces].chars().all(|c| c == ' ') {
let indent_level = leading_spaces / 2;
let remainder = leading_spaces % 2;
let mut new_leading = String::new();
match indent_style {
IndentStyle::Tabs => {
new_leading.push_str(&"\t".repeat(indent_level));
new_leading.push_str(&" ".repeat(remainder));
}
IndentStyle::Spaces(m) => {
new_leading.push_str(&" ".repeat(indent_level * (*m as usize)));
new_leading.push_str(&" ".repeat(remainder));
}
}
result.push_str(&new_leading);
result.push_str(content);
} else {
result.push_str(line);
}
result.push('\n');
}
if !source.ends_with('\n') && result.ends_with('\n') {
result.pop();
}
result
}
pub fn process_quotes(source: &str, target_style: QuoteStyle, target_jsx_style: QuoteStyle) -> String {
if target_style == QuoteStyle::Unknown && target_jsx_style == QuoteStyle::Unknown {
return source.to_string();
}
let mut result = String::new();
let chars: Vec<char> = source.chars().collect();
let mut i = 0;
let mut in_single_comment = false;
let mut in_multi_comment = false;
let mut jsx_tag_depth = 0;
let mut curly_depth = 0;
let mut jsx_curly_stack: Vec<usize> = Vec::new();
while i < chars.len() {
let c = chars[i];
if in_single_comment {
result.push(c);
if c == '\n' || c == '\r' {
in_single_comment = false;
}
i += 1;
continue;
}
if in_multi_comment {
result.push(c);
if c == '*' && i + 1 < chars.len() && chars[i + 1] == '/' {
result.push('/');
in_multi_comment = false;
i += 2;
} else {
i += 1;
}
continue;
}
if c == '/' && i + 1 < chars.len() {
if chars[i + 1] == '/' {
result.push('/');
result.push('/');
in_single_comment = true;
i += 2;
continue;
} else if chars[i + 1] == '*' {
result.push('/');
result.push('*');
in_multi_comment = true;
i += 2;
continue;
}
}
if c == '\'' || c == '"' {
let quote_char = c;
let mut inner = String::new();
let mut escaped = false;
i += 1;
while i < chars.len() {
let nc = chars[i];
if escaped {
inner.push(nc);
escaped = false;
} else if nc == '\\' {
inner.push(nc);
escaped = true;
} else if nc == quote_char {
i += 1;
break;
} else {
inner.push(nc);
}
i += 1;
}
let is_jsx_attr = jsx_tag_depth > 0 && jsx_curly_stack.last().map(|&d| d == curly_depth).unwrap_or(false);
let active_target_style = if is_jsx_attr {
&target_jsx_style
} else {
&target_style
};
let should_convert = match active_target_style {
QuoteStyle::Single => quote_char == '"',
QuoteStyle::Double => quote_char == '\'',
QuoteStyle::Unknown => false,
};
if should_convert {
let new_quote = if *active_target_style == QuoteStyle::Single { '\'' } else { '"' };
let mut new_inner = String::new();
let mut inner_chars = inner.chars().peekable();
while let Some(ic) = inner_chars.next() {
if ic == '\\' {
if let Some(&next_ic) = inner_chars.peek() {
if next_ic == new_quote {
new_inner.push('\\');
new_inner.push(inner_chars.next().unwrap());
} else if next_ic == quote_char {
new_inner.push(inner_chars.next().unwrap());
} else {
new_inner.push('\\');
new_inner.push(inner_chars.next().unwrap());
}
} else {
new_inner.push('\\');
}
} else if ic == new_quote {
new_inner.push('\\');
new_inner.push(ic);
} else {
new_inner.push(ic);
}
}
result.push(new_quote);
result.push_str(&new_inner);
result.push(new_quote);
} else {
result.push(quote_char);
result.push_str(&inner);
result.push(quote_char);
}
continue;
}
if c == '`' {
result.push(c);
let mut escaped = false;
i += 1;
while i < chars.len() {
let nc = chars[i];
result.push(nc);
if escaped {
escaped = false;
} else if nc == '\\' {
escaped = true;
} else if nc == '`' {
i += 1;
break;
}
i += 1;
}
continue;
}
if c == '{' {
curly_depth += 1;
result.push(c);
i += 1;
continue;
}
if c == '}' {
if curly_depth > 0 {
curly_depth -= 1;
}
result.push(c);
i += 1;
continue;
}
if c == '<' {
let mut is_tag_start = false;
if i + 1 < chars.len() {
let next = chars[i + 1];
if next.is_ascii_alphabetic() || next == '/' || next == '>' {
is_tag_start = if jsx_tag_depth > 0 {
true
} else {
let mut prev_idx = i;
let mut found_prev = None;
while prev_idx > 0 {
prev_idx -= 1;
let pc = chars[prev_idx];
if !pc.is_whitespace() {
found_prev = Some(pc);
break;
}
}
match found_prev {
Some(pc) => {
let is_operator_or_bracket = matches!(pc, '=' | '(' | ',' | '{' | '}' | ':' | '?' | '&' | '|');
if is_operator_or_bracket {
true
} else {
let mut word = String::new();
let mut w_idx = prev_idx + 1;
while w_idx > 0 {
w_idx -= 1;
let wc = chars[w_idx];
if wc.is_ascii_alphabetic() {
word.insert(0, wc);
} else {
break;
}
}
matches!(word.as_str(), "return" | "yield" | "default")
}
}
None => true,
}
};
}
}
if is_tag_start {
jsx_tag_depth += 1;
jsx_curly_stack.push(curly_depth);
}
result.push(c);
i += 1;
continue;
}
if c == '>' {
if jsx_tag_depth > 0 {
if jsx_curly_stack.last().map(|&d| d == curly_depth).unwrap_or(false) {
jsx_tag_depth -= 1;
jsx_curly_stack.pop();
}
}
result.push(c);
i += 1;
continue;
}
result.push(c);
i += 1;
}
result
}
pub fn strip_trailing_semicolons(source: &str) -> String {
let mut result = String::new();
let mut chars = source.chars().enumerate().peekable();
let mut paren_depth = 0;
let mut bracket_depth = 0;
while let Some((i, c)) = chars.next() {
if c == '/' {
if chars.peek().map(|&(_, next_c)| next_c) == Some('/') {
result.push(c);
result.push(chars.next().unwrap().1);
while let Some((_, nc)) = chars.next() {
result.push(nc);
if nc == '\n' {
break;
}
}
continue;
} else if chars.peek().map(|&(_, next_c)| next_c) == Some('*') {
result.push(c);
result.push(chars.next().unwrap().1);
while let Some((_, nc)) = chars.next() {
result.push(nc);
if nc == '*' && chars.peek().map(|&(_, next_c)| next_c) == Some('/') {
result.push(chars.next().unwrap().1);
break;
}
}
continue;
}
}
if c == '`' {
result.push(c);
let mut escaped = false;
while let Some((_, nc)) = chars.next() {
result.push(nc);
if escaped {
escaped = false;
} else if nc == '\\' {
escaped = true;
} else if nc == '`' {
break;
}
}
continue;
}
if c == '"' || c == '\'' {
result.push(c);
let mut escaped = false;
while let Some((_, nc)) = chars.next() {
result.push(nc);
if escaped {
escaped = false;
} else if nc == '\\' {
escaped = true;
} else if nc == c {
break;
}
}
continue;
}
if c == '(' {
paren_depth += 1;
} else if c == ')' {
if paren_depth > 0 { paren_depth -= 1; }
} else if c == '[' {
bracket_depth += 1;
} else if c == ']' {
if bracket_depth > 0 { bracket_depth -= 1; }
}
if c == ';' && paren_depth == 0 && bracket_depth == 0 {
if is_semicolon_at_line_end(source, i + 1) && !is_next_char_dangerous(source, i + 1) {
continue;
}
}
result.push(c);
}
result
}
fn is_semicolon_at_line_end(source: &str, start_idx: usize) -> bool {
let mut chars = source[start_idx..].chars().peekable();
while let Some(c) = chars.next() {
if c == '\n' || c == '\r' {
return true;
}
if c.is_whitespace() {
continue;
}
if c == '/' && chars.peek() == Some(&'/') {
return true;
}
if c == '/' && chars.peek() == Some(&'*') {
chars.next();
let mut found_end = false;
while let Some(mc) = chars.next() {
if mc == '\n' || mc == '\r' {
break;
}
if mc == '*' && chars.peek() == Some(&'/') {
chars.next();
found_end = true;
break;
}
}
if found_end {
continue;
} else {
return true;
}
}
return false;
}
true
}
fn is_next_char_dangerous(source: &str, start_idx: usize) -> bool {
let mut chars = source[start_idx..].chars().peekable();
let mut in_single_comment = false;
let mut in_multi_comment = false;
while let Some(c) = chars.next() {
if in_single_comment {
if c == '\n' {
in_single_comment = false;
}
continue;
}
if in_multi_comment {
if c == '*' && chars.peek() == Some(&'/') {
chars.next();
in_multi_comment = false;
}
continue;
}
if c.is_whitespace() {
continue;
}
if c == '/' && chars.peek() == Some(&'/') {
chars.next();
in_single_comment = true;
continue;
}
if c == '/' && chars.peek() == Some(&'*') {
chars.next();
in_multi_comment = true;
continue;
}
return matches!(c, '(' | '[' | '`' | '/' | '+' | '-' | '.');
}
false
}
pub fn insert_newline_after_imports(source: &str) -> String {
let mut lines: Vec<String> = source.lines().map(|s| s.to_string()).collect();
let mut last_import_idx = None;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("import ") || trimmed.starts_with("import{") {
last_import_idx = Some(i);
}
}
if let Some(idx) = last_import_idx {
if idx + 1 < lines.len() && !lines[idx + 1].trim().is_empty() {
lines.insert(idx + 1, String::new());
}
}
lines.join("\n")
}
pub fn restore_blank_lines(new_source: &str, orig_source: &str) -> String {
let orig_lines: Vec<&str> = orig_source.lines().collect();
let new_lines: Vec<&str> = new_source.lines().collect();
let mut result = Vec::new();
let mut orig_idx = 0;
let normalize = |s: &str| -> String {
s.chars()
.filter(|c| !c.is_whitespace() && *c != ';' && *c != '\'' && *c != '"' && *c != '`')
.collect::<String>()
.to_lowercase()
};
for new_line in new_lines {
let trimmed_new = new_line.trim();
if trimmed_new.is_empty() {
result.push("");
continue;
}
let normalized_new = normalize(trimmed_new);
let mut matched_idx = None;
let search_limit = std::cmp::min(orig_lines.len(), orig_idx + 20);
for idx in orig_idx..search_limit {
let trimmed_orig = orig_lines[idx].trim();
if !trimmed_orig.is_empty() && normalize(trimmed_orig) == normalized_new {
matched_idx = Some(idx);
break;
}
}
if let Some(idx) = matched_idx {
let mut has_blank = false;
for check_idx in orig_idx..idx {
if orig_lines[check_idx].trim().is_empty() {
has_blank = true;
break;
}
}
if has_blank {
if result.last().map(|s| !s.is_empty()).unwrap_or(false) {
result.push("");
}
}
orig_idx = idx + 1;
}
result.push(new_line);
}
result.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_newlines_crlf() {
let result = normalize_newlines("line1\r\nline2\r\n");
assert!(!result.contains('\r'));
}
#[test]
fn test_normalize_newlines_cr() {
let result = normalize_newlines("line1\rline2\r");
assert!(!result.contains('\r'));
}
#[test]
fn test_detect_indentation_tabs() {
let (style, changed) = detect_indentation("\tconst x = 1;\n\tconst y = 2;");
assert_eq!(style, IndentStyle::Tabs);
assert!(!changed);
}
#[test]
fn test_detect_indentation_spaces() {
let (style, changed) = detect_indentation(" const x = 1;\n const y = 2;");
assert_eq!(style, IndentStyle::Spaces(2));
assert!(!changed);
}
#[test]
fn test_detect_quote_style_double() {
assert_eq!(
detect_quote_style("const x = \"hello\";"),
QuoteStyle::Double
);
}
#[test]
fn test_detect_quote_style_single() {
assert_eq!(detect_quote_style("const x = 'hello';"), QuoteStyle::Single);
}
#[test]
fn test_detect_semicolons_true() {
assert!(detect_semicolons("const x = 1;"));
}
#[test]
fn test_detect_semicolons_false() {
assert!(!detect_semicolons("const x = 1"));
}
#[test]
fn test_detect_line_endings() {
assert_eq!(detect_line_endings("line1\nline2"), LineEnding::Lf);
assert_eq!(detect_line_endings("line1\r\nline2"), LineEnding::Crlf);
}
#[test]
fn test_analyze_style() {
let profile = analyze_style(" const x = \"hello\";\n");
assert_eq!(profile.indent, IndentStyle::Spaces(2));
assert_eq!(profile.quote_style, QuoteStyle::Double);
assert!(profile.semicolons);
}
#[test]
fn test_preserve_style() {
let profile = StyleProfile {
indent: IndentStyle::Spaces(2),
quote_style: QuoteStyle::Double,
jsx_quote_style: QuoteStyle::Double,
semicolons: true,
line_endings: LineEnding::Crlf,
};
let result = preserve_style("const x = 1;\nconst y = 2;", &profile);
assert!(result.contains("\r\n"));
}
#[test]
fn test_adjust_indentation() {
let input = " const x = 1;\n const y = 2;";
let tabs = adjust_indentation(input, &IndentStyle::Tabs);
assert_eq!(tabs, "\tconst x = 1;\n\t\tconst y = 2;");
let spaces4 = adjust_indentation(input, &IndentStyle::Spaces(4));
assert_eq!(spaces4, " const x = 1;\n const y = 2;");
}
#[test]
fn test_process_quotes() {
let double_input = "const x = \"hello \\\"world\\\"\";";
let single_out = process_quotes(double_input, QuoteStyle::Single, QuoteStyle::Unknown);
assert_eq!(single_out, "const x = 'hello \"world\"';");
let single_input = "const y = 'hello \\'world\\'';";
let double_out = process_quotes(single_input, QuoteStyle::Double, QuoteStyle::Unknown);
assert_eq!(double_out, "const y = \"hello 'world'\";");
}
#[test]
fn test_process_quotes_jsx() {
let input = "const el = <div className=\"my-class\" style={{color: 'red'}} />;";
let out = process_quotes(input, QuoteStyle::Single, QuoteStyle::Double);
assert_eq!(out, "const el = <div className=\"my-class\" style={{color: 'red'}} />;");
}
#[test]
fn test_restore_blank_lines() {
let orig = "const a = 1;\n\nconst b = 2;\n\nfunction foo() {\n return 3;\n}";
let new_code = "const a = 1;\nconst b = 2;\nfunction foo() {\n return 3;\n}";
let restored = restore_blank_lines(new_code, orig);
assert_eq!(restored, "const a = 1;\n\nconst b = 2;\n\nfunction foo() {\n return 3;\n}");
}
#[test]
fn test_strip_trailing_semicolons() {
let input = "const x = 1;\nconst y = 2;\nfor (let i = 0; i < 10; i++) {\n console.log(i);\n}";
let stripped = strip_trailing_semicolons(input);
assert_eq!(
stripped,
"const x = 1\nconst y = 2\nfor (let i = 0; i < 10; i++) {\n console.log(i)\n}"
);
}
#[test]
fn test_insert_newline_after_imports() {
let input = "import a from 'a';\nimport b from 'b';\nconst x = 1;";
let out = insert_newline_after_imports(input);
assert_eq!(out, "import a from 'a';\nimport b from 'b';\n\nconst x = 1;");
}
}