From 7e1b578246cd1e542b963131a51a74580bd6249c Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Tue, 10 Mar 2026 14:28:40 +0800 Subject: [PATCH 01/10] rename emmylua_code_style to emmylua_formatter --- Cargo.lock | 26 +++++++++---------- .../Cargo.toml | 4 +-- .../README.md | 2 +- .../src/bin/luafmt.rs} | 2 +- .../src/cmd_args.rs | 0 .../src/format/formatter_context.rs | 0 .../src/format/mod.rs | 0 .../src/format/syntax_node_change.rs | 0 .../src/lib.rs | 0 .../src/style_ruler/basic_space.rs | 0 .../src/style_ruler/mod.rs | 0 .../src/styles/lua_indent.rs | 0 .../src/styles/mod.rs | 0 .../src/test/mod.rs | 0 14 files changed, 17 insertions(+), 17 deletions(-) rename crates/{emmylua_code_style => emmylua_formatter}/Cargo.toml (88%) rename crates/{emmylua_code_style => emmylua_formatter}/README.md (86%) rename crates/{emmylua_code_style/src/bin/emmylua_format.rs => emmylua_formatter/src/bin/luafmt.rs} (98%) rename crates/{emmylua_code_style => emmylua_formatter}/src/cmd_args.rs (100%) rename crates/{emmylua_code_style => emmylua_formatter}/src/format/formatter_context.rs (100%) rename crates/{emmylua_code_style => emmylua_formatter}/src/format/mod.rs (100%) rename crates/{emmylua_code_style => emmylua_formatter}/src/format/syntax_node_change.rs (100%) rename crates/{emmylua_code_style => emmylua_formatter}/src/lib.rs (100%) rename crates/{emmylua_code_style => emmylua_formatter}/src/style_ruler/basic_space.rs (100%) rename crates/{emmylua_code_style => emmylua_formatter}/src/style_ruler/mod.rs (100%) rename crates/{emmylua_code_style => emmylua_formatter}/src/styles/lua_indent.rs (100%) rename crates/{emmylua_code_style => emmylua_formatter}/src/styles/mod.rs (100%) rename crates/{emmylua_code_style => emmylua_formatter}/src/test/mod.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index ef0d6e11e..4af65c722 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -647,19 +647,6 @@ dependencies = [ "wax", ] -[[package]] -name = "emmylua_code_style" -version = "0.1.0" -dependencies = [ - "clap", - "emmylua_parser", - "mimalloc", - "rowan", - "serde", - "serde_json", - "serde_yml", -] - [[package]] name = "emmylua_codestyle" version = "0.6.0" @@ -700,6 +687,19 @@ dependencies = [ "walkdir", ] +[[package]] +name = "emmylua_formatter" +version = "0.1.0" +dependencies = [ + "clap", + "emmylua_parser", + "mimalloc", + "rowan", + "serde", + "serde_json", + "serde_yml", +] + [[package]] name = "emmylua_ls" version = "0.21.0" diff --git a/crates/emmylua_code_style/Cargo.toml b/crates/emmylua_formatter/Cargo.toml similarity index 88% rename from crates/emmylua_code_style/Cargo.toml rename to crates/emmylua_formatter/Cargo.toml index bc60e94d4..f9fc90835 100644 --- a/crates/emmylua_code_style/Cargo.toml +++ b/crates/emmylua_formatter/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "emmylua_code_style" +name = "emmylua_formatter" version = "0.1.0" edition = "2024" @@ -19,7 +19,7 @@ workspace = true optional = true [[bin]] -name = "emmylua_format" +name = "luafmt" required-features = ["cli"] [features] diff --git a/crates/emmylua_code_style/README.md b/crates/emmylua_formatter/README.md similarity index 86% rename from crates/emmylua_code_style/README.md rename to crates/emmylua_formatter/README.md index 5eba69a8f..df42ab37e 100644 --- a/crates/emmylua_code_style/README.md +++ b/crates/emmylua_formatter/README.md @@ -1,3 +1,3 @@ -# EmmyLua Code Style +# EmmyLua Formatter Currently, this project is just a toy; I haven't fully figured out how to proceed with it. I'll research more when I have time. diff --git a/crates/emmylua_code_style/src/bin/emmylua_format.rs b/crates/emmylua_formatter/src/bin/luafmt.rs similarity index 98% rename from crates/emmylua_code_style/src/bin/emmylua_format.rs rename to crates/emmylua_formatter/src/bin/luafmt.rs index 0053cd665..169bc2563 100644 --- a/crates/emmylua_code_style/src/bin/emmylua_format.rs +++ b/crates/emmylua_formatter/src/bin/luafmt.rs @@ -6,7 +6,7 @@ use std::{ }; use clap::Parser; -use emmylua_code_style::{LuaCodeStyle, cmd_args, reformat_lua_code}; +use emmylua_formatter::{LuaCodeStyle, cmd_args, reformat_lua_code}; #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; diff --git a/crates/emmylua_code_style/src/cmd_args.rs b/crates/emmylua_formatter/src/cmd_args.rs similarity index 100% rename from crates/emmylua_code_style/src/cmd_args.rs rename to crates/emmylua_formatter/src/cmd_args.rs diff --git a/crates/emmylua_code_style/src/format/formatter_context.rs b/crates/emmylua_formatter/src/format/formatter_context.rs similarity index 100% rename from crates/emmylua_code_style/src/format/formatter_context.rs rename to crates/emmylua_formatter/src/format/formatter_context.rs diff --git a/crates/emmylua_code_style/src/format/mod.rs b/crates/emmylua_formatter/src/format/mod.rs similarity index 100% rename from crates/emmylua_code_style/src/format/mod.rs rename to crates/emmylua_formatter/src/format/mod.rs diff --git a/crates/emmylua_code_style/src/format/syntax_node_change.rs b/crates/emmylua_formatter/src/format/syntax_node_change.rs similarity index 100% rename from crates/emmylua_code_style/src/format/syntax_node_change.rs rename to crates/emmylua_formatter/src/format/syntax_node_change.rs diff --git a/crates/emmylua_code_style/src/lib.rs b/crates/emmylua_formatter/src/lib.rs similarity index 100% rename from crates/emmylua_code_style/src/lib.rs rename to crates/emmylua_formatter/src/lib.rs diff --git a/crates/emmylua_code_style/src/style_ruler/basic_space.rs b/crates/emmylua_formatter/src/style_ruler/basic_space.rs similarity index 100% rename from crates/emmylua_code_style/src/style_ruler/basic_space.rs rename to crates/emmylua_formatter/src/style_ruler/basic_space.rs diff --git a/crates/emmylua_code_style/src/style_ruler/mod.rs b/crates/emmylua_formatter/src/style_ruler/mod.rs similarity index 100% rename from crates/emmylua_code_style/src/style_ruler/mod.rs rename to crates/emmylua_formatter/src/style_ruler/mod.rs diff --git a/crates/emmylua_code_style/src/styles/lua_indent.rs b/crates/emmylua_formatter/src/styles/lua_indent.rs similarity index 100% rename from crates/emmylua_code_style/src/styles/lua_indent.rs rename to crates/emmylua_formatter/src/styles/lua_indent.rs diff --git a/crates/emmylua_code_style/src/styles/mod.rs b/crates/emmylua_formatter/src/styles/mod.rs similarity index 100% rename from crates/emmylua_code_style/src/styles/mod.rs rename to crates/emmylua_formatter/src/styles/mod.rs diff --git a/crates/emmylua_code_style/src/test/mod.rs b/crates/emmylua_formatter/src/test/mod.rs similarity index 100% rename from crates/emmylua_code_style/src/test/mod.rs rename to crates/emmylua_formatter/src/test/mod.rs From 44028c27bee12477e2c396455ef99fb3973352b8 Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Tue, 10 Mar 2026 19:15:34 +0800 Subject: [PATCH 02/10] basic format work --- Cargo.lock | 1 + crates/emmylua_formatter/Cargo.toml | 1 + crates/emmylua_formatter/src/bin/luafmt.rs | 6 +- crates/emmylua_formatter/src/cmd_args.rs | 29 +- crates/emmylua_formatter/src/config/mod.rs | 116 +++ .../src/format/formatter_context.rs | 43 - crates/emmylua_formatter/src/format/mod.rs | 137 --- .../src/format/syntax_node_change.rs | 15 - .../emmylua_formatter/src/formatter/block.rs | 186 ++++ .../src/formatter/comment.rs | 94 ++ .../src/formatter/expression.rs | 893 ++++++++++++++++++ crates/emmylua_formatter/src/formatter/mod.rs | 39 + .../src/formatter/statement.rs | 746 +++++++++++++++ .../emmylua_formatter/src/formatter/trivia.rs | 29 + crates/emmylua_formatter/src/ir/builder.rs | 109 +++ crates/emmylua_formatter/src/ir/doc_ir.rs | 93 ++ crates/emmylua_formatter/src/ir/mod.rs | 5 + crates/emmylua_formatter/src/lib.rs | 52 +- .../src/printer/alignment.rs | 111 +++ crates/emmylua_formatter/src/printer/mod.rs | 386 ++++++++ crates/emmylua_formatter/src/printer/test.rs | 79 ++ .../src/style_ruler/basic_space.rs | 156 --- .../emmylua_formatter/src/style_ruler/mod.rs | 17 - .../src/styles/lua_indent.rs | 15 - crates/emmylua_formatter/src/styles/mod.rs | 12 - .../src/test/breaking_tests.rs | 63 ++ .../src/test/comment_tests.rs | 350 +++++++ .../src/test/config_tests.rs | 212 +++++ .../src/test/expression_tests.rs | 110 +++ .../emmylua_formatter/src/test/misc_tests.rs | 158 ++++ crates/emmylua_formatter/src/test/mod.rs | 26 +- .../src/test/statement_tests.rs | 386 ++++++++ .../emmylua_formatter/src/test/test_helper.rs | 48 + 33 files changed, 4275 insertions(+), 448 deletions(-) create mode 100644 crates/emmylua_formatter/src/config/mod.rs delete mode 100644 crates/emmylua_formatter/src/format/formatter_context.rs delete mode 100644 crates/emmylua_formatter/src/format/mod.rs delete mode 100644 crates/emmylua_formatter/src/format/syntax_node_change.rs create mode 100644 crates/emmylua_formatter/src/formatter/block.rs create mode 100644 crates/emmylua_formatter/src/formatter/comment.rs create mode 100644 crates/emmylua_formatter/src/formatter/expression.rs create mode 100644 crates/emmylua_formatter/src/formatter/mod.rs create mode 100644 crates/emmylua_formatter/src/formatter/statement.rs create mode 100644 crates/emmylua_formatter/src/formatter/trivia.rs create mode 100644 crates/emmylua_formatter/src/ir/builder.rs create mode 100644 crates/emmylua_formatter/src/ir/doc_ir.rs create mode 100644 crates/emmylua_formatter/src/ir/mod.rs create mode 100644 crates/emmylua_formatter/src/printer/alignment.rs create mode 100644 crates/emmylua_formatter/src/printer/mod.rs create mode 100644 crates/emmylua_formatter/src/printer/test.rs delete mode 100644 crates/emmylua_formatter/src/style_ruler/basic_space.rs delete mode 100644 crates/emmylua_formatter/src/style_ruler/mod.rs delete mode 100644 crates/emmylua_formatter/src/styles/lua_indent.rs delete mode 100644 crates/emmylua_formatter/src/styles/mod.rs create mode 100644 crates/emmylua_formatter/src/test/breaking_tests.rs create mode 100644 crates/emmylua_formatter/src/test/comment_tests.rs create mode 100644 crates/emmylua_formatter/src/test/config_tests.rs create mode 100644 crates/emmylua_formatter/src/test/expression_tests.rs create mode 100644 crates/emmylua_formatter/src/test/misc_tests.rs create mode 100644 crates/emmylua_formatter/src/test/statement_tests.rs create mode 100644 crates/emmylua_formatter/src/test/test_helper.rs diff --git a/Cargo.lock b/Cargo.lock index 4af65c722..3529cdbb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -698,6 +698,7 @@ dependencies = [ "serde", "serde_json", "serde_yml", + "smol_str", ] [[package]] diff --git a/crates/emmylua_formatter/Cargo.toml b/crates/emmylua_formatter/Cargo.toml index f9fc90835..6c3fb06fe 100644 --- a/crates/emmylua_formatter/Cargo.toml +++ b/crates/emmylua_formatter/Cargo.toml @@ -9,6 +9,7 @@ emmylua_parser.workspace = true rowan.workspace = true serde_json.workspace = true serde_yml.workspace = true +smol_str.workspace = true [dependencies.clap] workspace = true diff --git a/crates/emmylua_formatter/src/bin/luafmt.rs b/crates/emmylua_formatter/src/bin/luafmt.rs index 169bc2563..cb83bcad2 100644 --- a/crates/emmylua_formatter/src/bin/luafmt.rs +++ b/crates/emmylua_formatter/src/bin/luafmt.rs @@ -6,7 +6,7 @@ use std::{ }; use clap::Parser; -use emmylua_formatter::{LuaCodeStyle, cmd_args, reformat_lua_code}; +use emmylua_formatter::{LuaFormatConfig, cmd_args, reformat_lua_code}; #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; @@ -17,14 +17,14 @@ fn read_stdin_to_string() -> io::Result { Ok(s) } -fn format_content(content: &str, style: &LuaCodeStyle) -> String { +fn format_content(content: &str, style: &LuaFormatConfig) -> String { reformat_lua_code(content, style) } #[allow(unused)] fn process_file( path: &PathBuf, - style: &LuaCodeStyle, + style: &LuaFormatConfig, write: bool, list_diff: bool, ) -> io::Result<(bool, Option)> { diff --git a/crates/emmylua_formatter/src/cmd_args.rs b/crates/emmylua_formatter/src/cmd_args.rs index 05527063e..cc18a0756 100644 --- a/crates/emmylua_formatter/src/cmd_args.rs +++ b/crates/emmylua_formatter/src/cmd_args.rs @@ -2,7 +2,7 @@ use std::{fs, path::PathBuf}; use clap::{ArgGroup, Parser}; -use crate::styles::{LuaCodeStyle, LuaIndent}; +use crate::config::{IndentStyle, LuaFormatConfig}; #[derive(Debug, Clone, Parser)] #[command( @@ -58,38 +58,39 @@ pub struct CliArgs { pub max_line_width: Option, } -pub fn resolve_style(args: &CliArgs) -> Result { +pub fn resolve_style(args: &CliArgs) -> Result { let mut style = if let Some(cfg) = &args.config { let content = fs::read_to_string(cfg) - .map_err(|e| format!("读取配置失败: {}: {e}", cfg.to_string_lossy()))?; + .map_err(|e| format!("failed to read config: {}: {e}", cfg.to_string_lossy()))?; let ext = cfg .extension() .and_then(|s| s.to_str()) .map(|s| s.to_ascii_lowercase()) .unwrap_or_default(); match ext.as_str() { - "json" => serde_json::from_str::(&content) - .map_err(|e| format!("解析 JSON 配置失败: {e}"))?, - "yml" | "yaml" => serde_yml::from_str::(&content) - .map_err(|e| format!("解析 YAML 配置失败: {e}"))?, + "json" => serde_json::from_str::(&content) + .map_err(|e| format!("failed to parse JSON config: {e}"))?, + "yml" | "yaml" => serde_yml::from_str::(&content) + .map_err(|e| format!("failed to parse YAML config: {e}"))?, _ => { // Unknown extension, try JSON first then YAML - match serde_json::from_str::(&content) { + match serde_json::from_str::(&content) { Ok(v) => v, - Err(_) => serde_yml::from_str::(&content) - .map_err(|e| format!("未知扩展名,按 JSON/YAML 解析均失败: {e}"))?, + Err(_) => serde_yml::from_str::(&content).map_err(|e| { + format!("unknown extension, failed to parse as JSON/YAML: {e}") + })?, } } } } else { - LuaCodeStyle::default() + LuaFormatConfig::default() }; // Indent overrides match (args.tab, args.spaces) { - (true, Some(_)) => return Err("--tab 与 --spaces 不能同时使用".into()), - (true, None) => style.indent = LuaIndent::Tab, - (false, Some(n)) => style.indent = LuaIndent::Space(n), + (true, Some(_)) => return Err("--tab and --spaces are mutually exclusive".into()), + (true, None) => style.indent_style = IndentStyle::Tab, + (false, Some(n)) => style.indent_style = IndentStyle::Space(n), _ => {} } diff --git a/crates/emmylua_formatter/src/config/mod.rs b/crates/emmylua_formatter/src/config/mod.rs new file mode 100644 index 000000000..827b15890 --- /dev/null +++ b/crates/emmylua_formatter/src/config/mod.rs @@ -0,0 +1,116 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct LuaFormatConfig { + // ===== Indentation ===== + pub indent_style: IndentStyle, + pub tab_width: usize, + + // ===== Line width ===== + pub max_line_width: usize, + + // ===== Blank lines ===== + pub max_blank_lines: usize, + + // ===== Trailing ===== + pub insert_final_newline: bool, + pub trailing_comma: TrailingComma, + + // ===== Spacing ===== + pub space_before_call_paren: bool, + pub space_before_func_paren: bool, + pub space_inside_braces: bool, + pub space_inside_parens: bool, + pub space_inside_brackets: bool, + + // ===== End of line ===== + pub end_of_line: EndOfLine, + + // ===== Line break style ===== + pub table_expand: ExpandStrategy, + pub call_args_expand: ExpandStrategy, + pub func_params_expand: ExpandStrategy, + + // ===== Alignment ===== + /// Align trailing comments on consecutive lines + pub align_continuous_line_comment: bool, + /// Align `=` signs in consecutive assignment statements + pub align_continuous_assign_statement: bool, + /// Align `=` signs in table fields + pub align_table_field: bool, +} + +impl Default for LuaFormatConfig { + fn default() -> Self { + Self { + indent_style: IndentStyle::Space(4), + tab_width: 4, + max_line_width: 120, + max_blank_lines: 1, + insert_final_newline: true, + trailing_comma: TrailingComma::Never, + space_before_call_paren: false, + space_before_func_paren: false, + space_inside_braces: true, + space_inside_parens: false, + space_inside_brackets: false, + table_expand: ExpandStrategy::Auto, + call_args_expand: ExpandStrategy::Auto, + func_params_expand: ExpandStrategy::Auto, + end_of_line: EndOfLine::LF, + align_continuous_line_comment: true, + align_continuous_assign_statement: true, + align_table_field: true, + } + } +} + +impl LuaFormatConfig { + pub fn indent_width(&self) -> usize { + match &self.indent_style { + IndentStyle::Tab => self.tab_width, + IndentStyle::Space(n) => *n, + } + } + + pub fn indent_str(&self) -> String { + match &self.indent_style { + IndentStyle::Tab => "\t".to_string(), + IndentStyle::Space(n) => " ".repeat(*n), + } + } + + pub fn newline_str(&self) -> &'static str { + match &self.end_of_line { + EndOfLine::LF => "\n", + EndOfLine::CRLF => "\r\n", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum IndentStyle { + Tab, + Space(usize), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum TrailingComma { + Never, + Multiline, + Always, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ExpandStrategy { + Never, + Always, + Auto, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum EndOfLine { + LF, + CRLF, +} diff --git a/crates/emmylua_formatter/src/format/formatter_context.rs b/crates/emmylua_formatter/src/format/formatter_context.rs deleted file mode 100644 index cc9bfa725..000000000 --- a/crates/emmylua_formatter/src/format/formatter_context.rs +++ /dev/null @@ -1,43 +0,0 @@ -use crate::format::TokenExpected; - -#[derive(Debug)] -pub struct FormatterContext { - pub current_expected: Option, - pub is_line_first_token: bool, - pub text: String, -} - -impl FormatterContext { - pub fn new() -> Self { - Self { - current_expected: None, - is_line_first_token: true, - text: String::new(), - } - } - - pub fn reset_whitespace(&mut self) { - while self.text.ends_with(' ') { - self.text.pop(); - } - } - - pub fn get_last_whitespace_count(&self) -> usize { - let mut count = 0; - for ch in self.text.chars().rev() { - if ch == ' ' { - count += 1; - } else { - break; - } - } - count - } - - pub fn reset_whitespace_to(&mut self, n: usize) { - self.reset_whitespace(); - if n > 0 { - self.text.push_str(&" ".repeat(n)); - } - } -} diff --git a/crates/emmylua_formatter/src/format/mod.rs b/crates/emmylua_formatter/src/format/mod.rs deleted file mode 100644 index 02b855af8..000000000 --- a/crates/emmylua_formatter/src/format/mod.rs +++ /dev/null @@ -1,137 +0,0 @@ -mod formatter_context; -mod syntax_node_change; - -use std::collections::HashMap; - -use emmylua_parser::{LuaAst, LuaAstNode, LuaSyntaxId, LuaTokenKind}; -use rowan::NodeOrToken; - -use crate::format::formatter_context::FormatterContext; -pub use crate::format::syntax_node_change::{TokenExpected, TokenNodeChange}; - -#[allow(unused)] -#[derive(Debug)] -pub struct LuaFormatter { - root: LuaAst, - token_changes: HashMap, - token_left_expected: HashMap, - token_right_expected: HashMap, -} - -#[allow(unused)] -impl LuaFormatter { - pub fn new(root: LuaAst) -> Self { - Self { - root, - token_changes: HashMap::new(), - token_left_expected: HashMap::new(), - token_right_expected: HashMap::new(), - } - } - - pub fn add_token_change(&mut self, syntax_id: LuaSyntaxId, change: TokenNodeChange) { - self.token_changes.insert(syntax_id, change); - } - - pub fn add_token_left_expected(&mut self, syntax_id: LuaSyntaxId, expected: TokenExpected) { - self.token_left_expected.insert(syntax_id, expected); - } - - pub fn add_token_right_expected(&mut self, syntax_id: LuaSyntaxId, expected: TokenExpected) { - self.token_right_expected.insert(syntax_id, expected); - } - - pub fn get_token_change(&self, syntax_id: &LuaSyntaxId) -> Option<&TokenNodeChange> { - self.token_changes.get(syntax_id) - } - - pub fn get_root(&self) -> LuaAst { - self.root.clone() - } - - pub fn get_formatted_text(&self) -> String { - let mut context = FormatterContext::new(); - for node_or_token in self.root.syntax().descendants_with_tokens() { - if let NodeOrToken::Token(token) = node_or_token { - let token_kind = token.kind().to_token(); - match (context.current_expected.take(), token_kind) { - (Some(TokenExpected::Space(n)), LuaTokenKind::TkWhitespace) => { - if !context.is_line_first_token { - context.text.push_str(&" ".repeat(n)); - continue; - } - } - (Some(TokenExpected::MaxSpace(n)), LuaTokenKind::TkWhitespace) => { - if !context.is_line_first_token { - let white_space_len = token.text().chars().count(); - if white_space_len > n { - context.reset_whitespace_to(n); - continue; - } - } - } - (_, LuaTokenKind::TkEndOfLine) => { - // No space expected - context.reset_whitespace(); - context.text.push('\n'); - context.is_line_first_token = true; - continue; - } - (Some(TokenExpected::Space(n)), _) => { - if !context.is_line_first_token { - context.text.push_str(&" ".repeat(n)); - } - } - _ => {} - } - - let syntax_id = LuaSyntaxId::from_token(&token); - if let Some(expected) = self.token_left_expected.get(&syntax_id) { - match expected { - TokenExpected::Space(n) => { - if !context.is_line_first_token { - context.reset_whitespace(); - context.text.push_str(&" ".repeat(*n)); - } - } - TokenExpected::MaxSpace(n) => { - if !context.is_line_first_token { - let current_spaces = context.get_last_whitespace_count(); - if current_spaces > *n { - context.reset_whitespace_to(*n); - } - } - } - } - } - - if token_kind != LuaTokenKind::TkWhitespace { - context.is_line_first_token = false; - } - - if let Some(change) = self.token_changes.get(&syntax_id) { - match change { - TokenNodeChange::Remove => continue, - TokenNodeChange::AddLeft(s) => { - context.text.push_str(s); - context.text.push_str(token.text()); - } - TokenNodeChange::AddRight(s) => { - context.text.push_str(token.text()); - context.text.push_str(s); - } - TokenNodeChange::ReplaceWith(s) => { - context.text.push_str(s); - } - } - } else { - context.text.push_str(token.text()); - } - - context.current_expected = self.token_right_expected.get(&syntax_id).cloned(); - } - } - - context.text - } -} diff --git a/crates/emmylua_formatter/src/format/syntax_node_change.rs b/crates/emmylua_formatter/src/format/syntax_node_change.rs deleted file mode 100644 index 902da67dc..000000000 --- a/crates/emmylua_formatter/src/format/syntax_node_change.rs +++ /dev/null @@ -1,15 +0,0 @@ -#[derive(Debug)] -#[allow(unused)] -pub enum TokenNodeChange { - Remove, - AddLeft(String), - AddRight(String), - ReplaceWith(String), -} - -#[allow(unused)] -#[derive(Debug, Clone, Copy)] -pub enum TokenExpected { - Space(usize), - MaxSpace(usize), -} diff --git a/crates/emmylua_formatter/src/formatter/block.rs b/crates/emmylua_formatter/src/formatter/block.rs new file mode 100644 index 000000000..265a0d340 --- /dev/null +++ b/crates/emmylua_formatter/src/formatter/block.rs @@ -0,0 +1,186 @@ +use emmylua_parser::{ + LuaAstNode, LuaBlock, LuaComment, LuaKind, LuaStat, LuaSyntaxKind, LuaSyntaxNode, +}; +use rowan::TextRange; + +use crate::ir::{self, AlignEntry, DocIR}; + +use super::FormatContext; +use super::comment::{format_comment, format_trailing_comment}; +use super::statement::{format_stat, format_stat_eq_split, is_eq_alignable}; +use super::trivia::count_blank_lines_before; + +/// A collected block child for two-pass processing +enum BlockChild { + Comment(LuaComment), + Statement(LuaStat), +} + +impl BlockChild { + fn syntax(&self) -> &LuaSyntaxNode { + match self { + BlockChild::Comment(c) => c.syntax(), + BlockChild::Statement(s) => s.syntax(), + } + } +} + +/// Format a block (statement list + blank line normalization + comment handling). +/// +/// Iterates all child nodes of the Block (including Statements and Comments), +/// processing them in their original CST order. +/// When `=` alignment is enabled, consecutive alignable statements are grouped +/// into an AlignGroup IR node so the Printer can align their `=` signs. +pub fn format_block(ctx: &FormatContext, block: &LuaBlock) -> Vec { + // Pass 1: collect all children + let children: Vec = block + .syntax() + .children() + .filter_map(|child| match child.kind() { + LuaKind::Syntax(LuaSyntaxKind::Comment) => { + LuaComment::cast(child).map(BlockChild::Comment) + } + _ => LuaStat::cast(child).map(BlockChild::Statement), + }) + .collect(); + + // Pass 2: emit IR, grouping consecutive alignable statements + let mut docs: Vec = Vec::new(); + let mut is_first = true; + let mut consumed_comment_ranges: Vec = Vec::new(); + let mut i = 0; + + while i < children.len() { + match &children[i] { + BlockChild::Comment(comment) => { + if consumed_comment_ranges + .iter() + .any(|r| *r == comment.syntax().text_range()) + { + i += 1; + continue; + } + + if !is_first { + let blank_lines = count_blank_lines_before(comment.syntax()); + let normalized = blank_lines.min(ctx.config.max_blank_lines); + for _ in 0..normalized { + docs.push(ir::hard_line()); + } + } + + docs.extend(format_comment(comment)); + + if !is_first || !docs.is_empty() { + docs.push(ir::hard_line()); + } + is_first = false; + i += 1; + } + BlockChild::Statement(stat) => { + // Try to form an alignment group if enabled + if ctx.config.align_continuous_assign_statement && is_eq_alignable(stat) { + let group_start = i; + let mut group_end = i + 1; + + // Scan forward for consecutive alignable statements (no blank lines between). + // Skip interleaved Comment children (they're trailing comments consumed later). + while group_end < children.len() { + match &children[group_end] { + BlockChild::Statement(next_stat) => { + if is_eq_alignable(next_stat) { + let blank_lines = count_blank_lines_before(next_stat.syntax()); + if blank_lines == 0 { + group_end += 1; + continue; + } + } + break; + } + BlockChild::Comment(_) => { + // Skip trailing comment nodes when scanning for alignment group + group_end += 1; + continue; + } + } + } + + if group_end - group_start >= 2 { + // Emit alignment group + if !is_first { + let blank_lines = + count_blank_lines_before(children[group_start].syntax()); + let normalized = blank_lines.min(ctx.config.max_blank_lines); + for _ in 0..normalized { + docs.push(ir::hard_line()); + } + } + + let mut entries = Vec::new(); + for child in children.iter().take(group_end).skip(group_start) { + if let BlockChild::Statement(s) = child { + if let Some((before, after)) = format_stat_eq_split(ctx, s) { + entries.push(AlignEntry::Aligned { before, after }); + } else { + entries.push(AlignEntry::Line(format_stat(ctx, s))); + } + // Handle trailing comment (as LineSuffix on the last doc) + if let Some((trailing_ir, range)) = + format_trailing_comment(s.syntax()) + { + // Attach trailing comment to the last entry + match entries.last_mut() { + Some(AlignEntry::Aligned { after, .. }) => { + after.push(trailing_ir); + } + Some(AlignEntry::Line(content)) => { + content.push(trailing_ir); + } + None => {} + } + consumed_comment_ranges.push(range); + } + } + } + + docs.push(ir::align_group(entries)); + docs.push(ir::hard_line()); + is_first = false; + i = group_end; + continue; + } + } + + // Normal (non-aligned) statement + if !is_first { + let blank_lines = count_blank_lines_before(stat.syntax()); + let normalized = blank_lines.min(ctx.config.max_blank_lines); + for _ in 0..normalized { + docs.push(ir::hard_line()); + } + } + + let stat_docs = format_stat(ctx, stat); + docs.extend(stat_docs); + + if let Some((trailing_ir, range)) = format_trailing_comment(stat.syntax()) { + docs.push(trailing_ir); + consumed_comment_ranges.push(range); + } + + if !is_first || !docs.is_empty() { + docs.push(ir::hard_line()); + } + is_first = false; + i += 1; + } + } + } + + // Remove trailing excess HardLines + while matches!(docs.last(), Some(DocIR::HardLine)) { + docs.pop(); + } + + docs +} diff --git a/crates/emmylua_formatter/src/formatter/comment.rs b/crates/emmylua_formatter/src/formatter/comment.rs new file mode 100644 index 000000000..2ce08383e --- /dev/null +++ b/crates/emmylua_formatter/src/formatter/comment.rs @@ -0,0 +1,94 @@ +use emmylua_parser::{LuaAstNode, LuaComment, LuaKind, LuaSyntaxKind, LuaSyntaxNode, LuaTokenKind}; +use rowan::TextRange; + +use crate::ir::{self, DocIR}; + +/// Format a Comment node. +/// +/// Comment is a syntax node in the CST (LuaSyntaxKind::Comment), +/// which can be a single-line comment (`-- ...`) or a multi-line comment (`--[[ ... ]]`). +/// We preserve the original comment text and only handle indentation (managed by Printer's indent). +pub fn format_comment(comment: &LuaComment) -> Vec { + let text = comment.syntax().text().to_string(); + let text = text.trim_end(); + + // Multi-line comment: split by lines, each line as a Text + HardLine + let lines: Vec<&str> = text.lines().collect(); + + if lines.len() <= 1 { + // Single-line comment + return vec![ir::text(text)]; + } + + // Multi-line content (doc comments or --[[ ]] block comments) + let mut docs = Vec::new(); + for (i, line) in lines.iter().enumerate() { + if i > 0 { + docs.push(ir::hard_line()); + } + let trimmed = line.trim_start(); + if trimmed.is_empty() { + // Preserve empty lines + continue; + } + docs.push(ir::text(trimmed)); + } + + docs +} + +/// Collect "orphan" comments in a syntax node. +/// +/// When a Block is empty (e.g. `if x then -- comment end`), +/// comments may become direct children of the parent statement node rather than the Block. +/// This function collects those comments and returns the formatted IR. +pub fn collect_orphan_comments(node: &LuaSyntaxNode) -> Vec { + let mut docs = Vec::new(); + for child in node.children() { + if child.kind() == LuaKind::Syntax(LuaSyntaxKind::Comment) + && let Some(comment) = LuaComment::cast(child) + { + if !docs.is_empty() { + docs.push(ir::hard_line()); + } + docs.extend(format_comment(&comment)); + } + } + docs +} +/// +/// Find a Comment node on the same line after a statement node; +/// if found, attach it to the end of line using LineSuffix. +pub fn format_trailing_comment(node: &LuaSyntaxNode) -> Option<(DocIR, TextRange)> { + let mut next = node.next_sibling_or_token(); + + // Look ahead at most 4 elements (skipping whitespace, commas, semicolons) + for _ in 0..4 { + let sibling = next.as_ref()?; + match sibling.kind() { + LuaKind::Token(LuaTokenKind::TkWhitespace) => {} + LuaKind::Token(LuaTokenKind::TkSemicolon) => {} + LuaKind::Token(LuaTokenKind::TkComma) => {} + LuaKind::Syntax(LuaSyntaxKind::Comment) => { + let comment_node = sibling.as_node()?; + let comment_text = comment_node.text().to_string(); + let comment_text = comment_text.trim_end().to_string(); + + // Only single-line comments are treated as trailing comments + if comment_text.contains('\n') { + return None; + } + + let range = comment_node.text_range(); + return Some(( + ir::line_suffix(vec![ir::space(), ir::text(comment_text)]), + range, + )); + } + _ => return None, + } + next = sibling.next_sibling_or_token(); + } + + None +} diff --git a/crates/emmylua_formatter/src/formatter/expression.rs b/crates/emmylua_formatter/src/formatter/expression.rs new file mode 100644 index 000000000..075aa8894 --- /dev/null +++ b/crates/emmylua_formatter/src/formatter/expression.rs @@ -0,0 +1,893 @@ +use emmylua_parser::{ + LuaAstNode, LuaAstToken, LuaBinaryExpr, LuaCallExpr, LuaClosureExpr, LuaComment, LuaExpr, + LuaIndexExpr, LuaKind, LuaLiteralExpr, LuaNameExpr, LuaParenExpr, LuaSyntaxKind, LuaTableExpr, + LuaTableField, LuaUnaryExpr, UnaryOperator, +}; +use rowan::TextRange; + +use crate::config::ExpandStrategy; +use crate::ir::{self, AlignEntry, DocIR, EqSplit}; + +use super::FormatContext; +use super::comment::{format_comment, format_trailing_comment}; + +/// 格式化表达式(分派) +pub fn format_expr(ctx: &FormatContext, expr: &LuaExpr) -> Vec { + match expr { + LuaExpr::NameExpr(e) => format_name_expr(ctx, e), + LuaExpr::LiteralExpr(e) => format_literal_expr(ctx, e), + LuaExpr::BinaryExpr(e) => format_binary_expr(ctx, e), + LuaExpr::UnaryExpr(e) => format_unary_expr(ctx, e), + LuaExpr::CallExpr(e) => format_call_expr(ctx, e), + LuaExpr::IndexExpr(e) => format_index_expr(ctx, e), + LuaExpr::TableExpr(e) => format_table_expr(ctx, e), + LuaExpr::ClosureExpr(e) => format_closure_expr(ctx, e), + LuaExpr::ParenExpr(e) => format_paren_expr(ctx, e), + } +} + +/// 标识符: name +fn format_name_expr(_ctx: &FormatContext, expr: &LuaNameExpr) -> Vec { + if let Some(name) = expr.get_name_text() { + vec![ir::text(name)] + } else { + vec![] + } +} + +/// 字面量: 1, "hello", true, nil, ... +fn format_literal_expr(_ctx: &FormatContext, expr: &LuaLiteralExpr) -> Vec { + // 直接使用原始文本 + vec![ir::text(expr.syntax().text().to_string())] +} + +/// 二元表达式: a + b, a and b, ... +/// +/// 当表达式太长时,在操作符前断行并缩进: +/// ```text +/// very_long_left +/// + right +/// ``` +fn format_binary_expr(ctx: &FormatContext, expr: &LuaBinaryExpr) -> Vec { + if let Some((left, right)) = expr.get_exprs() { + let left_docs = format_expr(ctx, &left); + let right_docs = format_expr(ctx, &right); + + if let Some(op_token) = expr.get_op_token() { + let op_text = op_token.syntax().text().to_string(); + + return vec![ir::group(vec![ + ir::list(left_docs), + ir::indent(vec![ + ir::soft_line(), + ir::text(op_text), + ir::space(), + ir::list(right_docs), + ]), + ])]; + } + } + + vec![] +} + +/// 一元表达式: -x, not x, #t, ~x +fn format_unary_expr(ctx: &FormatContext, expr: &LuaUnaryExpr) -> Vec { + let mut docs = Vec::new(); + + if let Some(op_token) = expr.get_op_token() { + let op = op_token.get_op(); + let op_text = op_token.syntax().text().to_string(); + docs.push(ir::text(op_text)); + + // `not` 和 `-`(作为关键字的)后面需要空格,`#` 和 `~` 不需要 + match op { + UnaryOperator::OpNot => docs.push(ir::space()), + UnaryOperator::OpUnm | UnaryOperator::OpLen | UnaryOperator::OpBNot => {} + UnaryOperator::OpNop => {} + } + } + + if let Some(inner) = expr.get_expr() { + docs.extend(format_expr(ctx, &inner)); + } + + docs +} + +/// 函数调用: f(a, b), obj:m(a), f "hello", f { ... } +fn format_call_expr(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { + // 尝试方法链格式化 + if let Some(chain) = try_format_chain(ctx, expr) { + return chain; + } + + let mut docs = Vec::new(); + + // 前缀(函数名/表达式) + if let Some(prefix) = expr.get_prefix_expr() { + docs.extend(format_expr(ctx, &prefix)); + } + + // 参数列表 + docs.extend(format_call_args_ir(ctx, expr)); + + docs +} + +/// 索引表达式: t.x, t:m, t[k] +fn format_index_expr(ctx: &FormatContext, expr: &LuaIndexExpr) -> Vec { + let mut docs = Vec::new(); + + // 前缀 + if let Some(prefix) = expr.get_prefix_expr() { + docs.extend(format_expr(ctx, &prefix)); + } + + // 索引操作符和 key + docs.extend(format_index_access_ir(ctx, expr)); + + docs +} + +/// 格式化调用参数部分(不含前缀),如 `(a, b)` 或单参数简写 ` "str"` / ` { ... }` +fn format_call_args_ir(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { + let mut docs = Vec::new(); + + if let Some(args_list) = expr.get_args_list() { + // 单参数简写 + if args_list.is_single_arg_no_parens() + && let Some(single_arg) = args_list.get_single_arg_expr() + { + match single_arg { + emmylua_parser::LuaSingleArgExpr::TableExpr(table) => { + docs.push(ir::space()); + docs.extend(format_table_expr(ctx, &table)); + return docs; + } + emmylua_parser::LuaSingleArgExpr::LiteralExpr(lit) => { + docs.push(ir::space()); + docs.extend(format_literal_expr(ctx, &lit)); + return docs; + } + } + } + + let args: Vec<_> = args_list.get_args().collect(); + + if ctx.config.space_before_call_paren { + docs.push(ir::space()); + } + + if args.is_empty() { + docs.push(ir::text("(")); + docs.push(ir::text(")")); + } else { + let arg_docs: Vec> = args.iter().map(|a| format_expr(ctx, a)).collect(); + let trailing = format_trailing_comma_ir(ctx.config.trailing_comma.clone()); + + match ctx.config.call_args_expand { + ExpandStrategy::Always => { + let inner = ir::intersperse(arg_docs, vec![ir::text(","), ir::soft_line()]); + docs.push(ir::group_break(vec![ + ir::text("("), + ir::indent(vec![ir::hard_line(), ir::list(inner), trailing]), + ir::hard_line(), + ir::text(")"), + ])); + } + ExpandStrategy::Never => { + let flat_inner = ir::intersperse(arg_docs, vec![ir::text(","), ir::space()]); + docs.push(ir::text("(")); + docs.push(ir::list(flat_inner)); + docs.push(ir::text(")")); + } + ExpandStrategy::Auto => { + let inner = ir::intersperse(arg_docs, vec![ir::text(","), ir::soft_line()]); + docs.push(ir::group(vec![ + ir::text("("), + ir::indent(vec![ir::soft_line_or_empty(), ir::list(inner), trailing]), + ir::soft_line_or_empty(), + ir::text(")"), + ])); + } + } + } + } + + docs +} + +/// 格式化索引访问部分(不含前缀),如 `.x`、`:m`、`[k]` +fn format_index_access_ir(ctx: &FormatContext, expr: &LuaIndexExpr) -> Vec { + let mut docs = Vec::new(); + + if let Some(index_token) = expr.get_index_token() { + if index_token.is_dot() { + docs.push(ir::text(".")); + if let Some(key) = expr.get_index_key() { + docs.push(ir::text(key.get_path_part())); + } + } else if index_token.is_colon() { + docs.push(ir::text(":")); + if let Some(key) = expr.get_index_key() { + docs.push(ir::text(key.get_path_part())); + } + } else if index_token.is_left_bracket() { + docs.push(ir::text("[")); + if ctx.config.space_inside_brackets { + docs.push(ir::space()); + } + if let Some(key) = expr.get_index_key() { + match key { + emmylua_parser::LuaIndexKey::Expr(e) => { + docs.extend(format_expr(ctx, &e)); + } + emmylua_parser::LuaIndexKey::Integer(n) => { + docs.push(ir::text(n.syntax().text().to_string())); + } + emmylua_parser::LuaIndexKey::String(s) => { + docs.push(ir::text(s.syntax().text().to_string())); + } + emmylua_parser::LuaIndexKey::Name(name) => { + docs.push(ir::text(name.get_name_text().to_string())); + } + _ => {} + } + } + if ctx.config.space_inside_brackets { + docs.push(ir::space()); + } + docs.push(ir::text("]")); + } + } + + docs +} + +/// 尝试将方法链格式化为缩进形式 +/// +/// 对于 `a:b():c():d()` 这样的链式调用,扁平化为: +/// - 单行放得下: `a:b():c():d()` +/// - 超宽时展开: +/// ```text +/// a +/// :b() +/// :c() +/// :d() +/// ``` +/// +/// 仅在链长度 >= 2 段时触发(base + 2+ 段)。 +fn try_format_chain(ctx: &FormatContext, expr: &LuaCallExpr) -> Option> { + // 收集链段(从外向内遍历,最后翻转) + struct ChainSegment { + access: Vec, + call_args: Option>, + } + + let mut segments: Vec = Vec::new(); + let mut current: LuaExpr = expr.clone().into(); + + loop { + match ¤t { + LuaExpr::CallExpr(call) => { + let args = format_call_args_ir(ctx, call); + if let Some(prefix) = call.get_prefix_expr() + && let LuaExpr::IndexExpr(idx) = &prefix + { + let access = format_index_access_ir(ctx, idx); + segments.push(ChainSegment { + access, + call_args: Some(args), + }); + if let Some(idx_prefix) = idx.get_prefix_expr() { + current = idx_prefix; + continue; + } + } + break; + } + LuaExpr::IndexExpr(idx) => { + let access = format_index_access_ir(ctx, idx); + segments.push(ChainSegment { + access, + call_args: None, + }); + if let Some(idx_prefix) = idx.get_prefix_expr() { + current = idx_prefix; + continue; + } + break; + } + _ => break, + } + } + + // 至少 2 段才使用链式格式化 + if segments.len() < 2 { + return None; + } + + segments.reverse(); + + // 基础表达式 + let base = format_expr(ctx, ¤t); + + // 构建链内容: indent(soft_line + seg1 + soft_line + seg2 + ...) + let mut chain_content = Vec::new(); + for seg in &segments { + chain_content.push(ir::soft_line_or_empty()); + chain_content.extend(seg.access.clone()); + if let Some(args) = &seg.call_args { + chain_content.extend(args.clone()); + } + } + + let mut docs = Vec::new(); + docs.extend(base); + docs.push(ir::group(vec![ir::indent(chain_content)])); + + Some(docs) +} + +/// Table literal: {}, { 1, 2, 3 }, { key = value, ... } +fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { + if expr.is_empty() { + return vec![ir::text("{}")]; + } + + // Collect all child nodes: fields and standalone comments + let mut entries: Vec = Vec::new(); + let mut consumed_comment_ranges: Vec = Vec::new(); + let mut has_standalone_comments = false; + + for child in expr.syntax().children() { + if let Some(field) = LuaTableField::cast(child.clone()) { + let fdoc = format_table_field_ir(ctx, &field); + let eq_split = if ctx.config.align_table_field { + format_table_field_eq_split(ctx, &field) + } else { + None + }; + let trailing_comment = if let Some((c, range)) = format_trailing_comment(field.syntax()) + { + consumed_comment_ranges.push(range); + Some(c) + } else { + None + }; + entries.push(TableEntry::Field { + doc: fdoc, + eq_split, + trailing_comment, + }); + } else if child.kind() == LuaKind::Syntax(LuaSyntaxKind::Comment) { + // Check if already consumed as trailing comment + if consumed_comment_ranges + .iter() + .any(|r| *r == child.text_range()) + { + continue; + } + let comment = LuaComment::cast(child).unwrap(); + entries.push(TableEntry::StandaloneComment(format_comment(&comment))); + has_standalone_comments = true; + } + } + + // Trailing comma + let trailing = format_trailing_comma_ir(ctx.config.trailing_comma.clone()); + + let space_inside = if ctx.config.space_inside_braces { + ir::soft_line() + } else { + ir::soft_line_or_empty() + }; + + // Whether any field has a trailing comment + let has_trailing_comments = entries.iter().any(|e| { + matches!( + e, + TableEntry::Field { + trailing_comment: Some(_), + .. + } + ) + }); + + // Standalone or trailing comments force expansion + let force_expand = has_standalone_comments || has_trailing_comments; + + match ctx.config.table_expand { + ExpandStrategy::Always => { + build_table_expanded(entries, trailing, true, ctx.config.align_table_field) + } + ExpandStrategy::Never if !force_expand => { + // Force single line (valid when no comments) + let field_docs: Vec> = entries + .into_iter() + .filter_map(|e| match e { + TableEntry::Field { doc, .. } => Some(doc), + TableEntry::StandaloneComment(_) => None, + }) + .collect(); + let flat_inner = ir::intersperse(field_docs, vec![ir::text(","), ir::space()]); + let mut result = vec![ir::text("{")]; + if ctx.config.space_inside_braces { + result.push(ir::space()); + } + result.push(ir::list(flat_inner)); + if ctx.config.space_inside_braces { + result.push(ir::space()); + } + result.push(ir::text("}")); + result + } + ExpandStrategy::Never => { + // Never mode but has comments — must expand + build_table_expanded(entries, trailing, true, ctx.config.align_table_field) + } + ExpandStrategy::Auto if force_expand => { + // Has comments: force expand + build_table_expanded(entries, trailing, true, ctx.config.align_table_field) + } + ExpandStrategy::Auto => { + if ctx.config.align_table_field + && entries.iter().any(|e| { + matches!( + e, + TableEntry::Field { + eq_split: Some(_), + .. + } + ) + }) + { + // Build flat content for single-line display + let flat_field_docs: Vec> = entries + .iter() + .filter_map(|e| match e { + TableEntry::Field { doc, .. } => Some(doc.clone()), + TableEntry::StandaloneComment(_) => None, + }) + .collect(); + let flat_separator = vec![ir::text(","), ir::soft_line()]; + let flat_inner = ir::intersperse(flat_field_docs, flat_separator); + let flat_doc = ir::list(vec![ + ir::text("{"), + ir::indent(vec![ + space_inside.clone(), + ir::list(flat_inner), + trailing.clone(), + ]), + space_inside.clone(), + ir::text("}"), + ]); + + // Build break content with alignment for multi-line display + let break_inner = build_table_expanded_inner(&entries, &trailing, true); + let break_doc = ir::list(vec![ + ir::text("{"), + ir::indent(break_inner), + ir::hard_line(), + ir::text("}"), + ]); + + let gid = ir::next_group_id(); + vec![ir::group_with_id( + vec![ir::if_break_with_group(break_doc, flat_doc, gid)], + gid, + )] + } else { + let field_docs: Vec> = entries + .into_iter() + .filter_map(|e| match e { + TableEntry::Field { doc, .. } => Some(doc), + TableEntry::StandaloneComment(_) => None, + }) + .collect(); + let separator = vec![ir::text(","), ir::soft_line()]; + let inner = ir::intersperse(field_docs, separator); + // Auto: single line if fits, otherwise expand + vec![ir::group(vec![ + ir::text("{"), + ir::indent(vec![space_inside.clone(), ir::list(inner), trailing]), + space_inside, + ir::text("}"), + ])] + } + } + } +} + +/// Format a single table field IR (without trailing comment) +fn format_table_field_ir(ctx: &FormatContext, field: &LuaTableField) -> Vec { + let mut fdoc = Vec::new(); + + if field.is_assign_field() { + fdoc.extend(format_table_field_key_ir(ctx, field)); + fdoc.push(ir::space()); + fdoc.push(ir::text("=")); + fdoc.push(ir::space()); + + if let Some(value) = field.get_value_expr() { + fdoc.extend(format_expr(ctx, &value)); + } + } else { + // value only + if let Some(value) = field.get_value_expr() { + fdoc.extend(format_expr(ctx, &value)); + } + } + + fdoc +} + +/// Format the key part of a table field +fn format_table_field_key_ir(ctx: &FormatContext, field: &LuaTableField) -> Vec { + let mut docs = Vec::new(); + if let Some(key) = field.get_field_key() { + match &key { + emmylua_parser::LuaIndexKey::Name(name) => { + docs.push(ir::text(name.get_name_text().to_string())); + } + emmylua_parser::LuaIndexKey::String(s) => { + docs.push(ir::text("[")); + docs.push(ir::text(s.syntax().text().to_string())); + docs.push(ir::text("]")); + } + emmylua_parser::LuaIndexKey::Integer(n) => { + docs.push(ir::text("[")); + docs.push(ir::text(n.syntax().text().to_string())); + docs.push(ir::text("]")); + } + emmylua_parser::LuaIndexKey::Expr(e) => { + docs.push(ir::text("[")); + docs.extend(format_expr(ctx, e)); + docs.push(ir::text("]")); + } + emmylua_parser::LuaIndexKey::Idx(_) => {} + } + } + docs +} + +/// Split a table field at `=` for alignment. +/// Returns (key_docs, value_docs) where value_docs starts with "=". +fn format_table_field_eq_split(ctx: &FormatContext, field: &LuaTableField) -> Option { + if !field.is_assign_field() { + return None; + } + + let before = format_table_field_key_ir(ctx, field); + if before.is_empty() { + return None; + } + + let mut after = vec![ir::text("="), ir::space()]; + if let Some(value) = field.get_value_expr() { + after.extend(format_expr(ctx, &value)); + } + + Some((before, after)) +} + +/// Table entry: field or standalone comment +enum TableEntry { + Field { + doc: Vec, + /// Split at `=` for alignment: (key_docs, eq_value_docs) + eq_split: Option, + trailing_comment: Option, + }, + StandaloneComment(Vec), +} + +/// Build inner content (entries between { and }) for an expanded table. +/// When `align_eq` is true and there are consecutive `key = value` fields, +/// they are wrapped in an AlignGroup so the Printer aligns their `=` signs. +fn build_table_expanded_inner( + entries: &[TableEntry], + trailing: &DocIR, + align_eq: bool, +) -> Vec { + let mut inner = Vec::new(); + + let last_field_idx = entries + .iter() + .rposition(|e| matches!(e, TableEntry::Field { .. })); + + if align_eq { + let len = entries.len(); + let mut i = 0; + while i < len { + if let TableEntry::Field { + eq_split: Some(_), .. + } = &entries[i] + { + let group_start = i; + let mut group_end = i + 1; + while group_end < len { + match &entries[group_end] { + TableEntry::Field { + eq_split: Some(_), .. + } => { + group_end += 1; + } + TableEntry::StandaloneComment(_) => { + group_end += 1; + } + _ => break, + } + } + + if group_end - group_start >= 2 { + inner.push(ir::hard_line()); + let mut align_entries = Vec::new(); + for (j, entry) in entries.iter().enumerate().take(group_end).skip(group_start) { + match entry { + TableEntry::Field { + eq_split: Some((before, after)), + trailing_comment, + .. + } => { + let is_last = last_field_idx == Some(j); + let mut after_with_comma = after.clone(); + if is_last { + after_with_comma.push(trailing.clone()); + } else { + after_with_comma.push(ir::text(",")); + } + if let Some(comment) = trailing_comment { + after_with_comma.push(comment.clone()); + } + align_entries.push(AlignEntry::Aligned { + before: before.clone(), + after: after_with_comma, + }); + } + TableEntry::StandaloneComment(comment_docs) => { + align_entries.push(AlignEntry::Line(comment_docs.clone())); + } + TableEntry::Field { + doc, + trailing_comment, + .. + } => { + let is_last = last_field_idx == Some(j); + let mut line = doc.clone(); + if is_last { + line.push(trailing.clone()); + } else { + line.push(ir::text(",")); + } + if let Some(comment) = trailing_comment { + line.push(comment.clone()); + } + align_entries.push(AlignEntry::Line(line)); + } + } + } + inner.push(ir::align_group(align_entries)); + i = group_end; + continue; + } + } + + match &entries[i] { + TableEntry::Field { + doc, + trailing_comment, + .. + } => { + inner.push(ir::hard_line()); + inner.extend(doc.clone()); + let is_last = last_field_idx == Some(i); + if is_last { + inner.push(trailing.clone()); + } else { + inner.push(ir::text(",")); + } + if let Some(comment) = trailing_comment { + inner.push(comment.clone()); + } + } + TableEntry::StandaloneComment(comment_docs) => { + inner.push(ir::hard_line()); + inner.extend(comment_docs.clone()); + } + } + i += 1; + } + } else { + for (i, entry) in entries.iter().enumerate() { + match entry { + TableEntry::Field { + doc, + trailing_comment, + .. + } => { + inner.push(ir::hard_line()); + inner.extend(doc.clone()); + + let is_last_field = last_field_idx == Some(i); + if is_last_field { + inner.push(trailing.clone()); + } else { + inner.push(ir::text(",")); + } + + if let Some(comment) = trailing_comment { + inner.push(comment.clone()); + } + } + TableEntry::StandaloneComment(comment_docs) => { + inner.push(ir::hard_line()); + inner.extend(comment_docs.clone()); + } + } + } + } + + inner +} + +/// Build expanded table (one field per line), wrapped in a Group. +fn build_table_expanded( + entries: Vec, + trailing: DocIR, + should_break: bool, + align_eq: bool, +) -> Vec { + let inner = build_table_expanded_inner(&entries, &trailing, align_eq); + + if should_break { + vec![ir::group_break(vec![ + ir::text("{"), + ir::indent(inner), + ir::hard_line(), + ir::text("}"), + ])] + } else { + vec![ir::group(vec![ + ir::text("{"), + ir::indent(inner), + ir::hard_line(), + ir::text("}"), + ])] + } +} + +/// 匿名函数: function(params) ... end +fn format_closure_expr(ctx: &FormatContext, expr: &LuaClosureExpr) -> Vec { + let mut docs = vec![ir::text("function")]; + + if ctx.config.space_before_func_paren { + docs.push(ir::space()); + } + + // 参数列表 + docs.push(ir::text("(")); + if let Some(params) = expr.get_params_list() { + docs.extend(format_params_ir(ctx, ¶ms)); + } + docs.push(ir::text(")")); + + // body + super::format_body_end_with_parent( + ctx, + expr.get_block().as_ref(), + Some(expr.syntax()), + &mut docs, + ); + + docs +} + +/// 括号表达式: (expr) +fn format_paren_expr(ctx: &FormatContext, expr: &LuaParenExpr) -> Vec { + let mut docs = vec![ir::text("(")]; + if ctx.config.space_inside_parens { + docs.push(ir::space()); + } + if let Some(inner) = expr.get_expr() { + docs.extend(format_expr(ctx, &inner)); + } + if ctx.config.space_inside_parens { + docs.push(ir::space()); + } + docs.push(ir::text(")")); + docs +} + +/// 根据 TrailingComma 配置生成尾逗号 IR +fn format_trailing_comma_ir(policy: crate::config::TrailingComma) -> DocIR { + use crate::config::TrailingComma; + match policy { + TrailingComma::Never => ir::list(vec![]), + TrailingComma::Multiline => ir::if_break(ir::text(","), ir::list(vec![])), + TrailingComma::Always => ir::text(","), + } +} + +/// 参数条目 +struct ParamEntry { + doc: Vec, + trailing_comment: Option, +} + +/// 格式化函数参数列表(支持参数注释) +/// +/// 当参数之间有注释时,自动强制展开为多行。 +/// 返回括号内的 IR(不含括号本身)。 +pub fn format_params_ir(ctx: &FormatContext, params: &emmylua_parser::LuaParamList) -> Vec { + // 收集参数和每个参数后的行尾注释 + let mut entries: Vec = Vec::new(); + let mut consumed_comment_ranges: Vec = Vec::new(); + + for p in params.get_params() { + let doc = if p.is_dots() { + vec![ir::text("...")] + } else if let Some(token) = p.get_name_token() { + vec![ir::text(token.get_name_text().to_string())] + } else { + continue; + }; + + let trailing_comment = if let Some((c, range)) = format_trailing_comment(p.syntax()) { + consumed_comment_ranges.push(range); + Some(c) + } else { + None + }; + + entries.push(ParamEntry { + doc, + trailing_comment, + }); + } + + if entries.is_empty() { + return vec![]; + } + + let has_comments = entries.iter().any(|e| e.trailing_comment.is_some()); + + if has_comments { + // 有注释:强制多行展开 + let len = entries.len(); + let mut inner = Vec::new(); + for (i, entry) in entries.into_iter().enumerate() { + inner.push(ir::hard_line()); + inner.extend(entry.doc); + if i < len - 1 { + inner.push(ir::text(",")); + } + if let Some(comment) = entry.trailing_comment { + inner.push(comment); + } + } + vec![ir::group_break(vec![ir::indent(inner), ir::hard_line()])] + } else { + // 无注释:使用配置的展开策略 + let param_docs: Vec> = entries.into_iter().map(|e| e.doc).collect(); + let inner = ir::intersperse(param_docs.clone(), vec![ir::text(","), ir::soft_line()]); + + match ctx.config.func_params_expand { + ExpandStrategy::Always => { + vec![ir::hard_line(), ir::indent(inner), ir::hard_line()] + } + ExpandStrategy::Never => ir::intersperse(param_docs, vec![ir::text(","), ir::space()]), + ExpandStrategy::Auto => { + vec![ir::group( + [ + vec![ir::soft_line_or_empty()], + vec![ir::indent(inner)], + vec![ir::soft_line_or_empty()], + ] + .concat(), + )] + } + } + } +} diff --git a/crates/emmylua_formatter/src/formatter/mod.rs b/crates/emmylua_formatter/src/formatter/mod.rs new file mode 100644 index 000000000..cd3cb1e69 --- /dev/null +++ b/crates/emmylua_formatter/src/formatter/mod.rs @@ -0,0 +1,39 @@ +mod block; +mod comment; +mod expression; +mod statement; +mod trivia; + +use crate::config::LuaFormatConfig; +use crate::ir::DocIR; +use emmylua_parser::LuaChunk; + +pub use block::format_block; +pub use statement::format_body_end_with_parent; + +/// Formatting context, shared throughout the formatting process +pub struct FormatContext<'a> { + pub config: &'a LuaFormatConfig, +} + +impl<'a> FormatContext<'a> { + pub fn new(config: &'a LuaFormatConfig) -> Self { + Self { config } + } +} + +/// Format a chunk (root node of the file) +pub fn format_chunk(ctx: &FormatContext, chunk: &LuaChunk) -> Vec { + let mut docs = Vec::new(); + + if let Some(block) = chunk.get_block() { + docs.extend(format_block(ctx, &block)); + } + + // Ensure file ends with a newline + if ctx.config.insert_final_newline { + docs.push(DocIR::HardLine); + } + + docs +} diff --git a/crates/emmylua_formatter/src/formatter/statement.rs b/crates/emmylua_formatter/src/formatter/statement.rs new file mode 100644 index 000000000..992ffbc6e --- /dev/null +++ b/crates/emmylua_formatter/src/formatter/statement.rs @@ -0,0 +1,746 @@ +use emmylua_parser::{ + LuaAssignStat, LuaAstNode, LuaAstToken, LuaBreakStat, LuaCallExprStat, LuaDoStat, LuaExpr, + LuaForRangeStat, LuaForStat, LuaFuncStat, LuaGlobalStat, LuaGotoStat, LuaIfStat, LuaLabelStat, + LuaLocalFuncStat, LuaLocalStat, LuaRepeatStat, LuaReturnStat, LuaStat, LuaWhileStat, +}; + +use crate::ir::{self, DocIR, EqSplit}; + +use super::FormatContext; +use super::block::format_block; +use super::comment::collect_orphan_comments; +use super::expression::format_expr; + +/// Format a statement (dispatch) +pub fn format_stat(ctx: &FormatContext, stat: &LuaStat) -> Vec { + match stat { + LuaStat::LocalStat(s) => format_local_stat(ctx, s), + LuaStat::AssignStat(s) => format_assign_stat(ctx, s), + LuaStat::CallExprStat(s) => format_call_expr_stat(ctx, s), + LuaStat::FuncStat(s) => format_func_stat(ctx, s), + LuaStat::LocalFuncStat(s) => format_local_func_stat(ctx, s), + LuaStat::IfStat(s) => format_if_stat(ctx, s), + LuaStat::WhileStat(s) => format_while_stat(ctx, s), + LuaStat::DoStat(s) => format_do_stat(ctx, s), + LuaStat::ForStat(s) => format_for_stat(ctx, s), + LuaStat::ForRangeStat(s) => format_for_range_stat(ctx, s), + LuaStat::RepeatStat(s) => format_repeat_stat(ctx, s), + LuaStat::BreakStat(s) => format_break_stat(ctx, s), + LuaStat::ReturnStat(s) => format_return_stat(ctx, s), + LuaStat::GotoStat(s) => format_goto_stat(ctx, s), + LuaStat::LabelStat(s) => format_label_stat(ctx, s), + LuaStat::EmptyStat(_) => vec![ir::text(";")], + LuaStat::GlobalStat(s) => format_global_stat(ctx, s), + } +} + +/// local name1, name2 = expr1, expr2 +/// local x = 1 +fn format_local_stat(ctx: &FormatContext, stat: &LuaLocalStat) -> Vec { + let mut docs = vec![ir::text("local"), ir::space()]; + + // Variable name list (with attributes) + let local_names: Vec<_> = stat.get_local_name_list().collect(); + + for (i, local_name) in local_names.iter().enumerate() { + if i > 0 { + docs.push(ir::text(",")); + docs.push(ir::space()); + } + if let Some(token) = local_name.get_name_token() { + docs.push(ir::text(token.get_name_text().to_string())); + } + // / attribute + if let Some(attrib) = local_name.get_attrib() { + docs.push(ir::space()); + docs.push(ir::text("<")); + if let Some(name_token) = attrib.get_name_token() { + docs.push(ir::text(name_token.get_name_text().to_string())); + } + docs.push(ir::text(">")); + } + } + + // Value list + let exprs: Vec<_> = stat.get_value_exprs().collect(); + if !exprs.is_empty() { + docs.push(ir::space()); + docs.push(ir::text("=")); + + let expr_docs: Vec> = exprs.iter().map(|e| format_expr(ctx, e)).collect(); + let separated = ir::intersperse(expr_docs, vec![ir::text(","), ir::space()]); + + // Single-value assignment to function/table: join with space, no line break + if exprs.len() == 1 && is_block_like_expr(&exprs[0]) { + docs.push(ir::space()); + docs.push(ir::list(separated)); + } else { + // When value is too long, break after = and indent + docs.push(ir::group(vec![ir::indent(vec![ + ir::soft_line(), + ir::list(separated), + ])])); + } + } + + docs +} + +/// var1, var2 = expr1, expr2 (or compound: var += expr) +fn format_assign_stat(ctx: &FormatContext, stat: &LuaAssignStat) -> Vec { + let mut docs = Vec::new(); + let (vars, exprs) = stat.get_var_and_expr_list(); + + // Variable list + let var_docs: Vec> = vars + .iter() + .map(|v| format_expr(ctx, &v.clone().into())) + .collect(); + + docs.extend(ir::intersperse(var_docs, vec![ir::text(","), ir::space()])); + + // Assignment operator + if let Some(op) = stat.get_assign_op() { + docs.push(ir::space()); + docs.push(ir::text(op.syntax().text().to_string())); + } + + // Value list + let expr_docs: Vec> = exprs.iter().map(|e| format_expr(ctx, e)).collect(); + let separated = ir::intersperse(expr_docs, vec![ir::text(","), ir::space()]); + + // Single-value assignment to function/table: join with space, no line break + if exprs.len() == 1 && is_block_like_expr(&exprs[0]) { + docs.push(ir::space()); + docs.push(ir::list(separated)); + } else { + // When value is too long, break after = and indent + docs.push(ir::group(vec![ir::indent(vec![ + ir::soft_line(), + ir::list(separated), + ])])); + } + + docs +} + +/// Function call statement f(x) +fn format_call_expr_stat(ctx: &FormatContext, stat: &LuaCallExprStat) -> Vec { + if let Some(call_expr) = stat.get_call_expr() { + format_expr(ctx, &call_expr.into()) + } else { + vec![] + } +} + +/// function name() ... end +fn format_func_stat(ctx: &FormatContext, stat: &LuaFuncStat) -> Vec { + // Compact output when function body is empty + if let Some(compact) = format_empty_func_stat(ctx, stat) { + return compact; + } + + let mut docs = vec![ir::text("function"), ir::space()]; + + if let Some(name) = stat.get_func_name() { + docs.extend(format_expr(ctx, &name.into())); + } + + if let Some(closure) = stat.get_closure() { + docs.extend(format_closure_body(ctx, &closure)); + } + + docs +} + +/// local function name() ... end +fn format_local_func_stat(ctx: &FormatContext, stat: &LuaLocalFuncStat) -> Vec { + // Compact output when function body is empty + if let Some(compact) = format_empty_local_func_stat(ctx, stat) { + return compact; + } + + let mut docs = vec![ + ir::text("local"), + ir::space(), + ir::text("function"), + ir::space(), + ]; + + if let Some(name) = stat.get_local_name() + && let Some(token) = name.get_name_token() + { + docs.push(ir::text(token.get_name_text().to_string())); + } + + if let Some(closure) = stat.get_closure() { + docs.extend(format_closure_body(ctx, &closure)); + } + + docs +} + +/// Single-line function definition: keep single-line output when body is empty +/// e.g. `function foo() end` +fn format_empty_func_stat(ctx: &FormatContext, stat: &LuaFuncStat) -> Option> { + let closure = stat.get_closure()?; + let block = closure.get_block()?; + let block_docs = format_block(ctx, &block); + if !block_docs.is_empty() { + return None; + } + + let mut docs = vec![ir::text("function"), ir::space()]; + if let Some(name) = stat.get_func_name() { + docs.extend(format_expr(ctx, &name.into())); + } + + if ctx.config.space_before_func_paren { + docs.push(ir::space()); + } + + docs.push(ir::text("(")); + if let Some(params) = closure.get_params_list() { + let mut param_docs: Vec> = Vec::new(); + for p in params.get_params() { + if p.is_dots() { + param_docs.push(vec![ir::text("...")]); + } else if let Some(token) = p.get_name_token() { + param_docs.push(vec![ir::text(token.get_name_text().to_string())]); + } + } + if !param_docs.is_empty() { + let inner = ir::intersperse(param_docs, vec![ir::text(","), ir::space()]); + docs.extend(inner); + } + } + docs.push(ir::text(")")); + docs.push(ir::space()); + docs.push(ir::text("end")); + Some(docs) +} + +/// Single-line local function: keep single-line output when body is empty +/// e.g. `local function foo() end` +fn format_empty_local_func_stat( + ctx: &FormatContext, + stat: &LuaLocalFuncStat, +) -> Option> { + let closure = stat.get_closure()?; + let block = closure.get_block()?; + let block_docs = format_block(ctx, &block); + if !block_docs.is_empty() { + return None; + } + + let mut docs = vec![ + ir::text("local"), + ir::space(), + ir::text("function"), + ir::space(), + ]; + + if let Some(name) = stat.get_local_name() + && let Some(token) = name.get_name_token() + { + docs.push(ir::text(token.get_name_text().to_string())); + } + + if ctx.config.space_before_func_paren { + docs.push(ir::space()); + } + + docs.push(ir::text("(")); + if let Some(params) = closure.get_params_list() { + let mut param_docs: Vec> = Vec::new(); + for p in params.get_params() { + if p.is_dots() { + param_docs.push(vec![ir::text("...")]); + } else if let Some(token) = p.get_name_token() { + param_docs.push(vec![ir::text(token.get_name_text().to_string())]); + } + } + if !param_docs.is_empty() { + let inner = ir::intersperse(param_docs, vec![ir::text(","), ir::space()]); + docs.extend(inner); + } + } + docs.push(ir::text(")")); + docs.push(ir::space()); + docs.push(ir::text("end")); + Some(docs) +} + +/// if cond then ... elseif cond then ... else ... end +fn format_if_stat(ctx: &FormatContext, stat: &LuaIfStat) -> Vec { + let mut docs = vec![ir::text("if"), ir::space()]; + + // if condition + if let Some(cond) = stat.get_condition_expr() { + docs.extend(format_expr(ctx, &cond)); + } + + docs.push(ir::space()); + docs.push(ir::text("then")); + + // if body + let _has_block = + format_block_or_orphan_comments(ctx, stat.get_block().as_ref(), stat.syntax(), &mut docs); + + // elseif branches + for clause in stat.get_else_if_clause_list() { + docs.push(ir::hard_line()); + docs.push(ir::text("elseif")); + docs.push(ir::space()); + if let Some(cond) = clause.get_condition_expr() { + docs.extend(format_expr(ctx, &cond)); + } + docs.push(ir::space()); + docs.push(ir::text("then")); + format_block_or_orphan_comments( + ctx, + clause.get_block().as_ref(), + clause.syntax(), + &mut docs, + ); + } + + // else branch + if let Some(else_clause) = stat.get_else_clause() { + docs.push(ir::hard_line()); + docs.push(ir::text("else")); + format_block_or_orphan_comments( + ctx, + else_clause.get_block().as_ref(), + else_clause.syntax(), + &mut docs, + ); + } + + docs.push(ir::hard_line()); + docs.push(ir::text("end")); + + docs +} + +/// while cond do ... end +fn format_while_stat(ctx: &FormatContext, stat: &LuaWhileStat) -> Vec { + let mut docs = vec![ir::text("while"), ir::space()]; + + if let Some(cond) = stat.get_condition_expr() { + docs.extend(format_expr(ctx, &cond)); + } + + docs.push(ir::space()); + docs.push(ir::text("do")); + + format_body_end_with_parent( + ctx, + stat.get_block().as_ref(), + Some(stat.syntax()), + &mut docs, + ); + + docs +} + +/// do ... end +fn format_do_stat(ctx: &FormatContext, stat: &LuaDoStat) -> Vec { + let mut docs = vec![ir::text("do")]; + + format_body_end_with_parent( + ctx, + stat.get_block().as_ref(), + Some(stat.syntax()), + &mut docs, + ); + + docs +} + +/// for i = start, stop[, step] do ... end +fn format_for_stat(ctx: &FormatContext, stat: &LuaForStat) -> Vec { + let mut docs = vec![ir::text("for"), ir::space()]; + + if let Some(var_name) = stat.get_var_name() { + docs.push(ir::text(var_name.get_name_text().to_string())); + } + + docs.push(ir::space()); + docs.push(ir::text("=")); + docs.push(ir::space()); + + let iter_exprs: Vec<_> = stat.get_iter_expr().collect(); + let iter_docs: Vec> = iter_exprs.iter().map(|e| format_expr(ctx, e)).collect(); + docs.extend(ir::intersperse(iter_docs, vec![ir::text(","), ir::space()])); + + docs.push(ir::space()); + docs.push(ir::text("do")); + + format_body_end_with_parent( + ctx, + stat.get_block().as_ref(), + Some(stat.syntax()), + &mut docs, + ); + + docs +} + +/// for k, v in expr_list do ... end +fn format_for_range_stat(ctx: &FormatContext, stat: &LuaForRangeStat) -> Vec { + let mut docs = vec![ir::text("for"), ir::space()]; + + let var_names: Vec<_> = stat + .get_var_name_list() + .map(|n| n.get_name_text().to_string()) + .collect(); + for (i, name) in var_names.iter().enumerate() { + if i > 0 { + docs.push(ir::text(",")); + docs.push(ir::space()); + } + docs.push(ir::text(name.as_str())); + } + + docs.push(ir::space()); + docs.push(ir::text("in")); + docs.push(ir::space()); + + let expr_list: Vec<_> = stat.get_expr_list().collect(); + let expr_docs: Vec> = expr_list.iter().map(|e| format_expr(ctx, e)).collect(); + docs.extend(ir::intersperse(expr_docs, vec![ir::text(","), ir::space()])); + + docs.push(ir::space()); + docs.push(ir::text("do")); + + format_body_end_with_parent( + ctx, + stat.get_block().as_ref(), + Some(stat.syntax()), + &mut docs, + ); + + docs +} + +/// repeat ... until cond +fn format_repeat_stat(ctx: &FormatContext, stat: &LuaRepeatStat) -> Vec { + let mut docs = vec![ir::text("repeat")]; + + let mut has_body = false; + if let Some(block) = stat.get_block() { + let block_docs = format_block(ctx, &block); + if !block_docs.is_empty() { + let mut indented = vec![ir::hard_line()]; + indented.extend(block_docs); + docs.push(ir::indent(indented)); + has_body = true; + } + } + if !has_body { + let comment_docs = collect_orphan_comments(stat.syntax()); + if !comment_docs.is_empty() { + let mut indented = vec![ir::hard_line()]; + indented.extend(comment_docs); + docs.push(ir::indent(indented)); + } + } + + docs.push(ir::hard_line()); + docs.push(ir::text("until")); + docs.push(ir::space()); + + if let Some(cond) = stat.get_condition_expr() { + docs.extend(format_expr(ctx, &cond)); + } + + docs +} + +/// break +fn format_break_stat(_ctx: &FormatContext, _stat: &LuaBreakStat) -> Vec { + vec![ir::text("break")] +} + +/// return expr1, expr2, ... +fn format_return_stat(ctx: &FormatContext, stat: &LuaReturnStat) -> Vec { + let mut docs = vec![ir::text("return")]; + + let exprs: Vec<_> = stat.get_expr_list().collect(); + if !exprs.is_empty() { + let expr_docs: Vec> = exprs.iter().map(|e| format_expr(ctx, e)).collect(); + let separated = ir::intersperse(expr_docs, vec![ir::text(","), ir::space()]); + + docs.push(ir::group(vec![ir::indent(vec![ + ir::soft_line(), + ir::list(separated), + ])])); + } + + docs +} + +/// goto label +fn format_goto_stat(_ctx: &FormatContext, stat: &LuaGotoStat) -> Vec { + let mut docs = vec![ir::text("goto"), ir::space()]; + if let Some(label) = stat.get_label_name_token() { + docs.push(ir::text(label.get_name_text().to_string())); + } + docs +} + +/// ::label:: +fn format_label_stat(_ctx: &FormatContext, stat: &LuaLabelStat) -> Vec { + let mut docs = vec![ir::text("::")]; + if let Some(label) = stat.get_label_name_token() { + docs.push(ir::text(label.get_name_text().to_string())); + } + docs.push(ir::text("::")); + docs +} + +/// Format the parameter list and body of a closure (excluding function keyword and name) +fn format_closure_body( + ctx: &FormatContext, + closure: &emmylua_parser::LuaClosureExpr, +) -> Vec { + let mut docs = Vec::new(); + + if ctx.config.space_before_func_paren { + docs.push(ir::space()); + } + + // Parameter list + docs.push(ir::text("(")); + if let Some(params) = closure.get_params_list() { + docs.extend(super::expression::format_params_ir(ctx, ¶ms)); + } + docs.push(ir::text(")")); + + // body + format_body_end_with_parent( + ctx, + closure.get_block().as_ref(), + Some(closure.syntax()), + &mut docs, + ); + + docs +} + +/// global name1, name2 / global name1 / global * +fn format_global_stat(_ctx: &FormatContext, stat: &LuaGlobalStat) -> Vec { + let mut docs = vec![ir::text("global")]; + + // global * : declare all variables as global + if stat.is_any_global() { + docs.push(ir::space()); + docs.push(ir::text("*")); + return docs; + } + + // global name1, name2 : declaration with attribute + if let Some(attrib) = stat.get_attrib() { + docs.push(ir::space()); + docs.push(ir::text("<")); + if let Some(name_token) = attrib.get_name_token() { + docs.push(ir::text(name_token.get_name_text().to_string())); + } + docs.push(ir::text(">")); + } + + // Variable name list + let names: Vec<_> = stat + .get_local_name_list() + .filter_map(|n| { + let token = n.get_name_token()?; + Some(token.get_name_text().to_string()) + }) + .collect(); + + for (i, name) in names.iter().enumerate() { + if i == 0 { + docs.push(ir::space()); + } else { + docs.push(ir::text(",")); + docs.push(ir::space()); + } + docs.push(ir::text(name.as_str())); + } + + docs +} + +/// Format a block structure with body + end (with optional parent node for collecting orphan comments) +/// Empty blocks produce compact output `... end`; non-empty blocks are indented with line breaks +pub fn format_body_end_with_parent( + ctx: &FormatContext, + block: Option<&emmylua_parser::LuaBlock>, + parent: Option<&emmylua_parser::LuaSyntaxNode>, + docs: &mut Vec, +) { + if let Some(block) = block { + let block_docs = format_block(ctx, block); + if !block_docs.is_empty() { + let mut indented = vec![ir::hard_line()]; + indented.extend(block_docs); + docs.push(ir::indent(indented)); + docs.push(ir::hard_line()); + docs.push(ir::text("end")); + return; + } + } + // Block is empty (or missing): check parent node for orphan comments + if let Some(parent) = parent { + let comment_docs = collect_orphan_comments(parent); + if !comment_docs.is_empty() { + let mut indented = vec![ir::hard_line()]; + indented.extend(comment_docs); + docs.push(ir::indent(indented)); + docs.push(ir::hard_line()); + docs.push(ir::text("end")); + return; + } + } + // Empty block: compact output ` end` + docs.push(ir::space()); + docs.push(ir::text("end")); +} + +/// Format block or orphan comments (for if/elseif/else bodies that don't end with `end`) +fn format_block_or_orphan_comments( + ctx: &FormatContext, + block: Option<&emmylua_parser::LuaBlock>, + parent: &emmylua_parser::LuaSyntaxNode, + docs: &mut Vec, +) -> bool { + if let Some(block) = block { + let block_docs = format_block(ctx, block); + if !block_docs.is_empty() { + let mut indented = vec![ir::hard_line()]; + indented.extend(block_docs); + docs.push(ir::indent(indented)); + return true; + } + } + // Block is empty: check parent node for orphan comments + let comment_docs = collect_orphan_comments(parent); + if !comment_docs.is_empty() { + let mut indented = vec![ir::hard_line()]; + indented.extend(comment_docs); + docs.push(ir::indent(indented)); + return true; + } + false +} + +/// Expressions with their own block structure (function/table), should not break at assignment +fn is_block_like_expr(expr: &LuaExpr) -> bool { + matches!(expr, LuaExpr::ClosureExpr(_) | LuaExpr::TableExpr(_)) +} + +/// Check if a statement can participate in `=` alignment. +/// Only simple local/assign statements with values qualify. +pub fn is_eq_alignable(stat: &LuaStat) -> bool { + match stat { + LuaStat::LocalStat(s) => { + // Must have values (local x = ...) and no block-like RHS + let exprs: Vec<_> = s.get_value_exprs().collect(); + if exprs.is_empty() { + return false; + } + // Skip if RHS is function/table (shouldn't be aligned) + if exprs.len() == 1 && is_block_like_expr(&exprs[0]) { + return false; + } + true + } + LuaStat::AssignStat(s) => { + let (_, exprs) = s.get_var_and_expr_list(); + if exprs.is_empty() { + return false; + } + if exprs.len() == 1 && is_block_like_expr(&exprs[0]) { + return false; + } + true + } + _ => false, + } +} + +/// Format a statement split at the `=` for alignment. +/// Returns `(before_eq, after_eq)` where before_eq is the LHS and after_eq starts with `=`. +pub fn format_stat_eq_split(ctx: &super::FormatContext, stat: &LuaStat) -> Option { + match stat { + LuaStat::LocalStat(s) => format_local_stat_eq_split(ctx, s), + LuaStat::AssignStat(s) => format_assign_stat_eq_split(ctx, s), + _ => None, + } +} + +/// Split local stat at `=`: before = ["local", " ", names...], after = ["=", " ", values...] +fn format_local_stat_eq_split(ctx: &super::FormatContext, stat: &LuaLocalStat) -> Option { + let exprs: Vec<_> = stat.get_value_exprs().collect(); + if exprs.is_empty() { + return None; + } + + // Build LHS: "local name1, name2 " + let mut before = vec![ir::text("local"), ir::space()]; + let local_names: Vec<_> = stat.get_local_name_list().collect(); + for (i, local_name) in local_names.iter().enumerate() { + if i > 0 { + before.push(ir::text(",")); + before.push(ir::space()); + } + if let Some(token) = local_name.get_name_token() { + before.push(ir::text(token.get_name_text().to_string())); + } + if let Some(attrib) = local_name.get_attrib() { + before.push(ir::space()); + before.push(ir::text("<")); + if let Some(name_token) = attrib.get_name_token() { + before.push(ir::text(name_token.get_name_text().to_string())); + } + before.push(ir::text(">")); + } + } + + // Build RHS: "= value1, value2" + let mut after = vec![ir::text("="), ir::space()]; + let expr_docs: Vec> = exprs.iter().map(|e| format_expr(ctx, e)).collect(); + after.extend(ir::intersperse(expr_docs, vec![ir::text(","), ir::space()])); + + Some((before, after)) +} + +/// Split assign stat at `=`: before = [vars...], after = ["=", " ", values...] +fn format_assign_stat_eq_split( + ctx: &super::FormatContext, + stat: &LuaAssignStat, +) -> Option { + let (vars, exprs) = stat.get_var_and_expr_list(); + if exprs.is_empty() { + return None; + } + + // Build LHS + let var_docs: Vec> = vars + .iter() + .map(|v| format_expr(ctx, &v.clone().into())) + .collect(); + let before = ir::intersperse(var_docs, vec![ir::text(","), ir::space()]); + + // Build RHS + let mut after = Vec::new(); + if let Some(op) = stat.get_assign_op() { + after.push(ir::text(op.syntax().text().to_string())); + } + after.push(ir::space()); + let expr_docs: Vec> = exprs.iter().map(|e| format_expr(ctx, e)).collect(); + after.extend(ir::intersperse(expr_docs, vec![ir::text(","), ir::space()])); + + Some((before, after)) +} diff --git a/crates/emmylua_formatter/src/formatter/trivia.rs b/crates/emmylua_formatter/src/formatter/trivia.rs new file mode 100644 index 000000000..3fd4d49fa --- /dev/null +++ b/crates/emmylua_formatter/src/formatter/trivia.rs @@ -0,0 +1,29 @@ +use emmylua_parser::{LuaSyntaxNode, LuaTokenKind}; + +/// Count how many blank lines appear before a node. +pub fn count_blank_lines_before(node: &LuaSyntaxNode) -> usize { + let mut blank_lines = 0; + let mut consecutive_newlines = 0; + + // Walk tokens backwards, counting consecutive newlines + if let Some(first_token) = node.first_token() { + let mut token = first_token.prev_token(); + while let Some(t) = token { + match t.kind().to_token() { + LuaTokenKind::TkEndOfLine => { + consecutive_newlines += 1; + if consecutive_newlines > 1 { + blank_lines += 1; + } + } + LuaTokenKind::TkWhitespace => { + // Skip whitespace + } + _ => break, + } + token = t.prev_token(); + } + } + + blank_lines +} diff --git a/crates/emmylua_formatter/src/ir/builder.rs b/crates/emmylua_formatter/src/ir/builder.rs new file mode 100644 index 000000000..031763f56 --- /dev/null +++ b/crates/emmylua_formatter/src/ir/builder.rs @@ -0,0 +1,109 @@ +use smol_str::SmolStr; +use std::rc::Rc; +use std::sync::atomic::{AtomicU32, Ordering}; + +use super::{AlignEntry, AlignGroupData, DocIR, GroupId}; + +static NEXT_GROUP_ID: AtomicU32 = AtomicU32::new(0); + +pub fn next_group_id() -> GroupId { + GroupId(NEXT_GROUP_ID.fetch_add(1, Ordering::Relaxed)) +} + +pub fn text(s: impl Into) -> DocIR { + DocIR::Text(s.into()) +} + +pub fn space() -> DocIR { + DocIR::Space +} + +pub fn hard_line() -> DocIR { + DocIR::HardLine +} + +pub fn soft_line() -> DocIR { + DocIR::SoftLine +} + +pub fn soft_line_or_empty() -> DocIR { + DocIR::SoftLineOrEmpty +} + +pub fn group(docs: Vec) -> DocIR { + DocIR::Group { + contents: docs, + should_break: false, + id: None, + } +} + +pub fn group_break(docs: Vec) -> DocIR { + DocIR::Group { + contents: docs, + should_break: true, + id: None, + } +} + +pub fn group_with_id(docs: Vec, id: GroupId) -> DocIR { + DocIR::Group { + contents: docs, + should_break: false, + id: Some(id), + } +} + +pub fn indent(docs: Vec) -> DocIR { + DocIR::Indent(docs) +} + +pub fn list(docs: Vec) -> DocIR { + DocIR::List(docs) +} + +pub fn if_break(break_doc: DocIR, flat_doc: DocIR) -> DocIR { + DocIR::IfBreak { + break_contents: Rc::new(break_doc), + flat_contents: Rc::new(flat_doc), + group_id: None, + } +} + +pub fn if_break_with_group(break_doc: DocIR, flat_doc: DocIR, group_id: GroupId) -> DocIR { + DocIR::IfBreak { + break_contents: Rc::new(break_doc), + flat_contents: Rc::new(flat_doc), + group_id: Some(group_id), + } +} + +pub fn fill(parts: Vec) -> DocIR { + DocIR::Fill { parts } +} + +pub fn line_suffix(docs: Vec) -> DocIR { + DocIR::LineSuffix(docs) +} + +/// Insert separators between elements +pub fn intersperse(docs: Vec>, separator: Vec) -> Vec { + let mut result = Vec::with_capacity(docs.len() * 2); + for (i, doc) in docs.into_iter().enumerate() { + if i > 0 { + result.extend(separator.clone()); + } + result.extend(doc); + } + result +} + +/// Flatten multiple DocIR fragments into a single Vec +pub fn concat(items: impl IntoIterator) -> Vec { + items.into_iter().collect() +} + +/// Build an alignment group from a list of entries +pub fn align_group(entries: Vec) -> DocIR { + DocIR::AlignGroup(Rc::new(AlignGroupData { entries })) +} diff --git a/crates/emmylua_formatter/src/ir/doc_ir.rs b/crates/emmylua_formatter/src/ir/doc_ir.rs new file mode 100644 index 000000000..7785a5c99 --- /dev/null +++ b/crates/emmylua_formatter/src/ir/doc_ir.rs @@ -0,0 +1,93 @@ +use std::rc::Rc; + +use smol_str::SmolStr; + +/// Group identifier for querying break state across groups +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct GroupId(pub(crate) u32); + +/// Formatting intermediate representation +#[derive(Debug, Clone)] +pub enum DocIR { + /// Raw text fragment + Text(SmolStr), + + /// Hard line break — always emits a newline regardless of line width + HardLine, + + /// Soft line break — becomes a newline when the Group is broken, otherwise a space + SoftLine, + + /// Soft line break (no space) — becomes a newline when the Group is broken, otherwise nothing + SoftLineOrEmpty, + + /// Fixed space + Space, + + /// Indent wrapper — contents are indented one level + Indent(Vec), + + /// Group — the Printer tries to fit all contents on one line; + /// if it exceeds line width, breaks and all SoftLines become newlines + Group { + contents: Vec, + should_break: bool, + id: Option, + }, + + /// List — directly concatenates multiple IRs + List(Vec), + + /// Conditional branch — selects different output based on whether the Group is broken + IfBreak { + break_contents: Rc, + flat_contents: Rc, + group_id: Option, + }, + + /// Fill — greedy fill: places as many elements on one line as the line width allows + Fill { parts: Vec }, + + /// Line suffix — output at the end of the current line (for trailing comments) + LineSuffix(Vec), + + /// Alignment group — consecutive entries whose alignment points are padded to the same column. + /// The Printer pads each entry's `before` to the max width so `after` parts line up. + AlignGroup(Rc), +} + +/// Data for an alignment group (behind Rc to keep DocIR enum small) +#[derive(Debug, Clone)] +pub struct AlignGroupData { + pub entries: Vec, +} + +/// Type alias for an eq-split pair: (before_docs, after_docs) +pub type EqSplit = (Vec, Vec); + +/// A single entry in an alignment group +#[derive(Debug, Clone)] +pub enum AlignEntry { + /// A line split at the alignment point. + /// `before` is padded to the max width across the group, then `after` is appended. + Aligned { + before: Vec, + after: Vec, + }, + /// A non-aligned line (e.g., standalone comment) kept in sequence + Line(Vec), +} + +/// Compute the flat (single-line) width of an IR slice. +/// Only handles simple nodes (Text, Space, List); other nodes contribute 0. +/// This is safe for alignment `before` parts which are always flat. +pub fn ir_flat_width(docs: &[DocIR]) -> usize { + docs.iter() + .map(|d| match d { + DocIR::Text(s) => s.len(), + DocIR::Space => 1, + DocIR::List(items) => ir_flat_width(items), + _ => 0, + }) + .sum() +} diff --git a/crates/emmylua_formatter/src/ir/mod.rs b/crates/emmylua_formatter/src/ir/mod.rs new file mode 100644 index 000000000..36a5203b8 --- /dev/null +++ b/crates/emmylua_formatter/src/ir/mod.rs @@ -0,0 +1,5 @@ +mod builder; +mod doc_ir; + +pub use builder::*; +pub use doc_ir::*; diff --git a/crates/emmylua_formatter/src/lib.rs b/crates/emmylua_formatter/src/lib.rs index c9f2cd5f6..5e779df7a 100644 --- a/crates/emmylua_formatter/src/lib.rs +++ b/crates/emmylua_formatter/src/lib.rs @@ -1,26 +1,44 @@ pub mod cmd_args; -mod format; -mod style_ruler; -mod styles; +pub mod config; +mod formatter; +pub mod ir; +mod printer; mod test; -use emmylua_parser::{LuaAst, LuaParser, ParserConfig}; +use emmylua_parser::{LuaParser, ParserConfig}; +use formatter::FormatContext; +use printer::Printer; -pub fn reformat_lua_code(code: &str, styles: &LuaCodeStyle) -> String { - let tree = LuaParser::parse(code, ParserConfig::default()); +pub use config::LuaFormatConfig; - let mut formatter = format::LuaFormatter::new(LuaAst::LuaChunk(tree.get_chunk_node())); - style_ruler::apply_styles(&mut formatter, styles); +pub fn reformat_lua_code(code: &str, config: &LuaFormatConfig) -> String { + // Preserve shebang line (e.g. #!/usr/bin/lua) + let (shebang, lua_code) = if code.starts_with("#!") { + match code.find('\n') { + Some(pos) => (&code[..=pos], &code[pos + 1..]), + None => (code, ""), + } + } else { + ("", code) + }; - formatter.get_formatted_text() -} + let tree = LuaParser::parse(lua_code, ParserConfig::default()); -pub fn reformat_node(node: &LuaAst, styles: &LuaCodeStyle) -> String { - let mut formatter = format::LuaFormatter::new(node.clone()); - style_ruler::apply_styles(&mut formatter, styles); + let ctx = FormatContext::new(config); + let chunk = tree.get_chunk_node(); + let ir = formatter::format_chunk(&ctx, &chunk); - formatter.get_formatted_text() -} + let mut output = Printer::new(config).print(&ir); + let newline = config.newline_str(); -// Re-export commonly used types for consumers/binaries -pub use styles::LuaCodeStyle; + // Post-processing: trailing comment alignment (text-based) + if config.align_continuous_line_comment { + output = printer::alignment::align_trailing_comments(&output, newline); + } + + if shebang.is_empty() { + output + } else { + format!("{}{}", shebang, output) + } +} diff --git a/crates/emmylua_formatter/src/printer/alignment.rs b/crates/emmylua_formatter/src/printer/alignment.rs new file mode 100644 index 000000000..5c1dc2967 --- /dev/null +++ b/crates/emmylua_formatter/src/printer/alignment.rs @@ -0,0 +1,111 @@ +//! Alignment post-processing module. +//! +//! After the Printer produces plain text output, this module performs +//! trailing comment alignment on consecutive lines. + +/// Align trailing comments on consecutive lines to the same column. +/// +/// Groups consecutive lines that have `--` trailing comments and pads +/// their code portion so the comments start at the same column. +/// ```text +/// local a = 1 -- short local a = 1 -- short +/// local bbb = 2 -- long var => local bbb = 2 -- long var +/// ``` +pub fn align_trailing_comments(text: &str, newline: &str) -> String { + let lines: Vec<&str> = text.lines().collect(); + let mut result_lines: Vec = Vec::with_capacity(lines.len()); + let mut i = 0; + + while i < lines.len() { + // Try to find a group of consecutive lines with trailing comments + if split_trailing_comment(lines[i]).is_some() { + let group_start = i; + let mut group_end = i + 1; + + // Scan forward for consecutive lines with trailing comments + while group_end < lines.len() { + if split_trailing_comment(lines[group_end]).is_some() { + group_end += 1; + } else { + break; + } + } + + if group_end - group_start >= 2 { + // Align only when there are at least 2 lines + let mut max_code_width = 0; + let mut entries: Vec<(&str, &str)> = Vec::new(); + + for line in lines.iter().take(group_end).skip(group_start) { + let (code, comment) = split_trailing_comment(line).unwrap(); + let code_trimmed = code.trim_end(); + max_code_width = max_code_width.max(code_trimmed.len()); + entries.push((code_trimmed, comment)); + } + + for (code, comment) in entries { + let padding = max_code_width - code.len(); + result_lines.push(format!("{}{} {}", code, " ".repeat(padding), comment)); + } + + i = group_end; + continue; + } + } + + result_lines.push(lines[i].to_string()); + i += 1; + } + + // Preserve trailing newline + let mut output = result_lines.join(newline); + if text.ends_with('\n') || text.ends_with("\r\n") { + output.push_str(newline); + } + output +} + +/// Find a trailing comment (`--` outside of strings) in a line. +/// Returns `(code_before_comment, comment_including_dashes)`. +fn split_trailing_comment(line: &str) -> Option<(&str, &str)> { + let trimmed = line.trim_start(); + // A line that starts with `--` is a standalone comment, not a trailing one + if trimmed.starts_with("--") { + return None; + } + + // Scan the line, skipping string contents, to find `--` + let bytes = line.as_bytes(); + let len = bytes.len(); + let mut i = 0; + + while i < len { + match bytes[i] { + b'"' | b'\'' => { + let quote = bytes[i]; + i += 1; + while i < len && bytes[i] != quote { + if bytes[i] == b'\\' { + i += 1; // skip escaped char + } + i += 1; + } + i += 1; // skip closing quote + } + b'[' if i + 1 < len && (bytes[i + 1] == b'[' || bytes[i + 1] == b'=') => { + // Long string [[ ... ]] or [=[ ... ]=] + i += 2; + while i + 1 < len && !(bytes[i] == b']' && bytes[i + 1] == b']') { + i += 1; + } + i += 2; + } + b'-' if i + 1 < len && bytes[i + 1] == b'-' => { + return Some((&line[..i], &line[i..])); + } + _ => i += 1, + } + } + + None +} diff --git a/crates/emmylua_formatter/src/printer/mod.rs b/crates/emmylua_formatter/src/printer/mod.rs new file mode 100644 index 000000000..935622ded --- /dev/null +++ b/crates/emmylua_formatter/src/printer/mod.rs @@ -0,0 +1,386 @@ +pub(crate) mod alignment; +mod test; + +use std::collections::HashMap; + +use crate::config::LuaFormatConfig; +use crate::ir::{AlignEntry, DocIR, GroupId, ir_flat_width}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PrintMode { + Flat, + Break, +} + +pub struct Printer { + max_line_width: usize, + indent_str: String, + indent_width: usize, + newline_str: &'static str, + output: String, + current_column: usize, + indent_level: usize, + group_break_map: HashMap, + line_suffixes: Vec>, +} + +impl Printer { + pub fn new(config: &LuaFormatConfig) -> Self { + Self { + max_line_width: config.max_line_width, + indent_str: config.indent_str(), + indent_width: config.indent_width(), + newline_str: config.newline_str(), + output: String::new(), + current_column: 0, + indent_level: 0, + group_break_map: HashMap::new(), + line_suffixes: Vec::new(), + } + } + + pub fn print(mut self, docs: &[DocIR]) -> String { + self.print_docs(docs, PrintMode::Break); + + // Flush any remaining line suffixes + if !self.line_suffixes.is_empty() { + let suffixes = std::mem::take(&mut self.line_suffixes); + for suffix in &suffixes { + self.print_docs(suffix, PrintMode::Break); + } + } + + self.output + } + + fn print_docs(&mut self, docs: &[DocIR], mode: PrintMode) { + for doc in docs { + self.print_doc(doc, mode); + } + } + + fn print_doc(&mut self, doc: &DocIR, mode: PrintMode) { + match doc { + DocIR::Text(s) => { + self.push_text(s); + } + DocIR::Space => { + self.push_text(" "); + } + DocIR::HardLine => { + self.flush_line_suffixes(); + self.push_newline(); + } + DocIR::SoftLine => match mode { + PrintMode::Flat => self.push_text(" "), + PrintMode::Break => { + self.flush_line_suffixes(); + self.push_newline(); + } + }, + DocIR::SoftLineOrEmpty => { + if mode == PrintMode::Break { + self.flush_line_suffixes(); + self.push_newline(); + } + } + DocIR::Group { + contents, + should_break, + id, + } => { + let should_break = *should_break || self.has_hard_line(contents); + let child_mode = if should_break { + PrintMode::Break + } else if self.fits_on_line(contents, mode) { + PrintMode::Flat + } else { + PrintMode::Break + }; + + if let Some(gid) = id { + self.group_break_map + .insert(*gid, child_mode == PrintMode::Break); + } + + self.print_docs(contents, child_mode); + } + DocIR::Indent(contents) => { + self.indent_level += 1; + self.print_docs(contents, mode); + self.indent_level -= 1; + } + DocIR::List(contents) => { + self.print_docs(contents, mode); + } + DocIR::IfBreak { + break_contents, + flat_contents, + group_id, + } => { + let is_break = if let Some(gid) = group_id { + self.group_break_map.get(gid).copied().unwrap_or(false) + } else { + mode == PrintMode::Break + }; + let d = if is_break { + break_contents.as_ref() + } else { + flat_contents.as_ref() + }; + self.print_doc(d, mode); + } + DocIR::Fill { parts } => { + self.print_fill(parts, mode); + } + DocIR::LineSuffix(contents) => { + self.line_suffixes.push(contents.clone()); + } + DocIR::AlignGroup(group) => { + self.print_align_group(&group.entries, mode); + } + } + } + + fn push_text(&mut self, s: &str) { + self.output.push_str(s); + if let Some(last_newline) = s.rfind('\n') { + self.current_column = s.len() - last_newline - 1; + } else { + self.current_column += s.len(); + } + } + + fn push_newline(&mut self) { + // Trim trailing spaces + let trimmed = self.output.trim_end_matches(' '); + let trimmed_len = trimmed.len(); + if trimmed_len < self.output.len() { + self.output.truncate(trimmed_len); + } + + self.output.push_str(self.newline_str); + let indent = self.indent_str.repeat(self.indent_level); + self.output.push_str(&indent); + self.current_column = self.indent_level * self.indent_width; + } + + fn flush_line_suffixes(&mut self) { + if self.line_suffixes.is_empty() { + return; + } + let suffixes = std::mem::take(&mut self.line_suffixes); + for suffix in &suffixes { + self.print_docs(suffix, PrintMode::Break); + } + } + + /// Check whether contents fit within the remaining line width in Flat mode + fn fits_on_line(&self, docs: &[DocIR], _current_mode: PrintMode) -> bool { + let remaining = self.max_line_width.saturating_sub(self.current_column); + self.fits(docs, remaining as isize) + } + + fn fits(&self, docs: &[DocIR], mut remaining: isize) -> bool { + let mut stack: Vec<(&DocIR, PrintMode)> = + docs.iter().rev().map(|d| (d, PrintMode::Flat)).collect(); + + while let Some((doc, mode)) = stack.pop() { + if remaining < 0 { + return false; + } + + match doc { + DocIR::Text(s) => { + remaining -= s.len() as isize; + } + DocIR::Space => { + remaining -= 1; + } + DocIR::HardLine => { + return true; + } + DocIR::SoftLine => { + if mode == PrintMode::Break { + return true; + } + remaining -= 1; + } + DocIR::SoftLineOrEmpty => { + if mode == PrintMode::Break { + return true; + } + } + DocIR::Group { + contents, + should_break, + .. + } => { + let child_mode = if *should_break { + PrintMode::Break + } else { + PrintMode::Flat + }; + for d in contents.iter().rev() { + stack.push((d, child_mode)); + } + } + DocIR::Indent(contents) | DocIR::List(contents) => { + for d in contents.iter().rev() { + stack.push((d, mode)); + } + } + DocIR::IfBreak { + break_contents, + flat_contents, + group_id, + } => { + let is_break = if let Some(gid) = group_id { + self.group_break_map.get(gid).copied().unwrap_or(false) + } else { + mode == PrintMode::Break + }; + let d = if is_break { + break_contents.as_ref() + } else { + flat_contents.as_ref() + }; + stack.push((d, mode)); + } + DocIR::Fill { parts } => { + for d in parts.iter().rev() { + stack.push((d, mode)); + } + } + DocIR::LineSuffix(_) => {} + DocIR::AlignGroup(group) => { + // For fit checking, treat as all entries printed flat + for entry in &group.entries { + match entry { + AlignEntry::Aligned { before, after } => { + for d in before.iter().rev() { + stack.push((d, mode)); + } + for d in after.iter().rev() { + stack.push((d, mode)); + } + } + AlignEntry::Line(content) => { + for d in content.iter().rev() { + stack.push((d, mode)); + } + } + } + } + } + } + } + + remaining >= 0 + } + + /// Check whether an IR list contains HardLine + fn has_hard_line(&self, docs: &[DocIR]) -> bool { + for doc in docs { + match doc { + DocIR::HardLine => return true, + DocIR::List(contents) | DocIR::Indent(contents) => { + if self.has_hard_line(contents) { + return true; + } + } + DocIR::Group { contents, .. } => { + if self.has_hard_line(contents) { + return true; + } + } + DocIR::AlignGroup(group) => { + // Alignment groups with 2+ entries always produce hard lines + if group.entries.len() >= 2 { + return true; + } + } + _ => {} + } + } + false + } + + /// Fill: greedy fill + fn print_fill(&mut self, parts: &[DocIR], mode: PrintMode) { + let mut i = 0; + while i < parts.len() { + let content = &parts[i]; + let content_fits = self.fits( + std::slice::from_ref(content), + (self.max_line_width.saturating_sub(self.current_column)) as isize, + ); + + if content_fits { + self.print_doc(content, PrintMode::Flat); + } else { + self.print_doc(content, PrintMode::Break); + } + + i += 1; + if i >= parts.len() { + break; + } + + let separator = &parts[i]; + i += 1; + + let next_fits = if i < parts.len() { + let combo = vec![separator.clone(), parts[i].clone()]; + self.fits( + &combo, + (self.max_line_width.saturating_sub(self.current_column)) as isize, + ) + } else { + true + }; + + if next_fits { + self.print_doc(separator, PrintMode::Flat); + } else { + self.print_doc(separator, PrintMode::Break); + } + } + let _ = mode; + } + + /// Print an alignment group: pad each entry's `before` to the max width so `after` parts align. + fn print_align_group(&mut self, entries: &[AlignEntry], mode: PrintMode) { + // Compute max flat width of `before` parts across all Aligned entries + let max_before = entries + .iter() + .filter_map(|e| match e { + AlignEntry::Aligned { before, .. } => Some(ir_flat_width(before)), + AlignEntry::Line(_) => None, + }) + .max() + .unwrap_or(0); + + for (i, entry) in entries.iter().enumerate() { + if i > 0 { + self.flush_line_suffixes(); + self.push_newline(); + } + match entry { + AlignEntry::Aligned { before, after } => { + let before_width = ir_flat_width(before); + self.print_docs(before, mode); + let padding = max_before - before_width; + if padding > 0 { + self.push_text(&" ".repeat(padding)); + } + self.push_text(" "); + self.print_docs(after, mode); + } + AlignEntry::Line(content) => { + self.print_docs(content, mode); + } + } + } + } +} diff --git a/crates/emmylua_formatter/src/printer/test.rs b/crates/emmylua_formatter/src/printer/test.rs new file mode 100644 index 000000000..c9aeaace4 --- /dev/null +++ b/crates/emmylua_formatter/src/printer/test.rs @@ -0,0 +1,79 @@ +#[cfg(test)] +mod tests { + use crate::config::LuaFormatConfig; + use crate::ir::*; + use crate::printer::Printer; + + #[test] + fn test_simple_text() { + let config = LuaFormatConfig::default(); + let printer = Printer::new(&config); + let docs = vec![text("hello"), space(), text("world")]; + let result = printer.print(&docs); + assert_eq!(result, "hello world"); + } + + #[test] + fn test_hard_line() { + let config = LuaFormatConfig::default(); + let printer = Printer::new(&config); + let docs = vec![text("line1"), hard_line(), text("line2")]; + let result = printer.print(&docs); + assert_eq!(result, "line1\nline2"); + } + + #[test] + fn test_group_flat() { + let config = LuaFormatConfig::default(); + let printer = Printer::new(&config); + let docs = vec![group(vec![ + text("f("), + soft_line_or_empty(), + text("a"), + text(","), + soft_line(), + text("b"), + soft_line_or_empty(), + text(")"), + ])]; + let result = printer.print(&docs); + assert_eq!(result, "f(a, b)"); + } + + #[test] + fn test_group_break() { + let config = LuaFormatConfig { + max_line_width: 10, + ..Default::default() + }; + let printer = Printer::new(&config); + let docs = vec![group(vec![ + text("f("), + indent(vec![ + soft_line_or_empty(), + text("very_long_arg1"), + text(","), + soft_line(), + text("very_long_arg2"), + ]), + soft_line_or_empty(), + text(")"), + ])]; + let result = printer.print(&docs); + assert_eq!(result, "f(\n very_long_arg1,\n very_long_arg2\n)"); + } + + #[test] + fn test_indent() { + let config = LuaFormatConfig::default(); + let printer = Printer::new(&config); + let docs = vec![ + text("if true then"), + indent(vec![hard_line(), text("print(1)")]), + hard_line(), + text("end"), + ]; + let result = printer.print(&docs); + assert_eq!(result, "if true then\n print(1)\nend"); + } +} diff --git a/crates/emmylua_formatter/src/style_ruler/basic_space.rs b/crates/emmylua_formatter/src/style_ruler/basic_space.rs deleted file mode 100644 index 00deb84a4..000000000 --- a/crates/emmylua_formatter/src/style_ruler/basic_space.rs +++ /dev/null @@ -1,156 +0,0 @@ -use emmylua_parser::{LuaAstNode, LuaSyntaxId, LuaSyntaxKind, LuaSyntaxToken, LuaTokenKind}; -use rowan::NodeOrToken; - -use crate::{ - format::{LuaFormatter, TokenExpected}, - styles::LuaCodeStyle, -}; - -use super::StyleRuler; - -pub struct BasicSpaceRuler; - -impl StyleRuler for BasicSpaceRuler { - fn apply_style(f: &mut LuaFormatter, _: &LuaCodeStyle) { - let root = f.get_root(); - for node_or_token in root.syntax().descendants_with_tokens() { - if let NodeOrToken::Token(token) = node_or_token { - let syntax_id = LuaSyntaxId::from_token(&token); - match token.kind().to_token() { - LuaTokenKind::TkLeftParen | LuaTokenKind::TkLeftBracket => { - if let Some(prev_token) = get_prev_sibling_token_without_space(&token) { - match prev_token.kind().to_token() { - LuaTokenKind::TkName - | LuaTokenKind::TkRightParen - | LuaTokenKind::TkRightBracket => { - f.add_token_left_expected(syntax_id, TokenExpected::Space(0)); - } - LuaTokenKind::TkString - | LuaTokenKind::TkRightBrace - | LuaTokenKind::TkLongString => { - f.add_token_left_expected(syntax_id, TokenExpected::Space(1)); - } - _ => {} - } - } - - f.add_token_right_expected(syntax_id, TokenExpected::Space(0)); - } - LuaTokenKind::TkRightBracket | LuaTokenKind::TkRightParen => { - f.add_token_left_expected(syntax_id, TokenExpected::Space(0)); - } - LuaTokenKind::TkLeftBrace => { - f.add_token_right_expected(syntax_id, TokenExpected::Space(1)); - } - LuaTokenKind::TkRightBrace => { - f.add_token_left_expected(syntax_id, TokenExpected::Space(1)); - } - LuaTokenKind::TkComma => { - f.add_token_left_expected(syntax_id, TokenExpected::Space(0)); - f.add_token_right_expected(syntax_id, TokenExpected::Space(1)); - } - LuaTokenKind::TkPlus | LuaTokenKind::TkMinus => { - if is_parent_syntax(&token, LuaSyntaxKind::UnaryExpr) { - f.add_token_right_expected(syntax_id, TokenExpected::Space(0)); - continue; - } - - f.add_token_left_expected(syntax_id, TokenExpected::Space(1)); - f.add_token_right_expected(syntax_id, TokenExpected::Space(1)); - } - LuaTokenKind::TkLt => { - if is_parent_syntax(&token, LuaSyntaxKind::Attribute) { - f.add_token_left_expected(syntax_id, TokenExpected::Space(1)); - f.add_token_right_expected(syntax_id, TokenExpected::Space(0)); - continue; - } - - f.add_token_left_expected(syntax_id, TokenExpected::Space(1)); - f.add_token_right_expected(syntax_id, TokenExpected::Space(1)); - } - LuaTokenKind::TkGt => { - if is_parent_syntax(&token, LuaSyntaxKind::Attribute) { - f.add_token_left_expected(syntax_id, TokenExpected::Space(0)); - f.add_token_right_expected(syntax_id, TokenExpected::Space(1)); - continue; - } - - f.add_token_left_expected(syntax_id, TokenExpected::Space(1)); - f.add_token_right_expected(syntax_id, TokenExpected::Space(1)); - } - LuaTokenKind::TkMul - | LuaTokenKind::TkDiv - | LuaTokenKind::TkIDiv - | LuaTokenKind::TkMod - | LuaTokenKind::TkPow - | LuaTokenKind::TkConcat - | LuaTokenKind::TkAssign - | LuaTokenKind::TkBitAnd - | LuaTokenKind::TkBitOr - | LuaTokenKind::TkBitXor - | LuaTokenKind::TkEq - | LuaTokenKind::TkGe - | LuaTokenKind::TkLe - | LuaTokenKind::TkNe - | LuaTokenKind::TkAnd - | LuaTokenKind::TkOr - | LuaTokenKind::TkShl - | LuaTokenKind::TkShr => { - f.add_token_left_expected(syntax_id, TokenExpected::Space(1)); - f.add_token_right_expected(syntax_id, TokenExpected::Space(1)); - } - LuaTokenKind::TkColon => { - if is_parent_syntax(&token, LuaSyntaxKind::IndexExpr) { - f.add_token_left_expected(syntax_id, TokenExpected::Space(0)); - f.add_token_right_expected(syntax_id, TokenExpected::Space(0)); - continue; - } - f.add_token_left_expected(syntax_id, TokenExpected::MaxSpace(1)); - f.add_token_right_expected(syntax_id, TokenExpected::MaxSpace(1)); - } - LuaTokenKind::TkDot => { - f.add_token_left_expected(syntax_id, TokenExpected::Space(0)); - f.add_token_right_expected(syntax_id, TokenExpected::Space(0)); - } - LuaTokenKind::TkLocal - | LuaTokenKind::TkFunction - | LuaTokenKind::TkIf - | LuaTokenKind::TkWhile - | LuaTokenKind::TkFor - | LuaTokenKind::TkRepeat - | LuaTokenKind::TkReturn - | LuaTokenKind::TkDo - | LuaTokenKind::TkElseIf - | LuaTokenKind::TkElse - | LuaTokenKind::TkThen - | LuaTokenKind::TkUntil - | LuaTokenKind::TkIn - | LuaTokenKind::TkNot => { - f.add_token_left_expected(syntax_id, TokenExpected::Space(1)); - f.add_token_right_expected(syntax_id, TokenExpected::Space(1)); - } - _ => {} - } - } - } - } -} - -fn is_parent_syntax(token: &LuaSyntaxToken, kind: LuaSyntaxKind) -> bool { - if let Some(parent) = token.parent() { - return parent.kind().to_syntax() == kind; - } - false -} - -fn get_prev_sibling_token_without_space(token: &LuaSyntaxToken) -> Option { - let mut current = token.clone(); - while let Some(prev) = current.prev_token() { - if prev.kind().to_token() != LuaTokenKind::TkWhitespace { - return Some(prev); - } - current = prev; - } - - None -} diff --git a/crates/emmylua_formatter/src/style_ruler/mod.rs b/crates/emmylua_formatter/src/style_ruler/mod.rs deleted file mode 100644 index c4531c783..000000000 --- a/crates/emmylua_formatter/src/style_ruler/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -mod basic_space; - -use crate::{format::LuaFormatter, styles::LuaCodeStyle}; - -#[allow(unused)] -pub fn apply_styles(formatter: &mut LuaFormatter, styles: &LuaCodeStyle) { - apply_style::(formatter, styles); -} - -pub trait StyleRuler { - /// Apply the style rules to the formatter - fn apply_style(formatter: &mut LuaFormatter, styles: &LuaCodeStyle); -} - -pub fn apply_style(formatter: &mut LuaFormatter, styles: &LuaCodeStyle) { - T::apply_style(formatter, styles) -} diff --git a/crates/emmylua_formatter/src/styles/lua_indent.rs b/crates/emmylua_formatter/src/styles/lua_indent.rs deleted file mode 100644 index 1e2ae6bd3..000000000 --- a/crates/emmylua_formatter/src/styles/lua_indent.rs +++ /dev/null @@ -1,15 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum LuaIndent { - /// Use tabs for indentation - Tab, - /// Use spaces for indentation - Space(usize), -} - -impl Default for LuaIndent { - fn default() -> Self { - LuaIndent::Space(4) - } -} diff --git a/crates/emmylua_formatter/src/styles/mod.rs b/crates/emmylua_formatter/src/styles/mod.rs deleted file mode 100644 index f73dde7a4..000000000 --- a/crates/emmylua_formatter/src/styles/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -mod lua_indent; - -pub use lua_indent::LuaIndent; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct LuaCodeStyle { - /// The indentation style to use - pub indent: LuaIndent, - /// The maximum width of a line before wrapping - pub max_line_width: usize, -} diff --git a/crates/emmylua_formatter/src/test/breaking_tests.rs b/crates/emmylua_formatter/src/test/breaking_tests.rs new file mode 100644 index 000000000..43d989b19 --- /dev/null +++ b/crates/emmylua_formatter/src/test/breaking_tests.rs @@ -0,0 +1,63 @@ +#[cfg(test)] +mod tests { + use crate::{assert_format_with_config, config::LuaFormatConfig}; + + #[test] + fn test_long_binary_expr_breaking() { + let config = LuaFormatConfig { + max_line_width: 80, + ..Default::default() + }; + assert_format_with_config!( + "local result = very_long_variable_name_aaa + another_long_variable_name_bbb + yet_another_variable_name_ccc + final_variable_name_ddd\n", + r#" +local result = + very_long_variable_name_aaa + another_long_variable_name_bbb + + yet_another_variable_name_ccc + + final_variable_name_ddd +"#, + config + ); + } + + #[test] + fn test_long_call_args_breaking() { + let config = LuaFormatConfig { + max_line_width: 60, + ..Default::default() + }; + assert_format_with_config!( + "some_function(very_long_argument_one, very_long_argument_two, very_long_argument_three, very_long_argument_four)\n", + r#" +some_function( + very_long_argument_one, + very_long_argument_two, + very_long_argument_three, + very_long_argument_four +) +"#, + config + ); + } + + #[test] + fn test_long_table_breaking() { + let config = LuaFormatConfig { + max_line_width: 60, + ..Default::default() + }; + assert_format_with_config!( + "local t = { first_key = 1, second_key = 2, third_key = 3, fourth_key = 4, fifth_key = 5 }\n", + r#" +local t = { + first_key = 1, + second_key = 2, + third_key = 3, + fourth_key = 4, + fifth_key = 5 +} +"#, + config + ); + } +} diff --git a/crates/emmylua_formatter/src/test/comment_tests.rs b/crates/emmylua_formatter/src/test/comment_tests.rs new file mode 100644 index 000000000..8dc4b2b11 --- /dev/null +++ b/crates/emmylua_formatter/src/test/comment_tests.rs @@ -0,0 +1,350 @@ +#[cfg(test)] +mod tests { + use crate::assert_format; + + #[test] + fn test_leading_comment() { + assert_format!( + r#" +-- this is a comment +local a = 1 +"#, + r#" +-- this is a comment +local a = 1 +"# + ); + } + + #[test] + fn test_trailing_comment() { + assert_format!("local a = 1 -- trailing\n", "local a = 1 -- trailing\n"); + } + + #[test] + fn test_multiple_comments() { + assert_format!( + r#" +-- comment 1 +-- comment 2 +local x = 1 +"#, + r#" +-- comment 1 +-- comment 2 +local x = 1 +"# + ); + } + + // ========== table field trailing comments ========== + + #[test] + fn test_table_field_trailing_comment() { + use crate::{ + assert_format_with_config, + config::{ExpandStrategy, LuaFormatConfig}, + }; + + let config = LuaFormatConfig { + table_expand: ExpandStrategy::Always, + ..Default::default() + }; + assert_format_with_config!( + r#" +local t = { + a = 1, -- first + b = 2, -- second + c = 3 +} +"#, + r#" +local t = { + a = 1, -- first + b = 2, -- second + c = 3 +} +"#, + config + ); + } + + #[test] + fn test_table_field_comment_forces_expand() { + assert_format!( + r#" +local t = {a = 1, -- comment +b = 2} +"#, + r#" +local t = { + a = 1, -- comment + b = 2 +} +"# + ); + } + + // ========== standalone comments ========== + + #[test] + fn test_table_standalone_comment() { + assert_format!( + r#" +local t = { + a = 1, + -- separator + b = 2, +} +"#, + r#" +local t = { + a = 1, + -- separator + b = 2 +} +"# + ); + } + + #[test] + fn test_comment_only_block() { + assert_format!( + r#" +if x then + -- only comment +end +"#, + r#" +if x then + -- only comment +end +"# + ); + } + + #[test] + fn test_comment_only_while_block() { + assert_format!( + r#" +while true do + -- todo +end +"#, + r#" +while true do + -- todo +end +"# + ); + } + + #[test] + fn test_comment_only_do_block() { + assert_format!( + r#" +do + -- scoped comment +end +"#, + r#" +do + -- scoped comment +end +"# + ); + } + + #[test] + fn test_comment_only_function_block() { + assert_format!( + r#" +function foo() + -- stub +end +"#, + r#" +function foo() + -- stub +end +"# + ); + } + + // ========== param comments ========== + + #[test] + fn test_function_param_comments() { + assert_format!( + r#" +function foo( + a, -- first + b, -- second + c +) + return a + b + c +end +"#, + r#" +function foo( + a, -- first + b, -- second + c +) + return a + b + c +end +"# + ); + } + + #[test] + fn test_local_function_param_comments() { + assert_format!( + r#" +local function bar( + x, -- coord x + y -- coord y +) + return x + y +end +"#, + r#" +local function bar( + x, -- coord x + y -- coord y +) + return x + y +end +"# + ); + } + + #[test] + fn test_closure_param_comments() { + assert_format!( + r#" +local f = function( + a, -- first + b -- second +) + return a + b +end +"#, + r#" +local f = function( + a, -- first + b -- second +) + return a + b +end +"# + ); + } + + // ========== alignment ========== + + #[test] + fn test_trailing_comment_alignment() { + assert_format!( + r#" +local a = 1 -- short +local bbb = 2 -- long var +local cc = 3 -- medium +"#, + r#" +local a = 1 -- short +local bbb = 2 -- long var +local cc = 3 -- medium +"# + ); + } + + #[test] + fn test_assign_alignment() { + assert_format!( + r#" +local x = 1 +local yy = 2 +local zzz = 3 +"#, + r#" +local x = 1 +local yy = 2 +local zzz = 3 +"# + ); + } + + #[test] + fn test_table_field_alignment() { + use crate::{ + assert_format_with_config, + config::{ExpandStrategy, LuaFormatConfig}, + }; + + let config = LuaFormatConfig { + table_expand: ExpandStrategy::Always, + ..Default::default() + }; + assert_format_with_config!( + r#" +local t = { + x = 1, + long_name = 2, + yy = 3, +} +"#, + r#" +local t = { + x = 1, + long_name = 2, + yy = 3 +} +"#, + config + ); + } + + #[test] + fn test_alignment_disabled() { + use crate::{assert_format_with_config, config::LuaFormatConfig}; + + let config = LuaFormatConfig { + align_continuous_line_comment: false, + align_continuous_assign_statement: false, + align_table_field: false, + ..Default::default() + }; + assert_format_with_config!( + r#" +local a = 1 -- x +local bbb = 2 -- y +"#, + r#" +local a = 1 -- x +local bbb = 2 -- y +"#, + config + ); + } + + #[test] + fn test_alignment_group_broken_by_blank_line() { + assert_format!( + r#" +local a = 1 -- x +local b = 2 -- y + +local cc = 3 -- z +local d = 4 -- w +"#, + r#" +local a = 1 -- x +local b = 2 -- y + +local cc = 3 -- z +local d = 4 -- w +"# + ); + } +} diff --git a/crates/emmylua_formatter/src/test/config_tests.rs b/crates/emmylua_formatter/src/test/config_tests.rs new file mode 100644 index 000000000..aa97e08de --- /dev/null +++ b/crates/emmylua_formatter/src/test/config_tests.rs @@ -0,0 +1,212 @@ +#[cfg(test)] +mod tests { + use crate::{ + assert_format_with_config, + config::{EndOfLine, ExpandStrategy, IndentStyle, LuaFormatConfig, TrailingComma}, + }; + + // ========== spacing options ========== + + #[test] + fn test_space_before_func_paren() { + let config = LuaFormatConfig { + space_before_func_paren: true, + ..Default::default() + }; + assert_format_with_config!( + r#" +function foo(a, b) +return a +end +"#, + r#" +function foo (a, b) + return a +end +"#, + config + ); + } + + #[test] + fn test_space_before_call_paren() { + let config = LuaFormatConfig { + space_before_call_paren: true, + ..Default::default() + }; + assert_format_with_config!("print(1)\n", "print (1)\n", config); + } + + #[test] + fn test_space_inside_parens() { + let config = LuaFormatConfig { + space_inside_parens: true, + ..Default::default() + }; + assert_format_with_config!("local a = (1 + 2)\n", "local a = ( 1 + 2 )\n", config); + } + + #[test] + fn test_space_inside_braces() { + let config = LuaFormatConfig { + space_inside_braces: true, + ..Default::default() + }; + assert_format_with_config!("local t = {1, 2, 3}\n", "local t = { 1, 2, 3 }\n", config); + } + + #[test] + fn test_no_space_inside_braces() { + let config = LuaFormatConfig { + space_inside_braces: false, + ..Default::default() + }; + assert_format_with_config!("local t = { 1, 2, 3 }\n", "local t = {1, 2, 3}\n", config); + } + + // ========== table expand strategy ========== + + #[test] + fn test_table_expand_always() { + let config = LuaFormatConfig { + table_expand: ExpandStrategy::Always, + ..Default::default() + }; + assert_format_with_config!( + "local t = {a = 1, b = 2}\n", + r#" +local t = { + a = 1, + b = 2 +} +"#, + config + ); + } + + #[test] + fn test_table_expand_never() { + let config = LuaFormatConfig { + table_expand: ExpandStrategy::Never, + ..Default::default() + }; + assert_format_with_config!( + r#" +local t = { +a = 1, +b = 2 +} +"#, + "local t = { a = 1, b = 2 }\n", + config + ); + } + + // ========== trailing comma ========== + + #[test] + fn test_trailing_comma_always_table() { + let config = LuaFormatConfig { + trailing_comma: TrailingComma::Always, + table_expand: ExpandStrategy::Always, + ..Default::default() + }; + assert_format_with_config!( + r#" +local t = { +a = 1, +b = 2 +} +"#, + r#" +local t = { + a = 1, + b = 2, +} +"#, + config + ); + } + + #[test] + fn test_trailing_comma_never() { + let config = LuaFormatConfig { + trailing_comma: TrailingComma::Never, + table_expand: ExpandStrategy::Always, + ..Default::default() + }; + assert_format_with_config!( + r#" +local t = { +a = 1, +b = 2, +} +"#, + r#" +local t = { + a = 1, + b = 2 +} +"#, + config + ); + } + + // ========== indentation ========== + + #[test] + fn test_tab_indent() { + let config = LuaFormatConfig { + indent_style: IndentStyle::Tab, + ..Default::default() + }; + // Keep escaped strings: raw strings can't represent \t visually + assert_format_with_config!( + "if true then\nprint(1)\nend\n", + "if true then\n\tprint(1)\nend\n", + config + ); + } + + // ========== blank lines ========== + + #[test] + fn test_max_blank_lines() { + let config = LuaFormatConfig { + max_blank_lines: 1, + ..Default::default() + }; + assert_format_with_config!( + r#" +local a = 1 + + + + +local b = 2 +"#, + r#" +local a = 1 + +local b = 2 +"#, + config + ); + } + + // ========== end of line ========== + + #[test] + fn test_crlf_end_of_line() { + let config = LuaFormatConfig { + end_of_line: EndOfLine::CRLF, + ..Default::default() + }; + // Keep escaped strings: raw strings can't represent \r\n distinctly + assert_format_with_config!( + "if true then\nprint(1)\nend\n", + "if true then\r\n print(1)\r\nend\r\n", + config + ); + } +} diff --git a/crates/emmylua_formatter/src/test/expression_tests.rs b/crates/emmylua_formatter/src/test/expression_tests.rs new file mode 100644 index 000000000..8ed4266b5 --- /dev/null +++ b/crates/emmylua_formatter/src/test/expression_tests.rs @@ -0,0 +1,110 @@ +#[cfg(test)] +mod tests { + // ========== unary / binary / concat ========== + + use crate::assert_format; + + #[test] + fn test_unary_expr() { + assert_format!( + r#" +local a = not b +local c = -d +local e = #t +"#, + r#" +local a = not b +local c = -d +local e = #t +"# + ); + } + + #[test] + fn test_binary_expr() { + assert_format!("local a = 1 + 2 * 3\n", "local a = 1 + 2 * 3\n"); + } + + #[test] + fn test_concat_expr() { + assert_format!("local s = a .. b .. c\n", "local s = a .. b .. c\n"); + } + + // ========== index ========== + + #[test] + fn test_index_expr() { + assert_format!( + r#" +local a = t.x +local b = t[1] +"#, + r#" +local a = t.x +local b = t[1] +"# + ); + } + + // ========== table ========== + + #[test] + fn test_table_expr() { + assert_format!( + "local t = { a = 1, b = 2, c = 3 }\n", + "local t = { a = 1, b = 2, c = 3 }\n" + ); + } + + #[test] + fn test_empty_table() { + assert_format!("local t = {}\n", "local t = {}\n"); + } + + // ========== call ========== + + #[test] + fn test_string_call() { + assert_format!("require \"module\"\n", "require \"module\"\n"); + } + + #[test] + fn test_table_call() { + assert_format!("foo { 1, 2, 3 }\n", "foo { 1, 2, 3 }\n"); + } + + // ========== chain call ========== + + #[test] + fn test_method_chain_short() { + assert_format!("a:b():c():d()\n", "a:b():c():d()\n"); + } + + #[test] + fn test_method_chain_with_args() { + assert_format!( + "builder:setName(\"foo\"):setAge(25):build()\n", + "builder:setName(\"foo\"):setAge(25):build()\n" + ); + } + + #[test] + fn test_property_chain() { + assert_format!("local a = t.x.y.z\n", "local a = t.x.y.z\n"); + } + + #[test] + fn test_mixed_chain() { + assert_format!("a.b:c():d()\n", "a.b:c():d()\n"); + } + + // ========== and / or expression ========== + + #[test] + fn test_and_or_expr() { + assert_format!( + "local x = condition_one and value_one or condition_two and value_two or default_value\n", + "local x = condition_one and value_one or condition_two and value_two or default_value\n" + ); + } +} diff --git a/crates/emmylua_formatter/src/test/misc_tests.rs b/crates/emmylua_formatter/src/test/misc_tests.rs new file mode 100644 index 000000000..978e6019e --- /dev/null +++ b/crates/emmylua_formatter/src/test/misc_tests.rs @@ -0,0 +1,158 @@ +#[cfg(test)] +mod tests { + use crate::{assert_format, config::LuaFormatConfig}; + + // ========== shebang ========== + + #[test] + fn test_shebang_preserved() { + assert_format!( + "#!/usr/bin/lua\nlocal a=1\n", + "#!/usr/bin/lua\nlocal a = 1\n" + ); + } + + #[test] + fn test_shebang_env() { + assert_format!( + "#!/usr/bin/env lua\nprint(1)\n", + "#!/usr/bin/env lua\nprint(1)\n" + ); + } + + #[test] + fn test_shebang_with_code() { + assert_format!( + "#!/usr/bin/lua\nlocal x=1\nlocal y=2\n", + "#!/usr/bin/lua\nlocal x = 1\nlocal y = 2\n" + ); + } + + #[test] + fn test_no_shebang() { + // Ensure normal code without shebang still works + assert_format!("local a = 1\n", "local a = 1\n"); + } + + // ========== idempotency ========== + + #[test] + fn test_idempotency_basic() { + let config = LuaFormatConfig::default(); + let input = r#" +local a = 1 +local bbb = 2 +if true +then +return a + bbb +end +"# + .trim_start_matches('\n'); + + let first = crate::reformat_lua_code(input, &config); + let second = crate::reformat_lua_code(&first, &config); + assert_eq!( + first, second, + "Formatter is not idempotent!\nFirst pass:\n{first}\nSecond pass:\n{second}" + ); + } + + #[test] + fn test_idempotency_table() { + let config = LuaFormatConfig::default(); + let input = r#" +local t = { + a = 1, + bbb = 2, + cc = 3, +} +"# + .trim_start_matches('\n'); + + let first = crate::reformat_lua_code(input, &config); + let second = crate::reformat_lua_code(&first, &config); + assert_eq!( + first, second, + "Formatter is not idempotent for tables!\nFirst pass:\n{first}\nSecond pass:\n{second}" + ); + } + + #[test] + fn test_idempotency_complex() { + let config = LuaFormatConfig::default(); + let input = r#" +local function foo(a, b, c) + local x = a + b * c + if x > 10 then + return { + result = x, + name = "test", + flag = true, + } + end + + for i = 1, 10 do + print(i) + end + + local t = { 1, 2, 3 } + return t +end +"# + .trim_start_matches('\n'); + + let first = crate::reformat_lua_code(input, &config); + let second = crate::reformat_lua_code(&first, &config); + assert_eq!( + first, second, + "Formatter is not idempotent for complex code!\nFirst pass:\n{first}\nSecond pass:\n{second}" + ); + } + + #[test] + fn test_idempotency_alignment() { + let config = LuaFormatConfig::default(); + let input = r#" +local a = 1 -- comment a +local bbb = 2 -- comment b +local cc = 3 -- comment c +"# + .trim_start_matches('\n'); + + let first = crate::reformat_lua_code(input, &config); + let second = crate::reformat_lua_code(&first, &config); + assert_eq!( + first, second, + "Formatter is not idempotent for aligned code!\nFirst pass:\n{first}\nSecond pass:\n{second}" + ); + } + + #[test] + fn test_idempotency_method_chain() { + let config = LuaFormatConfig { + max_line_width: 40, + ..Default::default() + }; + let input = "local x = obj:method1():method2():method3()\n"; + + let first = crate::reformat_lua_code(input, &config); + let second = crate::reformat_lua_code(&first, &config); + assert_eq!( + first, second, + "Formatter is not idempotent for method chains!\nFirst pass:\n{first}\nSecond pass:\n{second}" + ); + } + + #[test] + fn test_idempotency_shebang() { + let config = LuaFormatConfig::default(); + let input = "#!/usr/bin/lua\nlocal a = 1\n"; + + let first = crate::reformat_lua_code(input, &config); + let second = crate::reformat_lua_code(&first, &config); + assert_eq!( + first, second, + "Formatter is not idempotent with shebang!\nFirst pass:\n{first}\nSecond pass:\n{second}" + ); + } +} diff --git a/crates/emmylua_formatter/src/test/mod.rs b/crates/emmylua_formatter/src/test/mod.rs index 2b2fd2a80..66f1cbfd3 100644 --- a/crates/emmylua_formatter/src/test/mod.rs +++ b/crates/emmylua_formatter/src/test/mod.rs @@ -1,19 +1,7 @@ -#[allow(clippy::module_inception)] -#[cfg(test)] -mod test { - use crate::{reformat_lua_code, styles::LuaCodeStyle}; - - #[test] - fn test_reformat_lua_code() { - let code = r#" - local a = 1 - local b = 2 - local c = a+b - print (c ) - "#; - - let styles = LuaCodeStyle::default(); - let formatted_code = reformat_lua_code(code, &styles); - println!("Formatted code:\n{}", formatted_code); - } -} +mod breaking_tests; +mod comment_tests; +mod config_tests; +mod expression_tests; +mod misc_tests; +mod statement_tests; +mod test_helper; diff --git a/crates/emmylua_formatter/src/test/statement_tests.rs b/crates/emmylua_formatter/src/test/statement_tests.rs new file mode 100644 index 000000000..5fc4770f2 --- /dev/null +++ b/crates/emmylua_formatter/src/test/statement_tests.rs @@ -0,0 +1,386 @@ +#[cfg(test)] +mod tests { + // ========== if statement ========== + + use crate::assert_format; + + #[test] + fn test_if_stat() { + assert_format!( + r#" +if true then +print(1) +end +"#, + r#" +if true then + print(1) +end +"# + ); + } + + #[test] + fn test_if_elseif_else() { + assert_format!( + r#" +if a then +print(1) +elseif b then +print(2) +else +print(3) +end +"#, + r#" +if a then + print(1) +elseif b then + print(2) +else + print(3) +end +"# + ); + } + + // ========== for loop ========== + + #[test] + fn test_for_loop() { + assert_format!( + r#" +for i = 1, 10 do +print(i) +end +"#, + r#" +for i = 1, 10 do + print(i) +end +"# + ); + } + + #[test] + fn test_for_range() { + assert_format!( + r#" +for k, v in pairs(t) do +print(k, v) +end +"#, + r#" +for k, v in pairs(t) do + print(k, v) +end +"# + ); + } + + // ========== while / repeat / do ========== + + #[test] + fn test_while_loop() { + assert_format!( + r#" +while x > 0 do +x = x - 1 +end +"#, + r#" +while x > 0 do + x = x - 1 +end +"# + ); + } + + #[test] + fn test_repeat_until() { + assert_format!( + r#" +repeat +x = x + 1 +until x > 10 +"#, + r#" +repeat + x = x + 1 +until x > 10 +"# + ); + } + + #[test] + fn test_do_block() { + assert_format!( + r#" +do +local x = 1 +end +"#, + r#" +do + local x = 1 +end +"# + ); + } + + // ========== function definition ========== + + #[test] + fn test_function_def() { + assert_format!( + r#" +function foo(a, b) +return a + b +end +"#, + r#" +function foo(a, b) + return a + b +end +"# + ); + } + + #[test] + fn test_local_function() { + assert_format!( + r#" +local function bar(x) +return x * 2 +end +"#, + r#" +local function bar(x) + return x * 2 +end +"# + ); + } + + #[test] + fn test_varargs_function() { + assert_format!( + r#" +function foo(a, b, ...) +print(a, b, ...) +end +"#, + r#" +function foo(a, b, ...) + print(a, b, ...) +end +"# + ); + } + + #[test] + fn test_varargs_closure() { + assert_format!( + r#" +local f = function(...) +return ... +end +"#, + r#" +local f = function(...) + return ... +end +"# + ); + } + + // ========== assignment ========== + + #[test] + fn test_multi_assign() { + assert_format!("a, b = 1, 2\n", "a, b = 1, 2\n"); + } + + // ========== return ========== + + #[test] + fn test_return_multi() { + assert_format!( + r#" +function f() +return 1, 2, 3 +end +"#, + r#" +function f() + return 1, 2, 3 +end +"# + ); + } + + // ========== goto / label / break ========== + + #[test] + fn test_goto_label() { + assert_format!( + r#" +goto done +::done:: +print(1) +"#, + r#" +goto done +::done:: +print(1) +"# + ); + } + + #[test] + fn test_break_stat() { + assert_format!( + r#" +while true do +break +end +"#, + r#" +while true do + break +end +"# + ); + } + + // ========== comprehensive reformat ========== + + #[test] + fn test_reformat_lua_code() { + assert_format!( + r#" + local a = 1 + local b = 2 + local c = a+b + print (c ) +"#, + r#" +local a = 1 +local b = 2 +local c = a + b +print(c) +"# + ); + } + + // ========== empty body compact output ========== + + #[test] + fn test_empty_function() { + assert_format!( + r#" +function foo() +end +"#, + "function foo() end\n" + ); + } + + #[test] + fn test_empty_function_with_params() { + assert_format!( + r#" +function foo(a, b) +end +"#, + "function foo(a, b) end\n" + ); + } + + #[test] + fn test_empty_do_block() { + assert_format!( + r#" +do +end +"#, + "do end\n" + ); + } + + #[test] + fn test_empty_while_loop() { + assert_format!( + r#" +while true do +end +"#, + "while true do end\n" + ); + } + + #[test] + fn test_empty_for_loop() { + assert_format!( + r#" +for i = 1, 10 do +end +"#, + "for i = 1, 10 do end\n" + ); + } + + // ========== semicolon ========== + + #[test] + fn test_semicolon_preserved() { + assert_format!(";\n", ";\n"); + } + + // ========== local attributes ========== + + #[test] + fn test_local_const() { + assert_format!("local x = 42\n", "local x = 42\n"); + } + + #[test] + fn test_local_close() { + assert_format!( + "local f = io.open(\"test.txt\")\n", + "local f = io.open(\"test.txt\")\n" + ); + } + + #[test] + fn test_local_const_multi() { + assert_format!( + "local a , b = 1, 2\n", + "local a , b = 1, 2\n" + ); + } + + // ========== local function empty body compact ========== + + #[test] + fn test_empty_local_function() { + assert_format!( + r#" +local function foo() +end +"#, + "local function foo() end\n" + ); + } + + #[test] + fn test_empty_local_function_with_params() { + assert_format!( + r#" +local function foo(a, b) +end +"#, + "local function foo(a, b) end\n" + ); + } +} diff --git a/crates/emmylua_formatter/src/test/test_helper.rs b/crates/emmylua_formatter/src/test/test_helper.rs new file mode 100644 index 000000000..86e9137ca --- /dev/null +++ b/crates/emmylua_formatter/src/test/test_helper.rs @@ -0,0 +1,48 @@ +#[macro_export] +macro_rules! assert_format_with_config { + ($input:expr, $expected:expr, $config:expr) => {{ + let input = $input.trim_start_matches('\n'); + let expected = $expected.trim_start_matches('\n'); + let result = $crate::reformat_lua_code(input, &$config); + if result != expected { + let result_lines: Vec<&str> = result.lines().collect(); + let expected_lines: Vec<&str> = expected.lines().collect(); + let max_lines = result_lines.len().max(expected_lines.len()); + + let mut diff = String::new(); + diff.push_str("=== Formatting mismatch ===\n"); + diff.push_str(&format!("Input:\n{:?}\n\n", input)); + diff.push_str(&format!( + "Expected ({} lines):\n{:?}\n\n", + expected_lines.len(), + expected + )); + diff.push_str(&format!( + "Got ({} lines):\n{:?}\n\n", + result_lines.len(), + &result + )); + + diff.push_str("Line diff:\n"); + for i in 0..max_lines { + let exp = expected_lines.get(i).unwrap_or(&""); + let got = result_lines.get(i).unwrap_or(&""); + if exp != got { + diff.push_str(&format!(" line {}: DIFFER\n", i + 1)); + diff.push_str(&format!(" expected: {:?}\n", exp)); + diff.push_str(&format!(" got: {:?}\n", got)); + } + } + + panic!("{}", diff); + } + }}; +} + +#[macro_export] +macro_rules! assert_format { + ($input:expr, $expected:expr) => {{ + let config = $crate::config::LuaFormatConfig::default(); + $crate::assert_format_with_config!($input, $expected, config) + }}; +} From 59863d30874f3e64c8890439e5add6f2fb29cae5 Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Tue, 10 Mar 2026 19:55:15 +0800 Subject: [PATCH 03/10] update inline comment handle --- .../emmylua_formatter/src/formatter/block.rs | 121 +++++++++++++++--- .../src/formatter/comment.rs | 20 +-- .../src/formatter/expression.rs | 95 +++++++++----- crates/emmylua_formatter/src/ir/doc_ir.rs | 9 +- crates/emmylua_formatter/src/lib.rs | 8 +- crates/emmylua_formatter/src/printer/mod.rs | 80 ++++++++++-- 6 files changed, 254 insertions(+), 79 deletions(-) diff --git a/crates/emmylua_formatter/src/formatter/block.rs b/crates/emmylua_formatter/src/formatter/block.rs index 265a0d340..021c6fafe 100644 --- a/crates/emmylua_formatter/src/formatter/block.rs +++ b/crates/emmylua_formatter/src/formatter/block.rs @@ -6,7 +6,7 @@ use rowan::TextRange; use crate::ir::{self, AlignEntry, DocIR}; use super::FormatContext; -use super::comment::{format_comment, format_trailing_comment}; +use super::comment::{extract_trailing_comment, format_comment, format_trailing_comment}; use super::statement::{format_stat, format_stat_eq_split, is_eq_alignable}; use super::trivia::count_blank_lines_before; @@ -106,7 +106,7 @@ pub fn format_block(ctx: &FormatContext, block: &LuaBlock) -> Vec { } if group_end - group_start >= 2 { - // Emit alignment group + // Emit = alignment group if !is_first { let blank_lines = count_blank_lines_before(children[group_start].syntax()); @@ -119,26 +119,42 @@ pub fn format_block(ctx: &FormatContext, block: &LuaBlock) -> Vec { let mut entries = Vec::new(); for child in children.iter().take(group_end).skip(group_start) { if let BlockChild::Statement(s) = child { - if let Some((before, after)) = format_stat_eq_split(ctx, s) { - entries.push(AlignEntry::Aligned { before, after }); + // Extract trailing comment for IR-level alignment + let trailing = if ctx.config.align_continuous_line_comment { + extract_trailing_comment(s.syntax()).map( + |(trail_docs, range)| { + consumed_comment_ranges.push(range); + trail_docs + }, + ) } else { - entries.push(AlignEntry::Line(format_stat(ctx, s))); - } - // Handle trailing comment (as LineSuffix on the last doc) - if let Some((trailing_ir, range)) = - format_trailing_comment(s.syntax()) - { - // Attach trailing comment to the last entry - match entries.last_mut() { - Some(AlignEntry::Aligned { after, .. }) => { - after.push(trailing_ir); - } - Some(AlignEntry::Line(content)) => { - content.push(trailing_ir); - } - None => {} + None + }; + + if let Some((before, mut after)) = format_stat_eq_split(ctx, s) { + // When not using trailing alignment, attach as LineSuffix + if trailing.is_none() + && let Some((trailing_ir, range)) = + format_trailing_comment(s.syntax()) + { + after.push(trailing_ir); + consumed_comment_ranges.push(range); + } + entries.push(AlignEntry::Aligned { + before, + after, + trailing, + }); + } else { + let mut content = format_stat(ctx, s); + if trailing.is_none() + && let Some((trailing_ir, range)) = + format_trailing_comment(s.syntax()) + { + content.push(trailing_ir); + consumed_comment_ranges.push(range); } - consumed_comment_ranges.push(range); + entries.push(AlignEntry::Line { content, trailing }); } } } @@ -151,6 +167,71 @@ pub fn format_block(ctx: &FormatContext, block: &LuaBlock) -> Vec { } } + // Try to form a comment-only alignment group + if ctx.config.align_continuous_line_comment + && extract_trailing_comment(stat.syntax()).is_some() + { + let group_start = i; + let mut group_end = i + 1; + while group_end < children.len() { + match &children[group_end] { + BlockChild::Statement(next_stat) => { + let blank_lines = count_blank_lines_before(next_stat.syntax()); + if blank_lines > 0 { + break; + } + if extract_trailing_comment(next_stat.syntax()).is_some() { + group_end += 1; + } else { + break; + } + } + BlockChild::Comment(_) => { + group_end += 1; + continue; + } + } + } + + let stmt_count = children[group_start..group_end] + .iter() + .filter(|c| matches!(c, BlockChild::Statement(_))) + .count(); + + if stmt_count >= 2 { + if !is_first { + let blank_lines = + count_blank_lines_before(children[group_start].syntax()); + let normalized = blank_lines.min(ctx.config.max_blank_lines); + for _ in 0..normalized { + docs.push(ir::hard_line()); + } + } + + let mut entries = Vec::new(); + for child in children.iter().take(group_end).skip(group_start) { + if let BlockChild::Statement(s) = child { + let trailing = extract_trailing_comment(s.syntax()).map( + |(trail_docs, range)| { + consumed_comment_ranges.push(range); + trail_docs + }, + ); + entries.push(AlignEntry::Line { + content: format_stat(ctx, s), + trailing, + }); + } + } + + docs.push(ir::align_group(entries)); + docs.push(ir::hard_line()); + is_first = false; + i = group_end; + continue; + } + } + // Normal (non-aligned) statement if !is_first { let blank_lines = count_blank_lines_before(stat.syntax()); diff --git a/crates/emmylua_formatter/src/formatter/comment.rs b/crates/emmylua_formatter/src/formatter/comment.rs index 2ce08383e..078be6c03 100644 --- a/crates/emmylua_formatter/src/formatter/comment.rs +++ b/crates/emmylua_formatter/src/formatter/comment.rs @@ -56,10 +56,9 @@ pub fn collect_orphan_comments(node: &LuaSyntaxNode) -> Vec { } docs } -/// -/// Find a Comment node on the same line after a statement node; -/// if found, attach it to the end of line using LineSuffix. -pub fn format_trailing_comment(node: &LuaSyntaxNode) -> Option<(DocIR, TextRange)> { +/// Extract a trailing comment on the same line after a syntax node. +/// Returns the raw comment docs (NOT wrapped in LineSuffix) and the text range. +pub fn extract_trailing_comment(node: &LuaSyntaxNode) -> Option<(Vec, TextRange)> { let mut next = node.next_sibling_or_token(); // Look ahead at most 4 elements (skipping whitespace, commas, semicolons) @@ -80,10 +79,7 @@ pub fn format_trailing_comment(node: &LuaSyntaxNode) -> Option<(DocIR, TextRange } let range = comment_node.text_range(); - return Some(( - ir::line_suffix(vec![ir::space(), ir::text(comment_text)]), - range, - )); + return Some((vec![ir::text(comment_text)], range)); } _ => return None, } @@ -92,3 +88,11 @@ pub fn format_trailing_comment(node: &LuaSyntaxNode) -> Option<(DocIR, TextRange None } + +/// Format a trailing comment as LineSuffix (for non-grouped use). +pub fn format_trailing_comment(node: &LuaSyntaxNode) -> Option<(DocIR, TextRange)> { + let (docs, range) = extract_trailing_comment(node)?; + let mut suffix_content = vec![ir::space()]; + suffix_content.extend(docs); + Some((ir::line_suffix(suffix_content), range)) +} diff --git a/crates/emmylua_formatter/src/formatter/expression.rs b/crates/emmylua_formatter/src/formatter/expression.rs index 075aa8894..51a02f6f6 100644 --- a/crates/emmylua_formatter/src/formatter/expression.rs +++ b/crates/emmylua_formatter/src/formatter/expression.rs @@ -9,7 +9,7 @@ use crate::config::ExpandStrategy; use crate::ir::{self, AlignEntry, DocIR, EqSplit}; use super::FormatContext; -use super::comment::{format_comment, format_trailing_comment}; +use super::comment::{extract_trailing_comment, format_comment}; /// 格式化表达式(分派) pub fn format_expr(ctx: &FormatContext, expr: &LuaExpr) -> Vec { @@ -349,13 +349,13 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { } else { None }; - let trailing_comment = if let Some((c, range)) = format_trailing_comment(field.syntax()) - { - consumed_comment_ranges.push(range); - Some(c) - } else { - None - }; + let trailing_comment = + if let Some((docs, range)) = extract_trailing_comment(field.syntax()) { + consumed_comment_ranges.push(range); + Some(docs) + } else { + None + }; entries.push(TableEntry::Field { doc: fdoc, eq_split, @@ -578,7 +578,8 @@ enum TableEntry { doc: Vec, /// Split at `=` for alignment: (key_docs, eq_value_docs) eq_split: Option, - trailing_comment: Option, + /// Raw trailing comment docs (NOT wrapped in LineSuffix) + trailing_comment: Option>, }, StandaloneComment(Vec), } @@ -638,16 +639,17 @@ fn build_table_expanded_inner( } else { after_with_comma.push(ir::text(",")); } - if let Some(comment) = trailing_comment { - after_with_comma.push(comment.clone()); - } align_entries.push(AlignEntry::Aligned { before: before.clone(), after: after_with_comma, + trailing: trailing_comment.clone(), }); } TableEntry::StandaloneComment(comment_docs) => { - align_entries.push(AlignEntry::Line(comment_docs.clone())); + align_entries.push(AlignEntry::Line { + content: comment_docs.clone(), + trailing: None, + }); } TableEntry::Field { doc, @@ -661,10 +663,10 @@ fn build_table_expanded_inner( } else { line.push(ir::text(",")); } - if let Some(comment) = trailing_comment { - line.push(comment.clone()); - } - align_entries.push(AlignEntry::Line(line)); + align_entries.push(AlignEntry::Line { + content: line, + trailing: trailing_comment.clone(), + }); } } } @@ -688,8 +690,10 @@ fn build_table_expanded_inner( } else { inner.push(ir::text(",")); } - if let Some(comment) = trailing_comment { - inner.push(comment.clone()); + if let Some(comment_docs) = trailing_comment { + let mut suffix = vec![ir::space()]; + suffix.extend(comment_docs.clone()); + inner.push(ir::line_suffix(suffix)); } } TableEntry::StandaloneComment(comment_docs) => { @@ -717,8 +721,10 @@ fn build_table_expanded_inner( inner.push(ir::text(",")); } - if let Some(comment) = trailing_comment { - inner.push(comment.clone()); + if let Some(comment_docs) = trailing_comment { + let mut suffix = vec![ir::space()]; + suffix.extend(comment_docs.clone()); + inner.push(ir::line_suffix(suffix)); } } TableEntry::StandaloneComment(comment_docs) => { @@ -813,7 +819,8 @@ fn format_trailing_comma_ir(policy: crate::config::TrailingComma) -> DocIR { /// 参数条目 struct ParamEntry { doc: Vec, - trailing_comment: Option, + /// Raw trailing comment docs (NOT wrapped in LineSuffix) + trailing_comment: Option>, } /// 格式化函数参数列表(支持参数注释) @@ -834,9 +841,9 @@ pub fn format_params_ir(ctx: &FormatContext, params: &emmylua_parser::LuaParamLi continue; }; - let trailing_comment = if let Some((c, range)) = format_trailing_comment(p.syntax()) { + let trailing_comment = if let Some((docs, range)) = extract_trailing_comment(p.syntax()) { consumed_comment_ranges.push(range); - Some(c) + Some(docs) } else { None }; @@ -854,20 +861,40 @@ pub fn format_params_ir(ctx: &FormatContext, params: &emmylua_parser::LuaParamLi let has_comments = entries.iter().any(|e| e.trailing_comment.is_some()); if has_comments { - // 有注释:强制多行展开 + // 有注释:强制多行展开,使用 AlignGroup 对齐注释 let len = entries.len(); - let mut inner = Vec::new(); - for (i, entry) in entries.into_iter().enumerate() { - inner.push(ir::hard_line()); - inner.extend(entry.doc); - if i < len - 1 { - inner.push(ir::text(",")); + if ctx.config.align_continuous_line_comment { + let mut align_entries = Vec::new(); + for (i, entry) in entries.into_iter().enumerate() { + let mut content = entry.doc; + if i < len - 1 { + content.push(ir::text(",")); + } + align_entries.push(AlignEntry::Line { + content, + trailing: entry.trailing_comment, + }); } - if let Some(comment) = entry.trailing_comment { - inner.push(comment); + vec![ir::group_break(vec![ + ir::indent(vec![ir::hard_line(), ir::align_group(align_entries)]), + ir::hard_line(), + ])] + } else { + let mut inner = Vec::new(); + for (i, entry) in entries.into_iter().enumerate() { + inner.push(ir::hard_line()); + inner.extend(entry.doc); + if i < len - 1 { + inner.push(ir::text(",")); + } + if let Some(comment_docs) = entry.trailing_comment { + let mut suffix = vec![ir::space()]; + suffix.extend(comment_docs); + inner.push(ir::line_suffix(suffix)); + } } + vec![ir::group_break(vec![ir::indent(inner), ir::hard_line()])] } - vec![ir::group_break(vec![ir::indent(inner), ir::hard_line()])] } else { // 无注释:使用配置的展开策略 let param_docs: Vec> = entries.into_iter().map(|e| e.doc).collect(); diff --git a/crates/emmylua_formatter/src/ir/doc_ir.rs b/crates/emmylua_formatter/src/ir/doc_ir.rs index 7785a5c99..b1bdc5a18 100644 --- a/crates/emmylua_formatter/src/ir/doc_ir.rs +++ b/crates/emmylua_formatter/src/ir/doc_ir.rs @@ -70,12 +70,17 @@ pub type EqSplit = (Vec, Vec); pub enum AlignEntry { /// A line split at the alignment point. /// `before` is padded to the max width across the group, then `after` is appended. + /// `trailing` (if present) is a trailing comment aligned to a common column. Aligned { before: Vec, after: Vec, + trailing: Option>, + }, + /// A non-aligned line (e.g., standalone comment or non-= statement with trailing comment) + Line { + content: Vec, + trailing: Option>, }, - /// A non-aligned line (e.g., standalone comment) kept in sequence - Line(Vec), } /// Compute the flat (single-line) width of an IR slice. diff --git a/crates/emmylua_formatter/src/lib.rs b/crates/emmylua_formatter/src/lib.rs index 5e779df7a..407d4e804 100644 --- a/crates/emmylua_formatter/src/lib.rs +++ b/crates/emmylua_formatter/src/lib.rs @@ -28,13 +28,7 @@ pub fn reformat_lua_code(code: &str, config: &LuaFormatConfig) -> String { let chunk = tree.get_chunk_node(); let ir = formatter::format_chunk(&ctx, &chunk); - let mut output = Printer::new(config).print(&ir); - let newline = config.newline_str(); - - // Post-processing: trailing comment alignment (text-based) - if config.align_continuous_line_comment { - output = printer::alignment::align_trailing_comments(&output, newline); - } + let output = Printer::new(config).print(&ir); if shebang.is_empty() { output diff --git a/crates/emmylua_formatter/src/printer/mod.rs b/crates/emmylua_formatter/src/printer/mod.rs index 935622ded..cbc9dbe75 100644 --- a/crates/emmylua_formatter/src/printer/mod.rs +++ b/crates/emmylua_formatter/src/printer/mod.rs @@ -1,4 +1,3 @@ -pub(crate) mod alignment; mod test; use std::collections::HashMap; @@ -257,18 +256,32 @@ impl Printer { // For fit checking, treat as all entries printed flat for entry in &group.entries { match entry { - AlignEntry::Aligned { before, after } => { + AlignEntry::Aligned { + before, + after, + trailing, + } => { for d in before.iter().rev() { stack.push((d, mode)); } for d in after.iter().rev() { stack.push((d, mode)); } + if let Some(trail) = trailing { + for d in trail.iter().rev() { + stack.push((d, mode)); + } + } } - AlignEntry::Line(content) => { + AlignEntry::Line { content, trailing } => { for d in content.iter().rev() { stack.push((d, mode)); } + if let Some(trail) = trailing { + for d in trail.iter().rev() { + stack.push((d, mode)); + } + } } } } @@ -349,25 +362,56 @@ impl Printer { let _ = mode; } - /// Print an alignment group: pad each entry's `before` to the max width so `after` parts align. + /// Print an alignment group with up to three-column alignment: + /// Column 1: `before` (padded to max_before) + /// Column 2: `after` + /// Column 3: `trailing` comment (padded to max content width) fn print_align_group(&mut self, entries: &[AlignEntry], mode: PrintMode) { - // Compute max flat width of `before` parts across all Aligned entries + // Phase 1: Compute max flat width of `before` parts across all Aligned entries let max_before = entries .iter() .filter_map(|e| match e { AlignEntry::Aligned { before, .. } => Some(ir_flat_width(before)), - AlignEntry::Line(_) => None, + AlignEntry::Line { .. } => None, }) .max() .unwrap_or(0); + // Phase 2: Compute max content width for trailing comment alignment + let has_any_trailing = entries.iter().any(|e| match e { + AlignEntry::Aligned { trailing, .. } | AlignEntry::Line { trailing, .. } => { + trailing.is_some() + } + }); + + let max_content_width = if has_any_trailing { + entries + .iter() + .map(|e| match e { + AlignEntry::Aligned { after, .. } => { + // before is padded to max_before, then " ", then after + max_before + 1 + ir_flat_width(after) + } + AlignEntry::Line { content, .. } => ir_flat_width(content), + }) + .max() + .unwrap_or(0) + } else { + 0 + }; + + // Phase 3: Print each entry for (i, entry) in entries.iter().enumerate() { if i > 0 { self.flush_line_suffixes(); self.push_newline(); } match entry { - AlignEntry::Aligned { before, after } => { + AlignEntry::Aligned { + before, + after, + trailing, + } => { let before_width = ir_flat_width(before); self.print_docs(before, mode); let padding = max_before - before_width; @@ -376,9 +420,29 @@ impl Printer { } self.push_text(" "); self.print_docs(after, mode); + + if let Some(trail) = trailing { + let content_width = max_before + 1 + ir_flat_width(after); + let trail_padding = max_content_width.saturating_sub(content_width); + if trail_padding > 0 { + self.push_text(&" ".repeat(trail_padding)); + } + self.push_text(" "); + self.print_docs(trail, mode); + } } - AlignEntry::Line(content) => { + AlignEntry::Line { content, trailing } => { self.print_docs(content, mode); + + if let Some(trail) = trailing { + let content_width = ir_flat_width(content); + let trail_padding = max_content_width.saturating_sub(content_width); + if trail_padding > 0 { + self.push_text(&" ".repeat(trail_padding)); + } + self.push_text(" "); + self.print_docs(trail, mode); + } } } } From ace20dfceac5988bf2c1a033bd059793d545d04c Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Tue, 10 Mar 2026 20:55:52 +0800 Subject: [PATCH 04/10] update --- crates/emmylua_formatter/src/config/mod.rs | 9 ++ .../src/formatter/comment.rs | 81 +++++++++---- .../src/formatter/expression.rs | 43 +++++-- crates/emmylua_formatter/src/formatter/mod.rs | 13 +- .../src/formatter/spacing.rs | 96 +++++++++++++++ .../src/formatter/statement.rs | 33 ++++-- crates/emmylua_formatter/src/lib.rs | 20 +--- .../src/printer/alignment.rs | 111 ------------------ .../src/test/comment_tests.rs | 37 ++++++ .../src/test/config_tests.rs | 107 +++++++++++++++++ .../emmylua_formatter/src/test/misc_tests.rs | 11 ++ 11 files changed, 389 insertions(+), 172 deletions(-) create mode 100644 crates/emmylua_formatter/src/formatter/spacing.rs delete mode 100644 crates/emmylua_formatter/src/printer/alignment.rs diff --git a/crates/emmylua_formatter/src/config/mod.rs b/crates/emmylua_formatter/src/config/mod.rs index 827b15890..1b6b52d27 100644 --- a/crates/emmylua_formatter/src/config/mod.rs +++ b/crates/emmylua_formatter/src/config/mod.rs @@ -23,6 +23,12 @@ pub struct LuaFormatConfig { pub space_inside_braces: bool, pub space_inside_parens: bool, pub space_inside_brackets: bool, + /// Space around arithmetic operators: + - * / // % ^ + pub space_around_math_operator: bool, + /// Space around string concatenation operator: .. + pub space_around_concat_operator: bool, + /// Space around assign operator: = + pub space_around_assign_operator: bool, // ===== End of line ===== pub end_of_line: EndOfLine, @@ -55,6 +61,9 @@ impl Default for LuaFormatConfig { space_inside_braces: true, space_inside_parens: false, space_inside_brackets: false, + space_around_math_operator: true, + space_around_concat_operator: true, + space_around_assign_operator: true, table_expand: ExpandStrategy::Auto, call_args_expand: ExpandStrategy::Auto, func_params_expand: ExpandStrategy::Auto, diff --git a/crates/emmylua_formatter/src/formatter/comment.rs b/crates/emmylua_formatter/src/formatter/comment.rs index 078be6c03..60e166c2c 100644 --- a/crates/emmylua_formatter/src/formatter/comment.rs +++ b/crates/emmylua_formatter/src/formatter/comment.rs @@ -5,38 +5,77 @@ use crate::ir::{self, DocIR}; /// Format a Comment node. /// -/// Comment is a syntax node in the CST (LuaSyntaxKind::Comment), -/// which can be a single-line comment (`-- ...`) or a multi-line comment (`--[[ ... ]]`). -/// We preserve the original comment text and only handle indentation (managed by Printer's indent). +/// Dispatches between three comment types: +/// - Doc comments (`---@...`): walk the syntax tree, normalize whitespace +/// - Long comments (`--[[ ... ]]`): preserve content as-is +/// - Normal comments (`-- ...`): preserve text with trimming pub fn format_comment(comment: &LuaComment) -> Vec { let text = comment.syntax().text().to_string(); - let text = text.trim_end(); - // Multi-line comment: split by lines, each line as a Text + HardLine - let lines: Vec<&str> = text.lines().collect(); + // Long comments (--[[ ... ]]): preserve content exactly (like long strings) + if text.starts_with("--[[") || text.starts_with("--[=") { + return vec![ir::text(text.trim_end())]; + } - if lines.len() <= 1 { - // Single-line comment - return vec![ir::text(text)]; + // Doc comments: walk the parsed syntax tree to normalize whitespace + if comment.get_doc_tags().next().is_some() || comment.get_description().is_some() { + return format_doc_comment(comment); } - // Multi-line content (doc comments or --[[ ]] block comments) + // Normal single-line comment: preserve text + let text = text.trim_end(); + vec![ir::text(text)] +} + +/// Format a doc comment by walking its syntax tree token-by-token. +/// +/// Only flat formatting is used (Text, Space, HardLine) — no Group/SoftLine +/// since comments cannot have breaking rules. +fn format_doc_comment(comment: &LuaComment) -> Vec { let mut docs = Vec::new(); - for (i, line) in lines.iter().enumerate() { - if i > 0 { - docs.push(ir::hard_line()); - } - let trimmed = line.trim_start(); - if trimmed.is_empty() { - // Preserve empty lines - continue; - } - docs.push(ir::text(trimmed)); + let mut last_was_space = false; + walk_doc_tokens(comment.syntax(), &mut docs, &mut last_was_space); + // Trim trailing whitespace + while matches!(docs.last(), Some(DocIR::Space)) { + docs.pop(); } - docs } +/// Recursively walk a doc comment node, emitting flat IR for each token. +fn walk_doc_tokens(node: &LuaSyntaxNode, docs: &mut Vec, last_was_space: &mut bool) { + for child in node.children_with_tokens() { + match child { + rowan::NodeOrToken::Token(token) => { + let kind: LuaTokenKind = token.kind().into(); + match kind { + LuaTokenKind::TkWhitespace => { + if !*last_was_space { + docs.push(ir::space()); + *last_was_space = true; + } + } + LuaTokenKind::TkEndOfLine => { + // Remove trailing space before line break + if *last_was_space { + docs.pop(); + } + docs.push(ir::hard_line()); + *last_was_space = true; // prevent space at start of next line + } + _ => { + docs.push(ir::text(token.text())); + *last_was_space = false; + } + } + } + rowan::NodeOrToken::Node(child_node) => { + walk_doc_tokens(&child_node, docs, last_was_space); + } + } + } +} + /// Collect "orphan" comments in a syntax node. /// /// When a Block is empty (e.g. `if x then -- comment end`), diff --git a/crates/emmylua_formatter/src/formatter/expression.rs b/crates/emmylua_formatter/src/formatter/expression.rs index 51a02f6f6..b35c10f6d 100644 --- a/crates/emmylua_formatter/src/formatter/expression.rs +++ b/crates/emmylua_formatter/src/formatter/expression.rs @@ -1,7 +1,7 @@ use emmylua_parser::{ - LuaAstNode, LuaAstToken, LuaBinaryExpr, LuaCallExpr, LuaClosureExpr, LuaComment, LuaExpr, - LuaIndexExpr, LuaKind, LuaLiteralExpr, LuaNameExpr, LuaParenExpr, LuaSyntaxKind, LuaTableExpr, - LuaTableField, LuaUnaryExpr, UnaryOperator, + BinaryOperator, LuaAstNode, LuaAstToken, LuaBinaryExpr, LuaCallExpr, LuaClosureExpr, + LuaComment, LuaExpr, LuaIndexExpr, LuaKind, LuaLiteralExpr, LuaNameExpr, LuaParenExpr, + LuaSyntaxKind, LuaTableExpr, LuaTableField, LuaUnaryExpr, UnaryOperator, }; use rowan::TextRange; @@ -10,8 +10,8 @@ use crate::ir::{self, AlignEntry, DocIR, EqSplit}; use super::FormatContext; use super::comment::{extract_trailing_comment, format_comment}; +use super::spacing::{SpaceRule, space_around_assign, space_around_binary_op}; -/// 格式化表达式(分派) pub fn format_expr(ctx: &FormatContext, expr: &LuaExpr) -> Vec { match expr { LuaExpr::NameExpr(e) => format_name_expr(ctx, e), @@ -26,7 +26,6 @@ pub fn format_expr(ctx: &FormatContext, expr: &LuaExpr) -> Vec { } } -/// 标识符: name fn format_name_expr(_ctx: &FormatContext, expr: &LuaNameExpr) -> Vec { if let Some(name) = expr.get_name_text() { vec![ir::text(name)] @@ -35,7 +34,6 @@ fn format_name_expr(_ctx: &FormatContext, expr: &LuaNameExpr) -> Vec { } } -/// 字面量: 1, "hello", true, nil, ... fn format_literal_expr(_ctx: &FormatContext, expr: &LuaLiteralExpr) -> Vec { // 直接使用原始文本 vec![ir::text(expr.syntax().text().to_string())] @@ -55,13 +53,33 @@ fn format_binary_expr(ctx: &FormatContext, expr: &LuaBinaryExpr) -> Vec { if let Some(op_token) = expr.get_op_token() { let op_text = op_token.syntax().text().to_string(); + let op = op_token.get_op(); + let space_rule = space_around_binary_op(op, ctx.config); + let space_ir = space_rule.to_ir(); + + // Safety: when the left operand text ends with '.' and the operator + // is '..', we must force a space before the operator to avoid + // ambiguity (e.g. `1. ..` must not become `1...`). + // Only the before-space is forced; the after-space follows the + // configured space_rule. + let force_space_before = op == BinaryOperator::OpConcat + && space_rule == SpaceRule::NoSpace + && left.syntax().text().to_string().ends_with('.'); + + // Before-operator break: soft_line (→space when flat) if space, + // soft_line_or_empty (→"" when flat) if no space + let break_ir = if !force_space_before && space_rule == SpaceRule::NoSpace { + ir::soft_line_or_empty() + } else { + ir::soft_line() + }; return vec![ir::group(vec![ ir::list(left_docs), ir::indent(vec![ - ir::soft_line(), + break_ir, ir::text(op_text), - ir::space(), + space_ir, ir::list(right_docs), ]), ])]; @@ -506,9 +524,10 @@ fn format_table_field_ir(ctx: &FormatContext, field: &LuaTableField) -> Vec> = entries.into_iter().map(|e| e.doc).collect(); let inner = ir::intersperse(param_docs.clone(), vec![ir::text(","), ir::soft_line()]); diff --git a/crates/emmylua_formatter/src/formatter/mod.rs b/crates/emmylua_formatter/src/formatter/mod.rs index cd3cb1e69..94931542f 100644 --- a/crates/emmylua_formatter/src/formatter/mod.rs +++ b/crates/emmylua_formatter/src/formatter/mod.rs @@ -1,12 +1,13 @@ mod block; mod comment; mod expression; +pub mod spacing; mod statement; mod trivia; use crate::config::LuaFormatConfig; -use crate::ir::DocIR; -use emmylua_parser::LuaChunk; +use crate::ir::{self, DocIR}; +use emmylua_parser::{LuaAstNode, LuaChunk, LuaKind, LuaTokenKind}; pub use block::format_block; pub use statement::format_body_end_with_parent; @@ -26,6 +27,14 @@ impl<'a> FormatContext<'a> { pub fn format_chunk(ctx: &FormatContext, chunk: &LuaChunk) -> Vec { let mut docs = Vec::new(); + // Emit shebang if present (TkShebang is a trivia token in the syntax tree) + if let Some(first_token) = chunk.syntax().first_token() + && first_token.kind() == LuaKind::Token(LuaTokenKind::TkShebang) + { + docs.push(ir::text(first_token.text())); + docs.push(DocIR::HardLine); + } + if let Some(block) = chunk.get_block() { docs.extend(format_block(ctx, &block)); } diff --git a/crates/emmylua_formatter/src/formatter/spacing.rs b/crates/emmylua_formatter/src/formatter/spacing.rs new file mode 100644 index 000000000..868f7ecc0 --- /dev/null +++ b/crates/emmylua_formatter/src/formatter/spacing.rs @@ -0,0 +1,96 @@ +use emmylua_parser::BinaryOperator; + +use crate::config::LuaFormatConfig; +use crate::ir::{self, DocIR}; + +/// Spacing decision for a token boundary. +/// +/// This centralizes all "should there be a space here?" logic into a single +/// declarative system, decoupled from the recursive IR-building code. +/// +/// Format functions query this system instead of hard-coding `ir::space()`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub enum SpaceRule { + /// Must have exactly one space + Space, + /// Must have no space + NoSpace, + /// Soft line break — becomes space in flat mode, newline in break mode. + /// Use for positions that may line-wrap. + SoftLine, + /// Soft line break or empty — becomes empty in flat mode, newline in break mode + SoftLineOrEmpty, +} + +impl SpaceRule { + /// Convert a SpaceRule into the corresponding DocIR node + pub fn to_ir(self) -> DocIR { + match self { + SpaceRule::Space => ir::space(), + SpaceRule::NoSpace => ir::list(vec![]), + SpaceRule::SoftLine => ir::soft_line(), + SpaceRule::SoftLineOrEmpty => ir::soft_line_or_empty(), + } + } +} + +/// Resolve spacing around a binary operator. +/// +/// Controls whether spaces appear around `+`, `-`, `*`, `/`, `and`, `..`, etc. +pub fn space_around_binary_op(op: BinaryOperator, config: &LuaFormatConfig) -> SpaceRule { + match op { + // Arithmetic: + - * / // % ^ + BinaryOperator::OpAdd + | BinaryOperator::OpSub + | BinaryOperator::OpMul + | BinaryOperator::OpDiv + | BinaryOperator::OpIDiv + | BinaryOperator::OpMod + | BinaryOperator::OpPow => { + if config.space_around_math_operator { + SpaceRule::Space + } else { + SpaceRule::NoSpace + } + } + + // Comparison: == ~= < > <= >= + BinaryOperator::OpEq + | BinaryOperator::OpNe + | BinaryOperator::OpLt + | BinaryOperator::OpGt + | BinaryOperator::OpLe + | BinaryOperator::OpGe => SpaceRule::Space, + + // Logical: and or — always spaces (keyword operators) + BinaryOperator::OpAnd | BinaryOperator::OpOr => SpaceRule::Space, + + // Concatenation: .. + BinaryOperator::OpConcat => { + if config.space_around_concat_operator { + SpaceRule::Space + } else { + SpaceRule::NoSpace + } + } + + // Bitwise: & | ~ << >> + BinaryOperator::OpBAnd + | BinaryOperator::OpBOr + | BinaryOperator::OpBXor + | BinaryOperator::OpShl + | BinaryOperator::OpShr => SpaceRule::Space, + + BinaryOperator::OpNop => SpaceRule::Space, + } +} + +/// Resolve spacing around the assignment `=` operator. +pub fn space_around_assign(config: &LuaFormatConfig) -> SpaceRule { + if config.space_around_assign_operator { + SpaceRule::Space + } else { + SpaceRule::NoSpace + } +} diff --git a/crates/emmylua_formatter/src/formatter/statement.rs b/crates/emmylua_formatter/src/formatter/statement.rs index 992ffbc6e..4d6121cd3 100644 --- a/crates/emmylua_formatter/src/formatter/statement.rs +++ b/crates/emmylua_formatter/src/formatter/statement.rs @@ -10,6 +10,7 @@ use super::FormatContext; use super::block::format_block; use super::comment::collect_orphan_comments; use super::expression::format_expr; +use super::spacing::space_around_assign; /// Format a statement (dispatch) pub fn format_stat(ctx: &FormatContext, stat: &LuaStat) -> Vec { @@ -64,7 +65,8 @@ fn format_local_stat(ctx: &FormatContext, stat: &LuaLocalStat) -> Vec { // Value list let exprs: Vec<_> = stat.get_value_exprs().collect(); if !exprs.is_empty() { - docs.push(ir::space()); + let assign_space = space_around_assign(ctx.config).to_ir(); + docs.push(assign_space); docs.push(ir::text("=")); let expr_docs: Vec> = exprs.iter().map(|e| format_expr(ctx, e)).collect(); @@ -72,12 +74,18 @@ fn format_local_stat(ctx: &FormatContext, stat: &LuaLocalStat) -> Vec { // Single-value assignment to function/table: join with space, no line break if exprs.len() == 1 && is_block_like_expr(&exprs[0]) { - docs.push(ir::space()); + let assign_space_after = space_around_assign(ctx.config).to_ir(); + docs.push(assign_space_after); docs.push(ir::list(separated)); } else { // When value is too long, break after = and indent + let break_or_space = if ctx.config.space_around_assign_operator { + ir::soft_line() + } else { + ir::soft_line_or_empty() + }; docs.push(ir::group(vec![ir::indent(vec![ - ir::soft_line(), + break_or_space, ir::list(separated), ])])); } @@ -101,7 +109,8 @@ fn format_assign_stat(ctx: &FormatContext, stat: &LuaAssignStat) -> Vec { // Assignment operator if let Some(op) = stat.get_assign_op() { - docs.push(ir::space()); + let assign_space = space_around_assign(ctx.config).to_ir(); + docs.push(assign_space); docs.push(ir::text(op.syntax().text().to_string())); } @@ -111,12 +120,18 @@ fn format_assign_stat(ctx: &FormatContext, stat: &LuaAssignStat) -> Vec { // Single-value assignment to function/table: join with space, no line break if exprs.len() == 1 && is_block_like_expr(&exprs[0]) { - docs.push(ir::space()); + let assign_space_after = space_around_assign(ctx.config).to_ir(); + docs.push(assign_space_after); docs.push(ir::list(separated)); } else { // When value is too long, break after = and indent + let break_or_space = if ctx.config.space_around_assign_operator { + ir::soft_line() + } else { + ir::soft_line_or_empty() + }; docs.push(ir::group(vec![ir::indent(vec![ - ir::soft_line(), + break_or_space, ir::list(separated), ])])); } @@ -709,7 +724,8 @@ fn format_local_stat_eq_split(ctx: &super::FormatContext, stat: &LuaLocalStat) - } // Build RHS: "= value1, value2" - let mut after = vec![ir::text("="), ir::space()]; + let assign_space = space_around_assign(ctx.config).to_ir(); + let mut after = vec![ir::text("="), assign_space]; let expr_docs: Vec> = exprs.iter().map(|e| format_expr(ctx, e)).collect(); after.extend(ir::intersperse(expr_docs, vec![ir::text(","), ir::space()])); @@ -738,7 +754,8 @@ fn format_assign_stat_eq_split( if let Some(op) = stat.get_assign_op() { after.push(ir::text(op.syntax().text().to_string())); } - after.push(ir::space()); + let assign_space = space_around_assign(ctx.config).to_ir(); + after.push(assign_space); let expr_docs: Vec> = exprs.iter().map(|e| format_expr(ctx, e)).collect(); after.extend(ir::intersperse(expr_docs, vec![ir::text(","), ir::space()])); diff --git a/crates/emmylua_formatter/src/lib.rs b/crates/emmylua_formatter/src/lib.rs index 407d4e804..04bf2ce2a 100644 --- a/crates/emmylua_formatter/src/lib.rs +++ b/crates/emmylua_formatter/src/lib.rs @@ -12,27 +12,11 @@ use printer::Printer; pub use config::LuaFormatConfig; pub fn reformat_lua_code(code: &str, config: &LuaFormatConfig) -> String { - // Preserve shebang line (e.g. #!/usr/bin/lua) - let (shebang, lua_code) = if code.starts_with("#!") { - match code.find('\n') { - Some(pos) => (&code[..=pos], &code[pos + 1..]), - None => (code, ""), - } - } else { - ("", code) - }; - - let tree = LuaParser::parse(lua_code, ParserConfig::default()); + let tree = LuaParser::parse(code, ParserConfig::default()); let ctx = FormatContext::new(config); let chunk = tree.get_chunk_node(); let ir = formatter::format_chunk(&ctx, &chunk); - let output = Printer::new(config).print(&ir); - - if shebang.is_empty() { - output - } else { - format!("{}{}", shebang, output) - } + Printer::new(config).print(&ir) } diff --git a/crates/emmylua_formatter/src/printer/alignment.rs b/crates/emmylua_formatter/src/printer/alignment.rs deleted file mode 100644 index 5c1dc2967..000000000 --- a/crates/emmylua_formatter/src/printer/alignment.rs +++ /dev/null @@ -1,111 +0,0 @@ -//! Alignment post-processing module. -//! -//! After the Printer produces plain text output, this module performs -//! trailing comment alignment on consecutive lines. - -/// Align trailing comments on consecutive lines to the same column. -/// -/// Groups consecutive lines that have `--` trailing comments and pads -/// their code portion so the comments start at the same column. -/// ```text -/// local a = 1 -- short local a = 1 -- short -/// local bbb = 2 -- long var => local bbb = 2 -- long var -/// ``` -pub fn align_trailing_comments(text: &str, newline: &str) -> String { - let lines: Vec<&str> = text.lines().collect(); - let mut result_lines: Vec = Vec::with_capacity(lines.len()); - let mut i = 0; - - while i < lines.len() { - // Try to find a group of consecutive lines with trailing comments - if split_trailing_comment(lines[i]).is_some() { - let group_start = i; - let mut group_end = i + 1; - - // Scan forward for consecutive lines with trailing comments - while group_end < lines.len() { - if split_trailing_comment(lines[group_end]).is_some() { - group_end += 1; - } else { - break; - } - } - - if group_end - group_start >= 2 { - // Align only when there are at least 2 lines - let mut max_code_width = 0; - let mut entries: Vec<(&str, &str)> = Vec::new(); - - for line in lines.iter().take(group_end).skip(group_start) { - let (code, comment) = split_trailing_comment(line).unwrap(); - let code_trimmed = code.trim_end(); - max_code_width = max_code_width.max(code_trimmed.len()); - entries.push((code_trimmed, comment)); - } - - for (code, comment) in entries { - let padding = max_code_width - code.len(); - result_lines.push(format!("{}{} {}", code, " ".repeat(padding), comment)); - } - - i = group_end; - continue; - } - } - - result_lines.push(lines[i].to_string()); - i += 1; - } - - // Preserve trailing newline - let mut output = result_lines.join(newline); - if text.ends_with('\n') || text.ends_with("\r\n") { - output.push_str(newline); - } - output -} - -/// Find a trailing comment (`--` outside of strings) in a line. -/// Returns `(code_before_comment, comment_including_dashes)`. -fn split_trailing_comment(line: &str) -> Option<(&str, &str)> { - let trimmed = line.trim_start(); - // A line that starts with `--` is a standalone comment, not a trailing one - if trimmed.starts_with("--") { - return None; - } - - // Scan the line, skipping string contents, to find `--` - let bytes = line.as_bytes(); - let len = bytes.len(); - let mut i = 0; - - while i < len { - match bytes[i] { - b'"' | b'\'' => { - let quote = bytes[i]; - i += 1; - while i < len && bytes[i] != quote { - if bytes[i] == b'\\' { - i += 1; // skip escaped char - } - i += 1; - } - i += 1; // skip closing quote - } - b'[' if i + 1 < len && (bytes[i + 1] == b'[' || bytes[i + 1] == b'=') => { - // Long string [[ ... ]] or [=[ ... ]=] - i += 2; - while i + 1 < len && !(bytes[i] == b']' && bytes[i + 1] == b']') { - i += 1; - } - i += 2; - } - b'-' if i + 1 < len && bytes[i + 1] == b'-' => { - return Some((&line[..i], &line[i..])); - } - _ => i += 1, - } - } - - None -} diff --git a/crates/emmylua_formatter/src/test/comment_tests.rs b/crates/emmylua_formatter/src/test/comment_tests.rs index 8dc4b2b11..bb4ba0eb2 100644 --- a/crates/emmylua_formatter/src/test/comment_tests.rs +++ b/crates/emmylua_formatter/src/test/comment_tests.rs @@ -347,4 +347,41 @@ local d = 4 -- w "# ); } + + // ========== doc comment formatting ========== + + #[test] + fn test_doc_comment_normalize_whitespace() { + // Extra spaces in doc comment should be normalized to single space + assert_format!( + "---@param name string\nlocal function f(name) end\n", + "---@param name string\nlocal function f(name) end\n" + ); + } + + #[test] + fn test_doc_comment_preserved() { + // Well-formatted doc comment should be unchanged + assert_format!( + "---@param name string\nlocal function f(name) end\n", + "---@param name string\nlocal function f(name) end\n" + ); + } + + #[test] + fn test_doc_comment_multi_tag() { + assert_format!( + "---@param a number\n---@param b string\n---@return boolean\nlocal function f(a, b) end\n", + "---@param a number\n---@param b string\n---@return boolean\nlocal function f(a, b) end\n" + ); + } + + #[test] + fn test_long_comment_preserved() { + // Long comments should be preserved as-is (including content) + assert_format!( + "--[[ some content ]]\nlocal a = 1\n", + "--[[ some content ]]\nlocal a = 1\n" + ); + } } diff --git a/crates/emmylua_formatter/src/test/config_tests.rs b/crates/emmylua_formatter/src/test/config_tests.rs index aa97e08de..2c0db33d0 100644 --- a/crates/emmylua_formatter/src/test/config_tests.rs +++ b/crates/emmylua_formatter/src/test/config_tests.rs @@ -209,4 +209,111 @@ local b = 2 config ); } + + // ========== operator spacing options ========== + + #[test] + fn test_no_space_around_math_operator() { + let config = LuaFormatConfig { + space_around_math_operator: false, + ..Default::default() + }; + assert_format_with_config!( + "local a = 1 + 2 * 3 - 4 / 5\n", + "local a = 1+2*3-4/5\n", + config + ); + } + + #[test] + fn test_space_around_math_operator_default() { + // Default: spaces around math operators + assert_format_with_config!( + "local a = 1+2*3\n", + "local a = 1 + 2 * 3\n", + LuaFormatConfig::default() + ); + } + + #[test] + fn test_no_space_around_concat_operator() { + let config = LuaFormatConfig { + space_around_concat_operator: false, + ..Default::default() + }; + assert_format_with_config!("local s = a .. b .. c\n", "local s = a..b..c\n", config); + } + + #[test] + fn test_space_around_concat_operator_default() { + assert_format_with_config!( + "local s = a..b\n", + "local s = a .. b\n", + LuaFormatConfig::default() + ); + } + + #[test] + fn test_float_concat_no_space_keeps_space() { + // When no-space concat is enabled, `1. .. x` must keep the space to + // avoid producing the invalid token `1...` + let config = LuaFormatConfig { + space_around_concat_operator: false, + ..Default::default() + }; + assert_format_with_config!( + "local s = 1. .. \"str\"\n", + "local s = 1. ..\"str\"\n", + config + ); + } + + #[test] + fn test_no_math_space_keeps_comparison_space() { + // Disabling math operator spaces should NOT affect comparison operators + let config = LuaFormatConfig { + space_around_math_operator: false, + ..Default::default() + }; + assert_format_with_config!("local x = a+b == c*d\n", "local x = a+b == c*d\n", config); + } + + #[test] + fn test_no_math_space_keeps_logical_space() { + // Disabling math operator spaces should NOT affect logical operators + let config = LuaFormatConfig { + space_around_math_operator: false, + ..Default::default() + }; + assert_format_with_config!( + "local a = b and c or d\n", + "local a = b and c or d\n", + config + ); + } + + // ========== space around assign operator ========== + + #[test] + fn test_no_space_around_assign() { + let config = LuaFormatConfig { + space_around_assign_operator: false, + ..Default::default() + }; + assert_format_with_config!("local a = 1\n", "local a=1\n", config); + } + + #[test] + fn test_no_space_around_assign_table() { + let config = LuaFormatConfig { + space_around_assign_operator: false, + ..Default::default() + }; + assert_format_with_config!("local t = { a = 1 }\n", "local t={ a=1 }\n", config); + } + + #[test] + fn test_space_around_assign_default() { + assert_format_with_config!("local a=1\n", "local a = 1\n", LuaFormatConfig::default()); + } } diff --git a/crates/emmylua_formatter/src/test/misc_tests.rs b/crates/emmylua_formatter/src/test/misc_tests.rs index 978e6019e..c88948ef8 100644 --- a/crates/emmylua_formatter/src/test/misc_tests.rs +++ b/crates/emmylua_formatter/src/test/misc_tests.rs @@ -34,6 +34,17 @@ mod tests { assert_format!("local a = 1\n", "local a = 1\n"); } + // ========== long string preservation ========== + + #[test] + fn test_long_string_preserves_trailing_spaces() { + // Long string content including trailing spaces must be preserved exactly + assert_format!( + "local s = [[ hello \n world \n]]\n", + "local s = [[ hello \n world \n]]\n" + ); + } + // ========== idempotency ========== #[test] From 53e0f93851fb885cdec51b76f14926e85a5878d8 Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Thu, 19 Mar 2026 20:45:11 +0800 Subject: [PATCH 05/10] refactor --- Cargo.lock | 3 + crates/emmylua_formatter/Cargo.toml | 3 + crates/emmylua_formatter/README.md | 156 ++- crates/emmylua_formatter/src/bin/luafmt.rs | 125 +- crates/emmylua_formatter/src/cmd_args.rs | 93 +- crates/emmylua_formatter/src/config/mod.rs | 220 ++- .../emmylua_formatter/src/formatter/block.rs | 287 ++-- .../src/formatter/comment.rs | 904 +++++++++++- .../src/formatter/expression.rs | 1090 ++++++++++++--- crates/emmylua_formatter/src/formatter/mod.rs | 4 +- .../src/formatter/sequence.rs | 65 + .../src/formatter/spacing.rs | 6 +- .../src/formatter/statement.rs | 1234 +++++++++++++++-- .../emmylua_formatter/src/formatter/tokens.rs | 15 + .../emmylua_formatter/src/formatter/trivia.rs | 33 +- crates/emmylua_formatter/src/ir/builder.rs | 24 + crates/emmylua_formatter/src/ir/doc_ir.rs | 88 +- crates/emmylua_formatter/src/lib.rs | 29 +- crates/emmylua_formatter/src/printer/mod.rs | 70 +- crates/emmylua_formatter/src/printer/test.rs | 5 +- .../src/test/breaking_tests.rs | 55 +- .../src/test/comment_tests.rs | 459 +++++- .../src/test/config_tests.rs | 147 +- .../src/test/expression_tests.rs | 173 ++- .../emmylua_formatter/src/test/misc_tests.rs | 105 +- .../src/test/statement_tests.rs | 212 ++- .../emmylua_formatter/src/test/test_helper.rs | 2 +- crates/emmylua_formatter/src/workspace.rs | 524 +++++++ .../emmylua_parser/src/kind/lua_token_kind.rs | 73 + 29 files changed, 5486 insertions(+), 718 deletions(-) create mode 100644 crates/emmylua_formatter/src/formatter/sequence.rs create mode 100644 crates/emmylua_formatter/src/formatter/tokens.rs create mode 100644 crates/emmylua_formatter/src/workspace.rs diff --git a/Cargo.lock b/Cargo.lock index 3529cdbb4..acd1edb22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -693,12 +693,15 @@ version = "0.1.0" dependencies = [ "clap", "emmylua_parser", + "glob", "mimalloc", "rowan", "serde", "serde_json", "serde_yml", "smol_str", + "toml_edit", + "walkdir", ] [[package]] diff --git a/crates/emmylua_formatter/Cargo.toml b/crates/emmylua_formatter/Cargo.toml index 6c3fb06fe..c4ad7025c 100644 --- a/crates/emmylua_formatter/Cargo.toml +++ b/crates/emmylua_formatter/Cargo.toml @@ -9,7 +9,10 @@ emmylua_parser.workspace = true rowan.workspace = true serde_json.workspace = true serde_yml.workspace = true +toml_edit.workspace = true smol_str.workspace = true +glob.workspace = true +walkdir.workspace = true [dependencies.clap] workspace = true diff --git a/crates/emmylua_formatter/README.md b/crates/emmylua_formatter/README.md index df42ab37e..b5f2ff594 100644 --- a/crates/emmylua_formatter/README.md +++ b/crates/emmylua_formatter/README.md @@ -1,3 +1,157 @@ # EmmyLua Formatter -Currently, this project is just a toy; I haven't fully figured out how to proceed with it. I'll research more when I have time. +EmmyLua Formatter is an experimental Lua/EmmyLua formatter built on a DocIR-style pipeline: + +- parse source into syntax nodes +- convert syntax into formatting IR +- print IR back to text with width-aware layout decisions + +The crate already supports practical formatting for statements, expressions, tables, comments, and a growing subset of EmmyLua doc tags. + +Trivia-aware formatter redesign notes are documented in `TRIVIA_FORMATTING_DESIGN.md`. + +## Current Focus + +Recent work has concentrated on formatter stability and configurability, especially around alignment-sensitive output: + +- trailing line comment alignment with per-scope switches +- assignment spacing control +- shebang preservation +- EmmyLua doc-tag normalization and alignment +- conservative fallback for complex doc-tag syntax + +## Comment Alignment + +Trailing line comments are configured under `LuaFormatConfig.comments`: + +- `align_line_comments` +- `align_in_statements` +- `align_in_table_fields` +- `align_in_params` +- `align_across_standalone_comments` +- `align_same_kind_only` +- `line_comment_min_spaces_before` +- `line_comment_min_column` + +## EmmyLua Doc Tags + +The formatter currently has structured handling for: + +- `@param` +- `@field` +- `@return` +- `@class` +- `@alias` +- `@type` +- `@generic` +- `@overload` + +Alignment behavior is controlled under `LuaFormatConfig.emmy_doc`: + +- `align_tag_columns` +- `align_declaration_tags` +- `align_reference_tags` +- `tag_spacing` +- `space_after_description_dash` + +Notes: + +- declaration tags are `@class`, `@alias`, `@type`, `@generic`, `@overload` +- reference tags are `@param`, `@field`, `@return` +- `@alias` keeps its original single-line body text and only participates in description-column alignment +- `space_after_description_dash` controls whether plain doc lines render as `--- text` or `---text` +- multiline or complex doc-tag forms fall back to raw preservation instead of risky rewriting + +## luafmt + +The CLI now supports: + +- `--config ` with `toml`, `json`, `yml`, or `yaml` +- automatic discovery of `.luafmt.toml` or `luafmt.toml` +- `--dump-default-config` to print a starter TOML config +- recursive directory input +- `--include` / `--exclude` glob filters +- `.luafmtignore` support for batch formatting + +Typical usage: + +```powershell +luafmt src --write +luafmt . --check --exclude "vendor/**" +luafmt game --list-different +``` + +## Library API + +The crate now exposes workspace-friendly helpers so the language server or other callers do not need to shell out to `luafmt`: + +- `resolve_config_for_path` to load the nearest formatter config for a file +- `format_text_for_path` to format in-memory text with path-based config discovery +- `format_file` to format a file directly +- `collect_lua_files` to gather `lua` and `luau` files from directories with ignore support + +Example: + +```rust +use std::path::Path; + +use emmylua_formatter::{format_text_for_path, resolve_config_for_path}; + +let source_path = Path::new("workspace/scripts/main.lua"); +let resolved = resolve_config_for_path(Some(source_path), None)?; +let result = format_text_for_path("local x=1\n", Some(source_path), None)?; + +assert_eq!(resolved.source_path.is_some(), true); +assert!(result.output.changed); +``` + +## Example Config + +```toml +[indent] +kind = "Space" +width = 4 + +[layout] +max_line_width = 120 +max_blank_lines = 1 +table_expand = "Auto" +call_args_expand = "Auto" +func_params_expand = "Auto" + +[output] +insert_final_newline = true +trailing_comma = "Never" +end_of_line = "LF" + +[spacing] +space_before_call_paren = false +space_before_func_paren = false +space_inside_braces = true +space_inside_parens = false +space_inside_brackets = false +space_around_math_operator = true +space_around_concat_operator = true +space_around_assign_operator = true + +[comments] +align_line_comments = true +align_in_statements = true +align_in_table_fields = true +align_in_params = true +align_across_standalone_comments = true +align_same_kind_only = false +line_comment_min_spaces_before = 1 +line_comment_min_column = 0 + +[emmy_doc] +align_tag_columns = true +align_declaration_tags = true +align_reference_tags = true +tag_spacing = 1 +space_after_description_dash = true + +[align] +continuous_assign_statement = true +table_field = true +``` diff --git a/crates/emmylua_formatter/src/bin/luafmt.rs b/crates/emmylua_formatter/src/bin/luafmt.rs index cb83bcad2..d4d4333d7 100644 --- a/crates/emmylua_formatter/src/bin/luafmt.rs +++ b/crates/emmylua_formatter/src/bin/luafmt.rs @@ -1,12 +1,13 @@ use std::{ fs, io::{self, Read, Write}, - path::PathBuf, process::exit, }; use clap::Parser; -use emmylua_formatter::{LuaFormatConfig, cmd_args, reformat_lua_code}; +use emmylua_formatter::{ + cmd_args, collect_lua_files, default_config_toml, format_file, format_text_for_path, +}; #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; @@ -17,45 +18,23 @@ fn read_stdin_to_string() -> io::Result { Ok(s) } -fn format_content(content: &str, style: &LuaFormatConfig) -> String { - reformat_lua_code(content, style) -} - -#[allow(unused)] -fn process_file( - path: &PathBuf, - style: &LuaFormatConfig, - write: bool, - list_diff: bool, -) -> io::Result<(bool, Option)> { - let original = fs::read_to_string(path)?; - let formatted = format_content(&original, style); - let changed = formatted != original; - - if write && changed { - fs::write(path, formatted)?; - return Ok((true, None)); - } - - if list_diff && changed { - return Ok((true, Some(path.to_string_lossy().to_string()))); - } - - Ok((changed, None)) -} - fn main() { let args = cmd_args::CliArgs::parse(); - let mut exit_code = 0; - - let style = match cmd_args::resolve_style(&args) { - Ok(s) => s, - Err(e) => { - eprintln!("Error: {e}"); - exit(2); + if args.dump_default_config { + match default_config_toml() { + Ok(config) => { + println!("{config}"); + exit(0); + } + Err(e) => { + eprintln!("Error: {e}"); + exit(2); + } } - }; + } + + let mut exit_code = 0; let is_stdin = args.stdin || args.paths.is_empty(); @@ -68,15 +47,21 @@ fn main() { } }; - let formatted = format_content(&content, &style); - let changed = formatted != content; + let result = match format_text_for_path(&content, None, args.config.as_deref()) { + Ok(result) => result, + Err(err) => { + eprintln!("Error: {err}"); + exit(2); + } + }; + let changed = result.output.changed; if args.check || args.list_different { if changed { exit_code = 1; } } else if let Some(out) = &args.output { - if let Err(e) = fs::write(out, formatted) { + if let Err(e) = fs::write(out, result.output.formatted) { eprintln!("Failed to write output to {out:?}: {e}"); exit(2); } @@ -85,7 +70,7 @@ fn main() { exit(2); } else { let mut stdout = io::stdout(); - if let Err(e) = stdout.write_all(formatted.as_bytes()) { + if let Err(e) = stdout.write_all(result.output.formatted.as_bytes()) { eprintln!("Failed to write to stdout: {e}"); exit(2); } @@ -94,66 +79,66 @@ fn main() { exit(exit_code); } - if args.paths.len() > 1 && args.output.is_some() { + if args.output.is_some() && args.paths.len() != 1 { eprintln!("--output can only be used with a single input or stdin"); exit(2); } - if args.paths.len() > 1 && !(args.write || args.check || args.list_different) { - eprintln!("Multiple inputs require --write or --check"); + let file_options = cmd_args::build_file_collector_options(&args); + let files = match collect_lua_files(&args.paths, &file_options) { + Ok(files) => files, + Err(err) => { + eprintln!("Error: {err}"); + exit(2); + } + }; + + if files.len() > 1 && !(args.write || args.check || args.list_different) { + eprintln!("Multiple matched files require --write, --check, or --list-different"); exit(2); } - let mut different_paths: Vec = Vec::new(); + if files.is_empty() { + eprintln!("No Lua files matched the provided inputs"); + exit(2); + } - for path in &args.paths { - match fs::metadata(path) { - Ok(meta) => { - if !meta.is_file() { - eprintln!("Skipping non-file path: {}", path.to_string_lossy()); - continue; - } - } - Err(e) => { - eprintln!("Cannot access {}: {e}", path.to_string_lossy()); - exit_code = 2; - continue; - } - } + let mut different_paths: Vec = Vec::new(); - match fs::read_to_string(path) { - Ok(original) => { - let formatted = format_content(&original, &style); - let changed = formatted != original; + for path in &files { + match format_file(path, args.config.as_deref()) { + Ok(result) => { + let output = result.output; if args.check || args.list_different { - if changed { + if output.changed { exit_code = 1; if args.list_different { different_paths.push(path.to_string_lossy().to_string()); } } } else if args.write { - if changed && let Err(e) = fs::write(path, formatted) { + if output.changed + && let Err(e) = fs::write(path, output.formatted) + { eprintln!("Failed to write {}: {e}", path.to_string_lossy()); exit_code = 2; } } else if let Some(out) = &args.output { - if let Err(e) = fs::write(out, formatted) { + if let Err(e) = fs::write(out, output.formatted) { eprintln!("Failed to write output to {out:?}: {e}"); exit(2); } } else { - // Single file without write/check: print to stdout let mut stdout = io::stdout(); - if let Err(e) = stdout.write_all(formatted.as_bytes()) { + if let Err(e) = stdout.write_all(output.formatted.as_bytes()) { eprintln!("Failed to write to stdout: {e}"); exit(2); } } } - Err(e) => { - eprintln!("Failed to read {}: {e}", path.to_string_lossy()); + Err(err) => { + eprintln!("Failed to format {}: {err}", path.to_string_lossy()); exit_code = 2; } } diff --git a/crates/emmylua_formatter/src/cmd_args.rs b/crates/emmylua_formatter/src/cmd_args.rs index cc18a0756..85ab5e98a 100644 --- a/crates/emmylua_formatter/src/cmd_args.rs +++ b/crates/emmylua_formatter/src/cmd_args.rs @@ -1,14 +1,14 @@ -use std::{fs, path::PathBuf}; +use std::path::PathBuf; use clap::{ArgGroup, Parser}; -use crate::config::{IndentStyle, LuaFormatConfig}; +use crate::{FileCollectorOptions, IndentKind, ResolvedConfig, resolve_config_for_path}; #[derive(Debug, Clone, Parser)] #[command( - name = "emmylua_format", + name = "luafmt", version, - about = "Format Lua source code using EmmyLua code style rules", + about = "Format Lua source code with structured EmmyLua formatter settings", disable_help_subcommand = true )] #[command(group( @@ -41,10 +41,14 @@ pub struct CliArgs { #[arg(short, long, value_name = "FILE", value_hint = clap::ValueHint::FilePath)] pub output: Option, - /// Load style config from a file (json/yml/yaml) + /// Load style config from a file (toml/json/yml/yaml) #[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath)] pub config: Option, + /// Print the default configuration as TOML and exit + #[arg(long)] + pub dump_default_config: bool, + /// Use tabs for indentation #[arg(long)] pub tab: bool, @@ -56,47 +60,64 @@ pub struct CliArgs { /// Set maximum line width #[arg(long, value_name = "N")] pub max_line_width: Option, + + /// Recurse into directories to find Lua files + #[arg(long, default_value_t = true)] + pub recursive: bool, + + /// Include hidden files and directories + #[arg(long)] + pub include_hidden: bool, + + /// Follow symlinks while walking directories + #[arg(long)] + pub follow_symlinks: bool, + + /// Disable .luafmtignore support + #[arg(long)] + pub no_ignore: bool, + + /// Include files matching an additional glob pattern + #[arg(long, value_name = "GLOB")] + pub include: Vec, + + /// Exclude files matching a glob pattern + #[arg(long, value_name = "GLOB")] + pub exclude: Vec, } -pub fn resolve_style(args: &CliArgs) -> Result { - let mut style = if let Some(cfg) = &args.config { - let content = fs::read_to_string(cfg) - .map_err(|e| format!("failed to read config: {}: {e}", cfg.to_string_lossy()))?; - let ext = cfg - .extension() - .and_then(|s| s.to_str()) - .map(|s| s.to_ascii_lowercase()) - .unwrap_or_default(); - match ext.as_str() { - "json" => serde_json::from_str::(&content) - .map_err(|e| format!("failed to parse JSON config: {e}"))?, - "yml" | "yaml" => serde_yml::from_str::(&content) - .map_err(|e| format!("failed to parse YAML config: {e}"))?, - _ => { - // Unknown extension, try JSON first then YAML - match serde_json::from_str::(&content) { - Ok(v) => v, - Err(_) => serde_yml::from_str::(&content).map_err(|e| { - format!("unknown extension, failed to parse as JSON/YAML: {e}") - })?, - } - } - } - } else { - LuaFormatConfig::default() - }; +pub fn resolve_style(args: &CliArgs) -> Result { + let mut resolved = resolve_config_for_path( + args.paths.first().map(PathBuf::as_path), + args.config.as_deref(), + ) + .map_err(|err| err.to_string())?; // Indent overrides match (args.tab, args.spaces) { (true, Some(_)) => return Err("--tab and --spaces are mutually exclusive".into()), - (true, None) => style.indent_style = IndentStyle::Tab, - (false, Some(n)) => style.indent_style = IndentStyle::Space(n), + (true, None) => resolved.config.indent.kind = IndentKind::Tab, + (false, Some(n)) => { + resolved.config.indent.kind = IndentKind::Space; + resolved.config.indent.width = n; + } _ => {} } if let Some(w) = args.max_line_width { - style.max_line_width = w; + resolved.config.layout.max_line_width = w; } - Ok(style) + Ok(resolved) +} + +pub fn build_file_collector_options(args: &CliArgs) -> FileCollectorOptions { + FileCollectorOptions { + recursive: args.recursive, + include_hidden: args.include_hidden, + follow_symlinks: args.follow_symlinks, + respect_ignore_files: !args.no_ignore, + include: args.include.clone(), + exclude: args.exclude.clone(), + } } diff --git a/crates/emmylua_formatter/src/config/mod.rs b/crates/emmylua_formatter/src/config/mod.rs index 1b6b52d27..faaa4db5c 100644 --- a/crates/emmylua_formatter/src/config/mod.rs +++ b/crates/emmylua_formatter/src/config/mod.rs @@ -1,61 +1,129 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] pub struct LuaFormatConfig { - // ===== Indentation ===== - pub indent_style: IndentStyle, - pub tab_width: usize, + pub indent: IndentConfig, + pub layout: LayoutConfig, + pub output: OutputConfig, + pub spacing: SpacingConfig, + pub comments: CommentConfig, + pub emmy_doc: EmmyDocConfig, + pub align: AlignConfig, +} - // ===== Line width ===== - pub max_line_width: usize, +impl LuaFormatConfig { + pub fn indent_width(&self) -> usize { + self.indent.width + } - // ===== Blank lines ===== + pub fn indent_str(&self) -> String { + match &self.indent.kind { + IndentKind::Tab => "\t".to_string(), + IndentKind::Space => " ".repeat(self.indent.width), + } + } + + pub fn newline_str(&self) -> &'static str { + match &self.output.end_of_line { + EndOfLine::LF => "\n", + EndOfLine::CRLF => "\r\n", + } + } + + pub fn should_align_statement_line_comments(&self) -> bool { + self.comments.align_line_comments && self.comments.align_in_statements + } + + pub fn should_align_table_line_comments(&self) -> bool { + self.comments.align_line_comments && self.comments.align_in_table_fields + } + + pub fn should_align_param_line_comments(&self) -> bool { + self.comments.align_line_comments && self.comments.align_in_params + } + + pub fn should_align_emmy_doc_declaration_tags(&self) -> bool { + self.emmy_doc.align_tag_columns && self.emmy_doc.align_declaration_tags + } + + pub fn should_align_emmy_doc_reference_tags(&self) -> bool { + self.emmy_doc.align_tag_columns && self.emmy_doc.align_reference_tags + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct IndentConfig { + pub kind: IndentKind, + pub width: usize, +} + +impl Default for IndentConfig { + fn default() -> Self { + Self { + kind: IndentKind::Space, + width: 4, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct LayoutConfig { + pub max_line_width: usize, pub max_blank_lines: usize, + pub table_expand: ExpandStrategy, + pub call_args_expand: ExpandStrategy, + pub func_params_expand: ExpandStrategy, +} + +impl Default for LayoutConfig { + fn default() -> Self { + Self { + max_line_width: 120, + max_blank_lines: 1, + table_expand: ExpandStrategy::Auto, + call_args_expand: ExpandStrategy::Auto, + func_params_expand: ExpandStrategy::Auto, + } + } +} - // ===== Trailing ===== +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct OutputConfig { pub insert_final_newline: bool, pub trailing_comma: TrailingComma, + pub end_of_line: EndOfLine, +} + +impl Default for OutputConfig { + fn default() -> Self { + Self { + insert_final_newline: true, + trailing_comma: TrailingComma::Never, + end_of_line: EndOfLine::LF, + } + } +} - // ===== Spacing ===== +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct SpacingConfig { pub space_before_call_paren: bool, pub space_before_func_paren: bool, pub space_inside_braces: bool, pub space_inside_parens: bool, pub space_inside_brackets: bool, - /// Space around arithmetic operators: + - * / // % ^ pub space_around_math_operator: bool, - /// Space around string concatenation operator: .. pub space_around_concat_operator: bool, - /// Space around assign operator: = pub space_around_assign_operator: bool, - - // ===== End of line ===== - pub end_of_line: EndOfLine, - - // ===== Line break style ===== - pub table_expand: ExpandStrategy, - pub call_args_expand: ExpandStrategy, - pub func_params_expand: ExpandStrategy, - - // ===== Alignment ===== - /// Align trailing comments on consecutive lines - pub align_continuous_line_comment: bool, - /// Align `=` signs in consecutive assignment statements - pub align_continuous_assign_statement: bool, - /// Align `=` signs in table fields - pub align_table_field: bool, } -impl Default for LuaFormatConfig { +impl Default for SpacingConfig { fn default() -> Self { Self { - indent_style: IndentStyle::Space(4), - tab_width: 4, - max_line_width: 120, - max_blank_lines: 1, - insert_final_newline: true, - trailing_comma: TrailingComma::Never, space_before_call_paren: false, space_before_func_paren: false, space_inside_braces: true, @@ -64,44 +132,80 @@ impl Default for LuaFormatConfig { space_around_math_operator: true, space_around_concat_operator: true, space_around_assign_operator: true, - table_expand: ExpandStrategy::Auto, - call_args_expand: ExpandStrategy::Auto, - func_params_expand: ExpandStrategy::Auto, - end_of_line: EndOfLine::LF, - align_continuous_line_comment: true, - align_continuous_assign_statement: true, - align_table_field: true, } } } -impl LuaFormatConfig { - pub fn indent_width(&self) -> usize { - match &self.indent_style { - IndentStyle::Tab => self.tab_width, - IndentStyle::Space(n) => *n, +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct CommentConfig { + pub align_line_comments: bool, + pub align_in_statements: bool, + pub align_in_table_fields: bool, + pub align_in_params: bool, + pub align_across_standalone_comments: bool, + pub align_same_kind_only: bool, + pub line_comment_min_spaces_before: usize, + pub line_comment_min_column: usize, +} + +impl Default for CommentConfig { + fn default() -> Self { + Self { + align_line_comments: true, + align_in_statements: true, + align_in_table_fields: true, + align_in_params: true, + align_across_standalone_comments: true, + align_same_kind_only: false, + line_comment_min_spaces_before: 1, + line_comment_min_column: 0, } } +} - pub fn indent_str(&self) -> String { - match &self.indent_style { - IndentStyle::Tab => "\t".to_string(), - IndentStyle::Space(n) => " ".repeat(*n), +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct EmmyDocConfig { + pub align_tag_columns: bool, + pub align_declaration_tags: bool, + pub align_reference_tags: bool, + pub tag_spacing: usize, + pub space_after_description_dash: bool, +} + +impl Default for EmmyDocConfig { + fn default() -> Self { + Self { + align_tag_columns: true, + align_declaration_tags: true, + align_reference_tags: true, + tag_spacing: 1, + space_after_description_dash: true, } } +} - pub fn newline_str(&self) -> &'static str { - match &self.end_of_line { - EndOfLine::LF => "\n", - EndOfLine::CRLF => "\r\n", +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AlignConfig { + pub continuous_assign_statement: bool, + pub table_field: bool, +} + +impl Default for AlignConfig { + fn default() -> Self { + Self { + continuous_assign_statement: true, + table_field: true, } } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum IndentStyle { +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum IndentKind { Tab, - Space(usize), + Space, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/emmylua_formatter/src/formatter/block.rs b/crates/emmylua_formatter/src/formatter/block.rs index 021c6fafe..e8d171844 100644 --- a/crates/emmylua_formatter/src/formatter/block.rs +++ b/crates/emmylua_formatter/src/formatter/block.rs @@ -25,6 +25,156 @@ impl BlockChild { } } +fn same_stat_kind(left: &LuaStat, right: &LuaStat) -> bool { + std::mem::discriminant(left) == std::mem::discriminant(right) +} + +fn should_break_on_blank_lines(child: &BlockChild) -> bool { + count_blank_lines_before(child.syntax()) > 0 +} + +fn can_join_comment_alignment_group( + ctx: &FormatContext, + anchor: &LuaStat, + child: &BlockChild, +) -> bool { + if should_break_on_blank_lines(child) { + return false; + } + + match child { + BlockChild::Comment(_) => ctx.config.comments.align_across_standalone_comments, + BlockChild::Statement(next_stat) => { + if extract_trailing_comment(next_stat.syntax()).is_none() { + return false; + } + if ctx.config.comments.align_same_kind_only && !same_stat_kind(anchor, next_stat) { + return false; + } + true + } + } +} + +fn can_join_eq_alignment_group(ctx: &FormatContext, anchor: &LuaStat, child: &BlockChild) -> bool { + if should_break_on_blank_lines(child) { + return false; + } + + match child { + BlockChild::Comment(_) => ctx.config.comments.align_across_standalone_comments, + BlockChild::Statement(next_stat) => { + if !is_eq_alignable(next_stat) { + return false; + } + if ctx.config.comments.align_same_kind_only && !same_stat_kind(anchor, next_stat) { + return false; + } + true + } + } +} + +fn build_eq_alignment_entries( + ctx: &FormatContext, + children: &[BlockChild], + consumed_comment_ranges: &mut Vec, +) -> Vec { + let mut entries = Vec::new(); + + for child in children { + match child { + BlockChild::Comment(comment) => { + if consumed_comment_ranges + .iter() + .any(|range| *range == comment.syntax().text_range()) + { + continue; + } + entries.push(AlignEntry::Line { + content: format_comment(ctx.config, comment), + trailing: None, + }); + } + BlockChild::Statement(stat) => { + let trailing = if ctx.config.should_align_statement_line_comments() { + extract_trailing_comment(stat.syntax()).map(|(trail_docs, range)| { + consumed_comment_ranges.push(range); + trail_docs + }) + } else { + None + }; + + if let Some((before, mut after)) = format_stat_eq_split(ctx, stat) { + if trailing.is_none() + && let Some((trailing_ir, range)) = + format_trailing_comment(ctx.config, stat.syntax()) + { + after.push(trailing_ir); + consumed_comment_ranges.push(range); + } + entries.push(AlignEntry::Aligned { + before, + after, + trailing, + }); + } else { + let mut content = format_stat(ctx, stat); + if trailing.is_none() + && let Some((trailing_ir, range)) = + format_trailing_comment(ctx.config, stat.syntax()) + { + content.push(trailing_ir); + consumed_comment_ranges.push(range); + } + entries.push(AlignEntry::Line { content, trailing }); + } + } + } + } + + entries +} + +fn build_comment_alignment_entries( + ctx: &FormatContext, + children: &[BlockChild], + consumed_comment_ranges: &mut Vec, +) -> Vec { + let mut entries = Vec::new(); + + for child in children { + match child { + BlockChild::Comment(comment) => { + if consumed_comment_ranges + .iter() + .any(|range| *range == comment.syntax().text_range()) + { + continue; + } + entries.push(AlignEntry::Line { + content: format_comment(ctx.config, comment), + trailing: None, + }); + } + BlockChild::Statement(stat) => { + let trailing = + extract_trailing_comment(stat.syntax()).map(|(trail_docs, range)| { + consumed_comment_ranges.push(range); + trail_docs + }); + entries.push(AlignEntry::Line { + content: format_stat(ctx, stat), + trailing, + }); + } + } + } + + entries +} + /// Format a block (statement list + blank line normalization + comment handling). /// /// Iterates all child nodes of the Block (including Statements and Comments), @@ -32,7 +182,6 @@ impl BlockChild { /// When `=` alignment is enabled, consecutive alignable statements are grouped /// into an AlignGroup IR node so the Printer can align their `=` signs. pub fn format_block(ctx: &FormatContext, block: &LuaBlock) -> Vec { - // Pass 1: collect all children let children: Vec = block .syntax() .children() @@ -44,7 +193,6 @@ pub fn format_block(ctx: &FormatContext, block: &LuaBlock) -> Vec { }) .collect(); - // Pass 2: emit IR, grouping consecutive alignable statements let mut docs: Vec = Vec::new(); let mut is_first = true; let mut consumed_comment_ranges: Vec = Vec::new(); @@ -63,13 +211,13 @@ pub fn format_block(ctx: &FormatContext, block: &LuaBlock) -> Vec { if !is_first { let blank_lines = count_blank_lines_before(comment.syntax()); - let normalized = blank_lines.min(ctx.config.max_blank_lines); + let normalized = blank_lines.min(ctx.config.layout.max_blank_lines); for _ in 0..normalized { docs.push(ir::hard_line()); } } - docs.extend(format_comment(comment)); + docs.extend(format_comment(ctx.config, comment)); if !is_first || !docs.is_empty() { docs.push(ir::hard_line()); @@ -79,85 +227,38 @@ pub fn format_block(ctx: &FormatContext, block: &LuaBlock) -> Vec { } BlockChild::Statement(stat) => { // Try to form an alignment group if enabled - if ctx.config.align_continuous_assign_statement && is_eq_alignable(stat) { + if ctx.config.align.continuous_assign_statement && is_eq_alignable(stat) { let group_start = i; let mut group_end = i + 1; - - // Scan forward for consecutive alignable statements (no blank lines between). - // Skip interleaved Comment children (they're trailing comments consumed later). while group_end < children.len() { - match &children[group_end] { - BlockChild::Statement(next_stat) => { - if is_eq_alignable(next_stat) { - let blank_lines = count_blank_lines_before(next_stat.syntax()); - if blank_lines == 0 { - group_end += 1; - continue; - } - } - break; - } - BlockChild::Comment(_) => { - // Skip trailing comment nodes when scanning for alignment group - group_end += 1; - continue; - } + if can_join_eq_alignment_group(ctx, stat, &children[group_end]) { + group_end += 1; + } else { + break; } } - if group_end - group_start >= 2 { + let stmt_count = children[group_start..group_end] + .iter() + .filter(|child| matches!(child, BlockChild::Statement(_))) + .count(); + + if stmt_count >= 2 { // Emit = alignment group if !is_first { let blank_lines = count_blank_lines_before(children[group_start].syntax()); - let normalized = blank_lines.min(ctx.config.max_blank_lines); + let normalized = blank_lines.min(ctx.config.layout.max_blank_lines); for _ in 0..normalized { docs.push(ir::hard_line()); } } - let mut entries = Vec::new(); - for child in children.iter().take(group_end).skip(group_start) { - if let BlockChild::Statement(s) = child { - // Extract trailing comment for IR-level alignment - let trailing = if ctx.config.align_continuous_line_comment { - extract_trailing_comment(s.syntax()).map( - |(trail_docs, range)| { - consumed_comment_ranges.push(range); - trail_docs - }, - ) - } else { - None - }; - - if let Some((before, mut after)) = format_stat_eq_split(ctx, s) { - // When not using trailing alignment, attach as LineSuffix - if trailing.is_none() - && let Some((trailing_ir, range)) = - format_trailing_comment(s.syntax()) - { - after.push(trailing_ir); - consumed_comment_ranges.push(range); - } - entries.push(AlignEntry::Aligned { - before, - after, - trailing, - }); - } else { - let mut content = format_stat(ctx, s); - if trailing.is_none() - && let Some((trailing_ir, range)) = - format_trailing_comment(s.syntax()) - { - content.push(trailing_ir); - consumed_comment_ranges.push(range); - } - entries.push(AlignEntry::Line { content, trailing }); - } - } - } + let entries = build_eq_alignment_entries( + ctx, + &children[group_start..group_end], + &mut consumed_comment_ranges, + ); docs.push(ir::align_group(entries)); docs.push(ir::hard_line()); @@ -168,28 +269,16 @@ pub fn format_block(ctx: &FormatContext, block: &LuaBlock) -> Vec { } // Try to form a comment-only alignment group - if ctx.config.align_continuous_line_comment + if ctx.config.should_align_statement_line_comments() && extract_trailing_comment(stat.syntax()).is_some() { let group_start = i; let mut group_end = i + 1; while group_end < children.len() { - match &children[group_end] { - BlockChild::Statement(next_stat) => { - let blank_lines = count_blank_lines_before(next_stat.syntax()); - if blank_lines > 0 { - break; - } - if extract_trailing_comment(next_stat.syntax()).is_some() { - group_end += 1; - } else { - break; - } - } - BlockChild::Comment(_) => { - group_end += 1; - continue; - } + if can_join_comment_alignment_group(ctx, stat, &children[group_end]) { + group_end += 1; + } else { + break; } } @@ -202,27 +291,17 @@ pub fn format_block(ctx: &FormatContext, block: &LuaBlock) -> Vec { if !is_first { let blank_lines = count_blank_lines_before(children[group_start].syntax()); - let normalized = blank_lines.min(ctx.config.max_blank_lines); + let normalized = blank_lines.min(ctx.config.layout.max_blank_lines); for _ in 0..normalized { docs.push(ir::hard_line()); } } - let mut entries = Vec::new(); - for child in children.iter().take(group_end).skip(group_start) { - if let BlockChild::Statement(s) = child { - let trailing = extract_trailing_comment(s.syntax()).map( - |(trail_docs, range)| { - consumed_comment_ranges.push(range); - trail_docs - }, - ); - entries.push(AlignEntry::Line { - content: format_stat(ctx, s), - trailing, - }); - } - } + let entries = build_comment_alignment_entries( + ctx, + &children[group_start..group_end], + &mut consumed_comment_ranges, + ); docs.push(ir::align_group(entries)); docs.push(ir::hard_line()); @@ -235,7 +314,7 @@ pub fn format_block(ctx: &FormatContext, block: &LuaBlock) -> Vec { // Normal (non-aligned) statement if !is_first { let blank_lines = count_blank_lines_before(stat.syntax()); - let normalized = blank_lines.min(ctx.config.max_blank_lines); + let normalized = blank_lines.min(ctx.config.layout.max_blank_lines); for _ in 0..normalized { docs.push(ir::hard_line()); } @@ -244,7 +323,9 @@ pub fn format_block(ctx: &FormatContext, block: &LuaBlock) -> Vec { let stat_docs = format_stat(ctx, stat); docs.extend(stat_docs); - if let Some((trailing_ir, range)) = format_trailing_comment(stat.syntax()) { + if let Some((trailing_ir, range)) = + format_trailing_comment(ctx.config, stat.syntax()) + { docs.push(trailing_ir); consumed_comment_ranges.push(range); } diff --git a/crates/emmylua_formatter/src/formatter/comment.rs b/crates/emmylua_formatter/src/formatter/comment.rs index 60e166c2c..00c0d1664 100644 --- a/crates/emmylua_formatter/src/formatter/comment.rs +++ b/crates/emmylua_formatter/src/formatter/comment.rs @@ -1,6 +1,12 @@ -use emmylua_parser::{LuaAstNode, LuaComment, LuaKind, LuaSyntaxKind, LuaSyntaxNode, LuaTokenKind}; +use emmylua_parser::{ + LuaAstNode, LuaAstToken, LuaComment, LuaDocDescription, LuaDocFieldKey, LuaDocGenericDeclList, + LuaDocTag, LuaDocTagAlias, LuaDocTagClass, LuaDocTagField, LuaDocTagGeneric, LuaDocTagOverload, + LuaDocTagParam, LuaDocTagReturn, LuaDocTagType, LuaKind, LuaSyntaxElement, LuaSyntaxKind, + LuaSyntaxNode, LuaTokenKind, +}; use rowan::TextRange; +use crate::config::LuaFormatConfig; use crate::ir::{self, DocIR}; /// Format a Comment node. @@ -9,69 +15,841 @@ use crate::ir::{self, DocIR}; /// - Doc comments (`---@...`): walk the syntax tree, normalize whitespace /// - Long comments (`--[[ ... ]]`): preserve content as-is /// - Normal comments (`-- ...`): preserve text with trimming -pub fn format_comment(comment: &LuaComment) -> Vec { - let text = comment.syntax().text().to_string(); - - // Long comments (--[[ ... ]]): preserve content exactly (like long strings) - if text.starts_with("--[[") || text.starts_with("--[=") { - return vec![ir::text(text.trim_end())]; - } - - // Doc comments: walk the parsed syntax tree to normalize whitespace - if comment.get_doc_tags().next().is_some() || comment.get_description().is_some() { - return format_doc_comment(comment); +pub fn format_comment(config: &LuaFormatConfig, comment: &LuaComment) -> Vec { + match classify_comment(comment) { + CommentKind::Long => vec![ir::source_node_trimmed(comment.syntax().clone())], + CommentKind::Doc => format_doc_comment(config, comment), + CommentKind::Normal => format_normal_comment(comment), } - - // Normal single-line comment: preserve text - let text = text.trim_end(); - vec![ir::text(text)] } /// Format a doc comment by walking its syntax tree token-by-token. /// /// Only flat formatting is used (Text, Space, HardLine) — no Group/SoftLine /// since comments cannot have breaking rules. -fn format_doc_comment(comment: &LuaComment) -> Vec { +fn format_doc_comment(config: &LuaFormatConfig, comment: &LuaComment) -> Vec { + let lines = parse_doc_comment_lines(comment); + let rendered = render_doc_comment_lines(config, &lines); + let mut docs = Vec::new(); + for (index, line) in rendered.into_iter().enumerate() { + if index > 0 { + docs.push(ir::hard_line()); + } + if !line.is_empty() { + docs.push(ir::text(line)); + } + } + docs +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum CommentKind { + Long, + Doc, + Normal, +} + +fn classify_comment(comment: &LuaComment) -> CommentKind { + let Some(first_token) = comment.syntax().first_token() else { + return CommentKind::Normal; + }; + + match first_token.kind().into() { + LuaTokenKind::TkLongCommentStart => CommentKind::Long, + LuaTokenKind::TkDocStart + | LuaTokenKind::TkDocLongStart + | LuaTokenKind::TkDocContinue + | LuaTokenKind::TkDocContinueOr => CommentKind::Doc, + LuaTokenKind::TkNormalStart => { + if first_token.text().starts_with("---") || comment.get_doc_tags().next().is_some() { + CommentKind::Doc + } else { + CommentKind::Normal + } + } + _ => { + if comment.get_doc_tags().next().is_some() { + CommentKind::Doc + } else { + CommentKind::Normal + } + } + } +} + +fn format_normal_comment(comment: &LuaComment) -> Vec { + let Some(description) = comment.get_description() else { + return vec![ir::source_node_trimmed(comment.syntax().clone())]; + }; + + let rendered = render_normal_comment_lines(&description); let mut docs = Vec::new(); - let mut last_was_space = false; - walk_doc_tokens(comment.syntax(), &mut docs, &mut last_was_space); - // Trim trailing whitespace - while matches!(docs.last(), Some(DocIR::Space)) { - docs.pop(); + for (index, line) in rendered.into_iter().enumerate() { + if index > 0 { + docs.push(ir::hard_line()); + } + if !line.is_empty() { + docs.push(ir::text(line)); + } } docs } -/// Recursively walk a doc comment node, emitting flat IR for each token. -fn walk_doc_tokens(node: &LuaSyntaxNode, docs: &mut Vec, last_was_space: &mut bool) { - for child in node.children_with_tokens() { +fn render_normal_comment_lines(description: &LuaDocDescription) -> Vec { + let mut lines = Vec::new(); + let mut prefix: Option = None; + let mut gap = String::new(); + let mut detail = String::new(); + + for child in description.syntax().children_with_tokens() { + let LuaSyntaxElement::Token(token) = child else { + continue; + }; + + match token.kind().into() { + LuaTokenKind::TkNormalStart | LuaTokenKind::TKNonStdComment => { + if let Some(prefix_text) = prefix.take() { + lines.push(render_normal_comment_line(&prefix_text, &gap, &detail)); + } + prefix = Some(token.text().to_string()); + gap.clear(); + detail.clear(); + } + LuaTokenKind::TkWhitespace => { + if prefix.is_some() && detail.is_empty() { + gap.push_str(token.text()); + } else if !detail.is_empty() { + detail.push_str(token.text()); + } + } + LuaTokenKind::TkDocDetail => { + detail.push_str(token.text()); + } + LuaTokenKind::TkEndOfLine => { + if let Some(prefix_text) = prefix.take() { + lines.push(render_normal_comment_line(&prefix_text, &gap, &detail)); + } + gap.clear(); + detail.clear(); + } + _ => {} + } + } + + if let Some(prefix_text) = prefix.take() { + lines.push(render_normal_comment_line(&prefix_text, &gap, &detail)); + } + + lines +} + +fn render_normal_comment_line(prefix: &str, gap: &str, detail: &str) -> String { + let mut line = prefix.trim_end().to_string(); + if !gap.is_empty() || !detail.is_empty() { + line.push_str(gap); + line.push_str(detail); + } + line.trim_end().to_string() +} + +#[derive(Debug, Clone)] +enum DocCommentLine { + Empty, + Description(String), + Class { + body: String, + desc: Option, + }, + Alias { + body: String, + desc: Option, + }, + Type { + body: String, + desc: Option, + }, + Generic { + body: String, + desc: Option, + }, + Overload { + body: String, + desc: Option, + }, + Param { + name: String, + ty: String, + desc: Option, + }, + Field { + key: String, + ty: String, + desc: Option, + }, + Return { + body: String, + desc: Option, + }, + Raw(String), +} + +#[derive(Default)] +struct PendingDocLine { + prefix: Option, + tag: Option, + description: Option, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum AlignableDocTagKind { + Class, + Alias, + Type, + Generic, + Overload, + Param, + Field, + Return, +} + +fn parse_doc_comment_lines(comment: &LuaComment) -> Vec { + let mut lines = Vec::new(); + let mut pending = PendingDocLine::default(); + + for child in comment.syntax().children_with_tokens() { match child { - rowan::NodeOrToken::Token(token) => { - let kind: LuaTokenKind = token.kind().into(); - match kind { - LuaTokenKind::TkWhitespace => { - if !*last_was_space { - docs.push(ir::space()); - *last_was_space = true; + LuaSyntaxElement::Token(token) => match token.kind().into() { + LuaTokenKind::TkWhitespace => {} + LuaTokenKind::TkDocStart + | LuaTokenKind::TkDocLongStart + | LuaTokenKind::TkNormalStart + | LuaTokenKind::TkDocContinue => { + pending.prefix = Some(token.text().to_string()); + } + LuaTokenKind::TkEndOfLine => { + lines.push(finalize_doc_comment_line(&mut pending)); + } + _ => {} + }, + LuaSyntaxElement::Node(node) => match node.kind().into() { + LuaSyntaxKind::DocDescription => { + pending.description = LuaDocDescription::cast(node); + } + syntax_kind if LuaDocTag::can_cast(syntax_kind) => { + pending.tag = LuaDocTag::cast(node); + } + _ => {} + }, + } + } + + if pending.prefix.is_some() || pending.tag.is_some() || pending.description.is_some() { + lines.push(finalize_doc_comment_line(&mut pending)); + } + + lines +} + +fn finalize_doc_comment_line(pending: &mut PendingDocLine) -> DocCommentLine { + let prefix = pending.prefix.take().unwrap_or_default(); + let tag = pending.tag.take(); + let description = pending.description.take(); + + if let Some(tag) = tag { + build_doc_tag_line(&prefix, tag, description) + } else if let Some(description) = description { + let text = normalize_single_line_spaces(&description.get_description_text()); + if text.is_empty() { + DocCommentLine::Raw(prefix.trim_end().to_string()) + } else { + DocCommentLine::Description(text) + } + } else if prefix.is_empty() { + DocCommentLine::Empty + } else { + DocCommentLine::Raw(prefix.trim_end().to_string()) + } +} + +fn build_doc_tag_line( + prefix: &str, + tag: LuaDocTag, + description: Option, +) -> DocCommentLine { + if prefix != "---@" { + return raw_doc_tag_line(prefix, tag.syntax().text().to_string(), description); + } + + match tag { + LuaDocTag::Class(class_tag) => { + build_class_doc_line(prefix, &class_tag, description.clone()).unwrap_or_else(|| { + raw_doc_tag_line(prefix, class_tag.syntax().text().to_string(), description) + }) + } + LuaDocTag::Alias(alias) => build_alias_doc_line(prefix, &alias, description.clone()) + .unwrap_or_else(|| { + raw_doc_tag_line(prefix, alias.syntax().text().to_string(), description) + }), + LuaDocTag::Type(type_tag) => build_type_doc_line(prefix, &type_tag, description.clone()) + .unwrap_or_else(|| { + raw_doc_tag_line(prefix, type_tag.syntax().text().to_string(), description) + }), + LuaDocTag::Generic(generic) => { + build_generic_doc_line(prefix, &generic, description.clone()).unwrap_or_else(|| { + raw_doc_tag_line(prefix, generic.syntax().text().to_string(), description) + }) + } + LuaDocTag::Overload(overload) => { + build_overload_doc_line(prefix, &overload, description.clone()).unwrap_or_else(|| { + raw_doc_tag_line(prefix, overload.syntax().text().to_string(), description) + }) + } + LuaDocTag::Param(param) => build_param_doc_line(prefix, ¶m, description.clone()) + .unwrap_or_else(|| { + raw_doc_tag_line(prefix, param.syntax().text().to_string(), description) + }), + LuaDocTag::Field(field) => build_field_doc_line(prefix, &field, description.clone()) + .unwrap_or_else(|| { + raw_doc_tag_line(prefix, field.syntax().text().to_string(), description) + }), + LuaDocTag::Return(ret) => build_return_doc_line(prefix, &ret, description.clone()) + .unwrap_or_else(|| { + raw_doc_tag_line(prefix, ret.syntax().text().to_string(), description) + }), + other => raw_doc_tag_line(prefix, other.syntax().text().to_string(), description), + } +} + +fn build_class_doc_line( + _prefix: &str, + tag: &LuaDocTagClass, + description: Option, +) -> Option { + let mut body = tag.get_name_token()?.get_name_text().to_string(); + if let Some(generic_decl) = tag.get_generic_decl() { + body.push_str(&single_line_syntax_text(&generic_decl)?); + } + if let Some(supers) = tag.get_supers() { + body.push_str(": "); + body.push_str(&single_line_syntax_text(&supers)?); + } + let desc = inline_doc_description_text(description); + Some(DocCommentLine::Class { body, desc }) +} + +fn build_alias_doc_line( + _prefix: &str, + tag: &LuaDocTagAlias, + description: Option, +) -> Option { + let body = raw_doc_tag_body_text("alias", tag)?; + let desc = inline_doc_description_text(description); + Some(DocCommentLine::Alias { body, desc }) +} + +fn build_type_doc_line( + _prefix: &str, + tag: &LuaDocTagType, + description: Option, +) -> Option { + let mut parts = Vec::new(); + for ty in tag.get_type_list() { + parts.push(single_line_syntax_text(&ty)?); + } + if parts.is_empty() { + return None; + } + let desc = inline_doc_description_text(description); + Some(DocCommentLine::Type { + body: parts.join(", "), + desc, + }) +} + +fn build_generic_doc_line( + _prefix: &str, + tag: &LuaDocTagGeneric, + description: Option, +) -> Option { + let body = generic_decl_list_text(&tag.get_generic_decl_list()?)?; + let desc = inline_doc_description_text(description); + Some(DocCommentLine::Generic { body, desc }) +} + +fn build_overload_doc_line( + _prefix: &str, + tag: &LuaDocTagOverload, + description: Option, +) -> Option { + let body = single_line_syntax_text(&tag.get_type()?)?; + let desc = inline_doc_description_text(description); + Some(DocCommentLine::Overload { body, desc }) +} + +fn raw_doc_tag_line( + prefix: &str, + body: String, + description: Option, +) -> DocCommentLine { + if body.contains('\n') { + return DocCommentLine::Raw(format!("{prefix}{body}").trim_end().to_string()); + } + + let mut line = format!("{prefix}{}", normalize_single_line_spaces(&body)); + if let Some(desc) = inline_doc_description_text(description) + && !desc.is_empty() + { + line.push(' '); + line.push_str(&desc); + } + DocCommentLine::Raw(line) +} + +fn build_param_doc_line( + _prefix: &str, + tag: &LuaDocTagParam, + description: Option, +) -> Option { + let mut name = if tag.is_vararg() { + "...".to_string() + } else { + tag.get_name_token()?.get_name_text().to_string() + }; + if tag.is_nullable() { + name.push('?'); + } + + let ty = single_line_syntax_text(&tag.get_type()?)?; + let desc = inline_doc_description_text(description); + Some(DocCommentLine::Param { name, ty, desc }) +} + +fn build_field_doc_line( + _prefix: &str, + tag: &LuaDocTagField, + description: Option, +) -> Option { + let mut key = String::new(); + if let Some(visibility) = tag.get_visibility_token() { + key.push_str(visibility.syntax().text()); + key.push(' '); + } + key.push_str(&field_key_text(&tag.get_field_key()?)?); + if tag.is_nullable() { + key.push('?'); + } + + let ty = single_line_syntax_text(&tag.get_type()?)?; + let desc = inline_doc_description_text(description); + Some(DocCommentLine::Field { key, ty, desc }) +} + +fn build_return_doc_line( + _prefix: &str, + tag: &LuaDocTagReturn, + description: Option, +) -> Option { + let mut parts = Vec::new(); + for (ty, name) in tag.get_info_list() { + let mut part = single_line_syntax_text(&ty)?; + if let Some(name) = name { + part.push(' '); + part.push_str(name.get_name_text()); + } + parts.push(part); + } + + if parts.is_empty() { + parts.push(single_line_syntax_text(&tag.get_first_type()?)?); + } + + let desc = inline_doc_description_text(description); + Some(DocCommentLine::Return { + body: parts.join(", "), + desc, + }) +} + +fn field_key_text(key: &LuaDocFieldKey) -> Option { + Some(match key { + LuaDocFieldKey::Name(name) => name.get_name_text().to_string(), + LuaDocFieldKey::String(string) => format!("[{}]", string.syntax().text()), + LuaDocFieldKey::Integer(integer) => format!("[{}]", integer.syntax().text()), + LuaDocFieldKey::Type(typ) => format!("[{}]", single_line_syntax_text(typ)?), + }) +} + +fn single_line_syntax_text(node: &impl LuaAstNode) -> Option { + let text = node.syntax().text().to_string(); + if text.contains('\n') { + None + } else { + Some(normalize_single_line_spaces(&text)) + } +} + +fn inline_doc_description_text(description: Option) -> Option { + let description = description?; + let text = normalize_single_line_spaces(&description.get_description_text()); + if text.is_empty() { None } else { Some(text) } +} + +fn normalize_single_line_spaces(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +fn generic_decl_list_text(list: &LuaDocGenericDeclList) -> Option { + let text = single_line_syntax_text(list)?; + Some(text) +} + +fn raw_doc_tag_body_text(tag_name: &str, node: &T) -> Option { + let text = node.syntax().text().to_string(); + if text.contains('\n') { + return None; + } + + let body = text.trim().strip_prefix(tag_name)?.trim_start(); + Some(body.trim_end().to_string()) +} + +fn render_doc_comment_lines(config: &LuaFormatConfig, lines: &[DocCommentLine]) -> Vec { + let mut rendered = Vec::new(); + let mut index = 0; + while index < lines.len() { + let kind = alignable_doc_tag_kind(&lines[index]); + if let Some(kind) = kind + && should_align_doc_tag_kind(config, kind) + { + let mut group_end = index + 1; + while group_end < lines.len() && alignable_doc_tag_kind(&lines[group_end]) == Some(kind) + { + group_end += 1; + } + + if group_end - index >= 2 { + rendered.extend(render_aligned_doc_tag_group( + config, + &lines[index..group_end], + kind, + )); + index = group_end; + continue; + } + } + + rendered.push(render_single_doc_comment_line(config, &lines[index])); + index += 1; + } + rendered +} + +fn should_align_doc_tag_kind(config: &LuaFormatConfig, kind: AlignableDocTagKind) -> bool { + match kind { + AlignableDocTagKind::Class + | AlignableDocTagKind::Alias + | AlignableDocTagKind::Type + | AlignableDocTagKind::Generic + | AlignableDocTagKind::Overload => config.should_align_emmy_doc_declaration_tags(), + AlignableDocTagKind::Param | AlignableDocTagKind::Field | AlignableDocTagKind::Return => { + config.should_align_emmy_doc_reference_tags() + } + } +} + +fn alignable_doc_tag_kind(line: &DocCommentLine) -> Option { + match line { + DocCommentLine::Class { .. } => Some(AlignableDocTagKind::Class), + DocCommentLine::Alias { .. } => Some(AlignableDocTagKind::Alias), + DocCommentLine::Type { .. } => Some(AlignableDocTagKind::Type), + DocCommentLine::Generic { .. } => Some(AlignableDocTagKind::Generic), + DocCommentLine::Overload { .. } => Some(AlignableDocTagKind::Overload), + DocCommentLine::Param { .. } => Some(AlignableDocTagKind::Param), + DocCommentLine::Field { .. } => Some(AlignableDocTagKind::Field), + DocCommentLine::Return { .. } => Some(AlignableDocTagKind::Return), + _ => None, + } +} + +fn render_aligned_doc_tag_group( + config: &LuaFormatConfig, + lines: &[DocCommentLine], + kind: AlignableDocTagKind, +) -> Vec { + let gap = " ".repeat(config.emmy_doc.tag_spacing.max(1)); + match kind { + AlignableDocTagKind::Class => render_body_aligned_doc_group(config, lines, "class"), + AlignableDocTagKind::Alias => render_alias_doc_group(config, lines), + AlignableDocTagKind::Type => render_body_aligned_doc_group(config, lines, "type"), + AlignableDocTagKind::Generic => render_body_aligned_doc_group(config, lines, "generic"), + AlignableDocTagKind::Overload => render_body_aligned_doc_group(config, lines, "overload"), + AlignableDocTagKind::Param => { + let max_name = lines + .iter() + .filter_map(|line| match line { + DocCommentLine::Param { name, .. } => Some(name.len()), + _ => None, + }) + .max() + .unwrap_or(0); + let max_type = lines + .iter() + .filter_map(|line| match line { + DocCommentLine::Param { ty, .. } => Some(ty.len()), + _ => None, + }) + .max() + .unwrap_or(0); + + lines + .iter() + .map(|line| match line { + DocCommentLine::Param { name, ty, desc } => { + let mut rendered = format!( + "---@param{gap}{name: { - // Remove trailing space before line break - if *last_was_space { - docs.pop(); + other => render_single_doc_comment_line(config, other), + }) + .collect() + } + AlignableDocTagKind::Field => { + let max_key = lines + .iter() + .filter_map(|line| match line { + DocCommentLine::Field { key, .. } => Some(key.len()), + _ => None, + }) + .max() + .unwrap_or(0); + let max_type = lines + .iter() + .filter_map(|line| match line { + DocCommentLine::Field { ty, .. } => Some(ty.len()), + _ => None, + }) + .max() + .unwrap_or(0); + + lines + .iter() + .map(|line| match line { + DocCommentLine::Field { key, ty, desc } => { + let mut rendered = format!( + "---@field{gap}{key: { - docs.push(ir::text(token.text())); - *last_was_space = false; + other => render_single_doc_comment_line(config, other), + }) + .collect() + } + AlignableDocTagKind::Return => { + let max_body = lines + .iter() + .filter_map(|line| match line { + DocCommentLine::Return { body, .. } => Some(body.len()), + _ => None, + }) + .max() + .unwrap_or(0); + + lines + .iter() + .map(|line| match line { + DocCommentLine::Return { body, desc } => { + let mut rendered = format!( + "---@return{gap}{body: render_single_doc_comment_line(config, other), + }) + .collect() + } + } +} + +fn render_alias_doc_group(config: &LuaFormatConfig, lines: &[DocCommentLine]) -> Vec { + let gap = " ".repeat(config.emmy_doc.tag_spacing.max(1)); + let max_body = lines + .iter() + .filter_map(|line| match line { + DocCommentLine::Alias { body, .. } => Some(body.len()), + _ => None, + }) + .max() + .unwrap_or(0); + + lines + .iter() + .map(|line| match line { + DocCommentLine::Alias { body, desc } => { + let mut rendered = format!( + "---@alias{gap}{body: render_single_doc_comment_line(config, other), + }) + .collect() +} + +fn render_body_aligned_doc_group( + config: &LuaFormatConfig, + lines: &[DocCommentLine], + tag_name: &str, +) -> Vec { + let gap = " ".repeat(config.emmy_doc.tag_spacing.max(1)); + let max_body = lines + .iter() + .filter_map(|line| doc_line_body_and_desc(line).map(|(body, _)| body.len())) + .max() + .unwrap_or(0); + + lines + .iter() + .map(|line| { + if let Some((body, desc)) = doc_line_body_and_desc(line) { + let mut rendered = format!( + "---@{tag_name}{gap}{body: Option<(&str, Option<&String>)> { + match line { + DocCommentLine::Class { body, desc } + | DocCommentLine::Alias { body, desc } + | DocCommentLine::Type { body, desc } + | DocCommentLine::Generic { body, desc } + | DocCommentLine::Overload { body, desc } + | DocCommentLine::Return { body, desc } => Some((body.as_str(), desc.as_ref())), + _ => None, + } +} + +fn render_single_doc_comment_line(config: &LuaFormatConfig, line: &DocCommentLine) -> String { + let gap = " ".repeat(config.emmy_doc.tag_spacing.max(1)); + match line { + DocCommentLine::Empty => String::new(), + DocCommentLine::Description(text) => { + if config.emmy_doc.space_after_description_dash { + format!("--- {text}") + } else { + format!("---{text}") } - rowan::NodeOrToken::Node(child_node) => { - walk_doc_tokens(&child_node, docs, last_was_space); + } + DocCommentLine::Raw(text) => text.clone(), + DocCommentLine::Class { body, desc } => { + let mut rendered = format!("---@class{gap}{body}"); + if let Some(desc) = desc { + rendered.push_str(&gap); + rendered.push_str(desc); + } + rendered + } + DocCommentLine::Alias { body, desc } => { + let mut rendered = format!("---@alias{gap}{body}"); + if let Some(desc) = desc { + rendered.push_str(&gap); + rendered.push_str(desc); + } + rendered + } + DocCommentLine::Type { body, desc } => { + let mut rendered = format!("---@type{gap}{body}"); + if let Some(desc) = desc { + rendered.push_str(&gap); + rendered.push_str(desc); + } + rendered + } + DocCommentLine::Generic { body, desc } => { + let mut rendered = format!("---@generic{gap}{body}"); + if let Some(desc) = desc { + rendered.push_str(&gap); + rendered.push_str(desc); + } + rendered + } + DocCommentLine::Overload { body, desc } => { + let mut rendered = format!("---@overload{gap}{body}"); + if let Some(desc) = desc { + rendered.push_str(&gap); + rendered.push_str(desc); } + rendered + } + DocCommentLine::Param { name, ty, desc } => { + let mut rendered = format!("---@param{gap}{name}{gap}{ty}"); + if let Some(desc) = desc { + rendered.push_str(&gap); + rendered.push_str(desc); + } + rendered + } + DocCommentLine::Field { key, ty, desc } => { + let mut rendered = format!("---@field{gap}{key}{gap}{ty}"); + if let Some(desc) = desc { + rendered.push_str(&gap); + rendered.push_str(desc); + } + rendered + } + DocCommentLine::Return { body, desc } => { + let mut rendered = format!("---@return{gap}{body}"); + if let Some(desc) = desc { + rendered.push_str(&gap); + rendered.push_str(desc); + } + rendered } } } @@ -81,7 +859,7 @@ fn walk_doc_tokens(node: &LuaSyntaxNode, docs: &mut Vec, last_was_space: /// When a Block is empty (e.g. `if x then -- comment end`), /// comments may become direct children of the parent statement node rather than the Block. /// This function collects those comments and returns the formatted IR. -pub fn collect_orphan_comments(node: &LuaSyntaxNode) -> Vec { +pub fn collect_orphan_comments(config: &LuaFormatConfig, node: &LuaSyntaxNode) -> Vec { let mut docs = Vec::new(); for child in node.children() { if child.kind() == LuaKind::Syntax(LuaSyntaxKind::Comment) @@ -90,7 +868,7 @@ pub fn collect_orphan_comments(node: &LuaSyntaxNode) -> Vec { if !docs.is_empty() { docs.push(ir::hard_line()); } - docs.extend(format_comment(&comment)); + docs.extend(format_comment(config, &comment)); } } docs @@ -109,14 +887,16 @@ pub fn extract_trailing_comment(node: &LuaSyntaxNode) -> Option<(Vec, Tex LuaKind::Token(LuaTokenKind::TkComma) => {} LuaKind::Syntax(LuaSyntaxKind::Comment) => { let comment_node = sibling.as_node()?; - let comment_text = comment_node.text().to_string(); - let comment_text = comment_text.trim_end().to_string(); + let comment = LuaComment::cast(comment_node.clone())?; // Only single-line comments are treated as trailing comments - if comment_text.contains('\n') { + if comment_node.text().contains_char('\n') { return None; } + let comment_text = render_single_line_comment_text(&comment) + .unwrap_or_else(|| comment_node.text().to_string().trim_end().to_string()); + let range = comment_node.text_range(); return Some((vec![ir::text(comment_text)], range)); } @@ -128,10 +908,34 @@ pub fn extract_trailing_comment(node: &LuaSyntaxNode) -> Option<(Vec, Tex None } +fn render_single_line_comment_text(comment: &LuaComment) -> Option { + match classify_comment(comment) { + CommentKind::Long => Some(comment.syntax().text().to_string().trim_end().to_string()), + CommentKind::Normal => { + let description = comment.get_description()?; + let lines = render_normal_comment_lines(&description); + if lines.len() == 1 { + lines.into_iter().next() + } else { + None + } + } + CommentKind::Doc => None, + } +} + +pub fn trailing_comment_prefix(config: &LuaFormatConfig) -> Vec { + let gap = config.comments.line_comment_min_spaces_before.max(1); + (0..gap).map(|_| ir::space()).collect() +} + /// Format a trailing comment as LineSuffix (for non-grouped use). -pub fn format_trailing_comment(node: &LuaSyntaxNode) -> Option<(DocIR, TextRange)> { +pub fn format_trailing_comment( + config: &LuaFormatConfig, + node: &LuaSyntaxNode, +) -> Option<(DocIR, TextRange)> { let (docs, range) = extract_trailing_comment(node)?; - let mut suffix_content = vec![ir::space()]; + let mut suffix_content = trailing_comment_prefix(config); suffix_content.extend(docs); Some((ir::line_suffix(suffix_content), range)) } diff --git a/crates/emmylua_formatter/src/formatter/expression.rs b/crates/emmylua_formatter/src/formatter/expression.rs index b35c10f6d..2758d999e 100644 --- a/crates/emmylua_formatter/src/formatter/expression.rs +++ b/crates/emmylua_formatter/src/formatter/expression.rs @@ -1,7 +1,8 @@ use emmylua_parser::{ BinaryOperator, LuaAstNode, LuaAstToken, LuaBinaryExpr, LuaCallExpr, LuaClosureExpr, - LuaComment, LuaExpr, LuaIndexExpr, LuaKind, LuaLiteralExpr, LuaNameExpr, LuaParenExpr, - LuaSyntaxKind, LuaTableExpr, LuaTableField, LuaUnaryExpr, UnaryOperator, + LuaComment, LuaExpr, LuaIndexExpr, LuaIndexKey, LuaKind, LuaLiteralExpr, LuaNameExpr, + LuaParenExpr, LuaSingleArgExpr, LuaSyntaxKind, LuaTableExpr, LuaTableField, LuaTokenKind, + LuaUnaryExpr, UnaryOperator, }; use rowan::TextRange; @@ -9,8 +10,31 @@ use crate::config::ExpandStrategy; use crate::ir::{self, AlignEntry, DocIR, EqSplit}; use super::FormatContext; -use super::comment::{extract_trailing_comment, format_comment}; +use super::comment::{extract_trailing_comment, format_comment, trailing_comment_prefix}; +use super::sequence::{ + SequenceEntry, render_sequence, sequence_ends_with_comment, sequence_has_comment, + sequence_starts_with_comment, +}; use super::spacing::{SpaceRule, space_around_assign, space_around_binary_op}; +use super::tokens::{comma_soft_line_sep, comma_space_sep, tok}; +use super::trivia::{node_has_direct_comment_child, node_has_direct_same_line_inline_comment}; + +struct BinaryExprSplit { + lhs_entries: Vec, + op_text: Option, + rhs_entries: Vec, +} + +enum IndexStandaloneSuffix { + Dot(Vec), + Colon(Vec), + Bracket(Vec), +} + +struct IndexStandaloneLayout { + before_suffix_comments: Vec, + suffix: Option, +} pub fn format_expr(ctx: &FormatContext, expr: &LuaExpr) -> Vec { match expr { @@ -27,16 +51,15 @@ pub fn format_expr(ctx: &FormatContext, expr: &LuaExpr) -> Vec { } fn format_name_expr(_ctx: &FormatContext, expr: &LuaNameExpr) -> Vec { - if let Some(name) = expr.get_name_text() { - vec![ir::text(name)] + if let Some(token) = expr.get_name_token() { + vec![ir::source_token(token.syntax().clone())] } else { vec![] } } fn format_literal_expr(_ctx: &FormatContext, expr: &LuaLiteralExpr) -> Vec { - // 直接使用原始文本 - vec![ir::text(expr.syntax().text().to_string())] + vec![ir::source_node(expr.syntax().clone())] } /// 二元表达式: a + b, a and b, ... @@ -47,38 +70,50 @@ fn format_literal_expr(_ctx: &FormatContext, expr: &LuaLiteralExpr) -> Vec Vec { + if node_has_direct_comment_child(expr.syntax()) { + return format_binary_expr_with_standalone_comments(ctx, expr); + } + + if let Some(flattened) = try_format_flat_binary_chain(ctx, expr) { + return flattened; + } + if let Some((left, right)) = expr.get_exprs() { let left_docs = format_expr(ctx, &left); let right_docs = format_expr(ctx, &right); if let Some(op_token) = expr.get_op_token() { - let op_text = op_token.syntax().text().to_string(); let op = op_token.get_op(); let space_rule = space_around_binary_op(op, ctx.config); let space_ir = space_rule.to_ir(); + let preserve_multiline_layout = expr.syntax().text().contains_char('\n'); // Safety: when the left operand text ends with '.' and the operator // is '..', we must force a space before the operator to avoid // ambiguity (e.g. `1. ..` must not become `1...`). // Only the before-space is forced; the after-space follows the // configured space_rule. - let force_space_before = op == BinaryOperator::OpConcat + let mut force_space_before = false; + if op == BinaryOperator::OpConcat && space_rule == SpaceRule::NoSpace - && left.syntax().text().to_string().ends_with('.'); + && let Some(last_token) = left.syntax().last_token() + && last_token.kind() == LuaTokenKind::TkFloat.into() + { + force_space_before = true; + } // Before-operator break: soft_line (→space when flat) if space, // soft_line_or_empty (→"" when flat) if no space - let break_ir = if !force_space_before && space_rule == SpaceRule::NoSpace { - ir::soft_line_or_empty() - } else { - ir::soft_line() - }; + let break_ir = continuation_break_ir( + preserve_multiline_layout, + force_space_before || space_rule != SpaceRule::NoSpace, + ); return vec![ir::group(vec![ ir::list(left_docs), ir::indent(vec![ break_ir, - ir::text(op_text), + ir::source_token(op_token.syntax().clone()), space_ir, ir::list(right_docs), ]), @@ -89,14 +124,182 @@ fn format_binary_expr(ctx: &FormatContext, expr: &LuaBinaryExpr) -> Vec { vec![] } +fn format_binary_expr_with_standalone_comments( + ctx: &FormatContext, + expr: &LuaBinaryExpr, +) -> Vec { + let BinaryExprSplit { + lhs_entries, + op_text, + rhs_entries, + } = collect_binary_expr_entries(ctx, expr); + let mut docs = Vec::new(); + + render_sequence(&mut docs, &lhs_entries, false); + + let Some(op_text) = op_text else { + return docs; + }; + + let op = expr.get_op_token().map(|token| token.get_op()); + let space_rule = op + .map(|op| space_around_binary_op(op, ctx.config)) + .unwrap_or(SpaceRule::Space); + let after_op_ir = space_rule.to_ir(); + + let force_space_before = matches!(op, Some(BinaryOperator::OpConcat)) + && space_rule == SpaceRule::NoSpace + && expr + .get_left_expr() + .as_ref() + .is_some_and(expr_end_with_float); + + if sequence_has_comment(&lhs_entries) { + if !sequence_ends_with_comment(&lhs_entries) { + docs.push(ir::hard_line()); + } + } else if force_space_before { + docs.push(ir::space()); + } else { + docs.push(space_rule.to_ir()); + } + + docs.push(op_text); + + if !rhs_entries.is_empty() { + if sequence_starts_with_comment(&rhs_entries) { + docs.push(ir::hard_line()); + render_sequence(&mut docs, &rhs_entries, true); + } else { + docs.push(after_op_ir); + render_sequence(&mut docs, &rhs_entries, false); + } + } + + docs +} + +fn collect_binary_expr_entries(ctx: &FormatContext, expr: &LuaBinaryExpr) -> BinaryExprSplit { + let mut lhs_entries = Vec::new(); + let mut rhs_entries = Vec::new(); + let mut op_text = None; + let op_range = expr.get_op_token().map(|token| token.syntax().text_range()); + let mut meet_op = false; + + for child in expr.syntax().children_with_tokens() { + if let Some(token) = child.as_token() + && Some(token.text_range()) == op_range + { + meet_op = true; + op_text = Some(ir::source_token(token.clone())); + continue; + } + + match child.kind() { + LuaKind::Syntax(LuaSyntaxKind::Comment) => { + if let Some(node) = child.as_node() + && let Some(comment) = LuaComment::cast(node.clone()) + { + let entry = SequenceEntry::Comment(format_comment(ctx.config, &comment)); + if meet_op { + rhs_entries.push(entry); + } else { + lhs_entries.push(entry); + } + } + } + _ => { + if let Some(node) = child.as_node() + && let Some(inner_expr) = LuaExpr::cast(node.clone()) + { + let entry = SequenceEntry::Item(format_expr(ctx, &inner_expr)); + if meet_op { + rhs_entries.push(entry); + } else { + lhs_entries.push(entry); + } + } + } + } + } + + BinaryExprSplit { + lhs_entries, + op_text, + rhs_entries, + } +} + +fn try_format_flat_binary_chain(ctx: &FormatContext, expr: &LuaBinaryExpr) -> Option> { + let op_token = expr.get_op_token()?; + let op = op_token.get_op(); + let mut operands = Vec::new(); + collect_binary_chain_operands(&LuaExpr::BinaryExpr(expr.clone()), op, &mut operands); + if operands.len() < 3 { + return None; + } + + let space_rule = space_around_binary_op(op, ctx.config); + let space_ir = space_rule.to_ir(); + let preserve_multiline_layout = expr.syntax().text().contains_char('\n'); + + let mut docs = format_expr(ctx, &operands[0]); + let mut previous = &operands[0]; + for operand in operands.iter().skip(1) { + let force_space_before = op == BinaryOperator::OpConcat + && space_rule == SpaceRule::NoSpace + && expr_end_with_float(previous); + let break_ir = continuation_break_ir( + preserve_multiline_layout, + force_space_before || space_rule != SpaceRule::NoSpace, + ); + let mut segment = Vec::new(); + segment.push(break_ir); + segment.push(ir::source_token(op_token.syntax().clone())); + segment.push(space_ir.clone()); + segment.extend(format_expr(ctx, operand)); + + if preserve_multiline_layout { + docs.push(ir::indent(segment)); + } else { + docs.push(ir::group(vec![ir::indent(segment)])); + } + + previous = operand; + } + + Some(docs) +} + +fn collect_binary_chain_operands(expr: &LuaExpr, op: BinaryOperator, operands: &mut Vec) { + if let LuaExpr::BinaryExpr(binary) = expr + && let Some(op_token) = binary.get_op_token() + && op_token.get_op() == op + && let Some((left, right)) = binary.get_exprs() + { + collect_binary_chain_operands(&left, op, operands); + collect_binary_chain_operands(&right, op, operands); + return; + } + + operands.push(expr.clone()); +} + +fn expr_end_with_float(expr: &LuaExpr) -> bool { + let Some(last_token) = expr.syntax().last_token() else { + return false; + }; + + last_token.kind() == LuaTokenKind::TkFloat.into() +} + /// 一元表达式: -x, not x, #t, ~x fn format_unary_expr(ctx: &FormatContext, expr: &LuaUnaryExpr) -> Vec { let mut docs = Vec::new(); if let Some(op_token) = expr.get_op_token() { let op = op_token.get_op(); - let op_text = op_token.syntax().text().to_string(); - docs.push(ir::text(op_text)); + docs.push(ir::source_token(op_token.syntax().clone())); // `not` 和 `-`(作为关键字的)后面需要空格,`#` 和 `~` 不需要 match op { @@ -115,6 +318,10 @@ fn format_unary_expr(ctx: &FormatContext, expr: &LuaUnaryExpr) -> Vec { /// 函数调用: f(a, b), obj:m(a), f "hello", f { ... } fn format_call_expr(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { + if should_preserve_raw_call_expr(expr) { + return vec![ir::source_node_trimmed(expr.syntax().clone())]; + } + // 尝试方法链格式化 if let Some(chain) = try_format_chain(ctx, expr) { return chain; @@ -135,6 +342,10 @@ fn format_call_expr(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { /// 索引表达式: t.x, t:m, t[k] fn format_index_expr(ctx: &FormatContext, expr: &LuaIndexExpr) -> Vec { + if node_has_direct_comment_child(expr.syntax()) { + return format_index_expr_with_standalone_comments(ctx, expr); + } + let mut docs = Vec::new(); // 前缀 @@ -148,6 +359,144 @@ fn format_index_expr(ctx: &FormatContext, expr: &LuaIndexExpr) -> Vec { docs } +fn format_index_expr_with_standalone_comments( + ctx: &FormatContext, + expr: &LuaIndexExpr, +) -> Vec { + let mut docs = Vec::new(); + + if let Some(prefix) = expr.get_prefix_expr() { + docs.extend(format_expr(ctx, &prefix)); + } + + let IndexStandaloneLayout { + before_suffix_comments, + suffix, + } = collect_index_standalone_layout(ctx, expr); + + if sequence_has_comment(&before_suffix_comments) { + docs.push(ir::hard_line()); + render_sequence(&mut docs, &before_suffix_comments, true); + } + + match suffix { + Some(IndexStandaloneSuffix::Dot(entries)) => { + docs.push(tok(LuaTokenKind::TkDot)); + if sequence_starts_with_comment(&entries) { + docs.push(ir::hard_line()); + render_sequence(&mut docs, &entries, true); + } else { + render_sequence(&mut docs, &entries, false); + } + } + Some(IndexStandaloneSuffix::Colon(entries)) => { + docs.push(tok(LuaTokenKind::TkColon)); + if sequence_starts_with_comment(&entries) { + docs.push(ir::hard_line()); + render_sequence(&mut docs, &entries, true); + } else { + render_sequence(&mut docs, &entries, false); + } + } + Some(IndexStandaloneSuffix::Bracket(entries)) => { + docs.push(tok(LuaTokenKind::TkLeftBracket)); + if sequence_has_comment(&entries) { + docs.push(ir::hard_line()); + render_sequence(&mut docs, &entries, true); + docs.push(ir::hard_line()); + } else { + if ctx.config.spacing.space_inside_brackets { + docs.push(ir::space()); + } + render_sequence(&mut docs, &entries, false); + if ctx.config.spacing.space_inside_brackets { + docs.push(ir::space()); + } + } + docs.push(tok(LuaTokenKind::TkRightBracket)); + } + None => docs.extend(format_index_access_ir(ctx, expr)), + } + + docs +} + +fn collect_index_standalone_layout( + ctx: &FormatContext, + expr: &LuaIndexExpr, +) -> IndexStandaloneLayout { + let mut before_suffix_comments = Vec::new(); + let mut suffix_entries = Vec::new(); + let index_range = expr + .get_index_token() + .map(|token| token.syntax().text_range()); + let mut meet_prefix = false; + let mut suffix_kind = None; + + for child in expr.syntax().children_with_tokens() { + if let Some(token) = child.as_token() + && Some(token.text_range()) == index_range + { + suffix_kind = Some(match token.kind().into() { + LuaTokenKind::TkDot => LuaTokenKind::TkDot, + LuaTokenKind::TkColon => LuaTokenKind::TkColon, + LuaTokenKind::TkLeftBracket => LuaTokenKind::TkLeftBracket, + _ => LuaTokenKind::None, + }); + meet_prefix = true; + continue; + } + + match child.kind() { + LuaKind::Syntax(LuaSyntaxKind::Comment) => { + if let Some(node) = child.as_node() + && let Some(comment) = LuaComment::cast(node.clone()) + { + let entry = SequenceEntry::Comment(format_comment(ctx.config, &comment)); + if meet_prefix { + suffix_entries.push(entry); + } else { + before_suffix_comments.push(entry); + } + } + } + _ => { + if let Some(node) = child.as_node() { + if !meet_prefix && LuaExpr::cast(node.clone()).is_some() { + meet_prefix = false; + continue; + } + + if meet_prefix && let Some(inner_expr) = LuaExpr::cast(node.clone()) { + suffix_entries.push(SequenceEntry::Item(format_expr(ctx, &inner_expr))); + } + } else if let Some(token) = child.as_token() + && meet_prefix + { + match token.kind().into() { + LuaTokenKind::TkName => suffix_entries + .push(SequenceEntry::Item(vec![ir::source_token(token.clone())])), + LuaTokenKind::TkRightBracket => {} + _ => {} + } + } + } + } + } + + let suffix = match suffix_kind { + Some(LuaTokenKind::TkDot) => Some(IndexStandaloneSuffix::Dot(suffix_entries)), + Some(LuaTokenKind::TkColon) => Some(IndexStandaloneSuffix::Colon(suffix_entries)), + Some(LuaTokenKind::TkLeftBracket) => Some(IndexStandaloneSuffix::Bracket(suffix_entries)), + _ => None, + }; + + IndexStandaloneLayout { + before_suffix_comments, + suffix, + } +} + /// 格式化调用参数部分(不含前缀),如 `(a, b)` 或单参数简写 ` "str"` / ` { ... }` fn format_call_args_ir(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { let mut docs = Vec::new(); @@ -158,12 +507,12 @@ fn format_call_args_ir(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { && let Some(single_arg) = args_list.get_single_arg_expr() { match single_arg { - emmylua_parser::LuaSingleArgExpr::TableExpr(table) => { + LuaSingleArgExpr::TableExpr(table) => { docs.push(ir::space()); docs.extend(format_table_expr(ctx, &table)); return docs; } - emmylua_parser::LuaSingleArgExpr::LiteralExpr(lit) => { + LuaSingleArgExpr::LiteralExpr(lit) => { docs.push(ir::space()); docs.extend(format_literal_expr(ctx, &lit)); return docs; @@ -172,42 +521,85 @@ fn format_call_args_ir(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { } let args: Vec<_> = args_list.get_args().collect(); + let preserve_multiline_layout = args_list.syntax().text().contains_char('\n'); - if ctx.config.space_before_call_paren { + if ctx.config.spacing.space_before_call_paren { docs.push(ir::space()); } if args.is_empty() { - docs.push(ir::text("(")); - docs.push(ir::text(")")); + docs.push(tok(LuaTokenKind::TkLeftParen)); + docs.push(tok(LuaTokenKind::TkRightParen)); } else { - let arg_docs: Vec> = args.iter().map(|a| format_expr(ctx, a)).collect(); - let trailing = format_trailing_comma_ir(ctx.config.trailing_comma.clone()); + let arg_entries = collect_call_arg_entries(ctx, &args_list); + let has_comments = arg_entries.iter().any(|entry| match entry { + CallArgEntry::Arg { + trailing_comment, .. + } => trailing_comment.is_some(), + CallArgEntry::StandaloneComment(_) => true, + }); + let trailing = format_trailing_comma_ir(ctx.config.output.trailing_comma.clone()); - match ctx.config.call_args_expand { + match ctx.config.layout.call_args_expand { ExpandStrategy::Always => { - let inner = ir::intersperse(arg_docs, vec![ir::text(","), ir::soft_line()]); + let inner = if has_comments { + build_multiline_call_arg_entries(ctx, arg_entries) + } else { + let arg_docs: Vec> = + args.iter().map(|a| format_expr(ctx, a)).collect(); + vec![ir::list(ir::intersperse(arg_docs, comma_soft_line_sep()))] + }; docs.push(ir::group_break(vec![ - ir::text("("), + tok(LuaTokenKind::TkLeftParen), ir::indent(vec![ir::hard_line(), ir::list(inner), trailing]), ir::hard_line(), - ir::text(")"), + tok(LuaTokenKind::TkRightParen), ])); } ExpandStrategy::Never => { - let flat_inner = ir::intersperse(arg_docs, vec![ir::text(","), ir::space()]); - docs.push(ir::text("(")); - docs.push(ir::list(flat_inner)); - docs.push(ir::text(")")); + if has_comments { + let inner = build_multiline_call_arg_entries(ctx, arg_entries); + docs.push(ir::group_break(vec![ + tok(LuaTokenKind::TkLeftParen), + ir::indent(vec![ir::hard_line(), ir::list(inner), trailing]), + ir::hard_line(), + tok(LuaTokenKind::TkRightParen), + ])); + } else { + let arg_docs: Vec> = + args.iter().map(|a| format_expr(ctx, a)).collect(); + let flat_inner = ir::intersperse(arg_docs, comma_space_sep()); + docs.push(tok(LuaTokenKind::TkLeftParen)); + docs.push(ir::list(flat_inner)); + docs.push(tok(LuaTokenKind::TkRightParen)); + } } ExpandStrategy::Auto => { - let inner = ir::intersperse(arg_docs, vec![ir::text(","), ir::soft_line()]); - docs.push(ir::group(vec![ - ir::text("("), - ir::indent(vec![ir::soft_line_or_empty(), ir::list(inner), trailing]), - ir::soft_line_or_empty(), - ir::text(")"), - ])); + if has_comments || preserve_multiline_layout { + let inner = if has_comments { + build_multiline_call_arg_entries(ctx, arg_entries) + } else { + let arg_docs: Vec> = + args.iter().map(|a| format_expr(ctx, a)).collect(); + vec![ir::list(ir::intersperse(arg_docs, comma_soft_line_sep()))] + }; + docs.push(ir::group_break(vec![ + tok(LuaTokenKind::TkLeftParen), + ir::indent(vec![ir::hard_line(), ir::list(inner), trailing]), + ir::hard_line(), + tok(LuaTokenKind::TkRightParen), + ])); + } else { + let arg_docs: Vec> = + args.iter().map(|a| format_expr(ctx, a)).collect(); + let inner = ir::intersperse(arg_docs, comma_soft_line_sep()); + docs.push(ir::group(vec![ + tok(LuaTokenKind::TkLeftParen), + ir::indent(vec![ir::soft_line_or_empty(), ir::list(inner), trailing]), + ir::soft_line_or_empty(), + tok(LuaTokenKind::TkRightParen), + ])); + } } } } @@ -222,41 +614,41 @@ fn format_index_access_ir(ctx: &FormatContext, expr: &LuaIndexExpr) -> Vec { + LuaIndexKey::Expr(e) => { docs.extend(format_expr(ctx, &e)); } - emmylua_parser::LuaIndexKey::Integer(n) => { - docs.push(ir::text(n.syntax().text().to_string())); + LuaIndexKey::Integer(n) => { + docs.push(ir::source_token(n.syntax().clone())); } - emmylua_parser::LuaIndexKey::String(s) => { - docs.push(ir::text(s.syntax().text().to_string())); + LuaIndexKey::String(s) => { + docs.push(ir::source_token(s.syntax().clone())); } - emmylua_parser::LuaIndexKey::Name(name) => { - docs.push(ir::text(name.get_name_text().to_string())); + LuaIndexKey::Name(name) => { + docs.push(ir::source_token(name.syntax().clone())); } _ => {} } } - if ctx.config.space_inside_brackets { + if ctx.config.spacing.space_inside_brackets { docs.push(ir::space()); } - docs.push(ir::text("]")); + docs.push(tok(LuaTokenKind::TkRightBracket)); } } @@ -326,6 +718,8 @@ fn try_format_chain(ctx: &FormatContext, expr: &LuaCallExpr) -> Option Option Option Option Vec { if expr.is_empty() { - return vec![ir::text("{}")]; + return vec![ + tok(LuaTokenKind::TkLeftBrace), + tok(LuaTokenKind::TkRightBrace), + ]; } // Collect all child nodes: fields and standalone comments @@ -362,7 +763,7 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { for child in expr.syntax().children() { if let Some(field) = LuaTableField::cast(child.clone()) { let fdoc = format_table_field_ir(ctx, &field); - let eq_split = if ctx.config.align_table_field { + let eq_split = if ctx.config.align.table_field { format_table_field_eq_split(ctx, &field) } else { None @@ -388,15 +789,17 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { continue; } let comment = LuaComment::cast(child).unwrap(); - entries.push(TableEntry::StandaloneComment(format_comment(&comment))); + entries.push(TableEntry::StandaloneComment(format_comment( + ctx.config, &comment, + ))); has_standalone_comments = true; } } // Trailing comma - let trailing = format_trailing_comma_ir(ctx.config.trailing_comma.clone()); + let trailing = format_trailing_comma_ir(ctx.config.output.trailing_comma.clone()); - let space_inside = if ctx.config.space_inside_braces { + let space_inside = if ctx.config.spacing.space_inside_braces { ir::soft_line() } else { ir::soft_line_or_empty() @@ -414,11 +817,12 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { }); // Standalone or trailing comments force expansion + let preserve_multiline_layout = expr.syntax().text().contains_char('\n'); let force_expand = has_standalone_comments || has_trailing_comments; - match ctx.config.table_expand { + match ctx.config.layout.table_expand { ExpandStrategy::Always => { - build_table_expanded(entries, trailing, true, ctx.config.align_table_field) + build_table_expanded(ctx, entries, trailing, true, ctx.config.align.table_field) } ExpandStrategy::Never if !force_expand => { // Force single line (valid when no comments) @@ -429,28 +833,28 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { TableEntry::StandaloneComment(_) => None, }) .collect(); - let flat_inner = ir::intersperse(field_docs, vec![ir::text(","), ir::space()]); - let mut result = vec![ir::text("{")]; - if ctx.config.space_inside_braces { + let flat_inner = ir::intersperse(field_docs, comma_space_sep()); + let mut result = vec![tok(LuaTokenKind::TkLeftBrace)]; + if ctx.config.spacing.space_inside_braces { result.push(ir::space()); } result.push(ir::list(flat_inner)); - if ctx.config.space_inside_braces { + if ctx.config.spacing.space_inside_braces { result.push(ir::space()); } - result.push(ir::text("}")); + result.push(tok(LuaTokenKind::TkRightBrace)); result } ExpandStrategy::Never => { // Never mode but has comments — must expand - build_table_expanded(entries, trailing, true, ctx.config.align_table_field) + build_table_expanded(ctx, entries, trailing, true, ctx.config.align.table_field) } - ExpandStrategy::Auto if force_expand => { + ExpandStrategy::Auto if force_expand || preserve_multiline_layout => { // Has comments: force expand - build_table_expanded(entries, trailing, true, ctx.config.align_table_field) + build_table_expanded(ctx, entries, trailing, true, ctx.config.align.table_field) } ExpandStrategy::Auto => { - if ctx.config.align_table_field + if ctx.config.align.table_field && entries.iter().any(|e| { matches!( e, @@ -469,26 +873,32 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { TableEntry::StandaloneComment(_) => None, }) .collect(); - let flat_separator = vec![ir::text(","), ir::soft_line()]; + let flat_separator = comma_soft_line_sep(); let flat_inner = ir::intersperse(flat_field_docs, flat_separator); let flat_doc = ir::list(vec![ - ir::text("{"), + tok(LuaTokenKind::TkLeftBrace), ir::indent(vec![ space_inside.clone(), ir::list(flat_inner), trailing.clone(), ]), space_inside.clone(), - ir::text("}"), + tok(LuaTokenKind::TkRightBrace), ]); // Build break content with alignment for multi-line display - let break_inner = build_table_expanded_inner(&entries, &trailing, true); + let break_inner = build_table_expanded_inner( + ctx, + &entries, + &trailing, + true, + ctx.config.should_align_table_line_comments(), + ); let break_doc = ir::list(vec![ - ir::text("{"), + tok(LuaTokenKind::TkLeftBrace), ir::indent(break_inner), ir::hard_line(), - ir::text("}"), + tok(LuaTokenKind::TkRightBrace), ]); let gid = ir::next_group_id(); @@ -504,20 +914,30 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { TableEntry::StandaloneComment(_) => None, }) .collect(); - let separator = vec![ir::text(","), ir::soft_line()]; + let separator = comma_soft_line_sep(); let inner = ir::intersperse(field_docs, separator); // Auto: single line if fits, otherwise expand vec![ir::group(vec![ - ir::text("{"), + tok(LuaTokenKind::TkLeftBrace), ir::indent(vec![space_inside.clone(), ir::list(inner), trailing]), space_inside, - ir::text("}"), + tok(LuaTokenKind::TkRightBrace), ])] } } } } +fn continuation_break_ir(preserve_multiline_layout: bool, flat_space: bool) -> DocIR { + if preserve_multiline_layout { + ir::hard_line() + } else if flat_space { + ir::soft_line() + } else { + ir::soft_line_or_empty() + } +} + /// Format a single table field IR (without trailing comment) fn format_table_field_ir(ctx: &FormatContext, field: &LuaTableField) -> Vec { let mut fdoc = Vec::new(); @@ -526,7 +946,7 @@ fn format_table_field_ir(ctx: &FormatContext, field: &LuaTableField) -> Vec { + docs.push(tok(LuaTokenKind::TkLeftBracket)); + docs.push(ir::source_token(s.syntax().clone())); + docs.push(tok(LuaTokenKind::TkRightBracket)); } - emmylua_parser::LuaIndexKey::Integer(n) => { - docs.push(ir::text("[")); - docs.push(ir::text(n.syntax().text().to_string())); - docs.push(ir::text("]")); + LuaIndexKey::Integer(n) => { + docs.push(tok(LuaTokenKind::TkLeftBracket)); + docs.push(ir::source_token(n.syntax().clone())); + docs.push(tok(LuaTokenKind::TkRightBracket)); } - emmylua_parser::LuaIndexKey::Expr(e) => { - docs.push(ir::text("[")); + LuaIndexKey::Expr(e) => { + docs.push(tok(LuaTokenKind::TkLeftBracket)); docs.extend(format_expr(ctx, e)); - docs.push(ir::text("]")); + docs.push(tok(LuaTokenKind::TkRightBracket)); } - emmylua_parser::LuaIndexKey::Idx(_) => {} + LuaIndexKey::Idx(_) => {} } } docs @@ -584,7 +1004,7 @@ fn format_table_field_eq_split(ctx: &FormatContext, field: &LuaTableField) -> Op } let assign_space = space_around_assign(ctx.config).to_ir(); - let mut after = vec![ir::text("="), assign_space]; + let mut after = vec![tok(LuaTokenKind::TkAssign), assign_space]; if let Some(value) = field.get_value_expr() { after.extend(format_expr(ctx, &value)); } @@ -608,9 +1028,11 @@ enum TableEntry { /// When `align_eq` is true and there are consecutive `key = value` fields, /// they are wrapped in an AlignGroup so the Printer aligns their `=` signs. fn build_table_expanded_inner( + ctx: &FormatContext, entries: &[TableEntry], trailing: &DocIR, align_eq: bool, + align_comments: bool, ) -> Vec { let mut inner = Vec::new(); @@ -657,13 +1079,26 @@ fn build_table_expanded_inner( if is_last { after_with_comma.push(trailing.clone()); } else { - after_with_comma.push(ir::text(",")); + after_with_comma.push(tok(LuaTokenKind::TkComma)); + } + if align_comments { + align_entries.push(AlignEntry::Aligned { + before: before.clone(), + after: after_with_comma, + trailing: trailing_comment.clone(), + }); + } else { + if let Some(comment_docs) = trailing_comment { + let mut suffix = trailing_comment_prefix(ctx.config); + suffix.extend(comment_docs.clone()); + after_with_comma.push(ir::line_suffix(suffix)); + } + align_entries.push(AlignEntry::Aligned { + before: before.clone(), + after: after_with_comma, + trailing: None, + }); } - align_entries.push(AlignEntry::Aligned { - before: before.clone(), - after: after_with_comma, - trailing: trailing_comment.clone(), - }); } TableEntry::StandaloneComment(comment_docs) => { align_entries.push(AlignEntry::Line { @@ -681,12 +1116,24 @@ fn build_table_expanded_inner( if is_last { line.push(trailing.clone()); } else { - line.push(ir::text(",")); + line.push(tok(LuaTokenKind::TkComma)); + } + if align_comments { + align_entries.push(AlignEntry::Line { + content: line, + trailing: trailing_comment.clone(), + }); + } else { + if let Some(comment_docs) = trailing_comment { + let mut suffix = trailing_comment_prefix(ctx.config); + suffix.extend(comment_docs.clone()); + line.push(ir::line_suffix(suffix)); + } + align_entries.push(AlignEntry::Line { + content: line, + trailing: None, + }); } - align_entries.push(AlignEntry::Line { - content: line, - trailing: trailing_comment.clone(), - }); } } } @@ -708,10 +1155,10 @@ fn build_table_expanded_inner( if is_last { inner.push(trailing.clone()); } else { - inner.push(ir::text(",")); + inner.push(tok(LuaTokenKind::TkComma)); } if let Some(comment_docs) = trailing_comment { - let mut suffix = vec![ir::space()]; + let mut suffix = trailing_comment_prefix(ctx.config); suffix.extend(comment_docs.clone()); inner.push(ir::line_suffix(suffix)); } @@ -738,11 +1185,11 @@ fn build_table_expanded_inner( if is_last_field { inner.push(trailing.clone()); } else { - inner.push(ir::text(",")); + inner.push(tok(LuaTokenKind::TkComma)); } if let Some(comment_docs) = trailing_comment { - let mut suffix = vec![ir::space()]; + let mut suffix = trailing_comment_prefix(ctx.config); suffix.extend(comment_docs.clone()); inner.push(ir::line_suffix(suffix)); } @@ -760,44 +1207,55 @@ fn build_table_expanded_inner( /// Build expanded table (one field per line), wrapped in a Group. fn build_table_expanded( + ctx: &FormatContext, entries: Vec, trailing: DocIR, should_break: bool, align_eq: bool, ) -> Vec { - let inner = build_table_expanded_inner(&entries, &trailing, align_eq); + let inner = build_table_expanded_inner( + ctx, + &entries, + &trailing, + align_eq, + ctx.config.should_align_table_line_comments(), + ); if should_break { vec![ir::group_break(vec![ - ir::text("{"), + tok(LuaTokenKind::TkLeftBrace), ir::indent(inner), ir::hard_line(), - ir::text("}"), + tok(LuaTokenKind::TkRightBrace), ])] } else { vec![ir::group(vec![ - ir::text("{"), + tok(LuaTokenKind::TkLeftBrace), ir::indent(inner), ir::hard_line(), - ir::text("}"), + tok(LuaTokenKind::TkRightBrace), ])] } } /// 匿名函数: function(params) ... end fn format_closure_expr(ctx: &FormatContext, expr: &LuaClosureExpr) -> Vec { - let mut docs = vec![ir::text("function")]; + if should_preserve_raw_closure_expr(expr) { + return vec![ir::source_node_trimmed(expr.syntax().clone())]; + } + + let mut docs = vec![tok(LuaTokenKind::TkFunction)]; - if ctx.config.space_before_func_paren { + if ctx.config.spacing.space_before_func_paren { docs.push(ir::space()); } // 参数列表 - docs.push(ir::text("(")); + docs.push(tok(LuaTokenKind::TkLeftParen)); if let Some(params) = expr.get_params_list() { docs.extend(format_params_ir(ctx, ¶ms)); } - docs.push(ir::text(")")); + docs.push(tok(LuaTokenKind::TkRightParen)); // body super::format_body_end_with_parent( @@ -812,128 +1270,370 @@ fn format_closure_expr(ctx: &FormatContext, expr: &LuaClosureExpr) -> Vec /// 括号表达式: (expr) fn format_paren_expr(ctx: &FormatContext, expr: &LuaParenExpr) -> Vec { - let mut docs = vec![ir::text("(")]; - if ctx.config.space_inside_parens { + if node_has_direct_comment_child(expr.syntax()) { + return format_paren_expr_with_standalone_comments(ctx, expr); + } + + let mut docs = vec![tok(LuaTokenKind::TkLeftParen)]; + if ctx.config.spacing.space_inside_parens { docs.push(ir::space()); } if let Some(inner) = expr.get_expr() { docs.extend(format_expr(ctx, &inner)); } - if ctx.config.space_inside_parens { + if ctx.config.spacing.space_inside_parens { docs.push(ir::space()); } - docs.push(ir::text(")")); + docs.push(tok(LuaTokenKind::TkRightParen)); + docs +} + +fn format_paren_expr_with_standalone_comments( + ctx: &FormatContext, + expr: &LuaParenExpr, +) -> Vec { + let entries = collect_paren_expr_entries(ctx, expr); + let mut docs = vec![tok(LuaTokenKind::TkLeftParen)]; + + if sequence_has_comment(&entries) { + docs.push(ir::hard_line()); + render_sequence(&mut docs, &entries, true); + docs.push(ir::hard_line()); + } else { + if ctx.config.spacing.space_inside_parens { + docs.push(ir::space()); + } + render_sequence(&mut docs, &entries, false); + if ctx.config.spacing.space_inside_parens { + docs.push(ir::space()); + } + } + + docs.push(tok(LuaTokenKind::TkRightParen)); docs } +fn collect_paren_expr_entries(ctx: &FormatContext, expr: &LuaParenExpr) -> Vec { + let mut entries = Vec::new(); + + for child in expr.syntax().children_with_tokens() { + match child.kind() { + LuaKind::Syntax(LuaSyntaxKind::Comment) => { + if let Some(node) = child.as_node() + && let Some(comment) = LuaComment::cast(node.clone()) + { + entries.push(SequenceEntry::Comment(format_comment(ctx.config, &comment))); + } + } + _ => { + if let Some(node) = child.as_node() + && let Some(inner_expr) = LuaExpr::cast(node.clone()) + { + entries.push(SequenceEntry::Item(format_expr(ctx, &inner_expr))); + } + } + } + } + + entries +} + /// 根据 TrailingComma 配置生成尾逗号 IR fn format_trailing_comma_ir(policy: crate::config::TrailingComma) -> DocIR { use crate::config::TrailingComma; match policy { TrailingComma::Never => ir::list(vec![]), - TrailingComma::Multiline => ir::if_break(ir::text(","), ir::list(vec![])), - TrailingComma::Always => ir::text(","), + TrailingComma::Multiline => ir::if_break(tok(LuaTokenKind::TkComma), ir::list(vec![])), + TrailingComma::Always => tok(LuaTokenKind::TkComma), + } +} + +fn should_preserve_raw_call_expr(expr: &LuaCallExpr) -> bool { + if node_has_direct_same_line_inline_comment(expr.syntax()) { + return true; } + + expr.get_args_list() + .map(|args| node_has_direct_same_line_inline_comment(args.syntax())) + .unwrap_or(false) } -/// 参数条目 -struct ParamEntry { - doc: Vec, - /// Raw trailing comment docs (NOT wrapped in LineSuffix) - trailing_comment: Option>, +fn should_preserve_raw_closure_expr(expr: &LuaClosureExpr) -> bool { + if node_has_direct_same_line_inline_comment(expr.syntax()) { + return true; + } + + expr.get_params_list() + .map(|params| node_has_direct_same_line_inline_comment(params.syntax())) + .unwrap_or(false) } -/// 格式化函数参数列表(支持参数注释) -/// -/// 当参数之间有注释时,自动强制展开为多行。 -/// 返回括号内的 IR(不含括号本身)。 -pub fn format_params_ir(ctx: &FormatContext, params: &emmylua_parser::LuaParamList) -> Vec { - // 收集参数和每个参数后的行尾注释 - let mut entries: Vec = Vec::new(); +enum CallArgEntry { + Arg { + doc: Vec, + trailing_comment: Option>, + has_following_arg: bool, + }, + StandaloneComment(Vec), +} + +fn collect_call_arg_entries( + ctx: &FormatContext, + args_list: &emmylua_parser::LuaCallArgList, +) -> Vec { + let args: Vec<_> = args_list.get_args().collect(); + let mut entries = Vec::new(); let mut consumed_comment_ranges: Vec = Vec::new(); + let mut arg_index = 0usize; - for p in params.get_params() { - let doc = if p.is_dots() { - vec![ir::text("...")] - } else if let Some(token) = p.get_name_token() { - vec![ir::text(token.get_name_text().to_string())] - } else { - continue; - }; + for child in args_list.syntax().children() { + if let Some(arg) = LuaExpr::cast(child.clone()) { + let trailing_comment = + if let Some((docs, range)) = extract_trailing_comment(arg.syntax()) { + consumed_comment_ranges.push(range); + Some(docs) + } else { + None + }; - let trailing_comment = if let Some((docs, range)) = extract_trailing_comment(p.syntax()) { - consumed_comment_ranges.push(range); - Some(docs) - } else { - None - }; + let has_following_arg = arg_index + 1 < args.len(); + arg_index += 1; + entries.push(CallArgEntry::Arg { + doc: format_expr(ctx, &arg), + trailing_comment, + has_following_arg, + }); + } else if child.kind() == LuaKind::Syntax(LuaSyntaxKind::Comment) + && let Some(comment) = LuaComment::cast(child) + { + if consumed_comment_ranges + .iter() + .any(|range| *range == comment.syntax().text_range()) + { + continue; + } + entries.push(CallArgEntry::StandaloneComment(format_comment( + ctx.config, &comment, + ))); + } + } + + entries +} + +fn build_multiline_call_arg_entries(ctx: &FormatContext, entries: Vec) -> Vec { + let mut inner = Vec::new(); + + for (index, entry) in entries.into_iter().enumerate() { + if index > 0 { + inner.push(ir::hard_line()); + } - entries.push(ParamEntry { - doc, - trailing_comment, - }); + match entry { + CallArgEntry::Arg { + doc, + trailing_comment, + has_following_arg, + } => { + inner.extend(doc); + if has_following_arg { + inner.push(tok(LuaTokenKind::TkComma)); + } + if let Some(comment_docs) = trailing_comment { + let mut suffix = trailing_comment_prefix(ctx.config); + suffix.extend(comment_docs); + inner.push(ir::line_suffix(suffix)); + } + } + CallArgEntry::StandaloneComment(comment_docs) => { + inner.extend(comment_docs); + } + } } + inner +} + +/// 格式化函数参数列表(支持参数注释) +/// +/// 当参数之间有注释时,自动强制展开为多行。 +/// 返回括号内的 IR(不含括号本身)。 +pub fn format_params_ir(ctx: &FormatContext, params: &emmylua_parser::LuaParamList) -> Vec { + let entries = collect_param_entries(ctx, params); + let preserve_multiline_layout = params.syntax().text().contains_char('\n'); + if entries.is_empty() { return vec![]; } - let has_comments = entries.iter().any(|e| e.trailing_comment.is_some()); + let has_comments = entries.iter().any(|entry| match entry { + ParamEntry::Param { + trailing_comment, .. + } => trailing_comment.is_some(), + ParamEntry::StandaloneComment(_) => true, + }); if has_comments { - // 有注释:强制多行展开,使用 AlignGroup 对齐注释 - let len = entries.len(); - if ctx.config.align_continuous_line_comment { + let has_standalone_comments = entries + .iter() + .any(|entry| matches!(entry, ParamEntry::StandaloneComment(_))); + + if ctx.config.should_align_param_line_comments() && !has_standalone_comments { let mut align_entries = Vec::new(); - for (i, entry) in entries.into_iter().enumerate() { - let mut content = entry.doc; - if i < len - 1 { - content.push(ir::text(",")); + for entry in entries { + if let ParamEntry::Param { + mut doc, + trailing_comment, + has_following_param, + } = entry + { + if has_following_param { + doc.push(tok(LuaTokenKind::TkComma)); + } + align_entries.push(AlignEntry::Line { + content: doc, + trailing: trailing_comment, + }); } - align_entries.push(AlignEntry::Line { - content, - trailing: entry.trailing_comment, - }); } vec![ir::group_break(vec![ ir::indent(vec![ir::hard_line(), ir::align_group(align_entries)]), ir::hard_line(), ])] } else { - let mut inner = Vec::new(); - for (i, entry) in entries.into_iter().enumerate() { - inner.push(ir::hard_line()); - inner.extend(entry.doc); - if i < len - 1 { - inner.push(ir::text(",")); - } - if let Some(comment_docs) = entry.trailing_comment { - let mut suffix = vec![ir::space()]; - suffix.extend(comment_docs); - inner.push(ir::line_suffix(suffix)); - } - } - vec![ir::group_break(vec![ir::indent(inner), ir::hard_line()])] + let inner = build_multiline_param_entries(ctx, entries); + vec![ir::group_break(vec![ + ir::indent(vec![ir::hard_line(), ir::list(inner)]), + ir::hard_line(), + ])] } } else { - let param_docs: Vec> = entries.into_iter().map(|e| e.doc).collect(); - let inner = ir::intersperse(param_docs.clone(), vec![ir::text(","), ir::soft_line()]); - - match ctx.config.func_params_expand { + let param_docs: Vec> = entries + .into_iter() + .filter_map(|entry| match entry { + ParamEntry::Param { doc, .. } => Some(doc), + ParamEntry::StandaloneComment(_) => None, + }) + .collect(); + let inner = ir::intersperse(param_docs.clone(), comma_soft_line_sep()); + + match ctx.config.layout.func_params_expand { ExpandStrategy::Always => { vec![ir::hard_line(), ir::indent(inner), ir::hard_line()] } - ExpandStrategy::Never => ir::intersperse(param_docs, vec![ir::text(","), ir::space()]), + ExpandStrategy::Never => ir::intersperse(param_docs, comma_space_sep()), ExpandStrategy::Auto => { - vec![ir::group( - [ - vec![ir::soft_line_or_empty()], - vec![ir::indent(inner)], - vec![ir::soft_line_or_empty()], - ] - .concat(), - )] + if preserve_multiline_layout { + vec![ir::group_break(vec![ + ir::hard_line(), + ir::indent(inner), + ir::hard_line(), + ])] + } else { + vec![ir::group( + [ + vec![ir::soft_line_or_empty()], + vec![ir::indent(inner)], + vec![ir::soft_line_or_empty()], + ] + .concat(), + )] + } + } + } + } +} + +enum ParamEntry { + Param { + doc: Vec, + trailing_comment: Option>, + has_following_param: bool, + }, + StandaloneComment(Vec), +} + +fn collect_param_entries( + ctx: &FormatContext, + params: &emmylua_parser::LuaParamList, +) -> Vec { + let param_nodes: Vec<_> = params.get_params().collect(); + let mut entries = Vec::new(); + let mut consumed_comment_ranges: Vec = Vec::new(); + let mut param_index = 0usize; + + for child in params.syntax().children() { + if let Some(param) = emmylua_parser::LuaParamName::cast(child.clone()) { + let doc = if param.is_dots() { + vec![ir::text("...")] + } else if let Some(token) = param.get_name_token() { + vec![ir::source_token(token.syntax().clone())] + } else { + continue; + }; + + let trailing_comment = + if let Some((docs, range)) = extract_trailing_comment(param.syntax()) { + consumed_comment_ranges.push(range); + Some(docs) + } else { + None + }; + + let has_following_param = param_index + 1 < param_nodes.len(); + param_index += 1; + entries.push(ParamEntry::Param { + doc, + trailing_comment, + has_following_param, + }); + } else if child.kind() == LuaKind::Syntax(LuaSyntaxKind::Comment) + && let Some(comment) = LuaComment::cast(child) + { + if consumed_comment_ranges + .iter() + .any(|range| *range == comment.syntax().text_range()) + { + continue; } + entries.push(ParamEntry::StandaloneComment(format_comment( + ctx.config, &comment, + ))); } } + + entries +} + +fn build_multiline_param_entries(ctx: &FormatContext, entries: Vec) -> Vec { + let mut inner = Vec::new(); + + for (index, entry) in entries.into_iter().enumerate() { + if index > 0 { + inner.push(ir::hard_line()); + } + + match entry { + ParamEntry::Param { + doc, + trailing_comment, + has_following_param, + } => { + inner.extend(doc); + if has_following_param { + inner.push(tok(LuaTokenKind::TkComma)); + } + if let Some(comment_docs) = trailing_comment { + let mut suffix = trailing_comment_prefix(ctx.config); + suffix.extend(comment_docs); + inner.push(ir::line_suffix(suffix)); + } + } + ParamEntry::StandaloneComment(comment_docs) => { + inner.extend(comment_docs); + } + } + } + + inner } diff --git a/crates/emmylua_formatter/src/formatter/mod.rs b/crates/emmylua_formatter/src/formatter/mod.rs index 94931542f..cfeee4d6b 100644 --- a/crates/emmylua_formatter/src/formatter/mod.rs +++ b/crates/emmylua_formatter/src/formatter/mod.rs @@ -1,8 +1,10 @@ mod block; mod comment; mod expression; +mod sequence; pub mod spacing; mod statement; +mod tokens; mod trivia; use crate::config::LuaFormatConfig; @@ -40,7 +42,7 @@ pub fn format_chunk(ctx: &FormatContext, chunk: &LuaChunk) -> Vec { } // Ensure file ends with a newline - if ctx.config.insert_final_newline { + if ctx.config.output.insert_final_newline { docs.push(DocIR::HardLine); } diff --git a/crates/emmylua_formatter/src/formatter/sequence.rs b/crates/emmylua_formatter/src/formatter/sequence.rs new file mode 100644 index 000000000..f8f942c75 --- /dev/null +++ b/crates/emmylua_formatter/src/formatter/sequence.rs @@ -0,0 +1,65 @@ +use emmylua_parser::LuaTokenKind; + +use crate::ir::{self, DocIR}; + +#[derive(Clone)] +pub enum SequenceEntry { + Item(Vec), + Comment(Vec), + Separator { docs: Vec, space_after: bool }, +} + +pub fn comma_entry() -> SequenceEntry { + SequenceEntry::Separator { + docs: vec![ir::syntax_token(LuaTokenKind::TkComma)], + space_after: true, + } +} + +pub fn render_sequence(docs: &mut Vec, entries: &[SequenceEntry], mut line_start: bool) { + let mut needs_space_before_item = false; + + for entry in entries { + match entry { + SequenceEntry::Item(item_docs) => { + if !line_start && needs_space_before_item { + docs.push(ir::space()); + } + docs.extend(item_docs.clone()); + line_start = false; + needs_space_before_item = false; + } + SequenceEntry::Comment(comment_docs) => { + if !line_start { + docs.push(ir::hard_line()); + } + docs.extend(comment_docs.clone()); + docs.push(ir::hard_line()); + line_start = true; + needs_space_before_item = false; + } + SequenceEntry::Separator { + docs: separator_docs, + space_after, + } => { + docs.extend(separator_docs.clone()); + line_start = false; + needs_space_before_item = *space_after; + } + } + } +} + +pub fn sequence_has_comment(entries: &[SequenceEntry]) -> bool { + entries + .iter() + .any(|entry| matches!(entry, SequenceEntry::Comment(_))) +} + +pub fn sequence_ends_with_comment(entries: &[SequenceEntry]) -> bool { + matches!(entries.last(), Some(SequenceEntry::Comment(_))) +} + +pub fn sequence_starts_with_comment(entries: &[SequenceEntry]) -> bool { + matches!(entries.first(), Some(SequenceEntry::Comment(_))) +} diff --git a/crates/emmylua_formatter/src/formatter/spacing.rs b/crates/emmylua_formatter/src/formatter/spacing.rs index 868f7ecc0..726259b01 100644 --- a/crates/emmylua_formatter/src/formatter/spacing.rs +++ b/crates/emmylua_formatter/src/formatter/spacing.rs @@ -48,7 +48,7 @@ pub fn space_around_binary_op(op: BinaryOperator, config: &LuaFormatConfig) -> S | BinaryOperator::OpIDiv | BinaryOperator::OpMod | BinaryOperator::OpPow => { - if config.space_around_math_operator { + if config.spacing.space_around_math_operator { SpaceRule::Space } else { SpaceRule::NoSpace @@ -68,7 +68,7 @@ pub fn space_around_binary_op(op: BinaryOperator, config: &LuaFormatConfig) -> S // Concatenation: .. BinaryOperator::OpConcat => { - if config.space_around_concat_operator { + if config.spacing.space_around_concat_operator { SpaceRule::Space } else { SpaceRule::NoSpace @@ -88,7 +88,7 @@ pub fn space_around_binary_op(op: BinaryOperator, config: &LuaFormatConfig) -> S /// Resolve spacing around the assignment `=` operator. pub fn space_around_assign(config: &LuaFormatConfig) -> SpaceRule { - if config.space_around_assign_operator { + if config.spacing.space_around_assign_operator { SpaceRule::Space } else { SpaceRule::NoSpace diff --git a/crates/emmylua_formatter/src/formatter/statement.rs b/crates/emmylua_formatter/src/formatter/statement.rs index 4d6121cd3..c5996e432 100644 --- a/crates/emmylua_formatter/src/formatter/statement.rs +++ b/crates/emmylua_formatter/src/formatter/statement.rs @@ -1,19 +1,31 @@ use emmylua_parser::{ - LuaAssignStat, LuaAstNode, LuaAstToken, LuaBreakStat, LuaCallExprStat, LuaDoStat, LuaExpr, - LuaForRangeStat, LuaForStat, LuaFuncStat, LuaGlobalStat, LuaGotoStat, LuaIfStat, LuaLabelStat, - LuaLocalFuncStat, LuaLocalStat, LuaRepeatStat, LuaReturnStat, LuaStat, LuaWhileStat, + LuaAssignStat, LuaAstNode, LuaAstToken, LuaBlock, LuaBreakStat, LuaCallExprStat, + LuaClosureExpr, LuaComment, LuaDoStat, LuaExpr, LuaForRangeStat, LuaForStat, LuaFuncStat, + LuaGlobalStat, LuaGotoStat, LuaIfStat, LuaKind, LuaLabelStat, LuaLocalFuncStat, LuaLocalName, + LuaLocalStat, LuaRepeatStat, LuaReturnStat, LuaStat, LuaSyntaxKind, LuaSyntaxNode, + LuaTokenKind, LuaVarExpr, LuaWhileStat, }; use crate::ir::{self, DocIR, EqSplit}; use super::FormatContext; use super::block::format_block; -use super::comment::collect_orphan_comments; +use super::comment::{collect_orphan_comments, format_comment}; use super::expression::format_expr; +use super::sequence::{ + SequenceEntry, comma_entry, render_sequence, sequence_ends_with_comment, sequence_has_comment, + sequence_starts_with_comment, +}; use super::spacing::space_around_assign; +use super::tokens::{comma_space_sep, tok}; +use super::trivia::{node_has_direct_comment_child, node_has_direct_same_line_inline_comment}; /// Format a statement (dispatch) pub fn format_stat(ctx: &FormatContext, stat: &LuaStat) -> Vec { + if should_preserve_raw_statement_with_inline_comments(stat) { + return vec![ir::source_node_trimmed(stat.syntax().clone())]; + } + match stat { LuaStat::LocalStat(s) => format_local_stat(ctx, s), LuaStat::AssignStat(s) => format_assign_stat(ctx, s), @@ -30,7 +42,7 @@ pub fn format_stat(ctx: &FormatContext, stat: &LuaStat) -> Vec { LuaStat::ReturnStat(s) => format_return_stat(ctx, s), LuaStat::GotoStat(s) => format_goto_stat(ctx, s), LuaStat::LabelStat(s) => format_label_stat(ctx, s), - LuaStat::EmptyStat(_) => vec![ir::text(";")], + LuaStat::EmptyStat(_) => vec![tok(LuaTokenKind::TkSemicolon)], LuaStat::GlobalStat(s) => format_global_stat(ctx, s), } } @@ -38,25 +50,29 @@ pub fn format_stat(ctx: &FormatContext, stat: &LuaStat) -> Vec { /// local name1, name2 = expr1, expr2 /// local x = 1 fn format_local_stat(ctx: &FormatContext, stat: &LuaLocalStat) -> Vec { - let mut docs = vec![ir::text("local"), ir::space()]; + if node_has_direct_comment_child(stat.syntax()) { + return format_local_stat_trivia_aware(ctx, stat); + } + + let mut docs = vec![tok(LuaTokenKind::TkLocal), ir::space()]; // Variable name list (with attributes) let local_names: Vec<_> = stat.get_local_name_list().collect(); for (i, local_name) in local_names.iter().enumerate() { if i > 0 { - docs.push(ir::text(",")); + docs.push(tok(LuaTokenKind::TkComma)); docs.push(ir::space()); } if let Some(token) = local_name.get_name_token() { - docs.push(ir::text(token.get_name_text().to_string())); + docs.push(ir::source_token(token.syntax().clone())); } // / attribute if let Some(attrib) = local_name.get_attrib() { docs.push(ir::space()); docs.push(ir::text("<")); if let Some(name_token) = attrib.get_name_token() { - docs.push(ir::text(name_token.get_name_text().to_string())); + docs.push(ir::source_token(name_token.syntax().clone())); } docs.push(ir::text(">")); } @@ -67,28 +83,22 @@ fn format_local_stat(ctx: &FormatContext, stat: &LuaLocalStat) -> Vec { if !exprs.is_empty() { let assign_space = space_around_assign(ctx.config).to_ir(); docs.push(assign_space); - docs.push(ir::text("=")); + docs.push(tok(LuaTokenKind::TkAssign)); let expr_docs: Vec> = exprs.iter().map(|e| format_expr(ctx, e)).collect(); - let separated = ir::intersperse(expr_docs, vec![ir::text(","), ir::space()]); + let separated = ir::intersperse(expr_docs, comma_space_sep()); - // Single-value assignment to function/table: join with space, no line break - if exprs.len() == 1 && is_block_like_expr(&exprs[0]) { - let assign_space_after = space_around_assign(ctx.config).to_ir(); - docs.push(assign_space_after); - docs.push(ir::list(separated)); + // Keep the RHS width-driven so short values stay inline while long + // values can still break after `=`. + let break_or_space = if ctx.config.spacing.space_around_assign_operator { + ir::soft_line() } else { - // When value is too long, break after = and indent - let break_or_space = if ctx.config.space_around_assign_operator { - ir::soft_line() - } else { - ir::soft_line_or_empty() - }; - docs.push(ir::group(vec![ir::indent(vec![ - break_or_space, - ir::list(separated), - ])])); - } + ir::soft_line_or_empty() + }; + docs.push(ir::group(vec![ir::indent(vec![ + break_or_space, + ir::list(separated), + ])])); } docs @@ -96,6 +106,10 @@ fn format_local_stat(ctx: &FormatContext, stat: &LuaLocalStat) -> Vec { /// var1, var2 = expr1, expr2 (or compound: var += expr) fn format_assign_stat(ctx: &FormatContext, stat: &LuaAssignStat) -> Vec { + if node_has_direct_comment_child(stat.syntax()) { + return format_assign_stat_trivia_aware(ctx, stat); + } + let mut docs = Vec::new(); let (vars, exprs) = stat.get_var_and_expr_list(); @@ -105,35 +119,256 @@ fn format_assign_stat(ctx: &FormatContext, stat: &LuaAssignStat) -> Vec { .map(|v| format_expr(ctx, &v.clone().into())) .collect(); - docs.extend(ir::intersperse(var_docs, vec![ir::text(","), ir::space()])); + docs.extend(ir::intersperse( + var_docs, + vec![tok(LuaTokenKind::TkComma), ir::space()], + )); // Assignment operator if let Some(op) = stat.get_assign_op() { let assign_space = space_around_assign(ctx.config).to_ir(); docs.push(assign_space); - docs.push(ir::text(op.syntax().text().to_string())); + docs.push(ir::source_token(op.syntax().clone())); } // Value list let expr_docs: Vec> = exprs.iter().map(|e| format_expr(ctx, e)).collect(); - let separated = ir::intersperse(expr_docs, vec![ir::text(","), ir::space()]); + let separated = ir::intersperse(expr_docs, vec![tok(LuaTokenKind::TkComma), ir::space()]); - // Single-value assignment to function/table: join with space, no line break - if exprs.len() == 1 && is_block_like_expr(&exprs[0]) { - let assign_space_after = space_around_assign(ctx.config).to_ir(); - docs.push(assign_space_after); - docs.push(ir::list(separated)); + // Keep the RHS width-driven so short values stay inline while long values + // can still break after the assignment operator. + let break_or_space = if ctx.config.spacing.space_around_assign_operator { + ir::soft_line() } else { - // When value is too long, break after = and indent - let break_or_space = if ctx.config.space_around_assign_operator { - ir::soft_line() + ir::soft_line_or_empty() + }; + docs.push(ir::group(vec![ir::indent(vec![ + break_or_space, + ir::list(separated), + ])])); + + docs +} + +fn format_local_stat_trivia_aware(ctx: &FormatContext, stat: &LuaLocalStat) -> Vec { + let StatementAssignSplit { + lhs_entries, + assign_op, + rhs_entries, + } = collect_local_stat_entries(ctx, stat); + let mut docs = vec![tok(LuaTokenKind::TkLocal)]; + + if !lhs_entries.is_empty() { + docs.push(ir::space()); + render_sequence(&mut docs, &lhs_entries, false); + } + + if let Some(assign_op) = assign_op { + if sequence_has_comment(&lhs_entries) { + if !sequence_ends_with_comment(&lhs_entries) { + docs.push(ir::hard_line()); + } + docs.push(assign_op.clone()); } else { - ir::soft_line_or_empty() - }; - docs.push(ir::group(vec![ir::indent(vec![ - break_or_space, - ir::list(separated), - ])])); + docs.push(space_around_assign(ctx.config).to_ir()); + docs.push(assign_op); + } + + if !rhs_entries.is_empty() { + if sequence_has_comment(&rhs_entries) { + docs.push(ir::hard_line()); + render_sequence(&mut docs, &rhs_entries, true); + } else { + docs.push(space_around_assign(ctx.config).to_ir()); + render_sequence(&mut docs, &rhs_entries, false); + } + } + } + + docs +} + +fn format_assign_stat_trivia_aware(ctx: &FormatContext, stat: &LuaAssignStat) -> Vec { + let StatementAssignSplit { + lhs_entries, + assign_op, + rhs_entries, + } = collect_assign_stat_entries(ctx, stat); + let mut docs = Vec::new(); + + render_sequence(&mut docs, &lhs_entries, false); + + if let Some(assign_op) = assign_op { + if sequence_has_comment(&lhs_entries) { + if !sequence_ends_with_comment(&lhs_entries) { + docs.push(ir::hard_line()); + } + docs.push(assign_op.clone()); + } else { + docs.push(space_around_assign(ctx.config).to_ir()); + docs.push(assign_op); + } + + if !rhs_entries.is_empty() { + if sequence_has_comment(&rhs_entries) { + docs.push(ir::hard_line()); + render_sequence(&mut docs, &rhs_entries, true); + } else { + docs.push(space_around_assign(ctx.config).to_ir()); + render_sequence(&mut docs, &rhs_entries, false); + } + } + } + + docs +} + +struct StatementAssignSplit { + lhs_entries: Vec, + assign_op: Option, + rhs_entries: Vec, +} + +enum FunctionHeaderEntry { + Name(Vec), + Comment(Vec), + Closure(Vec), +} + +fn collect_local_stat_entries(ctx: &FormatContext, stat: &LuaLocalStat) -> StatementAssignSplit { + let mut lhs_entries = Vec::new(); + let mut rhs_entries = Vec::new(); + let mut assign_op = None; + let mut meet_assign = false; + + for child in stat.syntax().children_with_tokens() { + match child.kind() { + LuaKind::Token(token_kind) if token_kind.is_assign_op() => { + meet_assign = true; + assign_op = child + .as_token() + .map(|token| ir::source_token(token.clone())); + } + LuaKind::Token(LuaTokenKind::TkComma) => { + if meet_assign { + rhs_entries.push(comma_entry()); + } else { + lhs_entries.push(comma_entry()); + } + } + LuaKind::Syntax(LuaSyntaxKind::LocalName) => { + if let Some(node) = child.as_node() + && let Some(local_name) = LuaLocalName::cast(node.clone()) + { + let entry = SequenceEntry::Item(format_local_name_ir(&local_name)); + if meet_assign { + rhs_entries.push(entry); + } else { + lhs_entries.push(entry); + } + } + } + LuaKind::Syntax(LuaSyntaxKind::Comment) => { + if let Some(node) = child.as_node() + && let Some(comment) = LuaComment::cast(node.clone()) + { + let entry = SequenceEntry::Comment(format_comment(ctx.config, &comment)); + if meet_assign { + rhs_entries.push(entry); + } else { + lhs_entries.push(entry); + } + } + } + _ => { + if let Some(node) = child.as_node() + && let Some(expr) = LuaExpr::cast(node.clone()) + { + let entry = SequenceEntry::Item(format_expr(ctx, &expr)); + if meet_assign { + rhs_entries.push(entry); + } else { + lhs_entries.push(entry); + } + } + } + } + } + + StatementAssignSplit { + lhs_entries, + assign_op, + rhs_entries, + } +} + +fn collect_assign_stat_entries(ctx: &FormatContext, stat: &LuaAssignStat) -> StatementAssignSplit { + let mut lhs_entries = Vec::new(); + let mut rhs_entries = Vec::new(); + let mut assign_op = None; + let mut meet_assign = false; + + for child in stat.syntax().children_with_tokens() { + match child.kind() { + LuaKind::Token(token_kind) if token_kind.is_assign_op() => { + meet_assign = true; + assign_op = child + .as_token() + .map(|token| ir::source_token(token.clone())); + } + LuaKind::Token(LuaTokenKind::TkComma) => { + if meet_assign { + rhs_entries.push(comma_entry()); + } else { + lhs_entries.push(comma_entry()); + } + } + LuaKind::Syntax(LuaSyntaxKind::Comment) => { + if let Some(node) = child.as_node() + && let Some(comment) = LuaComment::cast(node.clone()) + { + let entry = SequenceEntry::Comment(format_comment(ctx.config, &comment)); + if meet_assign { + rhs_entries.push(entry); + } else { + lhs_entries.push(entry); + } + } + } + _ => { + if let Some(node) = child.as_node() { + if !meet_assign { + if let Some(var) = LuaVarExpr::cast(node.clone()) { + lhs_entries.push(SequenceEntry::Item(format_expr(ctx, &var.into()))); + } + } else if let Some(expr) = LuaExpr::cast(node.clone()) { + rhs_entries.push(SequenceEntry::Item(format_expr(ctx, &expr))); + } + } + } + } + } + + StatementAssignSplit { + lhs_entries, + assign_op, + rhs_entries, + } +} + +fn format_local_name_ir(local_name: &LuaLocalName) -> Vec { + let mut docs = Vec::new(); + + if let Some(token) = local_name.get_name_token() { + docs.push(ir::source_token(token.syntax().clone())); + } + if let Some(attrib) = local_name.get_attrib() { + docs.push(ir::space()); + docs.push(ir::text("<")); + if let Some(name_token) = attrib.get_name_token() { + docs.push(ir::source_token(name_token.syntax().clone())); + } + docs.push(ir::text(">")); } docs @@ -150,12 +385,16 @@ fn format_call_expr_stat(ctx: &FormatContext, stat: &LuaCallExprStat) -> Vec Vec { + if node_has_direct_comment_child(stat.syntax()) { + return format_func_stat_trivia_aware(ctx, stat); + } + // Compact output when function body is empty if let Some(compact) = format_empty_func_stat(ctx, stat) { return compact; } - let mut docs = vec![ir::text("function"), ir::space()]; + let mut docs = vec![tok(LuaTokenKind::TkFunction), ir::space()]; if let Some(name) = stat.get_func_name() { docs.extend(format_expr(ctx, &name.into())); @@ -170,22 +409,26 @@ fn format_func_stat(ctx: &FormatContext, stat: &LuaFuncStat) -> Vec { /// local function name() ... end fn format_local_func_stat(ctx: &FormatContext, stat: &LuaLocalFuncStat) -> Vec { + if node_has_direct_comment_child(stat.syntax()) { + return format_local_func_stat_trivia_aware(ctx, stat); + } + // Compact output when function body is empty if let Some(compact) = format_empty_local_func_stat(ctx, stat) { return compact; } let mut docs = vec![ - ir::text("local"), + tok(LuaTokenKind::TkLocal), ir::space(), - ir::text("function"), + tok(LuaTokenKind::TkFunction), ir::space(), ]; if let Some(name) = stat.get_local_name() && let Some(token) = name.get_name_token() { - docs.push(ir::text(token.get_name_text().to_string())); + docs.push(ir::source_token(token.syntax().clone())); } if let Some(closure) = stat.get_closure() { @@ -195,6 +438,112 @@ fn format_local_func_stat(ctx: &FormatContext, stat: &LuaLocalFuncStat) -> Vec Vec { + let entries = collect_func_stat_header_entries(ctx, stat); + render_function_header_entries(vec![tok(LuaTokenKind::TkFunction)], entries) +} + +fn format_local_func_stat_trivia_aware(ctx: &FormatContext, stat: &LuaLocalFuncStat) -> Vec { + let entries = collect_local_func_stat_header_entries(ctx, stat); + render_function_header_entries( + vec![ + tok(LuaTokenKind::TkLocal), + ir::space(), + tok(LuaTokenKind::TkFunction), + ], + entries, + ) +} + +fn collect_func_stat_header_entries( + ctx: &FormatContext, + stat: &LuaFuncStat, +) -> Vec { + let mut entries = Vec::new(); + + for child in stat.syntax().children() { + if let Some(name) = LuaVarExpr::cast(child.clone()) { + entries.push(FunctionHeaderEntry::Name(format_expr(ctx, &name.into()))); + } else if let Some(comment) = LuaComment::cast(child.clone()) { + entries.push(FunctionHeaderEntry::Comment(format_comment( + ctx.config, &comment, + ))); + } else if let Some(closure) = LuaClosureExpr::cast(child) { + entries.push(FunctionHeaderEntry::Closure( + format_closure_body_with_prefix_space(ctx, &closure, false), + )); + } + } + + entries +} + +fn collect_local_func_stat_header_entries( + ctx: &FormatContext, + stat: &LuaLocalFuncStat, +) -> Vec { + let mut entries = Vec::new(); + + for child in stat.syntax().children() { + if let Some(name) = LuaLocalName::cast(child.clone()) { + entries.push(FunctionHeaderEntry::Name(format_local_name_ir(&name))); + } else if let Some(comment) = LuaComment::cast(child.clone()) { + entries.push(FunctionHeaderEntry::Comment(format_comment( + ctx.config, &comment, + ))); + } else if let Some(closure) = LuaClosureExpr::cast(child) { + entries.push(FunctionHeaderEntry::Closure( + format_closure_body_with_prefix_space(ctx, &closure, false), + )); + } + } + + entries +} + +fn render_function_header_entries( + mut docs: Vec, + entries: Vec, +) -> Vec { + let mut prev_was_comment = false; + let mut has_seen_header_content = false; + + for entry in entries { + match entry { + FunctionHeaderEntry::Name(name_docs) => { + if prev_was_comment { + docs.push(ir::hard_line()); + } else { + docs.push(ir::space()); + } + docs.extend(name_docs); + prev_was_comment = false; + has_seen_header_content = true; + } + FunctionHeaderEntry::Comment(comment_docs) => { + if has_seen_header_content { + docs.push(ir::hard_line()); + } else { + docs.push(ir::space()); + } + docs.extend(comment_docs); + prev_was_comment = true; + has_seen_header_content = true; + } + FunctionHeaderEntry::Closure(closure_docs) => { + if prev_was_comment { + docs.push(ir::hard_line()); + } + docs.extend(closure_docs); + prev_was_comment = false; + has_seen_header_content = true; + } + } + } + + docs +} + /// Single-line function definition: keep single-line output when body is empty /// e.g. `function foo() end` fn format_empty_func_stat(ctx: &FormatContext, stat: &LuaFuncStat) -> Option> { @@ -205,33 +554,33 @@ fn format_empty_func_stat(ctx: &FormatContext, stat: &LuaFuncStat) -> Option> = Vec::new(); for p in params.get_params() { if p.is_dots() { param_docs.push(vec![ir::text("...")]); } else if let Some(token) = p.get_name_token() { - param_docs.push(vec![ir::text(token.get_name_text().to_string())]); + param_docs.push(vec![ir::source_token(token.syntax().clone())]); } } if !param_docs.is_empty() { - let inner = ir::intersperse(param_docs, vec![ir::text(","), ir::space()]); + let inner = ir::intersperse(param_docs, comma_space_sep()); docs.extend(inner); } } - docs.push(ir::text(")")); + docs.push(tok(LuaTokenKind::TkRightParen)); docs.push(ir::space()); - docs.push(ir::text("end")); + docs.push(tok(LuaTokenKind::TkEnd)); Some(docs) } @@ -249,46 +598,62 @@ fn format_empty_local_func_stat( } let mut docs = vec![ - ir::text("local"), + tok(LuaTokenKind::TkLocal), ir::space(), - ir::text("function"), + tok(LuaTokenKind::TkFunction), ir::space(), ]; if let Some(name) = stat.get_local_name() && let Some(token) = name.get_name_token() { - docs.push(ir::text(token.get_name_text().to_string())); + docs.push(ir::source_token(token.syntax().clone())); } - if ctx.config.space_before_func_paren { + if ctx.config.spacing.space_before_func_paren { docs.push(ir::space()); } - docs.push(ir::text("(")); + docs.push(tok(LuaTokenKind::TkLeftParen)); if let Some(params) = closure.get_params_list() { let mut param_docs: Vec> = Vec::new(); for p in params.get_params() { if p.is_dots() { param_docs.push(vec![ir::text("...")]); } else if let Some(token) = p.get_name_token() { - param_docs.push(vec![ir::text(token.get_name_text().to_string())]); + param_docs.push(vec![ir::source_token(token.syntax().clone())]); } } if !param_docs.is_empty() { - let inner = ir::intersperse(param_docs, vec![ir::text(","), ir::space()]); + let inner = ir::intersperse(param_docs, comma_space_sep()); docs.extend(inner); } } - docs.push(ir::text(")")); + docs.push(tok(LuaTokenKind::TkRightParen)); docs.push(ir::space()); - docs.push(ir::text("end")); + docs.push(tok(LuaTokenKind::TkEnd)); Some(docs) } /// if cond then ... elseif cond then ... else ... end fn format_if_stat(ctx: &FormatContext, stat: &LuaIfStat) -> Vec { - let mut docs = vec![ir::text("if"), ir::space()]; + if let Some(preserved) = try_preserve_single_line_if_body(ctx, stat) { + return preserved; + } + + if should_preserve_raw_if_stat_with_comments(stat) { + return vec![ir::source_node_trimmed(stat.syntax().clone())]; + } + + if should_preserve_raw_if_stat_trivia_aware(ctx, stat) { + return vec![ir::source_node_trimmed(stat.syntax().clone())]; + } + + if node_has_direct_comment_child(stat.syntax()) { + return format_if_stat_trivia_aware(ctx, stat); + } + + let mut docs = vec![tok(LuaTokenKind::TkIf), ir::space()]; // if condition if let Some(cond) = stat.get_condition_expr() { @@ -296,22 +661,21 @@ fn format_if_stat(ctx: &FormatContext, stat: &LuaIfStat) -> Vec { } docs.push(ir::space()); - docs.push(ir::text("then")); + docs.push(tok(LuaTokenKind::TkThen)); // if body - let _has_block = - format_block_or_orphan_comments(ctx, stat.get_block().as_ref(), stat.syntax(), &mut docs); + format_block_or_orphan_comments(ctx, stat.get_block().as_ref(), stat.syntax(), &mut docs); // elseif branches for clause in stat.get_else_if_clause_list() { docs.push(ir::hard_line()); - docs.push(ir::text("elseif")); + docs.push(tok(LuaTokenKind::TkElseIf)); docs.push(ir::space()); if let Some(cond) = clause.get_condition_expr() { docs.extend(format_expr(ctx, &cond)); } docs.push(ir::space()); - docs.push(ir::text("then")); + docs.push(tok(LuaTokenKind::TkThen)); format_block_or_orphan_comments( ctx, clause.get_block().as_ref(), @@ -323,7 +687,83 @@ fn format_if_stat(ctx: &FormatContext, stat: &LuaIfStat) -> Vec { // else branch if let Some(else_clause) = stat.get_else_clause() { docs.push(ir::hard_line()); - docs.push(ir::text("else")); + docs.push(tok(LuaTokenKind::TkElse)); + format_block_or_orphan_comments( + ctx, + else_clause.get_block().as_ref(), + else_clause.syntax(), + &mut docs, + ); + } + + docs.push(ir::hard_line()); + docs.push(tok(LuaTokenKind::TkEnd)); + + docs +} + +fn should_preserve_raw_if_stat_trivia_aware(ctx: &FormatContext, stat: &LuaIfStat) -> bool { + if node_has_direct_comment_child(stat.syntax()) + && should_preserve_raw_empty_loop_with_comments(ctx, stat.get_block().as_ref()) + { + return true; + } + + stat.get_else_if_clause_list().any(|clause| { + node_has_direct_comment_child(clause.syntax()) + && should_preserve_raw_empty_loop_with_comments(ctx, clause.get_block().as_ref()) + }) +} + +fn should_preserve_raw_if_stat_with_comments(stat: &LuaIfStat) -> bool { + let text = stat.syntax().text().to_string(); + text.contains("elseif") && text.contains("--") +} + +fn format_if_stat_trivia_aware(ctx: &FormatContext, stat: &LuaIfStat) -> Vec { + let mut docs = format_if_clause_header( + LuaTokenKind::TkIf, + &collect_if_clause_entries(ctx, stat.syntax()), + LuaTokenKind::TkThen, + ); + + format_block_or_orphan_comments(ctx, stat.get_block().as_ref(), stat.syntax(), &mut docs); + + for clause in stat.get_else_if_clause_list() { + docs.push(ir::hard_line()); + if let Some(raw_header) = + try_format_raw_clause_header_until_block(clause.syntax(), clause.get_block().as_ref()) + { + docs.extend(raw_header); + } else { + let clause_entries = collect_if_clause_entries(ctx, clause.syntax()); + if sequence_has_comment(&clause_entries) { + docs.extend(format_if_clause_header( + LuaTokenKind::TkElseIf, + &clause_entries, + LuaTokenKind::TkThen, + )); + } else { + docs.push(tok(LuaTokenKind::TkElseIf)); + docs.push(ir::space()); + if let Some(cond) = clause.get_condition_expr() { + docs.extend(format_expr(ctx, &cond)); + } + docs.push(ir::space()); + docs.push(tok(LuaTokenKind::TkThen)); + } + } + format_block_or_orphan_comments( + ctx, + clause.get_block().as_ref(), + clause.syntax(), + &mut docs, + ); + } + + if let Some(else_clause) = stat.get_else_clause() { + docs.push(ir::hard_line()); + docs.push(tok(LuaTokenKind::TkElse)); format_block_or_orphan_comments( ctx, else_clause.get_block().as_ref(), @@ -333,21 +773,147 @@ fn format_if_stat(ctx: &FormatContext, stat: &LuaIfStat) -> Vec { } docs.push(ir::hard_line()); - docs.push(ir::text("end")); + docs.push(tok(LuaTokenKind::TkEnd)); + docs +} + +fn collect_if_clause_entries(ctx: &FormatContext, syntax: &LuaSyntaxNode) -> Vec { + let mut entries = Vec::new(); + + for child in syntax.children_with_tokens() { + match child.kind() { + LuaKind::Syntax(LuaSyntaxKind::Comment) => { + if let Some(node) = child.as_node() + && let Some(comment) = LuaComment::cast(node.clone()) + { + entries.push(SequenceEntry::Comment(format_comment(ctx.config, &comment))); + } + } + _ => { + if let Some(node) = child.as_node() + && let Some(expr) = LuaExpr::cast(node.clone()) + { + entries.push(SequenceEntry::Item(format_expr(ctx, &expr))); + } + } + } + } + + entries +} + +fn format_if_clause_header( + leading_keyword: LuaTokenKind, + entries: &[SequenceEntry], + trailing_keyword: LuaTokenKind, +) -> Vec { + let mut docs = vec![tok(leading_keyword)]; + if !entries.is_empty() { + docs.push(ir::space()); + render_sequence(&mut docs, entries, false); + } + + if sequence_has_comment(entries) { + if !sequence_ends_with_comment(entries) { + docs.push(ir::hard_line()); + } + docs.push(tok(trailing_keyword)); + } else { + docs.push(ir::space()); + docs.push(tok(trailing_keyword)); + } docs } +fn try_format_raw_clause_header_until_block( + syntax: &LuaSyntaxNode, + block: Option<&LuaBlock>, +) -> Option> { + let block = block?; + let text = syntax.text().to_string(); + if !text.contains("--") { + return None; + } + + let start = syntax.text_range().start(); + let block_start = block.syntax().text_range().start(); + if block_start <= start { + return None; + } + + let header_len = usize::from(block_start - start); + let header = text + .get(..header_len)? + .trim_end_matches(['\r', '\n', ' ', '\t']); + Some(vec![ir::text(header.to_string())]) +} + +fn try_preserve_single_line_if_body(ctx: &FormatContext, stat: &LuaIfStat) -> Option> { + if stat.syntax().text().contains_char('\n') { + return None; + } + + if stat.syntax().text().len() > ctx.config.layout.max_line_width { + return None; + } + + if stat.get_else_clause().is_some() || stat.get_else_if_clause_list().next().is_some() { + return None; + } + + let block = stat.get_block()?; + let mut stats = block.get_stats(); + let only_stat = stats.next()?; + if stats.next().is_some() { + return None; + } + + if !is_simple_single_line_if_body(&only_stat) { + return None; + } + + Some(vec![ir::source_node(stat.syntax().clone())]) +} + +fn is_simple_single_line_if_body(stat: &LuaStat) -> bool { + match stat { + LuaStat::ReturnStat(_) + | LuaStat::BreakStat(_) + | LuaStat::GotoStat(_) + | LuaStat::CallExprStat(_) => true, + LuaStat::LocalStat(local) => { + let exprs: Vec<_> = local.get_value_exprs().collect(); + exprs.len() <= 1 && exprs.iter().all(|expr| !is_block_like_expr(expr)) + } + LuaStat::AssignStat(assign) => { + let (_, exprs) = assign.get_var_and_expr_list(); + exprs.len() <= 1 && exprs.iter().all(|expr| !is_block_like_expr(expr)) + } + _ => false, + } +} + /// while cond do ... end fn format_while_stat(ctx: &FormatContext, stat: &LuaWhileStat) -> Vec { - let mut docs = vec![ir::text("while"), ir::space()]; + if node_has_direct_comment_child(stat.syntax()) + && should_preserve_raw_empty_loop_with_comments(ctx, stat.get_block().as_ref()) + { + return vec![ir::source_node_trimmed(stat.syntax().clone())]; + } + + if node_has_direct_comment_child(stat.syntax()) { + return format_while_stat_trivia_aware(ctx, stat); + } + + let mut docs = vec![tok(LuaTokenKind::TkWhile), ir::space()]; if let Some(cond) = stat.get_condition_expr() { docs.extend(format_expr(ctx, &cond)); } docs.push(ir::space()); - docs.push(ir::text("do")); + docs.push(tok(LuaTokenKind::TkDo)); format_body_end_with_parent( ctx, @@ -361,7 +927,7 @@ fn format_while_stat(ctx: &FormatContext, stat: &LuaWhileStat) -> Vec { /// do ... end fn format_do_stat(ctx: &FormatContext, stat: &LuaDoStat) -> Vec { - let mut docs = vec![ir::text("do")]; + let mut docs = vec![tok(LuaTokenKind::TkDo)]; format_body_end_with_parent( ctx, @@ -375,22 +941,35 @@ fn format_do_stat(ctx: &FormatContext, stat: &LuaDoStat) -> Vec { /// for i = start, stop[, step] do ... end fn format_for_stat(ctx: &FormatContext, stat: &LuaForStat) -> Vec { - let mut docs = vec![ir::text("for"), ir::space()]; + if node_has_direct_comment_child(stat.syntax()) + && should_preserve_raw_empty_loop_with_comments(ctx, stat.get_block().as_ref()) + { + return vec![ir::source_node_trimmed(stat.syntax().clone())]; + } + + if node_has_direct_comment_child(stat.syntax()) { + return format_for_stat_trivia_aware(ctx, stat); + } + + let mut docs = vec![tok(LuaTokenKind::TkFor), ir::space()]; if let Some(var_name) = stat.get_var_name() { - docs.push(ir::text(var_name.get_name_text().to_string())); + docs.push(ir::source_token(var_name.syntax().clone())); } docs.push(ir::space()); - docs.push(ir::text("=")); + docs.push(tok(LuaTokenKind::TkAssign)); docs.push(ir::space()); let iter_exprs: Vec<_> = stat.get_iter_expr().collect(); let iter_docs: Vec> = iter_exprs.iter().map(|e| format_expr(ctx, e)).collect(); - docs.extend(ir::intersperse(iter_docs, vec![ir::text(","), ir::space()])); + docs.extend(ir::intersperse( + iter_docs, + vec![tok(LuaTokenKind::TkComma), ir::space()], + )); docs.push(ir::space()); - docs.push(ir::text("do")); + docs.push(tok(LuaTokenKind::TkDo)); format_body_end_with_parent( ctx, @@ -404,30 +983,40 @@ fn format_for_stat(ctx: &FormatContext, stat: &LuaForStat) -> Vec { /// for k, v in expr_list do ... end fn format_for_range_stat(ctx: &FormatContext, stat: &LuaForRangeStat) -> Vec { - let mut docs = vec![ir::text("for"), ir::space()]; + if node_has_direct_comment_child(stat.syntax()) + && should_preserve_raw_empty_loop_with_comments(ctx, stat.get_block().as_ref()) + { + return vec![ir::source_node_trimmed(stat.syntax().clone())]; + } - let var_names: Vec<_> = stat - .get_var_name_list() - .map(|n| n.get_name_text().to_string()) - .collect(); + if node_has_direct_comment_child(stat.syntax()) { + return format_for_range_stat_trivia_aware(ctx, stat); + } + + let mut docs = vec![tok(LuaTokenKind::TkFor), ir::space()]; + + let var_names: Vec<_> = stat.get_var_name_list().collect(); for (i, name) in var_names.iter().enumerate() { if i > 0 { - docs.push(ir::text(",")); + docs.push(tok(LuaTokenKind::TkComma)); docs.push(ir::space()); } - docs.push(ir::text(name.as_str())); + docs.push(ir::source_token(name.syntax().clone())); } docs.push(ir::space()); - docs.push(ir::text("in")); + docs.push(tok(LuaTokenKind::TkIn)); docs.push(ir::space()); let expr_list: Vec<_> = stat.get_expr_list().collect(); let expr_docs: Vec> = expr_list.iter().map(|e| format_expr(ctx, e)).collect(); - docs.extend(ir::intersperse(expr_docs, vec![ir::text(","), ir::space()])); + docs.extend(ir::intersperse( + expr_docs, + vec![tok(LuaTokenKind::TkComma), ir::space()], + )); docs.push(ir::space()); - docs.push(ir::text("do")); + docs.push(tok(LuaTokenKind::TkDo)); format_body_end_with_parent( ctx, @@ -439,9 +1028,290 @@ fn format_for_range_stat(ctx: &FormatContext, stat: &LuaForRangeStat) -> Vec Vec { + let entries = collect_while_stat_entries(ctx, stat); + let mut docs = vec![tok(LuaTokenKind::TkWhile)]; + + if !entries.is_empty() { + docs.push(ir::space()); + render_sequence(&mut docs, &entries, false); + } + + if sequence_has_comment(&entries) { + if !sequence_ends_with_comment(&entries) { + docs.push(ir::hard_line()); + } + docs.push(tok(LuaTokenKind::TkDo)); + } else { + docs.push(ir::space()); + docs.push(tok(LuaTokenKind::TkDo)); + } + + format_body_end_with_parent( + ctx, + stat.get_block().as_ref(), + Some(stat.syntax()), + &mut docs, + ); + docs +} + +fn collect_while_stat_entries(ctx: &FormatContext, stat: &LuaWhileStat) -> Vec { + let mut entries = Vec::new(); + + for child in stat.syntax().children_with_tokens() { + match child.kind() { + LuaKind::Syntax(LuaSyntaxKind::Comment) => { + if let Some(node) = child.as_node() + && let Some(comment) = LuaComment::cast(node.clone()) + { + entries.push(SequenceEntry::Comment(format_comment(ctx.config, &comment))); + } + } + _ => { + if let Some(node) = child.as_node() + && let Some(expr) = LuaExpr::cast(node.clone()) + { + entries.push(SequenceEntry::Item(format_expr(ctx, &expr))); + } + } + } + } + + entries +} + +fn format_for_stat_trivia_aware(ctx: &FormatContext, stat: &LuaForStat) -> Vec { + let StatementAssignSplit { + lhs_entries, + assign_op, + rhs_entries, + } = collect_for_stat_entries(ctx, stat); + let mut docs = vec![tok(LuaTokenKind::TkFor)]; + + if !lhs_entries.is_empty() { + docs.push(ir::space()); + render_sequence(&mut docs, &lhs_entries, false); + } + + if let Some(assign_op) = assign_op { + if sequence_has_comment(&lhs_entries) { + if !sequence_ends_with_comment(&lhs_entries) { + docs.push(ir::hard_line()); + } + docs.push(assign_op.clone()); + } else { + docs.push(ir::space()); + docs.push(assign_op); + } + + if !rhs_entries.is_empty() { + if sequence_starts_with_comment(&rhs_entries) { + docs.push(ir::hard_line()); + render_sequence(&mut docs, &rhs_entries, true); + } else { + docs.push(ir::space()); + render_sequence(&mut docs, &rhs_entries, false); + } + } + } + + if sequence_has_comment(&rhs_entries) { + if !sequence_ends_with_comment(&rhs_entries) { + docs.push(ir::hard_line()); + } + docs.push(tok(LuaTokenKind::TkDo)); + } else { + docs.push(ir::space()); + docs.push(tok(LuaTokenKind::TkDo)); + } + + format_body_end_with_parent( + ctx, + stat.get_block().as_ref(), + Some(stat.syntax()), + &mut docs, + ); + docs +} + +fn collect_for_stat_entries(ctx: &FormatContext, stat: &LuaForStat) -> StatementAssignSplit { + let mut lhs_entries = Vec::new(); + let mut rhs_entries = Vec::new(); + let mut assign_op = None; + let mut meet_assign = false; + + for child in stat.syntax().children_with_tokens() { + match child.kind() { + LuaKind::Token(LuaTokenKind::TkAssign) => { + meet_assign = true; + assign_op = Some(tok(LuaTokenKind::TkAssign)); + } + LuaKind::Token(LuaTokenKind::TkComma) => { + if meet_assign { + rhs_entries.push(comma_entry()); + } else { + lhs_entries.push(comma_entry()); + } + } + LuaKind::Syntax(LuaSyntaxKind::Comment) => { + if let Some(node) = child.as_node() + && let Some(comment) = LuaComment::cast(node.clone()) + { + let entry = SequenceEntry::Comment(format_comment(ctx.config, &comment)); + if meet_assign { + rhs_entries.push(entry); + } else { + lhs_entries.push(entry); + } + } + } + _ => { + if let Some(token) = child.as_token() + && token.kind() == LuaTokenKind::TkName.into() + && !meet_assign + { + lhs_entries.push(SequenceEntry::Item(vec![ir::source_token(token.clone())])); + continue; + } + + if let Some(node) = child.as_node() + && let Some(expr) = LuaExpr::cast(node.clone()) + { + rhs_entries.push(SequenceEntry::Item(format_expr(ctx, &expr))); + } + } + } + } + + StatementAssignSplit { + lhs_entries, + assign_op, + rhs_entries, + } +} + +fn format_for_range_stat_trivia_aware(ctx: &FormatContext, stat: &LuaForRangeStat) -> Vec { + let StatementAssignSplit { + lhs_entries, + assign_op, + rhs_entries, + } = collect_for_range_stat_entries(ctx, stat); + let mut docs = vec![tok(LuaTokenKind::TkFor)]; + + if !lhs_entries.is_empty() { + docs.push(ir::space()); + render_sequence(&mut docs, &lhs_entries, false); + } + + if let Some(assign_op) = assign_op { + if sequence_has_comment(&lhs_entries) { + if !sequence_ends_with_comment(&lhs_entries) { + docs.push(ir::hard_line()); + } + docs.push(assign_op.clone()); + } else { + docs.push(ir::space()); + docs.push(assign_op); + } + + if !rhs_entries.is_empty() { + if sequence_starts_with_comment(&rhs_entries) { + docs.push(ir::hard_line()); + render_sequence(&mut docs, &rhs_entries, true); + } else { + docs.push(ir::space()); + render_sequence(&mut docs, &rhs_entries, false); + } + } + } + + if sequence_has_comment(&rhs_entries) { + if !sequence_ends_with_comment(&rhs_entries) { + docs.push(ir::hard_line()); + } + docs.push(tok(LuaTokenKind::TkDo)); + } else { + docs.push(ir::space()); + docs.push(tok(LuaTokenKind::TkDo)); + } + + format_body_end_with_parent( + ctx, + stat.get_block().as_ref(), + Some(stat.syntax()), + &mut docs, + ); + docs +} + +fn collect_for_range_stat_entries( + ctx: &FormatContext, + stat: &LuaForRangeStat, +) -> StatementAssignSplit { + let mut lhs_entries = Vec::new(); + let mut rhs_entries = Vec::new(); + let mut assign_op = None; + let mut meet_in = false; + + for child in stat.syntax().children_with_tokens() { + match child.kind() { + LuaKind::Token(LuaTokenKind::TkIn) => { + meet_in = true; + assign_op = Some(tok(LuaTokenKind::TkIn)); + } + LuaKind::Token(LuaTokenKind::TkComma) => { + if meet_in { + rhs_entries.push(comma_entry()); + } else { + lhs_entries.push(comma_entry()); + } + } + LuaKind::Syntax(LuaSyntaxKind::Comment) => { + if let Some(node) = child.as_node() + && let Some(comment) = LuaComment::cast(node.clone()) + { + let entry = SequenceEntry::Comment(format_comment(ctx.config, &comment)); + if meet_in { + rhs_entries.push(entry); + } else { + lhs_entries.push(entry); + } + } + } + _ => { + if let Some(token) = child.as_token() + && token.kind() == LuaTokenKind::TkName.into() + && !meet_in + { + lhs_entries.push(SequenceEntry::Item(vec![ir::source_token(token.clone())])); + continue; + } + + if let Some(node) = child.as_node() + && let Some(expr) = LuaExpr::cast(node.clone()) + { + let entry = SequenceEntry::Item(format_expr(ctx, &expr)); + if meet_in { + rhs_entries.push(entry); + } else { + lhs_entries.push(entry); + } + } + } + } + } + + StatementAssignSplit { + lhs_entries, + assign_op, + rhs_entries, + } +} + /// repeat ... until cond fn format_repeat_stat(ctx: &FormatContext, stat: &LuaRepeatStat) -> Vec { - let mut docs = vec![ir::text("repeat")]; + let mut docs = vec![tok(LuaTokenKind::TkRepeat)]; let mut has_body = false; if let Some(block) = stat.get_block() { @@ -454,7 +1324,7 @@ fn format_repeat_stat(ctx: &FormatContext, stat: &LuaRepeatStat) -> Vec { } } if !has_body { - let comment_docs = collect_orphan_comments(stat.syntax()); + let comment_docs = collect_orphan_comments(ctx.config, stat.syntax()); if !comment_docs.is_empty() { let mut indented = vec![ir::hard_line()]; indented.extend(comment_docs); @@ -463,7 +1333,7 @@ fn format_repeat_stat(ctx: &FormatContext, stat: &LuaRepeatStat) -> Vec { } docs.push(ir::hard_line()); - docs.push(ir::text("until")); + docs.push(tok(LuaTokenKind::TkUntil)); docs.push(ir::space()); if let Some(cond) = stat.get_condition_expr() { @@ -475,17 +1345,21 @@ fn format_repeat_stat(ctx: &FormatContext, stat: &LuaRepeatStat) -> Vec { /// break fn format_break_stat(_ctx: &FormatContext, _stat: &LuaBreakStat) -> Vec { - vec![ir::text("break")] + vec![tok(LuaTokenKind::TkBreak)] } /// return expr1, expr2, ... fn format_return_stat(ctx: &FormatContext, stat: &LuaReturnStat) -> Vec { - let mut docs = vec![ir::text("return")]; + if node_has_direct_comment_child(stat.syntax()) { + return format_return_stat_trivia_aware(ctx, stat); + } + + let mut docs = vec![tok(LuaTokenKind::TkReturn)]; let exprs: Vec<_> = stat.get_expr_list().collect(); if !exprs.is_empty() { let expr_docs: Vec> = exprs.iter().map(|e| format_expr(ctx, e)).collect(); - let separated = ir::intersperse(expr_docs, vec![ir::text(","), ir::space()]); + let separated = ir::intersperse(expr_docs, vec![tok(LuaTokenKind::TkComma), ir::space()]); docs.push(ir::group(vec![ir::indent(vec![ ir::soft_line(), @@ -496,11 +1370,56 @@ fn format_return_stat(ctx: &FormatContext, stat: &LuaReturnStat) -> Vec { docs } +fn format_return_stat_trivia_aware(ctx: &FormatContext, stat: &LuaReturnStat) -> Vec { + let entries = collect_return_stat_entries(ctx, stat); + let mut docs = vec![tok(LuaTokenKind::TkReturn)]; + + if entries.is_empty() { + return docs; + } + + if sequence_has_comment(&entries) { + docs.push(ir::hard_line()); + render_sequence(&mut docs, &entries, true); + } else { + docs.push(ir::space()); + render_sequence(&mut docs, &entries, false); + } + + docs +} + +fn collect_return_stat_entries(ctx: &FormatContext, stat: &LuaReturnStat) -> Vec { + let mut entries = Vec::new(); + + for child in stat.syntax().children_with_tokens() { + match child.kind() { + LuaKind::Token(LuaTokenKind::TkComma) => entries.push(comma_entry()), + LuaKind::Syntax(LuaSyntaxKind::Comment) => { + if let Some(node) = child.as_node() + && let Some(comment) = LuaComment::cast(node.clone()) + { + entries.push(SequenceEntry::Comment(format_comment(ctx.config, &comment))); + } + } + _ => { + if let Some(node) = child.as_node() + && let Some(expr) = LuaExpr::cast(node.clone()) + { + entries.push(SequenceEntry::Item(format_expr(ctx, &expr))); + } + } + } + } + + entries +} + /// goto label fn format_goto_stat(_ctx: &FormatContext, stat: &LuaGotoStat) -> Vec { - let mut docs = vec![ir::text("goto"), ir::space()]; + let mut docs = vec![tok(LuaTokenKind::TkGoto), ir::space()]; if let Some(label) = stat.get_label_name_token() { - docs.push(ir::text(label.get_name_text().to_string())); + docs.push(ir::source_token(label.syntax().clone())); } docs } @@ -509,29 +1428,34 @@ fn format_goto_stat(_ctx: &FormatContext, stat: &LuaGotoStat) -> Vec { fn format_label_stat(_ctx: &FormatContext, stat: &LuaLabelStat) -> Vec { let mut docs = vec![ir::text("::")]; if let Some(label) = stat.get_label_name_token() { - docs.push(ir::text(label.get_name_text().to_string())); + docs.push(ir::source_token(label.syntax().clone())); } docs.push(ir::text("::")); docs } /// Format the parameter list and body of a closure (excluding function keyword and name) -fn format_closure_body( +fn format_closure_body(ctx: &FormatContext, closure: &LuaClosureExpr) -> Vec { + format_closure_body_with_prefix_space(ctx, closure, true) +} + +fn format_closure_body_with_prefix_space( ctx: &FormatContext, - closure: &emmylua_parser::LuaClosureExpr, + closure: &LuaClosureExpr, + prefix_space_before_paren: bool, ) -> Vec { let mut docs = Vec::new(); - if ctx.config.space_before_func_paren { + if prefix_space_before_paren && ctx.config.spacing.space_before_func_paren { docs.push(ir::space()); } // Parameter list - docs.push(ir::text("(")); + docs.push(tok(LuaTokenKind::TkLeftParen)); if let Some(params) = closure.get_params_list() { docs.extend(super::expression::format_params_ir(ctx, ¶ms)); } - docs.push(ir::text(")")); + docs.push(tok(LuaTokenKind::TkRightParen)); // body format_body_end_with_parent( @@ -546,7 +1470,7 @@ fn format_closure_body( /// global name1, name2 / global name1 / global * fn format_global_stat(_ctx: &FormatContext, stat: &LuaGlobalStat) -> Vec { - let mut docs = vec![ir::text("global")]; + let mut docs = vec![tok(LuaTokenKind::TkGlobal)]; // global * : declare all variables as global if stat.is_any_global() { @@ -560,28 +1484,24 @@ fn format_global_stat(_ctx: &FormatContext, stat: &LuaGlobalStat) -> Vec docs.push(ir::space()); docs.push(ir::text("<")); if let Some(name_token) = attrib.get_name_token() { - docs.push(ir::text(name_token.get_name_text().to_string())); + docs.push(ir::source_token(name_token.syntax().clone())); } docs.push(ir::text(">")); } // Variable name list - let names: Vec<_> = stat - .get_local_name_list() - .filter_map(|n| { - let token = n.get_name_token()?; - Some(token.get_name_text().to_string()) - }) - .collect(); + let names: Vec<_> = stat.get_local_name_list().collect(); for (i, name) in names.iter().enumerate() { if i == 0 { docs.push(ir::space()); } else { - docs.push(ir::text(",")); + docs.push(tok(LuaTokenKind::TkComma)); docs.push(ir::space()); } - docs.push(ir::text(name.as_str())); + if let Some(token) = name.get_name_token() { + docs.push(ir::source_token(token.syntax().clone())); + } } docs @@ -591,8 +1511,8 @@ fn format_global_stat(_ctx: &FormatContext, stat: &LuaGlobalStat) -> Vec /// Empty blocks produce compact output `... end`; non-empty blocks are indented with line breaks pub fn format_body_end_with_parent( ctx: &FormatContext, - block: Option<&emmylua_parser::LuaBlock>, - parent: Option<&emmylua_parser::LuaSyntaxNode>, + block: Option<&LuaBlock>, + parent: Option<&LuaSyntaxNode>, docs: &mut Vec, ) { if let Some(block) = block { @@ -602,32 +1522,32 @@ pub fn format_body_end_with_parent( indented.extend(block_docs); docs.push(ir::indent(indented)); docs.push(ir::hard_line()); - docs.push(ir::text("end")); + docs.push(tok(LuaTokenKind::TkEnd)); return; } } // Block is empty (or missing): check parent node for orphan comments if let Some(parent) = parent { - let comment_docs = collect_orphan_comments(parent); + let comment_docs = collect_orphan_comments(ctx.config, parent); if !comment_docs.is_empty() { let mut indented = vec![ir::hard_line()]; indented.extend(comment_docs); docs.push(ir::indent(indented)); docs.push(ir::hard_line()); - docs.push(ir::text("end")); + docs.push(tok(LuaTokenKind::TkEnd)); return; } } // Empty block: compact output ` end` docs.push(ir::space()); - docs.push(ir::text("end")); + docs.push(tok(LuaTokenKind::TkEnd)); } /// Format block or orphan comments (for if/elseif/else bodies that don't end with `end`) fn format_block_or_orphan_comments( ctx: &FormatContext, - block: Option<&emmylua_parser::LuaBlock>, - parent: &emmylua_parser::LuaSyntaxNode, + block: Option<&LuaBlock>, + parent: &LuaSyntaxNode, docs: &mut Vec, ) -> bool { if let Some(block) = block { @@ -640,7 +1560,7 @@ fn format_block_or_orphan_comments( } } // Block is empty: check parent node for orphan comments - let comment_docs = collect_orphan_comments(parent); + let comment_docs = collect_orphan_comments(ctx.config, parent); if !comment_docs.is_empty() { let mut indented = vec![ir::hard_line()]; indented.extend(comment_docs); @@ -650,16 +1570,59 @@ fn format_block_or_orphan_comments( false } -/// Expressions with their own block structure (function/table), should not break at assignment +/// Expressions with their own block structure (function/table), should not break at alignment-only paths. fn is_block_like_expr(expr: &LuaExpr) -> bool { matches!(expr, LuaExpr::ClosureExpr(_) | LuaExpr::TableExpr(_)) } +fn should_preserve_raw_empty_loop_with_comments( + ctx: &FormatContext, + block: Option<&LuaBlock>, +) -> bool { + block + .map(|block| format_block(ctx, block).is_empty()) + .unwrap_or(true) +} + +fn should_preserve_raw_statement_with_inline_comments(stat: &LuaStat) -> bool { + if node_has_direct_same_line_inline_comment(stat.syntax()) { + return true; + } + + match stat { + LuaStat::LocalStat(_) | LuaStat::AssignStat(_) => false, + LuaStat::FuncStat(func) => func + .get_closure() + .map(|closure| { + node_has_direct_same_line_inline_comment(closure.syntax()) + || closure + .get_params_list() + .map(|params| node_has_direct_same_line_inline_comment(params.syntax())) + .unwrap_or(false) + }) + .unwrap_or(false), + LuaStat::LocalFuncStat(func) => func + .get_closure() + .map(|closure| { + node_has_direct_same_line_inline_comment(closure.syntax()) + || closure + .get_params_list() + .map(|params| node_has_direct_same_line_inline_comment(params.syntax())) + .unwrap_or(false) + }) + .unwrap_or(false), + _ => false, + } +} + /// Check if a statement can participate in `=` alignment. /// Only simple local/assign statements with values qualify. pub fn is_eq_alignable(stat: &LuaStat) -> bool { match stat { LuaStat::LocalStat(s) => { + if node_has_direct_comment_child(s.syntax()) { + return false; + } // Must have values (local x = ...) and no block-like RHS let exprs: Vec<_> = s.get_value_exprs().collect(); if exprs.is_empty() { @@ -672,6 +1635,9 @@ pub fn is_eq_alignable(stat: &LuaStat) -> bool { true } LuaStat::AssignStat(s) => { + if node_has_direct_comment_child(s.syntax()) { + return false; + } let (_, exprs) = s.get_var_and_expr_list(); if exprs.is_empty() { return false; @@ -703,21 +1669,21 @@ fn format_local_stat_eq_split(ctx: &super::FormatContext, stat: &LuaLocalStat) - } // Build LHS: "local name1, name2 " - let mut before = vec![ir::text("local"), ir::space()]; + let mut before = vec![tok(LuaTokenKind::TkLocal), ir::space()]; let local_names: Vec<_> = stat.get_local_name_list().collect(); for (i, local_name) in local_names.iter().enumerate() { if i > 0 { - before.push(ir::text(",")); + before.push(tok(LuaTokenKind::TkComma)); before.push(ir::space()); } if let Some(token) = local_name.get_name_token() { - before.push(ir::text(token.get_name_text().to_string())); + before.push(ir::source_token(token.syntax().clone())); } if let Some(attrib) = local_name.get_attrib() { before.push(ir::space()); before.push(ir::text("<")); if let Some(name_token) = attrib.get_name_token() { - before.push(ir::text(name_token.get_name_text().to_string())); + before.push(ir::source_token(name_token.syntax().clone())); } before.push(ir::text(">")); } @@ -725,9 +1691,9 @@ fn format_local_stat_eq_split(ctx: &super::FormatContext, stat: &LuaLocalStat) - // Build RHS: "= value1, value2" let assign_space = space_around_assign(ctx.config).to_ir(); - let mut after = vec![ir::text("="), assign_space]; + let mut after = vec![tok(LuaTokenKind::TkAssign), assign_space]; let expr_docs: Vec> = exprs.iter().map(|e| format_expr(ctx, e)).collect(); - after.extend(ir::intersperse(expr_docs, vec![ir::text(","), ir::space()])); + after.extend(ir::intersperse(expr_docs, comma_space_sep())); Some((before, after)) } @@ -747,17 +1713,17 @@ fn format_assign_stat_eq_split( .iter() .map(|v| format_expr(ctx, &v.clone().into())) .collect(); - let before = ir::intersperse(var_docs, vec![ir::text(","), ir::space()]); + let before = ir::intersperse(var_docs, comma_space_sep()); // Build RHS let mut after = Vec::new(); if let Some(op) = stat.get_assign_op() { - after.push(ir::text(op.syntax().text().to_string())); + after.push(ir::source_token(op.syntax().clone())); } let assign_space = space_around_assign(ctx.config).to_ir(); after.push(assign_space); let expr_docs: Vec> = exprs.iter().map(|e| format_expr(ctx, e)).collect(); - after.extend(ir::intersperse(expr_docs, vec![ir::text(","), ir::space()])); + after.extend(ir::intersperse(expr_docs, comma_space_sep())); Some((before, after)) } diff --git a/crates/emmylua_formatter/src/formatter/tokens.rs b/crates/emmylua_formatter/src/formatter/tokens.rs new file mode 100644 index 000000000..271354ff3 --- /dev/null +++ b/crates/emmylua_formatter/src/formatter/tokens.rs @@ -0,0 +1,15 @@ +use emmylua_parser::LuaTokenKind; + +use crate::ir::{self, DocIR}; + +pub fn tok(kind: LuaTokenKind) -> DocIR { + ir::syntax_token(kind) +} + +pub fn comma_space_sep() -> Vec { + vec![tok(LuaTokenKind::TkComma), ir::space()] +} + +pub fn comma_soft_line_sep() -> Vec { + vec![tok(LuaTokenKind::TkComma), ir::soft_line()] +} diff --git a/crates/emmylua_formatter/src/formatter/trivia.rs b/crates/emmylua_formatter/src/formatter/trivia.rs index 3fd4d49fa..886c3e5f4 100644 --- a/crates/emmylua_formatter/src/formatter/trivia.rs +++ b/crates/emmylua_formatter/src/formatter/trivia.rs @@ -1,4 +1,4 @@ -use emmylua_parser::{LuaSyntaxNode, LuaTokenKind}; +use emmylua_parser::{LuaKind, LuaSyntaxKind, LuaSyntaxNode, LuaTokenKind}; /// Count how many blank lines appear before a node. pub fn count_blank_lines_before(node: &LuaSyntaxNode) -> usize { @@ -27,3 +27,34 @@ pub fn count_blank_lines_before(node: &LuaSyntaxNode) -> usize { blank_lines } + +pub fn node_has_direct_same_line_inline_comment(node: &LuaSyntaxNode) -> bool { + node.children().any(|child| { + child.kind() == LuaKind::Syntax(LuaSyntaxKind::Comment) + && has_non_trivia_before_on_same_line(&child) + }) +} + +pub fn node_has_direct_comment_child(node: &LuaSyntaxNode) -> bool { + node.children() + .any(|child| child.kind() == LuaKind::Syntax(LuaSyntaxKind::Comment)) +} + +pub fn has_non_trivia_before_on_same_line(node: &LuaSyntaxNode) -> bool { + let mut previous = node.prev_sibling_or_token(); + + while let Some(element) = previous { + match element.kind() { + LuaKind::Token(LuaTokenKind::TkWhitespace) => { + previous = element.prev_sibling_or_token(); + } + LuaKind::Token(LuaTokenKind::TkEndOfLine) => return false, + LuaKind::Syntax(LuaSyntaxKind::Comment) => { + previous = element.prev_sibling_or_token(); + } + _ => return true, + } + } + + false +} diff --git a/crates/emmylua_formatter/src/ir/builder.rs b/crates/emmylua_formatter/src/ir/builder.rs index 031763f56..2684173cb 100644 --- a/crates/emmylua_formatter/src/ir/builder.rs +++ b/crates/emmylua_formatter/src/ir/builder.rs @@ -2,6 +2,8 @@ use smol_str::SmolStr; use std::rc::Rc; use std::sync::atomic::{AtomicU32, Ordering}; +use emmylua_parser::{LuaSyntaxNode, LuaSyntaxToken, LuaTokenKind}; + use super::{AlignEntry, AlignGroupData, DocIR, GroupId}; static NEXT_GROUP_ID: AtomicU32 = AtomicU32::new(0); @@ -14,6 +16,28 @@ pub fn text(s: impl Into) -> DocIR { DocIR::Text(s.into()) } +pub fn source_node(node: LuaSyntaxNode) -> DocIR { + DocIR::SourceNode { + node, + trim_end: false, + } +} + +pub fn source_node_trimmed(node: LuaSyntaxNode) -> DocIR { + DocIR::SourceNode { + node, + trim_end: true, + } +} + +pub fn source_token(token: LuaSyntaxToken) -> DocIR { + DocIR::SourceToken(token) +} + +pub fn syntax_token(kind: LuaTokenKind) -> DocIR { + DocIR::SyntaxToken(kind) +} + pub fn space() -> DocIR { DocIR::Space } diff --git a/crates/emmylua_formatter/src/ir/doc_ir.rs b/crates/emmylua_formatter/src/ir/doc_ir.rs index b1bdc5a18..219419d5c 100644 --- a/crates/emmylua_formatter/src/ir/doc_ir.rs +++ b/crates/emmylua_formatter/src/ir/doc_ir.rs @@ -1,5 +1,7 @@ use std::rc::Rc; +use emmylua_parser::{LuaSyntaxNode, LuaSyntaxToken, LuaTokenKind}; +use rowan::{SyntaxText, TextSize}; use smol_str::SmolStr; /// Group identifier for querying break state across groups @@ -12,6 +14,15 @@ pub enum DocIR { /// Raw text fragment Text(SmolStr), + /// Raw source text emitted directly from an existing syntax node. + SourceNode { node: LuaSyntaxNode, trim_end: bool }, + + /// Raw source text emitted directly from an existing syntax token. + SourceToken(LuaSyntaxToken), + + /// Stable syntax token emitted from LuaTokenKind + SyntaxToken(LuaTokenKind), + /// Hard line break — always emits a newline regardless of line width HardLine, @@ -84,15 +95,86 @@ pub enum AlignEntry { } /// Compute the flat (single-line) width of an IR slice. -/// Only handles simple nodes (Text, Space, List); other nodes contribute 0. -/// This is safe for alignment `before` parts which are always flat. +/// +/// This follows the same rules the printer uses in flat mode so alignment logic +/// can estimate columns even when content contains nested groups or indents. pub fn ir_flat_width(docs: &[DocIR]) -> usize { docs.iter() .map(|d| match d { DocIR::Text(s) => s.len(), + DocIR::SourceNode { node, trim_end } => { + let text = node.text(); + syntax_text_len(&text, *trim_end) + } + DocIR::SourceToken(token) => token.text().len(), + DocIR::SyntaxToken(kind) => kind.syntax_text().map(str::len).unwrap_or(0), + DocIR::HardLine => 0, + DocIR::SoftLine => 1, + DocIR::SoftLineOrEmpty => 0, DocIR::Space => 1, + DocIR::Indent(items) => ir_flat_width(items), + DocIR::Group { contents, .. } => ir_flat_width(contents), DocIR::List(items) => ir_flat_width(items), - _ => 0, + DocIR::IfBreak { flat_contents, .. } => { + ir_flat_width(std::slice::from_ref(flat_contents.as_ref())) + } + DocIR::Fill { parts } => ir_flat_width(parts), + DocIR::LineSuffix(_) => 0, + DocIR::AlignGroup(group) => group + .entries + .iter() + .map(|entry| match entry { + AlignEntry::Aligned { + before, + after, + trailing, + } => { + let mut width = ir_flat_width(before) + ir_flat_width(after); + if let Some(trail) = trailing { + width += 1 + ir_flat_width(trail); + } + width + } + AlignEntry::Line { content, trailing } => { + let mut width = ir_flat_width(content); + if let Some(trail) = trailing { + width += 1 + ir_flat_width(trail); + } + width + } + }) + .max() + .unwrap_or(0), }) .sum() } + +pub fn syntax_text_len(text: &SyntaxText, trim_end: bool) -> usize { + let len = text.len(); + let end = if trim_end { + syntax_text_trimmed_end(text) + } else { + len + }; + + let width: u32 = end.into(); + width as usize +} + +pub fn syntax_text_trimmed_end(text: &SyntaxText) -> TextSize { + let mut trailing_len = 0usize; + + text.for_each_chunk(|chunk| { + let trimmed_len = chunk.trim_end_matches(['\r', '\n', ' ', '\t']).len(); + if trimmed_len == chunk.len() { + trailing_len = 0; + } else if trimmed_len == 0 { + trailing_len += chunk.len(); + } else { + trailing_len = chunk.len() - trimmed_len; + } + }); + + let trailing_size = TextSize::from(trailing_len as u32); + text.len() - trailing_size +} diff --git a/crates/emmylua_formatter/src/lib.rs b/crates/emmylua_formatter/src/lib.rs index 04bf2ce2a..59fdaaa79 100644 --- a/crates/emmylua_formatter/src/lib.rs +++ b/crates/emmylua_formatter/src/lib.rs @@ -4,15 +4,29 @@ mod formatter; pub mod ir; mod printer; mod test; +mod workspace; -use emmylua_parser::{LuaParser, ParserConfig}; +use emmylua_parser::{LuaChunk, LuaLanguageLevel, LuaParser, ParserConfig}; use formatter::FormatContext; use printer::Printer; -pub use config::LuaFormatConfig; +pub use config::{ + AlignConfig, CommentConfig, EmmyDocConfig, EndOfLine, ExpandStrategy, IndentConfig, IndentKind, + LayoutConfig, LuaFormatConfig, OutputConfig, SpacingConfig, TrailingComma, +}; +pub use workspace::{ + FileCollectorOptions, FormatOutput, FormatPathResult, FormatterError, ResolvedConfig, + collect_lua_files, default_config_toml, discover_config_path, format_file, format_text, + format_text_for_path, load_format_config, parse_format_config, resolve_config_for_path, +}; -pub fn reformat_lua_code(code: &str, config: &LuaFormatConfig) -> String { - let tree = LuaParser::parse(code, ParserConfig::default()); +pub struct SourceText<'a> { + pub text: &'a str, + pub level: LuaLanguageLevel, +} + +pub fn reformat_lua_code(source: &SourceText, config: &LuaFormatConfig) -> String { + let tree = LuaParser::parse(source.text, ParserConfig::with_level(source.level)); let ctx = FormatContext::new(config); let chunk = tree.get_chunk_node(); @@ -20,3 +34,10 @@ pub fn reformat_lua_code(code: &str, config: &LuaFormatConfig) -> String { Printer::new(config).print(&ir) } + +pub fn reformat_chunk(chunk: &LuaChunk, config: &LuaFormatConfig) -> String { + let ctx = FormatContext::new(config); + let ir = formatter::format_chunk(&ctx, chunk); + + Printer::new(config).print(&ir) +} diff --git a/crates/emmylua_formatter/src/printer/mod.rs b/crates/emmylua_formatter/src/printer/mod.rs index cbc9dbe75..d3d7e785d 100644 --- a/crates/emmylua_formatter/src/printer/mod.rs +++ b/crates/emmylua_formatter/src/printer/mod.rs @@ -3,7 +3,7 @@ mod test; use std::collections::HashMap; use crate::config::LuaFormatConfig; -use crate::ir::{AlignEntry, DocIR, GroupId, ir_flat_width}; +use crate::ir::{AlignEntry, DocIR, GroupId, ir_flat_width, syntax_text_trimmed_end}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PrintMode { @@ -16,6 +16,8 @@ pub struct Printer { indent_str: String, indent_width: usize, newline_str: &'static str, + line_comment_min_spaces_before: usize, + line_comment_min_column: usize, output: String, current_column: usize, indent_level: usize, @@ -26,10 +28,12 @@ pub struct Printer { impl Printer { pub fn new(config: &LuaFormatConfig) -> Self { Self { - max_line_width: config.max_line_width, + max_line_width: config.layout.max_line_width, indent_str: config.indent_str(), indent_width: config.indent_width(), newline_str: config.newline_str(), + line_comment_min_spaces_before: config.comments.line_comment_min_spaces_before.max(1), + line_comment_min_column: config.comments.line_comment_min_column, output: String::new(), current_column: 0, indent_level: 0, @@ -63,6 +67,23 @@ impl Printer { DocIR::Text(s) => { self.push_text(s); } + DocIR::SourceNode { node, trim_end } => { + let text = node.text(); + if *trim_end { + let end = syntax_text_trimmed_end(&text); + self.push_syntax_text(&text.slice(..end)); + } else { + self.push_syntax_text(&text); + } + } + DocIR::SourceToken(token) => { + self.push_text(token.text()); + } + DocIR::SyntaxToken(kind) => { + if let Some(text) = kind.syntax_text() { + self.push_text(text); + } + } DocIR::Space => { self.push_text(" "); } @@ -150,6 +171,10 @@ impl Printer { } } + fn push_syntax_text(&mut self, text: &rowan::SyntaxText) { + text.for_each_chunk(|chunk| self.push_text(chunk)); + } + fn push_newline(&mut self) { // Trim trailing spaces let trimmed = self.output.trim_end_matches(' '); @@ -174,6 +199,21 @@ impl Printer { } } + fn trailing_comment_padding( + &self, + content_width: usize, + aligned_content_width: usize, + ) -> usize { + let natural_padding = aligned_content_width.saturating_sub(content_width) + + self.line_comment_min_spaces_before; + + if self.line_comment_min_column == 0 { + natural_padding + } else { + natural_padding.max(self.line_comment_min_column.saturating_sub(content_width)) + } + } + /// Check whether contents fit within the remaining line width in Flat mode fn fits_on_line(&self, docs: &[DocIR], _current_mode: PrintMode) -> bool { let remaining = self.max_line_width.saturating_sub(self.current_column); @@ -193,6 +233,24 @@ impl Printer { DocIR::Text(s) => { remaining -= s.len() as isize; } + DocIR::SourceNode { node, trim_end } => { + let text = node.text(); + let width = if *trim_end { + let end = syntax_text_trimmed_end(&text); + let end: u32 = end.into(); + end as isize + } else { + let len: u32 = text.len().into(); + len as isize + }; + remaining -= width; + } + DocIR::SourceToken(token) => { + remaining -= token.text().len() as isize; + } + DocIR::SyntaxToken(kind) => { + remaining -= kind.syntax_text().map(str::len).unwrap_or(0) as isize; + } DocIR::Space => { remaining -= 1; } @@ -423,11 +481,11 @@ impl Printer { if let Some(trail) = trailing { let content_width = max_before + 1 + ir_flat_width(after); - let trail_padding = max_content_width.saturating_sub(content_width); + let trail_padding = + self.trailing_comment_padding(content_width, max_content_width); if trail_padding > 0 { self.push_text(&" ".repeat(trail_padding)); } - self.push_text(" "); self.print_docs(trail, mode); } } @@ -436,11 +494,11 @@ impl Printer { if let Some(trail) = trailing { let content_width = ir_flat_width(content); - let trail_padding = max_content_width.saturating_sub(content_width); + let trail_padding = + self.trailing_comment_padding(content_width, max_content_width); if trail_padding > 0 { self.push_text(&" ".repeat(trail_padding)); } - self.push_text(" "); self.print_docs(trail, mode); } } diff --git a/crates/emmylua_formatter/src/printer/test.rs b/crates/emmylua_formatter/src/printer/test.rs index c9aeaace4..b0b6f8b12 100644 --- a/crates/emmylua_formatter/src/printer/test.rs +++ b/crates/emmylua_formatter/src/printer/test.rs @@ -43,7 +43,10 @@ mod tests { #[test] fn test_group_break() { let config = LuaFormatConfig { - max_line_width: 10, + layout: crate::config::LayoutConfig { + max_line_width: 10, + ..Default::default() + }, ..Default::default() }; let printer = Printer::new(&config); diff --git a/crates/emmylua_formatter/src/test/breaking_tests.rs b/crates/emmylua_formatter/src/test/breaking_tests.rs index 43d989b19..8a8247e50 100644 --- a/crates/emmylua_formatter/src/test/breaking_tests.rs +++ b/crates/emmylua_formatter/src/test/breaking_tests.rs @@ -1,11 +1,17 @@ #[cfg(test)] mod tests { - use crate::{assert_format_with_config, config::LuaFormatConfig}; + use crate::{ + assert_format_with_config, + config::{LayoutConfig, LuaFormatConfig}, + }; #[test] fn test_long_binary_expr_breaking() { let config = LuaFormatConfig { - max_line_width: 80, + layout: LayoutConfig { + max_line_width: 80, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!( @@ -13,8 +19,7 @@ mod tests { r#" local result = very_long_variable_name_aaa + another_long_variable_name_bbb - + yet_another_variable_name_ccc - + final_variable_name_ddd + + yet_another_variable_name_ccc + final_variable_name_ddd "#, config ); @@ -23,7 +28,10 @@ local result = #[test] fn test_long_call_args_breaking() { let config = LuaFormatConfig { - max_line_width: 60, + layout: LayoutConfig { + max_line_width: 60, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!( @@ -43,7 +51,10 @@ some_function( #[test] fn test_long_table_breaking() { let config = LuaFormatConfig { - max_line_width: 60, + layout: LayoutConfig { + max_line_width: 60, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!( @@ -60,4 +71,36 @@ local t = { config ); } + + #[test] + fn test_multiline_table_input_stays_multiline_in_auto_mode() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 120, + ..Default::default() + }, + ..Default::default() + }; + assert_format_with_config!( + "local t = {\n a = 1,\n b = 2,\n}\n", + "local t = {\n a = 1,\n b = 2\n}\n", + config + ); + } + + #[test] + fn test_table_with_nested_values_stays_inline_when_width_allows() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 120, + ..Default::default() + }, + ..Default::default() + }; + assert_format_with_config!( + "local t = { user = { name = \"a\", age = 1 }, enabled = true }\n", + "local t = { user = { name = \"a\", age = 1 }, enabled = true }\n", + config + ); + } } diff --git a/crates/emmylua_formatter/src/test/comment_tests.rs b/crates/emmylua_formatter/src/test/comment_tests.rs index bb4ba0eb2..e5b9dd614 100644 --- a/crates/emmylua_formatter/src/test/comment_tests.rs +++ b/crates/emmylua_formatter/src/test/comment_tests.rs @@ -43,11 +43,14 @@ local x = 1 fn test_table_field_trailing_comment() { use crate::{ assert_format_with_config, - config::{ExpandStrategy, LuaFormatConfig}, + config::{LayoutConfig, LuaFormatConfig}, }; let config = LuaFormatConfig { - table_expand: ExpandStrategy::Always, + layout: LayoutConfig { + table_expand: crate::config::ExpandStrategy::Always, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!( @@ -171,6 +174,26 @@ end ); } + #[test] + fn test_multiline_normal_comment_in_block() { + assert_format!( + r#" +if ok then + -- hihihi + -- hello + --yyyy +end +"#, + r#" +if ok then + -- hihihi + -- hello + --yyyy +end +"# + ); + } + // ========== param comments ========== #[test] @@ -219,6 +242,50 @@ end ); } + #[test] + fn test_function_param_standalone_comment_preserved() { + assert_format!( + r#" +function foo( + a, + -- separator + b +) + return a + b +end +"#, + r#" +function foo( + a, + -- separator + b +) + return a + b +end +"# + ); + } + + #[test] + fn test_call_arg_standalone_comment_preserved() { + assert_format!( + r#" +foo( + a, + -- separator + b +) +"#, + r#" +foo( + a, + -- separator + b +) +"# + ); + } + #[test] fn test_closure_param_comments() { assert_format!( @@ -279,11 +346,14 @@ local zzz = 3 fn test_table_field_alignment() { use crate::{ assert_format_with_config, - config::{ExpandStrategy, LuaFormatConfig}, + config::{LayoutConfig, LuaFormatConfig}, }; let config = LuaFormatConfig { - table_expand: ExpandStrategy::Always, + layout: LayoutConfig { + table_expand: crate::config::ExpandStrategy::Always, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!( @@ -310,9 +380,14 @@ local t = { use crate::{assert_format_with_config, config::LuaFormatConfig}; let config = LuaFormatConfig { - align_continuous_line_comment: false, - align_continuous_assign_statement: false, - align_table_field: false, + comments: crate::config::CommentConfig { + align_line_comments: false, + ..Default::default() + }, + align: crate::config::AlignConfig { + continuous_assign_statement: false, + table_field: false, + }, ..Default::default() }; assert_format_with_config!( @@ -328,6 +403,152 @@ local bbb = 2 -- y ); } + #[test] + fn test_statement_comment_alignment_can_be_disabled_separately() { + use crate::{assert_format_with_config, config::LuaFormatConfig}; + + let config = LuaFormatConfig { + comments: crate::config::CommentConfig { + align_in_statements: false, + ..Default::default() + }, + align: crate::config::AlignConfig { + continuous_assign_statement: false, + ..Default::default() + }, + ..Default::default() + }; + assert_format_with_config!( + r#" +local a = 1 -- x +local long_name = 2 -- y +"#, + r#" +local a = 1 -- x +local long_name = 2 -- y +"#, + config + ); + } + + #[test] + fn test_param_comment_alignment_can_be_disabled_separately() { + use crate::{assert_format_with_config, config::LuaFormatConfig}; + + let config = LuaFormatConfig { + comments: crate::config::CommentConfig { + align_in_params: false, + ..Default::default() + }, + ..Default::default() + }; + assert_format_with_config!( + r#" +local f = function( + a, -- first + long_name -- second +) + return a +end +"#, + r#" +local f = function( + a, -- first + long_name -- second +) + return a +end +"#, + config + ); + } + + #[test] + fn test_table_comment_alignment_can_be_disabled_separately() { + use crate::{ + assert_format_with_config, + config::{LayoutConfig, LuaFormatConfig}, + }; + + let config = LuaFormatConfig { + layout: LayoutConfig { + table_expand: crate::config::ExpandStrategy::Always, + ..Default::default() + }, + align: crate::config::AlignConfig { + table_field: true, + ..Default::default() + }, + comments: crate::config::CommentConfig { + align_in_table_fields: false, + ..Default::default() + }, + ..Default::default() + }; + assert_format_with_config!( + r#" +local t = { + x = 100, -- first + long_name = 2, -- second +} +"#, + r#" +local t = { + x = 100, -- first + long_name = 2 -- second +} +"#, + config + ); + } + + #[test] + fn test_line_comment_min_spaces_before() { + use crate::{assert_format_with_config, config::LuaFormatConfig}; + + let config = LuaFormatConfig { + comments: crate::config::CommentConfig { + align_line_comments: false, + line_comment_min_spaces_before: 3, + ..Default::default() + }, + ..Default::default() + }; + assert_format_with_config!( + "local a = 1 -- trailing\n", + "local a = 1 -- trailing\n", + config + ); + } + + #[test] + fn test_line_comment_min_column() { + use crate::{assert_format_with_config, config::LuaFormatConfig}; + + let config = LuaFormatConfig { + align: crate::config::AlignConfig { + continuous_assign_statement: false, + ..Default::default() + }, + comments: crate::config::CommentConfig { + line_comment_min_column: 16, + ..Default::default() + }, + ..Default::default() + }; + assert_format_with_config!( + r#" +local a = 1 -- x +local bb = 2 -- y +"#, + r#" +local a = 1 -- x +local bb = 2 -- y +"#, + config + ); + } + #[test] fn test_alignment_group_broken_by_blank_line() { assert_format!( @@ -348,6 +569,76 @@ local d = 4 -- w ); } + #[test] + fn test_alignment_group_preserves_standalone_comment() { + assert_format!( + r#" +local a = 1 -- x +-- divider +local long_name = 2 -- y +"#, + r#" +local a = 1 -- x +-- divider +local long_name = 2 -- y +"# + ); + } + + #[test] + fn test_alignment_group_can_break_on_standalone_comment() { + use crate::{assert_format_with_config, config::LuaFormatConfig}; + + let config = LuaFormatConfig { + comments: crate::config::CommentConfig { + align_across_standalone_comments: false, + ..Default::default() + }, + ..Default::default() + }; + assert_format_with_config!( + r#" +local a = 1 -- x +-- divider +local long_name = 2 -- y +"#, + r#" +local a = 1 -- x +-- divider +local long_name = 2 -- y +"#, + config + ); + } + + #[test] + fn test_alignment_group_can_require_same_statement_kind() { + use crate::{assert_format_with_config, config::LuaFormatConfig}; + + let config = LuaFormatConfig { + align: crate::config::AlignConfig { + continuous_assign_statement: false, + ..Default::default() + }, + comments: crate::config::CommentConfig { + align_same_kind_only: true, + ..Default::default() + }, + ..Default::default() + }; + assert_format_with_config!( + r#" +local a = 1 -- x +bbbb = 2 -- y +"#, + r#" +local a = 1 -- x +bbbb = 2 -- y +"#, + config + ); + } + // ========== doc comment formatting ========== #[test] @@ -376,6 +667,160 @@ local d = 4 -- w ); } + #[test] + fn test_doc_comment_align_param_columns() { + assert_format!( + "---@param short string desc\n---@param much_longer integer longer desc\nlocal function f(short, much_longer) end\n", + "---@param short string desc\n---@param much_longer integer longer desc\nlocal function f(short, much_longer) end\n" + ); + } + + #[test] + fn test_doc_comment_align_field_columns() { + assert_format!( + "---@field x string desc\n---@field longer_name integer another desc\nlocal t = {}\n", + "---@field x string desc\n---@field longer_name integer another desc\nlocal t = {}\n" + ); + } + + #[test] + fn test_doc_comment_align_return_columns() { + assert_format!( + "---@return number ok success\n---@return string, integer err failure\nfunction f() end\n", + "---@return number ok success\n---@return string, integer err failure\nfunction f() end\n" + ); + } + + #[test] + fn test_doc_comment_alignment_can_be_disabled() { + use crate::{assert_format_with_config, config::LuaFormatConfig}; + + let config = LuaFormatConfig { + emmy_doc: crate::config::EmmyDocConfig { + align_tag_columns: false, + ..Default::default() + }, + ..Default::default() + }; + assert_format_with_config!( + "---@param short string desc\n---@param much_longer integer longer desc\nlocal function f(short, much_longer) end\n", + "---@param short string desc\n---@param much_longer integer longer desc\nlocal function f(short, much_longer) end\n", + config + ); + } + + #[test] + fn test_doc_comment_declaration_alignment_can_be_disabled_separately() { + use crate::{assert_format_with_config, config::LuaFormatConfig}; + + let config = LuaFormatConfig { + emmy_doc: crate::config::EmmyDocConfig { + align_declaration_tags: false, + ..Default::default() + }, + ..Default::default() + }; + assert_format_with_config!( + "---@class Short short desc\n---@class LongerName longer desc\nlocal value = {}\n", + "---@class Short short desc\n---@class LongerName longer desc\nlocal value = {}\n", + config + ); + } + + #[test] + fn test_doc_comment_reference_alignment_can_be_disabled_separately() { + use crate::{assert_format_with_config, config::LuaFormatConfig}; + + let config = LuaFormatConfig { + emmy_doc: crate::config::EmmyDocConfig { + align_reference_tags: false, + ..Default::default() + }, + ..Default::default() + }; + assert_format_with_config!( + "---@param short string desc\n---@param much_longer integer longer desc\nlocal function f(short, much_longer) end\n", + "---@param short string desc\n---@param much_longer integer longer desc\nlocal function f(short, much_longer) end\n", + config + ); + } + + #[test] + fn test_doc_comment_align_class_columns() { + assert_format!( + "---@class Short short desc\n---@class LongerName longer desc\nlocal value = {}\n", + "---@class Short short desc\n---@class LongerName longer desc\nlocal value = {}\n" + ); + } + + #[test] + fn test_doc_comment_align_alias_columns() { + assert_format!( + "---@alias Id integer identifier\n---@alias DisplayName string user facing name\nlocal value = nil\n", + "---@alias Id integer identifier\n---@alias DisplayName string user facing name\nlocal value = nil\n" + ); + } + + #[test] + fn test_doc_comment_alias_body_spacing_is_preserved() { + use crate::{assert_format_with_config, config::LuaFormatConfig}; + + let config = LuaFormatConfig { + emmy_doc: crate::config::EmmyDocConfig { + align_tag_columns: false, + ..Default::default() + }, + ..Default::default() + }; + assert_format_with_config!( + "---@alias Id integer|nil identifier\n---@alias DisplayName string user facing name\nlocal value = nil\n", + "---@alias Id integer|nil identifier\n---@alias DisplayName string user facing name\nlocal value = nil\n", + config + ); + } + + #[test] + fn test_doc_comment_description_spacing_can_omit_space_after_dash() { + use crate::{assert_format_with_config, config::LuaFormatConfig}; + + let config = LuaFormatConfig { + emmy_doc: crate::config::EmmyDocConfig { + space_after_description_dash: false, + ..Default::default() + }, + ..Default::default() + }; + assert_format_with_config!( + "--- keep tight\nlocal value = nil\n", + "---keep tight\nlocal value = nil\n", + config + ); + } + + #[test] + fn test_doc_comment_align_generic_columns() { + assert_format!( + "---@generic T value type\n---@generic Value, Result: number mapped result\nlocal function f() end\n", + "---@generic T value type\n---@generic Value, Result: number mapped result\nlocal function f() end\n" + ); + } + + #[test] + fn test_doc_comment_format_type_and_overload() { + assert_format!( + "---@type string|integer value\n---@overload fun(x: string): integer callable\nlocal fn = nil\n", + "---@type string|integer value\n---@overload fun(x: string): integer callable\nlocal fn = nil\n" + ); + } + + #[test] + fn test_doc_comment_multiline_alias_falls_back() { + assert_format!( + "---@alias Complex\n---| string\n---| integer\nlocal value = nil\n", + "---@alias Complex\n---| string\n---| integer\nlocal value = nil\n" + ); + } + #[test] fn test_long_comment_preserved() { // Long comments should be preserved as-is (including content) diff --git a/crates/emmylua_formatter/src/test/config_tests.rs b/crates/emmylua_formatter/src/test/config_tests.rs index 2c0db33d0..33ab6a6c2 100644 --- a/crates/emmylua_formatter/src/test/config_tests.rs +++ b/crates/emmylua_formatter/src/test/config_tests.rs @@ -2,7 +2,10 @@ mod tests { use crate::{ assert_format_with_config, - config::{EndOfLine, ExpandStrategy, IndentStyle, LuaFormatConfig, TrailingComma}, + config::{ + EndOfLine, ExpandStrategy, IndentConfig, IndentKind, LayoutConfig, LuaFormatConfig, + OutputConfig, SpacingConfig, TrailingComma, + }, }; // ========== spacing options ========== @@ -10,7 +13,10 @@ mod tests { #[test] fn test_space_before_func_paren() { let config = LuaFormatConfig { - space_before_func_paren: true, + spacing: SpacingConfig { + space_before_func_paren: true, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!( @@ -31,7 +37,10 @@ end #[test] fn test_space_before_call_paren() { let config = LuaFormatConfig { - space_before_call_paren: true, + spacing: SpacingConfig { + space_before_call_paren: true, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!("print(1)\n", "print (1)\n", config); @@ -40,7 +49,10 @@ end #[test] fn test_space_inside_parens() { let config = LuaFormatConfig { - space_inside_parens: true, + spacing: SpacingConfig { + space_inside_parens: true, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!("local a = (1 + 2)\n", "local a = ( 1 + 2 )\n", config); @@ -49,7 +61,10 @@ end #[test] fn test_space_inside_braces() { let config = LuaFormatConfig { - space_inside_braces: true, + spacing: SpacingConfig { + space_inside_braces: true, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!("local t = {1, 2, 3}\n", "local t = { 1, 2, 3 }\n", config); @@ -58,7 +73,10 @@ end #[test] fn test_no_space_inside_braces() { let config = LuaFormatConfig { - space_inside_braces: false, + spacing: SpacingConfig { + space_inside_braces: false, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!("local t = { 1, 2, 3 }\n", "local t = {1, 2, 3}\n", config); @@ -69,7 +87,10 @@ end #[test] fn test_table_expand_always() { let config = LuaFormatConfig { - table_expand: ExpandStrategy::Always, + layout: LayoutConfig { + table_expand: ExpandStrategy::Always, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!( @@ -87,7 +108,10 @@ local t = { #[test] fn test_table_expand_never() { let config = LuaFormatConfig { - table_expand: ExpandStrategy::Never, + layout: LayoutConfig { + table_expand: ExpandStrategy::Never, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!( @@ -107,8 +131,14 @@ b = 2 #[test] fn test_trailing_comma_always_table() { let config = LuaFormatConfig { - trailing_comma: TrailingComma::Always, - table_expand: ExpandStrategy::Always, + output: OutputConfig { + trailing_comma: TrailingComma::Always, + ..Default::default() + }, + layout: LayoutConfig { + table_expand: ExpandStrategy::Always, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!( @@ -131,8 +161,14 @@ local t = { #[test] fn test_trailing_comma_never() { let config = LuaFormatConfig { - trailing_comma: TrailingComma::Never, - table_expand: ExpandStrategy::Always, + output: OutputConfig { + trailing_comma: TrailingComma::Never, + ..Default::default() + }, + layout: LayoutConfig { + table_expand: ExpandStrategy::Always, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!( @@ -157,7 +193,10 @@ local t = { #[test] fn test_tab_indent() { let config = LuaFormatConfig { - indent_style: IndentStyle::Tab, + indent: IndentConfig { + kind: IndentKind::Tab, + ..Default::default() + }, ..Default::default() }; // Keep escaped strings: raw strings can't represent \t visually @@ -173,7 +212,10 @@ local t = { #[test] fn test_max_blank_lines() { let config = LuaFormatConfig { - max_blank_lines: 1, + layout: LayoutConfig { + max_blank_lines: 1, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!( @@ -199,7 +241,10 @@ local b = 2 #[test] fn test_crlf_end_of_line() { let config = LuaFormatConfig { - end_of_line: EndOfLine::CRLF, + output: OutputConfig { + end_of_line: EndOfLine::CRLF, + ..Default::default() + }, ..Default::default() }; // Keep escaped strings: raw strings can't represent \r\n distinctly @@ -215,7 +260,10 @@ local b = 2 #[test] fn test_no_space_around_math_operator() { let config = LuaFormatConfig { - space_around_math_operator: false, + spacing: SpacingConfig { + space_around_math_operator: false, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!( @@ -238,7 +286,10 @@ local b = 2 #[test] fn test_no_space_around_concat_operator() { let config = LuaFormatConfig { - space_around_concat_operator: false, + spacing: SpacingConfig { + space_around_concat_operator: false, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!("local s = a .. b .. c\n", "local s = a..b..c\n", config); @@ -258,7 +309,10 @@ local b = 2 // When no-space concat is enabled, `1. .. x` must keep the space to // avoid producing the invalid token `1...` let config = LuaFormatConfig { - space_around_concat_operator: false, + spacing: SpacingConfig { + space_around_concat_operator: false, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!( @@ -272,7 +326,10 @@ local b = 2 fn test_no_math_space_keeps_comparison_space() { // Disabling math operator spaces should NOT affect comparison operators let config = LuaFormatConfig { - space_around_math_operator: false, + spacing: SpacingConfig { + space_around_math_operator: false, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!("local x = a+b == c*d\n", "local x = a+b == c*d\n", config); @@ -282,7 +339,10 @@ local b = 2 fn test_no_math_space_keeps_logical_space() { // Disabling math operator spaces should NOT affect logical operators let config = LuaFormatConfig { - space_around_math_operator: false, + spacing: SpacingConfig { + space_around_math_operator: false, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!( @@ -297,7 +357,10 @@ local b = 2 #[test] fn test_no_space_around_assign() { let config = LuaFormatConfig { - space_around_assign_operator: false, + spacing: SpacingConfig { + space_around_assign_operator: false, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!("local a = 1\n", "local a=1\n", config); @@ -306,7 +369,10 @@ local b = 2 #[test] fn test_no_space_around_assign_table() { let config = LuaFormatConfig { - space_around_assign_operator: false, + spacing: SpacingConfig { + space_around_assign_operator: false, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!("local t = { a = 1 }\n", "local t={ a=1 }\n", config); @@ -316,4 +382,41 @@ local b = 2 fn test_space_around_assign_default() { assert_format_with_config!("local a=1\n", "local a = 1\n", LuaFormatConfig::default()); } + + #[test] + fn test_structured_toml_deserialize() { + let config: LuaFormatConfig = toml_edit::de::from_str( + r#" +[indent] +kind = "Space" +width = 2 + +[layout] +max_line_width = 88 +table_expand = "Always" + +[spacing] +space_before_call_paren = true + +[comments] +align_line_comments = false + +[emmy_doc] +space_after_description_dash = false + +[align] +table_field = false +"#, + ) + .expect("structured toml config should deserialize"); + + assert_eq!(config.indent.kind, IndentKind::Space); + assert_eq!(config.indent.width, 2); + assert_eq!(config.layout.max_line_width, 88); + assert_eq!(config.layout.table_expand, ExpandStrategy::Always); + assert!(config.spacing.space_before_call_paren); + assert!(!config.comments.align_line_comments); + assert!(!config.emmy_doc.space_after_description_dash); + assert!(!config.align.table_field); + } } diff --git a/crates/emmylua_formatter/src/test/expression_tests.rs b/crates/emmylua_formatter/src/test/expression_tests.rs index 8ed4266b5..46e860457 100644 --- a/crates/emmylua_formatter/src/test/expression_tests.rs +++ b/crates/emmylua_formatter/src/test/expression_tests.rs @@ -2,7 +2,10 @@ mod tests { // ========== unary / binary / concat ========== - use crate::assert_format; + use crate::{ + assert_format, assert_format_with_config, + config::{LayoutConfig, LuaFormatConfig}, + }; #[test] fn test_unary_expr() { @@ -30,6 +33,39 @@ local e = #t assert_format!("local s = a .. b .. c\n", "local s = a .. b .. c\n"); } + #[test] + fn test_multiline_binary_layout_preserved() { + assert_format!( + "local result = first\n + second\n + third\n", + "local result = first\n + second\n + third\n" + ); + } + + #[test] + fn test_binary_expr_preserves_standalone_comment_before_operator() { + assert_format!( + "local result = a\n-- separator\n+ b\n", + "local result = a\n-- separator\n+ b\n" + ); + } + + #[test] + fn test_binary_chain_uses_progressive_line_packing() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 48, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "local value = alpha_beta_gamma + delta_theta + epsilon + zeta\n", + "local value =\n alpha_beta_gamma + delta_theta + epsilon\n + zeta\n", + config + ); + } + // ========== index ========== #[test] @@ -46,6 +82,30 @@ local b = t[1] ); } + #[test] + fn test_index_expr_preserves_standalone_comment_inside_brackets() { + assert_format!( + "local value = t[\n-- separator\nkey\n]\n", + "local value = t[\n-- separator\nkey\n]\n" + ); + } + + #[test] + fn test_index_expr_preserves_standalone_comment_before_suffix() { + assert_format!( + "local value = t\n-- separator\n[key]\n", + "local value = t\n-- separator\n[key]\n" + ); + } + + #[test] + fn test_paren_expr_preserves_standalone_comment_inside() { + assert_format!( + "local value = (\n-- separator\na\n)\n", + "local value = (\n-- separator\na\n)\n" + ); + } + // ========== table ========== #[test] @@ -61,6 +121,46 @@ local b = t[1] assert_format!("local t = {}\n", "local t = {}\n"); } + #[test] + fn test_multiline_table_layout_preserved() { + assert_format!( + "local t = {\n a = 1,\n b = 2,\n}\n", + "local t = {\n a = 1,\n b = 2\n}\n" + ); + } + + #[test] + fn test_table_with_nested_table_expands_by_shape() { + assert_format!( + "local t = { user = { name = \"a\", age = 1 }, enabled = true }\n", + "local t = { user = { name = \"a\", age = 1 }, enabled = true }\n" + ); + } + + #[test] + fn test_mixed_table_style_expands_by_shape() { + assert_format!( + "local t = { answer = 42, compute() }\n", + "local t = { answer = 42, compute() }\n" + ); + } + + #[test] + fn test_mixed_named_and_bracket_key_table_expands_by_shape() { + assert_format!( + "local t = { answer = 42, [\"name\"] = user_name }\n", + "local t = { answer = 42, [\"name\"] = user_name }\n" + ); + } + + #[test] + fn test_dsl_style_call_list_table_expands_by_shape() { + assert_format!( + "local pipeline = { step_one(), step_two(), step_three() }\n", + "local pipeline = { step_one(), step_two(), step_three() }\n" + ); + } + // ========== call ========== #[test] @@ -73,6 +173,52 @@ local b = t[1] assert_format!("foo { 1, 2, 3 }\n", "foo { 1, 2, 3 }\n"); } + #[test] + fn test_call_expr_preserves_inline_comment_in_args() { + assert_format!("foo(a -- first\n, b)\n", "foo(a -- first\n, b)\n"); + } + + #[test] + fn test_closure_expr_preserves_inline_comment_in_params() { + assert_format!( + "local f = function(a -- first\n, b)\n return a + b\nend\n", + "local f = function(a -- first\n, b)\n return a + b\nend\n" + ); + } + + #[test] + fn test_multiline_call_args_layout_preserved() { + assert_format!( + "some_function(\n first,\n second,\n third\n)\n", + "some_function(\n first,\n second,\n third\n)\n" + ); + } + + #[test] + fn test_nested_call_args_do_not_force_outer_multiline_by_shape() { + assert_format!( + "cannotload(\"attempt to load a text chunk\", load(read1(x), \"modname\", \"b\", {}))\n", + "cannotload(\"attempt to load a text chunk\", load(read1(x), \"modname\", \"b\", {}))\n" + ); + } + + #[test] + fn test_nested_call_args_keep_inner_inline_when_outer_breaks() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 50, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "cannotload(\"attempt to load a text chunk\", load(read1(x), \"modname\", \"b\", {}))\n", + "cannotload(\n \"attempt to load a text chunk\",\n load(read1(x), \"modname\", \"b\", {})\n)\n", + config + ); + } + // ========== chain call ========== #[test] @@ -98,6 +244,31 @@ local b = t[1] assert_format!("a.b:c():d()\n", "a.b:c():d()\n"); } + #[test] + fn test_multiline_chain_layout_preserved() { + assert_format!( + "builder\n :set_name(name)\n :set_age(age)\n :build()\n", + "builder\n :set_name(name)\n :set_age(age)\n :build()\n" + ); + } + + #[test] + fn test_method_chain_breaks_one_segment_per_line_when_width_exceeded() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 24, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "builder:set_name(name):set_age(age):build()\n", + "builder\n :set_name(name)\n :set_age(age)\n :build()\n", + config + ); + } + // ========== and / or expression ========== #[test] diff --git a/crates/emmylua_formatter/src/test/misc_tests.rs b/crates/emmylua_formatter/src/test/misc_tests.rs index c88948ef8..6aa89172e 100644 --- a/crates/emmylua_formatter/src/test/misc_tests.rs +++ b/crates/emmylua_formatter/src/test/misc_tests.rs @@ -1,6 +1,8 @@ #[cfg(test)] mod tests { - use crate::{assert_format, config::LuaFormatConfig}; + use emmylua_parser::LuaLanguageLevel; + + use crate::{SourceText, assert_format, config::LuaFormatConfig, reformat_lua_code}; // ========== shebang ========== @@ -60,8 +62,20 @@ end "# .trim_start_matches('\n'); - let first = crate::reformat_lua_code(input, &config); - let second = crate::reformat_lua_code(&first, &config); + let first = crate::reformat_lua_code( + &SourceText { + text: input, + level: LuaLanguageLevel::default(), + }, + &config, + ); + let second = crate::reformat_lua_code( + &SourceText { + text: &first, + level: LuaLanguageLevel::default(), + }, + &config, + ); assert_eq!( first, second, "Formatter is not idempotent!\nFirst pass:\n{first}\nSecond pass:\n{second}" @@ -80,8 +94,20 @@ local t = { "# .trim_start_matches('\n'); - let first = crate::reformat_lua_code(input, &config); - let second = crate::reformat_lua_code(&first, &config); + let first = reformat_lua_code( + &SourceText { + text: input, + level: LuaLanguageLevel::default(), + }, + &config, + ); + let second = reformat_lua_code( + &SourceText { + text: &first, + level: LuaLanguageLevel::default(), + }, + &config, + ); assert_eq!( first, second, "Formatter is not idempotent for tables!\nFirst pass:\n{first}\nSecond pass:\n{second}" @@ -112,8 +138,20 @@ end "# .trim_start_matches('\n'); - let first = crate::reformat_lua_code(input, &config); - let second = crate::reformat_lua_code(&first, &config); + let first = crate::reformat_lua_code( + &SourceText { + text: input, + level: LuaLanguageLevel::default(), + }, + &config, + ); + let second = crate::reformat_lua_code( + &SourceText { + text: &first, + level: LuaLanguageLevel::default(), + }, + &config, + ); assert_eq!( first, second, "Formatter is not idempotent for complex code!\nFirst pass:\n{first}\nSecond pass:\n{second}" @@ -130,8 +168,20 @@ local cc = 3 -- comment c "# .trim_start_matches('\n'); - let first = crate::reformat_lua_code(input, &config); - let second = crate::reformat_lua_code(&first, &config); + let first = crate::reformat_lua_code( + &SourceText { + text: input, + level: LuaLanguageLevel::default(), + }, + &config, + ); + let second = crate::reformat_lua_code( + &SourceText { + text: &first, + level: LuaLanguageLevel::default(), + }, + &config, + ); assert_eq!( first, second, "Formatter is not idempotent for aligned code!\nFirst pass:\n{first}\nSecond pass:\n{second}" @@ -141,13 +191,28 @@ local cc = 3 -- comment c #[test] fn test_idempotency_method_chain() { let config = LuaFormatConfig { - max_line_width: 40, + layout: crate::config::LayoutConfig { + max_line_width: 40, + ..Default::default() + }, ..Default::default() }; let input = "local x = obj:method1():method2():method3()\n"; - let first = crate::reformat_lua_code(input, &config); - let second = crate::reformat_lua_code(&first, &config); + let first = crate::reformat_lua_code( + &SourceText { + text: input, + level: LuaLanguageLevel::default(), + }, + &config, + ); + let second = crate::reformat_lua_code( + &SourceText { + text: &first, + level: LuaLanguageLevel::default(), + }, + &config, + ); assert_eq!( first, second, "Formatter is not idempotent for method chains!\nFirst pass:\n{first}\nSecond pass:\n{second}" @@ -159,8 +224,20 @@ local cc = 3 -- comment c let config = LuaFormatConfig::default(); let input = "#!/usr/bin/lua\nlocal a = 1\n"; - let first = crate::reformat_lua_code(input, &config); - let second = crate::reformat_lua_code(&first, &config); + let first = crate::reformat_lua_code( + &SourceText { + text: input, + level: LuaLanguageLevel::default(), + }, + &config, + ); + let second = crate::reformat_lua_code( + &SourceText { + text: &first, + level: LuaLanguageLevel::default(), + }, + &config, + ); assert_eq!( first, second, "Formatter is not idempotent with shebang!\nFirst pass:\n{first}\nSecond pass:\n{second}" diff --git a/crates/emmylua_formatter/src/test/statement_tests.rs b/crates/emmylua_formatter/src/test/statement_tests.rs index 5fc4770f2..cad806cb0 100644 --- a/crates/emmylua_formatter/src/test/statement_tests.rs +++ b/crates/emmylua_formatter/src/test/statement_tests.rs @@ -2,7 +2,10 @@ mod tests { // ========== if statement ========== - use crate::assert_format; + use crate::{ + assert_format, assert_format_with_config, + config::{LayoutConfig, LuaFormatConfig}, + }; #[test] fn test_if_stat() { @@ -44,6 +47,92 @@ end ); } + #[test] + fn test_if_stat_preserves_standalone_comment_before_then() { + assert_format!( + "if ok\n-- separator\nthen\n print(1)\nend\n", + "if ok\n-- separator\nthen\n print(1)\nend\n" + ); + } + + #[test] + fn test_elseif_stat_preserves_standalone_comment_before_then() { + assert_format!( + "if a then\n print(1)\nelseif b\n-- separator\nthen\n print(2)\nend\n", + "if a then\n print(1)\nelseif b\n-- separator\nthen\n print(2)\nend\n" + ); + } + + #[test] + fn test_single_line_if_return_preserved() { + assert_format!( + "if ok then return value end\n", + "if ok then return value end\n" + ); + } + + #[test] + fn test_single_line_if_return_with_else_still_expands() { + assert_format!( + r#" +if ok then return value else return fallback end +"#, + r#" +if ok then + return value +else + return fallback +end +"# + ); + } + + #[test] + fn test_single_line_if_break_preserved() { + assert_format!("if stop then break end\n", "if stop then break end\n"); + } + + #[test] + fn test_single_line_if_call_preserved() { + assert_format!( + "if ready then notify(user) end\n", + "if ready then notify(user) end\n" + ); + } + + #[test] + fn test_single_line_if_assign_preserved() { + assert_format!( + "if ready then result = value end\n", + "if ready then result = value end\n" + ); + } + + #[test] + fn test_single_line_if_local_preserved() { + assert_format!( + "if ready then local x = value end\n", + "if ready then local x = value end\n" + ); + } + + #[test] + fn test_single_line_if_breaks_when_width_exceeded() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 40, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "if ready then notify_with_long_name(first_argument, second_argument, third_argument) end\n", + "if ready then\n notify_with_long_name(\n first_argument,\n second_argument,\n third_argument\n )\nend\n", + config + ); + } + // ========== for loop ========== #[test] @@ -78,6 +167,22 @@ end ); } + #[test] + fn test_for_loop_preserves_standalone_comment_before_do() { + assert_format!( + "for i = 1, 10\n-- separator\ndo\n print(i)\nend\n", + "for i = 1, 10\n-- separator\ndo\n print(i)\nend\n" + ); + } + + #[test] + fn test_for_range_preserves_standalone_comment_before_in() { + assert_format!( + "for k, v\n-- separator\nin pairs(t) do\n print(k, v)\nend\n", + "for k, v\n-- separator\nin pairs(t) do\n print(k, v)\nend\n" + ); + } + // ========== while / repeat / do ========== #[test] @@ -96,6 +201,14 @@ end ); } + #[test] + fn test_while_loop_preserves_standalone_comment_before_do() { + assert_format!( + "while x > 0\n-- separator\ndo\n x = x - 1\nend\n", + "while x > 0\n-- separator\ndo\n x = x - 1\nend\n" + ); + } + #[test] fn test_repeat_until() { assert_format!( @@ -178,6 +291,14 @@ end ); } + #[test] + fn test_multiline_function_params_layout_preserved() { + assert_format!( + "function foo(\n first,\n second,\n third\n)\n return first\nend\n", + "function foo(\n first,\n second,\n third\n)\n return first\nend\n" + ); + } + #[test] fn test_varargs_closure() { assert_format!( @@ -194,6 +315,14 @@ end ); } + #[test] + fn test_multiline_closure_params_layout_preserved() { + assert_format!( + "local f = function(\n first,\n second\n)\n return first + second\nend\n", + "local f = function(\n first,\n second\n)\n return first + second\nend\n" + ); + } + // ========== assignment ========== #[test] @@ -219,6 +348,26 @@ end ); } + #[test] + fn test_return_table_keeps_inline_with_keyword() { + assert_format!( + r#" +function f() +return { +key = value, +} +end +"#, + r#" +function f() + return { + key = value + } +end +"# + ); + } + // ========== goto / label / break ========== #[test] @@ -360,6 +509,67 @@ end ); } + #[test] + fn test_local_stat_preserves_inline_comment_before_assign() { + assert_format!("local a -- hiihi\n= 123\n", "local a -- hiihi\n= 123\n"); + } + + #[test] + fn test_function_stat_preserves_inline_comment_before_end() { + assert_format!( + "function t:a() -- this comment will stay the same\nend\n", + "function t:a() -- this comment will stay the same\nend\n" + ); + } + + #[test] + fn test_function_stat_preserves_inline_comment_in_params() { + assert_format!( + "function foo(a -- first\n, b)\n return a + b\nend\n", + "function foo(a -- first\n, b)\n return a + b\nend\n" + ); + } + + #[test] + fn test_function_stat_preserves_standalone_comment_before_params() { + assert_format!( + "function foo\n-- separator\n(a, b)\n return a + b\nend\n", + "function foo\n-- separator\n(a, b)\n return a + b\nend\n" + ); + } + + #[test] + fn test_local_function_stat_preserves_standalone_comment_before_params() { + assert_format!( + "local function foo\n-- separator\n(a, b)\n return a + b\nend\n", + "local function foo\n-- separator\n(a, b)\n return a + b\nend\n" + ); + } + + #[test] + fn test_local_stat_preserves_standalone_comment_between_name_and_assign() { + assert_format!( + "local a\n-- separator\n= 123\n", + "local a\n-- separator\n= 123\n" + ); + } + + #[test] + fn test_assign_stat_preserves_standalone_comment_before_assign_op() { + assert_format!( + "value\n-- separator\n= 123\n", + "value\n-- separator\n= 123\n" + ); + } + + #[test] + fn test_return_stat_preserves_standalone_comment_before_expr() { + assert_format!( + "return\n-- separator\nvalue\n", + "return\n-- separator\nvalue\n" + ); + } + // ========== local function empty body compact ========== #[test] diff --git a/crates/emmylua_formatter/src/test/test_helper.rs b/crates/emmylua_formatter/src/test/test_helper.rs index 86e9137ca..068a735ac 100644 --- a/crates/emmylua_formatter/src/test/test_helper.rs +++ b/crates/emmylua_formatter/src/test/test_helper.rs @@ -3,7 +3,7 @@ macro_rules! assert_format_with_config { ($input:expr, $expected:expr, $config:expr) => {{ let input = $input.trim_start_matches('\n'); let expected = $expected.trim_start_matches('\n'); - let result = $crate::reformat_lua_code(input, &$config); + let result = $crate::format_text(input, &$config).formatted; if result != expected { let result_lines: Vec<&str> = result.lines().collect(); let expected_lines: Vec<&str> = expected.lines().collect(); diff --git a/crates/emmylua_formatter/src/workspace.rs b/crates/emmylua_formatter/src/workspace.rs new file mode 100644 index 000000000..fecb2dda3 --- /dev/null +++ b/crates/emmylua_formatter/src/workspace.rs @@ -0,0 +1,524 @@ +use std::{ + collections::BTreeSet, + fmt, fs, io, + path::{Path, PathBuf}, +}; + +use emmylua_parser::LuaLanguageLevel; +use glob::Pattern; +use toml_edit::{de::from_str as from_toml_str, ser::to_string_pretty as to_toml_string}; +use walkdir::{DirEntry, WalkDir}; + +use crate::{LuaFormatConfig, reformat_lua_code}; + +const CONFIG_FILE_NAMES: [&str; 2] = [".luafmt.toml", "luafmt.toml"]; +const IGNORE_FILE_NAME: &str = ".luafmtignore"; +const DEFAULT_IGNORED_DIRS: [&str; 5] = [".git", ".hg", ".svn", "node_modules", "target"]; + +#[derive(Debug, Clone)] +pub struct ResolvedConfig { + pub config: LuaFormatConfig, + pub source_path: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FormatOutput { + pub formatted: String, + pub changed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FormatPathResult { + pub path: PathBuf, + pub output: FormatOutput, + pub config_path: Option, +} + +#[derive(Debug, Clone)] +pub struct FileCollectorOptions { + pub recursive: bool, + pub include_hidden: bool, + pub follow_symlinks: bool, + pub respect_ignore_files: bool, + pub include: Vec, + pub exclude: Vec, +} + +impl Default for FileCollectorOptions { + fn default() -> Self { + Self { + recursive: true, + include_hidden: false, + follow_symlinks: false, + respect_ignore_files: true, + include: Vec::new(), + exclude: Vec::new(), + } + } +} + +#[derive(Debug)] +pub enum FormatterError { + Io(io::Error), + ConfigRead { + path: PathBuf, + source: io::Error, + }, + ConfigParse { + path: Option, + message: String, + }, + GlobPattern { + pattern: String, + message: String, + }, +} + +impl fmt::Display for FormatterError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(err) => write!(f, "{err}"), + Self::ConfigRead { path, source } => { + write!( + f, + "failed to read config {}: {source}", + path.to_string_lossy() + ) + } + Self::ConfigParse { path, message } => { + if let Some(path) = path { + write!( + f, + "failed to parse config {}: {message}", + path.to_string_lossy() + ) + } else { + write!(f, "failed to parse config: {message}") + } + } + Self::GlobPattern { pattern, message } => { + write!(f, "invalid glob pattern {pattern:?}: {message}") + } + } + } +} + +impl std::error::Error for FormatterError {} + +impl From for FormatterError { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} + +pub fn format_text(code: &str, config: &LuaFormatConfig) -> FormatOutput { + let source = crate::SourceText { + text: code, + level: LuaLanguageLevel::default(), + }; + let formatted = reformat_lua_code(&source, config); + let changed = formatted != code; + FormatOutput { formatted, changed } +} + +pub fn format_text_for_path( + code: &str, + source_path: Option<&Path>, + explicit_config_path: Option<&Path>, +) -> Result { + let resolved = resolve_config_for_path(source_path, explicit_config_path)?; + let output = format_text(code, &resolved.config); + Ok(FormatPathResult { + path: source_path + .unwrap_or_else(|| Path::new("")) + .to_path_buf(), + output, + config_path: resolved.source_path, + }) +} + +pub fn format_file( + path: &Path, + explicit_config_path: Option<&Path>, +) -> Result { + let source = fs::read_to_string(path)?; + let resolved = resolve_config_for_path(Some(path), explicit_config_path)?; + let output = format_text(&source, &resolved.config); + Ok(FormatPathResult { + path: path.to_path_buf(), + output, + config_path: resolved.source_path, + }) +} + +pub fn default_config_toml() -> Result { + to_toml_string(&LuaFormatConfig::default()).map_err(|err| FormatterError::ConfigParse { + path: None, + message: format!("failed to serialize default config: {err}"), + }) +} + +pub fn parse_format_config( + content: &str, + path: Option<&Path>, +) -> Result { + let ext = path + .and_then(|value| value.extension()) + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .unwrap_or_default(); + + match ext.as_str() { + "toml" => { + from_toml_str::(content).map_err(|err| FormatterError::ConfigParse { + path: path.map(Path::to_path_buf), + message: err.to_string(), + }) + } + "json" => serde_json::from_str::(content).map_err(|err| { + FormatterError::ConfigParse { + path: path.map(Path::to_path_buf), + message: err.to_string(), + } + }), + "yml" | "yaml" => serde_yml::from_str::(content).map_err(|err| { + FormatterError::ConfigParse { + path: path.map(Path::to_path_buf), + message: err.to_string(), + } + }), + _ => try_parse_unknown_config_format(content, path), + } +} + +pub fn load_format_config(path: &Path) -> Result { + let content = fs::read_to_string(path).map_err(|source| FormatterError::ConfigRead { + path: path.to_path_buf(), + source, + })?; + parse_format_config(&content, Some(path)) +} + +pub fn discover_config_path(start: &Path) -> Option { + let root = if start.is_dir() { + start + } else { + start.parent().unwrap_or(start) + }; + + for dir in root.ancestors() { + for file_name in CONFIG_FILE_NAMES { + let path = dir.join(file_name); + if path.is_file() { + return Some(path); + } + } + } + + None +} + +pub fn resolve_config_for_path( + source_path: Option<&Path>, + explicit_config_path: Option<&Path>, +) -> Result { + if let Some(path) = explicit_config_path { + return Ok(ResolvedConfig { + config: load_format_config(path)?, + source_path: Some(path.to_path_buf()), + }); + } + + if let Some(source_path) = source_path + && let Some(path) = discover_config_path(source_path) + { + return Ok(ResolvedConfig { + config: load_format_config(&path)?, + source_path: Some(path), + }); + } + + Ok(ResolvedConfig { + config: LuaFormatConfig::default(), + source_path: None, + }) +} + +pub fn collect_lua_files( + inputs: &[PathBuf], + options: &FileCollectorOptions, +) -> Result, FormatterError> { + let include_patterns = compile_patterns(&options.include)?; + let mut exclude_values = options.exclude.clone(); + if options.respect_ignore_files { + exclude_values.extend(load_ignore_patterns(inputs)?); + } + let exclude_patterns = compile_patterns(&exclude_values)?; + + let mut files = BTreeSet::new(); + for input in inputs { + if input.is_file() { + let root = input.parent().unwrap_or(input.as_path()); + if should_include_file(input, root, options, &include_patterns, &exclude_patterns) { + files.insert(input.clone()); + } + continue; + } + + if !input.exists() { + return Err(FormatterError::Io(io::Error::new( + io::ErrorKind::NotFound, + format!("path not found: {}", input.to_string_lossy()), + ))); + } + + if !input.is_dir() { + continue; + } + + let walker = WalkDir::new(input) + .follow_links(options.follow_symlinks) + .max_depth(if options.recursive { usize::MAX } else { 1 }) + .into_iter() + .filter_entry(|entry| should_walk_entry(entry, options)); + + for entry in walker { + let entry = entry.map_err(|err| FormatterError::Io(io::Error::other(err)))?; + if !entry.file_type().is_file() { + continue; + } + + let path = entry.path(); + if should_include_file(path, input, options, &include_patterns, &exclude_patterns) { + files.insert(path.to_path_buf()); + } + } + } + + Ok(files.into_iter().collect()) +} + +fn try_parse_unknown_config_format( + content: &str, + path: Option<&Path>, +) -> Result { + from_toml_str::(content) + .or_else(|_| serde_json::from_str::(content)) + .or_else(|_| serde_yml::from_str::(content)) + .map_err(|err| FormatterError::ConfigParse { + path: path.map(Path::to_path_buf), + message: format!("unknown extension, failed to parse as TOML/JSON/YAML: {err}"), + }) +} + +fn compile_patterns(patterns: &[String]) -> Result, FormatterError> { + patterns + .iter() + .map(|pattern| { + Pattern::new(pattern).map_err(|err| FormatterError::GlobPattern { + pattern: pattern.clone(), + message: err.to_string(), + }) + }) + .collect() +} + +fn should_walk_entry(entry: &DirEntry, options: &FileCollectorOptions) -> bool { + if entry.depth() == 0 { + return true; + } + + let file_name = entry.file_name().to_string_lossy(); + if entry.file_type().is_dir() { + if DEFAULT_IGNORED_DIRS.contains(&file_name.as_ref()) { + return false; + } + if !options.include_hidden && file_name.starts_with('.') { + return false; + } + } else if !options.include_hidden && file_name.starts_with('.') { + return false; + } + + true +} + +fn should_include_file( + path: &Path, + root: &Path, + options: &FileCollectorOptions, + include_patterns: &[Pattern], + exclude_patterns: &[Pattern], +) -> bool { + if !options.include_hidden && has_hidden_component(path, root) { + return false; + } + + let extension = path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()); + if !matches!(extension.as_deref(), Some("lua") | Some("luau")) { + return false; + } + + let relative = path.strip_prefix(root).unwrap_or(path); + let relative_display = normalize_path(relative); + let absolute_display = normalize_path(path); + + if is_match(exclude_patterns, &relative_display) + || is_match(exclude_patterns, &absolute_display) + { + return false; + } + + if include_patterns.is_empty() { + return true; + } + + is_match(include_patterns, &relative_display) || is_match(include_patterns, &absolute_display) +} + +fn is_match(patterns: &[Pattern], candidate: &str) -> bool { + patterns.iter().any(|pattern| pattern.matches(candidate)) +} + +fn normalize_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn has_hidden_component(path: &Path, root: &Path) -> bool { + path.strip_prefix(root) + .unwrap_or(path) + .components() + .any(|component| component.as_os_str().to_string_lossy().starts_with('.')) +} + +fn load_ignore_patterns(inputs: &[PathBuf]) -> Result, FormatterError> { + let mut paths = BTreeSet::new(); + for input in inputs { + let start = if input.is_dir() { + input.as_path() + } else { + input.parent().unwrap_or(input.as_path()) + }; + + if let Some(path) = discover_ignore_path(start) { + paths.insert(path); + } + } + + let mut patterns = Vec::new(); + for path in paths { + let content = fs::read_to_string(&path).map_err(|source| FormatterError::ConfigRead { + path: path.clone(), + source, + })?; + patterns.extend(parse_ignore_file(&content)); + } + Ok(patterns) +} + +fn discover_ignore_path(start: &Path) -> Option { + let root = if start.is_dir() { + start + } else { + start.parent().unwrap_or(start) + }; + + for dir in root.ancestors() { + let path = dir.join(IGNORE_FILE_NAME); + if path.is_file() { + return Some(path); + } + } + + None +} + +fn parse_ignore_file(content: &str) -> Vec { + content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(ToOwned::to_owned) + .collect() +} + +#[cfg(test)] +mod tests { + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::*; + + fn make_temp_dir(prefix: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!("{prefix}-{unique}-{}", std::process::id())); + fs::create_dir_all(&path).unwrap(); + path + } + + #[test] + fn test_collect_lua_files_recurses_and_ignores_defaults() { + let root = make_temp_dir("luafmt-files"); + fs::create_dir_all(root.join("nested")).unwrap(); + fs::create_dir_all(root.join("target")).unwrap(); + fs::write(root.join("a.lua"), "local a=1\n").unwrap(); + fs::write(root.join("nested").join("b.luau"), "local b=2\n").unwrap(); + fs::write(root.join("nested").join("c.txt"), "noop\n").unwrap(); + fs::write(root.join("target").join("skip.lua"), "local c=3\n").unwrap(); + + let files = collect_lua_files( + std::slice::from_ref(&root), + &FileCollectorOptions::default(), + ) + .unwrap(); + + assert_eq!(files.len(), 2); + assert!(files.iter().any(|path| path.ends_with("a.lua"))); + assert!(files.iter().any(|path| path.ends_with("b.luau"))); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn test_collect_lua_files_respects_ignore_file_and_globs() { + let root = make_temp_dir("luafmt-ignore"); + fs::create_dir_all(root.join("gen")).unwrap(); + fs::write(root.join(".luafmtignore"), "gen/**\nignore.lua\n").unwrap(); + fs::write(root.join("keep.lua"), "local keep=1\n").unwrap(); + fs::write(root.join("ignore.lua"), "local ignore=1\n").unwrap(); + fs::write( + root.join("gen").join("generated.lua"), + "local generated=1\n", + ) + .unwrap(); + + let options = FileCollectorOptions { + include: vec!["**/*.lua".to_string()], + ..Default::default() + }; + let files = collect_lua_files(std::slice::from_ref(&root), &options).unwrap(); + + assert_eq!(files.len(), 1); + assert!(files[0].ends_with("keep.lua")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn test_resolve_config_for_path_discovers_nearest_config() { + let root = make_temp_dir("luafmt-config"); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join(".luafmt.toml"), "[layout]\nmax_line_width = 88\n").unwrap(); + let file_path = root.join("src").join("main.lua"); + fs::write(&file_path, "local x=1\n").unwrap(); + + let resolved = resolve_config_for_path(Some(&file_path), None).unwrap(); + + assert_eq!(resolved.config.layout.max_line_width, 88); + assert_eq!(resolved.source_path, Some(root.join(".luafmt.toml"))); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/emmylua_parser/src/kind/lua_token_kind.rs b/crates/emmylua_parser/src/kind/lua_token_kind.rs index f3a6a09c1..4a84d4267 100644 --- a/crates/emmylua_parser/src/kind/lua_token_kind.rs +++ b/crates/emmylua_parser/src/kind/lua_token_kind.rs @@ -172,6 +172,79 @@ impl fmt::Display for LuaTokenKind { } impl LuaTokenKind { + pub fn syntax_text(self) -> Option<&'static str> { + Some(match self { + LuaTokenKind::TkAnd => "and", + LuaTokenKind::TkBreak => "break", + LuaTokenKind::TkDo => "do", + LuaTokenKind::TkElse => "else", + LuaTokenKind::TkElseIf => "elseif", + LuaTokenKind::TkEnd => "end", + LuaTokenKind::TkFalse => "false", + LuaTokenKind::TkFor => "for", + LuaTokenKind::TkFunction => "function", + LuaTokenKind::TkGoto => "goto", + LuaTokenKind::TkIf => "if", + LuaTokenKind::TkIn => "in", + LuaTokenKind::TkLocal => "local", + LuaTokenKind::TkNil => "nil", + LuaTokenKind::TkNot => "not", + LuaTokenKind::TkOr => "or", + LuaTokenKind::TkRepeat => "repeat", + LuaTokenKind::TkReturn => "return", + LuaTokenKind::TkThen => "then", + LuaTokenKind::TkTrue => "true", + LuaTokenKind::TkUntil => "until", + LuaTokenKind::TkWhile => "while", + LuaTokenKind::TkGlobal => "global", + LuaTokenKind::TkPlus => "+", + LuaTokenKind::TkMinus => "-", + LuaTokenKind::TkMul => "*", + LuaTokenKind::TkDiv => "/", + LuaTokenKind::TkIDiv => "//", + LuaTokenKind::TkDot => ".", + LuaTokenKind::TkConcat => "..", + LuaTokenKind::TkDots => "...", + LuaTokenKind::TkComma => ",", + LuaTokenKind::TkAssign => "=", + LuaTokenKind::TkEq => "==", + LuaTokenKind::TkGe => ">=", + LuaTokenKind::TkLe => "<=", + LuaTokenKind::TkNe => "~=", + LuaTokenKind::TkShl => "<<", + LuaTokenKind::TkShr => ">>", + LuaTokenKind::TkLt => "<", + LuaTokenKind::TkGt => ">", + LuaTokenKind::TkMod => "%", + LuaTokenKind::TkPow => "^", + LuaTokenKind::TkLen => "#", + LuaTokenKind::TkBitAnd => "&", + LuaTokenKind::TkBitOr => "|", + LuaTokenKind::TkBitXor => "~", + LuaTokenKind::TkColon => ":", + LuaTokenKind::TkDbColon => "::", + LuaTokenKind::TkSemicolon => ";", + LuaTokenKind::TkPlusAssign => "+=", + LuaTokenKind::TkMinusAssign => "-=", + LuaTokenKind::TkStarAssign => "*=", + LuaTokenKind::TkSlashAssign => "/=", + LuaTokenKind::TkPercentAssign => "%=", + LuaTokenKind::TkCaretAssign => "^=", + LuaTokenKind::TkDoubleSlashAssign => "//=", + LuaTokenKind::TkPipeAssign => "|=", + LuaTokenKind::TkAmpAssign => "&=", + LuaTokenKind::TkShiftLeftAssign => "<<=", + LuaTokenKind::TkShiftRightAssign => ">>=", + LuaTokenKind::TkLeftBracket => "[", + LuaTokenKind::TkRightBracket => "]", + LuaTokenKind::TkLeftParen => "(", + LuaTokenKind::TkRightParen => ")", + LuaTokenKind::TkLeftBrace => "{", + LuaTokenKind::TkRightBrace => "}", + _ => return None, + }) + } + pub fn is_keyword(self) -> bool { matches!( self, From 75da2d590bc9d668c30453d47231b63bcfabb872 Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Thu, 19 Mar 2026 20:54:26 +0800 Subject: [PATCH 06/10] fix test --- .../src/formatter/expression.rs | 3 +- .../src/formatter/statement.rs | 72 ++++++++++++------- 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/crates/emmylua_formatter/src/formatter/expression.rs b/crates/emmylua_formatter/src/formatter/expression.rs index 2758d999e..43730108f 100644 --- a/crates/emmylua_formatter/src/formatter/expression.rs +++ b/crates/emmylua_formatter/src/formatter/expression.rs @@ -1525,8 +1525,7 @@ pub fn format_params_ir(ctx: &FormatContext, params: &emmylua_parser::LuaParamLi ExpandStrategy::Auto => { if preserve_multiline_layout { vec![ir::group_break(vec![ - ir::hard_line(), - ir::indent(inner), + ir::indent(vec![ir::hard_line(), ir::list(inner)]), ir::hard_line(), ])] } else { diff --git a/crates/emmylua_formatter/src/formatter/statement.rs b/crates/emmylua_formatter/src/formatter/statement.rs index c5996e432..373465aae 100644 --- a/crates/emmylua_formatter/src/formatter/statement.rs +++ b/crates/emmylua_formatter/src/formatter/statement.rs @@ -88,17 +88,23 @@ fn format_local_stat(ctx: &FormatContext, stat: &LuaLocalStat) -> Vec { let expr_docs: Vec> = exprs.iter().map(|e| format_expr(ctx, e)).collect(); let separated = ir::intersperse(expr_docs, comma_space_sep()); - // Keep the RHS width-driven so short values stay inline while long - // values can still break after `=`. - let break_or_space = if ctx.config.spacing.space_around_assign_operator { - ir::soft_line() + // Keep block-like / preserved multiline RHS heads attached to `=` while + // ordinary expressions remain width-driven. + if exprs.len() == 1 && should_attach_single_value_head(&exprs[0]) { + let assign_space_after = space_around_assign(ctx.config).to_ir(); + docs.push(assign_space_after); + docs.push(ir::list(separated)); } else { - ir::soft_line_or_empty() - }; - docs.push(ir::group(vec![ir::indent(vec![ - break_or_space, - ir::list(separated), - ])])); + let break_or_space = if ctx.config.spacing.space_around_assign_operator { + ir::soft_line() + } else { + ir::soft_line_or_empty() + }; + docs.push(ir::group(vec![ir::indent(vec![ + break_or_space, + ir::list(separated), + ])])); + } } docs @@ -135,17 +141,23 @@ fn format_assign_stat(ctx: &FormatContext, stat: &LuaAssignStat) -> Vec { let expr_docs: Vec> = exprs.iter().map(|e| format_expr(ctx, e)).collect(); let separated = ir::intersperse(expr_docs, vec![tok(LuaTokenKind::TkComma), ir::space()]); - // Keep the RHS width-driven so short values stay inline while long values - // can still break after the assignment operator. - let break_or_space = if ctx.config.spacing.space_around_assign_operator { - ir::soft_line() + // Keep block-like / preserved multiline RHS heads attached to the operator + // while ordinary expressions remain width-driven. + if exprs.len() == 1 && should_attach_single_value_head(&exprs[0]) { + let assign_space_after = space_around_assign(ctx.config).to_ir(); + docs.push(assign_space_after); + docs.push(ir::list(separated)); } else { - ir::soft_line_or_empty() - }; - docs.push(ir::group(vec![ir::indent(vec![ - break_or_space, - ir::list(separated), - ])])); + let break_or_space = if ctx.config.spacing.space_around_assign_operator { + ir::soft_line() + } else { + ir::soft_line_or_empty() + }; + docs.push(ir::group(vec![ir::indent(vec![ + break_or_space, + ir::list(separated), + ])])); + } docs } @@ -854,7 +866,8 @@ fn try_preserve_single_line_if_body(ctx: &FormatContext, stat: &LuaIfStat) -> Op return None; } - if stat.syntax().text().len() > ctx.config.layout.max_line_width { + let text_len: u32 = stat.syntax().text().len().into(); + if text_len as usize > ctx.config.layout.max_line_width { return None; } @@ -1361,10 +1374,15 @@ fn format_return_stat(ctx: &FormatContext, stat: &LuaReturnStat) -> Vec { let expr_docs: Vec> = exprs.iter().map(|e| format_expr(ctx, e)).collect(); let separated = ir::intersperse(expr_docs, vec![tok(LuaTokenKind::TkComma), ir::space()]); - docs.push(ir::group(vec![ir::indent(vec![ - ir::soft_line(), - ir::list(separated), - ])])); + if exprs.len() == 1 && should_attach_single_value_head(&exprs[0]) { + docs.push(ir::space()); + docs.push(ir::list(separated)); + } else { + docs.push(ir::group(vec![ir::indent(vec![ + ir::soft_line(), + ir::list(separated), + ])])); + } } docs @@ -1575,6 +1593,10 @@ fn is_block_like_expr(expr: &LuaExpr) -> bool { matches!(expr, LuaExpr::ClosureExpr(_) | LuaExpr::TableExpr(_)) } +fn should_attach_single_value_head(expr: &LuaExpr) -> bool { + is_block_like_expr(expr) || expr.syntax().text().contains_char('\n') +} + fn should_preserve_raw_empty_loop_with_comments( ctx: &FormatContext, block: Option<&LuaBlock>, From 1ad4078bc2cfb87fe8a57502670df0041647d1f1 Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Sun, 22 Mar 2026 13:19:33 +0800 Subject: [PATCH 07/10] update layout --- .../src/formatter/expression.rs | 361 ++++++++------ .../src/formatter/sequence.rs | 117 +++++ .../src/formatter/statement.rs | 463 +++++++++--------- .../src/test/breaking_tests.rs | 15 +- .../src/test/comment_tests.rs | 29 ++ .../src/test/expression_tests.rs | 90 +++- .../src/test/statement_tests.rs | 205 +++++++- 7 files changed, 879 insertions(+), 401 deletions(-) diff --git a/crates/emmylua_formatter/src/formatter/expression.rs b/crates/emmylua_formatter/src/formatter/expression.rs index 43730108f..f590402c9 100644 --- a/crates/emmylua_formatter/src/formatter/expression.rs +++ b/crates/emmylua_formatter/src/formatter/expression.rs @@ -12,8 +12,8 @@ use crate::ir::{self, AlignEntry, DocIR, EqSplit}; use super::FormatContext; use super::comment::{extract_trailing_comment, format_comment, trailing_comment_prefix}; use super::sequence::{ - SequenceEntry, render_sequence, sequence_ends_with_comment, sequence_has_comment, - sequence_starts_with_comment, + DelimitedSequenceLayout, SequenceEntry, format_delimited_sequence, render_sequence, + sequence_ends_with_comment, sequence_has_comment, sequence_starts_with_comment, }; use super::spacing::{SpaceRule, space_around_assign, space_around_binary_op}; use super::tokens::{comma_soft_line_sep, comma_space_sep, tok}; @@ -86,8 +86,6 @@ fn format_binary_expr(ctx: &FormatContext, expr: &LuaBinaryExpr) -> Vec { let op = op_token.get_op(); let space_rule = space_around_binary_op(op, ctx.config); let space_ir = space_rule.to_ir(); - let preserve_multiline_layout = expr.syntax().text().contains_char('\n'); - // Safety: when the left operand text ends with '.' and the operator // is '..', we must force a space before the operator to avoid // ambiguity (e.g. `1. ..` must not become `1...`). @@ -104,10 +102,8 @@ fn format_binary_expr(ctx: &FormatContext, expr: &LuaBinaryExpr) -> Vec { // Before-operator break: soft_line (→space when flat) if space, // soft_line_or_empty (→"" when flat) if no space - let break_ir = continuation_break_ir( - preserve_multiline_layout, - force_space_before || space_rule != SpaceRule::NoSpace, - ); + let break_ir = + continuation_break_ir(force_space_before || space_rule != SpaceRule::NoSpace); return vec![ir::group(vec![ ir::list(left_docs), @@ -241,34 +237,39 @@ fn try_format_flat_binary_chain(ctx: &FormatContext, expr: &LuaBinaryExpr) -> Op let space_rule = space_around_binary_op(op, ctx.config); let space_ir = space_rule.to_ir(); - let preserve_multiline_layout = expr.syntax().text().contains_char('\n'); - - let mut docs = format_expr(ctx, &operands[0]); + let mut fill_parts = Vec::new(); let mut previous = &operands[0]; - for operand in operands.iter().skip(1) { + let first_operand = format_expr(ctx, &operands[0]); + let mut first_chunk = first_operand; + + for (index, operand) in operands.iter().skip(1).enumerate() { let force_space_before = op == BinaryOperator::OpConcat && space_rule == SpaceRule::NoSpace && expr_end_with_float(previous); - let break_ir = continuation_break_ir( - preserve_multiline_layout, - force_space_before || space_rule != SpaceRule::NoSpace, - ); + let break_ir = + continuation_break_ir(force_space_before || space_rule != SpaceRule::NoSpace); let mut segment = Vec::new(); - segment.push(break_ir); segment.push(ir::source_token(op_token.syntax().clone())); segment.push(space_ir.clone()); segment.extend(format_expr(ctx, operand)); - if preserve_multiline_layout { - docs.push(ir::indent(segment)); + if index == 0 { + if force_space_before || space_rule != SpaceRule::NoSpace { + first_chunk.push(ir::space()); + } + first_chunk.extend(segment); + fill_parts.push(ir::list(first_chunk.clone())); } else { - docs.push(ir::group(vec![ir::indent(segment)])); + fill_parts.push(break_ir); + fill_parts.push(ir::list(segment)); } previous = operand; } - Some(docs) + Some(vec![ir::group(vec![ir::indent(vec![ir::fill( + fill_parts, + )])])]) } fn collect_binary_chain_operands(expr: &LuaExpr, op: BinaryOperator, operands: &mut Vec) { @@ -521,8 +522,6 @@ fn format_call_args_ir(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { } let args: Vec<_> = args_list.get_args().collect(); - let preserve_multiline_layout = args_list.syntax().text().contains_char('\n'); - if ctx.config.spacing.space_before_call_paren { docs.push(ir::space()); } @@ -547,7 +546,24 @@ fn format_call_args_ir(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { } else { let arg_docs: Vec> = args.iter().map(|a| format_expr(ctx, a)).collect(); - vec![ir::list(ir::intersperse(arg_docs, comma_soft_line_sep()))] + docs.extend(format_delimited_sequence(DelimitedSequenceLayout { + open: tok(LuaTokenKind::TkLeftParen), + close: tok(LuaTokenKind::TkRightParen), + items: arg_docs, + strategy: ExpandStrategy::Always, + preserve_multiline: false, + flat_separator: comma_space_sep(), + fill_separator: comma_soft_line_sep(), + break_separator: comma_soft_line_sep(), + flat_open_padding: vec![], + flat_close_padding: vec![], + grouped_padding: ir::soft_line_or_empty(), + flat_trailing: vec![], + grouped_trailing: trailing, + custom_break_contents: None, + prefer_custom_break_in_auto: false, + })); + return docs; }; docs.push(ir::group_break(vec![ tok(LuaTokenKind::TkLeftParen), @@ -568,14 +584,27 @@ fn format_call_args_ir(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { } else { let arg_docs: Vec> = args.iter().map(|a| format_expr(ctx, a)).collect(); - let flat_inner = ir::intersperse(arg_docs, comma_space_sep()); - docs.push(tok(LuaTokenKind::TkLeftParen)); - docs.push(ir::list(flat_inner)); - docs.push(tok(LuaTokenKind::TkRightParen)); + docs.extend(format_delimited_sequence(DelimitedSequenceLayout { + open: tok(LuaTokenKind::TkLeftParen), + close: tok(LuaTokenKind::TkRightParen), + items: arg_docs, + strategy: ExpandStrategy::Never, + preserve_multiline: false, + flat_separator: comma_space_sep(), + fill_separator: comma_soft_line_sep(), + break_separator: comma_soft_line_sep(), + flat_open_padding: vec![], + flat_close_padding: vec![], + grouped_padding: ir::soft_line_or_empty(), + flat_trailing: vec![], + grouped_trailing: trailing, + custom_break_contents: None, + prefer_custom_break_in_auto: false, + })); } } ExpandStrategy::Auto => { - if has_comments || preserve_multiline_layout { + if has_comments { let inner = if has_comments { build_multiline_call_arg_entries(ctx, arg_entries) } else { @@ -592,13 +621,23 @@ fn format_call_args_ir(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { } else { let arg_docs: Vec> = args.iter().map(|a| format_expr(ctx, a)).collect(); - let inner = ir::intersperse(arg_docs, comma_soft_line_sep()); - docs.push(ir::group(vec![ - tok(LuaTokenKind::TkLeftParen), - ir::indent(vec![ir::soft_line_or_empty(), ir::list(inner), trailing]), - ir::soft_line_or_empty(), - tok(LuaTokenKind::TkRightParen), - ])); + docs.extend(format_delimited_sequence(DelimitedSequenceLayout { + open: tok(LuaTokenKind::TkLeftParen), + close: tok(LuaTokenKind::TkRightParen), + items: arg_docs, + strategy: ExpandStrategy::Auto, + preserve_multiline: false, + flat_separator: comma_space_sep(), + fill_separator: comma_soft_line_sep(), + break_separator: comma_soft_line_sep(), + flat_open_padding: vec![], + flat_close_padding: vec![], + grouped_padding: ir::soft_line_or_empty(), + flat_trailing: vec![], + grouped_trailing: trailing, + custom_break_contents: None, + prefer_custom_break_in_auto: false, + })); } } } @@ -718,30 +757,30 @@ fn try_format_chain(ctx: &FormatContext, expr: &LuaCallExpr) -> Option Vec { }); // Standalone or trailing comments force expansion - let preserve_multiline_layout = expr.syntax().text().contains_char('\n'); let force_expand = has_standalone_comments || has_trailing_comments; match ctx.config.layout.table_expand { @@ -825,31 +863,43 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { build_table_expanded(ctx, entries, trailing, true, ctx.config.align.table_field) } ExpandStrategy::Never if !force_expand => { - // Force single line (valid when no comments) - let field_docs: Vec> = entries - .into_iter() - .filter_map(|e| match e { - TableEntry::Field { doc, .. } => Some(doc), - TableEntry::StandaloneComment(_) => None, - }) - .collect(); - let flat_inner = ir::intersperse(field_docs, comma_space_sep()); - let mut result = vec![tok(LuaTokenKind::TkLeftBrace)]; - if ctx.config.spacing.space_inside_braces { - result.push(ir::space()); - } - result.push(ir::list(flat_inner)); - if ctx.config.spacing.space_inside_braces { - result.push(ir::space()); - } - result.push(tok(LuaTokenKind::TkRightBrace)); - result + format_delimited_sequence(DelimitedSequenceLayout { + open: tok(LuaTokenKind::TkLeftBrace), + close: tok(LuaTokenKind::TkRightBrace), + items: entries + .into_iter() + .filter_map(|e| match e { + TableEntry::Field { doc, .. } => Some(doc), + TableEntry::StandaloneComment(_) => None, + }) + .collect(), + strategy: ExpandStrategy::Never, + preserve_multiline: false, + flat_separator: comma_space_sep(), + fill_separator: comma_soft_line_sep(), + break_separator: comma_soft_line_sep(), + flat_open_padding: if ctx.config.spacing.space_inside_braces { + vec![ir::space()] + } else { + vec![] + }, + flat_close_padding: if ctx.config.spacing.space_inside_braces { + vec![ir::space()] + } else { + vec![] + }, + grouped_padding: space_inside.clone(), + flat_trailing: vec![], + grouped_trailing: trailing.clone(), + custom_break_contents: None, + prefer_custom_break_in_auto: false, + }) } ExpandStrategy::Never => { // Never mode but has comments — must expand build_table_expanded(ctx, entries, trailing, true, ctx.config.align.table_field) } - ExpandStrategy::Auto if force_expand || preserve_multiline_layout => { + ExpandStrategy::Auto if force_expand => { // Has comments: force expand build_table_expanded(ctx, entries, trailing, true, ctx.config.align.table_field) } @@ -865,7 +915,6 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { ) }) { - // Build flat content for single-line display let flat_field_docs: Vec> = entries .iter() .filter_map(|e| match e { @@ -873,20 +922,6 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { TableEntry::StandaloneComment(_) => None, }) .collect(); - let flat_separator = comma_soft_line_sep(); - let flat_inner = ir::intersperse(flat_field_docs, flat_separator); - let flat_doc = ir::list(vec![ - tok(LuaTokenKind::TkLeftBrace), - ir::indent(vec![ - space_inside.clone(), - ir::list(flat_inner), - trailing.clone(), - ]), - space_inside.clone(), - tok(LuaTokenKind::TkRightBrace), - ]); - - // Build break content with alignment for multi-line display let break_inner = build_table_expanded_inner( ctx, &entries, @@ -894,44 +929,70 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { true, ctx.config.should_align_table_line_comments(), ); - let break_doc = ir::list(vec![ - tok(LuaTokenKind::TkLeftBrace), - ir::indent(break_inner), - ir::hard_line(), - tok(LuaTokenKind::TkRightBrace), - ]); - - let gid = ir::next_group_id(); - vec![ir::group_with_id( - vec![ir::if_break_with_group(break_doc, flat_doc, gid)], - gid, - )] + format_delimited_sequence(DelimitedSequenceLayout { + open: tok(LuaTokenKind::TkLeftBrace), + close: tok(LuaTokenKind::TkRightBrace), + items: flat_field_docs, + strategy: ExpandStrategy::Auto, + preserve_multiline: false, + flat_separator: comma_space_sep(), + fill_separator: comma_soft_line_sep(), + break_separator: comma_soft_line_sep(), + flat_open_padding: if ctx.config.spacing.space_inside_braces { + vec![ir::space()] + } else { + vec![] + }, + flat_close_padding: if ctx.config.spacing.space_inside_braces { + vec![ir::space()] + } else { + vec![] + }, + grouped_padding: space_inside.clone(), + flat_trailing: vec![], + grouped_trailing: trailing.clone(), + custom_break_contents: Some(break_inner), + prefer_custom_break_in_auto: true, + }) } else { - let field_docs: Vec> = entries - .into_iter() - .filter_map(|e| match e { - TableEntry::Field { doc, .. } => Some(doc), - TableEntry::StandaloneComment(_) => None, - }) - .collect(); - let separator = comma_soft_line_sep(); - let inner = ir::intersperse(field_docs, separator); - // Auto: single line if fits, otherwise expand - vec![ir::group(vec![ - tok(LuaTokenKind::TkLeftBrace), - ir::indent(vec![space_inside.clone(), ir::list(inner), trailing]), - space_inside, - tok(LuaTokenKind::TkRightBrace), - ])] + format_delimited_sequence(DelimitedSequenceLayout { + open: tok(LuaTokenKind::TkLeftBrace), + close: tok(LuaTokenKind::TkRightBrace), + items: entries + .into_iter() + .filter_map(|e| match e { + TableEntry::Field { doc, .. } => Some(doc), + TableEntry::StandaloneComment(_) => None, + }) + .collect(), + strategy: ExpandStrategy::Auto, + preserve_multiline: false, + flat_separator: comma_space_sep(), + fill_separator: comma_soft_line_sep(), + break_separator: comma_soft_line_sep(), + flat_open_padding: if ctx.config.spacing.space_inside_braces { + vec![ir::space()] + } else { + vec![] + }, + flat_close_padding: if ctx.config.spacing.space_inside_braces { + vec![ir::space()] + } else { + vec![] + }, + grouped_padding: space_inside, + flat_trailing: vec![], + grouped_trailing: trailing, + custom_break_contents: None, + prefer_custom_break_in_auto: false, + }) } } } } -fn continuation_break_ir(preserve_multiline_layout: bool, flat_space: bool) -> DocIR { - if preserve_multiline_layout { - ir::hard_line() - } else if flat_space { +fn continuation_break_ir(flat_space: bool) -> DocIR { + if flat_space { ir::soft_line() } else { ir::soft_line_or_empty() @@ -1251,11 +1312,12 @@ fn format_closure_expr(ctx: &FormatContext, expr: &LuaClosureExpr) -> Vec } // 参数列表 - docs.push(tok(LuaTokenKind::TkLeftParen)); if let Some(params) = expr.get_params_list() { - docs.extend(format_params_ir(ctx, ¶ms)); + docs.extend(format_param_list_ir(ctx, ¶ms)); + } else { + docs.push(tok(LuaTokenKind::TkLeftParen)); + docs.push(tok(LuaTokenKind::TkRightParen)); } - docs.push(tok(LuaTokenKind::TkRightParen)); // body super::format_body_end_with_parent( @@ -1457,13 +1519,16 @@ fn build_multiline_call_arg_entries(ctx: &FormatContext, entries: Vec Vec { +pub fn format_param_list_ir( + ctx: &FormatContext, + params: &emmylua_parser::LuaParamList, +) -> Vec { let entries = collect_param_entries(ctx, params); - let preserve_multiline_layout = params.syntax().text().contains_char('\n'); - if entries.is_empty() { - return vec![]; + return vec![ + tok(LuaTokenKind::TkLeftParen), + tok(LuaTokenKind::TkRightParen), + ]; } let has_comments = entries.iter().any(|entry| match entry { @@ -1497,14 +1562,18 @@ pub fn format_params_ir(ctx: &FormatContext, params: &emmylua_parser::LuaParamLi } } vec![ir::group_break(vec![ + tok(LuaTokenKind::TkLeftParen), ir::indent(vec![ir::hard_line(), ir::align_group(align_entries)]), ir::hard_line(), + tok(LuaTokenKind::TkRightParen), ])] } else { let inner = build_multiline_param_entries(ctx, entries); vec![ir::group_break(vec![ + tok(LuaTokenKind::TkLeftParen), ir::indent(vec![ir::hard_line(), ir::list(inner)]), ir::hard_line(), + tok(LuaTokenKind::TkRightParen), ])] } } else { @@ -1515,31 +1584,23 @@ pub fn format_params_ir(ctx: &FormatContext, params: &emmylua_parser::LuaParamLi ParamEntry::StandaloneComment(_) => None, }) .collect(); - let inner = ir::intersperse(param_docs.clone(), comma_soft_line_sep()); - - match ctx.config.layout.func_params_expand { - ExpandStrategy::Always => { - vec![ir::hard_line(), ir::indent(inner), ir::hard_line()] - } - ExpandStrategy::Never => ir::intersperse(param_docs, comma_space_sep()), - ExpandStrategy::Auto => { - if preserve_multiline_layout { - vec![ir::group_break(vec![ - ir::indent(vec![ir::hard_line(), ir::list(inner)]), - ir::hard_line(), - ])] - } else { - vec![ir::group( - [ - vec![ir::soft_line_or_empty()], - vec![ir::indent(inner)], - vec![ir::soft_line_or_empty()], - ] - .concat(), - )] - } - } - } + format_delimited_sequence(DelimitedSequenceLayout { + open: tok(LuaTokenKind::TkLeftParen), + close: tok(LuaTokenKind::TkRightParen), + items: param_docs, + strategy: ctx.config.layout.func_params_expand.clone(), + preserve_multiline: false, + flat_separator: comma_space_sep(), + fill_separator: comma_soft_line_sep(), + break_separator: comma_soft_line_sep(), + flat_open_padding: vec![], + flat_close_padding: vec![], + grouped_padding: ir::soft_line_or_empty(), + flat_trailing: vec![], + grouped_trailing: format_trailing_comma_ir(ctx.config.output.trailing_comma.clone()), + custom_break_contents: None, + prefer_custom_break_in_auto: false, + }) } } diff --git a/crates/emmylua_formatter/src/formatter/sequence.rs b/crates/emmylua_formatter/src/formatter/sequence.rs index f8f942c75..8c5ebb914 100644 --- a/crates/emmylua_formatter/src/formatter/sequence.rs +++ b/crates/emmylua_formatter/src/formatter/sequence.rs @@ -1,5 +1,6 @@ use emmylua_parser::LuaTokenKind; +use crate::config::ExpandStrategy; use crate::ir::{self, DocIR}; #[derive(Clone)] @@ -63,3 +64,119 @@ pub fn sequence_ends_with_comment(entries: &[SequenceEntry]) -> bool { pub fn sequence_starts_with_comment(entries: &[SequenceEntry]) -> bool { matches!(entries.first(), Some(SequenceEntry::Comment(_))) } + +#[derive(Clone)] +pub struct DelimitedSequenceLayout { + pub open: DocIR, + pub close: DocIR, + pub items: Vec>, + pub strategy: ExpandStrategy, + pub preserve_multiline: bool, + pub flat_separator: Vec, + pub fill_separator: Vec, + pub break_separator: Vec, + pub flat_open_padding: Vec, + pub flat_close_padding: Vec, + pub grouped_padding: DocIR, + pub flat_trailing: Vec, + pub grouped_trailing: DocIR, + pub custom_break_contents: Option>, + pub prefer_custom_break_in_auto: bool, +} + +pub fn format_delimited_sequence(layout: DelimitedSequenceLayout) -> Vec { + if layout.items.is_empty() { + return vec![layout.open, layout.close]; + } + + let flat_inner = ir::intersperse(layout.items.clone(), layout.flat_separator.clone()); + let fill_inner = ir::fill(build_fill_parts(&layout.items, &layout.fill_separator)); + let break_inner = ir::intersperse(layout.items, layout.break_separator); + let flat_doc = build_flat_doc( + &layout.open, + &layout.close, + &layout.flat_open_padding, + flat_inner, + &layout.flat_trailing, + &layout.flat_close_padding, + ); + let break_contents = layout + .custom_break_contents + .unwrap_or_else(|| default_break_contents(break_inner, layout.grouped_trailing.clone())); + + match layout.strategy { + ExpandStrategy::Never => flat_doc, + ExpandStrategy::Always => { + format_expanded_delimited_sequence(layout.open, layout.close, break_contents) + } + ExpandStrategy::Auto if layout.preserve_multiline => { + format_expanded_delimited_sequence(layout.open, layout.close, break_contents) + } + ExpandStrategy::Auto if layout.prefer_custom_break_in_auto => { + let gid = ir::next_group_id(); + let break_doc = ir::list(vec![ + layout.open, + ir::indent(break_contents), + ir::hard_line(), + layout.close, + ]); + vec![ir::group_with_id( + vec![ir::if_break_with_group(break_doc, ir::list(flat_doc), gid)], + gid, + )] + } + ExpandStrategy::Auto => vec![ir::group(vec![ + layout.open, + ir::indent(vec![ + layout.grouped_padding.clone(), + fill_inner, + layout.grouped_trailing, + ]), + layout.grouped_padding, + layout.close, + ])], + } +} + +fn format_expanded_delimited_sequence(open: DocIR, close: DocIR, inner: Vec) -> Vec { + vec![ir::group_break(vec![ + open, + ir::indent(inner), + ir::hard_line(), + close, + ])] +} + +fn default_break_contents(inner: Vec, trailing: DocIR) -> Vec { + vec![ir::hard_line(), ir::list(inner), trailing] +} + +fn build_flat_doc( + open: &DocIR, + close: &DocIR, + open_padding: &[DocIR], + inner: Vec, + trailing: &[DocIR], + close_padding: &[DocIR], +) -> Vec { + let mut docs = vec![open.clone()]; + docs.extend(open_padding.to_vec()); + docs.extend(inner); + docs.extend(trailing.to_vec()); + docs.extend(close_padding.to_vec()); + docs.push(close.clone()); + docs +} + +fn build_fill_parts(items: &[Vec], separator: &[DocIR]) -> Vec { + let mut parts = Vec::with_capacity(items.len().saturating_mul(2)); + + for (index, item) in items.iter().enumerate() { + parts.push(ir::list(item.clone())); + if index + 1 < items.len() { + parts.push(ir::list(separator.to_vec())); + } + } + + parts +} diff --git a/crates/emmylua_formatter/src/formatter/statement.rs b/crates/emmylua_formatter/src/formatter/statement.rs index 373465aae..7cfafe457 100644 --- a/crates/emmylua_formatter/src/formatter/statement.rs +++ b/crates/emmylua_formatter/src/formatter/statement.rs @@ -86,24 +86,20 @@ fn format_local_stat(ctx: &FormatContext, stat: &LuaLocalStat) -> Vec { docs.push(tok(LuaTokenKind::TkAssign)); let expr_docs: Vec> = exprs.iter().map(|e| format_expr(ctx, e)).collect(); - let separated = ir::intersperse(expr_docs, comma_space_sep()); // Keep block-like / preserved multiline RHS heads attached to `=` while // ordinary expressions remain width-driven. if exprs.len() == 1 && should_attach_single_value_head(&exprs[0]) { let assign_space_after = space_around_assign(ctx.config).to_ir(); docs.push(assign_space_after); - docs.push(ir::list(separated)); + docs.push(ir::list(expr_docs.into_iter().next().unwrap_or_default())); } else { - let break_or_space = if ctx.config.spacing.space_around_assign_operator { - ir::soft_line() + let leading_docs = if ctx.config.spacing.space_around_assign_operator { + vec![ir::space()] } else { - ir::soft_line_or_empty() + vec![] }; - docs.push(ir::group(vec![ir::indent(vec![ - break_or_space, - ir::list(separated), - ])])); + docs.extend(format_statement_expr_list(leading_docs, expr_docs)); } } @@ -139,24 +135,20 @@ fn format_assign_stat(ctx: &FormatContext, stat: &LuaAssignStat) -> Vec { // Value list let expr_docs: Vec> = exprs.iter().map(|e| format_expr(ctx, e)).collect(); - let separated = ir::intersperse(expr_docs, vec![tok(LuaTokenKind::TkComma), ir::space()]); // Keep block-like / preserved multiline RHS heads attached to the operator // while ordinary expressions remain width-driven. if exprs.len() == 1 && should_attach_single_value_head(&exprs[0]) { let assign_space_after = space_around_assign(ctx.config).to_ir(); docs.push(assign_space_after); - docs.push(ir::list(separated)); + docs.push(ir::list(expr_docs.into_iter().next().unwrap_or_default())); } else { - let break_or_space = if ctx.config.spacing.space_around_assign_operator { - ir::soft_line() + let leading_docs = if ctx.config.spacing.space_around_assign_operator { + vec![ir::space()] } else { - ir::soft_line_or_empty() + vec![] }; - docs.push(ir::group(vec![ir::indent(vec![ - break_or_space, - ir::list(separated), - ])])); + docs.extend(format_statement_expr_list(leading_docs, expr_docs)); } docs @@ -406,17 +398,17 @@ fn format_func_stat(ctx: &FormatContext, stat: &LuaFuncStat) -> Vec { return compact; } - let mut docs = vec![tok(LuaTokenKind::TkFunction), ir::space()]; + let mut head_docs = vec![ir::space()]; if let Some(name) = stat.get_func_name() { - docs.extend(format_expr(ctx, &name.into())); + head_docs.extend(format_expr(ctx, &name.into())); } if let Some(closure) = stat.get_closure() { - docs.extend(format_closure_body(ctx, &closure)); + head_docs.extend(format_closure_body(ctx, &closure)); } - docs + format_keyword_header(vec![tok(LuaTokenKind::TkFunction)], head_docs) } /// local function name() ... end @@ -430,24 +422,24 @@ fn format_local_func_stat(ctx: &FormatContext, stat: &LuaLocalFuncStat) -> Vec Vec { @@ -665,15 +657,13 @@ fn format_if_stat(ctx: &FormatContext, stat: &LuaIfStat) -> Vec { return format_if_stat_trivia_aware(ctx, stat); } - let mut docs = vec![tok(LuaTokenKind::TkIf), ir::space()]; + let mut head_docs = vec![ir::space()]; - // if condition if let Some(cond) = stat.get_condition_expr() { - docs.extend(format_expr(ctx, &cond)); + head_docs.extend(format_expr(ctx, &cond)); } - docs.push(ir::space()); - docs.push(tok(LuaTokenKind::TkThen)); + let mut docs = format_control_header(LuaTokenKind::TkIf, head_docs, LuaTokenKind::TkThen); // if body format_block_or_orphan_comments(ctx, stat.get_block().as_ref(), stat.syntax(), &mut docs); @@ -681,13 +671,15 @@ fn format_if_stat(ctx: &FormatContext, stat: &LuaIfStat) -> Vec { // elseif branches for clause in stat.get_else_if_clause_list() { docs.push(ir::hard_line()); - docs.push(tok(LuaTokenKind::TkElseIf)); - docs.push(ir::space()); + let mut clause_head_docs = vec![ir::space()]; if let Some(cond) = clause.get_condition_expr() { - docs.extend(format_expr(ctx, &cond)); + clause_head_docs.extend(format_expr(ctx, &cond)); } - docs.push(ir::space()); - docs.push(tok(LuaTokenKind::TkThen)); + docs.extend(format_control_header( + LuaTokenKind::TkElseIf, + clause_head_docs, + LuaTokenKind::TkThen, + )); format_block_or_orphan_comments( ctx, clause.get_block().as_ref(), @@ -733,8 +725,8 @@ fn should_preserve_raw_if_stat_with_comments(stat: &LuaIfStat) -> bool { } fn format_if_stat_trivia_aware(ctx: &FormatContext, stat: &LuaIfStat) -> Vec { - let mut docs = format_if_clause_header( - LuaTokenKind::TkIf, + let mut docs = format_sequence_control_header( + vec![tok(LuaTokenKind::TkIf)], &collect_if_clause_entries(ctx, stat.syntax()), LuaTokenKind::TkThen, ); @@ -749,21 +741,11 @@ fn format_if_stat_trivia_aware(ctx: &FormatContext, stat: &LuaIfStat) -> Vec Vec entries } -fn format_if_clause_header( - leading_keyword: LuaTokenKind, - entries: &[SequenceEntry], - trailing_keyword: LuaTokenKind, -) -> Vec { - let mut docs = vec![tok(leading_keyword)]; - - if !entries.is_empty() { - docs.push(ir::space()); - render_sequence(&mut docs, entries, false); - } - - if sequence_has_comment(entries) { - if !sequence_ends_with_comment(entries) { - docs.push(ir::hard_line()); - } - docs.push(tok(trailing_keyword)); - } else { - docs.push(ir::space()); - docs.push(tok(trailing_keyword)); - } - docs -} - fn try_format_raw_clause_header_until_block( syntax: &LuaSyntaxNode, block: Option<&LuaBlock>, @@ -867,7 +825,12 @@ fn try_preserve_single_line_if_body(ctx: &FormatContext, stat: &LuaIfStat) -> Op } let text_len: u32 = stat.syntax().text().len().into(); - if text_len as usize > ctx.config.layout.max_line_width { + let reserve_width = if ctx.config.layout.max_line_width > 40 { + 8 + } else { + 4 + }; + if text_len as usize + reserve_width > ctx.config.layout.max_line_width { return None; } @@ -919,14 +882,12 @@ fn format_while_stat(ctx: &FormatContext, stat: &LuaWhileStat) -> Vec { return format_while_stat_trivia_aware(ctx, stat); } - let mut docs = vec![tok(LuaTokenKind::TkWhile), ir::space()]; - + let mut head_docs = vec![ir::space()]; if let Some(cond) = stat.get_condition_expr() { - docs.extend(format_expr(ctx, &cond)); + head_docs.extend(format_expr(ctx, &cond)); } - docs.push(ir::space()); - docs.push(tok(LuaTokenKind::TkDo)); + let mut docs = format_control_header(LuaTokenKind::TkWhile, head_docs, LuaTokenKind::TkDo); format_body_end_with_parent( ctx, @@ -964,25 +925,20 @@ fn format_for_stat(ctx: &FormatContext, stat: &LuaForStat) -> Vec { return format_for_stat_trivia_aware(ctx, stat); } - let mut docs = vec![tok(LuaTokenKind::TkFor), ir::space()]; + let mut head_docs = vec![ir::space()]; if let Some(var_name) = stat.get_var_name() { - docs.push(ir::source_token(var_name.syntax().clone())); + head_docs.push(ir::source_token(var_name.syntax().clone())); } - docs.push(ir::space()); - docs.push(tok(LuaTokenKind::TkAssign)); - docs.push(ir::space()); + head_docs.push(ir::space()); + head_docs.push(tok(LuaTokenKind::TkAssign)); let iter_exprs: Vec<_> = stat.get_iter_expr().collect(); let iter_docs: Vec> = iter_exprs.iter().map(|e| format_expr(ctx, e)).collect(); - docs.extend(ir::intersperse( - iter_docs, - vec![tok(LuaTokenKind::TkComma), ir::space()], - )); + head_docs.extend(format_statement_expr_list(vec![ir::space()], iter_docs)); - docs.push(ir::space()); - docs.push(tok(LuaTokenKind::TkDo)); + let mut docs = format_control_header(LuaTokenKind::TkFor, head_docs, LuaTokenKind::TkDo); format_body_end_with_parent( ctx, @@ -1006,30 +962,25 @@ fn format_for_range_stat(ctx: &FormatContext, stat: &LuaForRangeStat) -> Vec = stat.get_var_name_list().collect(); for (i, name) in var_names.iter().enumerate() { if i > 0 { - docs.push(tok(LuaTokenKind::TkComma)); - docs.push(ir::space()); + head_docs.push(tok(LuaTokenKind::TkComma)); + head_docs.push(ir::space()); } - docs.push(ir::source_token(name.syntax().clone())); + head_docs.push(ir::source_token(name.syntax().clone())); } - docs.push(ir::space()); - docs.push(tok(LuaTokenKind::TkIn)); - docs.push(ir::space()); + head_docs.push(ir::space()); + head_docs.push(tok(LuaTokenKind::TkIn)); let expr_list: Vec<_> = stat.get_expr_list().collect(); let expr_docs: Vec> = expr_list.iter().map(|e| format_expr(ctx, e)).collect(); - docs.extend(ir::intersperse( - expr_docs, - vec![tok(LuaTokenKind::TkComma), ir::space()], - )); + head_docs.extend(format_statement_expr_list(vec![ir::space()], expr_docs)); - docs.push(ir::space()); - docs.push(tok(LuaTokenKind::TkDo)); + let mut docs = format_control_header(LuaTokenKind::TkFor, head_docs, LuaTokenKind::TkDo); format_body_end_with_parent( ctx, @@ -1043,22 +994,11 @@ fn format_for_range_stat(ctx: &FormatContext, stat: &LuaForRangeStat) -> Vec Vec { let entries = collect_while_stat_entries(ctx, stat); - let mut docs = vec![tok(LuaTokenKind::TkWhile)]; - - if !entries.is_empty() { - docs.push(ir::space()); - render_sequence(&mut docs, &entries, false); - } - - if sequence_has_comment(&entries) { - if !sequence_ends_with_comment(&entries) { - docs.push(ir::hard_line()); - } - docs.push(tok(LuaTokenKind::TkDo)); - } else { - docs.push(ir::space()); - docs.push(tok(LuaTokenKind::TkDo)); - } + let mut docs = format_sequence_control_header( + vec![tok(LuaTokenKind::TkWhile)], + &entries, + LuaTokenKind::TkDo, + ); format_body_end_with_parent( ctx, @@ -1100,44 +1040,13 @@ fn format_for_stat_trivia_aware(ctx: &FormatContext, stat: &LuaForStat) -> Vec Vec { } docs.push(ir::hard_line()); - docs.push(tok(LuaTokenKind::TkUntil)); - docs.push(ir::space()); + + let mut head_docs = vec![ir::space()]; if let Some(cond) = stat.get_condition_expr() { - docs.extend(format_expr(ctx, &cond)); + head_docs.extend(format_expr(ctx, &cond)); } + docs.extend(format_keyword_header( + vec![tok(LuaTokenKind::TkUntil)], + head_docs, + )); + docs } @@ -1372,16 +1255,12 @@ fn format_return_stat(ctx: &FormatContext, stat: &LuaReturnStat) -> Vec { let exprs: Vec<_> = stat.get_expr_list().collect(); if !exprs.is_empty() { let expr_docs: Vec> = exprs.iter().map(|e| format_expr(ctx, e)).collect(); - let separated = ir::intersperse(expr_docs, vec![tok(LuaTokenKind::TkComma), ir::space()]); if exprs.len() == 1 && should_attach_single_value_head(&exprs[0]) { docs.push(ir::space()); - docs.push(ir::list(separated)); + docs.push(ir::list(expr_docs.into_iter().next().unwrap_or_default())); } else { - docs.push(ir::group(vec![ir::indent(vec![ - ir::soft_line(), - ir::list(separated), - ])])); + docs.extend(format_statement_expr_list(vec![ir::space()], expr_docs)); } } @@ -1433,6 +1312,142 @@ fn collect_return_stat_entries(ctx: &FormatContext, stat: &LuaReturnStat) -> Vec entries } +fn format_statement_expr_list(leading_docs: Vec, expr_docs: Vec>) -> Vec { + if expr_docs.is_empty() { + return Vec::new(); + } + + if expr_docs.len() == 1 { + let mut docs = leading_docs; + docs.extend(expr_docs.into_iter().next().unwrap_or_default()); + return docs; + } + + let mut parts = Vec::with_capacity(expr_docs.len().saturating_mul(2)); + let mut expr_docs = expr_docs.into_iter(); + let mut first_chunk = leading_docs; + first_chunk.extend(expr_docs.next().unwrap_or_default()); + parts.push(ir::list(first_chunk)); + + for expr_doc in expr_docs { + parts.push(ir::list(vec![tok(LuaTokenKind::TkComma), ir::soft_line()])); + parts.push(ir::list(expr_doc)); + } + + vec![ir::group(vec![ir::indent(vec![ir::fill(parts)])])] +} + +fn format_control_header( + leading_keyword: LuaTokenKind, + head_docs: Vec, + trailing_keyword: LuaTokenKind, +) -> Vec { + format_header_with_trailing(vec![tok(leading_keyword)], head_docs, trailing_keyword) +} + +fn format_keyword_header(leading_docs: Vec, head_docs: Vec) -> Vec { + vec![ir::group(vec![ir::list(leading_docs), ir::list(head_docs)])] +} + +fn format_header_with_trailing( + leading_docs: Vec, + head_docs: Vec, + trailing_keyword: LuaTokenKind, +) -> Vec { + vec![ir::group(vec![ + ir::list(leading_docs), + ir::list(head_docs), + ir::space(), + tok(trailing_keyword), + ])] +} + +fn format_sequence_control_header( + leading_docs: Vec, + entries: &[SequenceEntry], + trailing_keyword: LuaTokenKind, +) -> Vec { + if sequence_has_comment(entries) { + let mut docs = leading_docs; + if !entries.is_empty() { + docs.push(ir::space()); + render_sequence(&mut docs, entries, false); + } + if !sequence_ends_with_comment(entries) { + docs.push(ir::hard_line()); + } + docs.push(tok(trailing_keyword)); + docs + } else { + let mut head_docs = vec![ir::space()]; + render_sequence(&mut head_docs, entries, false); + format_header_with_trailing(leading_docs, head_docs, trailing_keyword) + } +} + +fn format_split_control_header( + leading_docs: Vec, + lhs_entries: &[SequenceEntry], + split_op: Option<&DocIR>, + rhs_entries: &[SequenceEntry], + trailing_keyword: LuaTokenKind, +) -> Vec { + if sequence_has_comment(lhs_entries) || sequence_has_comment(rhs_entries) { + let mut docs = leading_docs; + + if !lhs_entries.is_empty() { + docs.push(ir::space()); + render_sequence(&mut docs, lhs_entries, false); + } + + if let Some(split_op) = split_op { + if sequence_has_comment(lhs_entries) { + if !sequence_ends_with_comment(lhs_entries) { + docs.push(ir::hard_line()); + } + docs.push(split_op.clone()); + } else { + docs.push(ir::space()); + docs.push(split_op.clone()); + } + + if !rhs_entries.is_empty() { + if sequence_starts_with_comment(rhs_entries) { + docs.push(ir::hard_line()); + render_sequence(&mut docs, rhs_entries, true); + } else { + docs.push(ir::space()); + render_sequence(&mut docs, rhs_entries, false); + } + } + } + + if sequence_has_comment(rhs_entries) { + if !sequence_ends_with_comment(rhs_entries) { + docs.push(ir::hard_line()); + } + docs.push(tok(trailing_keyword)); + } else { + docs.push(ir::space()); + docs.push(tok(trailing_keyword)); + } + + docs + } else { + let mut head_docs = vec![ir::space()]; + render_sequence(&mut head_docs, lhs_entries, false); + if let Some(split_op) = split_op { + head_docs.push(ir::space()); + head_docs.push(split_op.clone()); + if !rhs_entries.is_empty() { + head_docs.push(ir::space()); + render_sequence(&mut head_docs, rhs_entries, false); + } + } + format_header_with_trailing(leading_docs, head_docs, trailing_keyword) + } +} + /// goto label fn format_goto_stat(_ctx: &FormatContext, stat: &LuaGotoStat) -> Vec { let mut docs = vec![tok(LuaTokenKind::TkGoto), ir::space()]; @@ -1469,11 +1484,12 @@ fn format_closure_body_with_prefix_space( } // Parameter list - docs.push(tok(LuaTokenKind::TkLeftParen)); if let Some(params) = closure.get_params_list() { - docs.extend(super::expression::format_params_ir(ctx, ¶ms)); + docs.extend(super::expression::format_param_list_ir(ctx, ¶ms)); + } else { + docs.push(tok(LuaTokenKind::TkLeftParen)); + docs.push(tok(LuaTokenKind::TkRightParen)); } - docs.push(tok(LuaTokenKind::TkRightParen)); // body format_body_end_with_parent( @@ -1486,18 +1502,10 @@ fn format_closure_body_with_prefix_space( docs } -/// global name1, name2 / global name1 / global * +/// global name1, name2 / global name1 / global * fn format_global_stat(_ctx: &FormatContext, stat: &LuaGlobalStat) -> Vec { let mut docs = vec![tok(LuaTokenKind::TkGlobal)]; - // global * : declare all variables as global - if stat.is_any_global() { - docs.push(ir::space()); - docs.push(ir::text("*")); - return docs; - } - - // global name1, name2 : declaration with attribute if let Some(attrib) = stat.get_attrib() { docs.push(ir::space()); docs.push(ir::text("<")); @@ -1507,6 +1515,13 @@ fn format_global_stat(_ctx: &FormatContext, stat: &LuaGlobalStat) -> Vec docs.push(ir::text(">")); } + // global * : declare all variables as global + if stat.is_any_global() { + docs.push(ir::space()); + docs.push(ir::text("*")); + return docs; + } + // Variable name list let names: Vec<_> = stat.get_local_name_list().collect(); @@ -1517,9 +1532,7 @@ fn format_global_stat(_ctx: &FormatContext, stat: &LuaGlobalStat) -> Vec docs.push(tok(LuaTokenKind::TkComma)); docs.push(ir::space()); } - if let Some(token) = name.get_name_token() { - docs.push(ir::source_token(token.syntax().clone())); - } + docs.extend(format_local_name_ir(name)); } docs @@ -1594,7 +1607,7 @@ fn is_block_like_expr(expr: &LuaExpr) -> bool { } fn should_attach_single_value_head(expr: &LuaExpr) -> bool { - is_block_like_expr(expr) || expr.syntax().text().contains_char('\n') + is_block_like_expr(expr) || node_has_direct_comment_child(expr.syntax()) } fn should_preserve_raw_empty_loop_with_comments( diff --git a/crates/emmylua_formatter/src/test/breaking_tests.rs b/crates/emmylua_formatter/src/test/breaking_tests.rs index 8a8247e50..dcd8f2f2a 100644 --- a/crates/emmylua_formatter/src/test/breaking_tests.rs +++ b/crates/emmylua_formatter/src/test/breaking_tests.rs @@ -17,9 +17,8 @@ mod tests { assert_format_with_config!( "local result = very_long_variable_name_aaa + another_long_variable_name_bbb + yet_another_variable_name_ccc + final_variable_name_ddd\n", r#" -local result = - very_long_variable_name_aaa + another_long_variable_name_bbb - + yet_another_variable_name_ccc + final_variable_name_ddd +local result = very_long_variable_name_aaa + another_long_variable_name_bbb + + yet_another_variable_name_ccc + final_variable_name_ddd "#, config ); @@ -38,10 +37,8 @@ local result = "some_function(very_long_argument_one, very_long_argument_two, very_long_argument_three, very_long_argument_four)\n", r#" some_function( - very_long_argument_one, - very_long_argument_two, - very_long_argument_three, - very_long_argument_four + very_long_argument_one, very_long_argument_two, + very_long_argument_three, very_long_argument_four ) "#, config @@ -73,7 +70,7 @@ local t = { } #[test] - fn test_multiline_table_input_stays_multiline_in_auto_mode() { + fn test_multiline_table_input_reflows_in_auto_mode_when_width_allows() { let config = LuaFormatConfig { layout: LayoutConfig { max_line_width: 120, @@ -83,7 +80,7 @@ local t = { }; assert_format_with_config!( "local t = {\n a = 1,\n b = 2,\n}\n", - "local t = {\n a = 1,\n b = 2\n}\n", + "local t = { a = 1, b = 2 }\n", config ); } diff --git a/crates/emmylua_formatter/src/test/comment_tests.rs b/crates/emmylua_formatter/src/test/comment_tests.rs index e5b9dd614..628e33d22 100644 --- a/crates/emmylua_formatter/src/test/comment_tests.rs +++ b/crates/emmylua_formatter/src/test/comment_tests.rs @@ -375,6 +375,35 @@ local t = { ); } + #[test] + fn test_table_field_alignment_in_auto_mode_when_width_exceeded() { + use crate::{ + assert_format_with_config, + config::{LayoutConfig, LuaFormatConfig}, + }; + + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 28, + table_expand: crate::config::ExpandStrategy::Auto, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "local t = { x = 1, long_name = 2, yy = 3 }\n", + r#" +local t = { + x = 1, + long_name = 2, + yy = 3 +} +"#, + config + ); + } + #[test] fn test_alignment_disabled() { use crate::{assert_format_with_config, config::LuaFormatConfig}; diff --git a/crates/emmylua_formatter/src/test/expression_tests.rs b/crates/emmylua_formatter/src/test/expression_tests.rs index 46e860457..25e77c10b 100644 --- a/crates/emmylua_formatter/src/test/expression_tests.rs +++ b/crates/emmylua_formatter/src/test/expression_tests.rs @@ -34,10 +34,10 @@ local e = #t } #[test] - fn test_multiline_binary_layout_preserved() { + fn test_multiline_binary_layout_reflows_when_width_allows() { assert_format!( "local result = first\n + second\n + third\n", - "local result = first\n + second\n + third\n" + "local result = first + second + third\n" ); } @@ -61,7 +61,24 @@ local e = #t assert_format_with_config!( "local value = alpha_beta_gamma + delta_theta + epsilon + zeta\n", - "local value =\n alpha_beta_gamma + delta_theta + epsilon\n + zeta\n", + "local value = alpha_beta_gamma + delta_theta\n + epsilon + zeta\n", + config + ); + } + + #[test] + fn test_binary_chain_fill_keeps_multiple_segments_per_line() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 30, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "local total = alpha + beta + gamma + delta\n", + "local total = alpha + beta\n + gamma + delta\n", config ); } @@ -122,10 +139,10 @@ local b = t[1] } #[test] - fn test_multiline_table_layout_preserved() { + fn test_multiline_table_layout_reflows_when_width_allows() { assert_format!( "local t = {\n a = 1,\n b = 2,\n}\n", - "local t = {\n a = 1,\n b = 2\n}\n" + "local t = { a = 1, b = 2 }\n" ); } @@ -187,10 +204,10 @@ local b = t[1] } #[test] - fn test_multiline_call_args_layout_preserved() { + fn test_multiline_call_args_layout_reflow_when_width_allows() { assert_format!( "some_function(\n first,\n second,\n third\n)\n", - "some_function(\n first,\n second,\n third\n)\n" + "some_function(first, second, third)\n" ); } @@ -219,6 +236,44 @@ local b = t[1] ); } + #[test] + fn test_call_args_use_progressive_fill_before_full_expansion() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 44, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "some_function(first_arg, second_arg, third_arg, fourth_arg)\n", + "some_function(\n first_arg, second_arg, third_arg,\n fourth_arg\n)\n", + config + ); + } + + #[test] + fn test_table_auto_without_alignment_uses_progressive_fill() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 28, + ..Default::default() + }, + align: crate::config::AlignConfig { + table_field: false, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "local t = { alpha, beta, gamma, delta }\n", + "local t = {\n alpha, beta, gamma,\n delta\n}\n", + config + ); + } + // ========== chain call ========== #[test] @@ -245,10 +300,27 @@ local b = t[1] } #[test] - fn test_multiline_chain_layout_preserved() { + fn test_multiline_chain_layout_reflows_when_width_allows() { assert_format!( "builder\n :set_name(name)\n :set_age(age)\n :build()\n", - "builder\n :set_name(name)\n :set_age(age)\n :build()\n" + "builder:set_name(name):set_age(age):build()\n" + ); + } + + #[test] + fn test_method_chain_uses_progressive_fill_when_width_exceeded() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 32, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "builder:set_name(name):set_age(age):build()\n", + "builder\n :set_name(name):set_age(age)\n :build()\n", + config ); } diff --git a/crates/emmylua_formatter/src/test/statement_tests.rs b/crates/emmylua_formatter/src/test/statement_tests.rs index cad806cb0..102fba45d 100644 --- a/crates/emmylua_formatter/src/test/statement_tests.rs +++ b/crates/emmylua_formatter/src/test/statement_tests.rs @@ -128,7 +128,24 @@ end assert_format_with_config!( "if ready then notify_with_long_name(first_argument, second_argument, third_argument) end\n", - "if ready then\n notify_with_long_name(\n first_argument,\n second_argument,\n third_argument\n )\nend\n", + "if ready then\n notify_with_long_name(\n first_argument, second_argument,\n third_argument\n )\nend\n", + config + ); + } + + #[test] + fn test_if_header_breaks_with_long_condition() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 44, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "if alpha_beta_gamma + delta_theta + epsilon + zeta then\n print(result)\nend\n", + "if alpha_beta_gamma + delta_theta + epsilon\n + zeta then\n print(result)\nend\n", config ); } @@ -183,6 +200,40 @@ end ); } + #[test] + fn test_for_loop_header_breaks_with_long_iter_exprs() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 60, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "for i = very_long_start_expr, very_long_stop_expr, very_long_step_expr do\n print(i)\nend\n", + "for i = very_long_start_expr, very_long_stop_expr,\n very_long_step_expr do\n print(i)\nend\n", + config + ); + } + + #[test] + fn test_for_range_header_breaks_with_long_exprs() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 64, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "for key, value in very_long_iterator_expr, another_long_iterator_expr, fallback_iterator_expr do\n print(key, value)\nend\n", + "for key, value in very_long_iterator_expr,\n another_long_iterator_expr, fallback_iterator_expr do\n print(key, value)\nend\n", + config + ); + } + // ========== while / repeat / do ========== #[test] @@ -209,6 +260,31 @@ end ); } + #[test] + fn test_while_trivia_header_preserves_comment_before_do_with_shared_helper() { + assert_format!( + "while alpha_beta_gamma\n-- separator\ndo\n work()\nend\n", + "while alpha_beta_gamma\n-- separator\ndo\n work()\nend\n" + ); + } + + #[test] + fn test_while_header_breaks_with_long_condition() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 44, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "while alpha_beta_gamma + delta_theta + epsilon + zeta do\n consume()\nend\n", + "while alpha_beta_gamma + delta_theta\n + epsilon + zeta do\n consume()\nend\n", + config + ); + } + #[test] fn test_repeat_until() { assert_format!( @@ -225,6 +301,23 @@ until x > 10 ); } + #[test] + fn test_repeat_until_header_breaks_with_long_condition() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 44, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "repeat\n work()\nuntil alpha_beta_gamma + delta_theta + epsilon + zeta\n", + "repeat\n work()\nuntil alpha_beta_gamma + delta_theta\n + epsilon + zeta\n", + config + ); + } + #[test] fn test_do_block() { assert_format!( @@ -292,10 +385,44 @@ end } #[test] - fn test_multiline_function_params_layout_preserved() { + fn test_multiline_function_params_layout_reflow_when_width_allows() { assert_format!( "function foo(\n first,\n second,\n third\n)\n return first\nend\n", - "function foo(\n first,\n second,\n third\n)\n return first\nend\n" + "function foo(first, second, third)\n return first\nend\n" + ); + } + + #[test] + fn test_function_params_use_progressive_fill_before_full_expansion() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 27, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "function foo(first, second, third, fourth)\n return first\nend\n", + "function foo(\n first, second, third,\n fourth\n)\n return first\nend\n", + config + ); + } + + #[test] + fn test_function_header_keeps_name_and_breaks_params_progressively() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 52, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "function module_name.deep_property.compute(first_argument, second_argument, third_argument)\n return first_argument\nend\n", + "function module_name.deep_property.compute(\n first_argument, second_argument, third_argument\n)\n return first_argument\nend\n", + config ); } @@ -316,10 +443,10 @@ end } #[test] - fn test_multiline_closure_params_layout_preserved() { + fn test_multiline_closure_params_layout_reflow_when_width_allows() { assert_format!( "local f = function(\n first,\n second\n)\n return first + second\nend\n", - "local f = function(\n first,\n second\n)\n return first + second\nend\n" + "local f = function(first, second)\n return first + second\nend\n" ); } @@ -360,14 +487,46 @@ end "#, r#" function f() - return { - key = value - } + return { key = value } end "# ); } + #[test] + fn test_assign_keeps_first_expr_on_operator_line_when_breaking() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 48, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "result = alpha_beta_gamma + delta_theta + epsilon + zeta\n", + "result = alpha_beta_gamma + delta_theta\n + epsilon + zeta\n", + config + ); + } + + #[test] + fn test_return_keeps_first_expr_on_keyword_line_when_breaking() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 48, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "function f()\nreturn alpha_beta_gamma + delta_theta + epsilon + zeta\nend\n", + "function f()\n return alpha_beta_gamma + delta_theta\n + epsilon + zeta\nend\n", + config + ); + } + // ========== goto / label / break ========== #[test] @@ -509,6 +668,19 @@ end ); } + #[test] + fn test_global_const_star() { + assert_format!("global *\n", "global *\n"); + } + + #[test] + fn test_global_preserves_name_attributes() { + assert_format!( + "global a, b \n", + "global a, b \n" + ); + } + #[test] fn test_local_stat_preserves_inline_comment_before_assign() { assert_format!("local a -- hiihi\n= 123\n", "local a -- hiihi\n= 123\n"); @@ -546,6 +718,23 @@ end ); } + #[test] + fn test_single_line_if_near_width_limit_prefers_expanded_layout() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 48, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "if alpha_beta_gamma then return delta_theta end\n", + "if alpha_beta_gamma then\n return delta_theta\nend\n", + config + ); + } + #[test] fn test_local_stat_preserves_standalone_comment_between_name_and_assign() { assert_format!( From 8c336d133ec36d9c6e942c22e9888960e4a8cdb1 Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Sun, 22 Mar 2026 18:46:30 +0800 Subject: [PATCH 08/10] update --- Cargo.lock | 7 + crates/emmylua_formatter/Cargo.toml | 1 + crates/emmylua_formatter/src/bin/luafmt.rs | 298 ++++++++++++- crates/emmylua_formatter/src/cmd_args.rs | 25 +- crates/emmylua_formatter/src/config/mod.rs | 12 +- .../src/formatter/comment.rs | 42 ++ .../src/formatter/expression.rs | 411 ++++++++++++++++-- .../src/formatter/statement.rs | 77 +++- crates/emmylua_formatter/src/lib.rs | 3 +- .../src/test/breaking_tests.rs | 6 +- .../src/test/comment_tests.rs | 219 +++++++++- .../src/test/statement_tests.rs | 16 + crates/emmylua_formatter/src/workspace.rs | 158 ++++++- 13 files changed, 1182 insertions(+), 93 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index acd1edb22..1d786a379 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -699,6 +699,7 @@ dependencies = [ "serde", "serde_json", "serde_yml", + "similar", "smol_str", "toml_edit", "walkdir", @@ -2586,6 +2587,12 @@ dependencies = [ "libc", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "siphasher" version = "1.0.1" diff --git a/crates/emmylua_formatter/Cargo.toml b/crates/emmylua_formatter/Cargo.toml index c4ad7025c..29248ade8 100644 --- a/crates/emmylua_formatter/Cargo.toml +++ b/crates/emmylua_formatter/Cargo.toml @@ -13,6 +13,7 @@ toml_edit.workspace = true smol_str.workspace = true glob.workspace = true walkdir.workspace = true +similar = { version = "2.7.0", features = ["inline"] } [dependencies.clap] workspace = true diff --git a/crates/emmylua_formatter/src/bin/luafmt.rs b/crates/emmylua_formatter/src/bin/luafmt.rs index d4d4333d7..212870e39 100644 --- a/crates/emmylua_formatter/src/bin/luafmt.rs +++ b/crates/emmylua_formatter/src/bin/luafmt.rs @@ -1,13 +1,35 @@ use std::{ fs, - io::{self, Read, Write}, + io::{self, IsTerminal, Read, Write}, process::exit, }; use clap::Parser; use emmylua_formatter::{ - cmd_args, collect_lua_files, default_config_toml, format_file, format_text_for_path, + check_text_for_path, cmd_args, collect_lua_files, default_config_toml, format_file, }; +use similar::{ChangeTag, TextDiff}; + +#[derive(Clone, Copy)] +struct DiffRenderOptions { + use_color: bool, + style: cmd_args::DiffStyle, +} + +impl DiffRenderOptions { + fn marker_mode(self) -> bool { + !self.use_color && matches!(self.style, cmd_args::DiffStyle::Marker) + } +} + +fn render_diff_header_path(path: &str, is_new: bool, style: cmd_args::DiffStyle) -> String { + if matches!(style, cmd_args::DiffStyle::Git) { + let prefix = if is_new { "b/" } else { "a/" }; + return format!("{}{path}", prefix); + } + + path.to_string() +} #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; @@ -18,8 +40,161 @@ fn read_stdin_to_string() -> io::Result { Ok(s) } +fn format_unified_diff( + path: &str, + original: &str, + formatted: &str, + options: DiffRenderOptions, +) -> String { + let diff = TextDiff::from_lines(original, formatted); + let mut out = String::new(); + out.push_str(&colorize( + &format!( + "--- {}", + render_diff_header_path(path, false, options.style) + ), + "1;31", + options.use_color, + )); + out.push('\n'); + out.push_str(&colorize( + &format!("+++ {}", render_diff_header_path(path, true, options.style)), + "1;32", + options.use_color, + )); + out.push('\n'); + + for group in diff.grouped_ops(3) { + let mut old_start_line = None; + let mut old_end_line = None; + let mut new_start_line = None; + let mut new_end_line = None; + let mut body = String::new(); + + for op in group { + for change in diff.iter_inline_changes(&op) { + if old_start_line.is_none() { + old_start_line = change.old_index().map(|index| index + 1); + } + if new_start_line.is_none() { + new_start_line = change.new_index().map(|index| index + 1); + } + if let Some(index) = change.old_index() { + old_end_line = Some(index + 1); + } + if let Some(index) = change.new_index() { + new_end_line = Some(index + 1); + } + + body.push_str(&render_line_prefix(change.tag(), options)); + for (emphasized, value) in change.iter_strings_lossy() { + if emphasized { + body.push_str(&render_emphasized_segment( + change.tag(), + value.as_ref(), + options, + )); + } else { + body.push_str(&render_plain_segment(change.tag(), value.as_ref(), options)); + } + } + if !body.ends_with('\n') { + body.push('\n'); + } + } + } + + out.push_str(&colorize( + &format!( + "@@ -{} +{} @@", + format_hunk_range(old_start_line, old_end_line), + format_hunk_range(new_start_line, new_end_line) + ), + "1;36", + options.use_color, + )); + out.push('\n'); + out.push_str(&body); + } + + out +} + +fn render_line_prefix(tag: ChangeTag, options: DiffRenderOptions) -> String { + let (prefix, color) = match tag { + ChangeTag::Equal => (" ", "0"), + ChangeTag::Delete => ("-", "31"), + ChangeTag::Insert => ("+", "32"), + }; + colorize(prefix, color, options.use_color) +} + +fn render_plain_segment(tag: ChangeTag, text: &str, options: DiffRenderOptions) -> String { + if !options.use_color { + return text.to_string(); + } + + let color = match tag { + ChangeTag::Equal => return text.to_string(), + ChangeTag::Delete => "31", + ChangeTag::Insert => "32", + }; + + colorize(text, color, true) +} + +fn render_emphasized_segment(tag: ChangeTag, text: &str, options: DiffRenderOptions) -> String { + if options.marker_mode() { + return match tag { + ChangeTag::Delete => format!("[-{}-]", text), + ChangeTag::Insert => format!("{{+{}+}}", text), + ChangeTag::Equal => text.to_string(), + }; + } + + let color = match tag { + ChangeTag::Delete => "1;91", + ChangeTag::Insert => "1;92", + ChangeTag::Equal => return text.to_string(), + }; + + colorize(text, color, true) +} + +fn colorize(text: &str, ansi_code: &str, enabled: bool) -> String { + if !enabled || text.is_empty() { + return text.to_string(); + } + + format!("\x1b[{ansi_code}m{text}\x1b[0m") +} + +fn should_use_color(choice: cmd_args::ColorChoice) -> bool { + match choice { + cmd_args::ColorChoice::Auto => io::stderr().is_terminal(), + cmd_args::ColorChoice::Always => true, + cmd_args::ColorChoice::Never => false, + } +} + +fn format_hunk_range(start: Option, end: Option) -> String { + match (start, end) { + (Some(start_line), Some(end_line)) => { + let count = end_line.saturating_sub(start_line) + 1; + format!("{},{}", start_line, count) + } + (Some(start_line), None) => format!("{},0", start_line), + (None, Some(end_line)) => format!("0,{}", end_line), + (None, None) => "0,0".to_string(), + } +} + fn main() { let args = cmd_args::CliArgs::parse(); + let diff_render_options = DiffRenderOptions { + use_color: should_use_color(args.color), + style: args.diff_style, + }; if args.dump_default_config { match default_config_toml() { @@ -47,7 +222,7 @@ fn main() { } }; - let result = match format_text_for_path(&content, None, args.config.as_deref()) { + let result = match check_text_for_path(&content, None, args.config.as_deref()) { Ok(result) => result, Err(err) => { eprintln!("Error: {err}"); @@ -59,6 +234,17 @@ fn main() { if args.check || args.list_different { if changed { exit_code = 1; + if args.check && !args.list_different { + eprint!( + "{}", + format_unified_diff( + "", + &content, + &result.output.formatted, + diff_render_options, + ) + ); + } } } else if let Some(out) = &args.output { if let Err(e) = fs::write(out, result.output.formatted) { @@ -106,32 +292,64 @@ fn main() { let mut different_paths: Vec = Vec::new(); for path in &files { - match format_file(path, args.config.as_deref()) { + let format_result = if args.check || args.list_different { + fs::read_to_string(path) + .map_err(emmylua_formatter::FormatterError::from) + .and_then(|source| { + check_text_for_path(&source, Some(path), args.config.as_deref()).map(|result| { + ( + result.path, + source, + result.output.formatted, + result.output.changed, + ) + }) + }) + } else { + format_file(path, args.config.as_deref()).map(|result| { + ( + result.path, + String::new(), + result.output.formatted, + result.output.changed, + ) + }) + }; + + match format_result { Ok(result) => { - let output = result.output; + let (result_path, source, formatted, changed) = result; if args.check || args.list_different { - if output.changed { + if changed { exit_code = 1; if args.list_different { - different_paths.push(path.to_string_lossy().to_string()); + different_paths.push(result_path.to_string_lossy().to_string()); + } else if args.check { + eprint!( + "{}", + format_unified_diff( + &result_path.to_string_lossy(), + &source, + &formatted, + diff_render_options, + ) + ); } } } else if args.write { - if output.changed - && let Err(e) = fs::write(path, output.formatted) - { + if changed && let Err(e) = fs::write(path, formatted) { eprintln!("Failed to write {}: {e}", path.to_string_lossy()); exit_code = 2; } } else if let Some(out) = &args.output { - if let Err(e) = fs::write(out, output.formatted) { + if let Err(e) = fs::write(out, formatted) { eprintln!("Failed to write output to {out:?}: {e}"); exit(2); } } else { let mut stdout = io::stdout(); - if let Err(e) = stdout.write_all(output.formatted.as_bytes()) { + if let Err(e) = stdout.write_all(formatted.as_bytes()) { eprintln!("Failed to write to stdout: {e}"); exit(2); } @@ -152,3 +370,59 @@ fn main() { exit(exit_code); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_plain_diff_keeps_inline_markers() { + let rendered = format_unified_diff( + "", + "local x=1\n", + "local x = 1\n", + DiffRenderOptions { + use_color: false, + style: cmd_args::DiffStyle::Marker, + }, + ); + + assert!(rendered.contains("[-x=1-]") || rendered.contains("{+x = 1+}")); + assert!(!rendered.contains("\x1b[")); + } + + #[test] + fn test_color_diff_uses_ansi_without_inline_markers() { + let rendered = format_unified_diff( + "", + "local x=1\n", + "local x = 1\n", + DiffRenderOptions { + use_color: true, + style: cmd_args::DiffStyle::Marker, + }, + ); + + assert!(rendered.contains("\x1b[")); + assert!(!rendered.contains("[-")); + assert!(!rendered.contains("{+")); + } + + #[test] + fn test_git_diff_style_uses_prefixed_headers_without_inline_markers() { + let rendered = format_unified_diff( + "src/test.lua", + "local x=1\n", + "local x = 1\n", + DiffRenderOptions { + use_color: false, + style: cmd_args::DiffStyle::Git, + }, + ); + + assert!(rendered.contains("--- a/src/test.lua")); + assert!(rendered.contains("+++ b/src/test.lua")); + assert!(!rendered.contains("[-")); + assert!(!rendered.contains("{+")); + } +} diff --git a/crates/emmylua_formatter/src/cmd_args.rs b/crates/emmylua_formatter/src/cmd_args.rs index 85ab5e98a..211ee66ff 100644 --- a/crates/emmylua_formatter/src/cmd_args.rs +++ b/crates/emmylua_formatter/src/cmd_args.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use clap::{ArgGroup, Parser}; +use clap::{ArgGroup, Parser, ValueEnum}; use crate::{FileCollectorOptions, IndentKind, ResolvedConfig, resolve_config_for_path}; @@ -37,6 +37,14 @@ pub struct CliArgs { #[arg(long, alias = "list-different")] pub list_different: bool, + /// Colorize --check diff output + #[arg(long, value_enum, default_value_t = ColorChoice::Auto)] + pub color: ColorChoice, + + /// Diff rendering style for --check output + #[arg(long, value_enum, default_value_t = DiffStyle::Marker)] + pub diff_style: DiffStyle, + /// Write output to a specific file (only with a single input or stdin) #[arg(short, long, value_name = "FILE", value_hint = clap::ValueHint::FilePath)] pub output: Option, @@ -86,6 +94,21 @@ pub struct CliArgs { pub exclude: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)] +pub enum ColorChoice { + #[default] + Auto, + Always, + Never, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)] +pub enum DiffStyle { + #[default] + Marker, + Git, +} + pub fn resolve_style(args: &CliArgs) -> Result { let mut resolved = resolve_config_for_path( args.paths.first().map(PathBuf::as_path), diff --git a/crates/emmylua_formatter/src/config/mod.rs b/crates/emmylua_formatter/src/config/mod.rs index faaa4db5c..05dd7872d 100644 --- a/crates/emmylua_formatter/src/config/mod.rs +++ b/crates/emmylua_formatter/src/config/mod.rs @@ -39,6 +39,10 @@ impl LuaFormatConfig { self.comments.align_line_comments && self.comments.align_in_table_fields } + pub fn should_align_call_arg_line_comments(&self) -> bool { + self.comments.align_line_comments && self.comments.align_in_call_args + } + pub fn should_align_param_line_comments(&self) -> bool { self.comments.align_line_comments && self.comments.align_in_params } @@ -142,6 +146,7 @@ pub struct CommentConfig { pub align_line_comments: bool, pub align_in_statements: bool, pub align_in_table_fields: bool, + pub align_in_call_args: bool, pub align_in_params: bool, pub align_across_standalone_comments: bool, pub align_same_kind_only: bool, @@ -153,10 +158,11 @@ impl Default for CommentConfig { fn default() -> Self { Self { align_line_comments: true, - align_in_statements: true, + align_in_statements: false, align_in_table_fields: true, + align_in_call_args: true, align_in_params: true, - align_across_standalone_comments: true, + align_across_standalone_comments: false, align_same_kind_only: false, line_comment_min_spaces_before: 1, line_comment_min_column: 0, @@ -196,7 +202,7 @@ pub struct AlignConfig { impl Default for AlignConfig { fn default() -> Self { Self { - continuous_assign_statement: true, + continuous_assign_statement: false, table_field: true, } } diff --git a/crates/emmylua_formatter/src/formatter/comment.rs b/crates/emmylua_formatter/src/formatter/comment.rs index 00c0d1664..7b4a47f17 100644 --- a/crates/emmylua_formatter/src/formatter/comment.rs +++ b/crates/emmylua_formatter/src/formatter/comment.rs @@ -9,6 +9,8 @@ use rowan::TextRange; use crate::config::LuaFormatConfig; use crate::ir::{self, DocIR}; +use super::trivia::has_non_trivia_before_on_same_line; + /// Format a Comment node. /// /// Dispatches between three comment types: @@ -876,6 +878,25 @@ pub fn collect_orphan_comments(config: &LuaFormatConfig, node: &LuaSyntaxNode) - /// Extract a trailing comment on the same line after a syntax node. /// Returns the raw comment docs (NOT wrapped in LineSuffix) and the text range. pub fn extract_trailing_comment(node: &LuaSyntaxNode) -> Option<(Vec, TextRange)> { + for child in node.children() { + if child.kind() != LuaKind::Syntax(LuaSyntaxKind::Comment) + || !has_non_trivia_before_on_same_line(&child) + || has_non_trivia_after_on_same_line(&child) + { + continue; + } + + let comment = LuaComment::cast(child.clone())?; + if child.text().contains_char('\n') { + return None; + } + + let comment_text = render_single_line_comment_text(&comment) + .unwrap_or_else(|| child.text().to_string().trim_end().to_string()); + + return Some((vec![ir::text(comment_text)], child.text_range())); + } + let mut next = node.next_sibling_or_token(); // Look ahead at most 4 elements (skipping whitespace, commas, semicolons) @@ -908,6 +929,27 @@ pub fn extract_trailing_comment(node: &LuaSyntaxNode) -> Option<(Vec, Tex None } +fn has_non_trivia_after_on_same_line(node: &LuaSyntaxNode) -> bool { + let mut next = node.next_sibling_or_token(); + + while let Some(element) = next { + match element.kind() { + LuaKind::Token(LuaTokenKind::TkWhitespace) => { + next = element.next_sibling_or_token(); + } + LuaKind::Token(LuaTokenKind::TkEndOfLine) => { + next = element.next_sibling_or_token(); + } + LuaKind::Syntax(LuaSyntaxKind::Comment) => { + next = element.next_sibling_or_token(); + } + _ => return true, + } + } + + false +} + fn render_single_line_comment_text(comment: &LuaComment) -> Option { match classify_comment(comment) { CommentKind::Long => Some(comment.syntax().text().to_string().trim_end().to_string()), diff --git a/crates/emmylua_formatter/src/formatter/expression.rs b/crates/emmylua_formatter/src/formatter/expression.rs index f590402c9..2202f506d 100644 --- a/crates/emmylua_formatter/src/formatter/expression.rs +++ b/crates/emmylua_formatter/src/formatter/expression.rs @@ -1,8 +1,8 @@ use emmylua_parser::{ BinaryOperator, LuaAstNode, LuaAstToken, LuaBinaryExpr, LuaCallExpr, LuaClosureExpr, LuaComment, LuaExpr, LuaIndexExpr, LuaIndexKey, LuaKind, LuaLiteralExpr, LuaNameExpr, - LuaParenExpr, LuaSingleArgExpr, LuaSyntaxKind, LuaTableExpr, LuaTableField, LuaTokenKind, - LuaUnaryExpr, UnaryOperator, + LuaParenExpr, LuaSingleArgExpr, LuaSyntaxKind, LuaSyntaxNode, LuaTableExpr, LuaTableField, + LuaTokenKind, LuaUnaryExpr, UnaryOperator, }; use rowan::TextRange; @@ -537,12 +537,18 @@ fn format_call_args_ir(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { } => trailing_comment.is_some(), CallArgEntry::StandaloneComment(_) => true, }); + let has_standalone_comments = arg_entries + .iter() + .any(|entry| matches!(entry, CallArgEntry::StandaloneComment(_))); + let align_comments = ctx.config.should_align_call_arg_line_comments() + && !has_standalone_comments + && call_arg_group_requests_alignment(&arg_entries); let trailing = format_trailing_comma_ir(ctx.config.output.trailing_comma.clone()); match ctx.config.layout.call_args_expand { ExpandStrategy::Always => { let inner = if has_comments { - build_multiline_call_arg_entries(ctx, arg_entries) + build_multiline_call_arg_entries(ctx, arg_entries, align_comments) } else { let arg_docs: Vec> = args.iter().map(|a| format_expr(ctx, a)).collect(); @@ -574,7 +580,8 @@ fn format_call_args_ir(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { } ExpandStrategy::Never => { if has_comments { - let inner = build_multiline_call_arg_entries(ctx, arg_entries); + let inner = + build_multiline_call_arg_entries(ctx, arg_entries, align_comments); docs.push(ir::group_break(vec![ tok(LuaTokenKind::TkLeftParen), ir::indent(vec![ir::hard_line(), ir::list(inner), trailing]), @@ -605,13 +612,8 @@ fn format_call_args_ir(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { } ExpandStrategy::Auto => { if has_comments { - let inner = if has_comments { - build_multiline_call_arg_entries(ctx, arg_entries) - } else { - let arg_docs: Vec> = - args.iter().map(|a| format_expr(ctx, a)).collect(); - vec![ir::list(ir::intersperse(arg_docs, comma_soft_line_sep()))] - }; + let inner = + build_multiline_call_arg_entries(ctx, arg_entries, align_comments); docs.push(ir::group_break(vec![ tok(LuaTokenKind::TkLeftParen), ir::indent(vec![ir::hard_line(), ir::list(inner), trailing]), @@ -807,16 +809,26 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { } else { None }; - let trailing_comment = + let align_hint = field_requests_alignment(&field); + let (trailing_comment, comment_align_hint) = if let Some((docs, range)) = extract_trailing_comment(field.syntax()) { consumed_comment_ranges.push(range); - Some(docs) + ( + Some(docs), + trailing_comment_requests_alignment( + field.syntax(), + range, + ctx.config.comments.line_comment_min_spaces_before.max(1), + ), + ) } else { - None + (None, false) }; entries.push(TableEntry::Field { doc: fdoc, eq_split, + align_hint, + comment_align_hint, trailing_comment, }); } else if child.kind() == LuaKind::Syntax(LuaSyntaxKind::Comment) { @@ -1079,12 +1091,177 @@ enum TableEntry { doc: Vec, /// Split at `=` for alignment: (key_docs, eq_value_docs) eq_split: Option, + /// Whether the original source shows an intent to align this field's value. + align_hint: bool, + /// Whether the original source shows an intent to align this field's trailing comment. + comment_align_hint: bool, /// Raw trailing comment docs (NOT wrapped in LineSuffix) trailing_comment: Option>, }, StandaloneComment(Vec), } +fn field_requests_alignment(field: &LuaTableField) -> bool { + if !field.is_assign_field() { + return false; + } + + let Some(value) = field.get_value_expr() else { + return false; + }; + + let Some(assign_token) = field.syntax().children_with_tokens().find_map(|element| { + let token = element.into_token()?; + (token.kind() == LuaTokenKind::TkAssign.into()).then_some(token) + }) else { + return false; + }; + + let field_start = field.syntax().text_range().start(); + let gap_start = usize::from(assign_token.text_range().end() - field_start); + let gap_end = usize::from(value.syntax().text_range().start() - field_start); + if gap_end <= gap_start { + return false; + } + + let text = field.syntax().text().to_string(); + let Some(gap) = text.get(gap_start..gap_end) else { + return false; + }; + + !gap.contains(['\n', '\r']) && gap.chars().filter(|ch| matches!(ch, ' ' | '\t')).count() > 1 +} + +fn table_group_requests_alignment(entries: &[TableEntry]) -> bool { + entries.iter().any(|entry| { + matches!( + entry, + TableEntry::Field { + align_hint: true, + .. + } + ) + }) +} + +fn table_comment_group_requests_alignment(entries: &[TableEntry]) -> bool { + entries.iter().any(|entry| { + matches!( + entry, + TableEntry::Field { + trailing_comment: Some(_), + comment_align_hint: true, + .. + } + ) + }) +} + +fn trailing_comment_padding_for_config( + ctx: &FormatContext, + content_width: usize, + aligned_content_width: usize, +) -> usize { + let natural_padding = aligned_content_width.saturating_sub(content_width) + + ctx.config.comments.line_comment_min_spaces_before.max(1); + + if ctx.config.comments.line_comment_min_column == 0 { + natural_padding + } else { + natural_padding.max( + ctx.config + .comments + .line_comment_min_column + .saturating_sub(content_width), + ) + } +} + +fn trailing_comment_suffix_with_padding(comment_docs: &[DocIR], padding: usize) -> DocIR { + let mut suffix = Vec::new(); + suffix.extend((0..padding).map(|_| ir::space())); + suffix.extend(comment_docs.iter().cloned()); + ir::line_suffix(suffix) +} + +fn aligned_table_comment_widths( + entries: &[TableEntry], + group_start: usize, + group_end: usize, + last_field_idx: Option, + trailing: &DocIR, + max_before: usize, +) -> Vec> { + let mut widths = vec![None; group_end - group_start]; + let mut subgroup_start = group_start; + + while subgroup_start < group_end { + while subgroup_start < group_end + && !matches!( + &entries[subgroup_start], + TableEntry::Field { + trailing_comment: Some(_), + .. + } + ) + { + subgroup_start += 1; + } + + if subgroup_start >= group_end { + break; + } + + let mut subgroup_end = subgroup_start + 1; + while subgroup_end < group_end + && matches!( + &entries[subgroup_end], + TableEntry::Field { + trailing_comment: Some(_), + .. + } + ) + { + subgroup_end += 1; + } + + if table_comment_group_requests_alignment(&entries[subgroup_start..subgroup_end]) { + let mut max_content_width = 0; + + for (index, entry) in entries + .iter() + .enumerate() + .take(subgroup_end) + .skip(subgroup_start) + { + if let TableEntry::Field { + eq_split: Some((_, after)), + .. + } = entry + { + let mut after_with_separator = after.clone(); + if last_field_idx == Some(index) { + after_with_separator.push(trailing.clone()); + } else { + after_with_separator.push(tok(LuaTokenKind::TkComma)); + } + + max_content_width = max_content_width + .max(max_before + 1 + ir::ir_flat_width(&after_with_separator)); + } + } + + for index in subgroup_start..subgroup_end { + widths[index - group_start] = Some(max_content_width); + } + } + + subgroup_start = subgroup_end; + } + + widths +} + /// Build inner content (entries between { and }) for an expanded table. /// When `align_eq` is true and there are consecutive `key = value` fields, /// they are wrapped in an AlignGroup so the Printer aligns their `=` signs. @@ -1118,20 +1295,44 @@ fn build_table_expanded_inner( } => { group_end += 1; } - TableEntry::StandaloneComment(_) => { - group_end += 1; - } _ => break, } } - if group_end - group_start >= 2 { + if group_end - group_start >= 2 + && table_group_requests_alignment(&entries[group_start..group_end]) + { inner.push(ir::hard_line()); + let max_before = entries[group_start..group_end] + .iter() + .filter_map(|entry| match entry { + TableEntry::Field { + eq_split: Some((before, _)), + .. + } => Some(ir::ir_flat_width(before)), + _ => None, + }) + .max() + .unwrap_or(0); + let comment_widths = if align_comments { + aligned_table_comment_widths( + entries, + group_start, + group_end, + last_field_idx, + trailing, + max_before, + ) + } else { + vec![None; group_end - group_start] + }; let mut align_entries = Vec::new(); for (j, entry) in entries.iter().enumerate().take(group_end).skip(group_start) { match entry { TableEntry::Field { eq_split: Some((before, after)), + align_hint: _, + comment_align_hint: _, trailing_comment, .. } => { @@ -1142,24 +1343,34 @@ fn build_table_expanded_inner( } else { after_with_comma.push(tok(LuaTokenKind::TkComma)); } - if align_comments { - align_entries.push(AlignEntry::Aligned { - before: before.clone(), - after: after_with_comma, - trailing: trailing_comment.clone(), - }); - } else { - if let Some(comment_docs) = trailing_comment { + if let Some(comment_docs) = trailing_comment { + if let Some(aligned_content_width) = + comment_widths[j - group_start] + { + let content_width = + max_before + 1 + ir::ir_flat_width(&after_with_comma); + let padding = trailing_comment_padding_for_config( + ctx, + content_width, + aligned_content_width, + ); + after_with_comma.push( + trailing_comment_suffix_with_padding( + comment_docs, + padding, + ), + ); + } else { let mut suffix = trailing_comment_prefix(ctx.config); suffix.extend(comment_docs.clone()); after_with_comma.push(ir::line_suffix(suffix)); } - align_entries.push(AlignEntry::Aligned { - before: before.clone(), - after: after_with_comma, - trailing: None, - }); } + align_entries.push(AlignEntry::Aligned { + before: before.clone(), + after: after_with_comma, + trailing: None, + }); } TableEntry::StandaloneComment(comment_docs) => { align_entries.push(AlignEntry::Line { @@ -1169,6 +1380,8 @@ fn build_table_expanded_inner( } TableEntry::Field { doc, + align_hint: _, + comment_align_hint: _, trailing_comment, .. } => { @@ -1207,6 +1420,8 @@ fn build_table_expanded_inner( match &entries[i] { TableEntry::Field { doc, + align_hint: _, + comment_align_hint: _, trailing_comment, .. } => { @@ -1236,6 +1451,8 @@ fn build_table_expanded_inner( match entry { TableEntry::Field { doc, + align_hint: _, + comment_align_hint: _, trailing_comment, .. } => { @@ -1416,7 +1633,11 @@ fn should_preserve_raw_call_expr(expr: &LuaCallExpr) -> bool { } expr.get_args_list() - .map(|args| node_has_direct_same_line_inline_comment(args.syntax())) + .map(|args| { + node_has_direct_same_line_inline_comment(args.syntax()) + && !args.syntax().text().to_string().starts_with("(\n") + && !args.syntax().text().to_string().starts_with("(\r\n") + }) .unwrap_or(false) } @@ -1434,11 +1655,50 @@ enum CallArgEntry { Arg { doc: Vec, trailing_comment: Option>, + align_hint: bool, has_following_arg: bool, }, StandaloneComment(Vec), } +fn trailing_comment_requests_alignment( + node: &LuaSyntaxNode, + comment_range: TextRange, + required_min_gap: usize, +) -> bool { + let Some(parent) = node.parent() else { + return false; + }; + + let parent_start = parent.text_range().start(); + let gap_start = usize::from(node.text_range().end() - parent_start); + let gap_end = usize::from(comment_range.start() - parent_start); + if gap_end <= gap_start { + return false; + } + + let text = parent.text().to_string(); + let Some(gap) = text.get(gap_start..gap_end) else { + return false; + }; + + !gap.contains(['\n', '\r']) + && gap.chars().filter(|ch| matches!(ch, ' ' | '\t')).count() > required_min_gap +} + +fn call_arg_group_requests_alignment(entries: &[CallArgEntry]) -> bool { + entries.iter().any(|entry| { + matches!( + entry, + CallArgEntry::Arg { + trailing_comment: Some(_), + align_hint: true, + .. + } + ) + }) +} + fn collect_call_arg_entries( ctx: &FormatContext, args_list: &emmylua_parser::LuaCallArgList, @@ -1450,12 +1710,19 @@ fn collect_call_arg_entries( for child in args_list.syntax().children() { if let Some(arg) = LuaExpr::cast(child.clone()) { - let trailing_comment = + let (trailing_comment, align_hint) = if let Some((docs, range)) = extract_trailing_comment(arg.syntax()) { consumed_comment_ranges.push(range); - Some(docs) + ( + Some(docs), + trailing_comment_requests_alignment( + arg.syntax(), + range, + ctx.config.comments.line_comment_min_spaces_before.max(1), + ), + ) } else { - None + (None, false) }; let has_following_arg = arg_index + 1 < args.len(); @@ -1463,6 +1730,7 @@ fn collect_call_arg_entries( entries.push(CallArgEntry::Arg { doc: format_expr(ctx, &arg), trailing_comment, + align_hint, has_following_arg, }); } else if child.kind() == LuaKind::Syntax(LuaSyntaxKind::Comment) @@ -1483,7 +1751,42 @@ fn collect_call_arg_entries( entries } -fn build_multiline_call_arg_entries(ctx: &FormatContext, entries: Vec) -> Vec { +fn build_multiline_call_arg_entries( + ctx: &FormatContext, + entries: Vec, + align_comments: bool, +) -> Vec { + if align_comments { + let mut align_entries = Vec::new(); + + for entry in entries { + match entry { + CallArgEntry::Arg { + mut doc, + trailing_comment, + align_hint: _, + has_following_arg, + } => { + if has_following_arg { + doc.push(tok(LuaTokenKind::TkComma)); + } + align_entries.push(AlignEntry::Line { + content: doc, + trailing: trailing_comment, + }); + } + CallArgEntry::StandaloneComment(comment_docs) => { + align_entries.push(AlignEntry::Line { + content: comment_docs, + trailing: None, + }); + } + } + } + + return vec![ir::align_group(align_entries)]; + } + let mut inner = Vec::new(); for (index, entry) in entries.into_iter().enumerate() { @@ -1495,6 +1798,7 @@ fn build_multiline_call_arg_entries(ctx: &FormatContext, entries: Vec { inner.extend(doc); @@ -1543,12 +1847,16 @@ pub fn format_param_list_ir( .iter() .any(|entry| matches!(entry, ParamEntry::StandaloneComment(_))); - if ctx.config.should_align_param_line_comments() && !has_standalone_comments { + if ctx.config.should_align_param_line_comments() + && !has_standalone_comments + && param_group_requests_alignment(&entries) + { let mut align_entries = Vec::new(); for entry in entries { if let ParamEntry::Param { mut doc, trailing_comment, + align_hint: _, has_following_param, } = entry { @@ -1608,11 +1916,25 @@ enum ParamEntry { Param { doc: Vec, trailing_comment: Option>, + align_hint: bool, has_following_param: bool, }, StandaloneComment(Vec), } +fn param_group_requests_alignment(entries: &[ParamEntry]) -> bool { + entries.iter().any(|entry| { + matches!( + entry, + ParamEntry::Param { + trailing_comment: Some(_), + align_hint: true, + .. + } + ) + }) +} + fn collect_param_entries( ctx: &FormatContext, params: &emmylua_parser::LuaParamList, @@ -1632,12 +1954,19 @@ fn collect_param_entries( continue; }; - let trailing_comment = + let (trailing_comment, align_hint) = if let Some((docs, range)) = extract_trailing_comment(param.syntax()) { consumed_comment_ranges.push(range); - Some(docs) + ( + Some(docs), + trailing_comment_requests_alignment( + param.syntax(), + range, + ctx.config.comments.line_comment_min_spaces_before.max(1), + ), + ) } else { - None + (None, false) }; let has_following_param = param_index + 1 < param_nodes.len(); @@ -1645,6 +1974,7 @@ fn collect_param_entries( entries.push(ParamEntry::Param { doc, trailing_comment, + align_hint, has_following_param, }); } else if child.kind() == LuaKind::Syntax(LuaSyntaxKind::Comment) @@ -1677,6 +2007,7 @@ fn build_multiline_param_entries(ctx: &FormatContext, entries: Vec) ParamEntry::Param { doc, trailing_comment, + align_hint: _, has_following_param, } => { inner.extend(doc); diff --git a/crates/emmylua_formatter/src/formatter/statement.rs b/crates/emmylua_formatter/src/formatter/statement.rs index 7cfafe457..332b986b5 100644 --- a/crates/emmylua_formatter/src/formatter/statement.rs +++ b/crates/emmylua_formatter/src/formatter/statement.rs @@ -10,7 +10,7 @@ use crate::ir::{self, DocIR, EqSplit}; use super::FormatContext; use super::block::format_block; -use super::comment::{collect_orphan_comments, format_comment}; +use super::comment::{collect_orphan_comments, extract_trailing_comment, format_comment}; use super::expression::format_expr; use super::sequence::{ SequenceEntry, comma_entry, render_sequence, sequence_ends_with_comment, sequence_has_comment, @@ -444,18 +444,18 @@ fn format_local_func_stat(ctx: &FormatContext, stat: &LuaLocalFuncStat) -> Vec Vec { let entries = collect_func_stat_header_entries(ctx, stat); - render_function_header_entries(vec![tok(LuaTokenKind::TkFunction)], entries) + format_function_header_entries(vec![tok(LuaTokenKind::TkFunction)], &entries) } fn format_local_func_stat_trivia_aware(ctx: &FormatContext, stat: &LuaLocalFuncStat) -> Vec { let entries = collect_local_func_stat_header_entries(ctx, stat); - render_function_header_entries( + format_function_header_entries( vec![ tok(LuaTokenKind::TkLocal), ir::space(), tok(LuaTokenKind::TkFunction), ], - entries, + &entries, ) } @@ -505,10 +505,25 @@ fn collect_local_func_stat_header_entries( entries } -fn render_function_header_entries( - mut docs: Vec, - entries: Vec, +fn format_function_header_entries( + leading_docs: Vec, + entries: &[FunctionHeaderEntry], ) -> Vec { + if !function_header_has_comment(entries) { + let mut head_docs = vec![ir::space()]; + for entry in entries { + match entry { + FunctionHeaderEntry::Name(name_docs) => head_docs.extend(name_docs.clone()), + FunctionHeaderEntry::Closure(closure_docs) => { + head_docs.extend(closure_docs.clone()) + } + FunctionHeaderEntry::Comment(_) => {} + } + } + return format_keyword_header(leading_docs, head_docs); + } + + let mut docs = leading_docs; let mut prev_was_comment = false; let mut has_seen_header_content = false; @@ -520,7 +535,7 @@ fn render_function_header_entries( } else { docs.push(ir::space()); } - docs.extend(name_docs); + docs.extend(name_docs.clone()); prev_was_comment = false; has_seen_header_content = true; } @@ -530,7 +545,7 @@ fn render_function_header_entries( } else { docs.push(ir::space()); } - docs.extend(comment_docs); + docs.extend(comment_docs.clone()); prev_was_comment = true; has_seen_header_content = true; } @@ -538,7 +553,7 @@ fn render_function_header_entries( if prev_was_comment { docs.push(ir::hard_line()); } - docs.extend(closure_docs); + docs.extend(closure_docs.clone()); prev_was_comment = false; has_seen_header_content = true; } @@ -548,6 +563,12 @@ fn render_function_header_entries( docs } +fn function_header_has_comment(entries: &[FunctionHeaderEntry]) -> bool { + entries + .iter() + .any(|entry| matches!(entry, FunctionHeaderEntry::Comment(_))) +} + /// Single-line function definition: keep single-line output when body is empty /// e.g. `function foo() end` fn format_empty_func_stat(ctx: &FormatContext, stat: &LuaFuncStat) -> Option> { @@ -645,6 +666,10 @@ fn format_if_stat(ctx: &FormatContext, stat: &LuaIfStat) -> Vec { return preserved; } + if should_preserve_raw_if_header_inline_comment(stat) { + return vec![ir::source_node_trimmed(stat.syntax().clone())]; + } + if should_preserve_raw_if_stat_with_comments(stat) { return vec![ir::source_node_trimmed(stat.syntax().clone())]; } @@ -724,12 +749,26 @@ fn should_preserve_raw_if_stat_with_comments(stat: &LuaIfStat) -> bool { text.contains("elseif") && text.contains("--") } +fn should_preserve_raw_if_header_inline_comment(stat: &LuaIfStat) -> bool { + stat.syntax().text().to_string().lines().any(|line| { + line.find("then") + .map(|index| line[index + 4..].contains("--")) + .unwrap_or(false) + }) +} + fn format_if_stat_trivia_aware(ctx: &FormatContext, stat: &LuaIfStat) -> Vec { - let mut docs = format_sequence_control_header( - vec![tok(LuaTokenKind::TkIf)], - &collect_if_clause_entries(ctx, stat.syntax()), - LuaTokenKind::TkThen, - ); + let mut docs = if let Some(raw_header) = + try_format_raw_clause_header_until_block(stat.syntax(), stat.get_block().as_ref()) + { + raw_header + } else { + format_sequence_control_header( + vec![tok(LuaTokenKind::TkIf)], + &collect_if_clause_entries(ctx, stat.syntax()), + LuaTokenKind::TkThen, + ) + }; format_block_or_orphan_comments(ctx, stat.get_block().as_ref(), stat.syntax(), &mut docs); @@ -1655,7 +1694,9 @@ fn should_preserve_raw_statement_with_inline_comments(stat: &LuaStat) -> bool { pub fn is_eq_alignable(stat: &LuaStat) -> bool { match stat { LuaStat::LocalStat(s) => { - if node_has_direct_comment_child(s.syntax()) { + if node_has_direct_comment_child(s.syntax()) + && extract_trailing_comment(s.syntax()).is_none() + { return false; } // Must have values (local x = ...) and no block-like RHS @@ -1670,7 +1711,9 @@ pub fn is_eq_alignable(stat: &LuaStat) -> bool { true } LuaStat::AssignStat(s) => { - if node_has_direct_comment_child(s.syntax()) { + if node_has_direct_comment_child(s.syntax()) + && extract_trailing_comment(s.syntax()).is_none() + { return false; } let (_, exprs) = s.get_var_and_expr_list(); diff --git a/crates/emmylua_formatter/src/lib.rs b/crates/emmylua_formatter/src/lib.rs index 59fdaaa79..a88fcaab6 100644 --- a/crates/emmylua_formatter/src/lib.rs +++ b/crates/emmylua_formatter/src/lib.rs @@ -15,7 +15,8 @@ pub use config::{ LayoutConfig, LuaFormatConfig, OutputConfig, SpacingConfig, TrailingComma, }; pub use workspace::{ - FileCollectorOptions, FormatOutput, FormatPathResult, FormatterError, ResolvedConfig, + ChangedLineRange, FileCollectorOptions, FormatCheckPathResult, FormatCheckResult, FormatOutput, + FormatPathResult, FormatterError, ResolvedConfig, check_file, check_text, check_text_for_path, collect_lua_files, default_config_toml, discover_config_path, format_file, format_text, format_text_for_path, load_format_config, parse_format_config, resolve_config_for_path, }; diff --git a/crates/emmylua_formatter/src/test/breaking_tests.rs b/crates/emmylua_formatter/src/test/breaking_tests.rs index dcd8f2f2a..6342d7843 100644 --- a/crates/emmylua_formatter/src/test/breaking_tests.rs +++ b/crates/emmylua_formatter/src/test/breaking_tests.rs @@ -58,11 +58,11 @@ some_function( "local t = { first_key = 1, second_key = 2, third_key = 3, fourth_key = 4, fifth_key = 5 }\n", r#" local t = { - first_key = 1, + first_key = 1, second_key = 2, - third_key = 3, + third_key = 3, fourth_key = 4, - fifth_key = 5 + fifth_key = 5 } "#, config diff --git a/crates/emmylua_formatter/src/test/comment_tests.rs b/crates/emmylua_formatter/src/test/comment_tests.rs index 628e33d22..277d62f2f 100644 --- a/crates/emmylua_formatter/src/test/comment_tests.rs +++ b/crates/emmylua_formatter/src/test/comment_tests.rs @@ -286,6 +286,42 @@ foo( ); } + #[test] + fn test_call_arg_comments_stay_unaligned_without_alignment_signal() { + assert_format!( + r#" +foo( + a, -- first + long_name -- second +) +"#, + r#" +foo( + a, -- first + long_name -- second +) +"# + ); + } + + #[test] + fn test_call_arg_comments_align_when_input_has_alignment_signal() { + assert_format!( + r#" +foo( + a, -- first + long_name -- second +) +"#, + r#" +foo( + a, -- first + long_name -- second +) +"# + ); + } + #[test] fn test_closure_param_comments() { assert_format!( @@ -308,11 +344,48 @@ end ); } + #[test] + fn test_function_param_comments_stay_unaligned_without_alignment_signal() { + assert_format!( + r#" +function foo( + a, -- first + long_name -- second +) + return a +end +"#, + r#" +function foo( + a, -- first + long_name -- second +) + return a +end +"# + ); + } + // ========== alignment ========== #[test] fn test_trailing_comment_alignment() { - assert_format!( + use crate::{assert_format_with_config, config::LuaFormatConfig}; + + let config = LuaFormatConfig { + comments: crate::config::CommentConfig { + align_in_statements: true, + align_across_standalone_comments: true, + ..Default::default() + }, + align: crate::config::AlignConfig { + continuous_assign_statement: true, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( r#" local a = 1 -- short local bbb = 2 -- long var @@ -322,13 +395,24 @@ local cc = 3 -- medium local a = 1 -- short local bbb = 2 -- long var local cc = 3 -- medium -"# +"#, + config ); } #[test] fn test_assign_alignment() { - assert_format!( + use crate::{assert_format_with_config, config::LuaFormatConfig}; + + let config = LuaFormatConfig { + align: crate::config::AlignConfig { + continuous_assign_statement: true, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( r#" local x = 1 local yy = 2 @@ -338,7 +422,8 @@ local zzz = 3 local x = 1 local yy = 2 local zzz = 3 -"# +"#, + config ); } @@ -360,7 +445,7 @@ local zzz = 3 r#" local t = { x = 1, - long_name = 2, + long_name = 2, yy = 3, } "#, @@ -392,7 +477,7 @@ local t = { }; assert_format_with_config!( - "local t = { x = 1, long_name = 2, yy = 3 }\n", + "local t = { x = 1, long_name = 2, yy = 3 }\n", r#" local t = { x = 1, @@ -518,7 +603,7 @@ end r#" local t = { x = 100, -- first - long_name = 2, -- second + long_name = 2, -- second } "#, r#" @@ -531,6 +616,44 @@ local t = { ); } + #[test] + fn test_table_comment_alignment_uses_contiguous_subgroups() { + use crate::{ + assert_format_with_config, + config::{LayoutConfig, LuaFormatConfig}, + }; + + let config = LuaFormatConfig { + layout: LayoutConfig { + table_expand: crate::config::ExpandStrategy::Always, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + r#" +local t = { + a = "very very long", -- first + b = 2, -- second + c = 3, + d = 4, -- third + e = 5 -- fourth +} +"#, + r#" +local t = { + a = "very very long", -- first + b = 2, -- second + c = 3, + d = 4, -- third + e = 5 -- fourth +} +"#, + config + ); + } + #[test] fn test_line_comment_min_spaces_before() { use crate::{assert_format_with_config, config::LuaFormatConfig}; @@ -560,6 +683,8 @@ local t = { ..Default::default() }, comments: crate::config::CommentConfig { + align_in_statements: true, + align_across_standalone_comments: true, line_comment_min_column: 16, ..Default::default() }, @@ -580,7 +705,22 @@ local bb = 2 -- y #[test] fn test_alignment_group_broken_by_blank_line() { - assert_format!( + use crate::{assert_format_with_config, config::LuaFormatConfig}; + + let config = LuaFormatConfig { + comments: crate::config::CommentConfig { + align_in_statements: true, + align_across_standalone_comments: true, + ..Default::default() + }, + align: crate::config::AlignConfig { + continuous_assign_statement: true, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( r#" local a = 1 -- x local b = 2 -- y @@ -594,13 +734,29 @@ local b = 2 -- y local cc = 3 -- z local d = 4 -- w -"# +"#, + config ); } #[test] fn test_alignment_group_preserves_standalone_comment() { - assert_format!( + use crate::{assert_format_with_config, config::LuaFormatConfig}; + + let config = LuaFormatConfig { + comments: crate::config::CommentConfig { + align_in_statements: true, + align_across_standalone_comments: true, + ..Default::default() + }, + align: crate::config::AlignConfig { + continuous_assign_statement: true, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( r#" local a = 1 -- x -- divider @@ -610,7 +766,8 @@ local long_name = 2 -- y local a = 1 -- x -- divider local long_name = 2 -- y -"# +"#, + config ); } @@ -620,9 +777,14 @@ local long_name = 2 -- y let config = LuaFormatConfig { comments: crate::config::CommentConfig { + align_in_statements: true, align_across_standalone_comments: false, ..Default::default() }, + align: crate::config::AlignConfig { + continuous_assign_statement: true, + ..Default::default() + }, ..Default::default() }; assert_format_with_config!( @@ -650,6 +812,7 @@ local long_name = 2 -- y ..Default::default() }, comments: crate::config::CommentConfig { + align_in_statements: true, align_same_kind_only: true, ..Default::default() }, @@ -668,6 +831,40 @@ bbbb = 2 -- y ); } + #[test] + fn test_table_field_without_alignment_signal_stays_unaligned() { + use crate::{ + assert_format_with_config, + config::{LayoutConfig, LuaFormatConfig}, + }; + + let config = LuaFormatConfig { + layout: LayoutConfig { + table_expand: crate::config::ExpandStrategy::Always, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + r#" +local t = { + x = 1, + long_name = 2, + yy = 3, +} +"#, + r#" +local t = { + x = 1, + long_name = 2, + yy = 3 +} +"#, + config + ); + } + // ========== doc comment formatting ========== #[test] diff --git a/crates/emmylua_formatter/src/test/statement_tests.rs b/crates/emmylua_formatter/src/test/statement_tests.rs index 102fba45d..da6c6d9b3 100644 --- a/crates/emmylua_formatter/src/test/statement_tests.rs +++ b/crates/emmylua_formatter/src/test/statement_tests.rs @@ -55,6 +55,14 @@ end ); } + #[test] + fn test_if_stat_preserves_inline_comment_after_then() { + assert_format!( + "if ok then -- keep header note\n print(1)\nend\n", + "if ok then -- keep header note\n print(1)\nend\n" + ); + } + #[test] fn test_elseif_stat_preserves_standalone_comment_before_then() { assert_format!( @@ -718,6 +726,14 @@ end ); } + #[test] + fn test_function_stat_preserves_comment_before_params_with_method_name() { + assert_format!( + "function module.subsystem:build\n-- separator\n(first, second)\n return first + second\nend\n", + "function module.subsystem:build\n-- separator\n(first, second)\n return first + second\nend\n" + ); + } + #[test] fn test_single_line_if_near_width_limit_prefers_expanded_layout() { let config = LuaFormatConfig { diff --git a/crates/emmylua_formatter/src/workspace.rs b/crates/emmylua_formatter/src/workspace.rs index fecb2dda3..9f0290657 100644 --- a/crates/emmylua_formatter/src/workspace.rs +++ b/crates/emmylua_formatter/src/workspace.rs @@ -27,6 +27,19 @@ pub struct FormatOutput { pub changed: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FormatCheckResult { + pub formatted: String, + pub changed: bool, + pub changed_line_ranges: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChangedLineRange { + pub start_line: usize, + pub end_line: usize, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct FormatPathResult { pub path: PathBuf, @@ -34,6 +47,13 @@ pub struct FormatPathResult { pub config_path: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FormatCheckPathResult { + pub path: PathBuf, + pub output: FormatCheckResult, + pub config_path: Option, +} + #[derive(Debug, Clone)] pub struct FileCollectorOptions { pub recursive: bool, @@ -112,13 +132,30 @@ impl From for FormatterError { } pub fn format_text(code: &str, config: &LuaFormatConfig) -> FormatOutput { + let check = check_text(code, config); + FormatOutput { + formatted: check.formatted, + changed: check.changed, + } +} + +pub fn check_text(code: &str, config: &LuaFormatConfig) -> FormatCheckResult { let source = crate::SourceText { text: code, level: LuaLanguageLevel::default(), }; let formatted = reformat_lua_code(&source, config); let changed = formatted != code; - FormatOutput { formatted, changed } + let changed_line_ranges = if changed { + collect_changed_line_ranges(code, &formatted) + } else { + Vec::new() + }; + FormatCheckResult { + formatted, + changed, + changed_line_ranges, + } } pub fn format_text_for_path( @@ -126,9 +163,25 @@ pub fn format_text_for_path( source_path: Option<&Path>, explicit_config_path: Option<&Path>, ) -> Result { - let resolved = resolve_config_for_path(source_path, explicit_config_path)?; - let output = format_text(code, &resolved.config); + let result = check_text_for_path(code, source_path, explicit_config_path)?; Ok(FormatPathResult { + path: result.path, + output: FormatOutput { + formatted: result.output.formatted, + changed: result.output.changed, + }, + config_path: result.config_path, + }) +} + +pub fn check_text_for_path( + code: &str, + source_path: Option<&Path>, + explicit_config_path: Option<&Path>, +) -> Result { + let resolved = resolve_config_for_path(source_path, explicit_config_path)?; + let output = check_text(code, &resolved.config); + Ok(FormatCheckPathResult { path: source_path .unwrap_or_else(|| Path::new("")) .to_path_buf(), @@ -141,10 +194,25 @@ pub fn format_file( path: &Path, explicit_config_path: Option<&Path>, ) -> Result { + let result = check_file(path, explicit_config_path)?; + Ok(FormatPathResult { + path: result.path, + output: FormatOutput { + formatted: result.output.formatted, + changed: result.output.changed, + }, + config_path: result.config_path, + }) +} + +pub fn check_file( + path: &Path, + explicit_config_path: Option<&Path>, +) -> Result { let source = fs::read_to_string(path)?; let resolved = resolve_config_for_path(Some(path), explicit_config_path)?; - let output = format_text(&source, &resolved.config); - Ok(FormatPathResult { + let output = check_text(&source, &resolved.config); + Ok(FormatCheckPathResult { path: path.to_path_buf(), output, config_path: resolved.source_path, @@ -445,6 +513,39 @@ fn parse_ignore_file(content: &str) -> Vec { .collect() } +fn collect_changed_line_ranges(original: &str, formatted: &str) -> Vec { + let original_lines: Vec<&str> = original.lines().collect(); + let formatted_lines: Vec<&str> = formatted.lines().collect(); + let max_len = original_lines.len().max(formatted_lines.len()); + + let mut ranges = Vec::new(); + let mut current_start: Option = None; + + for index in 0..max_len { + let original_line = original_lines.get(index).copied(); + let formatted_line = formatted_lines.get(index).copied(); + if original_line != formatted_line { + if current_start.is_none() { + current_start = Some(index + 1); + } + } else if let Some(start_line) = current_start.take() { + ranges.push(ChangedLineRange { + start_line, + end_line: index, + }); + } + } + + if let Some(start_line) = current_start { + ranges.push(ChangedLineRange { + start_line, + end_line: max_len.max(start_line), + }); + } + + ranges +} + #[cfg(test)] mod tests { use std::time::{SystemTime, UNIX_EPOCH}; @@ -521,4 +622,51 @@ mod tests { assert_eq!(resolved.source_path, Some(root.join(".luafmt.toml"))); fs::remove_dir_all(root).unwrap(); } + + #[test] + fn test_check_text_reports_formatted_output_and_changed_flag() { + let config = LuaFormatConfig::default(); + + let result = check_text("local x=1\n", &config); + + assert!(result.changed); + assert_eq!(result.formatted, "local x = 1\n"); + assert_eq!(result.changed_line_ranges.len(), 1); + assert_eq!(result.changed_line_ranges[0].start_line, 1); + assert_eq!(result.changed_line_ranges[0].end_line, 1); + } + + #[test] + fn test_check_text_for_path_uses_discovered_config() { + let root = make_temp_dir("luafmt-check-config"); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join(".luafmt.toml"), "[layout]\nmax_line_width = 10\n").unwrap(); + let file_path = root.join("src").join("main.lua"); + fs::write(&file_path, "call(alpha, beta, gamma)\n").unwrap(); + + let result = check_file(&file_path, None).unwrap(); + + assert!(result.output.changed); + assert_eq!(result.config_path, Some(root.join(".luafmt.toml"))); + assert!(result.output.formatted.contains("\n")); + assert!(!result.output.changed_line_ranges.is_empty()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn test_check_text_collects_multiple_changed_line_ranges() { + let ranges = collect_changed_line_ranges( + "local a=1\nlocal b=2\nprint(a+b)\n", + "local a = 1\nlocal b = 2\nprint(a + b)\n", + ); + + assert_eq!(ranges.len(), 1); + assert_eq!( + ranges[0], + ChangedLineRange { + start_line: 1, + end_line: 3 + } + ); + } } From dc751f5669632b7571ababe4d67629d0ae584109 Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Sun, 22 Mar 2026 21:50:02 +0800 Subject: [PATCH 09/10] update --- README.md | 5 + crates/emmylua_formatter/README.md | 136 +++-- .../src/formatter/expression.rs | 503 ++++++++++++++---- .../src/formatter/sequence.rs | 467 ++++++++++++++++ .../src/formatter/statement.rs | 167 +++++- .../src/test/expression_tests.rs | 17 + .../src/test/statement_tests.rs | 38 +- docs/emmylua_formatter/README_CN.md | 56 ++ docs/emmylua_formatter/README_EN.md | 50 ++ docs/emmylua_formatter/examples_CN.md | 119 +++++ docs/emmylua_formatter/examples_EN.md | 119 +++++ docs/emmylua_formatter/options_CN.md | 177 ++++++ docs/emmylua_formatter/options_EN.md | 177 ++++++ docs/emmylua_formatter/profiles_CN.md | 122 +++++ docs/emmylua_formatter/profiles_EN.md | 122 +++++ docs/emmylua_formatter/tutorial_CN.md | 140 +++++ docs/emmylua_formatter/tutorial_EN.md | 140 +++++ 17 files changed, 2416 insertions(+), 139 deletions(-) create mode 100644 docs/emmylua_formatter/README_CN.md create mode 100644 docs/emmylua_formatter/README_EN.md create mode 100644 docs/emmylua_formatter/examples_CN.md create mode 100644 docs/emmylua_formatter/examples_EN.md create mode 100644 docs/emmylua_formatter/options_CN.md create mode 100644 docs/emmylua_formatter/options_EN.md create mode 100644 docs/emmylua_formatter/profiles_CN.md create mode 100644 docs/emmylua_formatter/profiles_EN.md create mode 100644 docs/emmylua_formatter/tutorial_CN.md create mode 100644 docs/emmylua_formatter/tutorial_EN.md diff --git a/README.md b/README.md index 89e6eb9b3..6d93feaf7 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,11 @@ EmmyLua Analyzer Rust implements the standard LSP protocol, making it compatible - [📖 **Features Guide**](./docs/features/features_EN.md) - Comprehensive feature documentation - [⚙️ **Configuration**](./docs/config/emmyrc_json_EN.md) - Advanced configuration options +- [🧾 **Formatter Guide**](./docs/emmylua_formatter/README_EN.md) - Formatter behavior, options, and usage guide +- [🖼️ **Formatter Examples**](./docs/emmylua_formatter/examples_EN.md) - Before-and-after formatting examples +- [🛠️ **Formatter Options**](./docs/emmylua_formatter/options_EN.md) - Formatter configuration reference +- [📐 **Formatter Profiles**](./docs/emmylua_formatter/profiles_EN.md) - Recommended formatter configurations for common styles +- [📚 **Formatter Tutorial**](./docs/emmylua_formatter/tutorial_EN.md) - Practical formatting workflow and examples - [📝 **Annotations Reference**](./docs/emmylua_doc/annotations_EN/README.md) - Detailed annotation documentation - [🎨 **Code Style**](https://github.com/CppCXY/EmmyLuaCodeStyle/blob/master/README_EN.md) - Formatting and style guidelines - [🛠️ **External Formatter Integration**](./docs/external_format/external_formatter_options_EN.md) - Using external formatters diff --git a/crates/emmylua_formatter/README.md b/crates/emmylua_formatter/README.md index b5f2ff594..342e35d3f 100644 --- a/crates/emmylua_formatter/README.md +++ b/crates/emmylua_formatter/README.md @@ -1,41 +1,96 @@ # EmmyLua Formatter -EmmyLua Formatter is an experimental Lua/EmmyLua formatter built on a DocIR-style pipeline: +EmmyLua Formatter is the structured Lua and EmmyLua formatter in the EmmyLua Analyzer Rust workspace. It is designed for deterministic output, conservative comment handling, and width-aware layout decisions that remain stable under repeated formatting. -- parse source into syntax nodes -- convert syntax into formatting IR -- print IR back to text with width-aware layout decisions +The formatter pipeline is built in three stages: -The crate already supports practical formatting for statements, expressions, tables, comments, and a growing subset of EmmyLua doc tags. +1. Parse source text into syntax nodes. +2. Lower syntax into DocIR. +3. Print DocIR back to text with configurable layout selection. -Trivia-aware formatter redesign notes are documented in `TRIVIA_FORMATTING_DESIGN.md`. +The current implementation covers statements, expressions, table literals, chained calls, binary-expression chains, trailing comments, and a practical subset of EmmyLua doc tags. -## Current Focus +Sequence-layout redesign notes are documented in `SEQUENCE_LAYOUT_DESIGN.md`. -Recent work has concentrated on formatter stability and configurability, especially around alignment-sensitive output: +## Design Goals -- trailing line comment alignment with per-scope switches -- assignment spacing control -- shebang preservation -- EmmyLua doc-tag normalization and alignment -- conservative fallback for complex doc-tag syntax +The formatter currently prioritizes the following properties: + +- stable formatting for repeated runs +- conservative preservation around comments and ambiguous syntax +- width-aware packing before fully expanded one-item-per-line output +- configuration that is narrow in scope and predictable in effect + +Recent layout work introduced candidate-based selection for sequence-like constructs. Instead of committing to a single hard-coded broken layout, the formatter can compare fill, packed, aligned, and one-per-line candidates and choose the best result for the active width. + +## Layout Behavior + +The formatter now uses candidate selection in several important paths: + +- call arguments +- function parameters +- table fields +- binary-expression chains +- statement expression lists used by `return`, assignment right-hand sides, and loop headers + +In practice this means the formatter can prefer: + +- a flat layout when everything fits +- progressive fill when a compact multi-line layout is sufficient +- a more balanced packed layout when it avoids ragged trailing lines +- one-item-per-line expansion only when the narrower layouts are clearly worse + +Comment-sensitive paths remain conservative. Standalone comments still block aggressive repacking, and trailing line comment alignment only activates when the input already shows alignment intent. + +## Configuration Overview + +The public formatter configuration is exposed through `LuaFormatConfig`: + +- `indent` +- `layout` +- `output` +- `spacing` +- `comments` +- `emmy_doc` +- `align` + +Key defaults: + +- `layout.max_line_width = 120` +- `layout.table_expand = "Auto"` +- `layout.call_args_expand = "Auto"` +- `layout.func_params_expand = "Auto"` +- `output.trailing_comma = "Never"` +- `comments.align_in_statements = false` +- `align.continuous_assign_statement = false` +- `align.table_field = true` + +These defaults intentionally favor conservative rewrites. Alignment-heavy output is not enabled broadly unless the source already indicates that alignment should be preserved. ## Comment Alignment -Trailing line comments are configured under `LuaFormatConfig.comments`: +Trailing line comment behavior is configured under `LuaFormatConfig.comments`: - `align_line_comments` - `align_in_statements` - `align_in_table_fields` +- `align_in_call_args` - `align_in_params` - `align_across_standalone_comments` - `align_same_kind_only` - `line_comment_min_spaces_before` - `line_comment_min_column` +Current alignment rules are intentionally scoped: + +- statement alignment is disabled by default +- call-arg, parameter, and table-field alignment only activate when the input already contains extra spacing that signals alignment intent +- standalone comments break alignment groups by default +- table comment alignment is limited to contiguous subgroups rather than the entire table body + ## EmmyLua Doc Tags -The formatter currently has structured handling for: +Structured handling currently exists for: - `@param` - `@field` @@ -46,7 +101,7 @@ The formatter currently has structured handling for: - `@generic` - `@overload` -Alignment behavior is controlled under `LuaFormatConfig.emmy_doc`: +Doc-tag behavior is controlled under `LuaFormatConfig.emmy_doc`: - `align_tag_columns` - `align_declaration_tags` @@ -60,18 +115,21 @@ Notes: - reference tags are `@param`, `@field`, `@return` - `@alias` keeps its original single-line body text and only participates in description-column alignment - `space_after_description_dash` controls whether plain doc lines render as `--- text` or `---text` -- multiline or complex doc-tag forms fall back to raw preservation instead of risky rewriting +- multiline or complex doc-tag forms fall back to raw preservation instead of speculative rewriting -## luafmt +## CLI -The CLI now supports: +The `luafmt` binary supports: - `--config ` with `toml`, `json`, `yml`, or `yaml` - automatic discovery of `.luafmt.toml` or `luafmt.toml` -- `--dump-default-config` to print a starter TOML config +- `--dump-default-config` - recursive directory input -- `--include` / `--exclude` glob filters -- `.luafmtignore` support for batch formatting +- `--include` and `--exclude` glob filters +- `.luafmtignore` +- `--check` and `--list-different` +- `--color auto|always|never` +- `--diff-style marker|git` Typical usage: @@ -83,12 +141,14 @@ luafmt game --list-different ## Library API -The crate now exposes workspace-friendly helpers so the language server or other callers do not need to shell out to `luafmt`: +The crate exposes workspace-friendly helpers so callers do not need to shell out to `luafmt`: -- `resolve_config_for_path` to load the nearest formatter config for a file -- `format_text_for_path` to format in-memory text with path-based config discovery -- `format_file` to format a file directly -- `collect_lua_files` to gather `lua` and `luau` files from directories with ignore support +- `resolve_config_for_path` +- `format_text_for_path` +- `check_text_for_path` +- `format_file` +- `check_file` +- `collect_lua_files` Example: @@ -101,10 +161,23 @@ let source_path = Path::new("workspace/scripts/main.lua"); let resolved = resolve_config_for_path(Some(source_path), None)?; let result = format_text_for_path("local x=1\n", Some(source_path), None)?; -assert_eq!(resolved.source_path.is_some(), true); +assert!(resolved.source_path.is_some()); assert!(result.output.changed); ``` +## Documentation + +Additional formatter documentation is available in the workspace docs directory: + +- `../../docs/emmylua_formatter/README_EN.md` +- `../../docs/emmylua_formatter/examples_EN.md` +- `../../docs/emmylua_formatter/options_EN.md` +- `../../docs/emmylua_formatter/profiles_EN.md` +- `../../docs/emmylua_formatter/tutorial_EN.md` + +The examples page is the best place to review actual before-and-after output for tables, call arguments, binary chains, and statement expression lists. + + ## Example Config ```toml @@ -136,10 +209,11 @@ space_around_assign_operator = true [comments] align_line_comments = true -align_in_statements = true +align_in_statements = false align_in_table_fields = true +align_in_call_args = true align_in_params = true -align_across_standalone_comments = true +align_across_standalone_comments = false align_same_kind_only = false line_comment_min_spaces_before = 1 line_comment_min_column = 0 @@ -152,6 +226,6 @@ tag_spacing = 1 space_after_description_dash = true [align] -continuous_assign_statement = true +continuous_assign_statement = false table_field = true ``` diff --git a/crates/emmylua_formatter/src/formatter/expression.rs b/crates/emmylua_formatter/src/formatter/expression.rs index 2202f506d..1300cd1d0 100644 --- a/crates/emmylua_formatter/src/formatter/expression.rs +++ b/crates/emmylua_formatter/src/formatter/expression.rs @@ -12,8 +12,10 @@ use crate::ir::{self, AlignEntry, DocIR, EqSplit}; use super::FormatContext; use super::comment::{extract_trailing_comment, format_comment, trailing_comment_prefix}; use super::sequence::{ - DelimitedSequenceLayout, SequenceEntry, format_delimited_sequence, render_sequence, - sequence_ends_with_comment, sequence_has_comment, sequence_starts_with_comment, + DelimitedSequenceLayout, SequenceEntry, SequenceLayoutCandidates, SequenceLayoutPolicy, + choose_sequence_break_contents, choose_sequence_layout, format_delimited_sequence, + render_sequence, sequence_ends_with_comment, sequence_has_comment, + sequence_starts_with_comment, }; use super::spacing::{SpaceRule, space_around_assign, space_around_binary_op}; use super::tokens::{comma_soft_line_sep, comma_space_sep, tok}; @@ -235,26 +237,90 @@ fn try_format_flat_binary_chain(ctx: &FormatContext, expr: &LuaBinaryExpr) -> Op return None; } + let fill_parts = build_binary_chain_fill_parts(ctx, &operands, op_token.syntax().clone(), op); + let packed = build_binary_chain_packed(ctx, &operands, op_token.syntax().clone(), op); + let one_per_line = + build_binary_chain_one_per_line(ctx, &operands, op_token.syntax().clone(), op); + + Some(choose_sequence_layout( + ctx, + SequenceLayoutCandidates { + fill: Some(vec![ir::group(vec![ir::indent(vec![ir::fill( + fill_parts, + )])])]), + packed: Some(packed), + one_per_line: Some(one_per_line), + ..Default::default() + }, + SequenceLayoutPolicy { + allow_alignment: false, + allow_fill: true, + allow_preserve: false, + prefer_preserve_multiline: false, + force_break_on_standalone_comments: false, + prefer_balanced_break_lines: true, + first_line_prefix_width: source_line_prefix_width(expr.syntax()), + }, + )) +} + +fn source_line_prefix_width(node: &LuaSyntaxNode) -> usize { + let mut root = node.clone(); + while let Some(parent) = root.parent() { + root = parent; + } + + let text = root.text().to_string(); + let start = usize::from(node.text_range().start()); + let line_start = text[..start] + .rfind(['\n', '\r']) + .map(|index| index + 1) + .unwrap_or(0); + + start.saturating_sub(line_start) +} + +fn build_binary_chain_segment( + ctx: &FormatContext, + previous: &LuaExpr, + operand: &LuaExpr, + op_token: &emmylua_parser::LuaSyntaxToken, + op: BinaryOperator, +) -> (bool, Vec) { let space_rule = space_around_binary_op(op, ctx.config); let space_ir = space_rule.to_ir(); + let force_space_before = op == BinaryOperator::OpConcat + && space_rule == SpaceRule::NoSpace + && expr_end_with_float(previous); + let mut segment = Vec::new(); + segment.push(ir::source_token(op_token.clone())); + segment.push(space_ir); + segment.extend(format_expr(ctx, operand)); + + ( + force_space_before || space_rule != SpaceRule::NoSpace, + segment, + ) +} + +fn build_binary_chain_fill_parts( + ctx: &FormatContext, + operands: &[LuaExpr], + op_token: emmylua_parser::LuaSyntaxToken, + op: BinaryOperator, +) -> Vec { let mut fill_parts = Vec::new(); let mut previous = &operands[0]; let first_operand = format_expr(ctx, &operands[0]); let mut first_chunk = first_operand; for (index, operand) in operands.iter().skip(1).enumerate() { - let force_space_before = op == BinaryOperator::OpConcat - && space_rule == SpaceRule::NoSpace - && expr_end_with_float(previous); - let break_ir = - continuation_break_ir(force_space_before || space_rule != SpaceRule::NoSpace); - let mut segment = Vec::new(); - segment.push(ir::source_token(op_token.syntax().clone())); - segment.push(space_ir.clone()); - segment.extend(format_expr(ctx, operand)); + let (space_before_segment, segment) = + build_binary_chain_segment(ctx, previous, operand, &op_token, op); + let break_ir = continuation_break_ir(space_before_segment); if index == 0 { - if force_space_before || space_rule != SpaceRule::NoSpace { + if space_before_segment { first_chunk.push(ir::space()); } first_chunk.extend(segment); @@ -267,9 +333,73 @@ fn try_format_flat_binary_chain(ctx: &FormatContext, expr: &LuaBinaryExpr) -> Op previous = operand; } - Some(vec![ir::group(vec![ir::indent(vec![ir::fill( - fill_parts, - )])])]) + fill_parts +} + +fn build_binary_chain_packed( + ctx: &FormatContext, + operands: &[LuaExpr], + op_token: emmylua_parser::LuaSyntaxToken, + op: BinaryOperator, +) -> Vec { + let mut docs = Vec::new(); + let mut previous = &operands[0]; + let mut first_line = format_expr(ctx, &operands[0]); + let mut tail_segments = Vec::new(); + + for (index, operand) in operands.iter().skip(1).enumerate() { + let (space_before_segment, segment) = + build_binary_chain_segment(ctx, previous, operand, &op_token, op); + if index == 0 { + if space_before_segment { + first_line.push(ir::space()); + } + first_line.extend(segment); + } else { + tail_segments.push((space_before_segment, segment)); + } + previous = operand; + } + + docs.push(ir::list(first_line)); + + for chunk in tail_segments.chunks(2) { + let mut line = Vec::new(); + for (index, (space_before_segment, segment)) in chunk.iter().enumerate() { + if index > 0 { + if *space_before_segment { + line.push(ir::space()); + } + } + line.extend(segment.clone()); + } + + docs.push(ir::hard_line()); + docs.push(ir::list(line)); + } + + vec![ir::group_break(vec![ir::indent(docs)])] +} + +fn build_binary_chain_one_per_line( + ctx: &FormatContext, + operands: &[LuaExpr], + op_token: emmylua_parser::LuaSyntaxToken, + op: BinaryOperator, +) -> Vec { + let mut docs = format_expr(ctx, &operands[0]); + let mut previous = &operands[0]; + + for operand in operands.iter().skip(1) { + let (space_before_segment, segment) = + build_binary_chain_segment(ctx, previous, operand, &op_token, op); + let break_ir = continuation_break_ir(space_before_segment); + docs.push(break_ir); + docs.extend(segment); + previous = operand; + } + + vec![ir::group_break(vec![ir::indent(docs)])] } fn collect_binary_chain_operands(expr: &LuaExpr, op: BinaryOperator, operands: &mut Vec) { @@ -612,14 +742,13 @@ fn format_call_args_ir(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { } ExpandStrategy::Auto => { if has_comments { - let inner = - build_multiline_call_arg_entries(ctx, arg_entries, align_comments); - docs.push(ir::group_break(vec![ - tok(LuaTokenKind::TkLeftParen), - ir::indent(vec![ir::hard_line(), ir::list(inner), trailing]), - ir::hard_line(), - tok(LuaTokenKind::TkRightParen), - ])); + docs.extend(format_call_args_multiline_candidates( + ctx, + arg_entries, + trailing, + align_comments, + has_standalone_comments, + )); } else { let arg_docs: Vec> = args.iter().map(|a| format_expr(ctx, a)).collect(); @@ -871,9 +1000,14 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { let force_expand = has_standalone_comments || has_trailing_comments; match ctx.config.layout.table_expand { - ExpandStrategy::Always => { - build_table_expanded(ctx, entries, trailing, true, ctx.config.align.table_field) - } + ExpandStrategy::Always => format_table_multiline_candidates( + ctx, + entries, + trailing, + ctx.config.align.table_field, + true, + has_standalone_comments, + ), ExpandStrategy::Never if !force_expand => { format_delimited_sequence(DelimitedSequenceLayout { open: tok(LuaTokenKind::TkLeftBrace), @@ -909,11 +1043,25 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { } ExpandStrategy::Never => { // Never mode but has comments — must expand - build_table_expanded(ctx, entries, trailing, true, ctx.config.align.table_field) + format_table_multiline_candidates( + ctx, + entries, + trailing, + ctx.config.align.table_field, + true, + has_standalone_comments, + ) } ExpandStrategy::Auto if force_expand => { // Has comments: force expand - build_table_expanded(ctx, entries, trailing, true, ctx.config.align.table_field) + format_table_multiline_candidates( + ctx, + entries, + trailing, + ctx.config.align.table_field, + true, + has_standalone_comments, + ) } ExpandStrategy::Auto => { if ctx.config.align.table_field @@ -941,6 +1089,25 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { true, ctx.config.should_align_table_line_comments(), ); + let plain_break_inner = + build_table_expanded_inner(ctx, &entries, &trailing, false, false); + let break_inner = choose_sequence_break_contents( + ctx, + SequenceLayoutCandidates { + aligned: Some(break_inner), + one_per_line: Some(plain_break_inner), + ..Default::default() + }, + SequenceLayoutPolicy { + allow_alignment: true, + allow_fill: false, + allow_preserve: false, + prefer_preserve_multiline: true, + force_break_on_standalone_comments: has_standalone_comments, + prefer_balanced_break_lines: false, + first_line_prefix_width: 0, + }, + ); format_delimited_sequence(DelimitedSequenceLayout { open: tok(LuaTokenKind::TkLeftBrace), close: tok(LuaTokenKind::TkRightBrace), @@ -1003,6 +1170,51 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { } } +fn format_table_multiline_candidates( + ctx: &FormatContext, + entries: Vec, + trailing: DocIR, + align_eq: bool, + should_break: bool, + has_standalone_comments: bool, +) -> Vec { + let align_comments = ctx.config.should_align_table_line_comments(); + let aligned = align_eq.then(|| { + wrap_multiline_table_docs(build_table_expanded_inner( + ctx, + &entries, + &trailing, + true, + align_comments, + )) + }); + let one_per_line = Some(wrap_multiline_table_docs(build_table_expanded_inner( + ctx, &entries, &trailing, false, false, + ))); + + if should_break { + choose_sequence_layout( + ctx, + SequenceLayoutCandidates { + aligned, + one_per_line, + ..Default::default() + }, + SequenceLayoutPolicy { + allow_alignment: align_eq, + allow_fill: false, + allow_preserve: false, + prefer_preserve_multiline: true, + force_break_on_standalone_comments: has_standalone_comments, + prefer_balanced_break_lines: false, + first_line_prefix_width: 0, + }, + ) + } else { + aligned.or(one_per_line).unwrap_or_default() + } +} + fn continuation_break_ir(flat_space: bool) -> DocIR { if flat_space { ir::soft_line() @@ -1483,39 +1695,6 @@ fn build_table_expanded_inner( inner } -/// Build expanded table (one field per line), wrapped in a Group. -fn build_table_expanded( - ctx: &FormatContext, - entries: Vec, - trailing: DocIR, - should_break: bool, - align_eq: bool, -) -> Vec { - let inner = build_table_expanded_inner( - ctx, - &entries, - &trailing, - align_eq, - ctx.config.should_align_table_line_comments(), - ); - - if should_break { - vec![ir::group_break(vec![ - tok(LuaTokenKind::TkLeftBrace), - ir::indent(inner), - ir::hard_line(), - tok(LuaTokenKind::TkRightBrace), - ])] - } else { - vec![ir::group(vec![ - tok(LuaTokenKind::TkLeftBrace), - ir::indent(inner), - ir::hard_line(), - tok(LuaTokenKind::TkRightBrace), - ])] - } -} - /// 匿名函数: function(params) ... end fn format_closure_expr(ctx: &FormatContext, expr: &LuaClosureExpr) -> Vec { if should_preserve_raw_closure_expr(expr) { @@ -1661,6 +1840,71 @@ enum CallArgEntry { StandaloneComment(Vec), } +impl Clone for CallArgEntry { + fn clone(&self) -> Self { + match self { + Self::Arg { + doc, + trailing_comment, + align_hint, + has_following_arg, + } => Self::Arg { + doc: doc.clone(), + trailing_comment: trailing_comment.clone(), + align_hint: *align_hint, + has_following_arg: *has_following_arg, + }, + Self::StandaloneComment(comment_docs) => Self::StandaloneComment(comment_docs.clone()), + } + } +} + +fn wrap_multiline_call_arg_docs(inner: Vec, trailing: DocIR) -> Vec { + vec![ir::group_break(vec![ + tok(LuaTokenKind::TkLeftParen), + ir::indent(vec![ir::hard_line(), ir::list(inner), trailing]), + ir::hard_line(), + tok(LuaTokenKind::TkRightParen), + ])] +} + +fn format_call_args_multiline_candidates( + ctx: &FormatContext, + entries: Vec, + trailing: DocIR, + align_comments: bool, + has_standalone_comments: bool, +) -> Vec { + let aligned = align_comments.then(|| { + wrap_multiline_call_arg_docs( + build_multiline_call_arg_entries(ctx, entries.clone(), true), + trailing.clone(), + ) + }); + let one_per_line = Some(wrap_multiline_call_arg_docs( + build_multiline_call_arg_entries(ctx, entries, false), + trailing, + )); + + choose_sequence_layout( + ctx, + SequenceLayoutCandidates { + aligned, + one_per_line, + ..Default::default() + }, + SequenceLayoutPolicy { + allow_alignment: align_comments, + allow_fill: false, + allow_preserve: false, + prefer_preserve_multiline: true, + force_break_on_standalone_comments: has_standalone_comments, + prefer_balanced_break_lines: false, + first_line_prefix_width: 0, + }, + ) +} + fn trailing_comment_requests_alignment( node: &LuaSyntaxNode, comment_range: TextRange, @@ -1846,44 +2090,17 @@ pub fn format_param_list_ir( let has_standalone_comments = entries .iter() .any(|entry| matches!(entry, ParamEntry::StandaloneComment(_))); - - if ctx.config.should_align_param_line_comments() + let align_comments = ctx.config.should_align_param_line_comments() && !has_standalone_comments - && param_group_requests_alignment(&entries) - { - let mut align_entries = Vec::new(); - for entry in entries { - if let ParamEntry::Param { - mut doc, - trailing_comment, - align_hint: _, - has_following_param, - } = entry - { - if has_following_param { - doc.push(tok(LuaTokenKind::TkComma)); - } - align_entries.push(AlignEntry::Line { - content: doc, - trailing: trailing_comment, - }); - } - } - vec![ir::group_break(vec![ - tok(LuaTokenKind::TkLeftParen), - ir::indent(vec![ir::hard_line(), ir::align_group(align_entries)]), - ir::hard_line(), - tok(LuaTokenKind::TkRightParen), - ])] - } else { - let inner = build_multiline_param_entries(ctx, entries); - vec![ir::group_break(vec![ - tok(LuaTokenKind::TkLeftParen), - ir::indent(vec![ir::hard_line(), ir::list(inner)]), - ir::hard_line(), - tok(LuaTokenKind::TkRightParen), - ])] - } + && param_group_requests_alignment(&entries); + + format_param_multiline_candidates( + ctx, + entries, + format_trailing_comma_ir(ctx.config.output.trailing_comma.clone()), + align_comments, + has_standalone_comments, + ) } else { let param_docs: Vec> = entries .into_iter() @@ -1922,6 +2139,96 @@ enum ParamEntry { StandaloneComment(Vec), } +impl Clone for ParamEntry { + fn clone(&self) -> Self { + match self { + Self::Param { + doc, + trailing_comment, + align_hint, + has_following_param, + } => Self::Param { + doc: doc.clone(), + trailing_comment: trailing_comment.clone(), + align_hint: *align_hint, + has_following_param: *has_following_param, + }, + Self::StandaloneComment(comment_docs) => Self::StandaloneComment(comment_docs.clone()), + } + } +} + +fn wrap_multiline_param_docs(inner: Vec, trailing: DocIR) -> Vec { + vec![ir::group_break(vec![ + tok(LuaTokenKind::TkLeftParen), + ir::indent(vec![ir::hard_line(), ir::list(inner), trailing]), + ir::hard_line(), + tok(LuaTokenKind::TkRightParen), + ])] +} + +fn format_param_multiline_candidates( + ctx: &FormatContext, + entries: Vec, + trailing: DocIR, + align_comments: bool, + has_standalone_comments: bool, +) -> Vec { + let aligned = align_comments.then(|| { + let mut align_entries = Vec::new(); + for entry in entries.clone() { + if let ParamEntry::Param { + mut doc, + trailing_comment, + align_hint: _, + has_following_param, + } = entry + { + if has_following_param { + doc.push(tok(LuaTokenKind::TkComma)); + } + align_entries.push(AlignEntry::Line { + content: doc, + trailing: trailing_comment, + }); + } + } + + wrap_multiline_param_docs(vec![ir::align_group(align_entries)], trailing.clone()) + }); + let one_per_line = Some(wrap_multiline_param_docs( + build_multiline_param_entries(ctx, entries), + trailing, + )); + + choose_sequence_layout( + ctx, + SequenceLayoutCandidates { + aligned, + one_per_line, + ..Default::default() + }, + SequenceLayoutPolicy { + allow_alignment: align_comments, + allow_fill: false, + allow_preserve: false, + prefer_preserve_multiline: true, + force_break_on_standalone_comments: has_standalone_comments, + prefer_balanced_break_lines: false, + first_line_prefix_width: 0, + }, + ) +} + +fn wrap_multiline_table_docs(inner: Vec) -> Vec { + vec![ir::group_break(vec![ + tok(LuaTokenKind::TkLeftBrace), + ir::indent(inner), + ir::hard_line(), + tok(LuaTokenKind::TkRightBrace), + ])] +} + fn param_group_requests_alignment(entries: &[ParamEntry]) -> bool { entries.iter().any(|entry| { matches!( diff --git a/crates/emmylua_formatter/src/formatter/sequence.rs b/crates/emmylua_formatter/src/formatter/sequence.rs index 8c5ebb914..848e9807d 100644 --- a/crates/emmylua_formatter/src/formatter/sequence.rs +++ b/crates/emmylua_formatter/src/formatter/sequence.rs @@ -2,6 +2,9 @@ use emmylua_parser::LuaTokenKind; use crate::config::ExpandStrategy; use crate::ir::{self, DocIR}; +use crate::printer::Printer; + +use super::FormatContext; #[derive(Clone)] pub enum SequenceEntry { @@ -84,6 +87,258 @@ pub struct DelimitedSequenceLayout { pub prefer_custom_break_in_auto: bool, } +#[derive(Clone, Default)] +pub struct SequenceLayoutCandidates { + pub flat: Option>, + pub fill: Option>, + pub packed: Option>, + pub one_per_line: Option>, + pub aligned: Option>, + pub preserve: Option>, +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum SequenceLayoutKind { + Flat, + Fill, + Packed, + Aligned, + OnePerLine, + Preserve, +} + +#[derive(Clone)] +struct RankedSequenceCandidate { + kind: SequenceLayoutKind, + docs: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct SequenceCandidateScore { + overflow_penalty: usize, + line_count: usize, + line_balance_penalty: usize, + kind_penalty: usize, + widest_line_slack: usize, +} + +#[derive(Clone, Copy, Default)] +pub struct SequenceLayoutPolicy { + pub allow_alignment: bool, + pub allow_fill: bool, + pub allow_preserve: bool, + pub prefer_preserve_multiline: bool, + pub force_break_on_standalone_comments: bool, + pub prefer_balanced_break_lines: bool, + pub first_line_prefix_width: usize, +} + +pub fn choose_sequence_layout( + ctx: &FormatContext, + candidates: SequenceLayoutCandidates, + policy: SequenceLayoutPolicy, +) -> Vec { + let ordered = ordered_sequence_candidates(candidates, policy); + + if ordered.is_empty() { + return vec![]; + } + + choose_best_sequence_candidate(ctx, ordered, policy) +} + +pub fn choose_sequence_break_contents( + ctx: &FormatContext, + candidates: SequenceLayoutCandidates, + policy: SequenceLayoutPolicy, +) -> Vec { + let ordered = ordered_sequence_candidates(candidates, policy); + + if ordered.is_empty() { + return vec![]; + } + + choose_best_sequence_candidate(ctx, ordered, policy) +} + +fn ordered_sequence_candidates( + candidates: SequenceLayoutCandidates, + policy: SequenceLayoutPolicy, +) -> Vec { + let mut ordered = Vec::new(); + + if policy.prefer_preserve_multiline { + if let Some(packed) = candidates.packed.clone() { + ordered.push(RankedSequenceCandidate { + kind: SequenceLayoutKind::Packed, + docs: packed, + }); + } + if policy.allow_alignment + && let Some(aligned) = candidates.aligned.clone() + { + ordered.push(RankedSequenceCandidate { + kind: SequenceLayoutKind::Aligned, + docs: aligned, + }); + } + if let Some(one_per_line) = candidates.one_per_line.clone() { + ordered.push(RankedSequenceCandidate { + kind: SequenceLayoutKind::OnePerLine, + docs: one_per_line, + }); + } + push_flat_and_fill_candidates( + &mut ordered, + candidates.flat.clone(), + candidates.fill.clone(), + policy, + ); + } else { + push_flat_and_fill_candidates( + &mut ordered, + candidates.flat.clone(), + candidates.fill.clone(), + policy, + ); + if let Some(packed) = candidates.packed.clone() { + ordered.push(RankedSequenceCandidate { + kind: SequenceLayoutKind::Packed, + docs: packed, + }); + } + if policy.allow_alignment + && let Some(aligned) = candidates.aligned.clone() + { + ordered.push(RankedSequenceCandidate { + kind: SequenceLayoutKind::Aligned, + docs: aligned, + }); + } + if let Some(one_per_line) = candidates.one_per_line.clone() { + ordered.push(RankedSequenceCandidate { + kind: SequenceLayoutKind::OnePerLine, + docs: one_per_line, + }); + } + } + + if policy.allow_preserve + && let Some(preserve) = candidates.preserve + { + ordered.push(RankedSequenceCandidate { + kind: SequenceLayoutKind::Preserve, + docs: preserve, + }); + } + + ordered +} + +fn push_flat_and_fill_candidates( + ordered: &mut Vec, + flat: Option>, + fill: Option>, + policy: SequenceLayoutPolicy, +) { + if policy.force_break_on_standalone_comments { + return; + } + + if let Some(flat) = flat { + ordered.push(RankedSequenceCandidate { + kind: SequenceLayoutKind::Flat, + docs: flat, + }); + } + + if policy.allow_fill + && let Some(fill) = fill + { + ordered.push(RankedSequenceCandidate { + kind: SequenceLayoutKind::Fill, + docs: fill, + }); + } +} + +fn choose_best_sequence_candidate( + ctx: &FormatContext, + candidates: Vec, + policy: SequenceLayoutPolicy, +) -> Vec { + let mut best_docs = None; + let mut best_score = None; + + for candidate in candidates { + let score = score_sequence_candidate(ctx, candidate.kind, &candidate.docs, policy); + if best_score.is_none_or(|current| score < current) { + best_score = Some(score); + best_docs = Some(candidate.docs); + } + } + + best_docs.unwrap_or_default() +} + +fn score_sequence_candidate( + ctx: &FormatContext, + kind: SequenceLayoutKind, + docs: &[DocIR], + policy: SequenceLayoutPolicy, +) -> SequenceCandidateScore { + let rendered = Printer::new(ctx.config).print(docs); + let mut line_count = 0usize; + let mut overflow_penalty = 0usize; + let mut widest_line_width = 0usize; + let mut narrowest_line_width = usize::MAX; + + for line in rendered.lines() { + line_count += 1; + let mut line_width = line.len(); + if line_count == 1 { + line_width += policy.first_line_prefix_width; + } + widest_line_width = widest_line_width.max(line_width); + narrowest_line_width = narrowest_line_width.min(line_width); + if line_width > ctx.config.layout.max_line_width { + overflow_penalty += line_width - ctx.config.layout.max_line_width; + } + } + + if line_count == 0 { + line_count = 1; + narrowest_line_width = 0; + } + + SequenceCandidateScore { + overflow_penalty, + line_count, + line_balance_penalty: if policy.prefer_balanced_break_lines { + widest_line_width.saturating_sub(narrowest_line_width) + } else { + 0 + }, + kind_penalty: sequence_layout_kind_penalty(kind), + widest_line_slack: ctx + .config + .layout + .max_line_width + .saturating_sub(widest_line_width.min(ctx.config.layout.max_line_width)), + } +} + +fn sequence_layout_kind_penalty(kind: SequenceLayoutKind) -> usize { + match kind { + SequenceLayoutKind::Flat => 0, + SequenceLayoutKind::Fill => 1, + SequenceLayoutKind::Packed => 2, + SequenceLayoutKind::Aligned => 3, + SequenceLayoutKind::OnePerLine => 4, + SequenceLayoutKind::Preserve => 10, + } +} + pub fn format_delimited_sequence(layout: DelimitedSequenceLayout) -> Vec { if layout.items.is_empty() { return vec![layout.open, layout.close]; @@ -138,6 +393,218 @@ pub fn format_delimited_sequence(layout: DelimitedSequenceLayout) -> Vec } } +#[cfg(test)] +mod tests { + use super::{ + FormatContext, SequenceLayoutCandidates, SequenceLayoutKind, SequenceLayoutPolicy, + choose_sequence_layout, score_sequence_candidate, + }; + use crate::{ + config::{LayoutConfig, LuaFormatConfig}, + ir, + printer::Printer, + }; + + fn render(config: &LuaFormatConfig, docs: &[crate::ir::DocIR]) -> String { + Printer::new(config).print(docs) + } + + #[test] + fn test_score_prefers_wider_line_when_other_metrics_tie() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 20, + ..Default::default() + }, + ..Default::default() + }; + let ctx = FormatContext::new(&config); + + let wider = vec![ir::list(vec![ + ir::text("alpha beta gamma"), + ir::hard_line(), + ir::text("delta"), + ])]; + let narrower = vec![ir::list(vec![ + ir::text("alpha beta"), + ir::hard_line(), + ir::text("gamma delta"), + ])]; + + let wider_score = score_sequence_candidate( + &ctx, + SequenceLayoutKind::OnePerLine, + &wider, + SequenceLayoutPolicy::default(), + ); + let narrower_score = score_sequence_candidate( + &ctx, + SequenceLayoutKind::OnePerLine, + &narrower, + SequenceLayoutPolicy::default(), + ); + + assert!(wider_score < narrower_score); + } + + #[test] + fn test_selector_prefers_fill_over_one_per_line_when_both_fit() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 18, + ..Default::default() + }, + ..Default::default() + }; + let ctx = FormatContext::new(&config); + + let selected = choose_sequence_layout( + &ctx, + SequenceLayoutCandidates { + fill: Some(vec![ir::list(vec![ + ir::text("alpha"), + ir::text(", "), + ir::text("beta"), + ir::hard_line(), + ir::text("gamma"), + ])]), + one_per_line: Some(vec![ir::list(vec![ + ir::text("alpha"), + ir::hard_line(), + ir::text("beta"), + ir::hard_line(), + ir::text("gamma"), + ])]), + ..Default::default() + }, + SequenceLayoutPolicy { + allow_fill: true, + ..Default::default() + }, + ); + + assert_eq!(render(&config, &selected), "alpha, beta\ngamma"); + } + + #[test] + fn test_selector_prefers_non_overflowing_break_candidate() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 12, + ..Default::default() + }, + ..Default::default() + }; + let ctx = FormatContext::new(&config); + + let selected = choose_sequence_layout( + &ctx, + SequenceLayoutCandidates { + fill: Some(vec![ir::text("alpha_beta_gamma")]), + one_per_line: Some(vec![ir::list(vec![ + ir::text("alpha"), + ir::hard_line(), + ir::text("beta"), + ])]), + ..Default::default() + }, + SequenceLayoutPolicy { + allow_fill: true, + ..Default::default() + }, + ); + + assert_eq!(render(&config, &selected), "alpha\nbeta"); + } + + #[test] + fn test_selector_prefers_balanced_packed_layout_when_line_count_ties() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 28, + ..Default::default() + }, + ..Default::default() + }; + let ctx = FormatContext::new(&config); + + let selected = choose_sequence_layout( + &ctx, + SequenceLayoutCandidates { + fill: Some(vec![ir::list(vec![ + ir::text("aaaa + bbbb"), + ir::hard_line(), + ir::text("cccc + dddd + eeee"), + ir::hard_line(), + ir::text("ffff"), + ])]), + packed: Some(vec![ir::list(vec![ + ir::text("aaaa + bbbb"), + ir::hard_line(), + ir::text("cccc + dddd"), + ir::hard_line(), + ir::text("eeee + ffff"), + ])]), + ..Default::default() + }, + SequenceLayoutPolicy { + allow_fill: true, + prefer_balanced_break_lines: true, + ..Default::default() + }, + ); + + assert_eq!( + render(&config, &selected), + "aaaa + bbbb\ncccc + dddd\neeee + ffff" + ); + } + + #[test] + fn test_prefix_width_can_change_selected_candidate() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 28, + ..Default::default() + }, + ..Default::default() + }; + let ctx = FormatContext::new(&config); + + let selected = choose_sequence_layout( + &ctx, + SequenceLayoutCandidates { + fill: Some(vec![ir::list(vec![ + ir::text("aaaa + bbbb"), + ir::hard_line(), + ir::text("+ cccc + dddd + eeee"), + ir::hard_line(), + ir::text("+ ffff"), + ])]), + packed: Some(vec![ir::list(vec![ + ir::text("aaaa + bbbb"), + ir::hard_line(), + ir::text("+ cccc + dddd"), + ir::hard_line(), + ir::text("+ eeee + ffff"), + ])]), + ..Default::default() + }, + SequenceLayoutPolicy { + allow_fill: true, + prefer_balanced_break_lines: true, + first_line_prefix_width: 14, + ..Default::default() + }, + ); + + assert_eq!( + render(&config, &selected), + "aaaa + bbbb\n+ cccc + dddd\n+ eeee + ffff" + ); + } +} + fn format_expanded_delimited_sequence(open: DocIR, close: DocIR, inner: Vec) -> Vec { vec![ir::group_break(vec![ open, diff --git a/crates/emmylua_formatter/src/formatter/statement.rs b/crates/emmylua_formatter/src/formatter/statement.rs index 332b986b5..cf5a2b026 100644 --- a/crates/emmylua_formatter/src/formatter/statement.rs +++ b/crates/emmylua_formatter/src/formatter/statement.rs @@ -13,7 +13,8 @@ use super::block::format_block; use super::comment::{collect_orphan_comments, extract_trailing_comment, format_comment}; use super::expression::format_expr; use super::sequence::{ - SequenceEntry, comma_entry, render_sequence, sequence_ends_with_comment, sequence_has_comment, + SequenceEntry, SequenceLayoutCandidates, SequenceLayoutPolicy, choose_sequence_layout, + comma_entry, render_sequence, sequence_ends_with_comment, sequence_has_comment, sequence_starts_with_comment, }; use super::spacing::space_around_assign; @@ -99,7 +100,16 @@ fn format_local_stat(ctx: &FormatContext, stat: &LuaLocalStat) -> Vec { } else { vec![] }; - docs.extend(format_statement_expr_list(leading_docs, expr_docs)); + let prefix_width = exprs + .first() + .map(|expr| source_line_prefix_width(expr.syntax())) + .unwrap_or(0); + docs.extend(format_statement_expr_list( + ctx, + leading_docs, + expr_docs, + prefix_width, + )); } } @@ -148,7 +158,16 @@ fn format_assign_stat(ctx: &FormatContext, stat: &LuaAssignStat) -> Vec { } else { vec![] }; - docs.extend(format_statement_expr_list(leading_docs, expr_docs)); + let prefix_width = exprs + .first() + .map(|expr| source_line_prefix_width(expr.syntax())) + .unwrap_or(0); + docs.extend(format_statement_expr_list( + ctx, + leading_docs, + expr_docs, + prefix_width, + )); } docs @@ -975,7 +994,16 @@ fn format_for_stat(ctx: &FormatContext, stat: &LuaForStat) -> Vec { let iter_exprs: Vec<_> = stat.get_iter_expr().collect(); let iter_docs: Vec> = iter_exprs.iter().map(|e| format_expr(ctx, e)).collect(); - head_docs.extend(format_statement_expr_list(vec![ir::space()], iter_docs)); + let prefix_width = iter_exprs + .first() + .map(|expr| source_line_prefix_width(expr.syntax())) + .unwrap_or(0); + head_docs.extend(format_statement_expr_list( + ctx, + vec![ir::space()], + iter_docs, + prefix_width, + )); let mut docs = format_control_header(LuaTokenKind::TkFor, head_docs, LuaTokenKind::TkDo); @@ -1017,7 +1045,16 @@ fn format_for_range_stat(ctx: &FormatContext, stat: &LuaForRangeStat) -> Vec = stat.get_expr_list().collect(); let expr_docs: Vec> = expr_list.iter().map(|e| format_expr(ctx, e)).collect(); - head_docs.extend(format_statement_expr_list(vec![ir::space()], expr_docs)); + let prefix_width = expr_list + .first() + .map(|expr| source_line_prefix_width(expr.syntax())) + .unwrap_or(0); + head_docs.extend(format_statement_expr_list( + ctx, + vec![ir::space()], + expr_docs, + prefix_width, + )); let mut docs = format_control_header(LuaTokenKind::TkFor, head_docs, LuaTokenKind::TkDo); @@ -1299,7 +1336,16 @@ fn format_return_stat(ctx: &FormatContext, stat: &LuaReturnStat) -> Vec { docs.push(ir::space()); docs.push(ir::list(expr_docs.into_iter().next().unwrap_or_default())); } else { - docs.extend(format_statement_expr_list(vec![ir::space()], expr_docs)); + let prefix_width = exprs + .first() + .map(|expr| source_line_prefix_width(expr.syntax())) + .unwrap_or(0); + docs.extend(format_statement_expr_list( + ctx, + vec![ir::space()], + expr_docs, + prefix_width, + )); } } @@ -1351,7 +1397,12 @@ fn collect_return_stat_entries(ctx: &FormatContext, stat: &LuaReturnStat) -> Vec entries } -fn format_statement_expr_list(leading_docs: Vec, expr_docs: Vec>) -> Vec { +fn format_statement_expr_list( + ctx: &FormatContext, + leading_docs: Vec, + expr_docs: Vec>, + first_line_prefix_width: usize, +) -> Vec { if expr_docs.is_empty() { return Vec::new(); } @@ -1362,6 +1413,52 @@ fn format_statement_expr_list(leading_docs: Vec, expr_docs: Vec usize { + let mut root = node.clone(); + while let Some(parent) = root.parent() { + root = parent; + } + + let text = root.text().to_string(); + let start = usize::from(node.text_range().start()); + let line_start = text[..start] + .rfind(['\n', '\r']) + .map(|index| index + 1) + .unwrap_or(0); + + start.saturating_sub(line_start) +} + +fn build_statement_expr_fill_parts( + leading_docs: Vec, + expr_docs: Vec>, +) -> Vec { let mut parts = Vec::with_capacity(expr_docs.len().saturating_mul(2)); let mut expr_docs = expr_docs.into_iter(); let mut first_chunk = leading_docs; @@ -1373,7 +1470,61 @@ fn format_statement_expr_list(leading_docs: Vec, expr_docs: Vec, + expr_docs: Vec>, +) -> Vec { + let mut docs = Vec::new(); + let mut expr_docs = expr_docs.into_iter(); + let mut first_chunk = leading_docs; + first_chunk.extend(expr_docs.next().unwrap_or_default()); + docs.push(ir::list(first_chunk)); + + for expr_doc in expr_docs { + docs.push(ir::list(vec![tok(LuaTokenKind::TkComma)])); + docs.push(ir::hard_line()); + docs.push(ir::list(expr_doc)); + } + + vec![ir::group_break(vec![ir::indent(docs)])] +} + +fn build_statement_expr_packed(leading_docs: Vec, expr_docs: Vec>) -> Vec { + let mut docs = Vec::new(); + let mut expr_docs = expr_docs.into_iter().peekable(); + let mut first_chunk = leading_docs; + first_chunk.extend(expr_docs.next().unwrap_or_default()); + if expr_docs.peek().is_some() { + first_chunk.push(tok(LuaTokenKind::TkComma)); + } + docs.push(ir::list(first_chunk)); + + let mut remaining = Vec::new(); + while let Some(expr_doc) = expr_docs.next() { + let has_more = expr_docs.peek().is_some(); + remaining.push((expr_doc, has_more)); + } + + for chunk in remaining.chunks(2) { + let mut line = Vec::new(); + for (index, (expr_doc, has_more)) in chunk.iter().enumerate() { + if index > 0 { + line.push(ir::space()); + } + line.extend(expr_doc.clone()); + if *has_more { + line.push(tok(LuaTokenKind::TkComma)); + } + } + + docs.push(ir::hard_line()); + docs.push(ir::list(line)); + } + + vec![ir::group_break(vec![ir::indent(docs)])] } fn format_control_header( diff --git a/crates/emmylua_formatter/src/test/expression_tests.rs b/crates/emmylua_formatter/src/test/expression_tests.rs index 25e77c10b..4ff53505a 100644 --- a/crates/emmylua_formatter/src/test/expression_tests.rs +++ b/crates/emmylua_formatter/src/test/expression_tests.rs @@ -83,6 +83,23 @@ local e = #t ); } + #[test] + fn test_binary_chain_prefers_balanced_packed_layout() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 28, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "local value = aaaa + bbbb + cccc + dddd + eeee + ffff\n", + "local value = aaaa + bbbb\n + cccc + dddd\n + eeee + ffff\n", + config + ); + } + // ========== index ========== #[test] diff --git a/crates/emmylua_formatter/src/test/statement_tests.rs b/crates/emmylua_formatter/src/test/statement_tests.rs index da6c6d9b3..7b2519025 100644 --- a/crates/emmylua_formatter/src/test/statement_tests.rs +++ b/crates/emmylua_formatter/src/test/statement_tests.rs @@ -153,7 +153,7 @@ end assert_format_with_config!( "if alpha_beta_gamma + delta_theta + epsilon + zeta then\n print(result)\nend\n", - "if alpha_beta_gamma + delta_theta + epsilon\n + zeta then\n print(result)\nend\n", + "if alpha_beta_gamma + delta_theta\n + epsilon + zeta then\n print(result)\nend\n", config ); } @@ -220,7 +220,7 @@ end assert_format_with_config!( "for i = very_long_start_expr, very_long_stop_expr, very_long_step_expr do\n print(i)\nend\n", - "for i = very_long_start_expr, very_long_stop_expr,\n very_long_step_expr do\n print(i)\nend\n", + "for i = very_long_start_expr,\n very_long_stop_expr, very_long_step_expr do\n print(i)\nend\n", config ); } @@ -242,6 +242,23 @@ end ); } + #[test] + fn test_for_range_header_prefers_balanced_packed_expr_list() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 44, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "for key, value in first_long_expr, second_long_expr, third_long_expr, fourth_long_expr, fifth_long_expr do\n print(key, value)\nend\n", + "for key, value in first_long_expr,\n second_long_expr, third_long_expr,\n fourth_long_expr, fifth_long_expr do\n print(key, value)\nend\n", + config + ); + } + // ========== while / repeat / do ========== #[test] @@ -518,6 +535,23 @@ end ); } + #[test] + fn test_assign_expr_list_prefers_balanced_packed_layout_with_long_prefix() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 44, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "very_long_result_name = first_long_expr, second_long_expr, third_long_expr, fourth_long_expr, fifth_long_expr\n", + "very_long_result_name = first_long_expr,\n second_long_expr, third_long_expr,\n fourth_long_expr, fifth_long_expr\n", + config + ); + } + #[test] fn test_return_keeps_first_expr_on_keyword_line_when_breaking() { let config = LuaFormatConfig { diff --git a/docs/emmylua_formatter/README_CN.md b/docs/emmylua_formatter/README_CN.md new file mode 100644 index 000000000..1556adccd --- /dev/null +++ b/docs/emmylua_formatter/README_CN.md @@ -0,0 +1,56 @@ +# EmmyLua Formatter 文档索引 + +[English](./README_EN.md) + +本文档是 EmmyLua Formatter 文档目录的入口,用于说明格式化器的目标、布局行为、配置模型,以及推荐的阅读顺序。 + +## 范围 + +格式化器当前负责以下内容: + +- Lua 与 EmmyLua 源码格式化 +- 基于行宽的换行决策 +- 受控的尾随注释对齐 +- EmmyLua 文档标签的规范化与对齐 +- CLI 与库 API 两种使用方式 + +格式化器在注释和语法歧义附近采取保守策略。当重写存在风险时,会优先保持结构稳定,而不是强行追求更激进的美化结果。 + +## 文档导航 + +- [格式化效果示例](./examples_CN.md):常见格式化决策的前后对比例子 +- [格式化选项](./options_CN.md):配置分组、默认值,以及每个选项影响的行为 +- [推荐配置方案](./profiles_CN.md):面向不同团队风格的建议配置 +- [格式化教程](./tutorial_CN.md):配置方式、CLI 工作流、以及常见前后对比示例 + +## 布局模型 + +近期的格式化器工作引入了面向序列结构的候选布局选择机制,覆盖以下场景: + +- 调用参数 +- 函数参数 +- 表字段 +- 二元表达式链 +- 赋值右侧、`return`、循环头部等语句表达式列表 + +对于这些结构,格式化器可以在多种候选布局之间进行比较: + +- 单行布局 +- progressive fill 紧凑换行 +- 更均衡的 packed 布局 +- 一项一行布局 +- 在输入已经体现对齐意图时启用的 aligned 布局 + +最终结果不是由固定优先级硬编码决定,而是先渲染候选结果,再比较是否溢出、总行数、目标场景的行均衡度、样式偏好以及剩余行宽。 + +## 推荐阅读顺序 + +如果你是第一次使用 formatter: + +1. 先读 [格式化教程](./tutorial_CN.md),了解安装、配置发现规则和日常用法。 +2. 需要调节行为时,再读 [格式化选项](./options_CN.md)。 + +如果你是在做工具集成: + +1. 先看 `crates/emmylua_formatter/README.md`。 +2. 再把 [格式化选项](./options_CN.md) 作为公开配置参考。 diff --git a/docs/emmylua_formatter/README_EN.md b/docs/emmylua_formatter/README_EN.md new file mode 100644 index 000000000..177dd0161 --- /dev/null +++ b/docs/emmylua_formatter/README_EN.md @@ -0,0 +1,50 @@ +# EmmyLua Formatter Guide + +[中文文档](./README_CN.md) + +This document is the entry point for the EmmyLua formatter documentation. It summarizes the formatter's goals, behavior, configuration model, and the recommended reading path for users who want either a quick setup or a deeper understanding of layout decisions. + +## Scope + +The formatter is responsible for: + +- Lua and EmmyLua source formatting +- width-aware line breaking +- controlled trailing-comment alignment +- EmmyLua doc-tag normalization and alignment +- CLI and library-based formatting workflows + +The formatter is intentionally conservative around comments and ambiguous syntax. When a rewrite would be risky, the implementation prefers preserving structure over forcing a prettier result. + +## Documentation Map + +- [Formatting Examples](./examples_EN.md): before-and-after examples for common formatter decisions +- [Formatter Options](./options_EN.md): configuration groups, defaults, and what each option changes +- [Recommended Profiles](./profiles_EN.md): suggested formatter configurations for common team styles +- [Formatter Tutorial](./tutorial_EN.md): practical setup, CLI workflows, and before/after examples + +## Layout Model + +Recent formatter work introduced candidate-based layout selection for sequence-like constructs such as call arguments, parameters, table fields, binary-expression chains, and statement expression lists. + +For these constructs, the formatter can compare multiple candidates: + +- flat +- progressive fill +- balanced packed layout +- one item per line +- aligned variants when comment alignment is enabled and justified by the input + +The selected result is based on rendered output rather than a fixed priority chain. Overflow is penalized first, then line count, then optional line-balance scoring for targeted sites, then style preference, and finally remaining line slack. + +## Recommended Reading + +If you are new to the formatter: + +1. Read [Formatter Tutorial](./tutorial_EN.md) for installation, config discovery, and day-to-day usage. +2. Read [Formatter Options](./options_EN.md) when you need to tune width, spacing, comments, or doc-tag behavior. + +If you are integrating the formatter into tooling: + +1. Start with the crate README at `crates/emmylua_formatter/README.md`. +2. Use [Formatter Options](./options_EN.md) as the public configuration reference. diff --git a/docs/emmylua_formatter/examples_CN.md b/docs/emmylua_formatter/examples_CN.md new file mode 100644 index 000000000..9345acf30 --- /dev/null +++ b/docs/emmylua_formatter/examples_CN.md @@ -0,0 +1,119 @@ +# EmmyLua Formatter 效果示例 + +[English](./examples_EN.md) + +本页给出一组有代表性的前后对比例子,用来说明当前格式化器的布局策略。 + +## 能放一行时保持单行 + +Before: + +```lua +local point={x=1,y=2} +``` + +After: + +```lua +local point = { x = 1, y = 2 } +``` + +## 调用参数优先使用 Progressive Fill + +Before: + +```lua +some_function(first_arg, second_arg, third_arg, fourth_arg) +``` + +After: + +```lua +some_function( + first_arg, second_arg, third_arg, + fourth_arg +) +``` + +这种布局会尽量保持紧凑,而不是一开始就退到一项一行。 + +## 二元表达式链的均衡 Packed 布局 + +Before: + +```lua +local value = aaaa + bbbb + cccc + dddd + eeee + ffff +``` + +After: + +```lua +local value = aaaa + bbbb + + cccc + dddd + + eeee + ffff +``` + +现在 binary chain 的候选评分会把真实的首行前缀宽度也算进去,因此像 `local value =` 这样的长锚点会正确影响候选选择。 + +## 语句表达式列表的均衡 Packed 布局 + +Before: + +```lua +for key, value in first_long_expr, second_long_expr, third_long_expr, fourth_long_expr, fifth_long_expr do + print(key, value) +end +``` + +After: + +```lua +for key, value in first_long_expr, + second_long_expr, third_long_expr, + fourth_long_expr, fifth_long_expr do + print(key, value) +end +``` + +这是 statement RHS 对 packed 布局的实际应用。第一项仍然贴在关键字所在行,后续项则按更均衡的方式打包。 + +## 必要时退到一段一行 + +Before: + +```lua +builder:set_name(name):set_age(age):build() +``` + +After: + +```lua +builder + :set_name(name) + :set_age(age) + :build() +``` + +当更窄的布局明显更差时,格式化器仍然会退到一段一行。 + +## 注释对齐是输入驱动的 + +Before: + +```lua +foo( + alpha, -- first + beta -- second +) +``` + +After: + +```lua +foo( + alpha, -- first + beta -- second +) +``` + +只有当输入已经体现出对齐意图时,格式化器才会对齐尾随注释;它不会在无关代码中主动制造宽对齐块。 diff --git a/docs/emmylua_formatter/examples_EN.md b/docs/emmylua_formatter/examples_EN.md new file mode 100644 index 000000000..24c5243db --- /dev/null +++ b/docs/emmylua_formatter/examples_EN.md @@ -0,0 +1,119 @@ +# EmmyLua Formatter Examples + +[中文文档](./examples_CN.md) + +This page shows representative before-and-after examples for the formatter's current layout strategy. + +## Flat When It Fits + +Before: + +```lua +local point={x=1,y=2} +``` + +After: + +```lua +local point = { x = 1, y = 2 } +``` + +## Progressive Fill For Call Arguments + +Before: + +```lua +some_function(first_arg, second_arg, third_arg, fourth_arg) +``` + +After: + +```lua +some_function( + first_arg, second_arg, third_arg, + fourth_arg +) +``` + +This keeps the argument list compact without immediately forcing one argument per line. + +## Balanced Packed Layout For Binary Chains + +Before: + +```lua +local value = aaaa + bbbb + cccc + dddd + eeee + ffff +``` + +After: + +```lua +local value = aaaa + bbbb + + cccc + dddd + + eeee + ffff +``` + +The formatter now scores binary-chain candidates with the real first-line prefix width, so long anchors such as `local value =` influence candidate selection correctly. + +## Balanced Packed Layout For Statement Expression Lists + +Before: + +```lua +for key, value in first_long_expr, second_long_expr, third_long_expr, fourth_long_expr, fifth_long_expr do + print(key, value) +end +``` + +After: + +```lua +for key, value in first_long_expr, + second_long_expr, third_long_expr, + fourth_long_expr, fifth_long_expr do + print(key, value) +end +``` + +This is the statement-level counterpart to packed binary chains. It keeps the first item attached to the keyword line and then packs later items in a balanced way. + +## One Segment Per Line When Necessary + +Before: + +```lua +builder:set_name(name):set_age(age):build() +``` + +After: + +```lua +builder + :set_name(name) + :set_age(age) + :build() +``` + +When narrower layouts are clearly worse, the formatter still falls back to one segment per line. + +## Comment Alignment Is Input-Driven + +Before: + +```lua +foo( + alpha, -- first + beta -- second +) +``` + +After: + +```lua +foo( + alpha, -- first + beta -- second +) +``` + +The formatter aligns trailing comments only when the input already indicates alignment intent. It does not manufacture wide alignment blocks in unrelated code. diff --git a/docs/emmylua_formatter/options_CN.md b/docs/emmylua_formatter/options_CN.md new file mode 100644 index 000000000..6e9888fbb --- /dev/null +++ b/docs/emmylua_formatter/options_CN.md @@ -0,0 +1,177 @@ +# EmmyLua Formatter 选项说明 + +[English](./options_EN.md) + +本文档说明格式化器对外公开的配置分组、默认值以及各选项的预期影响。 + +## 配置文件发现规则 + +`luafmt` 和路径感知的库 API 都支持向上查找最近的配置文件: + +- `.luafmt.toml` +- `luafmt.toml` + +显式传入配置文件时,支持: + +- TOML +- JSON +- YAML + +## indent + +- `kind`:`Space` 或 `Tab` +- `width`:缩进宽度 + +默认值: + +```toml +[indent] +kind = "Space" +width = 4 +``` + +## layout + +- `max_line_width`:目标最大行宽 +- `max_blank_lines`:保留的连续空行上限 +- `table_expand`:`Never`、`Always`、`Auto` +- `call_args_expand`:`Never`、`Always`、`Auto` +- `func_params_expand`:`Never`、`Always`、`Auto` + +默认值: + +```toml +[layout] +max_line_width = 120 +max_blank_lines = 1 +table_expand = "Auto" +call_args_expand = "Auto" +func_params_expand = "Auto" +``` + +行为说明: + +- `Auto` 表示允许格式化器在单行和多行候选之间进行比较。 +- 对于序列结构,格式化器在适用场景下会比较 fill、packed、aligned 和 one-per-line 等候选布局。 +- 二元表达式链和语句表达式列表在总行数不变时,会优先选择更均衡的 packed 布局,以避免最后一行过短。 + +## output + +- `insert_final_newline` +- `trailing_comma`:`Never`、`Multiline`、`Always` +- `end_of_line`:`LF` 或 `CRLF` + +默认值: + +```toml +[output] +insert_final_newline = true +trailing_comma = "Never" +end_of_line = "LF" +``` + +## spacing + +- `space_before_call_paren` +- `space_before_func_paren` +- `space_inside_braces` +- `space_inside_parens` +- `space_inside_brackets` +- `space_around_math_operator` +- `space_around_concat_operator` +- `space_around_assign_operator` + +这些选项只控制 token 级别的空格,不直接决定更高层的布局是否换行。 + +## comments + +- `align_line_comments` +- `align_in_statements` +- `align_in_table_fields` +- `align_in_call_args` +- `align_in_params` +- `align_across_standalone_comments` +- `align_same_kind_only` +- `line_comment_min_spaces_before` +- `line_comment_min_column` + +默认值: + +```toml +[comments] +align_line_comments = true +align_in_statements = false +align_in_table_fields = true +align_in_call_args = true +align_in_params = true +align_across_standalone_comments = false +align_same_kind_only = false +line_comment_min_spaces_before = 1 +line_comment_min_column = 0 +``` + +行为说明: + +- statement 尾随注释对齐默认关闭。 +- table、调用参数、函数参数中的尾随注释对齐是输入驱动的;只有源代码已经体现出额外空格的对齐意图时,才会启用。 +- standalone comment 默认会打断对齐分组。 +- table 字段尾随注释只在连续子组内部对齐,不会拖动整个表体。 + +## emmy_doc + +- `align_tag_columns` +- `align_declaration_tags` +- `align_reference_tags` +- `tag_spacing` +- `space_after_description_dash` + +默认值: + +```toml +[emmy_doc] +align_tag_columns = true +align_declaration_tags = true +align_reference_tags = true +tag_spacing = 1 +space_after_description_dash = true +``` + +当前已结构化处理的标签包括 `@param`、`@field`、`@return`、`@class`、`@alias`、`@type`、`@generic`、`@overload`。 + +## align + +- `continuous_assign_statement` +- `table_field` + +默认值: + +```toml +[align] +continuous_assign_statement = false +table_field = true +``` + +行为说明: + +- 连续赋值对齐默认关闭。 +- 表字段对齐默认开启,但只有当输入在 `=` 后已经表现出额外空格的对齐意图时才会激活。 + +## 建议起步配置 + +```toml +[layout] +max_line_width = 100 +table_expand = "Auto" +call_args_expand = "Auto" +func_params_expand = "Auto" + +[comments] +align_in_statements = false +align_in_table_fields = true +align_in_call_args = true +align_in_params = true + +[align] +continuous_assign_statement = false +table_field = true +``` diff --git a/docs/emmylua_formatter/options_EN.md b/docs/emmylua_formatter/options_EN.md new file mode 100644 index 000000000..a14fc84c9 --- /dev/null +++ b/docs/emmylua_formatter/options_EN.md @@ -0,0 +1,177 @@ +# EmmyLua Formatter Options + +[中文文档](./options_CN.md) + +This document describes the public formatter configuration groups and the intended effect of each option. + +## Configuration File Discovery + +`luafmt` and the library path-aware helpers support nearest-config discovery for: + +- `.luafmt.toml` +- `luafmt.toml` + +Supported explicit config formats are: + +- TOML +- JSON +- YAML + +## indent + +- `kind`: `Space` or `Tab` +- `width`: logical indent width + +Default: + +```toml +[indent] +kind = "Space" +width = 4 +``` + +## layout + +- `max_line_width`: preferred print width +- `max_blank_lines`: maximum consecutive blank lines retained +- `table_expand`: `Never`, `Always`, or `Auto` +- `call_args_expand`: `Never`, `Always`, or `Auto` +- `func_params_expand`: `Never`, `Always`, or `Auto` + +Default: + +```toml +[layout] +max_line_width = 120 +max_blank_lines = 1 +table_expand = "Auto" +call_args_expand = "Auto" +func_params_expand = "Auto" +``` + +Behavior notes: + +- `Auto` lets the formatter compare flat and broken candidates. +- Sequence-like structures can now choose between fill, packed, aligned, and one-per-line layouts when applicable. +- Binary-expression chains and statement expression lists may prefer a balanced packed layout when it keeps the same line count but avoids ragged trailing lines. + +## output + +- `insert_final_newline` +- `trailing_comma`: `Never`, `Multiline`, or `Always` +- `end_of_line`: `LF` or `CRLF` + +Default: + +```toml +[output] +insert_final_newline = true +trailing_comma = "Never" +end_of_line = "LF" +``` + +## spacing + +- `space_before_call_paren` +- `space_before_func_paren` +- `space_inside_braces` +- `space_inside_parens` +- `space_inside_brackets` +- `space_around_math_operator` +- `space_around_concat_operator` +- `space_around_assign_operator` + +These options control token spacing only. They do not override larger layout decisions such as whether an expression list should break. + +## comments + +- `align_line_comments` +- `align_in_statements` +- `align_in_table_fields` +- `align_in_call_args` +- `align_in_params` +- `align_across_standalone_comments` +- `align_same_kind_only` +- `line_comment_min_spaces_before` +- `line_comment_min_column` + +Default: + +```toml +[comments] +align_line_comments = true +align_in_statements = false +align_in_table_fields = true +align_in_call_args = true +align_in_params = true +align_across_standalone_comments = false +align_same_kind_only = false +line_comment_min_spaces_before = 1 +line_comment_min_column = 0 +``` + +Behavior notes: + +- Statement comment alignment is disabled by default. +- Table, call-arg, and parameter trailing-comment alignment are input-driven. Extra spacing in the original source is treated as alignment intent. +- Standalone comments usually break alignment groups. +- Table-field trailing-comment alignment is scoped to contiguous subgroups rather than the whole table. + +## emmy_doc + +- `align_tag_columns` +- `align_declaration_tags` +- `align_reference_tags` +- `tag_spacing` +- `space_after_description_dash` + +Default: + +```toml +[emmy_doc] +align_tag_columns = true +align_declaration_tags = true +align_reference_tags = true +tag_spacing = 1 +space_after_description_dash = true +``` + +Structured handling currently covers `@param`, `@field`, `@return`, `@class`, `@alias`, `@type`, `@generic`, and `@overload`. + +## align + +- `continuous_assign_statement` +- `table_field` + +Default: + +```toml +[align] +continuous_assign_statement = false +table_field = true +``` + +Behavior notes: + +- Continuous assignment alignment is disabled by default. +- Table-field alignment is enabled, but only activates when the source already shows extra post-`=` spacing that indicates alignment intent. + +## Recommended Starting Point + +```toml +[layout] +max_line_width = 100 +table_expand = "Auto" +call_args_expand = "Auto" +func_params_expand = "Auto" + +[comments] +align_in_statements = false +align_in_table_fields = true +align_in_call_args = true +align_in_params = true + +[align] +continuous_assign_statement = false +table_field = true +``` diff --git a/docs/emmylua_formatter/profiles_CN.md b/docs/emmylua_formatter/profiles_CN.md new file mode 100644 index 000000000..951293d17 --- /dev/null +++ b/docs/emmylua_formatter/profiles_CN.md @@ -0,0 +1,122 @@ +# EmmyLua Formatter 推荐配置方案 + +[English](./profiles_EN.md) + +本文档给出几组适合常见团队风格的 formatter 推荐配置。它们不是内置模式,而是基于当前默认行为与布局策略整理出来的建议模板。 + +## 1. 保守默认方案 + +适用于历史风格混杂、注释较多、人工排版痕迹明显的代码库。 + +目标: + +- 尽量减少意外重写 +- 让对齐保持为输入驱动、按需启用 +- 对序列结构继续使用 `Auto` 的宽度感知布局选择 + +```toml +[layout] +max_line_width = 100 +table_expand = "Auto" +call_args_expand = "Auto" +func_params_expand = "Auto" + +[comments] +align_in_statements = false +align_in_table_fields = true +align_in_call_args = true +align_in_params = true +align_across_standalone_comments = false + +[align] +continuous_assign_statement = false +table_field = true +``` + +适用场景: + +- 大型存量仓库 +- 手工注释较多的游戏脚本仓库 +- 希望稳定格式化、但不希望到处出现强对齐的团队 + +## 2. 团队统一方案 + +适用于希望统一格式化风格、但仍然保留保守注释策略的团队。 + +目标: + +- 统一宽度和空格规则 +- 保持注释可读性 +- 让格式化器自动选择 flat、fill、packed 或 one-per-line 布局 + +```toml +[layout] +max_line_width = 88 +table_expand = "Auto" +call_args_expand = "Auto" +func_params_expand = "Auto" + +[spacing] +space_inside_braces = true +space_around_math_operator = true +space_around_concat_operator = true +space_around_assign_operator = true + +[comments] +align_in_statements = false +align_in_table_fields = true +align_in_call_args = true +align_in_params = true + +[align] +continuous_assign_statement = false +table_field = true +``` + +适用场景: + +- 使用 CI 格式检查的仓库 +- 希望行宽和换行决策更可预测的团队 +- 想使用 packed 布局,但不想让对齐规则过于激进的项目 + +## 3. 对齐敏感方案 + +只建议在代码库本身已经强依赖视觉对齐时使用。 + +目标: + +- 尽量保留有意存在的表格与注释对齐 +- 在已有视觉列的地方保持对齐结构 + +```toml +[layout] +max_line_width = 100 +table_expand = "Auto" +call_args_expand = "Auto" +func_params_expand = "Auto" + +[comments] +align_in_statements = true +align_in_table_fields = true +align_in_call_args = true +align_in_params = true +align_across_standalone_comments = false +align_same_kind_only = true +line_comment_min_spaces_before = 2 + +[align] +continuous_assign_statement = true +table_field = true +``` + +适用场景: + +- 已经存在稳定视觉列风格的代码库 +- 生成式或半生成式的脚本表数据 +- 愿意认真审查对齐型 diff 的团队 + +## 说明 + +- 对 table、call arguments 和 parameters 来说,`Auto` 通常都是最合适的起点。 +- formatter 现在已经为 binary chains 和 statement expression lists 提供了更均衡的 packed 布局,因此较窄的行宽也能保持相对紧凑的多行输出,而不必立刻退化成一项一行。 +- 如果仓库里有很多脆弱的注释块,建议先从保守默认方案开始,观察 diff 质量后再逐步打开更强的对齐选项。 diff --git a/docs/emmylua_formatter/profiles_EN.md b/docs/emmylua_formatter/profiles_EN.md new file mode 100644 index 000000000..b60da257d --- /dev/null +++ b/docs/emmylua_formatter/profiles_EN.md @@ -0,0 +1,122 @@ +# EmmyLua Formatter Recommended Profiles + +[中文文档](./profiles_CN.md) + +This page provides recommended formatter configurations for common team styles. These profiles are not special built-in modes. They are curated config examples based on the formatter's current behavior and defaults. + +## 1. Conservative Default + +Use this profile when the codebase has mixed style history, many comments, or frequent manual formatting. + +Goals: + +- minimize surprising rewrites +- keep alignment opt-in and input-driven +- prefer `Auto` for width-aware layout selection + +```toml +[layout] +max_line_width = 100 +table_expand = "Auto" +call_args_expand = "Auto" +func_params_expand = "Auto" + +[comments] +align_in_statements = false +align_in_table_fields = true +align_in_call_args = true +align_in_params = true +align_across_standalone_comments = false + +[align] +continuous_assign_statement = false +table_field = true +``` + +Recommended for: + +- large existing repositories +- game scripts with hand-aligned comments +- teams that want stable formatting without strong alignment rules + +## 2. Team Standard Profile + +Use this profile when the team wants consistent formatting, but still prefers conservative comment handling. + +Goals: + +- unify width and spacing rules +- keep comments readable +- allow the formatter to choose flat, fill, packed, or one-per-line layouts automatically + +```toml +[layout] +max_line_width = 88 +table_expand = "Auto" +call_args_expand = "Auto" +func_params_expand = "Auto" + +[spacing] +space_inside_braces = true +space_around_math_operator = true +space_around_concat_operator = true +space_around_assign_operator = true + +[comments] +align_in_statements = false +align_in_table_fields = true +align_in_call_args = true +align_in_params = true + +[align] +continuous_assign_statement = false +table_field = true +``` + +Recommended for: + +- repositories using CI formatting checks +- teams that want predictable line breaking +- projects that want packed layouts but do not want aggressive alignment everywhere + +## 3. Alignment-Sensitive Profile + +Use this profile only when the codebase already relies heavily on visual alignment. + +Goals: + +- preserve intentionally aligned tables and comments +- retain explicit visual columns where they already exist + +```toml +[layout] +max_line_width = 100 +table_expand = "Auto" +call_args_expand = "Auto" +func_params_expand = "Auto" + +[comments] +align_in_statements = true +align_in_table_fields = true +align_in_call_args = true +align_in_params = true +align_across_standalone_comments = false +align_same_kind_only = true +line_comment_min_spaces_before = 2 + +[align] +continuous_assign_statement = true +table_field = true +``` + +Recommended for: + +- codebases with established visual columns +- generated or semi-generated script tables +- teams willing to review alignment-heavy diffs carefully + +## Notes + +- `Auto` is usually the best starting point for tables, call arguments, and parameter lists. +- The formatter now has balanced packed layouts for binary chains and statement expression lists. That means tighter line widths can still produce compact multi-line output without immediately collapsing into one item per line. +- If the repository contains many fragile comment blocks, start with the conservative profile and only enable more alignment after reviewing the diff quality. diff --git a/docs/emmylua_formatter/tutorial_CN.md b/docs/emmylua_formatter/tutorial_CN.md new file mode 100644 index 000000000..ded913093 --- /dev/null +++ b/docs/emmylua_formatter/tutorial_CN.md @@ -0,0 +1,140 @@ +# EmmyLua Formatter 教程 + +[English](./tutorial_EN.md) + +本文档介绍 EmmyLua Formatter 的实际使用方式,包括命令行、配置文件以及库 API 集成。 + +## 1. 构建 + +在当前工作区中构建 formatter 可执行文件: + +```bash +cargo build --release -p emmylua_formatter +``` + +生成的可执行文件名为 `luafmt`。 + +## 2. 编写配置文件 + +在项目根目录创建 `.luafmt.toml`: + +```toml +[layout] +max_line_width = 100 +table_expand = "Auto" +call_args_expand = "Auto" +func_params_expand = "Auto" + +[comments] +align_in_statements = false +align_in_table_fields = true +align_in_call_args = true +align_in_params = true + +[align] +continuous_assign_statement = false +table_field = true +``` + +格式化器会为每个文件向上查找最近的 `.luafmt.toml` 或 `luafmt.toml`。 + +## 3. 格式化文件 + +直接写回目录中的文件: + +```bash +luafmt src --write +``` + +检查哪些文件会被改动: + +```bash +luafmt . --check +``` + +只输出会变化的路径: + +```bash +luafmt . --list-different +``` + +从标准输入读取: + +```bash +cat script.lua | luafmt --stdin +``` + +## 4. 理解主要布局模式 + +### 能放一行时保持单行 + +```lua +local point = { x = 1, y = 2 } +``` + +### 需要换行时优先使用 progressive fill + +```lua +some_function( + first_arg, second_arg, third_arg, + fourth_arg +) +``` + +### 在序列结构上选择更均衡的 packed 布局 + +```lua +if alpha_beta_gamma + delta_theta + + epsilon + zeta then + work() +end +``` + +```lua +for key, value in first_long_expr, + second_long_expr, third_long_expr, + fourth_long_expr, fifth_long_expr do + print(key, value) +end +``` + +### 只有更窄布局明显更差时才退到一项一行 + +```lua +builder + :set_name(name) + :set_age(age) + :build() +``` + +## 5. 注释对齐 + +默认策略是保守的: + +- statement 尾随注释对齐默认关闭 +- table、调用参数、函数参数的尾随注释对齐是输入驱动的 +- standalone comment 默认打断对齐分组 + +这样做是为了避免在原始代码没有体现对齐意图时,格式化器主动制造过宽的对齐块。 + +## 6. 库 API 集成 + +```rust +use std::path::Path; + +use emmylua_formatter::{check_text_for_path, format_text_for_path}; + +let path = Path::new("scripts/main.lua"); +let formatted = format_text_for_path("local x=1\n", Some(path), None)?; +let checked = check_text_for_path("local x=1\n", Some(path), None)?; + +assert!(formatted.output.changed); +assert!(checked.changed); +``` + +## 7. 团队建议 + +1. 将统一的 `.luafmt.toml` 提交到仓库。 +2. 在 CI 中使用 `luafmt --check`。 +3. 对齐相关选项保持保守,除非代码库本身已经普遍依赖对齐风格。 +4. 除非项目有非常强的统一风格要求,否则优先使用 `Auto` 扩展模式。 diff --git a/docs/emmylua_formatter/tutorial_EN.md b/docs/emmylua_formatter/tutorial_EN.md new file mode 100644 index 000000000..1fccc0dd4 --- /dev/null +++ b/docs/emmylua_formatter/tutorial_EN.md @@ -0,0 +1,140 @@ +# EmmyLua Formatter Tutorial + +[中文文档](./tutorial_CN.md) + +This tutorial covers the practical workflow for using the EmmyLua formatter from the command line, configuration files, and library APIs. + +## 1. Install or Build + +Build the formatter binary from this workspace: + +```bash +cargo build --release -p emmylua_formatter +``` + +The formatter executable is `luafmt`. + +## 2. Create a Config File + +Create `.luafmt.toml` in the project root: + +```toml +[layout] +max_line_width = 100 +table_expand = "Auto" +call_args_expand = "Auto" +func_params_expand = "Auto" + +[comments] +align_in_statements = false +align_in_table_fields = true +align_in_call_args = true +align_in_params = true + +[align] +continuous_assign_statement = false +table_field = true +``` + +The formatter discovers the nearest `.luafmt.toml` or `luafmt.toml` for each file. + +## 3. Format Files + +Format a directory in place: + +```bash +luafmt src --write +``` + +Check whether files would change: + +```bash +luafmt . --check +``` + +List only changed paths: + +```bash +luafmt . --list-different +``` + +Read from stdin: + +```bash +cat script.lua | luafmt --stdin +``` + +## 4. Understand the Main Layout Modes + +### Flat when possible + +```lua +local point = { x = 1, y = 2 } +``` + +### Progressive fill for compact multi-line output + +```lua +some_function( + first_arg, second_arg, third_arg, + fourth_arg +) +``` + +### Balanced packed layout for sequence-like structures + +```lua +if alpha_beta_gamma + delta_theta + + epsilon + zeta then + work() +end +``` + +```lua +for key, value in first_long_expr, + second_long_expr, third_long_expr, + fourth_long_expr, fifth_long_expr do + print(key, value) +end +``` + +### One item per line when narrower layouts are clearly worse + +```lua +builder + :set_name(name) + :set_age(age) + :build() +``` + +## 5. Comment Alignment + +The formatter is conservative by default: + +- statement comment alignment is off +- table, call-arg, and param comment alignment are input-driven +- standalone comments break alignment groups + +This is intentional. It avoids manufacturing wide alignment blocks in files that were not written that way originally. + +## 6. Use the Library API + +```rust +use std::path::Path; + +use emmylua_formatter::{check_text_for_path, format_text_for_path}; + +let path = Path::new("scripts/main.lua"); +let formatted = format_text_for_path("local x=1\n", Some(path), None)?; +let checked = check_text_for_path("local x=1\n", Some(path), None)?; + +assert!(formatted.output.changed); +assert!(checked.changed); +``` + +## 7. Recommended Team Workflow + +1. Commit a shared `.luafmt.toml`. +2. Use `luafmt --check` in CI. +3. Keep alignment-related options conservative unless the codebase already relies on aligned comments or fields. +4. Prefer `Auto` expansion modes unless the project has a strong one-style policy. From 334aa7b0c05265706e46e350b42931a507e561cf Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Mon, 23 Mar 2026 01:10:01 +0800 Subject: [PATCH 10/10] update comment handle --- crates/emmylua_formatter/README.md | 7 + crates/emmylua_formatter/src/config/mod.rs | 39 ++ .../emmylua_formatter/src/formatter/block.rs | 26 +- .../src/formatter/comment.rs | 300 ++++++++++----- .../src/formatter/expression.rs | 350 ++++++++++++------ .../src/formatter/sequence.rs | 131 ++++--- .../src/formatter/statement.rs | 7 +- crates/emmylua_formatter/src/lib.rs | 3 +- .../src/test/comment_tests.rs | 58 +++ .../src/test/config_tests.rs | 182 ++++++++- docs/emmylua_formatter/examples_CN.md | 142 ++++++- docs/emmylua_formatter/examples_EN.md | 144 ++++++- docs/emmylua_formatter/options_CN.md | 17 + docs/emmylua_formatter/options_EN.md | 17 + docs/emmylua_formatter/profiles_CN.md | 15 + docs/emmylua_formatter/profiles_EN.md | 15 + docs/emmylua_formatter/tutorial_CN.md | 9 + docs/emmylua_formatter/tutorial_EN.md | 9 + 18 files changed, 1167 insertions(+), 304 deletions(-) diff --git a/crates/emmylua_formatter/README.md b/crates/emmylua_formatter/README.md index 342e35d3f..0fad3d903 100644 --- a/crates/emmylua_formatter/README.md +++ b/crates/emmylua_formatter/README.md @@ -61,7 +61,11 @@ Key defaults: - `layout.call_args_expand = "Auto"` - `layout.func_params_expand = "Auto"` - `output.trailing_comma = "Never"` +- `output.trailing_table_separator = "Inherit"` +- `output.quote_style = "Preserve"` +- `output.single_arg_call_parens = "Preserve"` - `comments.align_in_statements = false` +- `comments.space_after_comment_dash = true` - `align.continuous_assign_statement = false` - `align.table_field = true` @@ -195,6 +199,9 @@ func_params_expand = "Auto" [output] insert_final_newline = true trailing_comma = "Never" +trailing_table_separator = "Inherit" +quote_style = "Preserve" +single_arg_call_parens = "Preserve" end_of_line = "LF" [spacing] diff --git a/crates/emmylua_formatter/src/config/mod.rs b/crates/emmylua_formatter/src/config/mod.rs index 05dd7872d..8d606630c 100644 --- a/crates/emmylua_formatter/src/config/mod.rs +++ b/crates/emmylua_formatter/src/config/mod.rs @@ -54,6 +54,15 @@ impl LuaFormatConfig { pub fn should_align_emmy_doc_reference_tags(&self) -> bool { self.emmy_doc.align_tag_columns && self.emmy_doc.align_reference_tags } + + pub fn trailing_table_comma(&self) -> TrailingComma { + match self.output.trailing_table_separator { + TrailingTableSeparator::Inherit => self.output.trailing_comma.clone(), + TrailingTableSeparator::Never => TrailingComma::Never, + TrailingTableSeparator::Multiline => TrailingComma::Multiline, + TrailingTableSeparator::Always => TrailingComma::Always, + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -99,6 +108,9 @@ impl Default for LayoutConfig { pub struct OutputConfig { pub insert_final_newline: bool, pub trailing_comma: TrailingComma, + pub trailing_table_separator: TrailingTableSeparator, + pub quote_style: QuoteStyle, + pub single_arg_call_parens: SingleArgCallParens, pub end_of_line: EndOfLine, } @@ -107,6 +119,9 @@ impl Default for OutputConfig { Self { insert_final_newline: true, trailing_comma: TrailingComma::Never, + trailing_table_separator: TrailingTableSeparator::Inherit, + quote_style: QuoteStyle::Preserve, + single_arg_call_parens: SingleArgCallParens::Preserve, end_of_line: EndOfLine::LF, } } @@ -150,6 +165,7 @@ pub struct CommentConfig { pub align_in_params: bool, pub align_across_standalone_comments: bool, pub align_same_kind_only: bool, + pub space_after_comment_dash: bool, pub line_comment_min_spaces_before: usize, pub line_comment_min_column: usize, } @@ -164,6 +180,7 @@ impl Default for CommentConfig { align_in_params: true, align_across_standalone_comments: false, align_same_kind_only: false, + space_after_comment_dash: true, line_comment_min_spaces_before: 1, line_comment_min_column: 0, } @@ -221,6 +238,28 @@ pub enum TrailingComma { Always, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum TrailingTableSeparator { + Inherit, + Never, + Multiline, + Always, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum QuoteStyle { + Preserve, + Double, + Single, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum SingleArgCallParens { + Preserve, + Always, + Omit, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum ExpandStrategy { Never, diff --git a/crates/emmylua_formatter/src/formatter/block.rs b/crates/emmylua_formatter/src/formatter/block.rs index e8d171844..7964201f9 100644 --- a/crates/emmylua_formatter/src/formatter/block.rs +++ b/crates/emmylua_formatter/src/formatter/block.rs @@ -45,7 +45,7 @@ fn can_join_comment_alignment_group( match child { BlockChild::Comment(_) => ctx.config.comments.align_across_standalone_comments, BlockChild::Statement(next_stat) => { - if extract_trailing_comment(next_stat.syntax()).is_none() { + if extract_trailing_comment(ctx.config, next_stat.syntax()).is_none() { return false; } if ctx.config.comments.align_same_kind_only && !same_stat_kind(anchor, next_stat) { @@ -64,7 +64,7 @@ fn can_join_eq_alignment_group(ctx: &FormatContext, anchor: &LuaStat, child: &Bl match child { BlockChild::Comment(_) => ctx.config.comments.align_across_standalone_comments, BlockChild::Statement(next_stat) => { - if !is_eq_alignable(next_stat) { + if !is_eq_alignable(ctx.config, next_stat) { return false; } if ctx.config.comments.align_same_kind_only && !same_stat_kind(anchor, next_stat) { @@ -98,10 +98,12 @@ fn build_eq_alignment_entries( } BlockChild::Statement(stat) => { let trailing = if ctx.config.should_align_statement_line_comments() { - extract_trailing_comment(stat.syntax()).map(|(trail_docs, range)| { - consumed_comment_ranges.push(range); - trail_docs - }) + extract_trailing_comment(ctx.config, stat.syntax()).map( + |(trail_docs, range)| { + consumed_comment_ranges.push(range); + trail_docs + }, + ) } else { None }; @@ -159,11 +161,12 @@ fn build_comment_alignment_entries( }); } BlockChild::Statement(stat) => { - let trailing = - extract_trailing_comment(stat.syntax()).map(|(trail_docs, range)| { + let trailing = extract_trailing_comment(ctx.config, stat.syntax()).map( + |(trail_docs, range)| { consumed_comment_ranges.push(range); trail_docs - }); + }, + ); entries.push(AlignEntry::Line { content: format_stat(ctx, stat), trailing, @@ -227,7 +230,8 @@ pub fn format_block(ctx: &FormatContext, block: &LuaBlock) -> Vec { } BlockChild::Statement(stat) => { // Try to form an alignment group if enabled - if ctx.config.align.continuous_assign_statement && is_eq_alignable(stat) { + if ctx.config.align.continuous_assign_statement && is_eq_alignable(ctx.config, stat) + { let group_start = i; let mut group_end = i + 1; while group_end < children.len() { @@ -270,7 +274,7 @@ pub fn format_block(ctx: &FormatContext, block: &LuaBlock) -> Vec { // Try to form a comment-only alignment group if ctx.config.should_align_statement_line_comments() - && extract_trailing_comment(stat.syntax()).is_some() + && extract_trailing_comment(ctx.config, stat.syntax()).is_some() { let group_start = i; let mut group_end = i + 1; diff --git a/crates/emmylua_formatter/src/formatter/comment.rs b/crates/emmylua_formatter/src/formatter/comment.rs index 7b4a47f17..2edf92d59 100644 --- a/crates/emmylua_formatter/src/formatter/comment.rs +++ b/crates/emmylua_formatter/src/formatter/comment.rs @@ -1,6 +1,6 @@ use emmylua_parser::{ - LuaAstNode, LuaAstToken, LuaComment, LuaDocDescription, LuaDocFieldKey, LuaDocGenericDeclList, - LuaDocTag, LuaDocTagAlias, LuaDocTagClass, LuaDocTagField, LuaDocTagGeneric, LuaDocTagOverload, + LuaAstNode, LuaAstToken, LuaComment, LuaDocFieldKey, LuaDocGenericDeclList, LuaDocTag, + LuaDocTagAlias, LuaDocTagClass, LuaDocTagField, LuaDocTagGeneric, LuaDocTagOverload, LuaDocTagParam, LuaDocTagReturn, LuaDocTagType, LuaKind, LuaSyntaxElement, LuaSyntaxKind, LuaSyntaxNode, LuaTokenKind, }; @@ -21,7 +21,7 @@ pub fn format_comment(config: &LuaFormatConfig, comment: &LuaComment) -> Vec vec![ir::source_node_trimmed(comment.syntax().clone())], CommentKind::Doc => format_doc_comment(config, comment), - CommentKind::Normal => format_normal_comment(comment), + CommentKind::Normal => format_normal_comment(config, comment), } } @@ -79,12 +79,17 @@ fn classify_comment(comment: &LuaComment) -> CommentKind { } } -fn format_normal_comment(comment: &LuaComment) -> Vec { - let Some(description) = comment.get_description() else { - return vec![ir::source_node_trimmed(comment.syntax().clone())]; - }; +fn format_normal_comment(config: &LuaFormatConfig, comment: &LuaComment) -> Vec { + let lines = parse_normal_comment_lines(comment); + if lines.is_empty() { + let raw = comment.syntax().text().to_string().trim_end().to_string(); + return vec![ir::text(apply_space_after_comment_dash( + &raw, + config.comments.space_after_comment_dash, + ))]; + } - let rendered = render_normal_comment_lines(&description); + let rendered = render_normal_comment_lines(&lines, config.comments.space_after_comment_dash); let mut docs = Vec::new(); for (index, line) in rendered.into_iter().enumerate() { if index > 0 { @@ -97,61 +102,115 @@ fn format_normal_comment(comment: &LuaComment) -> Vec { docs } -fn render_normal_comment_lines(description: &LuaDocDescription) -> Vec { +#[derive(Debug, Clone, Default)] +struct NormalCommentLine { + prefix: String, + gap: String, + detail: String, +} + +fn parse_normal_comment_lines(comment: &LuaComment) -> Vec { let mut lines = Vec::new(); - let mut prefix: Option = None; - let mut gap = String::new(); - let mut detail = String::new(); + let mut current_line: Option = None; - for child in description.syntax().children_with_tokens() { + for child in comment.syntax().children_with_tokens() { let LuaSyntaxElement::Token(token) = child else { continue; }; match token.kind().into() { LuaTokenKind::TkNormalStart | LuaTokenKind::TKNonStdComment => { - if let Some(prefix_text) = prefix.take() { - lines.push(render_normal_comment_line(&prefix_text, &gap, &detail)); + if let Some(line) = current_line.take() { + lines.push(line); } - prefix = Some(token.text().to_string()); - gap.clear(); - detail.clear(); + current_line = Some(NormalCommentLine { + prefix: token.text().to_string(), + ..Default::default() + }); } LuaTokenKind::TkWhitespace => { - if prefix.is_some() && detail.is_empty() { - gap.push_str(token.text()); - } else if !detail.is_empty() { - detail.push_str(token.text()); + let Some(line) = current_line.as_mut() else { + continue; + }; + + if line.detail.is_empty() { + line.gap.push_str(token.text()); + } else { + line.detail.push_str(token.text()); } } LuaTokenKind::TkDocDetail => { - detail.push_str(token.text()); + if let Some(line) = current_line.as_mut() { + line.detail.push_str(token.text()); + } } LuaTokenKind::TkEndOfLine => { - if let Some(prefix_text) = prefix.take() { - lines.push(render_normal_comment_line(&prefix_text, &gap, &detail)); + if let Some(line) = current_line.take() { + lines.push(line); } - gap.clear(); - detail.clear(); } _ => {} } } - if let Some(prefix_text) = prefix.take() { - lines.push(render_normal_comment_line(&prefix_text, &gap, &detail)); + if let Some(line) = current_line.take() { + lines.push(line); } lines } -fn render_normal_comment_line(prefix: &str, gap: &str, detail: &str) -> String { - let mut line = prefix.trim_end().to_string(); - if !gap.is_empty() || !detail.is_empty() { - line.push_str(gap); - line.push_str(detail); +fn render_normal_comment_lines( + lines: &[NormalCommentLine], + space_after_comment_dash: bool, +) -> Vec { + lines + .iter() + .map(|line| render_normal_comment_line(line, space_after_comment_dash)) + .collect() +} + +fn render_normal_comment_line(line: &NormalCommentLine, space_after_comment_dash: bool) -> String { + let mut rendered = line.prefix.trim_end().to_string(); + if line.gap.is_empty() + && line.detail.is_empty() + && space_after_comment_dash + && let Some(body) = rendered.strip_prefix("--") + && !body.is_empty() + && !body.starts_with(' ') + && !body.starts_with('\t') + { + return format!("-- {body}").trim_end().to_string(); + } + + if !line.gap.is_empty() || !line.detail.is_empty() { + if line.gap.is_empty() && !line.detail.is_empty() && space_after_comment_dash { + rendered.push(' '); + rendered.push_str(line.detail.trim_start()); + } else { + rendered.push_str(&line.gap); + rendered.push_str(&line.detail); + } + } + + rendered.trim_end().to_string() +} + +fn apply_space_after_comment_dash(text: &str, space_after_comment_dash: bool) -> String { + let trimmed = text.trim_end(); + if !space_after_comment_dash { + return trimmed.to_string(); + } + + if let Some(body) = trimmed.strip_prefix("--") + && !body.is_empty() + && !body.starts_with(' ') + && !body.starts_with('\t') + { + return format!("-- {body}"); } - line.trim_end().to_string() + + trimmed.to_string() } #[derive(Debug, Clone)] @@ -199,7 +258,8 @@ enum DocCommentLine { struct PendingDocLine { prefix: Option, tag: Option, - description: Option, + description: Option, + preserve_description_raw: bool, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -235,7 +295,7 @@ fn parse_doc_comment_lines(comment: &LuaComment) -> Vec { }, LuaSyntaxElement::Node(node) => match node.kind().into() { LuaSyntaxKind::DocDescription => { - pending.description = LuaDocDescription::cast(node); + append_doc_description_lines(&mut lines, &mut pending, &node); } syntax_kind if LuaDocTag::can_cast(syntax_kind) => { pending.tag = LuaDocTag::cast(node); @@ -252,16 +312,66 @@ fn parse_doc_comment_lines(comment: &LuaComment) -> Vec { lines } +fn append_doc_description_lines( + lines: &mut Vec, + pending: &mut PendingDocLine, + description: &LuaSyntaxNode, +) { + let mut current_text = pending.description.take().unwrap_or_default(); + let mut seen_embedded_line_break = false; + + for child in description.children_with_tokens() { + let Some(token) = child.into_token() else { + continue; + }; + + match token.kind().into() { + LuaTokenKind::TkWhitespace | LuaTokenKind::TkDocDetail => { + current_text.push_str(token.text()); + } + LuaTokenKind::TkNormalStart + | LuaTokenKind::TkDocStart + | LuaTokenKind::TkDocLongStart + | LuaTokenKind::TkDocContinue + | LuaTokenKind::TkDocContinueOr => { + pending.prefix = Some(token.text().to_string()); + pending.preserve_description_raw = seen_embedded_line_break; + } + LuaTokenKind::TkEndOfLine => { + pending.description = Some(if pending.preserve_description_raw { + current_text.trim_end().to_string() + } else { + normalize_single_line_spaces(¤t_text) + }); + lines.push(finalize_doc_comment_line(pending)); + current_text.clear(); + seen_embedded_line_break = true; + } + _ => {} + } + } + + if !current_text.is_empty() { + pending.description = Some(if pending.preserve_description_raw { + current_text.trim_end().to_string() + } else { + normalize_single_line_spaces(¤t_text) + }); + } +} + fn finalize_doc_comment_line(pending: &mut PendingDocLine) -> DocCommentLine { let prefix = pending.prefix.take().unwrap_or_default(); let tag = pending.tag.take(); let description = pending.description.take(); + let preserve_description_raw = std::mem::take(&mut pending.preserve_description_raw); if let Some(tag) = tag { build_doc_tag_line(&prefix, tag, description) - } else if let Some(description) = description { - let text = normalize_single_line_spaces(&description.get_description_text()); - if text.is_empty() { + } else if let Some(text) = description { + if preserve_description_raw { + DocCommentLine::Raw(format!("{prefix}{text}").trim_end().to_string()) + } else if text.is_empty() { DocCommentLine::Raw(prefix.trim_end().to_string()) } else { DocCommentLine::Description(text) @@ -273,11 +383,7 @@ fn finalize_doc_comment_line(pending: &mut PendingDocLine) -> DocCommentLine { } } -fn build_doc_tag_line( - prefix: &str, - tag: LuaDocTag, - description: Option, -) -> DocCommentLine { +fn build_doc_tag_line(prefix: &str, tag: LuaDocTag, description: Option) -> DocCommentLine { if prefix != "---@" { return raw_doc_tag_line(prefix, tag.syntax().text().to_string(), description); } @@ -325,7 +431,7 @@ fn build_doc_tag_line( fn build_class_doc_line( _prefix: &str, tag: &LuaDocTagClass, - description: Option, + description: Option, ) -> Option { let mut body = tag.get_name_token()?.get_name_text().to_string(); if let Some(generic_decl) = tag.get_generic_decl() { @@ -335,24 +441,24 @@ fn build_class_doc_line( body.push_str(": "); body.push_str(&single_line_syntax_text(&supers)?); } - let desc = inline_doc_description_text(description); + let desc = non_empty_description_text(description); Some(DocCommentLine::Class { body, desc }) } fn build_alias_doc_line( _prefix: &str, tag: &LuaDocTagAlias, - description: Option, + description: Option, ) -> Option { let body = raw_doc_tag_body_text("alias", tag)?; - let desc = inline_doc_description_text(description); + let desc = non_empty_description_text(description); Some(DocCommentLine::Alias { body, desc }) } fn build_type_doc_line( _prefix: &str, tag: &LuaDocTagType, - description: Option, + description: Option, ) -> Option { let mut parts = Vec::new(); for ty in tag.get_type_list() { @@ -361,7 +467,7 @@ fn build_type_doc_line( if parts.is_empty() { return None; } - let desc = inline_doc_description_text(description); + let desc = non_empty_description_text(description); Some(DocCommentLine::Type { body: parts.join(", "), desc, @@ -371,34 +477,30 @@ fn build_type_doc_line( fn build_generic_doc_line( _prefix: &str, tag: &LuaDocTagGeneric, - description: Option, + description: Option, ) -> Option { let body = generic_decl_list_text(&tag.get_generic_decl_list()?)?; - let desc = inline_doc_description_text(description); + let desc = non_empty_description_text(description); Some(DocCommentLine::Generic { body, desc }) } fn build_overload_doc_line( _prefix: &str, tag: &LuaDocTagOverload, - description: Option, + description: Option, ) -> Option { let body = single_line_syntax_text(&tag.get_type()?)?; - let desc = inline_doc_description_text(description); + let desc = non_empty_description_text(description); Some(DocCommentLine::Overload { body, desc }) } -fn raw_doc_tag_line( - prefix: &str, - body: String, - description: Option, -) -> DocCommentLine { +fn raw_doc_tag_line(prefix: &str, body: String, description: Option) -> DocCommentLine { if body.contains('\n') { return DocCommentLine::Raw(format!("{prefix}{body}").trim_end().to_string()); } let mut line = format!("{prefix}{}", normalize_single_line_spaces(&body)); - if let Some(desc) = inline_doc_description_text(description) + if let Some(desc) = non_empty_description_text(description) && !desc.is_empty() { line.push(' '); @@ -410,7 +512,7 @@ fn raw_doc_tag_line( fn build_param_doc_line( _prefix: &str, tag: &LuaDocTagParam, - description: Option, + description: Option, ) -> Option { let mut name = if tag.is_vararg() { "...".to_string() @@ -422,14 +524,14 @@ fn build_param_doc_line( } let ty = single_line_syntax_text(&tag.get_type()?)?; - let desc = inline_doc_description_text(description); + let desc = non_empty_description_text(description); Some(DocCommentLine::Param { name, ty, desc }) } fn build_field_doc_line( _prefix: &str, tag: &LuaDocTagField, - description: Option, + description: Option, ) -> Option { let mut key = String::new(); if let Some(visibility) = tag.get_visibility_token() { @@ -442,14 +544,14 @@ fn build_field_doc_line( } let ty = single_line_syntax_text(&tag.get_type()?)?; - let desc = inline_doc_description_text(description); + let desc = non_empty_description_text(description); Some(DocCommentLine::Field { key, ty, desc }) } fn build_return_doc_line( _prefix: &str, tag: &LuaDocTagReturn, - description: Option, + description: Option, ) -> Option { let mut parts = Vec::new(); for (ty, name) in tag.get_info_list() { @@ -465,7 +567,7 @@ fn build_return_doc_line( parts.push(single_line_syntax_text(&tag.get_first_type()?)?); } - let desc = inline_doc_description_text(description); + let desc = non_empty_description_text(description); Some(DocCommentLine::Return { body: parts.join(", "), desc, @@ -482,17 +584,11 @@ fn field_key_text(key: &LuaDocFieldKey) -> Option { } fn single_line_syntax_text(node: &impl LuaAstNode) -> Option { - let text = node.syntax().text().to_string(); - if text.contains('\n') { - None - } else { - Some(normalize_single_line_spaces(&text)) - } + Some(normalize_single_line_spaces(&single_line_node_text(node)?)) } -fn inline_doc_description_text(description: Option) -> Option { - let description = description?; - let text = normalize_single_line_spaces(&description.get_description_text()); +fn non_empty_description_text(description: Option) -> Option { + let text = description?; if text.is_empty() { None } else { Some(text) } } @@ -506,15 +602,29 @@ fn generic_decl_list_text(list: &LuaDocGenericDeclList) -> Option { } fn raw_doc_tag_body_text(tag_name: &str, node: &T) -> Option { - let text = node.syntax().text().to_string(); - if text.contains('\n') { - return None; - } + let text = single_line_node_text(node)?; let body = text.trim().strip_prefix(tag_name)?.trim_start(); Some(body.trim_end().to_string()) } +fn single_line_node_text(node: &impl LuaAstNode) -> Option { + let mut text = String::new(); + + for element in node.syntax().descendants_with_tokens() { + let Some(token) = element.into_token() else { + continue; + }; + + match token.kind().into() { + LuaTokenKind::TkEndOfLine => return None, + _ => text.push_str(token.text()), + } + } + + Some(text) +} + fn render_doc_comment_lines(config: &LuaFormatConfig, lines: &[DocCommentLine]) -> Vec { let mut rendered = Vec::new(); let mut index = 0; @@ -877,7 +987,10 @@ pub fn collect_orphan_comments(config: &LuaFormatConfig, node: &LuaSyntaxNode) - } /// Extract a trailing comment on the same line after a syntax node. /// Returns the raw comment docs (NOT wrapped in LineSuffix) and the text range. -pub fn extract_trailing_comment(node: &LuaSyntaxNode) -> Option<(Vec, TextRange)> { +pub fn extract_trailing_comment( + config: &LuaFormatConfig, + node: &LuaSyntaxNode, +) -> Option<(Vec, TextRange)> { for child in node.children() { if child.kind() != LuaKind::Syntax(LuaSyntaxKind::Comment) || !has_non_trivia_before_on_same_line(&child) @@ -891,7 +1004,7 @@ pub fn extract_trailing_comment(node: &LuaSyntaxNode) -> Option<(Vec, Tex return None; } - let comment_text = render_single_line_comment_text(&comment) + let comment_text = render_single_line_comment_text(config, &comment) .unwrap_or_else(|| child.text().to_string().trim_end().to_string()); return Some((vec![ir::text(comment_text)], child.text_range())); @@ -915,7 +1028,7 @@ pub fn extract_trailing_comment(node: &LuaSyntaxNode) -> Option<(Vec, Tex return None; } - let comment_text = render_single_line_comment_text(&comment) + let comment_text = render_single_line_comment_text(config, &comment) .unwrap_or_else(|| comment_node.text().to_string().trim_end().to_string()); let range = comment_node.text_range(); @@ -950,12 +1063,25 @@ fn has_non_trivia_after_on_same_line(node: &LuaSyntaxNode) -> bool { false } -fn render_single_line_comment_text(comment: &LuaComment) -> Option { +fn render_single_line_comment_text( + config: &LuaFormatConfig, + comment: &LuaComment, +) -> Option { match classify_comment(comment) { CommentKind::Long => Some(comment.syntax().text().to_string().trim_end().to_string()), CommentKind::Normal => { - let description = comment.get_description()?; - let lines = render_normal_comment_lines(&description); + let parsed_lines = parse_normal_comment_lines(comment); + if parsed_lines.is_empty() { + return Some(apply_space_after_comment_dash( + &comment.syntax().text().to_string(), + config.comments.space_after_comment_dash, + )); + } + + let lines = render_normal_comment_lines( + &parsed_lines, + config.comments.space_after_comment_dash, + ); if lines.len() == 1 { lines.into_iter().next() } else { @@ -976,7 +1102,7 @@ pub fn format_trailing_comment( config: &LuaFormatConfig, node: &LuaSyntaxNode, ) -> Option<(DocIR, TextRange)> { - let (docs, range) = extract_trailing_comment(node)?; + let (docs, range) = extract_trailing_comment(config, node)?; let mut suffix_content = trailing_comment_prefix(config); suffix_content.extend(docs); Some((ir::line_suffix(suffix_content), range)) diff --git a/crates/emmylua_formatter/src/formatter/expression.rs b/crates/emmylua_formatter/src/formatter/expression.rs index 1300cd1d0..67fb23b71 100644 --- a/crates/emmylua_formatter/src/formatter/expression.rs +++ b/crates/emmylua_formatter/src/formatter/expression.rs @@ -1,19 +1,20 @@ use emmylua_parser::{ BinaryOperator, LuaAstNode, LuaAstToken, LuaBinaryExpr, LuaCallExpr, LuaClosureExpr, - LuaComment, LuaExpr, LuaIndexExpr, LuaIndexKey, LuaKind, LuaLiteralExpr, LuaNameExpr, - LuaParenExpr, LuaSingleArgExpr, LuaSyntaxKind, LuaSyntaxNode, LuaTableExpr, LuaTableField, - LuaTokenKind, LuaUnaryExpr, UnaryOperator, + LuaComment, LuaExpr, LuaIndexExpr, LuaIndexKey, LuaKind, LuaLiteralExpr, LuaLiteralToken, + LuaNameExpr, LuaParenExpr, LuaSingleArgExpr, LuaStringToken, LuaSyntaxKind, LuaSyntaxNode, + LuaTableExpr, LuaTableField, LuaTokenKind, LuaUnaryExpr, UnaryOperator, }; use rowan::TextRange; -use crate::config::ExpandStrategy; +use crate::config::{ExpandStrategy, QuoteStyle, SingleArgCallParens}; use crate::ir::{self, AlignEntry, DocIR, EqSplit}; use super::FormatContext; use super::comment::{extract_trailing_comment, format_comment, trailing_comment_prefix}; use super::sequence::{ DelimitedSequenceLayout, SequenceEntry, SequenceLayoutCandidates, SequenceLayoutPolicy, - choose_sequence_break_contents, choose_sequence_layout, format_delimited_sequence, + build_delimited_sequence_break_candidate, build_delimited_sequence_default_break_candidate, + build_delimited_sequence_flat_candidate, choose_sequence_layout, format_delimited_sequence, render_sequence, sequence_ends_with_comment, sequence_has_comment, sequence_starts_with_comment, }; @@ -60,10 +61,101 @@ fn format_name_expr(_ctx: &FormatContext, expr: &LuaNameExpr) -> Vec { } } -fn format_literal_expr(_ctx: &FormatContext, expr: &LuaLiteralExpr) -> Vec { +fn format_literal_expr(ctx: &FormatContext, expr: &LuaLiteralExpr) -> Vec { + if let Some(LuaLiteralToken::String(token)) = expr.get_literal() { + return format_string_literal(ctx, &token); + } + vec![ir::source_node(expr.syntax().clone())] } +fn format_string_literal(ctx: &FormatContext, token: &LuaStringToken) -> Vec { + let text = token.syntax().text().to_string(); + let Some(original_quote) = text.chars().next() else { + return vec![ir::source_token(token.syntax().clone())]; + }; + + if token.syntax().kind() == LuaTokenKind::TkLongString.into() + || !matches!(original_quote, '\'' | '"') + { + return vec![ir::source_token(token.syntax().clone())]; + } + + let preferred_quote = match ctx.config.output.quote_style { + QuoteStyle::Preserve => return vec![ir::source_token(token.syntax().clone())], + QuoteStyle::Double => '"', + QuoteStyle::Single => '\'', + }; + + if preferred_quote == original_quote { + return vec![ir::source_token(token.syntax().clone())]; + } + + let raw_body = &text[1..text.len() - 1]; + if raw_short_string_contains_unescaped_quote(raw_body, preferred_quote) { + return vec![ir::source_token(token.syntax().clone())]; + } + + vec![ir::text(rewrite_short_string_quotes( + raw_body, + original_quote, + preferred_quote, + ))] +} + +fn raw_short_string_contains_unescaped_quote(raw_body: &str, quote: char) -> bool { + let mut consecutive_backslashes = 0usize; + + for ch in raw_body.chars() { + if ch == '\\' { + consecutive_backslashes += 1; + continue; + } + + let is_escaped = consecutive_backslashes % 2 == 1; + consecutive_backslashes = 0; + + if ch == quote && !is_escaped { + return true; + } + } + + false +} + +fn rewrite_short_string_quotes(raw_body: &str, original_quote: char, quote: char) -> String { + let mut result = String::with_capacity(raw_body.len() + 2); + result.push(quote); + + let mut consecutive_backslashes = 0usize; + for ch in raw_body.chars() { + if ch == '\\' { + consecutive_backslashes += 1; + continue; + } + + if ch == original_quote && consecutive_backslashes % 2 == 1 { + for _ in 0..(consecutive_backslashes - 1) { + result.push('\\'); + } + } else { + for _ in 0..consecutive_backslashes { + result.push('\\'); + } + } + + consecutive_backslashes = 0; + result.push(ch); + } + + for _ in 0..consecutive_backslashes { + result.push('\\'); + } + + result.push(quote); + result +} + /// 二元表达式: a + b, a and b, ... /// /// 当表达式太长时,在操作符前断行并缩进: @@ -366,10 +458,8 @@ fn build_binary_chain_packed( for chunk in tail_segments.chunks(2) { let mut line = Vec::new(); for (index, (space_before_segment, segment)) in chunk.iter().enumerate() { - if index > 0 { - if *space_before_segment { - line.push(ir::space()); - } + if index > 0 && *space_before_segment { + line.push(ir::space()); } line.extend(segment.clone()); } @@ -633,25 +723,14 @@ fn format_call_args_ir(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { let mut docs = Vec::new(); if let Some(args_list) = expr.get_args_list() { - // 单参数简写 - if args_list.is_single_arg_no_parens() - && let Some(single_arg) = args_list.get_single_arg_expr() + let args: Vec<_> = args_list.get_args().collect(); + if let Some(single_arg_docs) = format_single_arg_call_without_parens(ctx, &args_list, &args) { - match single_arg { - LuaSingleArgExpr::TableExpr(table) => { - docs.push(ir::space()); - docs.extend(format_table_expr(ctx, &table)); - return docs; - } - LuaSingleArgExpr::LiteralExpr(lit) => { - docs.push(ir::space()); - docs.extend(format_literal_expr(ctx, &lit)); - return docs; - } - } + docs.push(ir::space()); + docs.extend(single_arg_docs); + return docs; } - let args: Vec<_> = args_list.get_args().collect(); if ctx.config.spacing.space_before_call_paren { docs.push(ir::space()); } @@ -748,6 +827,7 @@ fn format_call_args_ir(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { trailing, align_comments, has_standalone_comments, + source_line_prefix_width(args_list.syntax()), )); } else { let arg_docs: Vec> = @@ -940,7 +1020,7 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { }; let align_hint = field_requests_alignment(&field); let (trailing_comment, comment_align_hint) = - if let Some((docs, range)) = extract_trailing_comment(field.syntax()) { + if let Some((docs, range)) = extract_trailing_comment(ctx.config, field.syntax()) { consumed_comment_ranges.push(range); ( Some(docs), @@ -977,7 +1057,7 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { } // Trailing comma - let trailing = format_trailing_comma_ir(ctx.config.output.trailing_comma.clone()); + let trailing = format_trailing_comma_ir(ctx.config.trailing_table_comma()); let space_inside = if ctx.config.spacing.space_inside_braces { ir::soft_line() @@ -1007,6 +1087,7 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { ctx.config.align.table_field, true, has_standalone_comments, + source_line_prefix_width(expr.syntax()), ), ExpandStrategy::Never if !force_expand => { format_delimited_sequence(DelimitedSequenceLayout { @@ -1050,6 +1131,7 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { ctx.config.align.table_field, true, has_standalone_comments, + source_line_prefix_width(expr.syntax()), ) } ExpandStrategy::Auto if force_expand => { @@ -1061,110 +1143,90 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { ctx.config.align.table_field, true, has_standalone_comments, + source_line_prefix_width(expr.syntax()), ) } ExpandStrategy::Auto => { - if ctx.config.align.table_field - && entries.iter().any(|e| { - matches!( - e, - TableEntry::Field { - eq_split: Some(_), - .. - } - ) + let flat_field_docs: Vec> = entries + .iter() + .filter_map(|e| match e { + TableEntry::Field { doc, .. } => Some(doc.clone()), + TableEntry::StandaloneComment(_) => None, }) - { - let flat_field_docs: Vec> = entries - .iter() - .filter_map(|e| match e { - TableEntry::Field { doc, .. } => Some(doc.clone()), - TableEntry::StandaloneComment(_) => None, - }) - .collect(); - let break_inner = build_table_expanded_inner( - ctx, - &entries, - &trailing, - true, - ctx.config.should_align_table_line_comments(), - ); - let plain_break_inner = - build_table_expanded_inner(ctx, &entries, &trailing, false, false); - let break_inner = choose_sequence_break_contents( + .collect(); + let layout = DelimitedSequenceLayout { + open: tok(LuaTokenKind::TkLeftBrace), + close: tok(LuaTokenKind::TkRightBrace), + items: flat_field_docs, + strategy: ExpandStrategy::Auto, + preserve_multiline: false, + flat_separator: comma_space_sep(), + fill_separator: comma_soft_line_sep(), + break_separator: comma_soft_line_sep(), + flat_open_padding: if ctx.config.spacing.space_inside_braces { + vec![ir::space()] + } else { + vec![] + }, + flat_close_padding: if ctx.config.spacing.space_inside_braces { + vec![ir::space()] + } else { + vec![] + }, + grouped_padding: space_inside, + flat_trailing: vec![], + grouped_trailing: trailing.clone(), + custom_break_contents: None, + prefer_custom_break_in_auto: false, + }; + let has_assign_fields = entries.iter().any(|e| { + matches!( + e, + TableEntry::Field { + eq_split: Some(_), + .. + } + ) + }); + let has_assign_alignment = ctx.config.align.table_field && has_assign_fields; + + if has_assign_fields { + let aligned = has_assign_alignment.then(|| { + build_delimited_sequence_break_candidate( + layout.open.clone(), + layout.close.clone(), + build_table_expanded_inner( + ctx, + &entries, + &trailing, + true, + ctx.config.should_align_table_line_comments(), + ), + ) + }); + + choose_sequence_layout( ctx, SequenceLayoutCandidates { - aligned: Some(break_inner), - one_per_line: Some(plain_break_inner), + flat: Some(build_delimited_sequence_flat_candidate(&layout)), + aligned, + one_per_line: Some(build_delimited_sequence_default_break_candidate( + &layout, + )), ..Default::default() }, SequenceLayoutPolicy { - allow_alignment: true, + allow_alignment: has_assign_alignment, allow_fill: false, allow_preserve: false, - prefer_preserve_multiline: true, - force_break_on_standalone_comments: has_standalone_comments, + prefer_preserve_multiline: false, + force_break_on_standalone_comments: false, prefer_balanced_break_lines: false, - first_line_prefix_width: 0, - }, - ); - format_delimited_sequence(DelimitedSequenceLayout { - open: tok(LuaTokenKind::TkLeftBrace), - close: tok(LuaTokenKind::TkRightBrace), - items: flat_field_docs, - strategy: ExpandStrategy::Auto, - preserve_multiline: false, - flat_separator: comma_space_sep(), - fill_separator: comma_soft_line_sep(), - break_separator: comma_soft_line_sep(), - flat_open_padding: if ctx.config.spacing.space_inside_braces { - vec![ir::space()] - } else { - vec![] + first_line_prefix_width: source_line_prefix_width(expr.syntax()), }, - flat_close_padding: if ctx.config.spacing.space_inside_braces { - vec![ir::space()] - } else { - vec![] - }, - grouped_padding: space_inside.clone(), - flat_trailing: vec![], - grouped_trailing: trailing.clone(), - custom_break_contents: Some(break_inner), - prefer_custom_break_in_auto: true, - }) + ) } else { - format_delimited_sequence(DelimitedSequenceLayout { - open: tok(LuaTokenKind::TkLeftBrace), - close: tok(LuaTokenKind::TkRightBrace), - items: entries - .into_iter() - .filter_map(|e| match e { - TableEntry::Field { doc, .. } => Some(doc), - TableEntry::StandaloneComment(_) => None, - }) - .collect(), - strategy: ExpandStrategy::Auto, - preserve_multiline: false, - flat_separator: comma_space_sep(), - fill_separator: comma_soft_line_sep(), - break_separator: comma_soft_line_sep(), - flat_open_padding: if ctx.config.spacing.space_inside_braces { - vec![ir::space()] - } else { - vec![] - }, - flat_close_padding: if ctx.config.spacing.space_inside_braces { - vec![ir::space()] - } else { - vec![] - }, - grouped_padding: space_inside, - flat_trailing: vec![], - grouped_trailing: trailing, - custom_break_contents: None, - prefer_custom_break_in_auto: false, - }) + format_delimited_sequence(layout) } } } @@ -1177,6 +1239,7 @@ fn format_table_multiline_candidates( align_eq: bool, should_break: bool, has_standalone_comments: bool, + first_line_prefix_width: usize, ) -> Vec { let align_comments = ctx.config.should_align_table_line_comments(); let aligned = align_eq.then(|| { @@ -1207,7 +1270,7 @@ fn format_table_multiline_candidates( prefer_preserve_multiline: true, force_break_on_standalone_comments: has_standalone_comments, prefer_balanced_break_lines: false, - first_line_prefix_width: 0, + first_line_prefix_width, }, ) } else { @@ -1806,6 +1869,44 @@ fn format_trailing_comma_ir(policy: crate::config::TrailingComma) -> DocIR { } } +fn format_single_arg_call_without_parens( + ctx: &FormatContext, + args_list: &emmylua_parser::LuaCallArgList, + args: &[LuaExpr], +) -> Option> { + let single_arg = match ctx.config.output.single_arg_call_parens { + SingleArgCallParens::Always => None, + SingleArgCallParens::Preserve => args_list + .is_single_arg_no_parens() + .then(|| args_list.get_single_arg_expr()) + .flatten(), + SingleArgCallParens::Omit => args_list + .get_single_arg_expr() + .or_else(|| single_arg_expr_from_args(args)), + }?; + + Some(match single_arg { + LuaSingleArgExpr::TableExpr(table) => format_table_expr(ctx, &table), + LuaSingleArgExpr::LiteralExpr(lit) => format_literal_expr(ctx, &lit), + }) +} + +fn single_arg_expr_from_args(args: &[LuaExpr]) -> Option { + if args.len() != 1 { + return None; + } + + match &args[0] { + LuaExpr::TableExpr(table) => Some(LuaSingleArgExpr::TableExpr(table.clone())), + LuaExpr::LiteralExpr(lit) + if matches!(lit.get_literal(), Some(LuaLiteralToken::String(_))) => + { + Some(LuaSingleArgExpr::LiteralExpr(lit.clone())) + } + _ => None, + } +} + fn should_preserve_raw_call_expr(expr: &LuaCallExpr) -> bool { if node_has_direct_same_line_inline_comment(expr.syntax()) { return true; @@ -1874,6 +1975,7 @@ fn format_call_args_multiline_candidates( trailing: DocIR, align_comments: bool, has_standalone_comments: bool, + first_line_prefix_width: usize, ) -> Vec { let aligned = align_comments.then(|| { wrap_multiline_call_arg_docs( @@ -1900,7 +2002,7 @@ fn format_call_args_multiline_candidates( prefer_preserve_multiline: true, force_break_on_standalone_comments: has_standalone_comments, prefer_balanced_break_lines: false, - first_line_prefix_width: 0, + first_line_prefix_width, }, ) } @@ -1955,7 +2057,7 @@ fn collect_call_arg_entries( for child in args_list.syntax().children() { if let Some(arg) = LuaExpr::cast(child.clone()) { let (trailing_comment, align_hint) = - if let Some((docs, range)) = extract_trailing_comment(arg.syntax()) { + if let Some((docs, range)) = extract_trailing_comment(ctx.config, arg.syntax()) { consumed_comment_ranges.push(range); ( Some(docs), @@ -2100,6 +2202,7 @@ pub fn format_param_list_ir( format_trailing_comma_ir(ctx.config.output.trailing_comma.clone()), align_comments, has_standalone_comments, + source_line_prefix_width(params.syntax()), ) } else { let param_docs: Vec> = entries @@ -2173,6 +2276,7 @@ fn format_param_multiline_candidates( trailing: DocIR, align_comments: bool, has_standalone_comments: bool, + first_line_prefix_width: usize, ) -> Vec { let aligned = align_comments.then(|| { let mut align_entries = Vec::new(); @@ -2215,7 +2319,7 @@ fn format_param_multiline_candidates( prefer_preserve_multiline: true, force_break_on_standalone_comments: has_standalone_comments, prefer_balanced_break_lines: false, - first_line_prefix_width: 0, + first_line_prefix_width, }, ) } @@ -2262,7 +2366,7 @@ fn collect_param_entries( }; let (trailing_comment, align_hint) = - if let Some((docs, range)) = extract_trailing_comment(param.syntax()) { + if let Some((docs, range)) = extract_trailing_comment(ctx.config, param.syntax()) { consumed_comment_ranges.push(range); ( Some(docs), diff --git a/crates/emmylua_formatter/src/formatter/sequence.rs b/crates/emmylua_formatter/src/formatter/sequence.rs index 848e9807d..149f1318a 100644 --- a/crates/emmylua_formatter/src/formatter/sequence.rs +++ b/crates/emmylua_formatter/src/formatter/sequence.rs @@ -147,20 +147,6 @@ pub fn choose_sequence_layout( choose_best_sequence_candidate(ctx, ordered, policy) } -pub fn choose_sequence_break_contents( - ctx: &FormatContext, - candidates: SequenceLayoutCandidates, - policy: SequenceLayoutPolicy, -) -> Vec { - let ordered = ordered_sequence_candidates(candidates, policy); - - if ordered.is_empty() { - return vec![]; - } - - choose_best_sequence_candidate(ctx, ordered, policy) -} - fn ordered_sequence_candidates( candidates: SequenceLayoutCandidates, policy: SequenceLayoutPolicy, @@ -393,6 +379,80 @@ pub fn format_delimited_sequence(layout: DelimitedSequenceLayout) -> Vec } } +pub fn build_delimited_sequence_flat_candidate(layout: &DelimitedSequenceLayout) -> Vec { + let flat_inner = ir::intersperse(layout.items.clone(), layout.flat_separator.clone()); + build_flat_doc( + &layout.open, + &layout.close, + &layout.flat_open_padding, + flat_inner, + &layout.flat_trailing, + &layout.flat_close_padding, + ) +} + +pub fn build_delimited_sequence_default_break_candidate( + layout: &DelimitedSequenceLayout, +) -> Vec { + let break_inner = ir::intersperse(layout.items.clone(), layout.break_separator.clone()); + build_delimited_sequence_break_candidate( + layout.open.clone(), + layout.close.clone(), + default_break_contents(break_inner, layout.grouped_trailing.clone()), + ) +} + +pub fn build_delimited_sequence_break_candidate( + open: DocIR, + close: DocIR, + inner: Vec, +) -> Vec { + format_expanded_delimited_sequence(open, close, inner) +} + +fn format_expanded_delimited_sequence(open: DocIR, close: DocIR, inner: Vec) -> Vec { + vec![ir::group_break(vec![ + open, + ir::indent(inner), + ir::hard_line(), + close, + ])] +} + +fn default_break_contents(inner: Vec, trailing: DocIR) -> Vec { + vec![ir::hard_line(), ir::list(inner), trailing] +} + +fn build_flat_doc( + open: &DocIR, + close: &DocIR, + open_padding: &[DocIR], + inner: Vec, + trailing: &[DocIR], + close_padding: &[DocIR], +) -> Vec { + let mut docs = vec![open.clone()]; + docs.extend(open_padding.to_vec()); + docs.extend(inner); + docs.extend(trailing.to_vec()); + docs.extend(close_padding.to_vec()); + docs.push(close.clone()); + docs +} + +fn build_fill_parts(items: &[Vec], separator: &[DocIR]) -> Vec { + let mut parts = Vec::with_capacity(items.len().saturating_mul(2)); + + for (index, item) in items.iter().enumerate() { + parts.push(ir::list(item.clone())); + if index + 1 < items.len() { + parts.push(ir::list(separator.to_vec())); + } + } + + parts +} + #[cfg(test)] mod tests { use super::{ @@ -604,46 +664,3 @@ mod tests { ); } } - -fn format_expanded_delimited_sequence(open: DocIR, close: DocIR, inner: Vec) -> Vec { - vec![ir::group_break(vec![ - open, - ir::indent(inner), - ir::hard_line(), - close, - ])] -} - -fn default_break_contents(inner: Vec, trailing: DocIR) -> Vec { - vec![ir::hard_line(), ir::list(inner), trailing] -} - -fn build_flat_doc( - open: &DocIR, - close: &DocIR, - open_padding: &[DocIR], - inner: Vec, - trailing: &[DocIR], - close_padding: &[DocIR], -) -> Vec { - let mut docs = vec![open.clone()]; - docs.extend(open_padding.to_vec()); - docs.extend(inner); - docs.extend(trailing.to_vec()); - docs.extend(close_padding.to_vec()); - docs.push(close.clone()); - docs -} - -fn build_fill_parts(items: &[Vec], separator: &[DocIR]) -> Vec { - let mut parts = Vec::with_capacity(items.len().saturating_mul(2)); - - for (index, item) in items.iter().enumerate() { - parts.push(ir::list(item.clone())); - if index + 1 < items.len() { - parts.push(ir::list(separator.to_vec())); - } - } - - parts -} diff --git a/crates/emmylua_formatter/src/formatter/statement.rs b/crates/emmylua_formatter/src/formatter/statement.rs index cf5a2b026..67747afdc 100644 --- a/crates/emmylua_formatter/src/formatter/statement.rs +++ b/crates/emmylua_formatter/src/formatter/statement.rs @@ -6,6 +6,7 @@ use emmylua_parser::{ LuaTokenKind, LuaVarExpr, LuaWhileStat, }; +use crate::config::LuaFormatConfig; use crate::ir::{self, DocIR, EqSplit}; use super::FormatContext; @@ -1842,11 +1843,11 @@ fn should_preserve_raw_statement_with_inline_comments(stat: &LuaStat) -> bool { /// Check if a statement can participate in `=` alignment. /// Only simple local/assign statements with values qualify. -pub fn is_eq_alignable(stat: &LuaStat) -> bool { +pub fn is_eq_alignable(config: &LuaFormatConfig, stat: &LuaStat) -> bool { match stat { LuaStat::LocalStat(s) => { if node_has_direct_comment_child(s.syntax()) - && extract_trailing_comment(s.syntax()).is_none() + && extract_trailing_comment(config, s.syntax()).is_none() { return false; } @@ -1863,7 +1864,7 @@ pub fn is_eq_alignable(stat: &LuaStat) -> bool { } LuaStat::AssignStat(s) => { if node_has_direct_comment_child(s.syntax()) - && extract_trailing_comment(s.syntax()).is_none() + && extract_trailing_comment(config, s.syntax()).is_none() { return false; } diff --git a/crates/emmylua_formatter/src/lib.rs b/crates/emmylua_formatter/src/lib.rs index a88fcaab6..c74199908 100644 --- a/crates/emmylua_formatter/src/lib.rs +++ b/crates/emmylua_formatter/src/lib.rs @@ -12,7 +12,8 @@ use printer::Printer; pub use config::{ AlignConfig, CommentConfig, EmmyDocConfig, EndOfLine, ExpandStrategy, IndentConfig, IndentKind, - LayoutConfig, LuaFormatConfig, OutputConfig, SpacingConfig, TrailingComma, + LayoutConfig, LuaFormatConfig, OutputConfig, QuoteStyle, SingleArgCallParens, SpacingConfig, + TrailingComma, TrailingTableSeparator, }; pub use workspace::{ ChangedLineRange, FileCollectorOptions, FormatCheckPathResult, FormatCheckResult, FormatOutput, diff --git a/crates/emmylua_formatter/src/test/comment_tests.rs b/crates/emmylua_formatter/src/test/comment_tests.rs index 277d62f2f..206beb44a 100644 --- a/crates/emmylua_formatter/src/test/comment_tests.rs +++ b/crates/emmylua_formatter/src/test/comment_tests.rs @@ -21,6 +21,30 @@ local a = 1 assert_format!("local a = 1 -- trailing\n", "local a = 1 -- trailing\n"); } + #[test] + fn test_normal_comment_inserts_space_after_dash_by_default() { + assert_format!("--comment\nlocal a = 1\n", "-- comment\nlocal a = 1\n"); + } + + #[test] + fn test_normal_comment_can_keep_no_space_after_dash() { + use crate::{assert_format_with_config, config::LuaFormatConfig}; + + let config = LuaFormatConfig { + comments: crate::config::CommentConfig { + space_after_comment_dash: false, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "--comment\nlocal a = 1\n", + "--comment\nlocal a = 1\n", + config + ); + } + #[test] fn test_multiple_comments() { assert_format!( @@ -194,6 +218,24 @@ end ); } + #[test] + fn test_multiline_normal_comment_keeps_line_structure_from_comment_node() { + assert_format!( + r#" +-- alpha +-- beta gamma +--delta +local value = 1 +"#, + r#" +-- alpha +-- beta gamma +--delta +local value = 1 +"# + ); + } + // ========== param comments ========== #[test] @@ -1023,6 +1065,22 @@ local t = { ); } + #[test] + fn test_doc_comment_single_line_description_still_normalizes_whitespace() { + assert_format!( + "--- spaced words\nlocal value = nil\n", + "--- spaced words\nlocal value = nil\n" + ); + } + + #[test] + fn test_doc_comment_multiline_description_preserves_line_structure() { + assert_format!( + "---@class Test first line\n--- second line\nlocal value = {}\n", + "---@class Test first line\n--- second line\nlocal value = {}\n" + ); + } + #[test] fn test_doc_comment_align_generic_columns() { assert_format!( diff --git a/crates/emmylua_formatter/src/test/config_tests.rs b/crates/emmylua_formatter/src/test/config_tests.rs index 33ab6a6c2..e58cecc75 100644 --- a/crates/emmylua_formatter/src/test/config_tests.rs +++ b/crates/emmylua_formatter/src/test/config_tests.rs @@ -4,7 +4,8 @@ mod tests { assert_format_with_config, config::{ EndOfLine, ExpandStrategy, IndentConfig, IndentKind, LayoutConfig, LuaFormatConfig, - OutputConfig, SpacingConfig, TrailingComma, + OutputConfig, QuoteStyle, SingleArgCallParens, SpacingConfig, TrailingComma, + TrailingTableSeparator, }, }; @@ -188,6 +189,169 @@ local t = { ); } + #[test] + fn test_table_trailing_separator_can_override_global_trailing_comma() { + let config = LuaFormatConfig { + output: OutputConfig { + trailing_comma: TrailingComma::Never, + trailing_table_separator: TrailingTableSeparator::Multiline, + ..Default::default() + }, + layout: LayoutConfig { + table_expand: ExpandStrategy::Always, + call_args_expand: ExpandStrategy::Always, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "local t = { a = 1, b = 2 }\n", + "local t = {\n a = 1,\n b = 2,\n}\n", + config.clone() + ); + + assert_format_with_config!("foo(a, b)\n", "foo(\n a,\n b\n)\n", config); + } + + // ========== quote style =========== + + #[test] + fn test_quote_style_double_rewrites_short_strings() { + let config = LuaFormatConfig { + output: OutputConfig { + quote_style: QuoteStyle::Double, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!("local s = 'hello'\n", "local s = \"hello\"\n", config); + } + + #[test] + fn test_quote_style_double_allows_escaped_target_quotes_in_raw_text() { + let config = LuaFormatConfig { + output: OutputConfig { + quote_style: QuoteStyle::Double, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "local s = 'hello \\\"lua\\\"'\n", + "local s = \"hello \\\"lua\\\"\"\n", + config + ); + } + + #[test] + fn test_quote_style_single_preserves_when_target_quote_exists_in_value() { + let config = LuaFormatConfig { + output: OutputConfig { + quote_style: QuoteStyle::Single, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "local s = \"it's \\\"ok\\\"\"\n", + "local s = \"it's \\\"ok\\\"\"\n", + config + ); + } + + #[test] + fn test_quote_style_single_allows_escaped_target_quotes_in_raw_text() { + let config = LuaFormatConfig { + output: OutputConfig { + quote_style: QuoteStyle::Single, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "local s = \"it\\'s fine\"\n", + "local s = 'it\\'s fine'\n", + config + ); + } + + #[test] + fn test_quote_style_single_rewrites_when_value_has_no_target_quote() { + let config = LuaFormatConfig { + output: OutputConfig { + quote_style: QuoteStyle::Single, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "local s = \"hello \\\"lua\\\"\"\n", + "local s = 'hello \"lua\"'\n", + config + ); + } + + #[test] + fn test_quote_style_preserves_long_strings() { + let config = LuaFormatConfig { + output: OutputConfig { + quote_style: QuoteStyle::Single, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "local s = [[a\n\"b\"\n]]\n", + "local s = [[a\n\"b\"\n]]\n", + config + ); + } + + // ========== single arg call parens =========== + + #[test] + fn test_single_arg_call_parens_always_wraps_string_and_table_calls() { + let config = LuaFormatConfig { + output: OutputConfig { + single_arg_call_parens: SingleArgCallParens::Always, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "require \"module\"\n", + "require(\"module\")\n", + config.clone() + ); + assert_format_with_config!("foo {1, 2, 3}\n", "foo({ 1, 2, 3 })\n", config); + } + + #[test] + fn test_single_arg_call_parens_omit_removes_parens_for_string_and_table_calls() { + let config = LuaFormatConfig { + output: OutputConfig { + single_arg_call_parens: SingleArgCallParens::Omit, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "require(\"module\")\n", + "require \"module\"\n", + config.clone() + ); + assert_format_with_config!("foo({1, 2, 3})\n", "foo { 1, 2, 3 }\n", config); + } + // ========== indentation ========== #[test] @@ -395,11 +559,17 @@ width = 2 max_line_width = 88 table_expand = "Always" +[output] +quote_style = "Single" +trailing_table_separator = "Multiline" +single_arg_call_parens = "Always" + [spacing] space_before_call_paren = true [comments] align_line_comments = false +space_after_comment_dash = false [emmy_doc] space_after_description_dash = false @@ -414,8 +584,18 @@ table_field = false assert_eq!(config.indent.width, 2); assert_eq!(config.layout.max_line_width, 88); assert_eq!(config.layout.table_expand, ExpandStrategy::Always); + assert_eq!(config.output.quote_style, QuoteStyle::Single); + assert_eq!( + config.output.trailing_table_separator, + TrailingTableSeparator::Multiline + ); + assert_eq!( + config.output.single_arg_call_parens, + SingleArgCallParens::Always + ); assert!(config.spacing.space_before_call_paren); assert!(!config.comments.align_line_comments); + assert!(!config.comments.space_after_comment_dash); assert!(!config.emmy_doc.space_after_description_dash); assert!(!config.align.table_field); } diff --git a/docs/emmylua_formatter/examples_CN.md b/docs/emmylua_formatter/examples_CN.md index 9345acf30..6be343779 100644 --- a/docs/emmylua_formatter/examples_CN.md +++ b/docs/emmylua_formatter/examples_CN.md @@ -2,9 +2,11 @@ [English](./examples_EN.md) -本页给出一组有代表性的前后对比例子,用来说明当前格式化器的布局策略。 +本页按场景展示当前格式化器的典型布局结果。示例重点不是“所有代码都会变成同一种样子”,而是说明 formatter 会怎样在 flat、fill、packed、aligned 与 one-per-line 之间做选择。 -## 能放一行时保持单行 +## 1. 基础单行规整 + +### 能放一行时保持单行 Before: @@ -18,7 +20,11 @@ After: local point = { x = 1, y = 2 } ``` -## 调用参数优先使用 Progressive Fill +小而稳定的结构会优先保持单行,只做空格、逗号和分隔符的规范化。 + +## 2. 调用与参数序列 + +### 调用参数优先使用 Progressive Fill Before: @@ -37,7 +43,101 @@ some_function( 这种布局会尽量保持紧凑,而不是一开始就退到一项一行。 -## 二元表达式链的均衡 Packed 布局 +### 嵌套调用只让外层换行,内层保持紧凑 + +Before: + +```lua +cannotload("attempt to load a text chunk", load(read1(x), "modname", "b", {})) +``` + +After: + +```lua +cannotload( + "attempt to load a text chunk", + load(read1(x), "modname", "b", {}) +) +``` + +外层实参列表会根据行宽展开,但内部较短的子调用不会被连带打散。 + +### 函数参数中的尾随注释会被保留 + +Before: + +```lua +local f = function(a -- first +, b) + return a + b +end +``` + +After: + +```lua +local f = function(a -- first +, b) + return a + b +end +``` + +参数列表上的 inline comment 属于语义敏感区域,格式化器会优先保留原有结构。 + +## 3. 表构造 + +### 简短表保持紧凑 + +Before: + +```lua +local t = { a = 1, b = 2, c = 3 } +``` + +After: + +```lua +local t = { a = 1, b = 2, c = 3 } +``` + +### 关闭字段对齐后,Auto 模式使用渐进式换行 + +Before: + +```lua +local t = { alpha, beta, gamma, delta } +``` + +After: + +```lua +local t = { + alpha, beta, gamma, + delta +} +``` + +这类表不会因为换行就直接退成一项一行,而是先尝试更紧凑的分布。 + +### 嵌套表按结构决定是否展开 + +Before: + +```lua +local t = { user = { name = "a", age = 1 }, enabled = true } +``` + +After: + +```lua +local t = { user = { name = "a", age = 1 }, enabled = true } +``` + +格式化器不会因为“表里还有表”就机械地全部展开,而是先看整体形状和行宽。 + +## 4. 链式与表达式序列 + +### 二元表达式链使用更均衡的 Packed 布局 Before: @@ -53,9 +153,9 @@ local value = aaaa + bbbb + eeee + ffff ``` -现在 binary chain 的候选评分会把真实的首行前缀宽度也算进去,因此像 `local value =` 这样的长锚点会正确影响候选选择。 +binary chain 的候选评分会把真实的首行前缀宽度算进去,因此像 local value = 这样的长锚点会参与布局选择。 -## 语句表达式列表的均衡 Packed 布局 +### 语句表达式列表也会选择均衡 Packed 布局 Before: @@ -75,9 +175,9 @@ for key, value in first_long_expr, end ``` -这是 statement RHS 对 packed 布局的实际应用。第一项仍然贴在关键字所在行,后续项则按更均衡的方式打包。 +第一项仍然贴在关键字所在行,后续项按更均衡的方式打包,而不是简单退到一项一行。 -## 必要时退到一段一行 +### 必要时退到一段一行 Before: @@ -94,9 +194,11 @@ builder :build() ``` -当更窄的布局明显更差时,格式化器仍然会退到一段一行。 +当 fill 或 packed 的结果明显更差时,格式化器仍然会退到更窄的一段一行布局。 -## 注释对齐是输入驱动的 +## 5. 注释与保守策略 + +### 注释对齐是输入驱动的 Before: @@ -117,3 +219,23 @@ foo( ``` 只有当输入已经体现出对齐意图时,格式化器才会对齐尾随注释;它不会在无关代码中主动制造宽对齐块。 + +### 语句头部的 inline comment 会保留在头部 + +Before: + +```lua +if ready then -- inline comment + work() +end +``` + +After: + +```lua +if ready then -- inline comment + work() +end +``` + +这类注释如果被移动进语句体,会改变阅读语义,因此 formatter 会保守处理。 diff --git a/docs/emmylua_formatter/examples_EN.md b/docs/emmylua_formatter/examples_EN.md index 24c5243db..e2647d4ca 100644 --- a/docs/emmylua_formatter/examples_EN.md +++ b/docs/emmylua_formatter/examples_EN.md @@ -2,9 +2,11 @@ [中文文档](./examples_CN.md) -This page shows representative before-and-after examples for the formatter's current layout strategy. +This page groups representative before-and-after examples by scenario. The point is not that every construct is formatted the same way, but that the formatter chooses between flat, fill, packed, aligned, and one-per-line layouts based on the rendered result. -## Flat When It Fits +## 1. Basic Flat Formatting + +### Flat when it fits Before: @@ -18,7 +20,11 @@ After: local point = { x = 1, y = 2 } ``` -## Progressive Fill For Call Arguments +Small stable structures stay on one line, with spacing and separators normalized. + +## 2. Calls And Parameter Lists + +### Progressive fill for call arguments Before: @@ -37,7 +43,101 @@ some_function( This keeps the argument list compact without immediately forcing one argument per line. -## Balanced Packed Layout For Binary Chains +### Outer calls may break while inner calls stay compact + +Before: + +```lua +cannotload("attempt to load a text chunk", load(read1(x), "modname", "b", {})) +``` + +After: + +```lua +cannotload( + "attempt to load a text chunk", + load(read1(x), "modname", "b", {}) +) +``` + +The outer call expands because of width pressure, but short nested calls are not blown apart unnecessarily. + +### Inline comments in parameter lists are preserved + +Before: + +```lua +local f = function(a -- first +, b) + return a + b +end +``` + +After: + +```lua +local f = function(a -- first +, b) + return a + b +end +``` + +Inline comments in parameter lists are treated conservatively because rewriting them can change how the signature reads. + +## 3. Table Constructors + +### Small tables stay compact + +Before: + +```lua +local t = { a = 1, b = 2, c = 3 } +``` + +After: + +```lua +local t = { a = 1, b = 2, c = 3 } +``` + +### Auto mode uses progressive breaking when field alignment is off + +Before: + +```lua +local t = { alpha, beta, gamma, delta } +``` + +After: + +```lua +local t = { + alpha, beta, gamma, + delta +} +``` + +The formatter tries a compact multi-line distribution before falling back to one item per line. + +### Nested tables expand by shape, not by blanket rules + +Before: + +```lua +local t = { user = { name = "a", age = 1 }, enabled = true } +``` + +After: + +```lua +local t = { user = { name = "a", age = 1 }, enabled = true } +``` + +Having a nested table is not enough on its own to force full expansion. + +## 4. Chains And Expression Sequences + +### Balanced packed layout for binary chains Before: @@ -53,9 +153,9 @@ local value = aaaa + bbbb + eeee + ffff ``` -The formatter now scores binary-chain candidates with the real first-line prefix width, so long anchors such as `local value =` influence candidate selection correctly. +Binary-chain candidates are scored with the real first-line prefix width, so long anchors such as local value = affect candidate selection. -## Balanced Packed Layout For Statement Expression Lists +### Statement expression lists also use balanced packed layouts Before: @@ -75,9 +175,9 @@ for key, value in first_long_expr, end ``` -This is the statement-level counterpart to packed binary chains. It keeps the first item attached to the keyword line and then packs later items in a balanced way. +This keeps the first item attached to the keyword line and then packs later items more evenly. -## One Segment Per Line When Necessary +### One segment per line when necessary Before: @@ -94,9 +194,11 @@ builder :build() ``` -When narrower layouts are clearly worse, the formatter still falls back to one segment per line. +When fill or packed layouts are clearly worse, the formatter still falls back to one segment per line. -## Comment Alignment Is Input-Driven +## 5. Comments And Conservative Preservation + +### Comment alignment is input-driven Before: @@ -116,4 +218,24 @@ foo( ) ``` -The formatter aligns trailing comments only when the input already indicates alignment intent. It does not manufacture wide alignment blocks in unrelated code. +Trailing comments are aligned only when the input already signals alignment intent. The formatter does not manufacture wide alignment blocks across unrelated code. + +### Inline comments on statement headers stay on the header + +Before: + +```lua +if ready then -- inline comment + work() +end +``` + +After: + +```lua +if ready then -- inline comment + work() +end +``` + +Moving this kind of comment into the body changes how the control flow reads, so the formatter preserves the header structure. diff --git a/docs/emmylua_formatter/options_CN.md b/docs/emmylua_formatter/options_CN.md index 6e9888fbb..65160456d 100644 --- a/docs/emmylua_formatter/options_CN.md +++ b/docs/emmylua_formatter/options_CN.md @@ -59,6 +59,9 @@ func_params_expand = "Auto" - `insert_final_newline` - `trailing_comma`:`Never`、`Multiline`、`Always` +- `trailing_table_separator`:`Inherit`、`Never`、`Multiline`、`Always` +- `quote_style`:`Preserve`、`Double`、`Single` +- `single_arg_call_parens`:`Preserve`、`Always`、`Omit` - `end_of_line`:`LF` 或 `CRLF` 默认值: @@ -67,9 +70,20 @@ func_params_expand = "Auto" [output] insert_final_newline = true trailing_comma = "Never" +trailing_table_separator = "Inherit" +quote_style = "Preserve" +single_arg_call_parens = "Preserve" end_of_line = "LF" ``` +行为说明: + +- `trailing_comma` 是通用序列的尾逗号策略。 +- `trailing_table_separator` 只覆盖 table 的尾部分隔符策略;设为 `Inherit` 时继承 `trailing_comma`。 +- `quote_style` 只会在安全时重写普通短字符串;长字符串和其它字符串形式会保留原样。 +- 引号重写基于原始 token 文本判断是否存在未转义的目标引号,并只做保持语义不变所需的最小分隔符转义调整。 +- `single_arg_call_parens = "Omit"` 只会对 Lua 允许的单字符串参数调用和单 table 参数调用去掉括号。 + ## spacing - `space_before_call_paren` @@ -92,6 +106,7 @@ end_of_line = "LF" - `align_in_params` - `align_across_standalone_comments` - `align_same_kind_only` +- `space_after_comment_dash` - `line_comment_min_spaces_before` - `line_comment_min_column` @@ -106,6 +121,7 @@ align_in_call_args = true align_in_params = true align_across_standalone_comments = false align_same_kind_only = false +space_after_comment_dash = true line_comment_min_spaces_before = 1 line_comment_min_column = 0 ``` @@ -116,6 +132,7 @@ line_comment_min_column = 0 - table、调用参数、函数参数中的尾随注释对齐是输入驱动的;只有源代码已经体现出额外空格的对齐意图时,才会启用。 - standalone comment 默认会打断对齐分组。 - table 字段尾随注释只在连续子组内部对齐,不会拖动整个表体。 +- `space_after_comment_dash` 只会在普通 `--comment` 这类“前缀后完全没有空格”的情况下补一个空格;已有多个空格的注释会保留原样。 ## emmy_doc diff --git a/docs/emmylua_formatter/options_EN.md b/docs/emmylua_formatter/options_EN.md index a14fc84c9..5531b1198 100644 --- a/docs/emmylua_formatter/options_EN.md +++ b/docs/emmylua_formatter/options_EN.md @@ -59,6 +59,9 @@ Behavior notes: - `insert_final_newline` - `trailing_comma`: `Never`, `Multiline`, or `Always` +- `trailing_table_separator`: `Inherit`, `Never`, `Multiline`, or `Always` +- `quote_style`: `Preserve`, `Double`, or `Single` +- `single_arg_call_parens`: `Preserve`, `Always`, or `Omit` - `end_of_line`: `LF` or `CRLF` Default: @@ -67,9 +70,20 @@ Default: [output] insert_final_newline = true trailing_comma = "Never" +trailing_table_separator = "Inherit" +quote_style = "Preserve" +single_arg_call_parens = "Preserve" end_of_line = "LF" ``` +Behavior notes: + +- `trailing_comma` is the general trailing-comma policy for sequence-like constructs. +- `trailing_table_separator` overrides that policy for tables only. `Inherit` keeps using `trailing_comma`. +- `quote_style` only rewrites normal short strings when it is safe to do so. Long strings and other string forms are preserved. +- Quote rewriting works from the raw token text, checks for unescaped occurrences of the target delimiter, and only adjusts the minimal delimiter escaping needed to preserve semantics. +- `single_arg_call_parens = "Omit"` only removes parentheses for Lua-valid single-string and single-table calls. + ## spacing - `space_before_call_paren` @@ -92,6 +106,7 @@ These options control token spacing only. They do not override larger layout dec - `align_in_params` - `align_across_standalone_comments` - `align_same_kind_only` +- `space_after_comment_dash` - `line_comment_min_spaces_before` - `line_comment_min_column` @@ -106,6 +121,7 @@ align_in_call_args = true align_in_params = true align_across_standalone_comments = false align_same_kind_only = false +space_after_comment_dash = true line_comment_min_spaces_before = 1 line_comment_min_column = 0 ``` @@ -116,6 +132,7 @@ Behavior notes: - Table, call-arg, and parameter trailing-comment alignment are input-driven. Extra spacing in the original source is treated as alignment intent. - Standalone comments usually break alignment groups. - Table-field trailing-comment alignment is scoped to contiguous subgroups rather than the whole table. +- `space_after_comment_dash` only inserts one space for plain comments such as `--comment` when there is no gap after the prefix already; comments with larger existing gaps are preserved. ## emmy_doc diff --git a/docs/emmylua_formatter/profiles_CN.md b/docs/emmylua_formatter/profiles_CN.md index 951293d17..dace30b35 100644 --- a/docs/emmylua_formatter/profiles_CN.md +++ b/docs/emmylua_formatter/profiles_CN.md @@ -21,6 +21,11 @@ table_expand = "Auto" call_args_expand = "Auto" func_params_expand = "Auto" +[output] +quote_style = "Preserve" +trailing_table_separator = "Multiline" +single_arg_call_parens = "Preserve" + [comments] align_in_statements = false align_in_table_fields = true @@ -56,6 +61,11 @@ table_expand = "Auto" call_args_expand = "Auto" func_params_expand = "Auto" +[output] +quote_style = "Double" +trailing_table_separator = "Multiline" +single_arg_call_parens = "Always" + [spacing] space_inside_braces = true space_around_math_operator = true @@ -95,6 +105,11 @@ table_expand = "Auto" call_args_expand = "Auto" func_params_expand = "Auto" +[output] +quote_style = "Preserve" +trailing_table_separator = "Multiline" +single_arg_call_parens = "Preserve" + [comments] align_in_statements = true align_in_table_fields = true diff --git a/docs/emmylua_formatter/profiles_EN.md b/docs/emmylua_formatter/profiles_EN.md index b60da257d..a99505eae 100644 --- a/docs/emmylua_formatter/profiles_EN.md +++ b/docs/emmylua_formatter/profiles_EN.md @@ -21,6 +21,11 @@ table_expand = "Auto" call_args_expand = "Auto" func_params_expand = "Auto" +[output] +quote_style = "Preserve" +trailing_table_separator = "Multiline" +single_arg_call_parens = "Preserve" + [comments] align_in_statements = false align_in_table_fields = true @@ -56,6 +61,11 @@ table_expand = "Auto" call_args_expand = "Auto" func_params_expand = "Auto" +[output] +quote_style = "Double" +trailing_table_separator = "Multiline" +single_arg_call_parens = "Always" + [spacing] space_inside_braces = true space_around_math_operator = true @@ -95,6 +105,11 @@ table_expand = "Auto" call_args_expand = "Auto" func_params_expand = "Auto" +[output] +quote_style = "Preserve" +trailing_table_separator = "Multiline" +single_arg_call_parens = "Preserve" + [comments] align_in_statements = true align_in_table_fields = true diff --git a/docs/emmylua_formatter/tutorial_CN.md b/docs/emmylua_formatter/tutorial_CN.md index ded913093..04d5c292a 100644 --- a/docs/emmylua_formatter/tutorial_CN.md +++ b/docs/emmylua_formatter/tutorial_CN.md @@ -25,6 +25,11 @@ table_expand = "Auto" call_args_expand = "Auto" func_params_expand = "Auto" +[output] +quote_style = "Preserve" +trailing_table_separator = "Multiline" +single_arg_call_parens = "Preserve" + [comments] align_in_statements = false align_in_table_fields = true @@ -38,6 +43,10 @@ table_field = true 格式化器会为每个文件向上查找最近的 `.luafmt.toml` 或 `luafmt.toml`。 +如果你希望只让竖排 table 默认带尾逗号,但不影响调用参数和函数参数,可以只设置 `output.trailing_table_separator = "Multiline"`。 + +如果你希望统一短字符串引号,可以设置 `output.quote_style = "Double"` 或 `"Single"`。长字符串会继续保留原样。 + ## 3. 格式化文件 直接写回目录中的文件: diff --git a/docs/emmylua_formatter/tutorial_EN.md b/docs/emmylua_formatter/tutorial_EN.md index 1fccc0dd4..24ad77758 100644 --- a/docs/emmylua_formatter/tutorial_EN.md +++ b/docs/emmylua_formatter/tutorial_EN.md @@ -25,6 +25,11 @@ table_expand = "Auto" call_args_expand = "Auto" func_params_expand = "Auto" +[output] +quote_style = "Preserve" +trailing_table_separator = "Multiline" +single_arg_call_parens = "Preserve" + [comments] align_in_statements = false align_in_table_fields = true @@ -38,6 +43,10 @@ table_field = true The formatter discovers the nearest `.luafmt.toml` or `luafmt.toml` for each file. +If you want vertically expanded tables to carry trailing separators by default without changing call arguments or parameter lists, set `output.trailing_table_separator = "Multiline"`. + +If you want to normalize short-string quoting, set `output.quote_style = "Double"` or `"Single"`. Long strings are preserved. + ## 3. Format Files Format a directory in place: