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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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: From 0892b40adcfd013b280fd0d62e1e67ad28f489a1 Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Mon, 23 Mar 2026 11:17:06 +0800 Subject: [PATCH 11/23] fix performance issue --- Cargo.toml | 1 + crates/emmylua_formatter/Cargo.toml | 7 +- .../src/formatter/expression.rs | 97 +++++++++---------- .../src/formatter/sequence.rs | 19 +++- .../src/formatter/statement.rs | 57 ++++++----- .../emmylua_formatter/src/formatter/trivia.rs | 70 +++++++++++++ crates/emmylua_formatter/src/ir/doc_ir.rs | 51 ++++++++++ crates/emmylua_formatter/src/lib.rs | 1 + .../src/test/expression_tests.rs | 8 ++ 9 files changed, 225 insertions(+), 86 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2802d0c94..abafc8b36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,7 @@ reqwest = { version = "0.13.1", default-features = false, features = [ "system-proxy", "native-tls-vendored", ]} +similar = { version = "2.7.0", features = ["inline"] } # Lint configuration for the entire workspace [workspace.lints.clippy] diff --git a/crates/emmylua_formatter/Cargo.toml b/crates/emmylua_formatter/Cargo.toml index 29248ade8..0700531d9 100644 --- a/crates/emmylua_formatter/Cargo.toml +++ b/crates/emmylua_formatter/Cargo.toml @@ -13,7 +13,6 @@ 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 @@ -23,10 +22,14 @@ optional = true workspace = true optional = true +[dependencies.similar] +workspace = true +optional = true + [[bin]] name = "luafmt" required-features = ["cli"] [features] default = ["cli"] -cli = ["dep:clap", "dep:mimalloc"] +cli = ["dep:clap", "dep:mimalloc", "dep:similar"] diff --git a/crates/emmylua_formatter/src/formatter/expression.rs b/crates/emmylua_formatter/src/formatter/expression.rs index 67fb23b71..736f17f0e 100644 --- a/crates/emmylua_formatter/src/formatter/expression.rs +++ b/crates/emmylua_formatter/src/formatter/expression.rs @@ -7,7 +7,7 @@ use emmylua_parser::{ use rowan::TextRange; use crate::config::{ExpandStrategy, QuoteStyle, SingleArgCallParens}; -use crate::ir::{self, AlignEntry, DocIR, EqSplit}; +use crate::ir::{self, AlignEntry, DocIR, EqSplit, ir_has_forced_line_break}; use super::FormatContext; use super::comment::{extract_trailing_comment, format_comment, trailing_comment_prefix}; @@ -20,7 +20,10 @@ use super::sequence::{ }; 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}; +use super::trivia::{ + node_has_direct_comment_child, node_has_direct_same_line_inline_comment, + source_line_prefix_width, trailing_gap_requests_alignment, +}; struct BinaryExprSplit { lhs_entries: Vec, @@ -356,22 +359,6 @@ fn try_format_flat_binary_chain(ctx: &FormatContext, expr: &LuaBinaryExpr) -> Op )) } -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, @@ -832,23 +819,44 @@ fn format_call_args_ir(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { } else { let arg_docs: Vec> = args.iter().map(|a| format_expr(ctx, a)).collect(); - 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, - })); + if arg_docs.iter().any(|doc| ir_has_forced_line_break(doc)) { + let multiline_entries = arg_docs + .into_iter() + .enumerate() + .map(|(index, doc)| CallArgEntry::Arg { + doc, + trailing_comment: None, + align_hint: false, + has_following_arg: index + 1 < args.len(), + }) + .collect(); + docs.extend(format_call_args_multiline_candidates( + ctx, + multiline_entries, + trailing, + false, + false, + source_line_prefix_width(args_list.syntax()), + )); + } else { + 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, + })); + } } } } @@ -2012,24 +2020,7 @@ fn trailing_comment_requests_alignment( 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 + trailing_gap_requests_alignment(node, comment_range, required_min_gap) } fn call_arg_group_requests_alignment(entries: &[CallArgEntry]) -> bool { diff --git a/crates/emmylua_formatter/src/formatter/sequence.rs b/crates/emmylua_formatter/src/formatter/sequence.rs index 149f1318a..669806fde 100644 --- a/crates/emmylua_formatter/src/formatter/sequence.rs +++ b/crates/emmylua_formatter/src/formatter/sequence.rs @@ -1,7 +1,7 @@ use emmylua_parser::LuaTokenKind; use crate::config::ExpandStrategy; -use crate::ir::{self, DocIR}; +use crate::ir::{self, DocIR, ir_flat_width, ir_has_forced_line_break}; use crate::printer::Printer; use super::FormatContext; @@ -144,6 +144,23 @@ pub fn choose_sequence_layout( return vec![]; } + if ordered.len() == 1 { + return ordered + .into_iter() + .next() + .map(|candidate| candidate.docs) + .unwrap_or_default(); + } + + if let Some(flat_candidate) = ordered.first() + && flat_candidate.kind == SequenceLayoutKind::Flat + && !ir_has_forced_line_break(&flat_candidate.docs) + && ir_flat_width(&flat_candidate.docs) + policy.first_line_prefix_width + <= ctx.config.layout.max_line_width + { + return flat_candidate.docs.clone(); + } + choose_best_sequence_candidate(ctx, ordered, policy) } diff --git a/crates/emmylua_formatter/src/formatter/statement.rs b/crates/emmylua_formatter/src/formatter/statement.rs index 67747afdc..f39b2b4c6 100644 --- a/crates/emmylua_formatter/src/formatter/statement.rs +++ b/crates/emmylua_formatter/src/formatter/statement.rs @@ -20,7 +20,10 @@ use super::sequence::{ }; 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}; +use super::trivia::{ + node_has_direct_comment_child, node_has_direct_same_line_inline_comment, + source_line_prefix_width, syntax_has_descendant_comment, +}; /// Format a statement (dispatch) pub fn format_stat(ctx: &FormatContext, stat: &LuaStat) -> Vec { @@ -765,8 +768,7 @@ fn should_preserve_raw_if_stat_trivia_aware(ctx: &FormatContext, stat: &LuaIfSta } fn should_preserve_raw_if_stat_with_comments(stat: &LuaIfStat) -> bool { - let text = stat.syntax().text().to_string(); - text.contains("elseif") && text.contains("--") + stat.get_else_if_clause_list().next().is_some() && syntax_has_descendant_comment(stat.syntax()) } fn should_preserve_raw_if_header_inline_comment(stat: &LuaIfStat) -> bool { @@ -860,22 +862,33 @@ fn try_format_raw_clause_header_until_block( block: Option<&LuaBlock>, ) -> Option> { let block = block?; - let text = syntax.text().to_string(); - if !text.contains("--") { + if !syntax_has_descendant_comment(syntax) { return None; } - let start = syntax.text_range().start(); - let block_start = block.syntax().text_range().start(); - if block_start <= start { - return None; + let mut header = String::new(); + let block_range = block.syntax().text_range(); + for child in syntax.children_with_tokens() { + if let Some(node) = child.as_node() + && node.text_range() == block_range + { + break; + } + + match child { + rowan::NodeOrToken::Node(node) => { + node.text().for_each_chunk(|chunk| header.push_str(chunk)); + } + rowan::NodeOrToken::Token(token) => header.push_str(token.text()), + } } - 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())]) + let header = header.trim_end_matches(['\r', '\n', ' ', '\t']); + if header.is_empty() { + None + } else { + Some(vec![ir::text(header.to_string())]) + } } fn try_preserve_single_line_if_body(ctx: &FormatContext, stat: &LuaIfStat) -> Option> { @@ -1440,22 +1453,6 @@ fn format_statement_expr_list( ) } -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_statement_expr_fill_parts( leading_docs: Vec, expr_docs: Vec>, diff --git a/crates/emmylua_formatter/src/formatter/trivia.rs b/crates/emmylua_formatter/src/formatter/trivia.rs index 886c3e5f4..676cbd677 100644 --- a/crates/emmylua_formatter/src/formatter/trivia.rs +++ b/crates/emmylua_formatter/src/formatter/trivia.rs @@ -1,4 +1,5 @@ use emmylua_parser::{LuaKind, LuaSyntaxKind, LuaSyntaxNode, LuaTokenKind}; +use rowan::TextRange; /// Count how many blank lines appear before a node. pub fn count_blank_lines_before(node: &LuaSyntaxNode) -> usize { @@ -58,3 +59,72 @@ pub fn has_non_trivia_before_on_same_line(node: &LuaSyntaxNode) -> bool { false } + +pub fn source_line_prefix_width(node: &LuaSyntaxNode) -> usize { + let mut width = 0usize; + let Some(mut token) = node.first_token() else { + return 0; + }; + + while let Some(prev) = token.prev_token() { + let text = prev.text(); + let mut chars_since_break = 0usize; + + for ch in text.chars().rev() { + if matches!(ch, '\n' | '\r') { + return width; + } + chars_since_break += 1; + } + + width += chars_since_break; + token = prev; + } + + width +} + +pub fn syntax_has_descendant_comment(node: &LuaSyntaxNode) -> bool { + node.descendants() + .any(|child| child.kind() == LuaKind::Syntax(LuaSyntaxKind::Comment)) +} + +pub fn trailing_gap_requests_alignment( + node: &LuaSyntaxNode, + comment_range: TextRange, + required_min_gap: usize, +) -> bool { + let mut gap_width = 0usize; + let mut next = node.next_sibling_or_token(); + + while let Some(element) = next { + if element.text_range().start() >= comment_range.start() { + break; + } + + match element.kind() { + LuaKind::Token(LuaTokenKind::TkEndOfLine) => return false, + LuaKind::Token(LuaTokenKind::TkWhitespace) => { + if let Some(token) = element.as_token() { + for ch in token.text().chars() { + if matches!(ch, '\n' | '\r') { + return false; + } + if matches!(ch, ' ' | '\t') { + gap_width += 1; + } + } + } + } + _ => { + if element.text_range().end() > comment_range.start() { + return false; + } + } + } + + next = element.next_sibling_or_token(); + } + + gap_width > required_min_gap +} diff --git a/crates/emmylua_formatter/src/ir/doc_ir.rs b/crates/emmylua_formatter/src/ir/doc_ir.rs index 219419d5c..67e71afc1 100644 --- a/crates/emmylua_formatter/src/ir/doc_ir.rs +++ b/crates/emmylua_formatter/src/ir/doc_ir.rs @@ -149,6 +149,57 @@ pub fn ir_flat_width(docs: &[DocIR]) -> usize { .sum() } +pub fn ir_has_forced_line_break(docs: &[DocIR]) -> bool { + docs.iter().any(doc_has_forced_line_break) +} + +fn doc_has_forced_line_break(doc: &DocIR) -> bool { + match doc { + DocIR::HardLine => true, + DocIR::Indent(items) | DocIR::List(items) => ir_has_forced_line_break(items), + DocIR::Group { contents, .. } => ir_has_forced_line_break(contents), + DocIR::IfBreak { + break_contents, + flat_contents, + .. + } => { + doc_has_forced_line_break(break_contents.as_ref()) + || doc_has_forced_line_break(flat_contents.as_ref()) + } + DocIR::Fill { parts } => ir_has_forced_line_break(parts), + DocIR::LineSuffix(contents) => ir_has_forced_line_break(contents), + DocIR::AlignGroup(group) => { + group.entries.len() > 1 + || group.entries.iter().any(|entry| match entry { + AlignEntry::Aligned { + before, + after, + trailing, + } => { + ir_has_forced_line_break(before) + || ir_has_forced_line_break(after) + || trailing + .as_ref() + .is_some_and(|trail| ir_has_forced_line_break(trail)) + } + AlignEntry::Line { content, trailing } => { + ir_has_forced_line_break(content) + || trailing + .as_ref() + .is_some_and(|trail| ir_has_forced_line_break(trail)) + } + }) + } + DocIR::Text(_) + | DocIR::SourceNode { .. } + | DocIR::SourceToken(_) + | DocIR::SyntaxToken(_) + | DocIR::SoftLine + | DocIR::SoftLineOrEmpty + | DocIR::Space => false, + } +} + pub fn syntax_text_len(text: &SyntaxText, trim_end: bool) -> usize { let len = text.len(); let end = if trim_end { diff --git a/crates/emmylua_formatter/src/lib.rs b/crates/emmylua_formatter/src/lib.rs index c74199908..05ead1543 100644 --- a/crates/emmylua_formatter/src/lib.rs +++ b/crates/emmylua_formatter/src/lib.rs @@ -1,3 +1,4 @@ +#![cfg(feature = "cli")] pub mod cmd_args; pub mod config; mod formatter; diff --git a/crates/emmylua_formatter/src/test/expression_tests.rs b/crates/emmylua_formatter/src/test/expression_tests.rs index 4ff53505a..5b6bb6f0a 100644 --- a/crates/emmylua_formatter/src/test/expression_tests.rs +++ b/crates/emmylua_formatter/src/test/expression_tests.rs @@ -270,6 +270,14 @@ local b = t[1] ); } + #[test] + fn test_callback_arg_with_multiline_closure_breaks_one_arg_per_line() { + assert_format!( + "check(function()\n return not not k3\nend, 'LOADTRUE', 'RETURN1')\n", + "check(\n function()\n return not not k3\n end,\n 'LOADTRUE',\n 'RETURN1'\n)\n" + ); + } + #[test] fn test_table_auto_without_alignment_uses_progressive_fill() { let config = LuaFormatConfig { From c605bc0026eaac6a6ea94cba17d113090cb04111 Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Mon, 23 Mar 2026 14:22:21 +0800 Subject: [PATCH 12/23] update --- .../src/formatter/expression.rs | 758 ++++++++++++------ .../src/formatter/statement.rs | 204 ++++- .../src/test/expression_tests.rs | 99 ++- .../src/test/statement_tests.rs | 56 ++ 4 files changed, 836 insertions(+), 281 deletions(-) diff --git a/crates/emmylua_formatter/src/formatter/expression.rs b/crates/emmylua_formatter/src/formatter/expression.rs index 736f17f0e..40a78ea45 100644 --- a/crates/emmylua_formatter/src/formatter/expression.rs +++ b/crates/emmylua_formatter/src/formatter/expression.rs @@ -7,7 +7,7 @@ use emmylua_parser::{ use rowan::TextRange; use crate::config::{ExpandStrategy, QuoteStyle, SingleArgCallParens}; -use crate::ir::{self, AlignEntry, DocIR, EqSplit, ir_has_forced_line_break}; +use crate::ir::{self, AlignEntry, DocIR, EqSplit, ir_flat_width, ir_has_forced_line_break}; use super::FormatContext; use super::comment::{extract_trailing_comment, format_comment, trailing_comment_prefix}; @@ -56,6 +56,249 @@ pub fn format_expr(ctx: &FormatContext, expr: &LuaExpr) -> Vec { } } +fn format_table_expr_with_forced_expand( + ctx: &FormatContext, + expr: &LuaTableExpr, + force_expand_from_context: bool, +) -> Vec { + if expr.is_empty() { + return vec![ + tok(LuaTokenKind::TkLeftBrace), + tok(LuaTokenKind::TkRightBrace), + ]; + } + + 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 force_expand = field + .get_value_expr() + .as_ref() + .is_some_and(should_preserve_multiline_table_field_value); + let eq_split = if ctx.config.align.table_field { + format_table_field_eq_split(ctx, &field) + } else { + None + }; + let align_hint = field_requests_alignment(&field); + let (trailing_comment, comment_align_hint) = + if let Some((docs, range)) = extract_trailing_comment(ctx.config, field.syntax()) { + consumed_comment_ranges.push(range); + ( + Some(docs), + trailing_comment_requests_alignment( + field.syntax(), + range, + ctx.config.comments.line_comment_min_spaces_before.max(1), + ), + ) + } else { + (None, false) + }; + entries.push(TableEntry::Field { + doc: fdoc, + eq_split, + force_expand, + align_hint, + comment_align_hint, + trailing_comment, + }); + } else if child.kind() == LuaKind::Syntax(LuaSyntaxKind::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( + ctx.config, &comment, + ))); + has_standalone_comments = true; + } + } + + let trailing = format_trailing_comma_ir(ctx.config.trailing_table_comma()); + + let space_inside = if ctx.config.spacing.space_inside_braces { + ir::soft_line() + } else { + ir::soft_line_or_empty() + }; + + let has_trailing_comments = entries.iter().any(|e| { + matches!( + e, + TableEntry::Field { + trailing_comment: Some(_), + .. + } + ) + }); + + let has_multiline_field_docs = entries.iter().any(|entry| match entry { + TableEntry::Field { + doc, force_expand, .. + } => *force_expand || ir_has_forced_line_break(doc), + TableEntry::StandaloneComment(_) => false, + }); + + let force_expand = force_expand_from_context + || has_standalone_comments + || has_trailing_comments + || has_multiline_field_docs; + + match ctx.config.layout.table_expand { + ExpandStrategy::Always => format_table_multiline_candidates( + ctx, + entries, + trailing, + ctx.config.align.table_field, + true, + has_standalone_comments, + source_line_prefix_width(expr.syntax()), + ), + ExpandStrategy::Never if !force_expand => { + 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 => format_table_multiline_candidates( + ctx, + entries, + trailing, + ctx.config.align.table_field, + true, + has_standalone_comments, + source_line_prefix_width(expr.syntax()), + ), + ExpandStrategy::Auto if force_expand => format_table_multiline_candidates( + ctx, + entries, + trailing, + ctx.config.align.table_field, + true, + has_standalone_comments, + source_line_prefix_width(expr.syntax()), + ), + ExpandStrategy::Auto => { + let flat_field_docs: Vec> = entries + .iter() + .filter_map(|e| match e { + TableEntry::Field { doc, .. } => Some(doc.clone()), + TableEntry::StandaloneComment(_) => None, + }) + .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 { + 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: has_assign_alignment, + allow_fill: false, + allow_preserve: false, + prefer_preserve_multiline: false, + force_break_on_standalone_comments: false, + prefer_balanced_break_lines: false, + first_line_prefix_width: source_line_prefix_width(expr.syntax()), + }, + ) + } else { + format_delimited_sequence(layout) + } + } + } +} + fn format_name_expr(_ctx: &FormatContext, expr: &LuaNameExpr) -> Vec { if let Some(token) = expr.get_name_token() { vec![ir::source_token(token.syntax().clone())] @@ -197,6 +440,21 @@ fn format_binary_expr(ctx: &FormatContext, expr: &LuaBinaryExpr) -> Vec { force_space_before = true; } + if ir_has_forced_line_break(&left_docs) + && should_attach_short_binary_tail(op, &right, &right_docs) + { + let mut docs = left_docs; + if force_space_before { + docs.push(ir::space()); + } else { + docs.push(space_rule.to_ir()); + } + docs.push(ir::source_token(op_token.syntax().clone())); + docs.push(space_ir); + docs.extend(right_docs); + return docs; + } + // Before-operator break: soft_line (→space when flat) if space, // soft_line_or_empty (→"" when flat) if no space let break_ir = @@ -217,6 +475,43 @@ fn format_binary_expr(ctx: &FormatContext, expr: &LuaBinaryExpr) -> Vec { vec![] } +fn should_attach_short_binary_tail( + op: BinaryOperator, + right: &LuaExpr, + right_docs: &[DocIR], +) -> bool { + if ir_has_forced_line_break(right_docs) { + return false; + } + + match op { + BinaryOperator::OpEq + | BinaryOperator::OpNe + | BinaryOperator::OpLt + | BinaryOperator::OpLe + | BinaryOperator::OpGt + | BinaryOperator::OpGe => { + ir_flat_width(right_docs) <= 16 + && matches!( + right, + LuaExpr::LiteralExpr(_) | LuaExpr::NameExpr(_) | LuaExpr::ParenExpr(_) + ) + } + BinaryOperator::OpAnd | BinaryOperator::OpOr => { + ir_flat_width(right_docs) <= 24 + && matches!( + right, + LuaExpr::LiteralExpr(_) + | LuaExpr::NameExpr(_) + | LuaExpr::ParenExpr(_) + | LuaExpr::IndexExpr(_) + | LuaExpr::CallExpr(_) + ) + } + _ => false, + } +} + fn format_binary_expr_with_standalone_comments( ctx: &FormatContext, expr: &LuaBinaryExpr, @@ -817,9 +1112,28 @@ fn format_call_args_ir(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { source_line_prefix_width(args_list.syntax()), )); } else { - let arg_docs: Vec> = - args.iter().map(|a| format_expr(ctx, a)).collect(); - if arg_docs.iter().any(|doc| ir_has_forced_line_break(doc)) { + let attach_first_arg = should_attach_first_call_arg(&args); + let preserve_multiline_args = args_list.syntax().text().contains_char('\n'); + let arg_docs: Vec> = args + .iter() + .enumerate() + .map(|(index, arg)| { + format_call_arg_value_ir( + ctx, + arg, + attach_first_arg, + preserve_multiline_args, + index, + ) + }) + .collect(); + if attach_first_arg { + docs.extend(format_call_args_with_attached_first_arg( + arg_docs, + trailing, + preserve_multiline_args, + )); + } else if arg_docs.iter().any(|doc| ir_has_forced_line_break(doc)) { let multiline_entries = arg_docs .into_iter() .enumerate() @@ -866,15 +1180,168 @@ fn format_call_args_ir(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { docs } -/// 格式化索引访问部分(不含前缀),如 `.x`、`:m`、`[k]` -fn format_index_access_ir(ctx: &FormatContext, expr: &LuaIndexExpr) -> Vec { - let mut docs = Vec::new(); +fn should_attach_first_call_arg(args: &[LuaExpr]) -> bool { + matches!( + args.first(), + Some(LuaExpr::TableExpr(_) | LuaExpr::ClosureExpr(_)) + ) +} - if let Some(index_token) = expr.get_index_token() { - if index_token.is_dot() { - docs.push(tok(LuaTokenKind::TkDot)); - if let Some(key) = expr.get_index_key() { - docs.push(ir::text(key.get_path_part())); +fn format_call_arg_value_ir( + ctx: &FormatContext, + arg: &LuaExpr, + attach_first_arg: bool, + preserve_multiline_args: bool, + index: usize, +) -> Vec { + if preserve_multiline_args && arg.syntax().text().contains_char('\n') { + if let LuaExpr::TableExpr(table) = arg { + if attach_first_arg && index == 0 { + return format_preserved_multiline_attached_table_arg(ctx, table); + } + + return format_table_expr_with_forced_expand(ctx, table, true); + } + + if attach_first_arg && index == 0 { + return format_expr(ctx, arg); + } + } + + format_expr(ctx, arg) +} + +fn format_preserved_multiline_attached_table_arg( + ctx: &FormatContext, + table: &LuaTableExpr, +) -> Vec { + let text = table.syntax().text().to_string(); + let normalized = normalize_multiline_table_trailing_separator( + text.trim_end_matches(['\r', '\n', ' ', '\t']), + ctx.config.trailing_table_comma(), + ); + + vec![ir::text(normalized)] +} + +fn normalize_multiline_table_trailing_separator( + source: &str, + policy: crate::config::TrailingComma, +) -> String { + let mut normalized = source.to_string(); + let close_index = normalized.rfind('}'); + let Some(close_index) = close_index else { + return normalized; + }; + + let before_close = &normalized[..close_index]; + let content_end = before_close.trim_end_matches(['\r', '\n', ' ', '\t']).len(); + if content_end == 0 { + return normalized; + } + + let has_trailing_comma = normalized[..content_end].ends_with(','); + match policy { + crate::config::TrailingComma::Never => { + if has_trailing_comma { + normalized.remove(content_end - 1); + } + } + crate::config::TrailingComma::Always | crate::config::TrailingComma::Multiline => { + if !has_trailing_comma { + normalized.insert(content_end, ','); + } + } + } + + normalized +} + +fn format_call_args_with_attached_first_arg( + arg_docs: Vec>, + trailing: DocIR, + preserve_multiline: bool, +) -> Vec { + let layout = DelimitedSequenceLayout { + open: tok(LuaTokenKind::TkLeftParen), + close: tok(LuaTokenKind::TkRightParen), + items: arg_docs.clone(), + 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.clone(), + custom_break_contents: None, + prefer_custom_break_in_auto: false, + }; + + let flat_docs = build_delimited_sequence_flat_candidate(&layout); + let break_docs = build_call_args_attached_first_break_doc(arg_docs, trailing); + + if preserve_multiline { + break_docs + } else { + let gid = ir::next_group_id(); + vec![ir::group_with_id( + vec![ir::if_break_with_group( + ir::list(break_docs), + ir::list(flat_docs), + gid, + )], + gid, + )] + } +} + +fn build_call_args_attached_first_break_doc( + arg_docs: Vec>, + trailing: DocIR, +) -> Vec { + if arg_docs.is_empty() { + return vec![]; + } + + let mut docs = vec![tok(LuaTokenKind::TkLeftParen)]; + docs.extend(arg_docs[0].clone()); + + if arg_docs.len() == 1 { + docs.push(trailing); + docs.push(tok(LuaTokenKind::TkRightParen)); + return vec![ir::group_break(docs)]; + } else { + docs.push(tok(LuaTokenKind::TkComma)); + let mut rest = Vec::new(); + for (index, item_docs) in arg_docs.iter().enumerate().skip(1) { + rest.push(ir::hard_line()); + rest.extend(item_docs.clone()); + if index + 1 < arg_docs.len() { + rest.push(tok(LuaTokenKind::TkComma)); + } + } + rest.push(trailing); + docs.push(ir::indent(rest)); + } + + docs.push(ir::hard_line()); + docs.push(tok(LuaTokenKind::TkRightParen)); + + vec![ir::group_break(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(tok(LuaTokenKind::TkDot)); + if let Some(key) = expr.get_index_key() { + docs.push(ir::text(key.get_path_part())); } } else if index_token.is_colon() { docs.push(tok(LuaTokenKind::TkColon)); @@ -1006,238 +1473,7 @@ fn try_format_chain(ctx: &FormatContext, expr: &LuaCallExpr) -> Option Vec { - if expr.is_empty() { - return vec![ - tok(LuaTokenKind::TkLeftBrace), - tok(LuaTokenKind::TkRightBrace), - ]; - } - - // 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 align_hint = field_requests_alignment(&field); - let (trailing_comment, comment_align_hint) = - if let Some((docs, range)) = extract_trailing_comment(ctx.config, field.syntax()) { - consumed_comment_ranges.push(range); - ( - Some(docs), - trailing_comment_requests_alignment( - field.syntax(), - range, - ctx.config.comments.line_comment_min_spaces_before.max(1), - ), - ) - } else { - (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) { - // 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( - ctx.config, &comment, - ))); - has_standalone_comments = true; - } - } - - // Trailing comma - let trailing = format_trailing_comma_ir(ctx.config.trailing_table_comma()); - - let space_inside = if ctx.config.spacing.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.layout.table_expand { - ExpandStrategy::Always => format_table_multiline_candidates( - ctx, - entries, - trailing, - ctx.config.align.table_field, - true, - has_standalone_comments, - source_line_prefix_width(expr.syntax()), - ), - ExpandStrategy::Never if !force_expand => { - 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 - format_table_multiline_candidates( - ctx, - entries, - trailing, - ctx.config.align.table_field, - true, - has_standalone_comments, - source_line_prefix_width(expr.syntax()), - ) - } - ExpandStrategy::Auto if force_expand => { - // Has comments: force expand - format_table_multiline_candidates( - ctx, - entries, - trailing, - ctx.config.align.table_field, - true, - has_standalone_comments, - source_line_prefix_width(expr.syntax()), - ) - } - ExpandStrategy::Auto => { - let flat_field_docs: Vec> = entries - .iter() - .filter_map(|e| match e { - TableEntry::Field { doc, .. } => Some(doc.clone()), - TableEntry::StandaloneComment(_) => None, - }) - .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 { - 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: has_assign_alignment, - allow_fill: false, - allow_preserve: false, - prefer_preserve_multiline: false, - force_break_on_standalone_comments: false, - prefer_balanced_break_lines: false, - first_line_prefix_width: source_line_prefix_width(expr.syntax()), - }, - ) - } else { - format_delimited_sequence(layout) - } - } - } + format_table_expr_with_forced_expand(ctx, expr, false) } fn format_table_multiline_candidates( @@ -1306,18 +1542,28 @@ fn format_table_field_ir(ctx: &FormatContext, field: &LuaTableField) -> Vec Vec { + if let LuaExpr::TableExpr(table) = value + && should_preserve_multiline_table_field_table_value(value) + { + format_table_expr_with_forced_expand(ctx, table, true) + } else { + format_expr(ctx, value) + } +} + /// Format the key part of a table field fn format_table_field_key_ir(ctx: &FormatContext, field: &LuaTableField) -> Vec { let mut docs = Vec::new(); @@ -1354,6 +1600,14 @@ fn format_table_field_eq_split(ctx: &FormatContext, field: &LuaTableField) -> Op return None; } + if field + .get_value_expr() + .as_ref() + .is_some_and(should_preserve_multiline_table_field_value) + { + return None; + } + let before = format_table_field_key_ir(ctx, field); if before.is_empty() { return None; @@ -1368,12 +1622,24 @@ fn format_table_field_eq_split(ctx: &FormatContext, field: &LuaTableField) -> Op Some((before, after)) } +fn should_preserve_multiline_table_field_value(expr: &LuaExpr) -> bool { + matches!(expr, LuaExpr::ClosureExpr(_) | LuaExpr::TableExpr(_)) + && expr.syntax().text().contains_char('\n') +} + +fn should_preserve_multiline_table_field_table_value(expr: &LuaExpr) -> bool { + matches!(expr, LuaExpr::TableExpr(_)) && expr.syntax().text().contains_char('\n') +} + /// Table entry: field or standalone comment enum TableEntry { Field { doc: Vec, /// Split at `=` for alignment: (key_docs, eq_value_docs) eq_split: Option, + /// The field value should keep its multiline source shape, so the outer + /// table must not stay in a flat candidate. + force_expand: bool, /// 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. diff --git a/crates/emmylua_formatter/src/formatter/statement.rs b/crates/emmylua_formatter/src/formatter/statement.rs index f39b2b4c6..fed5d673a 100644 --- a/crates/emmylua_formatter/src/formatter/statement.rs +++ b/crates/emmylua_formatter/src/formatter/statement.rs @@ -7,7 +7,7 @@ use emmylua_parser::{ }; use crate::config::LuaFormatConfig; -use crate::ir::{self, DocIR, EqSplit}; +use crate::ir::{self, DocIR, EqSplit, ir_has_forced_line_break}; use super::FormatContext; use super::block::format_block; @@ -90,7 +90,11 @@ fn format_local_stat(ctx: &FormatContext, stat: &LuaLocalStat) -> Vec { docs.push(assign_space); docs.push(tok(LuaTokenKind::TkAssign)); - let expr_docs: Vec> = exprs.iter().map(|e| format_expr(ctx, e)).collect(); + let expr_docs: Vec> = exprs + .iter() + .enumerate() + .map(|(index, expr)| format_statement_value_expr(ctx, expr, index, exprs.len())) + .collect(); // Keep block-like / preserved multiline RHS heads attached to `=` while // ordinary expressions remain width-driven. @@ -108,12 +112,19 @@ fn format_local_stat(ctx: &FormatContext, stat: &LuaLocalStat) -> Vec { .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, - )); + if should_preserve_first_multiline_statement_value(&exprs[0], 0, exprs.len()) { + docs.extend(format_statement_expr_list_with_attached_first_multiline( + leading_docs, + expr_docs, + )); + } else { + docs.extend(format_statement_expr_list( + ctx, + leading_docs, + expr_docs, + prefix_width, + )); + } } } @@ -148,7 +159,11 @@ 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 expr_docs: Vec> = exprs + .iter() + .enumerate() + .map(|(index, expr)| format_statement_value_expr(ctx, expr, index, exprs.len())) + .collect(); // Keep block-like / preserved multiline RHS heads attached to the operator // while ordinary expressions remain width-driven. @@ -166,12 +181,19 @@ fn format_assign_stat(ctx: &FormatContext, stat: &LuaAssignStat) -> Vec { .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, - )); + if should_preserve_first_multiline_statement_value(&exprs[0], 0, exprs.len()) { + docs.extend(format_statement_expr_list_with_attached_first_multiline( + leading_docs, + expr_docs, + )); + } else { + docs.extend(format_statement_expr_list( + ctx, + leading_docs, + expr_docs, + prefix_width, + )); + } } docs @@ -1012,12 +1034,25 @@ fn format_for_stat(ctx: &FormatContext, stat: &LuaForStat) -> Vec { .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, - )); + if iter_exprs + .first() + .zip(iter_docs.first()) + .is_some_and(|(expr, doc)| { + should_preserve_first_multiline_header_expr(expr, doc, 0, iter_exprs.len()) + }) + { + head_docs.extend(format_statement_expr_list_with_attached_first_multiline( + vec![ir::space()], + iter_docs, + )); + } else { + 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); @@ -1063,12 +1098,25 @@ fn format_for_range_stat(ctx: &FormatContext, stat: &LuaForRangeStat) -> Vec 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 expr_docs: Vec> = exprs + .iter() + .enumerate() + .map(|(index, expr)| format_statement_value_expr(ctx, expr, index, exprs.len())) + .collect(); if exprs.len() == 1 && should_attach_single_value_head(&exprs[0]) { docs.push(ir::space()); @@ -1354,12 +1406,19 @@ fn format_return_stat(ctx: &FormatContext, stat: &LuaReturnStat) -> Vec { .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, - )); + if should_preserve_first_multiline_statement_value(&exprs[0], 0, exprs.len()) { + docs.extend(format_statement_expr_list_with_attached_first_multiline( + vec![ir::space()], + expr_docs, + )); + } else { + docs.extend(format_statement_expr_list( + ctx, + vec![ir::space()], + expr_docs, + prefix_width, + )); + } } } @@ -1453,6 +1512,40 @@ fn format_statement_expr_list( ) } +fn format_statement_expr_list_with_attached_first_multiline( + leading_docs: Vec, + expr_docs: Vec>, +) -> Vec { + if expr_docs.is_empty() { + return Vec::new(); + } + + let mut docs = leading_docs; + let mut iter = expr_docs.into_iter(); + let first_expr = iter.next().unwrap_or_default(); + docs.extend(first_expr); + + let remaining: Vec> = iter.collect(); + if remaining.is_empty() { + return docs; + } + + docs.push(tok(LuaTokenKind::TkComma)); + + let mut tail = Vec::new(); + let remaining_len = remaining.len(); + for (index, expr_doc) in remaining.into_iter().enumerate() { + tail.push(ir::hard_line()); + tail.extend(expr_doc); + if index + 1 < remaining_len { + tail.push(tok(LuaTokenKind::TkComma)); + } + } + + docs.push(ir::indent(tail)); + docs +} + fn build_statement_expr_fill_parts( leading_docs: Vec, expr_docs: Vec>, @@ -1798,6 +1891,49 @@ fn should_attach_single_value_head(expr: &LuaExpr) -> bool { is_block_like_expr(expr) || node_has_direct_comment_child(expr.syntax()) } +fn format_statement_value_expr( + ctx: &FormatContext, + expr: &LuaExpr, + index: usize, + total_exprs: usize, +) -> Vec { + if should_preserve_first_multiline_statement_value(expr, index, total_exprs) { + vec![ir::source_node_trimmed(expr.syntax().clone())] + } else { + format_expr(ctx, expr) + } +} + +fn should_preserve_first_multiline_statement_value( + expr: &LuaExpr, + index: usize, + total_exprs: usize, +) -> bool { + index == 0 + && total_exprs > 1 + && is_block_like_expr(expr) + && expr.syntax().text().contains_char('\n') +} + +fn should_preserve_first_multiline_header_expr( + expr: &LuaExpr, + expr_doc: &[DocIR], + index: usize, + total_exprs: usize, +) -> bool { + index == 0 + && total_exprs > 1 + && expr.syntax().text().contains_char('\n') + && ir_has_forced_line_break(expr_doc) + && matches!( + expr, + LuaExpr::CallExpr(_) + | LuaExpr::BinaryExpr(_) + | LuaExpr::ClosureExpr(_) + | LuaExpr::TableExpr(_) + ) +} + fn should_preserve_raw_empty_loop_with_comments( ctx: &FormatContext, block: Option<&LuaBlock>, diff --git a/crates/emmylua_formatter/src/test/expression_tests.rs b/crates/emmylua_formatter/src/test/expression_tests.rs index 5b6bb6f0a..2a0fdf0ae 100644 --- a/crates/emmylua_formatter/src/test/expression_tests.rs +++ b/crates/emmylua_formatter/src/test/expression_tests.rs @@ -274,7 +274,32 @@ local b = t[1] fn test_callback_arg_with_multiline_closure_breaks_one_arg_per_line() { assert_format!( "check(function()\n return not not k3\nend, 'LOADTRUE', 'RETURN1')\n", - "check(\n function()\n return not not k3\n end,\n 'LOADTRUE',\n 'RETURN1'\n)\n" + "check(function()\n return not not k3\nend,\n 'LOADTRUE',\n 'RETURN1'\n)\n" + ); + } + + #[test] + fn test_first_table_arg_stays_attached_when_call_breaks() { + assert_format!( + "configure({\n key = value,\n another = other,\n}, option_one, option_two)\n", + "configure({\n key = value,\n another = other\n},\n option_one,\n option_two\n)\n" + ); + } + + #[test] + fn test_multiline_call_comparison_keeps_short_rhs_on_closing_line() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 40, + ..Default::default() + }, + ..Default::default() + }; + + assert_format_with_config!( + "assert(check(function()\n return true\nend, 'LOADTRUE', 'RETURN1') == \"hiho\")\n", + "assert(\n check(function()\n return true\n end,\n 'LOADTRUE',\n 'RETURN1'\n ) == \"hiho\"\n)\n", + config ); } @@ -299,6 +324,46 @@ local b = t[1] ); } + #[test] + fn test_table_field_preserves_multiline_closure_value_shape() { + assert_format!( + "local spec = {\n callback = function()\n return true\n end,\n fallback = another_value,\n}\n", + "local spec = {\n callback = function()\n return true\n end,\n fallback = another_value\n}\n" + ); + } + + #[test] + fn test_table_field_multiline_closure_value_still_formats_interior() { + assert_format!( + "local mt = {\n __eq = function (a, b)\n coroutine.yield(nil, \"eq\")\n return val(a) == val(b)\n end\n}\n", + "local mt = {\n __eq = function(a, b)\n coroutine.yield(nil, \"eq\")\n return val(a) == val(b)\n end\n}\n" + ); + } + + #[test] + fn test_table_field_preserves_multiline_nested_table_value_shape() { + assert_format!( + "local spec = {\n nested = {\n foo=1,\n bar = 2,\n },\n fallback = another_value,\n}\n", + "local spec = {\n nested = {\n foo = 1,\n bar = 2\n },\n fallback = another_value\n}\n" + ); + } + + #[test] + fn test_deep_nested_table_field_keeps_expanded_shape_and_formats_interior() { + assert_format!( + "local spec = {\n outer = {\n callback = function (a, b)\n return val(a) == val(b)\n end,\n nested = {\n foo=1,\n bar = 2,\n },\n },\n}\n", + "local spec = {\n outer = {\n callback = function(a, b)\n return val(a) == val(b)\n end,\n nested = {\n foo = 1,\n bar = 2\n }\n }\n}\n" + ); + } + + #[test] + fn test_multiline_call_arg_nested_table_keeps_expanded_shape_and_formats_interior() { + assert_format!( + "local spec = {\n outer = {\n callback = wrap(function (a, b)\n return val(a) == val(b)\n end, {\n foo=1,\n bar = 2,\n }),\n fallback = another_value,\n },\n}\n", + "local spec = {\n outer = {\n callback = wrap(function(a, b)\n return val(a) == val(b)\n end,\n {\n foo = 1,\n bar = 2\n }\n ),\n fallback = another_value\n }\n}\n" + ); + } + // ========== chain call ========== #[test] @@ -366,6 +431,38 @@ local b = t[1] ); } + #[test] + fn test_chain_keeps_single_multiline_table_payload_attached() { + assert_format!( + "builder:with_config({\n key = value,\n another = other,\n}):set_name(name):build()\n", + "builder:with_config({\n key = value,\n another = other\n}):set_name(name):build()\n" + ); + } + + #[test] + fn test_chain_keeps_mixed_closure_and_multiline_table_payloads_expanded() { + assert_format!( + "builder:with_config(function (a, b)\n return val(a) == val(b)\nend, {\n foo=1,\n bar = 2,\n}):set_name(name):build()\n", + "builder:with_config(function(a, b)\n return val(a) == val(b)\n end,\n {\n foo = 1,\n bar = 2\n }\n ):set_name(name):build()\n" + ); + } + + #[test] + fn test_chain_keeps_mixed_closure_table_and_fallback_payloads_expanded() { + assert_format!( + "builder:with_config(function (a, b)\n return val(a) == val(b)\nend, {\n foo=1,\n bar = 2,\n}, fallback):set_name(name):build()\n", + "builder:with_config(function(a, b)\n return val(a) == val(b)\n end,\n {\n foo = 1,\n bar = 2\n },\n fallback\n ):set_name(name):build()\n" + ); + } + + #[test] + fn test_if_header_keeps_short_comparison_tail_with_multiline_callback_call() { + assert_format!( + "if check(function()\n return true\nend, 'LOADTRUE', 'RETURN1') == \"hiho\" then\n print('ok')\nend\n", + "if check(function()\n return true\nend,\n 'LOADTRUE',\n 'RETURN1'\n) == \"hiho\" then\n print('ok')\nend\n" + ); + } + // ========== and / or expression ========== #[test] diff --git a/crates/emmylua_formatter/src/test/statement_tests.rs b/crates/emmylua_formatter/src/test/statement_tests.rs index 7b2519025..66e92237e 100644 --- a/crates/emmylua_formatter/src/test/statement_tests.rs +++ b/crates/emmylua_formatter/src/test/statement_tests.rs @@ -158,6 +158,22 @@ end ); } + #[test] + fn test_if_header_keeps_short_logical_tail_with_multiline_callback_call() { + assert_format!( + "if check(function()\n return true\nend, 'LOADTRUE', 'RETURN1') and another_predicate then\n print('ok')\nend\n", + "if check(function()\n return true\nend,\n 'LOADTRUE',\n 'RETURN1'\n) and another_predicate then\n print('ok')\nend\n" + ); + } + + #[test] + fn test_while_header_keeps_short_logical_tail_with_multiline_callback_call() { + assert_format!( + "while check(function()\n return true\nend, 'LOADTRUE', 'RETURN1') and another_predicate do\n print('ok')\nend\n", + "while check(function()\n return true\nend,\n 'LOADTRUE',\n 'RETURN1'\n) and another_predicate do\n print('ok')\nend\n" + ); + } + // ========== for loop ========== #[test] @@ -242,6 +258,14 @@ end ); } + #[test] + fn test_for_range_keeps_first_multiline_iterator_shape_when_breaking() { + assert_format!( + "for key, value in iterate(function()\n return true\nend, 'LOADTRUE', 'RETURN1'), fallback_iterator do\n print(key, value)\nend\n", + "for key, value in iterate(function()\n return true\nend,\n 'LOADTRUE',\n 'RETURN1'\n),\n fallback_iterator do\n print(key, value)\nend\n" + ); + } + #[test] fn test_for_range_header_prefers_balanced_packed_expr_list() { let config = LuaFormatConfig { @@ -569,6 +593,38 @@ end ); } + #[test] + fn test_return_preserves_first_multiline_closure_shape_when_breaking() { + assert_format!( + "function f()\n return function()\n return true\n end, first_result, second_result\nend\n", + "function f()\n return function()\n return true\n end,\n first_result,\n second_result\nend\n" + ); + } + + #[test] + fn test_return_preserves_first_multiline_table_shape_when_breaking() { + assert_format!( + "function f()\n return {\n key = value,\n another = other,\n }, first_result, second_result\nend\n", + "function f()\n return {\n key = value,\n another = other,\n },\n first_result,\n second_result\nend\n" + ); + } + + #[test] + fn test_local_assign_preserves_first_multiline_closure_shape_when_breaking() { + assert_format!( + "local first, second, third = function()\n return true\nend, alpha_result, beta_result\n", + "local first, second, third = function()\n return true\nend,\n alpha_result,\n beta_result\n" + ); + } + + #[test] + fn test_assign_preserves_first_multiline_table_shape_when_breaking() { + assert_format!( + "target, fallback = {\n key = value,\n another = other,\n}, alpha_result, beta_result\n", + "target, fallback = {\n key = value,\n another = other,\n},\n alpha_result,\n beta_result\n" + ); + } + // ========== goto / label / break ========== #[test] From 60f15dd569347e8cb5f825a2bba55808fcb8a850 Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Mon, 23 Mar 2026 14:37:36 +0800 Subject: [PATCH 13/23] update comment handle --- .../src/formatter/comment.rs | 115 +++++++++++++++--- .../src/test/comment_tests.rs | 32 +++++ 2 files changed, 128 insertions(+), 19 deletions(-) diff --git a/crates/emmylua_formatter/src/formatter/comment.rs b/crates/emmylua_formatter/src/formatter/comment.rs index 2edf92d59..d4b9f5109 100644 --- a/crates/emmylua_formatter/src/formatter/comment.rs +++ b/crates/emmylua_formatter/src/formatter/comment.rs @@ -629,25 +629,14 @@ fn render_doc_comment_lines(config: &LuaFormatConfig, lines: &[DocCommentLine]) 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; - } + if let Some((kind, group_end)) = find_interleaved_aligned_group(config, lines, index) { + rendered.extend(render_interleaved_aligned_doc_tag_group( + config, + &lines[index..group_end], + kind, + )); + index = group_end; + continue; } rendered.push(render_single_doc_comment_line(config, &lines[index])); @@ -656,6 +645,94 @@ fn render_doc_comment_lines(config: &LuaFormatConfig, lines: &[DocCommentLine]) rendered } +fn find_interleaved_aligned_group( + config: &LuaFormatConfig, + lines: &[DocCommentLine], + start: usize, +) -> Option<(AlignableDocTagKind, usize)> { + let mut cursor = start; + let kind = loop { + let line = lines.get(cursor)?; + if let Some(kind) = alignable_doc_tag_kind(line) { + break kind; + } + + if !matches!(line, DocCommentLine::Description(_) | DocCommentLine::Empty) + && !matches!(line, DocCommentLine::Raw(text) if is_raw_doc_description_line(text)) + { + return None; + } + + cursor += 1; + }; + + if !should_align_doc_tag_kind(config, kind) { + return None; + } + + let mut group_end = cursor + 1; + let mut alignable_count = 1usize; + while group_end < lines.len() { + if alignable_doc_tag_kind(&lines[group_end]) == Some(kind) { + alignable_count += 1; + group_end += 1; + continue; + } + + if should_keep_doc_line_inside_aligned_group(&lines[group_end], kind) { + group_end += 1; + continue; + } + + break; + } + + (alignable_count >= 2).then_some((kind, group_end)) +} + +fn should_keep_doc_line_inside_aligned_group( + line: &DocCommentLine, + _kind: AlignableDocTagKind, +) -> bool { + match line { + DocCommentLine::Description(_) | DocCommentLine::Empty => true, + DocCommentLine::Raw(text) if is_raw_doc_description_line(text) => true, + _ => false, + } +} + +fn is_raw_doc_description_line(text: &str) -> bool { + let trimmed = text.trim(); + trimmed == "---" || (trimmed.starts_with("---") && !trimmed.starts_with("---@")) +} + +fn render_interleaved_aligned_doc_tag_group( + config: &LuaFormatConfig, + lines: &[DocCommentLine], + kind: AlignableDocTagKind, +) -> Vec { + let alignable_lines: Vec = lines + .iter() + .filter(|line| alignable_doc_tag_kind(line) == Some(kind)) + .cloned() + .collect(); + let aligned_rendered = render_aligned_doc_tag_group(config, &alignable_lines, kind); + let mut aligned_iter = aligned_rendered.into_iter(); + + lines + .iter() + .map(|line| { + if alignable_doc_tag_kind(line) == Some(kind) { + aligned_iter + .next() + .unwrap_or_else(|| render_single_doc_comment_line(config, line)) + } else { + render_single_doc_comment_line(config, line) + } + }) + .collect() +} + fn should_align_doc_tag_kind(config: &LuaFormatConfig, kind: AlignableDocTagKind) -> bool { match kind { AlignableDocTagKind::Class diff --git a/crates/emmylua_formatter/src/test/comment_tests.rs b/crates/emmylua_formatter/src/test/comment_tests.rs index 206beb44a..164385f9a 100644 --- a/crates/emmylua_formatter/src/test/comment_tests.rs +++ b/crates/emmylua_formatter/src/test/comment_tests.rs @@ -943,6 +943,14 @@ local t = { ); } + #[test] + fn test_doc_comment_align_param_columns_with_interleaved_descriptions() { + assert_format!( + "--- first parameter docs\n---@param short string desc\n--- second parameter docs\n---@param much_longer integer longer desc\nlocal function f(short, much_longer) end\n", + "--- first parameter docs\n---@param short string desc\n--- second parameter docs\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!( @@ -951,6 +959,14 @@ local t = { ); } + #[test] + fn test_doc_comment_align_field_columns_with_interleaved_descriptions() { + assert_format!( + "---@class schema.EmmyrcStrict\n--- Whether to enable strict mode array indexing.\n---@field arrayIndex boolean?\n--- Base constant types defined in doc can match base types, allowing int to match `---@alias id 1|2|3`, same for string.\n---@field docBaseConstMatchBaseType boolean?\n--- meta define overrides file define\n---@field metaOverrideFileDefine boolean?\n", + "---@class schema.EmmyrcStrict\n--- Whether to enable strict mode array indexing.\n---@field arrayIndex boolean?\n--- Base constant types defined in doc can match base types, allowing int to match `---@alias id 1|2|3`, same for string.\n---@field docBaseConstMatchBaseType boolean?\n--- meta define overrides file define\n---@field metaOverrideFileDefine boolean?\n" + ); + } + #[test] fn test_doc_comment_align_return_columns() { assert_format!( @@ -959,6 +975,22 @@ local t = { ); } + #[test] + fn test_doc_comment_align_return_columns_with_interleaved_descriptions() { + assert_format!( + "--- first return docs\n---@return number ok success\n--- second return docs\n---@return string, integer err failure\nfunction f() end\n", + "--- first return docs\n---@return number ok success\n--- second return docs\n---@return string, integer err failure\nfunction f() end\n" + ); + } + + #[test] + fn test_doc_comment_align_complex_field_columns() { + assert_format!( + "---@field public [\"foo\"] string?\n---@field private [bar] integer\n---@field protected baz fun(x: string): boolean\nlocal t = {}\n", + "---@field public [\"foo\"] string?\n---@field private [bar] integer\n---@field protected baz fun(x: string): boolean\nlocal t = {}\n" + ); + } + #[test] fn test_doc_comment_alignment_can_be_disabled() { use crate::{assert_format_with_config, config::LuaFormatConfig}; From 3bac4a53749c3d6517570113b5957b422759289f Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Mon, 23 Mar 2026 14:56:11 +0800 Subject: [PATCH 14/23] update --- .../emmylua_formatter/src/formatter/expression.rs | 14 ++++++++++++-- .../emmylua_formatter/src/test/statement_tests.rs | 8 ++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/emmylua_formatter/src/formatter/expression.rs b/crates/emmylua_formatter/src/formatter/expression.rs index 40a78ea45..e72eb14b2 100644 --- a/crates/emmylua_formatter/src/formatter/expression.rs +++ b/crates/emmylua_formatter/src/formatter/expression.rs @@ -1002,6 +1002,14 @@ fn collect_index_standalone_layout( /// 格式化调用参数部分(不含前缀),如 `(a, b)` 或单参数简写 ` "str"` / ` { ... }` fn format_call_args_ir(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { + format_call_args_ir_with_options(ctx, expr, false) +} + +fn format_call_args_ir_with_options( + ctx: &FormatContext, + expr: &LuaCallExpr, + preserve_chain_attached_table_source: bool, +) -> Vec { let mut docs = Vec::new(); if let Some(args_list) = expr.get_args_list() { @@ -1124,6 +1132,7 @@ fn format_call_args_ir(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { attach_first_arg, preserve_multiline_args, index, + preserve_chain_attached_table_source, ) }) .collect(); @@ -1193,10 +1202,11 @@ fn format_call_arg_value_ir( attach_first_arg: bool, preserve_multiline_args: bool, index: usize, + preserve_chain_attached_table_source: bool, ) -> Vec { if preserve_multiline_args && arg.syntax().text().contains_char('\n') { if let LuaExpr::TableExpr(table) = arg { - if attach_first_arg && index == 0 { + if preserve_chain_attached_table_source && attach_first_arg && index == 0 { return format_preserved_multiline_attached_table_arg(ctx, table); } @@ -1406,7 +1416,7 @@ fn try_format_chain(ctx: &FormatContext, expr: &LuaCallExpr) -> Option { - let args = format_call_args_ir(ctx, call); + let args = format_call_args_ir_with_options(ctx, call, true); if let Some(prefix) = call.get_prefix_expr() && let LuaExpr::IndexExpr(idx) = &prefix { diff --git a/crates/emmylua_formatter/src/test/statement_tests.rs b/crates/emmylua_formatter/src/test/statement_tests.rs index 66e92237e..43672e7d6 100644 --- a/crates/emmylua_formatter/src/test/statement_tests.rs +++ b/crates/emmylua_formatter/src/test/statement_tests.rs @@ -166,6 +166,14 @@ end ); } + #[test] + fn test_if_block_reindents_attached_multiline_table_call_arg() { + assert_format!( + "if ok then\n configure({\nkey = value,\nanother = other,\n}, option_one, option_two)\nend\n", + "if ok then\n configure({\n key = value,\n another = other\n },\n option_one,\n option_two\n )\nend\n" + ); + } + #[test] fn test_while_header_keeps_short_logical_tail_with_multiline_callback_call() { assert_format!( From 902ad94bd19817468b254d40134fd00235b95f2c Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Mon, 23 Mar 2026 16:38:24 +0800 Subject: [PATCH 15/23] fix formatter error --- crates/emmylua_formatter/src/formatter/comment.rs | 11 +++++------ crates/emmylua_formatter/src/test/comment_tests.rs | 13 +++++++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/emmylua_formatter/src/formatter/comment.rs b/crates/emmylua_formatter/src/formatter/comment.rs index d4b9f5109..b6eb1d4b9 100644 --- a/crates/emmylua_formatter/src/formatter/comment.rs +++ b/crates/emmylua_formatter/src/formatter/comment.rs @@ -15,7 +15,7 @@ use super::trivia::has_non_trivia_before_on_same_line; /// /// Dispatches between three comment types: /// - Doc comments (`---@...`): walk the syntax tree, normalize whitespace -/// - Long comments (`--[[ ... ]]`): preserve content as-is +/// - Long comments (`--[[ ... ]]`, `--[[@...]]`): preserve content as-is /// - Normal comments (`-- ...`): preserve text with trimming pub fn format_comment(config: &LuaFormatConfig, comment: &LuaComment) -> Vec { match classify_comment(comment) { @@ -57,11 +57,10 @@ fn classify_comment(comment: &LuaComment) -> CommentKind { }; match first_token.kind().into() { - LuaTokenKind::TkLongCommentStart => CommentKind::Long, - LuaTokenKind::TkDocStart - | LuaTokenKind::TkDocLongStart - | LuaTokenKind::TkDocContinue - | LuaTokenKind::TkDocContinueOr => CommentKind::Doc, + LuaTokenKind::TkLongCommentStart | LuaTokenKind::TkDocLongStart => CommentKind::Long, + LuaTokenKind::TkDocStart | LuaTokenKind::TkDocContinue | LuaTokenKind::TkDocContinueOr => { + CommentKind::Doc + } LuaTokenKind::TkNormalStart => { if first_token.text().starts_with("---") || comment.get_doc_tags().next().is_some() { CommentKind::Doc diff --git a/crates/emmylua_formatter/src/test/comment_tests.rs b/crates/emmylua_formatter/src/test/comment_tests.rs index 164385f9a..9f11d6cdd 100644 --- a/crates/emmylua_formatter/src/test/comment_tests.rs +++ b/crates/emmylua_formatter/src/test/comment_tests.rs @@ -927,6 +927,19 @@ local t = { ); } + #[test] + fn test_doc_long_comment_cast_preserved() { + assert_format!("--[[@cast -?]]\n", "--[[@cast -?]]\n"); + } + + #[test] + fn test_doc_long_comment_multiline_preserved() { + assert_format!( + "--[[@as string\nsecond line\n]]\nlocal value = nil\n", + "--[[@as string\nsecond line\n]]\nlocal value = nil\n" + ); + } + #[test] fn test_doc_comment_multi_tag() { assert_format!( From a5902da956edd06da293010e05c762c12d5de7cd Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Mon, 23 Mar 2026 19:10:53 +0800 Subject: [PATCH 16/23] refactor comment handle --- .../src/formatter/comment.rs | 5 + .../src/formatter/expression.rs | 671 +++++++++++------- crates/emmylua_formatter/src/formatter/mod.rs | 16 +- .../src/formatter/sequence.rs | 119 +++- .../src/formatter/statement.rs | 25 +- .../emmylua_formatter/src/formatter/trivia.rs | 20 + crates/emmylua_formatter/src/ir/builder.rs | 7 - .../src/test/expression_tests.rs | 92 ++- .../src/test/statement_tests.rs | 10 +- 9 files changed, 669 insertions(+), 296 deletions(-) diff --git a/crates/emmylua_formatter/src/formatter/comment.rs b/crates/emmylua_formatter/src/formatter/comment.rs index b6eb1d4b9..5aa50373e 100644 --- a/crates/emmylua_formatter/src/formatter/comment.rs +++ b/crates/emmylua_formatter/src/formatter/comment.rs @@ -1183,3 +1183,8 @@ pub fn format_trailing_comment( suffix_content.extend(docs); Some((ir::line_suffix(suffix_content), range)) } + +pub fn should_keep_comment_inline_in_expression(comment: &LuaComment) -> bool { + matches!(classify_comment(comment), CommentKind::Long) + && !comment.syntax().text().contains_char('\n') +} diff --git a/crates/emmylua_formatter/src/formatter/expression.rs b/crates/emmylua_formatter/src/formatter/expression.rs index e72eb14b2..4cc84d99a 100644 --- a/crates/emmylua_formatter/src/formatter/expression.rs +++ b/crates/emmylua_formatter/src/formatter/expression.rs @@ -10,13 +10,18 @@ use crate::config::{ExpandStrategy, QuoteStyle, SingleArgCallParens}; use crate::ir::{self, AlignEntry, DocIR, EqSplit, ir_flat_width, ir_has_forced_line_break}; use super::FormatContext; -use super::comment::{extract_trailing_comment, format_comment, trailing_comment_prefix}; +use super::comment::{ + extract_trailing_comment, format_comment, should_keep_comment_inline_in_expression, + trailing_comment_prefix, +}; use super::sequence::{ - DelimitedSequenceLayout, SequenceEntry, SequenceLayoutCandidates, SequenceLayoutPolicy, - 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, + DelimitedSequenceAttachments, DelimitedSequenceCommentState, DelimitedSequenceLayout, + DelimitedSequenceMultilineWrapOptions, SequenceEntry, SequenceLayoutCandidates, + SequenceLayoutPolicy, build_delimited_sequence_break_candidate, + build_delimited_sequence_default_break_candidate, build_delimited_sequence_flat_candidate, + choose_sequence_layout, format_delimited_sequence, push_comment_lines, render_sequence, + sequence_ends_with_comment, sequence_has_comment, sequence_starts_with_comment, + wrap_multiline_delimited_sequence_docs, }; use super::spacing::{SpaceRule, space_around_assign, space_around_binary_op}; use super::tokens::{comma_soft_line_sep, comma_space_sep, tok}; @@ -36,7 +41,6 @@ enum IndexStandaloneSuffix { Colon(Vec), Bracket(Vec), } - struct IndexStandaloneLayout { before_suffix_comments: Vec, suffix: Option, @@ -68,59 +72,10 @@ fn format_table_expr_with_forced_expand( ]; } - 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 force_expand = field - .get_value_expr() - .as_ref() - .is_some_and(should_preserve_multiline_table_field_value); - let eq_split = if ctx.config.align.table_field { - format_table_field_eq_split(ctx, &field) - } else { - None - }; - let align_hint = field_requests_alignment(&field); - let (trailing_comment, comment_align_hint) = - if let Some((docs, range)) = extract_trailing_comment(ctx.config, field.syntax()) { - consumed_comment_ranges.push(range); - ( - Some(docs), - trailing_comment_requests_alignment( - field.syntax(), - range, - ctx.config.comments.line_comment_min_spaces_before.max(1), - ), - ) - } else { - (None, false) - }; - entries.push(TableEntry::Field { - doc: fdoc, - eq_split, - force_expand, - align_hint, - comment_align_hint, - trailing_comment, - }); - } else if child.kind() == LuaKind::Syntax(LuaSyntaxKind::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( - ctx.config, &comment, - ))); - has_standalone_comments = true; - } - } + let collected = collect_table_layout(ctx, expr); + let entries = collected.entries; + let attachments = collected.attachments; + let has_standalone_comments = collected.has_standalone_comments; let trailing = format_trailing_comma_ir(ctx.config.trailing_table_comma()); @@ -144,33 +99,35 @@ fn format_table_expr_with_forced_expand( TableEntry::Field { doc, force_expand, .. } => *force_expand || ir_has_forced_line_break(doc), - TableEntry::StandaloneComment(_) => false, }); let force_expand = force_expand_from_context || has_standalone_comments || has_trailing_comments + || attachments.after_open_comment.is_some() + || !attachments.before_close_comments.is_empty() || has_multiline_field_docs; match ctx.config.layout.table_expand { ExpandStrategy::Always => format_table_multiline_candidates( ctx, entries, + &attachments, trailing, ctx.config.align.table_field, true, has_standalone_comments, source_line_prefix_width(expr.syntax()), ), - ExpandStrategy::Never if !force_expand => { - format_delimited_sequence(DelimitedSequenceLayout { + ExpandStrategy::Never if !force_expand => format_delimited_sequence( + ctx, + 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, + .map(|e| match e { + TableEntry::Field { doc, .. } => doc, }) .collect(), strategy: ExpandStrategy::Never, @@ -193,11 +150,12 @@ fn format_table_expr_with_forced_expand( grouped_trailing: trailing.clone(), custom_break_contents: None, prefer_custom_break_in_auto: false, - }) - } + }, + ), ExpandStrategy::Never => format_table_multiline_candidates( ctx, entries, + &attachments, trailing, ctx.config.align.table_field, true, @@ -207,6 +165,7 @@ fn format_table_expr_with_forced_expand( ExpandStrategy::Auto if force_expand => format_table_multiline_candidates( ctx, entries, + &attachments, trailing, ctx.config.align.table_field, true, @@ -216,9 +175,8 @@ fn format_table_expr_with_forced_expand( ExpandStrategy::Auto => { let flat_field_docs: Vec> = entries .iter() - .filter_map(|e| match e { - TableEntry::Field { doc, .. } => Some(doc.clone()), - TableEntry::StandaloneComment(_) => None, + .map(|e| match e { + TableEntry::Field { doc, .. } => doc.clone(), }) .collect(); let layout = DelimitedSequenceLayout { @@ -293,10 +251,75 @@ fn format_table_expr_with_forced_expand( }, ) } else { - format_delimited_sequence(layout) + format_delimited_sequence(ctx, layout) + } + } + } +} + +struct CollectedTableLayout { + entries: Vec, + attachments: DelimitedSequenceAttachments, + has_standalone_comments: bool, +} + +fn collect_table_layout(ctx: &FormatContext, expr: &LuaTableExpr) -> CollectedTableLayout { + let mut entries = Vec::new(); + let mut comment_state = DelimitedSequenceCommentState::default(); + + for child in expr.syntax().children() { + if let Some(field) = LuaTableField::cast(child.clone()) { + let fdoc = format_table_field_ir(ctx, &field); + let force_expand = field + .get_value_expr() + .as_ref() + .is_some_and(should_preserve_multiline_table_field_value); + let eq_split = if ctx.config.align.table_field { + format_table_field_eq_split(ctx, &field) + } else { + None + }; + let align_hint = field_requests_alignment(&field); + let (trailing_comment, comment_align_hint) = + if let Some((docs, range)) = extract_trailing_comment(ctx.config, field.syntax()) { + comment_state.record_consumed_comment_range(range); + ( + Some(docs), + trailing_comment_requests_alignment( + field.syntax(), + range, + ctx.config.comments.line_comment_min_spaces_before.max(1), + ), + ) + } else { + (None, false) + }; + entries.push(TableEntry::Field { + leading_comments: comment_state.take_leading_comments(), + doc: fdoc, + eq_split, + force_expand, + align_hint, + comment_align_hint, + trailing_comment, + }); + comment_state.mark_item_seen(); + } else if child.kind() == LuaKind::Syntax(LuaSyntaxKind::Comment) { + let comment = LuaComment::cast(child.clone()).unwrap(); + if comment_state.should_skip_comment(&comment) { + continue; } + comment_state.handle_comment(ctx, &comment); } } + + let (attachments, has_standalone_comments) = comment_state.finish(); + + CollectedTableLayout { + entries, + attachments, + has_standalone_comments, + } } fn format_name_expr(_ctx: &FormatContext, expr: &LuaNameExpr) -> Vec { @@ -588,7 +611,12 @@ fn collect_binary_expr_entries(ctx: &FormatContext, expr: &LuaBinaryExpr) -> Bin if let Some(node) = child.as_node() && let Some(comment) = LuaComment::cast(node.clone()) { - let entry = SequenceEntry::Comment(format_comment(ctx.config, &comment)); + let comment_docs = format_comment(ctx.config, &comment); + let entry = if should_keep_comment_inline_in_expression(&comment) { + SequenceEntry::Item(comment_docs) + } else { + SequenceEntry::Comment(comment_docs) + }; if meet_op { rhs_entries.push(entry); } else { @@ -955,7 +983,12 @@ fn collect_index_standalone_layout( if let Some(node) = child.as_node() && let Some(comment) = LuaComment::cast(node.clone()) { - let entry = SequenceEntry::Comment(format_comment(ctx.config, &comment)); + let comment_docs = format_comment(ctx.config, &comment); + let entry = if should_keep_comment_inline_in_expression(&comment) { + SequenceEntry::Item(comment_docs) + } else { + SequenceEntry::Comment(comment_docs) + }; if meet_prefix { suffix_entries.push(entry); } else { @@ -1029,16 +1062,18 @@ fn format_call_args_ir_with_options( docs.push(tok(LuaTokenKind::TkLeftParen)); docs.push(tok(LuaTokenKind::TkRightParen)); } else { - let arg_entries = collect_call_arg_entries(ctx, &args_list); + let collected = collect_call_arg_entries(ctx, &args_list); + let arg_entries = collected.entries; + let arg_attachments = collected.attachments; + let has_standalone_comments = collected.has_standalone_comments; let has_comments = arg_entries.iter().any(|entry| match entry { CallArgEntry::Arg { - trailing_comment, .. - } => trailing_comment.is_some(), - CallArgEntry::StandaloneComment(_) => true, - }); - let has_standalone_comments = arg_entries - .iter() - .any(|entry| matches!(entry, CallArgEntry::StandaloneComment(_))); + trailing_comment, + leading_comments, + .. + } => trailing_comment.is_some() || !leading_comments.is_empty(), + }) || arg_attachments.after_open_comment.is_some() + || !arg_attachments.before_close_comments.is_empty(); let align_comments = ctx.config.should_align_call_arg_line_comments() && !has_standalone_comments && call_arg_group_requests_alignment(&arg_entries); @@ -1051,62 +1086,68 @@ fn format_call_args_ir_with_options( } else { let arg_docs: Vec> = args.iter().map(|a| format_expr(ctx, a)).collect(); - 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, - })); + docs.extend(format_delimited_sequence( + ctx, + 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), - ir::indent(vec![ir::hard_line(), ir::list(inner), trailing]), - ir::hard_line(), - tok(LuaTokenKind::TkRightParen), - ])); + docs.extend(wrap_multiline_call_arg_docs( + ctx, + inner, + trailing, + &arg_attachments, + )); } ExpandStrategy::Never => { 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(wrap_multiline_call_arg_docs( + ctx, + inner, + trailing, + &arg_attachments, + )); } else { let arg_docs: Vec> = args.iter().map(|a| format_expr(ctx, a)).collect(); - 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, - })); + docs.extend(format_delimited_sequence( + ctx, + 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 => { @@ -1114,6 +1155,7 @@ fn format_call_args_ir_with_options( docs.extend(format_call_args_multiline_candidates( ctx, arg_entries, + &arg_attachments, trailing, align_comments, has_standalone_comments, @@ -1138,6 +1180,7 @@ fn format_call_args_ir_with_options( .collect(); if attach_first_arg { docs.extend(format_call_args_with_attached_first_arg( + ctx, arg_docs, trailing, preserve_multiline_args, @@ -1147,6 +1190,7 @@ fn format_call_args_ir_with_options( .into_iter() .enumerate() .map(|(index, doc)| CallArgEntry::Arg { + leading_comments: Vec::new(), doc, trailing_comment: None, align_hint: false, @@ -1156,29 +1200,33 @@ fn format_call_args_ir_with_options( docs.extend(format_call_args_multiline_candidates( ctx, multiline_entries, + &DelimitedSequenceAttachments::default(), trailing, false, false, source_line_prefix_width(args_list.syntax()), )); } else { - 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, - })); + docs.extend(format_delimited_sequence( + ctx, + 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, + }, + )); } } } @@ -1268,6 +1316,7 @@ fn normalize_multiline_table_trailing_separator( } fn format_call_args_with_attached_first_arg( + ctx: &FormatContext, arg_docs: Vec>, trailing: DocIR, preserve_multiline: bool, @@ -1296,7 +1345,7 @@ fn format_call_args_with_attached_first_arg( if preserve_multiline { break_docs } else { - let gid = ir::next_group_id(); + let gid = ctx.next_group_id(); vec![ir::group_with_id( vec![ir::if_break_with_group( ir::list(break_docs), @@ -1489,6 +1538,7 @@ fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { fn format_table_multiline_candidates( ctx: &FormatContext, entries: Vec, + attachments: &DelimitedSequenceAttachments, trailing: DocIR, align_eq: bool, should_break: bool, @@ -1497,17 +1547,17 @@ fn format_table_multiline_candidates( ) -> Vec { let align_comments = ctx.config.should_align_table_line_comments(); let aligned = align_eq.then(|| { - wrap_multiline_table_docs(build_table_expanded_inner( + wrap_multiline_table_docs( ctx, - &entries, - &trailing, - true, - align_comments, - )) + build_table_expanded_inner(ctx, &entries, &trailing, true, align_comments), + attachments, + ) }); - let one_per_line = Some(wrap_multiline_table_docs(build_table_expanded_inner( - ctx, &entries, &trailing, false, false, - ))); + let one_per_line = Some(wrap_multiline_table_docs( + ctx, + build_table_expanded_inner(ctx, &entries, &trailing, false, false), + attachments, + )); if should_break { choose_sequence_layout( @@ -1644,6 +1694,7 @@ fn should_preserve_multiline_table_field_table_value(expr: &LuaExpr) -> bool { /// Table entry: field or standalone comment enum TableEntry { Field { + leading_comments: Vec>, doc: Vec, /// Split at `=` for alignment: (key_docs, eq_value_docs) eq_split: Option, @@ -1657,7 +1708,6 @@ enum TableEntry { /// Raw trailing comment docs (NOT wrapped in LineSuffix) trailing_comment: Option>, }, - StandaloneComment(Vec), } fn field_requests_alignment(field: &LuaTableField) -> bool { @@ -1850,8 +1900,10 @@ fn build_table_expanded_inner( while group_end < len { match &entries[group_end] { TableEntry::Field { - eq_split: Some(_), .. - } => { + leading_comments, + eq_split: Some(_), + .. + } if leading_comments.is_empty() => { group_end += 1; } _ => break, @@ -1861,6 +1913,10 @@ fn build_table_expanded_inner( if group_end - group_start >= 2 && table_group_requests_alignment(&entries[group_start..group_end]) { + let TableEntry::Field { + leading_comments, .. + } = &entries[group_start]; + push_comment_lines(&mut inner, leading_comments); inner.push(ir::hard_line()); let max_before = entries[group_start..group_end] .iter() @@ -1931,12 +1987,6 @@ fn build_table_expanded_inner( trailing: None, }); } - TableEntry::StandaloneComment(comment_docs) => { - align_entries.push(AlignEntry::Line { - content: comment_docs.clone(), - trailing: None, - }); - } TableEntry::Field { doc, align_hint: _, @@ -1978,12 +2028,14 @@ fn build_table_expanded_inner( match &entries[i] { TableEntry::Field { + leading_comments, doc, align_hint: _, comment_align_hint: _, trailing_comment, .. } => { + push_comment_lines(&mut inner, leading_comments); inner.push(ir::hard_line()); inner.extend(doc.clone()); let is_last = last_field_idx == Some(i); @@ -1998,10 +2050,6 @@ fn build_table_expanded_inner( inner.push(ir::line_suffix(suffix)); } } - TableEntry::StandaloneComment(comment_docs) => { - inner.push(ir::hard_line()); - inner.extend(comment_docs.clone()); - } } i += 1; } @@ -2009,12 +2057,14 @@ fn build_table_expanded_inner( for (i, entry) in entries.iter().enumerate() { match entry { TableEntry::Field { + leading_comments, doc, align_hint: _, comment_align_hint: _, trailing_comment, .. } => { + push_comment_lines(&mut inner, leading_comments); inner.push(ir::hard_line()); inner.extend(doc.clone()); @@ -2031,10 +2081,6 @@ fn build_table_expanded_inner( inner.push(ir::line_suffix(suffix)); } } - TableEntry::StandaloneComment(comment_docs) => { - inner.push(ir::hard_line()); - inner.extend(comment_docs.clone()); - } } } } @@ -2127,7 +2173,12 @@ fn collect_paren_expr_entries(ctx: &FormatContext, expr: &LuaParenExpr) -> Vec { @@ -2196,13 +2247,7 @@ fn should_preserve_raw_call_expr(expr: &LuaCallExpr) -> bool { return true; } - expr.get_args_list() - .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) + false } fn should_preserve_raw_closure_expr(expr: &LuaClosureExpr) -> bool { @@ -2210,52 +2255,69 @@ fn should_preserve_raw_closure_expr(expr: &LuaClosureExpr) -> bool { return true; } - expr.get_params_list() - .map(|params| node_has_direct_same_line_inline_comment(params.syntax())) - .unwrap_or(false) + false } enum CallArgEntry { Arg { + leading_comments: Vec>, doc: Vec, trailing_comment: Option>, align_hint: bool, has_following_arg: bool, }, - StandaloneComment(Vec), } impl Clone for CallArgEntry { fn clone(&self) -> Self { match self { Self::Arg { + leading_comments, doc, trailing_comment, align_hint, has_following_arg, } => Self::Arg { + leading_comments: leading_comments.clone(), 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![ +struct CollectedCallArgLayout { + entries: Vec, + attachments: DelimitedSequenceAttachments, + has_standalone_comments: bool, +} + +fn wrap_multiline_call_arg_docs( + ctx: &FormatContext, + inner: Vec, + trailing: DocIR, + attachments: &DelimitedSequenceAttachments, +) -> Vec { + wrap_multiline_delimited_sequence_docs( + ctx, tok(LuaTokenKind::TkLeftParen), - ir::indent(vec![ir::hard_line(), ir::list(inner), trailing]), - ir::hard_line(), tok(LuaTokenKind::TkRightParen), - ])] + inner, + Some(trailing), + attachments, + DelimitedSequenceMultilineWrapOptions { + leading_hard_line: true, + indent_before_close_comments: false, + }, + ) } fn format_call_args_multiline_candidates( ctx: &FormatContext, entries: Vec, + attachments: &DelimitedSequenceAttachments, trailing: DocIR, align_comments: bool, has_standalone_comments: bool, @@ -2263,13 +2325,17 @@ fn format_call_args_multiline_candidates( ) -> Vec { let aligned = align_comments.then(|| { wrap_multiline_call_arg_docs( + ctx, build_multiline_call_arg_entries(ctx, entries.clone(), true), trailing.clone(), + attachments, ) }); let one_per_line = Some(wrap_multiline_call_arg_docs( + ctx, build_multiline_call_arg_entries(ctx, entries, false), trailing, + attachments, )); choose_sequence_layout( @@ -2315,17 +2381,17 @@ fn call_arg_group_requests_alignment(entries: &[CallArgEntry]) -> bool { fn collect_call_arg_entries( ctx: &FormatContext, args_list: &emmylua_parser::LuaCallArgList, -) -> Vec { +) -> CollectedCallArgLayout { let args: Vec<_> = args_list.get_args().collect(); let mut entries = Vec::new(); - let mut consumed_comment_ranges: Vec = Vec::new(); + let mut comment_state = DelimitedSequenceCommentState::default(); let mut arg_index = 0usize; 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(ctx.config, arg.syntax()) { - consumed_comment_ranges.push(range); + comment_state.record_consumed_comment_range(range); ( Some(docs), trailing_comment_requests_alignment( @@ -2341,27 +2407,30 @@ fn collect_call_arg_entries( let has_following_arg = arg_index + 1 < args.len(); arg_index += 1; entries.push(CallArgEntry::Arg { + leading_comments: comment_state.take_leading_comments(), doc: format_expr(ctx, &arg), trailing_comment, align_hint, has_following_arg, }); + comment_state.mark_item_seen(); } 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()) - { + if comment_state.should_skip_comment(&comment) { continue; } - entries.push(CallArgEntry::StandaloneComment(format_comment( - ctx.config, &comment, - ))); + comment_state.handle_comment(ctx, &comment); } } - entries + let (attachments, has_standalone_comments) = comment_state.finish(); + + CollectedCallArgLayout { + entries, + attachments, + has_standalone_comments, + } } fn build_multiline_call_arg_entries( @@ -2375,11 +2444,18 @@ fn build_multiline_call_arg_entries( for entry in entries { match entry { CallArgEntry::Arg { + leading_comments, mut doc, trailing_comment, align_hint: _, has_following_arg, } => { + for comment_docs in leading_comments { + align_entries.push(AlignEntry::Line { + content: comment_docs, + trailing: None, + }); + } if has_following_arg { doc.push(tok(LuaTokenKind::TkComma)); } @@ -2388,12 +2464,6 @@ fn build_multiline_call_arg_entries( trailing: trailing_comment, }); } - CallArgEntry::StandaloneComment(comment_docs) => { - align_entries.push(AlignEntry::Line { - content: comment_docs, - trailing: None, - }); - } } } @@ -2409,11 +2479,16 @@ fn build_multiline_call_arg_entries( match entry { CallArgEntry::Arg { + leading_comments, doc, trailing_comment, align_hint: _, has_following_arg, } => { + for comment_docs in leading_comments { + inner.extend(comment_docs); + inner.push(ir::hard_line()); + } inner.extend(doc); if has_following_arg { inner.push(tok(LuaTokenKind::TkComma)); @@ -2424,9 +2499,6 @@ fn build_multiline_call_arg_entries( inner.push(ir::line_suffix(suffix)); } } - CallArgEntry::StandaloneComment(comment_docs) => { - inner.extend(comment_docs); - } } } @@ -2440,7 +2512,10 @@ pub fn format_param_list_ir( ctx: &FormatContext, params: &emmylua_parser::LuaParamList, ) -> Vec { - let entries = collect_param_entries(ctx, params); + let collected = collect_param_entries(ctx, params); + let entries = collected.entries; + let attachments = collected.attachments; + let has_standalone_comments = collected.has_standalone_comments; if entries.is_empty() { return vec![ tok(LuaTokenKind::TkLeftParen), @@ -2450,15 +2525,14 @@ pub fn format_param_list_ir( let has_comments = entries.iter().any(|entry| match entry { ParamEntry::Param { - trailing_comment, .. - } => trailing_comment.is_some(), - ParamEntry::StandaloneComment(_) => true, - }); + trailing_comment, + leading_comments, + .. + } => trailing_comment.is_some() || !leading_comments.is_empty(), + }) || attachments.after_open_comment.is_some() + || !attachments.before_close_comments.is_empty(); if has_comments { - let has_standalone_comments = entries - .iter() - .any(|entry| matches!(entry, ParamEntry::StandaloneComment(_))); let align_comments = ctx.config.should_align_param_line_comments() && !has_standalone_comments && param_group_requests_alignment(&entries); @@ -2466,6 +2540,7 @@ pub fn format_param_list_ir( format_param_multiline_candidates( ctx, entries, + &attachments, format_trailing_comma_ir(ctx.config.output.trailing_comma.clone()), align_comments, has_standalone_comments, @@ -2474,72 +2549,95 @@ pub fn format_param_list_ir( } else { let param_docs: Vec> = entries .into_iter() - .filter_map(|entry| match entry { - ParamEntry::Param { doc, .. } => Some(doc), - ParamEntry::StandaloneComment(_) => None, + .map(|entry| match entry { + ParamEntry::Param { doc, .. } => doc, }) .collect(); - 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, - }) + format_delimited_sequence( + ctx, + 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, + }, + ) } } enum ParamEntry { Param { + leading_comments: Vec>, doc: Vec, trailing_comment: Option>, align_hint: bool, has_following_param: bool, }, - StandaloneComment(Vec), } impl Clone for ParamEntry { fn clone(&self) -> Self { match self { Self::Param { + leading_comments, doc, trailing_comment, align_hint, has_following_param, } => Self::Param { + leading_comments: leading_comments.clone(), 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![ +struct CollectedParamLayout { + entries: Vec, + attachments: DelimitedSequenceAttachments, + has_standalone_comments: bool, +} + +fn wrap_multiline_param_docs( + ctx: &FormatContext, + inner: Vec, + trailing: DocIR, + attachments: &DelimitedSequenceAttachments, +) -> Vec { + wrap_multiline_delimited_sequence_docs( + ctx, tok(LuaTokenKind::TkLeftParen), - ir::indent(vec![ir::hard_line(), ir::list(inner), trailing]), - ir::hard_line(), tok(LuaTokenKind::TkRightParen), - ])] + inner, + Some(trailing), + attachments, + DelimitedSequenceMultilineWrapOptions { + leading_hard_line: true, + indent_before_close_comments: false, + }, + ) } fn format_param_multiline_candidates( ctx: &FormatContext, entries: Vec, + attachments: &DelimitedSequenceAttachments, trailing: DocIR, align_comments: bool, has_standalone_comments: bool, @@ -2548,28 +2646,40 @@ fn format_param_multiline_candidates( let aligned = align_comments.then(|| { let mut align_entries = Vec::new(); for entry in entries.clone() { - if let ParamEntry::Param { + let ParamEntry::Param { + leading_comments, mut doc, trailing_comment, align_hint: _, has_following_param, - } = entry - { - if has_following_param { - doc.push(tok(LuaTokenKind::TkComma)); - } + } = entry; + for comment_docs in leading_comments { align_entries.push(AlignEntry::Line { - content: doc, - trailing: trailing_comment, + content: comment_docs, + trailing: None, }); } + 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()) + wrap_multiline_param_docs( + ctx, + vec![ir::align_group(align_entries)], + trailing.clone(), + attachments, + ) }); let one_per_line = Some(wrap_multiline_param_docs( + ctx, build_multiline_param_entries(ctx, entries), trailing, + attachments, )); choose_sequence_layout( @@ -2591,13 +2701,23 @@ fn format_param_multiline_candidates( ) } -fn wrap_multiline_table_docs(inner: Vec) -> Vec { - vec![ir::group_break(vec![ +fn wrap_multiline_table_docs( + ctx: &FormatContext, + inner: Vec, + attachments: &DelimitedSequenceAttachments, +) -> Vec { + wrap_multiline_delimited_sequence_docs( + ctx, tok(LuaTokenKind::TkLeftBrace), - ir::indent(inner), - ir::hard_line(), tok(LuaTokenKind::TkRightBrace), - ])] + inner, + None, + attachments, + DelimitedSequenceMultilineWrapOptions { + leading_hard_line: false, + indent_before_close_comments: true, + }, + ) } fn param_group_requests_alignment(entries: &[ParamEntry]) -> bool { @@ -2616,10 +2736,10 @@ fn param_group_requests_alignment(entries: &[ParamEntry]) -> bool { fn collect_param_entries( ctx: &FormatContext, params: &emmylua_parser::LuaParamList, -) -> Vec { +) -> CollectedParamLayout { let param_nodes: Vec<_> = params.get_params().collect(); let mut entries = Vec::new(); - let mut consumed_comment_ranges: Vec = Vec::new(); + let mut comment_state = DelimitedSequenceCommentState::default(); let mut param_index = 0usize; for child in params.syntax().children() { @@ -2634,7 +2754,7 @@ fn collect_param_entries( let (trailing_comment, align_hint) = if let Some((docs, range)) = extract_trailing_comment(ctx.config, param.syntax()) { - consumed_comment_ranges.push(range); + comment_state.record_consumed_comment_range(range); ( Some(docs), trailing_comment_requests_alignment( @@ -2650,27 +2770,30 @@ fn collect_param_entries( let has_following_param = param_index + 1 < param_nodes.len(); param_index += 1; entries.push(ParamEntry::Param { + leading_comments: comment_state.take_leading_comments(), doc, trailing_comment, align_hint, has_following_param, }); + comment_state.mark_item_seen(); } 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()) - { + if comment_state.should_skip_comment(&comment) { continue; } - entries.push(ParamEntry::StandaloneComment(format_comment( - ctx.config, &comment, - ))); + comment_state.handle_comment(ctx, &comment); } } - entries + let (attachments, has_standalone_comments) = comment_state.finish(); + + CollectedParamLayout { + entries, + attachments, + has_standalone_comments, + } } fn build_multiline_param_entries(ctx: &FormatContext, entries: Vec) -> Vec { @@ -2683,11 +2806,16 @@ fn build_multiline_param_entries(ctx: &FormatContext, entries: Vec) match entry { ParamEntry::Param { + leading_comments, doc, trailing_comment, align_hint: _, has_following_param, } => { + for comment_docs in leading_comments { + inner.extend(comment_docs); + inner.push(ir::hard_line()); + } inner.extend(doc); if has_following_param { inner.push(tok(LuaTokenKind::TkComma)); @@ -2698,9 +2826,6 @@ fn build_multiline_param_entries(ctx: &FormatContext, entries: Vec) inner.push(ir::line_suffix(suffix)); } } - ParamEntry::StandaloneComment(comment_docs) => { - inner.extend(comment_docs); - } } } diff --git a/crates/emmylua_formatter/src/formatter/mod.rs b/crates/emmylua_formatter/src/formatter/mod.rs index cfeee4d6b..516f84adf 100644 --- a/crates/emmylua_formatter/src/formatter/mod.rs +++ b/crates/emmylua_formatter/src/formatter/mod.rs @@ -7,8 +7,10 @@ mod statement; mod tokens; mod trivia; +use std::cell::Cell; + use crate::config::LuaFormatConfig; -use crate::ir::{self, DocIR}; +use crate::ir::{self, DocIR, GroupId}; use emmylua_parser::{LuaAstNode, LuaChunk, LuaKind, LuaTokenKind}; pub use block::format_block; @@ -17,11 +19,21 @@ pub use statement::format_body_end_with_parent; /// Formatting context, shared throughout the formatting process pub struct FormatContext<'a> { pub config: &'a LuaFormatConfig, + next_group_id: Cell, } impl<'a> FormatContext<'a> { pub fn new(config: &'a LuaFormatConfig) -> Self { - Self { config } + Self { + config, + next_group_id: Cell::new(0), + } + } + + pub fn next_group_id(&self) -> GroupId { + let next = self.next_group_id.get(); + self.next_group_id.set(next + 1); + GroupId(next) } } diff --git a/crates/emmylua_formatter/src/formatter/sequence.rs b/crates/emmylua_formatter/src/formatter/sequence.rs index 669806fde..09889a8f2 100644 --- a/crates/emmylua_formatter/src/formatter/sequence.rs +++ b/crates/emmylua_formatter/src/formatter/sequence.rs @@ -1,10 +1,13 @@ -use emmylua_parser::LuaTokenKind; +use emmylua_parser::{LuaAstNode, LuaComment, LuaTokenKind}; +use rowan::TextRange; use crate::config::ExpandStrategy; use crate::ir::{self, DocIR, ir_flat_width, ir_has_forced_line_break}; use crate::printer::Printer; use super::FormatContext; +use super::comment::{format_comment, trailing_comment_prefix}; +use super::trivia::has_non_trivia_before_on_same_line_tokenwise; #[derive(Clone)] pub enum SequenceEntry { @@ -87,6 +90,113 @@ pub struct DelimitedSequenceLayout { pub prefer_custom_break_in_auto: bool, } +#[derive(Clone, Default)] +pub struct DelimitedSequenceAttachments { + pub after_open_comment: Option>, + pub before_close_comments: Vec>, +} + +#[derive(Default)] +pub struct DelimitedSequenceCommentState { + attachments: DelimitedSequenceAttachments, + consumed_comment_ranges: Vec, + pending_leading_comments: Vec>, + has_standalone_comments: bool, + seen_item: bool, +} + +impl DelimitedSequenceCommentState { + pub fn record_consumed_comment_range(&mut self, range: TextRange) { + self.consumed_comment_ranges.push(range); + } + + pub fn should_skip_comment(&self, comment: &LuaComment) -> bool { + self.consumed_comment_ranges + .iter() + .any(|range| *range == comment.syntax().text_range()) + } + + pub fn handle_comment(&mut self, ctx: &FormatContext, comment: &LuaComment) { + let comment_docs = format_comment(ctx.config, comment); + if !self.seen_item + && self.attachments.after_open_comment.is_none() + && has_non_trivia_before_on_same_line_tokenwise(comment.syntax()) + { + self.attachments.after_open_comment = Some(comment_docs); + return; + } + + self.pending_leading_comments.push(comment_docs); + self.has_standalone_comments = true; + } + + pub fn take_leading_comments(&mut self) -> Vec> { + std::mem::take(&mut self.pending_leading_comments) + } + + pub fn mark_item_seen(&mut self) { + self.seen_item = true; + } + + pub fn finish(mut self) -> (DelimitedSequenceAttachments, bool) { + self.attachments.before_close_comments = self.pending_leading_comments; + (self.attachments, self.has_standalone_comments) + } +} + +#[derive(Clone, Copy)] +pub struct DelimitedSequenceMultilineWrapOptions { + pub leading_hard_line: bool, + pub indent_before_close_comments: bool, +} + +pub fn push_comment_lines(target: &mut Vec, comments: &[Vec]) { + for comment_docs in comments { + target.push(ir::hard_line()); + target.extend(comment_docs.clone()); + } +} + +pub fn wrap_multiline_delimited_sequence_docs( + ctx: &FormatContext, + open: DocIR, + close: DocIR, + inner: Vec, + trailing: Option, + attachments: &DelimitedSequenceAttachments, + options: DelimitedSequenceMultilineWrapOptions, +) -> Vec { + let mut indented = Vec::new(); + if options.leading_hard_line { + indented.push(ir::hard_line()); + } + indented.push(ir::list(inner)); + if let Some(trailing) = trailing { + indented.push(trailing); + } + if !options.indent_before_close_comments { + push_comment_lines(&mut indented, &attachments.before_close_comments); + } + + let mut docs = vec![open]; + if let Some(comment_docs) = &attachments.after_open_comment { + let mut suffix = trailing_comment_prefix(ctx.config); + suffix.extend(comment_docs.clone()); + docs.push(ir::line_suffix(suffix)); + } + docs.push(ir::indent(indented)); + + if options.indent_before_close_comments && !attachments.before_close_comments.is_empty() { + let mut closing_comments = Vec::new(); + push_comment_lines(&mut closing_comments, &attachments.before_close_comments); + docs.push(ir::indent(closing_comments)); + } + + docs.push(ir::hard_line()); + docs.push(close); + vec![ir::group_break(docs)] +} + #[derive(Clone, Default)] pub struct SequenceLayoutCandidates { pub flat: Option>, @@ -342,7 +452,10 @@ fn sequence_layout_kind_penalty(kind: SequenceLayoutKind) -> usize { } } -pub fn format_delimited_sequence(layout: DelimitedSequenceLayout) -> Vec { +pub fn format_delimited_sequence( + ctx: &FormatContext, + layout: DelimitedSequenceLayout, +) -> Vec { if layout.items.is_empty() { return vec![layout.open, layout.close]; } @@ -371,7 +484,7 @@ pub fn format_delimited_sequence(layout: DelimitedSequenceLayout) -> Vec 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 gid = ctx.next_group_id(); let break_doc = ir::list(vec![ layout.open, ir::indent(break_contents), diff --git a/crates/emmylua_formatter/src/formatter/statement.rs b/crates/emmylua_formatter/src/formatter/statement.rs index fed5d673a..99874a23f 100644 --- a/crates/emmylua_formatter/src/formatter/statement.rs +++ b/crates/emmylua_formatter/src/formatter/statement.rs @@ -21,8 +21,9 @@ use super::sequence::{ 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, - source_line_prefix_width, syntax_has_descendant_comment, + has_non_trivia_before_on_same_line_tokenwise, node_has_direct_comment_child, + node_has_direct_same_line_inline_comment, source_line_prefix_width, + syntax_has_descendant_comment, }; /// Format a statement (dispatch) @@ -1955,9 +1956,9 @@ fn should_preserve_raw_statement_with_inline_comments(stat: &LuaStat) -> bool { .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) + .get_block() + .as_ref() + .is_some_and(block_has_leading_same_line_inline_comment) }) .unwrap_or(false), LuaStat::LocalFuncStat(func) => func @@ -1965,15 +1966,23 @@ fn should_preserve_raw_statement_with_inline_comments(stat: &LuaStat) -> bool { .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) + .get_block() + .as_ref() + .is_some_and(block_has_leading_same_line_inline_comment) }) .unwrap_or(false), _ => false, } } +fn block_has_leading_same_line_inline_comment(block: &LuaBlock) -> bool { + block + .syntax() + .children() + .find(|child| child.kind() == LuaKind::Syntax(LuaSyntaxKind::Comment)) + .is_some_and(|comment| has_non_trivia_before_on_same_line_tokenwise(&comment)) +} + /// Check if a statement can participate in `=` alignment. /// Only simple local/assign statements with values qualify. pub fn is_eq_alignable(config: &LuaFormatConfig, stat: &LuaStat) -> bool { diff --git a/crates/emmylua_formatter/src/formatter/trivia.rs b/crates/emmylua_formatter/src/formatter/trivia.rs index 676cbd677..e0b10dc8a 100644 --- a/crates/emmylua_formatter/src/formatter/trivia.rs +++ b/crates/emmylua_formatter/src/formatter/trivia.rs @@ -60,6 +60,26 @@ pub fn has_non_trivia_before_on_same_line(node: &LuaSyntaxNode) -> bool { false } +pub fn has_non_trivia_before_on_same_line_tokenwise(node: &LuaSyntaxNode) -> bool { + let Some(first_token) = node.first_token() else { + return false; + }; + + let mut previous = first_token.prev_token(); + + while let Some(token) = previous { + match token.kind().to_token() { + LuaTokenKind::TkWhitespace => { + previous = token.prev_token(); + } + LuaTokenKind::TkEndOfLine => return false, + _ => return true, + } + } + + false +} + pub fn source_line_prefix_width(node: &LuaSyntaxNode) -> usize { let mut width = 0usize; let Some(mut token) = node.first_token() else { diff --git a/crates/emmylua_formatter/src/ir/builder.rs b/crates/emmylua_formatter/src/ir/builder.rs index 2684173cb..620ebd33d 100644 --- a/crates/emmylua_formatter/src/ir/builder.rs +++ b/crates/emmylua_formatter/src/ir/builder.rs @@ -1,17 +1,10 @@ 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); - -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()) } diff --git a/crates/emmylua_formatter/src/test/expression_tests.rs b/crates/emmylua_formatter/src/test/expression_tests.rs index 2a0fdf0ae..7746aa305 100644 --- a/crates/emmylua_formatter/src/test/expression_tests.rs +++ b/crates/emmylua_formatter/src/test/expression_tests.rs @@ -49,6 +49,22 @@ local e = #t ); } + #[test] + fn test_binary_expr_keeps_inline_doc_long_comment_before_operator() { + assert_format!( + "local x = x--[[@cast -?]] * 60\n", + "local x = x--[[@cast -?]] * 60\n" + ); + } + + #[test] + fn test_binary_expr_keeps_inline_long_comment_before_operator() { + assert_format!( + "local x = x--[[cast]] * 60\n", + "local x = x--[[cast]] * 60\n" + ); + } + #[test] fn test_binary_chain_uses_progressive_line_packing() { let config = LuaFormatConfig { @@ -150,6 +166,38 @@ local b = t[1] ); } + #[test] + fn test_table_expr_preserves_inline_comment_after_open_brace() { + assert_format!( + "local d = { -- enne\n a = 1, -- hf\n b = 2,\n}\n", + "local d = { -- enne\n a = 1, -- hf\n b = 2\n}\n" + ); + } + + #[test] + fn test_table_expr_formats_body_with_after_open_delimiter_comment() { + assert_format!( + "local d = { -- enne\na=1,-- hf\nb=2,\n}\n", + "local d = { -- enne\n a = 1, -- hf\n b = 2\n}\n" + ); + } + + #[test] + fn test_table_expr_formats_separator_comment_with_attached_field() { + assert_format!( + "local t = {\na=1,\n-- separator\nb=2\n}\n", + "local t = {\n a = 1,\n -- separator\n b = 2\n}\n" + ); + } + + #[test] + fn test_table_expr_formats_before_close_comment_attachment() { + assert_format!( + "local t = {\na=1,\n-- tail\n}\n", + "local t = {\n a = 1\n -- tail\n}\n" + ); + } + #[test] fn test_empty_table() { assert_format!("local t = {}\n", "local t = {}\n"); @@ -209,14 +257,54 @@ local b = t[1] #[test] fn test_call_expr_preserves_inline_comment_in_args() { - assert_format!("foo(a -- first\n, b)\n", "foo(a -- first\n, b)\n"); + assert_format!( + "foo(a -- first\n, b)\n", + "foo(\n a, -- first\n b\n)\n" + ); + } + + #[test] + fn test_call_expr_formats_after_open_comment_attachment() { + assert_format!( + "foo( -- first\na,-- second\nb\n)\n", + "foo( -- first\n a, -- second\n b\n)\n" + ); + } + + #[test] + fn test_call_expr_formats_separator_comment_attachment() { + assert_format!( + "foo(\na,\n-- separator\nb\n)\n", + "foo(\n a,\n -- separator\n b\n)\n" + ); + } + + #[test] + fn test_call_expr_formats_before_close_comment_attachment() { + assert_format!("foo(\na,\n-- tail\n)\n", "foo(\n a\n -- tail\n)\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" + "local f = function(\n a, -- first\n b\n)\n return a + b\nend\n" + ); + } + + #[test] + fn test_closure_expr_formats_after_open_comment_in_params() { + assert_format!( + "local f = function( -- first\na,-- second\nb\n)\n return a + b\nend\n", + "local f = function( -- first\n a, -- second\n b\n)\n return a + b\nend\n" + ); + } + + #[test] + fn test_closure_expr_formats_before_close_comment_in_params() { + assert_format!( + "local f = function(\na,\n-- tail\n)\n return a\nend\n", + "local f = function(\n a\n -- tail\n)\n return a\nend\n" ); } diff --git a/crates/emmylua_formatter/src/test/statement_tests.rs b/crates/emmylua_formatter/src/test/statement_tests.rs index 43672e7d6..8a739e46a 100644 --- a/crates/emmylua_formatter/src/test/statement_tests.rs +++ b/crates/emmylua_formatter/src/test/statement_tests.rs @@ -800,11 +800,19 @@ end ); } + #[test] + fn test_function_stat_preserves_inline_comment_before_non_empty_body() { + assert_format!( + "function name13() --hhii\n return \"name13\" --jj\nend\n", + "function name13() --hhii\n return \"name13\" --jj\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" + "function foo(\n a, -- first\n b\n)\n return a + b\nend\n" ); } From 60c99ccc45ae4ca3ef5e0b5c08a0e4e4e5048b87 Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Thu, 26 Mar 2026 15:38:10 +0800 Subject: [PATCH 17/23] refactor comment handle --- .../emmylua_formatter/src/formatter/block.rs | 2 +- .../formatter/comments/comment_formatter.rs | 173 ++++ .../formatter/{comment.rs => comments/mod.rs} | 744 ++++++++++-------- .../src/formatter/expression.rs | 2 +- crates/emmylua_formatter/src/formatter/mod.rs | 2 +- .../src/formatter/sequence.rs | 2 +- .../src/formatter/statement.rs | 2 +- .../src/test/comment_tests.rs | 16 + .../src/syntax/node/doc/test.rs | 38 +- 9 files changed, 644 insertions(+), 337 deletions(-) create mode 100644 crates/emmylua_formatter/src/formatter/comments/comment_formatter.rs rename crates/emmylua_formatter/src/formatter/{comment.rs => comments/mod.rs} (67%) diff --git a/crates/emmylua_formatter/src/formatter/block.rs b/crates/emmylua_formatter/src/formatter/block.rs index 7964201f9..bf5555ccf 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::{extract_trailing_comment, format_comment, format_trailing_comment}; +use super::comments::{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; diff --git a/crates/emmylua_formatter/src/formatter/comments/comment_formatter.rs b/crates/emmylua_formatter/src/formatter/comments/comment_formatter.rs new file mode 100644 index 000000000..7b9be2dab --- /dev/null +++ b/crates/emmylua_formatter/src/formatter/comments/comment_formatter.rs @@ -0,0 +1,173 @@ +use std::collections::HashMap; + +use emmylua_parser::{LuaAstNode, LuaComment, LuaSyntaxId, LuaTokenKind}; + +use crate::formatter::comments::TokenExpected; +use crate::ir::{self, DocIR}; + +pub struct CommentFormatter { + left_expected: HashMap, + right_expected: HashMap, + replace_tokens: HashMap, +} + +impl CommentFormatter { + pub fn new() -> Self { + Self { + left_expected: HashMap::new(), + right_expected: HashMap::new(), + replace_tokens: HashMap::new(), + } + } + + pub fn add_token_left_expected(&mut self, syntax_id: LuaSyntaxId, expected: TokenExpected) { + self.left_expected.insert(syntax_id, expected); + } + + pub fn add_token_right_expected(&mut self, syntax_id: LuaSyntaxId, expected: TokenExpected) { + self.right_expected.insert(syntax_id, expected); + } + + pub fn get_left_expected(&self, syntax_id: LuaSyntaxId) -> Option<&TokenExpected> { + self.left_expected.get(&syntax_id) + } + + pub fn get_right_expected(&self, syntax_id: LuaSyntaxId) -> Option<&TokenExpected> { + self.right_expected.get(&syntax_id) + } + + pub fn add_token_replace(&mut self, syntax_id: LuaSyntaxId, replacement: String) { + self.replace_tokens.insert(syntax_id, replacement); + } + + pub fn get_token_replace(&self, syntax_id: LuaSyntaxId) -> Option<&str> { + self.replace_tokens.get(&syntax_id).map(String::as_str) + } + + pub fn render_comment(&self, comment: &LuaComment) -> Vec { + self.render_comment_lines(comment) + .into_iter() + .enumerate() + .flat_map(|(index, line)| { + let mut docs = Vec::new(); + if index > 0 { + docs.push(ir::hard_line()); + } + if !line.is_empty() { + docs.push(ir::text(line)); + } + docs + }) + .collect() + } + + pub fn render_comment_text(&self, comment: &LuaComment) -> String { + let mut lines = self.render_comment_lines(comment).into_iter(); + let Some(first_line) = lines.next() else { + return String::new(); + }; + + if lines.len() == 0 { + return first_line; + } + + let mut rendered = first_line; + for line in lines { + rendered.push('\n'); + rendered.push_str(&line); + } + + rendered + } + + fn render_comment_lines(&self, comment: &LuaComment) -> Vec { + let mut lines = Vec::new(); + let mut current_line = String::new(); + let mut prev_token_id = None; + let mut pending_gap = String::new(); + let mut line_has_content = false; + let mut ended_with_newline = false; + + for element in comment.syntax().descendants_with_tokens() { + let Some(token) = element.into_token() else { + continue; + }; + + match token.kind().into() { + LuaTokenKind::TkWhitespace => { + pending_gap.push_str(token.text()); + } + LuaTokenKind::TkEndOfLine => { + lines.push(std::mem::take(&mut current_line)); + prev_token_id = None; + pending_gap.clear(); + line_has_content = false; + ended_with_newline = true; + } + _ => { + let syntax_id = LuaSyntaxId::from_token(&token); + if line_has_content { + current_line + .push_str(&self.resolve_gap(prev_token_id, syntax_id, &pending_gap)); + } + + current_line.push_str( + self.get_token_replace(syntax_id) + .unwrap_or_else(|| token.text()), + ); + line_has_content = true; + prev_token_id = Some(syntax_id); + pending_gap.clear(); + ended_with_newline = false; + } + } + } + + if line_has_content || ended_with_newline { + lines.push(std::mem::take(&mut current_line)); + } + + lines + } + + fn resolve_gap( + &self, + prev_token_id: Option, + token_id: LuaSyntaxId, + gap: &str, + ) -> String { + let mut exact_space = None; + let mut max_space = None; + + if let Some(prev_token_id) = prev_token_id + && let Some(expected) = self.get_right_expected(prev_token_id) + { + match expected { + TokenExpected::Space(count) => exact_space = Some(*count), + TokenExpected::MaxSpace(count) => max_space = Some(*count), + } + } + + if let Some(expected) = self.get_left_expected(token_id) { + match expected { + TokenExpected::Space(count) => { + exact_space = Some(exact_space.map_or(*count, |current| current.max(*count))); + } + TokenExpected::MaxSpace(count) => { + max_space = Some(max_space.map_or(*count, |current| current.min(*count))); + } + } + } + + if let Some(exact_space) = exact_space { + return " ".repeat(exact_space); + } + + if let Some(max_space) = max_space { + let original_space_count = gap.chars().take_while(|ch| *ch == ' ').count(); + return " ".repeat(original_space_count.min(max_space)); + } + + gap.to_string() + } +} diff --git a/crates/emmylua_formatter/src/formatter/comment.rs b/crates/emmylua_formatter/src/formatter/comments/mod.rs similarity index 67% rename from crates/emmylua_formatter/src/formatter/comment.rs rename to crates/emmylua_formatter/src/formatter/comments/mod.rs index 5aa50373e..f36570d3a 100644 --- a/crates/emmylua_formatter/src/formatter/comment.rs +++ b/crates/emmylua_formatter/src/formatter/comments/mod.rs @@ -1,3 +1,6 @@ +#[allow(dead_code)] +mod comment_formatter; + use emmylua_parser::{ LuaAstNode, LuaAstToken, LuaComment, LuaDocFieldKey, LuaDocGenericDeclList, LuaDocTag, LuaDocTagAlias, LuaDocTagClass, LuaDocTagField, LuaDocTagGeneric, LuaDocTagOverload, @@ -9,233 +12,368 @@ use rowan::TextRange; use crate::config::LuaFormatConfig; use crate::ir::{self, DocIR}; +use self::comment_formatter::CommentFormatter; use super::trivia::has_non_trivia_before_on_same_line; -/// Format a Comment node. -/// -/// 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 +enum TokenExpected { + Space(usize), + MaxSpace(usize), +} + 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(config, comment), + let is_doc = is_doc_comment(comment); + + if has_nonstandard_dash_prefix(comment) + || (is_doc && should_preserve_doc_comment_raw(comment)) + { + return vec![ir::source_node_trimmed(comment.syntax().clone())]; } + + if is_long_comment(comment) { + return vec![ir::source_node_trimmed(comment.syntax().clone())]; + } + + if !is_doc { + return format_normal_comment(config, comment); + } + + format_doc_comment(config, comment) } -/// 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(config: &LuaFormatConfig, comment: &LuaComment) -> Vec { - let lines = parse_doc_comment_lines(comment); - let rendered = render_doc_comment_lines(config, &lines); +pub fn collect_orphan_comments(config: &LuaFormatConfig, node: &LuaSyntaxNode) -> Vec { 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)); + 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(config, &comment)); } } 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; - }; +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) + || has_non_trivia_after_on_same_line(&child) + { + continue; + } - match first_token.kind().into() { - LuaTokenKind::TkLongCommentStart | LuaTokenKind::TkDocLongStart => CommentKind::Long, - LuaTokenKind::TkDocStart | LuaTokenKind::TkDocContinue | LuaTokenKind::TkDocContinueOr => { - CommentKind::Doc + let comment = LuaComment::cast(child.clone())?; + if child.text().contains_char('\n') { + return None; } - LuaTokenKind::TkNormalStart => { - if first_token.text().starts_with("---") || comment.get_doc_tags().next().is_some() { - CommentKind::Doc - } else { - CommentKind::Normal + + let comment_text = + render_single_line_comment_text(config, &comment).unwrap_or_else(|| trim_end_owned(child.text())); + + return Some((vec![ir::text(comment_text)], child.text_range())); + } + + let mut next = node.next_sibling_or_token(); + 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 = LuaComment::cast(comment_node.clone())?; + if comment_node.text().contains_char('\n') { + return None; + } + + let comment_text = render_single_line_comment_text(config, &comment) + .unwrap_or_else(|| trim_end_owned(comment_node.text())); + + return Some((vec![ir::text(comment_text)], comment_node.text_range())); } + _ => return None, } - _ => { - if comment.get_doc_tags().next().is_some() { - CommentKind::Doc - } else { - CommentKind::Normal + next = sibling.next_sibling_or_token(); + } + + 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() +} + +pub fn format_trailing_comment( + config: &LuaFormatConfig, + node: &LuaSyntaxNode, +) -> Option<(DocIR, TextRange)> { + 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)) +} + +pub fn should_keep_comment_inline_in_expression(comment: &LuaComment) -> bool { + is_long_comment(comment) && !comment.syntax().text().contains_char('\n') +} + +fn should_preserve_doc_comment_raw(comment: &LuaComment) -> bool { + let mut seen_prefix_on_line = false; + + for element in comment.syntax().descendants_with_tokens() { + let Some(token) = element.into_token() else { + continue; + }; + + match token.kind().into() { + LuaTokenKind::TkEndOfLine => { + seen_prefix_on_line = false; } + LuaTokenKind::TkDocStart + | LuaTokenKind::TkDocLongStart + | LuaTokenKind::TkDocContinue + | LuaTokenKind::TkDocContinueOr + | LuaTokenKind::TkNormalStart => { + if seen_prefix_on_line { + return true; + } + seen_prefix_on_line = true; + } + _ => {} } } + + false } -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, - ))]; - } +fn is_doc_comment(comment: &LuaComment) -> bool { + let Some(first_token) = comment.syntax().first_token() else { + return false; + }; - 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 { - docs.push(ir::hard_line()); - } - if !line.is_empty() { - docs.push(ir::text(line)); + match first_token.kind().into() { + LuaTokenKind::TkDocStart | LuaTokenKind::TkDocContinue | LuaTokenKind::TkDocContinueOr => { + true } + LuaTokenKind::TkNormalStart => is_doc_normal_start(first_token.text()), + _ => comment.get_doc_tags().next().is_some(), } - docs } -#[derive(Debug, Clone, Default)] -struct NormalCommentLine { - prefix: String, - gap: String, - detail: String, +fn is_long_comment(comment: &LuaComment) -> bool { + let Some(first_token) = comment.syntax().first_token() else { + return false; + }; + + matches!( + first_token.kind().into(), + LuaTokenKind::TkLongCommentStart | LuaTokenKind::TkDocLongStart + ) } -fn parse_normal_comment_lines(comment: &LuaComment) -> Vec { - let mut lines = Vec::new(); - let mut current_line: Option = None; +fn format_normal_comment(config: &LuaFormatConfig, comment: &LuaComment) -> Vec { + let formatter = build_comment_formatter(config, comment, !comment.syntax().text().contains_char('\n')); + formatter.render_comment(comment) +} - for child in comment.syntax().children_with_tokens() { - let LuaSyntaxElement::Token(token) = child else { +fn build_comment_formatter( + config: &LuaFormatConfig, + comment: &LuaComment, + normalize_start_tokens: bool, +) -> CommentFormatter { + let mut formatter = CommentFormatter::new(); + + for element in comment.syntax().descendants_with_tokens() { + let Some(token) = element.into_token() else { continue; }; - match token.kind().into() { - LuaTokenKind::TkNormalStart | LuaTokenKind::TKNonStdComment => { - if let Some(line) = current_line.take() { - lines.push(line); + let syntax_id = emmylua_parser::LuaSyntaxId::from_token(&token); + match token.kind().to_token() { + LuaTokenKind::TkNormalStart if normalize_start_tokens => { + if let Some(replacement) = normalized_comment_prefix(config, token.text()) { + formatter.add_token_replace(syntax_id, replacement); + formatter.add_token_right_expected(syntax_id, TokenExpected::Space(0)); } - current_line = Some(NormalCommentLine { - prefix: token.text().to_string(), - ..Default::default() - }); } - LuaTokenKind::TkWhitespace => { - let Some(line) = current_line.as_mut() else { + LuaTokenKind::TkDocStart if normalize_start_tokens => { + formatter.add_token_replace(syntax_id, "---@".to_string()); + formatter.add_token_right_expected(syntax_id, TokenExpected::Space(0)); + } + LuaTokenKind::TkDocContinue if normalize_start_tokens => { + formatter.add_token_replace( + syntax_id, + normalized_doc_continue_prefix(config, token.text()), + ); + formatter.add_token_right_expected(syntax_id, TokenExpected::Space(0)); + } + 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 => { + formatter.add_token_left_expected(syntax_id, TokenExpected::Space(0)); + } + LuaTokenKind::TkString + | LuaTokenKind::TkRightBrace + | LuaTokenKind::TkLongString => { + formatter.add_token_left_expected(syntax_id, TokenExpected::Space(1)); + } + _ => {} + } + } + formatter.add_token_right_expected(syntax_id, TokenExpected::Space(0)); + } + LuaTokenKind::TkRightBracket | LuaTokenKind::TkRightParen => { + formatter.add_token_left_expected(syntax_id, TokenExpected::Space(0)); + } + LuaTokenKind::TkLeftBrace => { + formatter.add_token_right_expected(syntax_id, TokenExpected::Space(1)); + } + LuaTokenKind::TkRightBrace => { + formatter.add_token_left_expected(syntax_id, TokenExpected::Space(1)); + } + LuaTokenKind::TkComma => { + formatter.add_token_left_expected(syntax_id, TokenExpected::Space(0)); + formatter.add_token_right_expected(syntax_id, TokenExpected::Space(1)); + } + LuaTokenKind::TkPlus | LuaTokenKind::TkMinus => { + if is_parent_syntax(&token, LuaSyntaxKind::UnaryExpr) { + formatter.add_token_right_expected(syntax_id, TokenExpected::Space(0)); continue; - }; - - if line.detail.is_empty() { - line.gap.push_str(token.text()); - } else { - line.detail.push_str(token.text()); } + formatter.add_token_left_expected(syntax_id, TokenExpected::Space(1)); + formatter.add_token_right_expected(syntax_id, TokenExpected::Space(1)); } - LuaTokenKind::TkDocDetail => { - if let Some(line) = current_line.as_mut() { - line.detail.push_str(token.text()); + LuaTokenKind::TkLt => { + if is_parent_syntax(&token, LuaSyntaxKind::Attribute) { + formatter.add_token_left_expected(syntax_id, TokenExpected::Space(1)); + formatter.add_token_right_expected(syntax_id, TokenExpected::Space(0)); + continue; } + formatter.add_token_left_expected(syntax_id, TokenExpected::Space(1)); + formatter.add_token_right_expected(syntax_id, TokenExpected::Space(1)); } - LuaTokenKind::TkEndOfLine => { - if let Some(line) = current_line.take() { - lines.push(line); + LuaTokenKind::TkGt => { + if is_parent_syntax(&token, LuaSyntaxKind::Attribute) { + formatter.add_token_left_expected(syntax_id, TokenExpected::Space(0)); + formatter.add_token_right_expected(syntax_id, TokenExpected::Space(1)); + continue; + } + formatter.add_token_left_expected(syntax_id, TokenExpected::Space(1)); + formatter.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 => { + formatter.add_token_left_expected(syntax_id, TokenExpected::Space(1)); + formatter.add_token_right_expected(syntax_id, TokenExpected::Space(1)); + } + LuaTokenKind::TkColon => { + if is_parent_syntax(&token, LuaSyntaxKind::IndexExpr) { + formatter.add_token_left_expected(syntax_id, TokenExpected::Space(0)); + formatter.add_token_right_expected(syntax_id, TokenExpected::Space(0)); + continue; } + formatter.add_token_left_expected(syntax_id, TokenExpected::MaxSpace(1)); + formatter.add_token_right_expected(syntax_id, TokenExpected::MaxSpace(1)); + } + LuaTokenKind::TkDot => { + formatter.add_token_left_expected(syntax_id, TokenExpected::Space(0)); + formatter.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 => { + formatter.add_token_left_expected(syntax_id, TokenExpected::Space(1)); + formatter.add_token_right_expected(syntax_id, TokenExpected::Space(1)); } _ => {} } } - if let Some(line) = current_line.take() { - lines.push(line); - } - - lines -} - -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() + formatter } -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(); +fn render_single_line_comment_text(config: &LuaFormatConfig, comment: &LuaComment) -> Option { + if is_long_comment(comment) { + return Some(trim_end_owned(comment.syntax().text())); } - 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); - } + if has_nonstandard_dash_prefix(comment) { + return Some(trim_end_owned(comment.syntax().text())); } - 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 is_doc_comment(comment) { + return None; } - if let Some(body) = trimmed.strip_prefix("--") - && !body.is_empty() - && !body.starts_with(' ') - && !body.starts_with('\t') - { - return format!("-- {body}"); + if comment.syntax().text().contains_char('\n') { + return None; } - trimmed.to_string() + let formatter = build_comment_formatter(config, comment, true); + Some(formatter.render_comment_text(comment)) +} + +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(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, - }, + 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, @@ -246,10 +384,7 @@ enum DocCommentLine { ty: String, desc: Option, }, - Return { - body: String, - desc: Option, - }, + Return { body: String, desc: Option }, Raw(String), } @@ -338,7 +473,7 @@ fn append_doc_description_lines( } LuaTokenKind::TkEndOfLine => { pending.description = Some(if pending.preserve_description_raw { - current_text.trim_end().to_string() + trim_end_owned(current_text.as_str()) } else { normalize_single_line_spaces(¤t_text) }); @@ -352,7 +487,7 @@ fn append_doc_description_lines( if !current_text.is_empty() { pending.description = Some(if pending.preserve_description_raw { - current_text.trim_end().to_string() + trim_end_owned(current_text.as_str()) } else { normalize_single_line_spaces(¤t_text) }); @@ -369,16 +504,16 @@ fn finalize_doc_comment_line(pending: &mut PendingDocLine) -> DocCommentLine { build_doc_tag_line(&prefix, tag, description) } else if let Some(text) = description { if preserve_description_raw { - DocCommentLine::Raw(format!("{prefix}{text}").trim_end().to_string()) + DocCommentLine::Raw(trim_end_owned(format!("{prefix}{text}"))) } else if text.is_empty() { - DocCommentLine::Raw(prefix.trim_end().to_string()) + DocCommentLine::Raw(trim_end_owned(prefix.as_str())) } else { DocCommentLine::Description(text) } } else if prefix.is_empty() { DocCommentLine::Empty } else { - DocCommentLine::Raw(prefix.trim_end().to_string()) + DocCommentLine::Raw(trim_end_owned(prefix.as_str())) } } @@ -389,46 +524,29 @@ fn build_doc_tag_line(prefix: &str, tag: LuaDocTag, description: Option) match tag { LuaDocTag::Class(class_tag) => { - build_class_doc_line(prefix, &class_tag, description.clone()).unwrap_or_else(|| { + build_class_doc_line(&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) - }), + LuaDocTag::Alias(alias) => build_alias_doc_line(&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(&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(&generic, description.clone()) + .unwrap_or_else(|| raw_doc_tag_line(prefix, generic.syntax().text().to_string(), description)), + LuaDocTag::Overload(overload) => build_overload_doc_line(&overload, description.clone()) + .unwrap_or_else(|| raw_doc_tag_line(prefix, overload.syntax().text().to_string(), description)), + LuaDocTag::Param(param) => build_param_doc_line(¶m, description.clone()) + .unwrap_or_else(|| raw_doc_tag_line(prefix, param.syntax().text().to_string(), description)), + LuaDocTag::Field(field) => build_field_doc_line(&field, description.clone()) + .unwrap_or_else(|| raw_doc_tag_line(prefix, field.syntax().text().to_string(), description)), + LuaDocTag::Return(ret) => build_return_doc_line(&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 { @@ -445,7 +563,6 @@ fn build_class_doc_line( } fn build_alias_doc_line( - _prefix: &str, tag: &LuaDocTagAlias, description: Option, ) -> Option { @@ -455,7 +572,6 @@ fn build_alias_doc_line( } fn build_type_doc_line( - _prefix: &str, tag: &LuaDocTagType, description: Option, ) -> Option { @@ -474,7 +590,6 @@ fn build_type_doc_line( } fn build_generic_doc_line( - _prefix: &str, tag: &LuaDocTagGeneric, description: Option, ) -> Option { @@ -484,7 +599,6 @@ fn build_generic_doc_line( } fn build_overload_doc_line( - _prefix: &str, tag: &LuaDocTagOverload, description: Option, ) -> Option { @@ -495,7 +609,7 @@ fn build_overload_doc_line( 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()); + return DocCommentLine::Raw(trim_end_owned(format!("{prefix}{body}"))); } let mut line = format!("{prefix}{}", normalize_single_line_spaces(&body)); @@ -509,7 +623,6 @@ fn raw_doc_tag_line(prefix: &str, body: String, description: Option) -> } fn build_param_doc_line( - _prefix: &str, tag: &LuaDocTagParam, description: Option, ) -> Option { @@ -528,7 +641,6 @@ fn build_param_doc_line( } fn build_field_doc_line( - _prefix: &str, tag: &LuaDocTagField, description: Option, ) -> Option { @@ -548,7 +660,6 @@ fn build_field_doc_line( } fn build_return_doc_line( - _prefix: &str, tag: &LuaDocTagReturn, description: Option, ) -> Option { @@ -588,7 +699,11 @@ fn single_line_syntax_text(node: &impl LuaAstNode) -> Option { fn non_empty_description_text(description: Option) -> Option { let text = description?; - if text.is_empty() { None } else { Some(text) } + if text.is_empty() { + None + } else { + Some(text) + } } fn normalize_single_line_spaces(text: &str) -> String { @@ -602,9 +717,8 @@ fn generic_decl_list_text(list: &LuaDocGenericDeclList) -> Option { fn raw_doc_tag_body_text(tag_name: &str, node: &T) -> Option { let text = single_line_node_text(node)?; - let body = text.trim().strip_prefix(tag_name)?.trim_start(); - Some(body.trim_end().to_string()) + Some(trim_end_owned(body)) } fn single_line_node_text(node: &impl LuaAstNode) -> Option { @@ -702,7 +816,7 @@ fn should_keep_doc_line_inside_aligned_group( fn is_raw_doc_description_line(text: &str) -> bool { let trimmed = text.trim(); - trimmed == "---" || (trimmed.starts_with("---") && !trimmed.starts_with("---@")) + trimmed == "---" || (dash_prefix_len(trimmed) == 3 && !trimmed.starts_with("---@")) } fn render_interleaved_aligned_doc_tag_group( @@ -805,7 +919,7 @@ fn render_aligned_doc_tag_group( rendered.push_str(&gap); rendered.push_str(desc); } - rendered.trim_end().to_string() + trim_end_owned(rendered) } other => render_single_doc_comment_line(config, other), }) @@ -845,7 +959,7 @@ fn render_aligned_doc_tag_group( rendered.push_str(&gap); rendered.push_str(desc); } - rendered.trim_end().to_string() + trim_end_owned(rendered) } other => render_single_doc_comment_line(config, other), }) @@ -875,7 +989,7 @@ fn render_aligned_doc_tag_group( rendered.push_str(&gap); rendered.push_str(desc); } - rendered.trim_end().to_string() + trim_end_owned(rendered) } other => render_single_doc_comment_line(config, other), }) @@ -909,7 +1023,7 @@ fn render_alias_doc_group(config: &LuaFormatConfig, lines: &[DocCommentLine]) -> rendered.push_str(&gap); rendered.push_str(desc); } - rendered.trim_end().to_string() + trim_end_owned(rendered) } other => render_single_doc_comment_line(config, other), }) @@ -943,7 +1057,7 @@ fn render_body_aligned_doc_group( rendered.push_str(&gap); rendered.push_str(desc); } - rendered.trim_end().to_string() + trim_end_owned(rendered) } else { render_single_doc_comment_line(config, line) } @@ -1042,80 +1156,77 @@ fn render_single_doc_comment_line(config: &LuaFormatConfig, line: &DocCommentLin } } -/// 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(config: &LuaFormatConfig, 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(config, &comment)); - } +fn normalized_comment_prefix(config: &LuaFormatConfig, prefix_text: &str) -> Option { + match dash_prefix_len(prefix_text) { + 2 => Some(if config.comments.space_after_comment_dash { + "-- ".to_string() + } else { + "--".to_string() + }), + 3 => Some(if config.emmy_doc.space_after_description_dash { + "--- ".to_string() + } else { + "---".to_string() + }), + _ => None, } - docs } -/// 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( - 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) - || has_non_trivia_after_on_same_line(&child) - { - continue; - } - let comment = LuaComment::cast(child.clone())?; - if child.text().contains_char('\n') { - return None; +fn normalized_doc_continue_prefix(config: &LuaFormatConfig, prefix_text: &str) -> String { + if prefix_text == "---" || prefix_text == "--- " { + if config.emmy_doc.space_after_description_dash { + "--- ".to_string() + } else { + "---".to_string() } - - 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())); + } else { + prefix_text.to_string() } +} - 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 = LuaComment::cast(comment_node.clone())?; +fn trim_end_owned(text: impl ToString) -> String { + let mut text = text.to_string(); + let trimmed_len = text.trim_end().len(); + text.truncate(trimmed_len); + text +} - // Only single-line comments are treated as trailing comments - if comment_node.text().contains_char('\n') { - return None; - } +fn has_nonstandard_dash_prefix(comment: &LuaComment) -> bool { + let Some(first_token) = comment.syntax().first_token() else { + return false; + }; - let comment_text = render_single_line_comment_text(config, &comment) - .unwrap_or_else(|| comment_node.text().to_string().trim_end().to_string()); + if !matches!(first_token.kind().into(), LuaTokenKind::TkNormalStart) { + return false; + } - let range = comment_node.text_range(); - return Some((vec![ir::text(comment_text)], range)); - } - _ => return None, - } - next = sibling.next_sibling_or_token(); + let dash_len = dash_prefix_len(first_token.text()); + if dash_len > 3 { + return true; } - None + dash_len == 3 + && !first_token + .text() + .chars() + .last() + .is_some_and(char::is_whitespace) + && comment + .syntax() + .descendants_with_tokens() + .filter_map(|element| element.into_token()) + .skip(1) + .take_while(|token| token.kind().to_token() != LuaTokenKind::TkEndOfLine) + .find(|token| token.kind().to_token() != LuaTokenKind::TkWhitespace) + .is_some_and(|token| token.text().starts_with('-')) +} + +fn is_doc_normal_start(prefix_text: &str) -> bool { + dash_prefix_len(prefix_text) == 3 +} + +fn dash_prefix_len(prefix_text: &str) -> usize { + prefix_text.bytes().take_while(|byte| *byte == b'-').count() } fn has_non_trivia_after_on_same_line(node: &LuaSyntaxNode) -> bool { @@ -1139,52 +1250,23 @@ fn has_non_trivia_after_on_same_line(node: &LuaSyntaxNode) -> bool { false } -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 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 { - None - } - } - CommentKind::Doc => None, +fn is_parent_syntax(token: &emmylua_parser::LuaSyntaxToken, kind: LuaSyntaxKind) -> bool { + if let Some(parent) = token.parent() { + return parent.kind().to_syntax() == kind; } + false } -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( - config: &LuaFormatConfig, - node: &LuaSyntaxNode, -) -> Option<(DocIR, TextRange)> { - 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)) -} +fn get_prev_sibling_token_without_space( + token: &emmylua_parser::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; + } -pub fn should_keep_comment_inline_in_expression(comment: &LuaComment) -> bool { - matches!(classify_comment(comment), CommentKind::Long) - && !comment.syntax().text().contains_char('\n') + None } diff --git a/crates/emmylua_formatter/src/formatter/expression.rs b/crates/emmylua_formatter/src/formatter/expression.rs index 4cc84d99a..35a33e690 100644 --- a/crates/emmylua_formatter/src/formatter/expression.rs +++ b/crates/emmylua_formatter/src/formatter/expression.rs @@ -10,7 +10,7 @@ use crate::config::{ExpandStrategy, QuoteStyle, SingleArgCallParens}; use crate::ir::{self, AlignEntry, DocIR, EqSplit, ir_flat_width, ir_has_forced_line_break}; use super::FormatContext; -use super::comment::{ +use super::comments::{ extract_trailing_comment, format_comment, should_keep_comment_inline_in_expression, trailing_comment_prefix, }; diff --git a/crates/emmylua_formatter/src/formatter/mod.rs b/crates/emmylua_formatter/src/formatter/mod.rs index 516f84adf..fadb8a5cd 100644 --- a/crates/emmylua_formatter/src/formatter/mod.rs +++ b/crates/emmylua_formatter/src/formatter/mod.rs @@ -1,5 +1,5 @@ mod block; -mod comment; +mod comments; mod expression; mod sequence; pub mod spacing; diff --git a/crates/emmylua_formatter/src/formatter/sequence.rs b/crates/emmylua_formatter/src/formatter/sequence.rs index 09889a8f2..1bbd7710a 100644 --- a/crates/emmylua_formatter/src/formatter/sequence.rs +++ b/crates/emmylua_formatter/src/formatter/sequence.rs @@ -6,7 +6,7 @@ use crate::ir::{self, DocIR, ir_flat_width, ir_has_forced_line_break}; use crate::printer::Printer; use super::FormatContext; -use super::comment::{format_comment, trailing_comment_prefix}; +use super::comments::{format_comment, trailing_comment_prefix}; use super::trivia::has_non_trivia_before_on_same_line_tokenwise; #[derive(Clone)] diff --git a/crates/emmylua_formatter/src/formatter/statement.rs b/crates/emmylua_formatter/src/formatter/statement.rs index 99874a23f..22e65f9e4 100644 --- a/crates/emmylua_formatter/src/formatter/statement.rs +++ b/crates/emmylua_formatter/src/formatter/statement.rs @@ -11,7 +11,7 @@ use crate::ir::{self, DocIR, EqSplit, ir_has_forced_line_break}; use super::FormatContext; use super::block::format_block; -use super::comment::{collect_orphan_comments, extract_trailing_comment, format_comment}; +use super::comments::{collect_orphan_comments, extract_trailing_comment, format_comment}; use super::expression::format_expr; use super::sequence::{ SequenceEntry, SequenceLayoutCandidates, SequenceLayoutPolicy, choose_sequence_layout, diff --git a/crates/emmylua_formatter/src/test/comment_tests.rs b/crates/emmylua_formatter/src/test/comment_tests.rs index 9f11d6cdd..302fe58ed 100644 --- a/crates/emmylua_formatter/src/test/comment_tests.rs +++ b/crates/emmylua_formatter/src/test/comment_tests.rs @@ -1142,6 +1142,22 @@ local t = { ); } + #[test] + fn test_doc_type_with_inline_comment_marker_is_preserved_raw() { + assert_format!( + "---@type string --1\nlocal s\n", + "---@type string --1\nlocal s\n" + ); + } + + #[test] + fn test_nonstandard_dash_comment_is_preserved_raw() { + assert_format!( + "---- keep odd prefix\nlocal value = nil\n", + "---- keep odd prefix\nlocal value = nil\n" + ); + } + #[test] fn test_doc_comment_multiline_alias_falls_back() { assert_format!( diff --git a/crates/emmylua_parser/src/syntax/node/doc/test.rs b/crates/emmylua_parser/src/syntax/node/doc/test.rs index b2de6fecf..27e3ce550 100644 --- a/crates/emmylua_parser/src/syntax/node/doc/test.rs +++ b/crates/emmylua_parser/src/syntax/node/doc/test.rs @@ -1,6 +1,6 @@ #[cfg(test)] mod test { - use crate::{LuaAstNode, LuaComment, LuaParser, ParserConfig}; + use crate::{LuaAstNode, LuaComment, LuaKind, LuaParser, LuaTokenKind, ParserConfig}; #[allow(unused)] fn print_ast(lua_code: &str) { @@ -82,4 +82,40 @@ mod test { print_ast(code); } + + #[test] + fn test_doc_type_with_inline_comment_marker_has_second_prefix_on_same_line() { + let code = "---@type string --1\nlocal s\n"; + + let tree = LuaParser::parse(code, ParserConfig::default()); + let root = tree.get_chunk_node(); + let comment = root.descendants::().next().unwrap(); + + let prefix_tokens: Vec<_> = comment + .syntax() + .descendants_with_tokens() + .filter_map(|element| { + let token = element.into_token()?; + matches!( + token.kind(), + LuaKind::Token( + LuaTokenKind::TkDocStart + | LuaTokenKind::TkDocLongStart + | LuaTokenKind::TkDocContinue + | LuaTokenKind::TkDocContinueOr + | LuaTokenKind::TkNormalStart + ) + ) + .then_some((token.kind(), token.text().to_string())) + }) + .collect(); + + assert_eq!( + prefix_tokens, + vec![ + (LuaKind::Token(LuaTokenKind::TkDocStart), "---@".to_string()), + (LuaKind::Token(LuaTokenKind::TkNormalStart), "--".to_string()), + ] + ); + } } From bf664f9edde27622dac042ad401b898cf35a6a18 Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Thu, 26 Mar 2026 19:14:51 +0800 Subject: [PATCH 18/23] update formatter --- .../formatter/comments/comment_formatter.rs | 7 +- .../src/formatter/comments/mod.rs | 401 +++++++++++++++--- .../src/test/comment_tests.rs | 94 +++- .../src/syntax/node/doc/test.rs | 5 +- 4 files changed, 432 insertions(+), 75 deletions(-) diff --git a/crates/emmylua_formatter/src/formatter/comments/comment_formatter.rs b/crates/emmylua_formatter/src/formatter/comments/comment_formatter.rs index 7b9be2dab..69bc551be 100644 --- a/crates/emmylua_formatter/src/formatter/comments/comment_formatter.rs +++ b/crates/emmylua_formatter/src/formatter/comments/comment_formatter.rs @@ -107,8 +107,11 @@ impl CommentFormatter { _ => { let syntax_id = LuaSyntaxId::from_token(&token); if line_has_content { - current_line - .push_str(&self.resolve_gap(prev_token_id, syntax_id, &pending_gap)); + current_line.push_str(&self.resolve_gap( + prev_token_id, + syntax_id, + &pending_gap, + )); } current_line.push_str( diff --git a/crates/emmylua_formatter/src/formatter/comments/mod.rs b/crates/emmylua_formatter/src/formatter/comments/mod.rs index f36570d3a..1dd7051b9 100644 --- a/crates/emmylua_formatter/src/formatter/comments/mod.rs +++ b/crates/emmylua_formatter/src/formatter/comments/mod.rs @@ -23,8 +23,7 @@ enum TokenExpected { pub fn format_comment(config: &LuaFormatConfig, comment: &LuaComment) -> Vec { let is_doc = is_doc_comment(comment); - if has_nonstandard_dash_prefix(comment) - || (is_doc && should_preserve_doc_comment_raw(comment)) + if has_nonstandard_dash_prefix(comment) || (is_doc && should_preserve_doc_comment_raw(comment)) { return vec![ir::source_node_trimmed(comment.syntax().clone())]; } @@ -72,8 +71,8 @@ pub fn extract_trailing_comment( return None; } - let comment_text = - render_single_line_comment_text(config, &comment).unwrap_or_else(|| trim_end_owned(child.text())); + let comment_text = render_single_line_comment_text(config, &comment) + .unwrap_or_else(|| trim_end_owned(child.text())); return Some((vec![ir::text(comment_text)], child.text_range())); } @@ -179,7 +178,11 @@ fn is_long_comment(comment: &LuaComment) -> bool { } fn format_normal_comment(config: &LuaFormatConfig, comment: &LuaComment) -> Vec { - let formatter = build_comment_formatter(config, comment, !comment.syntax().text().contains_char('\n')); + let formatter = build_comment_formatter( + config, + comment, + !comment.syntax().text().contains_char('\n'), + ); formatter.render_comment(comment) } @@ -204,7 +207,7 @@ fn build_comment_formatter( } } LuaTokenKind::TkDocStart if normalize_start_tokens => { - formatter.add_token_replace(syntax_id, "---@".to_string()); + formatter.add_token_replace(syntax_id, normalized_doc_tag_prefix(config)); formatter.add_token_right_expected(syntax_id, TokenExpected::Space(0)); } LuaTokenKind::TkDocContinue if normalize_start_tokens => { @@ -214,6 +217,13 @@ fn build_comment_formatter( ); formatter.add_token_right_expected(syntax_id, TokenExpected::Space(0)); } + LuaTokenKind::TkDocContinueOr if normalize_start_tokens => { + formatter.add_token_replace( + syntax_id, + normalized_doc_continue_or_prefix(config, token.text()), + ); + formatter.add_token_right_expected(syntax_id, TokenExpected::Space(0)); + } LuaTokenKind::TkLeftParen | LuaTokenKind::TkLeftBracket => { if let Some(prev_token) = get_prev_sibling_token_without_space(&token) { match prev_token.kind().to_token() { @@ -329,7 +339,10 @@ fn build_comment_formatter( formatter } -fn render_single_line_comment_text(config: &LuaFormatConfig, comment: &LuaComment) -> Option { +fn render_single_line_comment_text( + config: &LuaFormatConfig, + comment: &LuaComment, +) -> Option { if is_long_comment(comment) { return Some(trim_end_owned(comment.syntax().text())); } @@ -351,6 +364,10 @@ fn render_single_line_comment_text(config: &LuaFormatConfig, comment: &LuaCommen } fn format_doc_comment(config: &LuaFormatConfig, comment: &LuaComment) -> Vec { + if let Some(docs) = try_format_doc_comment_with_tokens(config, comment) { + return docs; + } + let lines = parse_doc_comment_lines(comment); let rendered = render_doc_comment_lines(config, &lines); let mut docs = Vec::new(); @@ -365,15 +382,174 @@ fn format_doc_comment(config: &LuaFormatConfig, comment: &LuaComment) -> Vec Option> { + let is_single_line = !comment.syntax().text().contains_char('\n'); + let mut doc_tags = comment.get_doc_tags(); + let first_tag = doc_tags.next(); + if doc_tags.next().is_some() { + return None; + } + + let normalize_start_tokens = is_single_line || first_tag.is_some(); + let mut formatter = build_comment_formatter(config, comment, normalize_start_tokens); + + match first_tag { + None => { + let description = comment.get_description()?; + if is_single_line { + normalize_doc_description_tokens(&mut formatter, description.syntax()); + } + return Some(formatter.render_comment(comment)); + } + Some(LuaDocTag::Param(tag)) if is_single_line => { + configure_doc_tag_token_spacing( + &mut formatter, + config, + &tag.syntax().clone(), + tag.get_name_token().map(|token| token.syntax().clone()), + tag.get_type().and_then(|ty| ty.syntax().first_token()), + find_inline_doc_description_after(tag.syntax()), + )?; + } + Some(LuaDocTag::Type(tag)) if is_single_line => { + let first_type_token = tag + .get_type_list() + .next() + .and_then(|ty| ty.syntax().first_token()); + configure_doc_tag_token_spacing( + &mut formatter, + config, + &tag.syntax().clone(), + None, + first_type_token, + find_inline_doc_description_after(tag.syntax()), + )?; + } + Some(LuaDocTag::Overload(tag)) if is_single_line => { + configure_doc_tag_token_spacing( + &mut formatter, + config, + &tag.syntax().clone(), + None, + tag.get_type().and_then(|ty| ty.syntax().first_token()), + find_inline_doc_description_after(tag.syntax()), + )?; + } + _ => return None, + } + + Some(formatter.render_comment(comment)) +} + +fn configure_doc_tag_token_spacing( + formatter: &mut CommentFormatter, + config: &LuaFormatConfig, + tag_syntax: &LuaSyntaxNode, + middle_token: Option, + body_first_token: Option, + inline_description: Option, +) -> Option<()> { + let tag_token = tag_syntax.first_token()?; + formatter.add_token_right_expected( + emmylua_parser::LuaSyntaxId::from_token(&tag_token), + TokenExpected::Space(config.emmy_doc.tag_spacing.max(1)), + ); + + if let Some(middle_token) = middle_token { + formatter.add_token_right_expected( + emmylua_parser::LuaSyntaxId::from_token(&middle_token), + TokenExpected::Space(1), + ); + } + + if let Some(body_first_token) = body_first_token { + formatter.add_token_left_expected( + emmylua_parser::LuaSyntaxId::from_token(&body_first_token), + TokenExpected::Space(1), + ); + } + + if let Some(description) = inline_description { + normalize_doc_description_tokens(formatter, &description); + if let Some(first_description_token) = first_non_whitespace_token(&description) { + formatter.add_token_left_expected( + emmylua_parser::LuaSyntaxId::from_token(&first_description_token), + TokenExpected::Space(1), + ); + } + } + + Some(()) +} + +fn normalize_doc_description_tokens(formatter: &mut CommentFormatter, description: &LuaSyntaxNode) { + for element in description.descendants_with_tokens() { + let Some(token) = element.into_token() else { + continue; + }; + + if token.kind().to_token() == LuaTokenKind::TkDocDetail { + formatter.add_token_replace( + emmylua_parser::LuaSyntaxId::from_token(&token), + normalize_single_line_spaces(token.text()), + ); + } + } +} + +fn first_non_whitespace_token(node: &LuaSyntaxNode) -> Option { + node.descendants_with_tokens() + .filter_map(|element| element.into_token()) + .find(|token| { + token.kind().to_token() != LuaTokenKind::TkWhitespace + && token.kind().to_token() != LuaTokenKind::TkEndOfLine + }) +} + +fn find_inline_doc_description_after(node: &LuaSyntaxNode) -> Option { + let mut next_sibling = node.next_sibling_or_token(); + for _ in 0..=3 { + let sibling = next_sibling.as_ref()?; + match sibling.kind() { + LuaKind::Token(LuaTokenKind::TkWhitespace) => {} + LuaKind::Syntax(LuaSyntaxKind::DocDescription) => { + return sibling.clone().into_node(); + } + _ => return None, + } + next_sibling = sibling.next_sibling_or_token(); + } + + None +} + #[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 }, + 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, @@ -384,7 +560,10 @@ enum DocCommentLine { ty: String, desc: Option, }, - Return { body: String, desc: Option }, + Return { + body: String, + desc: Option, + }, Raw(String), } @@ -518,30 +697,44 @@ fn finalize_doc_comment_line(pending: &mut PendingDocLine) -> DocCommentLine { } fn build_doc_tag_line(prefix: &str, tag: LuaDocTag, description: Option) -> DocCommentLine { - if prefix != "---@" { + if !is_structured_doc_tag_prefix(prefix) { return raw_doc_tag_line(prefix, tag.syntax().text().to_string(), description); } match tag { - LuaDocTag::Class(class_tag) => { - build_class_doc_line(&class_tag, description.clone()).unwrap_or_else(|| { + LuaDocTag::Class(class_tag) => build_class_doc_line(&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(&alias, description.clone()) - .unwrap_or_else(|| raw_doc_tag_line(prefix, alias.syntax().text().to_string(), description)), + .unwrap_or_else(|| { + raw_doc_tag_line(prefix, alias.syntax().text().to_string(), description) + }), LuaDocTag::Type(type_tag) => build_type_doc_line(&type_tag, description.clone()) - .unwrap_or_else(|| raw_doc_tag_line(prefix, type_tag.syntax().text().to_string(), description)), + .unwrap_or_else(|| { + raw_doc_tag_line(prefix, type_tag.syntax().text().to_string(), description) + }), LuaDocTag::Generic(generic) => build_generic_doc_line(&generic, description.clone()) - .unwrap_or_else(|| raw_doc_tag_line(prefix, generic.syntax().text().to_string(), description)), + .unwrap_or_else(|| { + raw_doc_tag_line(prefix, generic.syntax().text().to_string(), description) + }), LuaDocTag::Overload(overload) => build_overload_doc_line(&overload, description.clone()) - .unwrap_or_else(|| raw_doc_tag_line(prefix, overload.syntax().text().to_string(), description)), + .unwrap_or_else(|| { + raw_doc_tag_line(prefix, overload.syntax().text().to_string(), description) + }), LuaDocTag::Param(param) => build_param_doc_line(¶m, description.clone()) - .unwrap_or_else(|| raw_doc_tag_line(prefix, param.syntax().text().to_string(), description)), + .unwrap_or_else(|| { + raw_doc_tag_line(prefix, param.syntax().text().to_string(), description) + }), LuaDocTag::Field(field) => build_field_doc_line(&field, description.clone()) - .unwrap_or_else(|| raw_doc_tag_line(prefix, field.syntax().text().to_string(), description)), - LuaDocTag::Return(ret) => build_return_doc_line(&ret, description.clone()) - .unwrap_or_else(|| raw_doc_tag_line(prefix, ret.syntax().text().to_string(), description)), + .unwrap_or_else(|| { + raw_doc_tag_line(prefix, field.syntax().text().to_string(), description) + }), + LuaDocTag::Return(ret) => { + build_return_doc_line(&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), } } @@ -571,10 +764,7 @@ fn build_alias_doc_line( Some(DocCommentLine::Alias { body, desc }) } -fn build_type_doc_line( - tag: &LuaDocTagType, - description: Option, -) -> Option { +fn build_type_doc_line(tag: &LuaDocTagType, description: Option) -> Option { let mut parts = Vec::new(); for ty in tag.get_type_list() { parts.push(single_line_syntax_text(&ty)?); @@ -699,11 +889,7 @@ fn single_line_syntax_text(node: &impl LuaAstNode) -> Option { fn non_empty_description_text(description: Option) -> Option { let text = description?; - if text.is_empty() { - None - } else { - Some(text) - } + if text.is_empty() { None } else { Some(text) } } fn normalize_single_line_spaces(text: &str) -> String { @@ -816,7 +1002,10 @@ fn should_keep_doc_line_inside_aligned_group( fn is_raw_doc_description_line(text: &str) -> bool { let trimmed = text.trim(); - trimmed == "---" || (dash_prefix_len(trimmed) == 3 && !trimmed.starts_with("---@")) + trimmed == "---" + || (dash_prefix_len(trimmed) == 3 + && !trimmed.starts_with("---@") + && !trimmed.starts_with("--- @")) } fn render_interleaved_aligned_doc_tag_group( @@ -907,8 +1096,10 @@ fn render_aligned_doc_tag_group( .iter() .map(|line| match line { DocCommentLine::Param { name, ty, desc } => { + let tag_prefix = normalized_doc_tag_with_name_prefix(config, "param"); let mut rendered = format!( - "---@param{gap}{name: { + let tag_prefix = normalized_doc_tag_with_name_prefix(config, "field"); let mut rendered = format!( - "---@field{gap}{key: { + let tag_prefix = normalized_doc_tag_with_name_prefix(config, "return"); let mut rendered = format!( - "---@return{gap}{body: .iter() .map(|line| match line { DocCommentLine::Alias { body, desc } => { + let tag_prefix = normalized_doc_tag_with_name_prefix(config, "alias"); + let normalized_body = normalize_embedded_doc_prefixes(config, body); let mut rendered = format!( - "---@alias{gap}{body: text.clone(), + DocCommentLine::Raw(text) => normalize_embedded_doc_prefixes(config, text), DocCommentLine::Class { body, desc } => { - let mut rendered = format!("---@class{gap}{body}"); + let mut rendered = format!( + "{}{gap}{body}", + normalized_doc_tag_with_name_prefix(config, "class") + ); if let Some(desc) = desc { rendered.push_str(&gap); rendered.push_str(desc); @@ -1098,7 +1300,11 @@ fn render_single_doc_comment_line(config: &LuaFormatConfig, line: &DocCommentLin rendered } DocCommentLine::Alias { body, desc } => { - let mut rendered = format!("---@alias{gap}{body}"); + let mut rendered = format!( + "{}{gap}{}", + normalized_doc_tag_with_name_prefix(config, "alias"), + normalize_embedded_doc_prefixes(config, body) + ); if let Some(desc) = desc { rendered.push_str(&gap); rendered.push_str(desc); @@ -1106,7 +1312,10 @@ fn render_single_doc_comment_line(config: &LuaFormatConfig, line: &DocCommentLin rendered } DocCommentLine::Type { body, desc } => { - let mut rendered = format!("---@type{gap}{body}"); + let mut rendered = format!( + "{}{gap}{body}", + normalized_doc_tag_with_name_prefix(config, "type") + ); if let Some(desc) = desc { rendered.push_str(&gap); rendered.push_str(desc); @@ -1114,7 +1323,10 @@ fn render_single_doc_comment_line(config: &LuaFormatConfig, line: &DocCommentLin rendered } DocCommentLine::Generic { body, desc } => { - let mut rendered = format!("---@generic{gap}{body}"); + let mut rendered = format!( + "{}{gap}{body}", + normalized_doc_tag_with_name_prefix(config, "generic") + ); if let Some(desc) = desc { rendered.push_str(&gap); rendered.push_str(desc); @@ -1122,7 +1334,10 @@ fn render_single_doc_comment_line(config: &LuaFormatConfig, line: &DocCommentLin rendered } DocCommentLine::Overload { body, desc } => { - let mut rendered = format!("---@overload{gap}{body}"); + let mut rendered = format!( + "{}{gap}{body}", + normalized_doc_tag_with_name_prefix(config, "overload") + ); if let Some(desc) = desc { rendered.push_str(&gap); rendered.push_str(desc); @@ -1130,7 +1345,10 @@ fn render_single_doc_comment_line(config: &LuaFormatConfig, line: &DocCommentLin rendered } DocCommentLine::Param { name, ty, desc } => { - let mut rendered = format!("---@param{gap}{name}{gap}{ty}"); + let mut rendered = format!( + "{}{gap}{name}{gap}{ty}", + normalized_doc_tag_with_name_prefix(config, "param") + ); if let Some(desc) = desc { rendered.push_str(&gap); rendered.push_str(desc); @@ -1138,7 +1356,10 @@ fn render_single_doc_comment_line(config: &LuaFormatConfig, line: &DocCommentLin rendered } DocCommentLine::Field { key, ty, desc } => { - let mut rendered = format!("---@field{gap}{key}{gap}{ty}"); + let mut rendered = format!( + "{}{gap}{key}{gap}{ty}", + normalized_doc_tag_with_name_prefix(config, "field") + ); if let Some(desc) = desc { rendered.push_str(&gap); rendered.push_str(desc); @@ -1146,7 +1367,10 @@ fn render_single_doc_comment_line(config: &LuaFormatConfig, line: &DocCommentLin rendered } DocCommentLine::Return { body, desc } => { - let mut rendered = format!("---@return{gap}{body}"); + let mut rendered = format!( + "{}{gap}{body}", + normalized_doc_tag_with_name_prefix(config, "return") + ); if let Some(desc) = desc { rendered.push_str(&gap); rendered.push_str(desc); @@ -1172,6 +1396,18 @@ fn normalized_comment_prefix(config: &LuaFormatConfig, prefix_text: &str) -> Opt } } +fn normalized_doc_tag_prefix(config: &LuaFormatConfig) -> String { + if config.emmy_doc.space_after_description_dash { + "--- @".to_string() + } else { + "---@".to_string() + } +} + +fn normalized_doc_tag_with_name_prefix(config: &LuaFormatConfig, tag_name: &str) -> String { + format!("{}{tag_name}", normalized_doc_tag_prefix(config)) +} + fn normalized_doc_continue_prefix(config: &LuaFormatConfig, prefix_text: &str) -> String { if prefix_text == "---" || prefix_text == "--- " { if config.emmy_doc.space_after_description_dash { @@ -1184,6 +1420,67 @@ fn normalized_doc_continue_prefix(config: &LuaFormatConfig, prefix_text: &str) - } } +fn normalized_doc_continue_or_prefix(config: &LuaFormatConfig, prefix_text: &str) -> String { + if !prefix_text.starts_with("---") { + return prefix_text.to_string(); + } + + let suffix = prefix_text[3..].trim_start(); + if config.emmy_doc.space_after_description_dash { + format!("--- {suffix}") + } else { + format!("---{suffix}") + } +} + +fn is_structured_doc_tag_prefix(prefix: &str) -> bool { + let trimmed = prefix.trim_end(); + trimmed == "---@" || trimmed == "---" +} + +fn normalize_raw_doc_line(config: &LuaFormatConfig, text: &str) -> String { + let Some(rest) = text.strip_prefix("---@") else { + if let Some(rest) = text.strip_prefix("--- @") { + return if config.emmy_doc.space_after_description_dash { + format!("--- @{rest}") + } else { + format!("---@{rest}") + }; + } + + if let Some(rest) = text.strip_prefix("---|") { + return if config.emmy_doc.space_after_description_dash { + format!("--- |{rest}") + } else { + format!("---|{rest}") + }; + } + + if let Some(rest) = text.strip_prefix("--- |") { + return if config.emmy_doc.space_after_description_dash { + format!("--- |{rest}") + } else { + format!("---|{rest}") + }; + } + + return text.to_string(); + }; + + if config.emmy_doc.space_after_description_dash { + format!("--- @{rest}") + } else { + format!("---@{rest}") + } +} + +fn normalize_embedded_doc_prefixes(config: &LuaFormatConfig, text: &str) -> String { + text.lines() + .map(|line| normalize_raw_doc_line(config, line)) + .collect::>() + .join("\n") +} + fn trim_end_owned(text: impl ToString) -> String { let mut text = text.to_string(); let trimmed_len = text.trim_end().len(); diff --git a/crates/emmylua_formatter/src/test/comment_tests.rs b/crates/emmylua_formatter/src/test/comment_tests.rs index 302fe58ed..adf33d942 100644 --- a/crates/emmylua_formatter/src/test/comment_tests.rs +++ b/crates/emmylua_formatter/src/test/comment_tests.rs @@ -914,7 +914,7 @@ local t = { // 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" + "--- @param name string\nlocal function f(name) end\n" ); } @@ -923,7 +923,7 @@ local t = { // 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" + "--- @param name string\nlocal function f(name) end\n" ); } @@ -944,7 +944,7 @@ local t = { 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" + "--- @param a number\n--- @param b string\n--- @return boolean\nlocal function f(a, b) end\n" ); } @@ -952,7 +952,7 @@ local t = { 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" + "--- @param short string desc\n--- @param much_longer integer longer desc\nlocal function f(short, much_longer) end\n" ); } @@ -960,7 +960,7 @@ local t = { fn test_doc_comment_align_param_columns_with_interleaved_descriptions() { assert_format!( "--- first parameter docs\n---@param short string desc\n--- second parameter docs\n---@param much_longer integer longer desc\nlocal function f(short, much_longer) end\n", - "--- first parameter docs\n---@param short string desc\n--- second parameter docs\n---@param much_longer integer longer desc\nlocal function f(short, much_longer) end\n" + "--- first parameter docs\n--- @param short string desc\n--- second parameter docs\n--- @param much_longer integer longer desc\nlocal function f(short, much_longer) end\n" ); } @@ -968,7 +968,7 @@ local t = { 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" + "--- @field x string desc\n--- @field longer_name integer another desc\nlocal t = {}\n" ); } @@ -976,7 +976,7 @@ local t = { fn test_doc_comment_align_field_columns_with_interleaved_descriptions() { assert_format!( "---@class schema.EmmyrcStrict\n--- Whether to enable strict mode array indexing.\n---@field arrayIndex boolean?\n--- Base constant types defined in doc can match base types, allowing int to match `---@alias id 1|2|3`, same for string.\n---@field docBaseConstMatchBaseType boolean?\n--- meta define overrides file define\n---@field metaOverrideFileDefine boolean?\n", - "---@class schema.EmmyrcStrict\n--- Whether to enable strict mode array indexing.\n---@field arrayIndex boolean?\n--- Base constant types defined in doc can match base types, allowing int to match `---@alias id 1|2|3`, same for string.\n---@field docBaseConstMatchBaseType boolean?\n--- meta define overrides file define\n---@field metaOverrideFileDefine boolean?\n" + "--- @class schema.EmmyrcStrict\n--- Whether to enable strict mode array indexing.\n--- @field arrayIndex boolean?\n--- Base constant types defined in doc can match base types, allowing int to match `---@alias id 1|2|3`, same for string.\n--- @field docBaseConstMatchBaseType boolean?\n--- meta define overrides file define\n--- @field metaOverrideFileDefine boolean?\n" ); } @@ -984,7 +984,7 @@ local t = { 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" + "--- @return number ok success\n--- @return string, integer err failure\nfunction f() end\n" ); } @@ -992,7 +992,7 @@ local t = { fn test_doc_comment_align_return_columns_with_interleaved_descriptions() { assert_format!( "--- first return docs\n---@return number ok success\n--- second return docs\n---@return string, integer err failure\nfunction f() end\n", - "--- first return docs\n---@return number ok success\n--- second return docs\n---@return string, integer err failure\nfunction f() end\n" + "--- first return docs\n--- @return number ok success\n--- second return docs\n--- @return string, integer err failure\nfunction f() end\n" ); } @@ -1000,7 +1000,7 @@ local t = { fn test_doc_comment_align_complex_field_columns() { assert_format!( "---@field public [\"foo\"] string?\n---@field private [bar] integer\n---@field protected baz fun(x: string): boolean\nlocal t = {}\n", - "---@field public [\"foo\"] string?\n---@field private [bar] integer\n---@field protected baz fun(x: string): boolean\nlocal t = {}\n" + "--- @field public [\"foo\"] string?\n--- @field private [bar] integer\n--- @field protected baz fun(x: string): boolean\nlocal t = {}\n" ); } @@ -1017,7 +1017,7 @@ local t = { }; 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", + "--- @param short string desc\n--- @param much_longer integer longer desc\nlocal function f(short, much_longer) end\n", config ); } @@ -1035,7 +1035,7 @@ local t = { }; 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", + "--- @class Short short desc\n--- @class LongerName longer desc\nlocal value = {}\n", config ); } @@ -1053,7 +1053,7 @@ local t = { }; 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", + "--- @param short string desc\n--- @param much_longer integer longer desc\nlocal function f(short, much_longer) end\n", config ); } @@ -1062,7 +1062,7 @@ local t = { 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" + "--- @class Short short desc\n--- @class LongerName longer desc\nlocal value = {}\n" ); } @@ -1070,7 +1070,7 @@ local t = { 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" + "--- @alias Id integer identifier\n--- @alias DisplayName string user facing name\nlocal value = nil\n" ); } @@ -1087,7 +1087,7 @@ local t = { }; 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", + "--- @alias Id integer|nil identifier\n--- @alias DisplayName string user facing name\nlocal value = nil\n", config ); } @@ -1110,6 +1110,44 @@ local t = { ); } + #[test] + fn test_doc_tag_prefix_can_omit_space_before_at() { + 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!( + "--- @param name string\nlocal function f(name) end\n", + "---@param name string\nlocal function f(name) end\n", + config + ); + } + + #[test] + fn test_doc_continue_or_prefix_can_omit_space() { + 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!( + "--- @alias Complex\n--- | string\n--- | integer\nlocal value = nil\n", + "---@alias Complex\n---| string\n---| integer\nlocal value = nil\n", + config + ); + } + #[test] fn test_doc_comment_single_line_description_still_normalizes_whitespace() { assert_format!( @@ -1118,11 +1156,27 @@ local t = { ); } + #[test] + fn test_doc_comment_multiline_description_without_tags_uses_token_prefixes() { + assert_format!( + "--- first line\n--- second line\nlocal value = nil\n", + "--- first line\n--- second line\nlocal value = nil\n" + ); + } + + #[test] + fn test_doc_tag_prefix_inserts_space_before_at_by_default() { + 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_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" + "--- @class Test first line\n--- second line\nlocal value = {}\n" ); } @@ -1130,7 +1184,7 @@ local t = { 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" + "--- @generic T value type\n--- @generic Value, Result: number mapped result\nlocal function f() end\n" ); } @@ -1138,7 +1192,7 @@ local t = { 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" + "--- @type string|integer value\n--- @overload fun(x: string): integer callable\nlocal fn = nil\n" ); } @@ -1162,7 +1216,7 @@ local t = { 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" + "--- @alias Complex\n--- | string\n--- | integer\nlocal value = nil\n" ); } diff --git a/crates/emmylua_parser/src/syntax/node/doc/test.rs b/crates/emmylua_parser/src/syntax/node/doc/test.rs index 27e3ce550..64810b118 100644 --- a/crates/emmylua_parser/src/syntax/node/doc/test.rs +++ b/crates/emmylua_parser/src/syntax/node/doc/test.rs @@ -114,7 +114,10 @@ mod test { prefix_tokens, vec![ (LuaKind::Token(LuaTokenKind::TkDocStart), "---@".to_string()), - (LuaKind::Token(LuaTokenKind::TkNormalStart), "--".to_string()), + ( + LuaKind::Token(LuaTokenKind::TkNormalStart), + "--".to_string() + ), ] ); } From 4e457de1d2a316e0ef3c3e0c0979d97692864292 Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Thu, 26 Mar 2026 20:57:01 +0800 Subject: [PATCH 19/23] update --- .../formatter/comments/comment_formatter.rs | 131 ++- .../src/formatter/comments/mod.rs | 777 ++++++++++++------ 2 files changed, 625 insertions(+), 283 deletions(-) diff --git a/crates/emmylua_formatter/src/formatter/comments/comment_formatter.rs b/crates/emmylua_formatter/src/formatter/comments/comment_formatter.rs index 69bc551be..df29723cb 100644 --- a/crates/emmylua_formatter/src/formatter/comments/comment_formatter.rs +++ b/crates/emmylua_formatter/src/formatter/comments/comment_formatter.rs @@ -8,14 +8,29 @@ use crate::ir::{self, DocIR}; pub struct CommentFormatter { left_expected: HashMap, right_expected: HashMap, + align_left_expected: HashMap, + align_right_expected: HashMap, replace_tokens: HashMap, } +#[derive(Default)] +struct CommentLine { + tokens: Vec, + gaps: Vec, +} + +struct CommentToken { + syntax_id: LuaSyntaxId, + text: String, +} + impl CommentFormatter { pub fn new() -> Self { Self { left_expected: HashMap::new(), right_expected: HashMap::new(), + align_left_expected: HashMap::new(), + align_right_expected: HashMap::new(), replace_tokens: HashMap::new(), } } @@ -28,6 +43,22 @@ impl CommentFormatter { self.right_expected.insert(syntax_id, expected); } + pub fn add_token_left_alignment_expected( + &mut self, + syntax_id: LuaSyntaxId, + expected: TokenExpected, + ) { + self.align_left_expected.insert(syntax_id, expected); + } + + pub fn add_token_right_alignment_expected( + &mut self, + syntax_id: LuaSyntaxId, + expected: TokenExpected, + ) { + self.align_right_expected.insert(syntax_id, expected); + } + pub fn get_left_expected(&self, syntax_id: LuaSyntaxId) -> Option<&TokenExpected> { self.left_expected.get(&syntax_id) } @@ -36,6 +67,14 @@ impl CommentFormatter { self.right_expected.get(&syntax_id) } + pub fn get_left_alignment_expected(&self, syntax_id: LuaSyntaxId) -> Option<&TokenExpected> { + self.align_left_expected.get(&syntax_id) + } + + pub fn get_right_alignment_expected(&self, syntax_id: LuaSyntaxId) -> Option<&TokenExpected> { + self.align_right_expected.get(&syntax_id) + } + pub fn add_token_replace(&mut self, syntax_id: LuaSyntaxId, replacement: String) { self.replace_tokens.insert(syntax_id, replacement); } @@ -81,11 +120,23 @@ impl CommentFormatter { } fn render_comment_lines(&self, comment: &LuaComment) -> Vec { + let mut lines = self.collect_comment_lines(comment); + + for line in &mut lines { + self.apply_spacing_pass(line, false); + } + + for line in &mut lines { + self.apply_spacing_pass(line, true); + } + + lines.into_iter().map(|line| line.into_string()).collect() + } + + fn collect_comment_lines(&self, comment: &LuaComment) -> Vec { let mut lines = Vec::new(); - let mut current_line = String::new(); - let mut prev_token_id = None; + let mut current_line = CommentLine::default(); let mut pending_gap = String::new(); - let mut line_has_content = false; let mut ended_with_newline = false; for element in comment.syntax().descendants_with_tokens() { @@ -99,51 +150,68 @@ impl CommentFormatter { } LuaTokenKind::TkEndOfLine => { lines.push(std::mem::take(&mut current_line)); - prev_token_id = None; pending_gap.clear(); - line_has_content = false; ended_with_newline = true; } _ => { let syntax_id = LuaSyntaxId::from_token(&token); - if line_has_content { - current_line.push_str(&self.resolve_gap( - prev_token_id, - syntax_id, - &pending_gap, - )); + if !current_line.tokens.is_empty() { + current_line.gaps.push(std::mem::take(&mut pending_gap)); + } else { + pending_gap.clear(); } - current_line.push_str( - self.get_token_replace(syntax_id) - .unwrap_or_else(|| token.text()), - ); - line_has_content = true; - prev_token_id = Some(syntax_id); - pending_gap.clear(); + current_line.tokens.push(CommentToken { + syntax_id, + text: self + .get_token_replace(syntax_id) + .unwrap_or_else(|| token.text()) + .to_string(), + }); ended_with_newline = false; } } } - if line_has_content || ended_with_newline { - lines.push(std::mem::take(&mut current_line)); + if !current_line.tokens.is_empty() || ended_with_newline { + lines.push(current_line); } lines } + fn apply_spacing_pass(&self, line: &mut CommentLine, use_alignment: bool) { + for gap_index in 0..line.gaps.len() { + let prev_token_id = line.tokens[gap_index].syntax_id; + let token_id = line.tokens[gap_index + 1].syntax_id; + let resolved_gap = self.resolve_gap( + Some(prev_token_id), + token_id, + &line.gaps[gap_index], + use_alignment, + ); + line.gaps[gap_index] = resolved_gap; + } + } + fn resolve_gap( &self, prev_token_id: Option, token_id: LuaSyntaxId, gap: &str, + use_alignment: bool, ) -> String { let mut exact_space = None; let mut max_space = None; + let (left_expected, right_expected) = if use_alignment { + (&self.align_left_expected, &self.align_right_expected) + } else { + (&self.left_expected, &self.right_expected) + }; + if let Some(prev_token_id) = prev_token_id - && let Some(expected) = self.get_right_expected(prev_token_id) + && let Some(expected) = right_expected.get(&prev_token_id) { match expected { TokenExpected::Space(count) => exact_space = Some(*count), @@ -151,7 +219,7 @@ impl CommentFormatter { } } - if let Some(expected) = self.get_left_expected(token_id) { + if let Some(expected) = left_expected.get(&token_id) { match expected { TokenExpected::Space(count) => { exact_space = Some(exact_space.map_or(*count, |current| current.max(*count))); @@ -174,3 +242,22 @@ impl CommentFormatter { gap.to_string() } } + +impl CommentLine { + fn into_string(self) -> String { + let mut rendered = String::new(); + let mut tokens = self.tokens.into_iter(); + let Some(first_token) = tokens.next() else { + return rendered; + }; + + rendered.push_str(&first_token.text); + + for (gap, token) in self.gaps.into_iter().zip(tokens) { + rendered.push_str(&gap); + rendered.push_str(&token.text); + } + + rendered + } +} diff --git a/crates/emmylua_formatter/src/formatter/comments/mod.rs b/crates/emmylua_formatter/src/formatter/comments/mod.rs index 1dd7051b9..9119e5478 100644 --- a/crates/emmylua_formatter/src/formatter/comments/mod.rs +++ b/crates/emmylua_formatter/src/formatter/comments/mod.rs @@ -4,8 +4,8 @@ mod comment_formatter; use emmylua_parser::{ LuaAstNode, LuaAstToken, LuaComment, LuaDocFieldKey, LuaDocGenericDeclList, LuaDocTag, LuaDocTagAlias, LuaDocTagClass, LuaDocTagField, LuaDocTagGeneric, LuaDocTagOverload, - LuaDocTagParam, LuaDocTagReturn, LuaDocTagType, LuaKind, LuaSyntaxElement, LuaSyntaxKind, - LuaSyntaxNode, LuaTokenKind, + LuaDocTagParam, LuaDocTagReturn, LuaDocTagType, LuaKind, LuaSyntaxElement, LuaSyntaxId, + LuaSyntaxKind, LuaSyntaxNode, LuaTokenKind, }; use rowan::TextRange; @@ -198,7 +198,7 @@ fn build_comment_formatter( continue; }; - let syntax_id = emmylua_parser::LuaSyntaxId::from_token(&token); + let syntax_id = LuaSyntaxId::from_token(&token); match token.kind().to_token() { LuaTokenKind::TkNormalStart if normalize_start_tokens => { if let Some(replacement) = normalized_comment_prefix(config, token.text()) { @@ -368,6 +368,10 @@ fn format_doc_comment(config: &LuaFormatConfig, comment: &LuaComment) -> Vec Option> { + if !comment.syntax().text().contains_char('\n') { + return None; + } + + let lines = parse_doc_comment_lines(comment); + let tag_count = comment.get_doc_tags().count(); + let supported_tag_count = lines + .iter() + .filter(|line| { + matches!( + line, + DocCommentLine::Class { .. } + | DocCommentLine::Alias { .. } + | DocCommentLine::Type { .. } + | DocCommentLine::Generic { .. } + | DocCommentLine::Overload { .. } + | DocCommentLine::Param { .. } + | DocCommentLine::Field { .. } + | DocCommentLine::Return { .. } + ) + }) + .count(); + + if tag_count == 0 || tag_count != supported_tag_count { + return None; + } + + let mut formatter = build_comment_formatter(config, comment, true); + let gap = config.emmy_doc.tag_spacing.max(1); + let mut tags = comment.get_doc_tags(); + let mut line_tags: Vec> = Vec::with_capacity(lines.len()); + + for line in &lines { + match line { + DocCommentLine::Class { .. } => { + let LuaDocTag::Class(tag) = tags.next()? else { + return None; + }; + configure_declaration_doc_tag_token_spacing( + &mut formatter, + config, + &tag.syntax().clone(), + tag.get_name_token().map(|token| token.syntax().clone()), + find_inline_doc_description_after(tag.syntax()), + )?; + if let Some(generic_decl) = tag.get_generic_decl() { + configure_generic_decl_token_spacing(&mut formatter, generic_decl.syntax()); + } + line_tags.push(Some(LuaDocTag::Class(tag))); + } + DocCommentLine::Alias { .. } => { + let LuaDocTag::Alias(tag) = tags.next()? else { + return None; + }; + configure_declaration_doc_tag_token_spacing( + &mut formatter, + config, + &tag.syntax().clone(), + tag.get_name_token().map(|token| token.syntax().clone()), + find_inline_doc_description_after(tag.syntax()), + )?; + if let Some(generic_decl_list) = tag.get_generic_decl_list() { + configure_generic_decl_token_spacing( + &mut formatter, + generic_decl_list.syntax(), + ); + } + line_tags.push(Some(LuaDocTag::Alias(tag))); + } + DocCommentLine::Type { .. } => { + let LuaDocTag::Type(tag) = tags.next()? else { + return None; + }; + configure_declaration_doc_tag_token_spacing( + &mut formatter, + config, + &tag.syntax().clone(), + tag.get_type_list() + .next() + .and_then(|ty| ty.syntax().first_token()), + find_inline_doc_description_after(tag.syntax()), + )?; + line_tags.push(Some(LuaDocTag::Type(tag))); + } + DocCommentLine::Generic { .. } => { + let LuaDocTag::Generic(tag) = tags.next()? else { + return None; + }; + let generic_decl_list = tag.get_generic_decl_list(); + configure_declaration_doc_tag_token_spacing( + &mut formatter, + config, + &tag.syntax().clone(), + generic_decl_list + .as_ref() + .and_then(|decls| decls.syntax().first_token()), + find_inline_doc_description_after(tag.syntax()), + )?; + if let Some(generic_decl_list) = generic_decl_list { + configure_generic_decl_token_spacing( + &mut formatter, + generic_decl_list.syntax(), + ); + } + line_tags.push(Some(LuaDocTag::Generic(tag))); + } + DocCommentLine::Overload { .. } => { + let LuaDocTag::Overload(tag) = tags.next()? else { + return None; + }; + configure_declaration_doc_tag_token_spacing( + &mut formatter, + config, + &tag.syntax().clone(), + tag.get_type().and_then(|ty| ty.syntax().first_token()), + find_inline_doc_description_after(tag.syntax()), + )?; + line_tags.push(Some(LuaDocTag::Overload(tag))); + } + DocCommentLine::Param { .. } => { + let LuaDocTag::Param(tag) = tags.next()? else { + return None; + }; + configure_param_doc_tag_token_spacing(&mut formatter, config, &tag)?; + line_tags.push(Some(LuaDocTag::Param(tag))); + } + DocCommentLine::Field { .. } => { + let LuaDocTag::Field(tag) = tags.next()? else { + return None; + }; + configure_field_doc_tag_token_spacing(&mut formatter, config, &tag)?; + line_tags.push(Some(LuaDocTag::Field(tag))); + } + DocCommentLine::Return { .. } => { + let LuaDocTag::Return(tag) = tags.next()? else { + return None; + }; + configure_return_doc_tag_token_spacing(&mut formatter, config, &tag)?; + line_tags.push(Some(LuaDocTag::Return(tag))); + } + _ => line_tags.push(None), + } + } + + if tags.next().is_some() { + return None; + } + + let mut applied_group = false; + let mut index = 0; + while index < lines.len() { + let Some((kind, group_end)) = find_interleaved_aligned_group(config, &lines, index) else { + index += 1; + continue; + }; + + applied_group = true; + match kind { + AlignableDocTagKind::Class + | AlignableDocTagKind::Alias + | AlignableDocTagKind::Type + | AlignableDocTagKind::Generic + | AlignableDocTagKind::Overload => apply_declaration_alignment_group( + &mut formatter, + &lines[index..group_end], + &line_tags[index..group_end], + gap, + )?, + AlignableDocTagKind::Param => apply_param_alignment_group( + &mut formatter, + &lines[index..group_end], + &line_tags[index..group_end], + gap, + )?, + AlignableDocTagKind::Field => apply_field_alignment_group( + &mut formatter, + &lines[index..group_end], + &line_tags[index..group_end], + gap, + )?, + AlignableDocTagKind::Return => apply_return_alignment_group( + &mut formatter, + &lines[index..group_end], + &line_tags[index..group_end], + gap, + )?, + } + + index = group_end; + } + + if !applied_group { + return None; + } + + Some(formatter.render_comment(comment)) +} + fn configure_doc_tag_token_spacing( formatter: &mut CommentFormatter, config: &LuaFormatConfig, @@ -485,6 +691,309 @@ fn configure_doc_tag_token_spacing( Some(()) } +fn configure_param_doc_tag_token_spacing( + formatter: &mut CommentFormatter, + config: &LuaFormatConfig, + tag: &LuaDocTagParam, +) -> Option<()> { + configure_doc_tag_token_spacing( + formatter, + config, + &tag.syntax().clone(), + tag.get_name_token().map(|token| token.syntax().clone()), + tag.get_type().and_then(|ty| ty.syntax().first_token()), + find_inline_doc_description_after(tag.syntax()), + ) +} + +fn configure_declaration_doc_tag_token_spacing( + formatter: &mut CommentFormatter, + config: &LuaFormatConfig, + tag_syntax: &LuaSyntaxNode, + body_first_token: Option, + inline_description: Option, +) -> Option<()> { + configure_doc_tag_token_spacing( + formatter, + config, + tag_syntax, + None, + body_first_token, + inline_description, + ) +} + +fn configure_generic_decl_token_spacing( + formatter: &mut CommentFormatter, + generic_decl_syntax: &LuaSyntaxNode, +) { + for element in generic_decl_syntax.descendants_with_tokens() { + let Some(token) = element.into_token() else { + continue; + }; + + let syntax_id = LuaSyntaxId::from_token(&token); + match token.kind().to_token() { + LuaTokenKind::TkLt | LuaTokenKind::TkGt => { + formatter.add_token_left_expected(syntax_id, TokenExpected::Space(0)); + formatter.add_token_right_expected(syntax_id, TokenExpected::Space(0)); + } + LuaTokenKind::TkComma => { + formatter.add_token_left_expected(syntax_id, TokenExpected::Space(0)); + formatter.add_token_right_expected(syntax_id, TokenExpected::Space(1)); + } + _ => {} + } + } +} + +fn configure_field_doc_tag_token_spacing( + formatter: &mut CommentFormatter, + config: &LuaFormatConfig, + tag: &LuaDocTagField, +) -> Option<()> { + let tag_token = tag.syntax().first_token()?; + formatter.add_token_right_expected( + LuaSyntaxId::from_token(&tag_token), + TokenExpected::Space(config.emmy_doc.tag_spacing.max(1)), + ); + + if let Some(body_first_token) = tag.get_type().and_then(|ty| ty.syntax().first_token()) { + formatter.add_token_left_expected( + LuaSyntaxId::from_token(&body_first_token), + TokenExpected::Space(1), + ); + } + + if let Some(description) = find_inline_doc_description_after(tag.syntax()) { + normalize_doc_description_tokens(formatter, &description); + if let Some(first_description_token) = first_non_whitespace_token(&description) { + formatter.add_token_left_expected( + LuaSyntaxId::from_token(&first_description_token), + TokenExpected::Space(1), + ); + } + } + + Some(()) +} + +fn configure_return_doc_tag_token_spacing( + formatter: &mut CommentFormatter, + config: &LuaFormatConfig, + tag: &LuaDocTagReturn, +) -> Option<()> { + let tag_token = tag.syntax().first_token()?; + formatter.add_token_right_expected( + LuaSyntaxId::from_token(&tag_token), + TokenExpected::Space(config.emmy_doc.tag_spacing.max(1)), + ); + + if let Some(body_first_token) = tag + .get_first_type() + .and_then(|ty| ty.syntax().first_token()) + { + formatter.add_token_left_expected( + LuaSyntaxId::from_token(&body_first_token), + TokenExpected::Space(1), + ); + } + + if let Some(description) = find_inline_doc_description_after(tag.syntax()) { + normalize_doc_description_tokens(formatter, &description); + if let Some(first_description_token) = first_non_whitespace_token(&description) { + formatter.add_token_left_expected( + LuaSyntaxId::from_token(&first_description_token), + TokenExpected::Space(1), + ); + } + } + + Some(()) +} + +fn apply_param_alignment_group( + formatter: &mut CommentFormatter, + lines: &[DocCommentLine], + tags: &[Option], + gap: usize, +) -> Option<()> { + 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); + + for (line, tag) in lines.iter().zip(tags.iter()) { + let (name, ty, tag) = match (line, tag) { + (DocCommentLine::Param { name, ty, .. }, Some(LuaDocTag::Param(tag))) => { + (name, ty, tag) + } + _ => continue, + }; + + let body_first_token = tag.get_type().and_then(|ty| ty.syntax().first_token())?; + formatter.add_token_left_alignment_expected( + LuaSyntaxId::from_token(&body_first_token), + TokenExpected::Space(gap + max_name.saturating_sub(name.len())), + ); + + if let Some(description) = find_inline_doc_description_after(tag.syntax()) + && let Some(first_description_token) = first_non_whitespace_token(&description) + { + formatter.add_token_left_alignment_expected( + LuaSyntaxId::from_token(&first_description_token), + TokenExpected::Space(gap + max_type.saturating_sub(ty.len())), + ); + } + } + + Some(()) +} + +fn apply_declaration_alignment_group( + formatter: &mut CommentFormatter, + lines: &[DocCommentLine], + tags: &[Option], + gap: usize, +) -> Option<()> { + let max_body = lines + .iter() + .filter_map(|line| match line { + DocCommentLine::Class { body, .. } + | DocCommentLine::Alias { body, .. } + | DocCommentLine::Type { body, .. } + | DocCommentLine::Generic { body, .. } + | DocCommentLine::Overload { body, .. } => Some(body.len()), + _ => None, + }) + .max() + .unwrap_or(0); + + for (line, tag) in lines.iter().zip(tags.iter()) { + let (body, tag_syntax) = match (line, tag) { + (DocCommentLine::Class { body, .. }, Some(LuaDocTag::Class(tag))) => { + (body, tag.syntax()) + } + (DocCommentLine::Alias { body, .. }, Some(LuaDocTag::Alias(tag))) => { + (body, tag.syntax()) + } + (DocCommentLine::Type { body, .. }, Some(LuaDocTag::Type(tag))) => (body, tag.syntax()), + (DocCommentLine::Generic { body, .. }, Some(LuaDocTag::Generic(tag))) => { + (body, tag.syntax()) + } + (DocCommentLine::Overload { body, .. }, Some(LuaDocTag::Overload(tag))) => { + (body, tag.syntax()) + } + _ => continue, + }; + + if let Some(description) = find_inline_doc_description_after(tag_syntax) + && let Some(first_description_token) = first_non_whitespace_token(&description) + { + formatter.add_token_left_alignment_expected( + LuaSyntaxId::from_token(&first_description_token), + TokenExpected::Space(gap + max_body.saturating_sub(body.len())), + ); + } + } + + Some(()) +} + +fn apply_field_alignment_group( + formatter: &mut CommentFormatter, + lines: &[DocCommentLine], + tags: &[Option], + gap: usize, +) -> Option<()> { + 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); + + for (line, tag) in lines.iter().zip(tags.iter()) { + let (key, ty, tag) = match (line, tag) { + (DocCommentLine::Field { key, ty, .. }, Some(LuaDocTag::Field(tag))) => (key, ty, tag), + _ => continue, + }; + + let body_first_token = tag.get_type().and_then(|ty| ty.syntax().first_token())?; + formatter.add_token_left_alignment_expected( + LuaSyntaxId::from_token(&body_first_token), + TokenExpected::Space(gap + max_key.saturating_sub(key.len())), + ); + + if let Some(description) = find_inline_doc_description_after(tag.syntax()) + && let Some(first_description_token) = first_non_whitespace_token(&description) + { + formatter.add_token_left_alignment_expected( + LuaSyntaxId::from_token(&first_description_token), + TokenExpected::Space(gap + max_type.saturating_sub(ty.len())), + ); + } + } + + Some(()) +} + +fn apply_return_alignment_group( + formatter: &mut CommentFormatter, + lines: &[DocCommentLine], + tags: &[Option], + gap: usize, +) -> Option<()> { + let max_body = lines + .iter() + .filter_map(|line| match line { + DocCommentLine::Return { body, .. } => Some(body.len()), + _ => None, + }) + .max() + .unwrap_or(0); + + for (line, tag) in lines.iter().zip(tags.iter()) { + let (body, tag) = match (line, tag) { + (DocCommentLine::Return { body, .. }, Some(LuaDocTag::Return(tag))) => (body, tag), + _ => continue, + }; + + if let Some(description) = find_inline_doc_description_after(tag.syntax()) + && let Some(first_description_token) = first_non_whitespace_token(&description) + { + formatter.add_token_left_alignment_expected( + LuaSyntaxId::from_token(&first_description_token), + TokenExpected::Space(gap + max_body.saturating_sub(body.len())), + ); + } + } + + Some(()) +} + fn normalize_doc_description_tokens(formatter: &mut CommentFormatter, description: &LuaSyntaxNode) { for element in description.descendants_with_tokens() { let Some(token) = element.into_token() else { @@ -925,23 +1434,10 @@ fn single_line_node_text(node: &impl LuaAstNode) -> Option { } fn render_doc_comment_lines(config: &LuaFormatConfig, lines: &[DocCommentLine]) -> Vec { - let mut rendered = Vec::new(); - let mut index = 0; - while index < lines.len() { - if let Some((kind, group_end)) = find_interleaved_aligned_group(config, lines, index) { - rendered.extend(render_interleaved_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 + lines + .iter() + .map(|line| render_single_doc_comment_line(config, line)) + .collect() } fn find_interleaved_aligned_group( @@ -1008,33 +1504,6 @@ fn is_raw_doc_description_line(text: &str) -> bool { && !trimmed.starts_with("--- @")) } -fn render_interleaved_aligned_doc_tag_group( - config: &LuaFormatConfig, - lines: &[DocCommentLine], - kind: AlignableDocTagKind, -) -> Vec { - let alignable_lines: Vec = lines - .iter() - .filter(|line| alignable_doc_tag_kind(line) == Some(kind)) - .cloned() - .collect(); - let aligned_rendered = render_aligned_doc_tag_group(config, &alignable_lines, kind); - let mut aligned_iter = aligned_rendered.into_iter(); - - lines - .iter() - .map(|line| { - if alignable_doc_tag_kind(line) == Some(kind) { - aligned_iter - .next() - .unwrap_or_else(|| render_single_doc_comment_line(config, line)) - } else { - render_single_doc_comment_line(config, line) - } - }) - .collect() -} - fn should_align_doc_tag_kind(config: &LuaFormatConfig, kind: AlignableDocTagKind) -> bool { match kind { AlignableDocTagKind::Class @@ -1062,220 +1531,6 @@ fn alignable_doc_tag_kind(line: &DocCommentLine) -> Option } } -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 tag_prefix = normalized_doc_tag_with_name_prefix(config, "param"); - let mut rendered = format!( - "{tag_prefix}{gap}{name: 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 tag_prefix = normalized_doc_tag_with_name_prefix(config, "field"); - let mut rendered = format!( - "{tag_prefix}{gap}{key: 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 tag_prefix = normalized_doc_tag_with_name_prefix(config, "return"); - let mut rendered = format!( - "{tag_prefix}{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 tag_prefix = normalized_doc_tag_with_name_prefix(config, "alias"); - let normalized_body = normalize_embedded_doc_prefixes(config, body); - let mut rendered = format!( - "{tag_prefix}{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 tag_prefix = normalized_doc_tag_with_name_prefix(config, tag_name); - let mut rendered = format!( - "{tag_prefix}{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 { From 5d6ccab8eb884b912cce17fe8c679ca7087fe3fa Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Fri, 27 Mar 2026 10:59:42 +0800 Subject: [PATCH 20/23] update --- .../src/formatter/comments/mod.rs | 134 ++++++------------ 1 file changed, 46 insertions(+), 88 deletions(-) diff --git a/crates/emmylua_formatter/src/formatter/comments/mod.rs b/crates/emmylua_formatter/src/formatter/comments/mod.rs index 9119e5478..dae54775c 100644 --- a/crates/emmylua_formatter/src/formatter/comments/mod.rs +++ b/crates/emmylua_formatter/src/formatter/comments/mod.rs @@ -373,9 +373,12 @@ fn format_doc_comment(config: &LuaFormatConfig, comment: &LuaComment) -> Vec 0 { docs.push(ir::hard_line()); } @@ -1433,13 +1436,6 @@ fn single_line_node_text(node: &impl LuaAstNode) -> Option { Some(text) } -fn render_doc_comment_lines(config: &LuaFormatConfig, lines: &[DocCommentLine]) -> Vec { - lines - .iter() - .map(|line| render_single_doc_comment_line(config, line)) - .collect() -} - fn find_interleaved_aligned_group( config: &LuaFormatConfig, lines: &[DocCommentLine], @@ -1544,97 +1540,59 @@ fn render_single_doc_comment_line(config: &LuaFormatConfig, line: &DocCommentLin } DocCommentLine::Raw(text) => normalize_embedded_doc_prefixes(config, text), DocCommentLine::Class { body, desc } => { - let mut rendered = format!( - "{}{gap}{body}", - normalized_doc_tag_with_name_prefix(config, "class") - ); - if let Some(desc) = desc { - rendered.push_str(&gap); - rendered.push_str(desc); - } - rendered - } - DocCommentLine::Alias { body, desc } => { - let mut rendered = format!( - "{}{gap}{}", - normalized_doc_tag_with_name_prefix(config, "alias"), - normalize_embedded_doc_prefixes(config, body) - ); - if let Some(desc) = desc { - rendered.push_str(&gap); - rendered.push_str(desc); - } - rendered + render_structured_doc_line(config, "class", body, desc.as_deref()) } + DocCommentLine::Alias { body, desc } => render_structured_doc_line( + config, + "alias", + &normalize_embedded_doc_prefixes(config, body), + desc.as_deref(), + ), DocCommentLine::Type { body, desc } => { - let mut rendered = format!( - "{}{gap}{body}", - normalized_doc_tag_with_name_prefix(config, "type") - ); - if let Some(desc) = desc { - rendered.push_str(&gap); - rendered.push_str(desc); - } - rendered + render_structured_doc_line(config, "type", body, desc.as_deref()) } DocCommentLine::Generic { body, desc } => { - let mut rendered = format!( - "{}{gap}{body}", - normalized_doc_tag_with_name_prefix(config, "generic") - ); - if let Some(desc) = desc { - rendered.push_str(&gap); - rendered.push_str(desc); - } - rendered + render_structured_doc_line(config, "generic", body, desc.as_deref()) } DocCommentLine::Overload { body, desc } => { - let mut rendered = format!( - "{}{gap}{body}", - normalized_doc_tag_with_name_prefix(config, "overload") - ); - if let Some(desc) = desc { - rendered.push_str(&gap); - rendered.push_str(desc); - } - rendered - } - DocCommentLine::Param { name, ty, desc } => { - let mut rendered = format!( - "{}{gap}{name}{gap}{ty}", - normalized_doc_tag_with_name_prefix(config, "param") - ); - if let Some(desc) = desc { - rendered.push_str(&gap); - rendered.push_str(desc); - } - rendered - } - DocCommentLine::Field { key, ty, desc } => { - let mut rendered = format!( - "{}{gap}{key}{gap}{ty}", - normalized_doc_tag_with_name_prefix(config, "field") - ); - if let Some(desc) = desc { - rendered.push_str(&gap); - rendered.push_str(desc); - } - rendered + render_structured_doc_line(config, "overload", body, desc.as_deref()) } + DocCommentLine::Param { name, ty, desc } => render_structured_doc_line( + config, + "param", + &format!("{name}{gap}{ty}"), + desc.as_deref(), + ), + DocCommentLine::Field { key, ty, desc } => render_structured_doc_line( + config, + "field", + &format!("{key}{gap}{ty}"), + desc.as_deref(), + ), DocCommentLine::Return { body, desc } => { - let mut rendered = format!( - "{}{gap}{body}", - normalized_doc_tag_with_name_prefix(config, "return") - ); - if let Some(desc) = desc { - rendered.push_str(&gap); - rendered.push_str(desc); - } - rendered + render_structured_doc_line(config, "return", body, desc.as_deref()) } } } +fn render_structured_doc_line( + config: &LuaFormatConfig, + tag_name: &str, + body: &str, + desc: Option<&str>, +) -> String { + let gap = " ".repeat(config.emmy_doc.tag_spacing.max(1)); + let mut rendered = format!( + "{}{gap}{body}", + normalized_doc_tag_with_name_prefix(config, tag_name) + ); + if let Some(desc) = desc { + rendered.push_str(&gap); + rendered.push_str(desc); + } + rendered +} + fn normalized_comment_prefix(config: &LuaFormatConfig, prefix_text: &str) -> Option { match dash_prefix_len(prefix_text) { 2 => Some(if config.comments.space_after_comment_dash { From 20798f99bb09c0b6126db79bac82f976d974db81 Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Fri, 27 Mar 2026 17:55:09 +0800 Subject: [PATCH 21/23] refactor formatter --- .../src/formatter/comments/mod.rs | 9 +- .../src/formatter/expression.rs | 338 +++- .../src/formatter/statement.rs | 2 +- .../src/formatter_new/expr.rs | 1045 ++++++++++ .../src/formatter_new/layout/mod.rs | 474 +++++ .../src/formatter_new/layout/tree.rs | 28 + .../src/formatter_new/line_breaks.rs | 13 + .../src/formatter_new/mod.rs | 41 + .../src/formatter_new/model.rs | 147 ++ .../src/formatter_new/render.rs | 1796 +++++++++++++++++ .../src/formatter_new/sequence.rs | 446 ++++ .../src/formatter_new/spacing.rs | 662 ++++++ .../src/formatter_new/trivia.rs | 71 + crates/emmylua_formatter/src/lib.rs | 19 + .../src/test/expression_tests.rs | 16 + .../emmylua_formatter/src/test/misc_tests.rs | 353 +++- 16 files changed, 5366 insertions(+), 94 deletions(-) create mode 100644 crates/emmylua_formatter/src/formatter_new/expr.rs create mode 100644 crates/emmylua_formatter/src/formatter_new/layout/mod.rs create mode 100644 crates/emmylua_formatter/src/formatter_new/layout/tree.rs create mode 100644 crates/emmylua_formatter/src/formatter_new/line_breaks.rs create mode 100644 crates/emmylua_formatter/src/formatter_new/mod.rs create mode 100644 crates/emmylua_formatter/src/formatter_new/model.rs create mode 100644 crates/emmylua_formatter/src/formatter_new/render.rs create mode 100644 crates/emmylua_formatter/src/formatter_new/sequence.rs create mode 100644 crates/emmylua_formatter/src/formatter_new/spacing.rs create mode 100644 crates/emmylua_formatter/src/formatter_new/trivia.rs diff --git a/crates/emmylua_formatter/src/formatter/comments/mod.rs b/crates/emmylua_formatter/src/formatter/comments/mod.rs index dae54775c..9a09fb8c5 100644 --- a/crates/emmylua_formatter/src/formatter/comments/mod.rs +++ b/crates/emmylua_formatter/src/formatter/comments/mod.rs @@ -1563,12 +1563,9 @@ fn render_single_doc_comment_line(config: &LuaFormatConfig, line: &DocCommentLin &format!("{name}{gap}{ty}"), desc.as_deref(), ), - DocCommentLine::Field { key, ty, desc } => render_structured_doc_line( - config, - "field", - &format!("{key}{gap}{ty}"), - desc.as_deref(), - ), + DocCommentLine::Field { key, ty, desc } => { + render_structured_doc_line(config, "field", &format!("{key}{gap}{ty}"), desc.as_deref()) + } DocCommentLine::Return { body, desc } => { render_structured_doc_line(config, "return", body, desc.as_deref()) } diff --git a/crates/emmylua_formatter/src/formatter/expression.rs b/crates/emmylua_formatter/src/formatter/expression.rs index 35a33e690..b7fc4b2c7 100644 --- a/crates/emmylua_formatter/src/formatter/expression.rs +++ b/crates/emmylua_formatter/src/formatter/expression.rs @@ -26,7 +26,7 @@ use super::sequence::{ 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, + has_non_trivia_before_on_same_line_tokenwise, node_has_direct_comment_child, source_line_prefix_width, trailing_gap_requests_alignment, }; @@ -46,6 +46,28 @@ struct IndexStandaloneLayout { suffix: Option, } +struct InlineCommentFragment { + docs: Vec, + same_line_before: bool, +} + +struct CallArgsRenderPlan { + docs: Vec, + inline_space_before: bool, +} + +struct CallExprShellPlan { + prefix: Vec, + comments: Vec, + args: CallArgsRenderPlan, +} + +struct ClosureExprShellPlan { + params: Vec, + before_params_comments: Vec, + before_body_comments: Vec, +} + pub fn format_expr(ctx: &FormatContext, expr: &LuaExpr) -> Vec { match expr { LuaExpr::NameExpr(e) => format_name_expr(ctx, e), @@ -849,26 +871,14 @@ 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) { + if !node_has_direct_comment_child(expr.syntax()) + && 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 + render_call_expr_shell(ctx, collect_call_expr_shell_plan(ctx, expr)) } /// 索引表达式: t.x, t:m, t[k] @@ -1033,29 +1043,35 @@ fn collect_index_standalone_layout( } } -/// 格式化调用参数部分(不含前缀),如 `(a, b)` 或单参数简写 ` "str"` / ` { ... }` -fn format_call_args_ir(ctx: &FormatContext, expr: &LuaCallExpr) -> Vec { - format_call_args_ir_with_options(ctx, expr, false) -} - fn format_call_args_ir_with_options( ctx: &FormatContext, expr: &LuaCallExpr, preserve_chain_attached_table_source: bool, ) -> Vec { + let plan = format_call_args_render_plan(ctx, expr, preserve_chain_attached_table_source); + let mut docs = Vec::new(); + if plan.inline_space_before { + docs.push(ir::space()); + } + docs.extend(plan.docs); + docs +} + +fn format_call_args_render_plan( + ctx: &FormatContext, + expr: &LuaCallExpr, + preserve_chain_attached_table_source: bool, +) -> CallArgsRenderPlan { let mut docs = Vec::new(); if let Some(args_list) = expr.get_args_list() { let args: Vec<_> = args_list.get_args().collect(); if let Some(single_arg_docs) = format_single_arg_call_without_parens(ctx, &args_list, &args) { - docs.push(ir::space()); - docs.extend(single_arg_docs); - return docs; - } - - if ctx.config.spacing.space_before_call_paren { - docs.push(ir::space()); + return CallArgsRenderPlan { + docs: single_arg_docs, + inline_space_before: true, + }; } if args.is_empty() { @@ -1086,27 +1102,29 @@ fn format_call_args_ir_with_options( } else { let arg_docs: Vec> = args.iter().map(|a| format_expr(ctx, a)).collect(); - docs.extend(format_delimited_sequence( - ctx, - 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; + return CallArgsRenderPlan { + docs: format_delimited_sequence( + ctx, + 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, + }, + ), + inline_space_before: ctx.config.spacing.space_before_call_paren, + }; }; docs.extend(wrap_multiline_call_arg_docs( ctx, @@ -1234,7 +1252,10 @@ fn format_call_args_ir_with_options( } } - docs + CallArgsRenderPlan { + docs, + inline_space_before: ctx.config.spacing.space_before_call_paren, + } } fn should_attach_first_call_arg(args: &[LuaExpr]) -> bool { @@ -2090,33 +2111,7 @@ fn build_table_expanded_inner( /// 匿名函数: function(params) ... end fn format_closure_expr(ctx: &FormatContext, expr: &LuaClosureExpr) -> Vec { - 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.spacing.space_before_func_paren { - docs.push(ir::space()); - } - - // 参数列表 - if let Some(params) = expr.get_params_list() { - docs.extend(format_param_list_ir(ctx, ¶ms)); - } else { - docs.push(tok(LuaTokenKind::TkLeftParen)); - docs.push(tok(LuaTokenKind::TkRightParen)); - } - - // body - super::format_body_end_with_parent( - ctx, - expr.get_block().as_ref(), - Some(expr.syntax()), - &mut docs, - ); - - docs + render_closure_expr_shell(ctx, expr, collect_closure_expr_shell_plan(ctx, expr)) } /// 括号表达式: (expr) @@ -2242,20 +2237,191 @@ fn single_arg_expr_from_args(args: &[LuaExpr]) -> Option { } } -fn should_preserve_raw_call_expr(expr: &LuaCallExpr) -> bool { - if node_has_direct_same_line_inline_comment(expr.syntax()) { - return true; +fn collect_call_expr_shell_plan(ctx: &FormatContext, expr: &LuaCallExpr) -> CallExprShellPlan { + let mut prefix = Vec::new(); + let mut comments = Vec::new(); + + for child in expr.syntax().children() { + if let Some(prefix_expr) = LuaExpr::cast(child.clone()) { + prefix = format_expr(ctx, &prefix_expr); + } else if let Some(comment) = LuaComment::cast(child) { + comments.push(InlineCommentFragment { + docs: format_comment(ctx.config, &comment), + same_line_before: has_non_trivia_before_on_same_line_tokenwise(comment.syntax()), + }); + } } - false + CallExprShellPlan { + prefix, + comments, + args: format_call_args_render_plan(ctx, expr, false), + } } -fn should_preserve_raw_closure_expr(expr: &LuaClosureExpr) -> bool { - if node_has_direct_same_line_inline_comment(expr.syntax()) { - return true; +fn render_call_expr_shell(ctx: &FormatContext, plan: CallExprShellPlan) -> Vec { + let mut docs = plan.prefix; + + if plan.comments.is_empty() { + if plan.args.inline_space_before { + docs.push(ir::space()); + } + docs.extend(plan.args.docs); + return docs; } - false + let mut broke_before_args = false; + for comment in plan.comments { + if comment.same_line_before && !broke_before_args { + let mut suffix = trailing_comment_prefix(ctx.config); + suffix.extend(comment.docs); + docs.push(ir::line_suffix(suffix)); + } else { + docs.push(ir::hard_line()); + docs.extend(comment.docs); + } + broke_before_args = true; + } + + if broke_before_args { + docs.push(ir::hard_line()); + docs.extend(plan.args.docs); + } else { + if plan.args.inline_space_before { + docs.push(ir::space()); + } + docs.extend(plan.args.docs); + } + + docs +} + +fn collect_closure_expr_shell_plan( + ctx: &FormatContext, + expr: &LuaClosureExpr, +) -> ClosureExprShellPlan { + let mut params = vec![ + tok(LuaTokenKind::TkLeftParen), + tok(LuaTokenKind::TkRightParen), + ]; + let mut before_params_comments = Vec::new(); + let mut before_body_comments = Vec::new(); + let mut seen_params = false; + + for child in expr.syntax().children() { + if let Some(params_list) = emmylua_parser::LuaParamList::cast(child.clone()) { + params = format_param_list_ir(ctx, ¶ms_list); + seen_params = true; + } else if LuaComment::cast(child.clone()).is_some() { + let comment = LuaComment::cast(child).unwrap(); + let fragment = InlineCommentFragment { + docs: format_comment(ctx.config, &comment), + same_line_before: has_non_trivia_before_on_same_line_tokenwise(comment.syntax()), + }; + if seen_params { + before_body_comments.push(fragment); + } else { + before_params_comments.push(fragment); + } + } + } + + ClosureExprShellPlan { + params, + before_params_comments, + before_body_comments, + } +} + +fn render_closure_expr_shell( + ctx: &FormatContext, + expr: &LuaClosureExpr, + plan: ClosureExprShellPlan, +) -> Vec { + let mut docs = vec![tok(LuaTokenKind::TkFunction)]; + let mut broke_before_params = false; + + for comment in plan.before_params_comments { + if comment.same_line_before && !broke_before_params { + let mut suffix = trailing_comment_prefix(ctx.config); + suffix.extend(comment.docs); + docs.push(ir::line_suffix(suffix)); + } else { + docs.push(ir::hard_line()); + docs.extend(comment.docs); + } + broke_before_params = true; + } + + if broke_before_params { + docs.push(ir::hard_line()); + } else if ctx.config.spacing.space_before_func_paren { + docs.push(ir::space()); + } + docs.extend(plan.params); + + let has_comment_before_body = !plan.before_body_comments.is_empty(); + let mut body_comment_lines = Vec::new(); + let mut saw_same_line_body_comment = false; + for comment in plan.before_body_comments { + if comment.same_line_before && body_comment_lines.is_empty() { + let mut suffix = trailing_comment_prefix(ctx.config); + suffix.extend(comment.docs); + docs.push(ir::line_suffix(suffix)); + saw_same_line_body_comment = true; + } else { + body_comment_lines.push(comment.docs); + } + } + + if has_comment_before_body { + let block_docs = expr + .get_block() + .map(|block| super::format_block(ctx, &block)); + if let Some(block_docs) = block_docs + && !block_docs.is_empty() + { + let mut indented = vec![ir::hard_line()]; + for comment_docs in body_comment_lines { + indented.extend(comment_docs); + indented.push(ir::hard_line()); + } + indented.extend(block_docs); + docs.push(ir::indent(indented)); + docs.push(ir::hard_line()); + docs.push(tok(LuaTokenKind::TkEnd)); + return docs; + } + + if !body_comment_lines.is_empty() { + let mut indented = vec![ir::hard_line()]; + let body_comment_count = body_comment_lines.len(); + for (index, comment_docs) in body_comment_lines.into_iter().enumerate() { + indented.extend(comment_docs); + if index + 1 < body_comment_count { + indented.push(ir::hard_line()); + } + } + docs.push(ir::indent(indented)); + docs.push(ir::hard_line()); + docs.push(tok(LuaTokenKind::TkEnd)); + return docs; + } + + if saw_same_line_body_comment { + docs.push(ir::hard_line()); + docs.push(tok(LuaTokenKind::TkEnd)); + return docs; + } + } + + super::format_body_end_with_parent( + ctx, + expr.get_block().as_ref(), + Some(expr.syntax()), + &mut docs, + ); + docs } enum CallArgEntry { diff --git a/crates/emmylua_formatter/src/formatter/statement.rs b/crates/emmylua_formatter/src/formatter/statement.rs index 22e65f9e4..32329ebd1 100644 --- a/crates/emmylua_formatter/src/formatter/statement.rs +++ b/crates/emmylua_formatter/src/formatter/statement.rs @@ -48,7 +48,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![tok(LuaTokenKind::TkSemicolon)], + LuaStat::EmptyStat(_) => vec![], LuaStat::GlobalStat(s) => format_global_stat(ctx, s), } } diff --git a/crates/emmylua_formatter/src/formatter_new/expr.rs b/crates/emmylua_formatter/src/formatter_new/expr.rs new file mode 100644 index 000000000..7ba36b026 --- /dev/null +++ b/crates/emmylua_formatter/src/formatter_new/expr.rs @@ -0,0 +1,1045 @@ +use emmylua_parser::{ + LuaAstNode, LuaAstToken, LuaCallArgList, LuaCallExpr, LuaClosureExpr, LuaComment, LuaExpr, + LuaIndexKey, LuaKind, LuaLiteralToken, LuaParamList, LuaSingleArgExpr, LuaSyntaxId, + LuaSyntaxKind, LuaSyntaxNode, LuaSyntaxToken, LuaTableExpr, LuaTableField, LuaTokenKind, +}; +use rowan::TextRange; + +use crate::config::{ExpandStrategy, SingleArgCallParens, TrailingComma}; +use crate::ir::{self, DocIR}; + +use super::FormatContext; +use super::model::{ExprSequenceLayoutPlan, RootFormatPlan, TokenSpacingExpected}; +use super::sequence::{DelimitedSequenceLayout, format_delimited_sequence}; +use super::trivia::{has_non_trivia_before_on_same_line_tokenwise, node_has_direct_comment_child}; + +pub fn format_expr(ctx: &FormatContext, plan: &RootFormatPlan, expr: &LuaExpr) -> Vec { + match expr { + LuaExpr::CallExpr(expr) => format_call_expr(ctx, plan, expr), + LuaExpr::TableExpr(expr) => format_table_expr(ctx, plan, expr), + LuaExpr::ClosureExpr(expr) => format_closure_expr(ctx, plan, expr), + _ => vec![ir::source_node_trimmed(expr.syntax().clone())], + } +} + +pub fn format_param_list_ir( + ctx: &FormatContext, + plan: &RootFormatPlan, + params: &LuaParamList, +) -> Vec { + let collected = collect_param_entries(params); + + if collected.has_comments { + return format_param_list_with_comments(ctx, plan, params, collected); + } + + let param_docs: Vec> = collected + .entries + .into_iter() + .map(|entry| entry.doc) + .collect(); + let (open, close) = paren_tokens(params.syntax()); + let comma = first_direct_token(params.syntax(), LuaTokenKind::TkComma); + let layout_plan = expr_sequence_layout_plan(plan, params.syntax()); + + format_delimited_sequence( + ctx, + DelimitedSequenceLayout { + open: token_or_kind_doc(open.as_ref(), LuaTokenKind::TkLeftParen), + close: token_or_kind_doc(close.as_ref(), LuaTokenKind::TkRightParen), + items: param_docs, + strategy: ctx.config.layout.func_params_expand.clone(), + preserve_multiline: layout_plan.preserve_multiline, + flat_separator: comma_flat_separator(plan, comma.as_ref()), + fill_separator: comma_fill_separator(comma.as_ref()), + break_separator: comma_break_separator(comma.as_ref()), + flat_open_padding: token_right_spacing_docs(plan, open.as_ref()), + flat_close_padding: token_left_spacing_docs(plan, close.as_ref()), + grouped_padding: grouped_padding_from_pair(plan, open.as_ref(), close.as_ref()), + flat_trailing: vec![], + grouped_trailing: trailing_comma_ir(ctx.config.output.trailing_comma.clone()), + }, + ) +} + +#[derive(Default)] +struct CollectedParamEntries { + entries: Vec, + comments_after_open: Vec>, + comments_before_close: Vec>, + has_comments: bool, + consumed_comment_ranges: Vec, +} + +struct ParamEntry { + leading_comments: Vec>, + doc: Vec, + trailing_comment: Option>, +} + +fn collect_param_entries(params: &LuaParamList) -> CollectedParamEntries { + let mut collected = CollectedParamEntries::default(); + let mut pending_comments = Vec::new(); + let mut seen_param = false; + + for child in params.syntax().children() { + if let Some(comment) = LuaComment::cast(child.clone()) { + if collected + .consumed_comment_ranges + .iter() + .any(|range| *range == comment.syntax().text_range()) + { + continue; + } + let docs = vec![ir::source_node_trimmed(comment.syntax().clone())]; + collected.has_comments = true; + if !seen_param { + collected.comments_after_open.push(docs); + } else { + pending_comments.push(docs); + } + continue; + } + + if let Some(param) = emmylua_parser::LuaParamName::cast(child) { + let trailing_comment = extract_trailing_comment_text(param.syntax()); + if trailing_comment.is_some() { + collected.has_comments = true; + } + if let Some((_, range)) = &trailing_comment { + collected.consumed_comment_ranges.push(*range); + } + 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; + }; + collected.entries.push(ParamEntry { + leading_comments: std::mem::take(&mut pending_comments), + doc, + trailing_comment: trailing_comment.map(|(docs, _)| docs), + }); + seen_param = true; + } + } + + if !pending_comments.is_empty() { + collected.comments_before_close = pending_comments; + } + + collected +} + +fn format_param_list_with_comments( + ctx: &FormatContext, + _plan: &RootFormatPlan, + params: &LuaParamList, + collected: CollectedParamEntries, +) -> Vec { + let (open, close) = paren_tokens(params.syntax()); + let comma = first_direct_token(params.syntax(), LuaTokenKind::TkComma); + let mut docs = vec![token_or_kind_doc(open.as_ref(), LuaTokenKind::TkLeftParen)]; + let trailing = trailing_comma_ir(ctx.config.output.trailing_comma.clone()); + + if !collected.comments_after_open.is_empty() || !collected.entries.is_empty() { + let mut inner = Vec::new(); + + for comment_docs in collected.comments_after_open { + inner.push(ir::hard_line()); + inner.extend(comment_docs); + } + + let entry_count = collected.entries.len(); + for (index, entry) in collected.entries.into_iter().enumerate() { + inner.push(ir::hard_line()); + for comment_docs in entry.leading_comments { + inner.extend(comment_docs); + inner.push(ir::hard_line()); + } + inner.extend(entry.doc); + if index + 1 < entry_count { + inner.extend(comma_token_docs(comma.as_ref())); + } else { + inner.push(trailing.clone()); + } + if let Some(comment_docs) = entry.trailing_comment { + let mut suffix = trailing_comment_prefix(ctx); + suffix.extend(comment_docs); + inner.push(ir::line_suffix(suffix)); + } + } + + for comment_docs in collected.comments_before_close { + inner.push(ir::hard_line()); + inner.extend(comment_docs); + } + + docs.push(ir::indent(inner)); + docs.push(ir::hard_line()); + } + + docs.push(token_or_kind_doc( + close.as_ref(), + LuaTokenKind::TkRightParen, + )); + docs +} + +fn format_call_expr(ctx: &FormatContext, plan: &RootFormatPlan, expr: &LuaCallExpr) -> Vec { + if node_has_direct_comment_child(expr.syntax()) { + return vec![ir::source_node_trimmed(expr.syntax().clone())]; + } + + let mut docs = expr + .get_prefix_expr() + .map(|prefix| format_expr(ctx, plan, &prefix)) + .unwrap_or_default(); + + let Some(args_list) = expr.get_args_list() else { + return docs; + }; + + if let Some(single_arg_docs) = format_single_arg_call_without_parens(ctx, plan, &args_list) { + docs.push(ir::space()); + docs.extend(single_arg_docs); + return docs; + } + + let (open, _) = paren_tokens(args_list.syntax()); + docs.extend(token_left_spacing_docs(plan, open.as_ref())); + if docs.is_empty() && ctx.config.spacing.space_before_call_paren { + docs.push(ir::space()); + } + docs.extend(format_call_arg_list(ctx, plan, &args_list)); + docs +} + +fn format_call_arg_list( + ctx: &FormatContext, + plan: &RootFormatPlan, + args_list: &LuaCallArgList, +) -> Vec { + let collected = collect_call_arg_entries(ctx, plan, args_list); + + if collected.has_comments { + return format_call_arg_list_with_comments(ctx, plan, args_list, collected); + } + + let arg_docs: Vec> = collected + .entries + .into_iter() + .map(|entry| entry.doc) + .collect(); + let (open, close) = paren_tokens(args_list.syntax()); + let comma = first_direct_token(args_list.syntax(), LuaTokenKind::TkComma); + let layout_plan = expr_sequence_layout_plan(plan, args_list.syntax()); + + format_delimited_sequence( + ctx, + DelimitedSequenceLayout { + open: token_or_kind_doc(open.as_ref(), LuaTokenKind::TkLeftParen), + close: token_or_kind_doc(close.as_ref(), LuaTokenKind::TkRightParen), + items: arg_docs, + strategy: ctx.config.layout.call_args_expand.clone(), + preserve_multiline: layout_plan.preserve_multiline, + flat_separator: comma_flat_separator(plan, comma.as_ref()), + fill_separator: comma_fill_separator(comma.as_ref()), + break_separator: comma_break_separator(comma.as_ref()), + flat_open_padding: token_right_spacing_docs(plan, open.as_ref()), + flat_close_padding: token_left_spacing_docs(plan, close.as_ref()), + grouped_padding: grouped_padding_from_pair(plan, open.as_ref(), close.as_ref()), + flat_trailing: vec![], + grouped_trailing: trailing_comma_ir(ctx.config.output.trailing_comma.clone()), + }, + ) +} + +#[derive(Default)] +struct CollectedCallArgEntries { + entries: Vec, + comments_after_open: Vec>, + comments_before_close: Vec>, + has_comments: bool, + consumed_comment_ranges: Vec, +} + +struct CallArgEntry { + leading_comments: Vec>, + doc: Vec, + trailing_comment: Option>, +} + +fn collect_call_arg_entries( + ctx: &FormatContext, + plan: &RootFormatPlan, + args_list: &LuaCallArgList, +) -> CollectedCallArgEntries { + let mut collected = CollectedCallArgEntries::default(); + let mut pending_comments = Vec::new(); + let mut seen_arg = false; + + for child in args_list.syntax().children() { + if let Some(comment) = LuaComment::cast(child.clone()) { + if collected + .consumed_comment_ranges + .iter() + .any(|range| *range == comment.syntax().text_range()) + { + continue; + } + let docs = vec![ir::source_node_trimmed(comment.syntax().clone())]; + collected.has_comments = true; + if !seen_arg { + collected.comments_after_open.push(docs); + } else { + pending_comments.push(docs); + } + continue; + } + + if let Some(arg) = LuaExpr::cast(child) { + let trailing_comment = extract_trailing_comment(ctx, arg.syntax()); + if trailing_comment.is_some() { + collected.has_comments = true; + } + if let Some((_, range)) = &trailing_comment { + collected.consumed_comment_ranges.push(*range); + } + collected.entries.push(CallArgEntry { + leading_comments: std::mem::take(&mut pending_comments), + doc: format_expr(ctx, plan, &arg), + trailing_comment: trailing_comment.map(|(docs, _)| docs), + }); + seen_arg = true; + } + } + + if !pending_comments.is_empty() { + collected.comments_before_close = pending_comments; + } + + collected +} + +fn format_call_arg_list_with_comments( + ctx: &FormatContext, + plan: &RootFormatPlan, + args_list: &LuaCallArgList, + collected: CollectedCallArgEntries, +) -> Vec { + let (open, close) = paren_tokens(args_list.syntax()); + let comma = first_direct_token(args_list.syntax(), LuaTokenKind::TkComma); + let mut docs = vec![token_or_kind_doc(open.as_ref(), LuaTokenKind::TkLeftParen)]; + let trailing = trailing_comma_ir(ctx.config.output.trailing_comma.clone()); + + if !collected.comments_after_open.is_empty() || !collected.entries.is_empty() { + let mut inner = Vec::new(); + + for comment_docs in collected.comments_after_open { + inner.push(ir::hard_line()); + inner.extend(comment_docs); + } + + let entry_count = collected.entries.len(); + for (index, entry) in collected.entries.into_iter().enumerate() { + inner.push(ir::hard_line()); + for comment_docs in entry.leading_comments { + inner.extend(comment_docs); + inner.push(ir::hard_line()); + } + inner.extend(entry.doc); + if index + 1 < entry_count { + inner.extend(comma_token_docs(comma.as_ref())); + } else { + inner.push(trailing.clone()); + } + if let Some(comment_docs) = entry.trailing_comment { + let mut suffix = trailing_comment_prefix(ctx); + suffix.extend(comment_docs); + inner.push(ir::line_suffix(suffix)); + } + } + + for comment_docs in collected.comments_before_close { + inner.push(ir::hard_line()); + inner.extend(comment_docs); + } + + docs.push(ir::indent(inner)); + docs.push(ir::hard_line()); + } else { + docs.extend(token_right_spacing_docs(plan, open.as_ref())); + } + + docs.push(token_or_kind_doc( + close.as_ref(), + LuaTokenKind::TkRightParen, + )); + docs +} + +fn format_single_arg_call_without_parens( + ctx: &FormatContext, + plan: &RootFormatPlan, + args_list: &LuaCallArgList, +) -> 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(), + }?; + + Some(match single_arg { + LuaSingleArgExpr::TableExpr(table) => format_table_expr(ctx, plan, &table), + LuaSingleArgExpr::LiteralExpr(lit) + if matches!(lit.get_literal(), Some(LuaLiteralToken::String(_))) => + { + vec![ir::source_node_trimmed(lit.syntax().clone())] + } + LuaSingleArgExpr::LiteralExpr(_) => return None, + }) +} + +fn format_table_expr( + ctx: &FormatContext, + plan: &RootFormatPlan, + expr: &LuaTableExpr, +) -> Vec { + if expr.is_empty() { + let (open, close) = brace_tokens(expr.syntax()); + return vec![ + token_or_kind_doc(open.as_ref(), LuaTokenKind::TkLeftBrace), + token_or_kind_doc(close.as_ref(), LuaTokenKind::TkRightBrace), + ]; + } + + let collected = collect_table_entries(ctx, plan, expr); + + if collected.has_comments { + return format_table_with_comments(ctx, expr, collected); + } + + let field_docs: Vec> = collected + .entries + .into_iter() + .map(|entry| entry.doc) + .collect(); + let (open, close) = brace_tokens(expr.syntax()); + let comma = first_direct_token(expr.syntax(), LuaTokenKind::TkComma); + let layout_plan = expr_sequence_layout_plan(plan, expr.syntax()); + + format_delimited_sequence( + ctx, + DelimitedSequenceLayout { + open: token_or_kind_doc(open.as_ref(), LuaTokenKind::TkLeftBrace), + close: token_or_kind_doc(close.as_ref(), LuaTokenKind::TkRightBrace), + items: field_docs, + strategy: if expr.is_empty() { + ExpandStrategy::Never + } else { + ctx.config.layout.table_expand.clone() + }, + preserve_multiline: layout_plan.preserve_multiline, + flat_separator: comma_flat_separator(plan, comma.as_ref()), + fill_separator: comma_fill_separator(comma.as_ref()), + break_separator: comma_break_separator(comma.as_ref()), + flat_open_padding: token_right_spacing_docs(plan, open.as_ref()), + flat_close_padding: token_left_spacing_docs(plan, close.as_ref()), + grouped_padding: grouped_padding_from_pair(plan, open.as_ref(), close.as_ref()), + flat_trailing: vec![], + grouped_trailing: trailing_comma_ir(ctx.config.trailing_table_comma()), + }, + ) +} + +#[derive(Default)] +struct CollectedTableEntries { + entries: Vec, + comments_after_open: Vec>, + comments_before_close: Vec>, + has_comments: bool, + consumed_comment_ranges: Vec, +} + +struct TableEntry { + leading_comments: Vec>, + doc: Vec, + trailing_comment: Option>, +} + +fn collect_table_entries( + ctx: &FormatContext, + plan: &RootFormatPlan, + expr: &LuaTableExpr, +) -> CollectedTableEntries { + let mut collected = CollectedTableEntries::default(); + let mut pending_comments: Vec> = Vec::new(); + let mut seen_field = false; + + for child in expr.syntax().children() { + if let Some(comment) = LuaComment::cast(child.clone()) { + if collected + .consumed_comment_ranges + .iter() + .any(|range| *range == comment.syntax().text_range()) + { + continue; + } + let docs = vec![ir::source_node_trimmed(comment.syntax().clone())]; + collected.has_comments = true; + if !seen_field { + collected.comments_after_open.push(docs); + } else { + pending_comments.push(docs); + } + continue; + } + + if let Some(field) = LuaTableField::cast(child) { + let trailing_comment = extract_trailing_comment(ctx, field.syntax()); + if trailing_comment.is_some() { + collected.has_comments = true; + } + if let Some((_, range)) = &trailing_comment { + collected.consumed_comment_ranges.push(*range); + } + collected.entries.push(TableEntry { + leading_comments: std::mem::take(&mut pending_comments), + doc: format_table_field_ir(ctx, plan, &field), + trailing_comment: trailing_comment.map(|(docs, _)| docs), + }); + seen_field = true; + } + } + + if !pending_comments.is_empty() { + collected.comments_before_close = pending_comments; + } + + collected +} + +fn format_table_with_comments( + ctx: &FormatContext, + expr: &LuaTableExpr, + collected: CollectedTableEntries, +) -> Vec { + let (open, close) = brace_tokens(expr.syntax()); + let comma = first_direct_token(expr.syntax(), LuaTokenKind::TkComma); + let mut docs = vec![token_or_kind_doc(open.as_ref(), LuaTokenKind::TkLeftBrace)]; + + if !collected.comments_after_open.is_empty() || !collected.entries.is_empty() { + let mut inner = Vec::new(); + + for comment_docs in collected.comments_after_open { + inner.push(ir::hard_line()); + inner.extend(comment_docs); + } + + let entry_count = collected.entries.len(); + for (index, entry) in collected.entries.into_iter().enumerate() { + inner.push(ir::hard_line()); + for comment_docs in entry.leading_comments { + inner.extend(comment_docs); + inner.push(ir::hard_line()); + } + inner.extend(entry.doc); + if index + 1 < entry_count + || !matches!(ctx.config.trailing_table_comma(), TrailingComma::Never) + { + inner.extend(comma_token_docs(comma.as_ref())); + } + if let Some(comment_docs) = entry.trailing_comment { + let mut suffix = trailing_comment_prefix(ctx); + suffix.extend(comment_docs); + inner.push(ir::line_suffix(suffix)); + } + } + + for comment_docs in collected.comments_before_close { + inner.push(ir::hard_line()); + inner.extend(comment_docs); + } + + docs.push(ir::indent(inner)); + docs.push(ir::hard_line()); + } + + docs.push(token_or_kind_doc( + close.as_ref(), + LuaTokenKind::TkRightBrace, + )); + docs +} + +fn format_table_field_ir( + ctx: &FormatContext, + plan: &RootFormatPlan, + field: &LuaTableField, +) -> Vec { + let mut docs = Vec::new(); + + if field.is_assign_field() { + docs.extend(format_table_field_key_ir(ctx, plan, field)); + let assign_space = if ctx.config.spacing.space_around_assign_operator { + ir::space() + } else { + ir::list(vec![]) + }; + docs.push(assign_space.clone()); + docs.push(ir::syntax_token(LuaTokenKind::TkAssign)); + docs.push(assign_space); + } + + if let Some(value) = field.get_value_expr() { + docs.extend(format_table_field_value_ir(ctx, plan, &value)); + } + + docs +} + +fn format_table_field_key_ir( + ctx: &FormatContext, + plan: &RootFormatPlan, + field: &LuaTableField, +) -> Vec { + let Some(key) = field.get_field_key() else { + return Vec::new(); + }; + + match key { + LuaIndexKey::Name(name) => vec![ir::source_token(name.syntax().clone())], + LuaIndexKey::String(string) => vec![ + ir::syntax_token(LuaTokenKind::TkLeftBracket), + ir::source_token(string.syntax().clone()), + ir::syntax_token(LuaTokenKind::TkRightBracket), + ], + LuaIndexKey::Integer(number) => vec![ + ir::syntax_token(LuaTokenKind::TkLeftBracket), + ir::source_token(number.syntax().clone()), + ir::syntax_token(LuaTokenKind::TkRightBracket), + ], + LuaIndexKey::Expr(expr) => vec![ + ir::syntax_token(LuaTokenKind::TkLeftBracket), + ir::list(format_expr(ctx, plan, &expr)), + ir::syntax_token(LuaTokenKind::TkRightBracket), + ], + LuaIndexKey::Idx(_) => Vec::new(), + } +} + +fn format_table_field_value_ir( + ctx: &FormatContext, + plan: &RootFormatPlan, + value: &LuaExpr, +) -> Vec { + if let LuaExpr::TableExpr(table) = value + && value.syntax().text().contains_char('\n') + { + return format_table_expr(ctx, plan, table); + } + + format_expr(ctx, plan, value) +} + +fn format_closure_expr( + ctx: &FormatContext, + plan: &RootFormatPlan, + expr: &LuaClosureExpr, +) -> Vec { + let shell_plan = collect_closure_shell_plan(ctx, plan, expr); + render_closure_shell(ctx, plan, expr, shell_plan) +} + +struct InlineCommentFragment { + docs: Vec, + same_line_before: bool, +} + +struct ClosureShellPlan { + params: Vec, + before_params_comments: Vec, + before_body_comments: Vec, +} + +fn collect_closure_shell_plan( + ctx: &FormatContext, + plan: &RootFormatPlan, + expr: &LuaClosureExpr, +) -> ClosureShellPlan { + let mut params = vec![ + ir::syntax_token(LuaTokenKind::TkLeftParen), + ir::syntax_token(LuaTokenKind::TkRightParen), + ]; + let mut before_params_comments = Vec::new(); + let mut before_body_comments = Vec::new(); + let mut seen_params = false; + + for child in expr.syntax().children() { + if let Some(params_list) = LuaParamList::cast(child.clone()) { + params = format_param_list_ir(ctx, plan, ¶ms_list); + seen_params = true; + } else if let Some(comment) = LuaComment::cast(child) { + let fragment = InlineCommentFragment { + docs: vec![ir::source_node_trimmed(comment.syntax().clone())], + same_line_before: has_non_trivia_before_on_same_line_tokenwise(comment.syntax()), + }; + if seen_params { + before_body_comments.push(fragment); + } else { + before_params_comments.push(fragment); + } + } + } + + ClosureShellPlan { + params, + before_params_comments, + before_body_comments, + } +} + +fn render_closure_shell( + ctx: &FormatContext, + root_plan: &RootFormatPlan, + expr: &LuaClosureExpr, + plan: ClosureShellPlan, +) -> Vec { + let mut docs = vec![ir::syntax_token(LuaTokenKind::TkFunction)]; + let mut broke_before_params = false; + + for comment in plan.before_params_comments { + if comment.same_line_before && !broke_before_params { + let mut suffix = trailing_comment_prefix(ctx); + suffix.extend(comment.docs); + docs.push(ir::line_suffix(suffix)); + } else { + docs.push(ir::hard_line()); + docs.extend(comment.docs); + } + broke_before_params = true; + } + + if broke_before_params { + docs.push(ir::hard_line()); + } else if let Some(params) = expr.get_params_list() { + let (open, _) = paren_tokens(params.syntax()); + docs.extend(token_left_spacing_docs(root_plan, open.as_ref())); + } + docs.extend(plan.params); + + let mut body_comment_lines = Vec::new(); + let mut saw_same_line_body_comment = false; + for comment in plan.before_body_comments { + if comment.same_line_before && body_comment_lines.is_empty() { + let mut suffix = trailing_comment_prefix(ctx); + suffix.extend(comment.docs); + docs.push(ir::line_suffix(suffix)); + saw_same_line_body_comment = true; + } else { + body_comment_lines.push(comment.docs); + } + } + + let block_lines = expr + .get_block() + .map(|block| { + block + .syntax() + .text() + .to_string() + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default(); + + if !body_comment_lines.is_empty() || !block_lines.is_empty() { + let mut block_docs = vec![ir::hard_line()]; + for comment_docs in body_comment_lines { + block_docs.extend(comment_docs); + block_docs.push(ir::hard_line()); + } + for (index, line) in block_lines.into_iter().enumerate() { + if index > 0 { + block_docs.push(ir::hard_line()); + } + block_docs.push(ir::text(line)); + } + docs.push(ir::indent(block_docs)); + docs.push(ir::hard_line()); + } else if saw_same_line_body_comment { + docs.push(ir::hard_line()); + } + + if !saw_same_line_body_comment && expr.get_block().is_none() { + docs.push(ir::space()); + } + + docs.push(ir::syntax_token(LuaTokenKind::TkEnd)); + docs +} + +fn trailing_comma_ir(policy: TrailingComma) -> DocIR { + match policy { + TrailingComma::Never => ir::list(vec![]), + TrailingComma::Multiline => { + ir::if_break(ir::syntax_token(LuaTokenKind::TkComma), ir::list(vec![])) + } + TrailingComma::Always => ir::syntax_token(LuaTokenKind::TkComma), + } +} + +fn expr_sequence_layout_plan( + plan: &RootFormatPlan, + syntax: &LuaSyntaxNode, +) -> ExprSequenceLayoutPlan { + plan.layout + .expr_sequences + .get(&LuaSyntaxId::from_node(syntax)) + .copied() + .unwrap_or_default() +} + +fn token_or_kind_doc(token: Option<&LuaSyntaxToken>, fallback_kind: LuaTokenKind) -> DocIR { + token + .map(|token| ir::source_token(token.clone())) + .unwrap_or_else(|| ir::syntax_token(fallback_kind)) +} + +fn paren_tokens(node: &LuaSyntaxNode) -> (Option, Option) { + ( + first_direct_token(node, LuaTokenKind::TkLeftParen), + last_direct_token(node, LuaTokenKind::TkRightParen), + ) +} + +fn brace_tokens(node: &LuaSyntaxNode) -> (Option, Option) { + ( + first_direct_token(node, LuaTokenKind::TkLeftBrace), + last_direct_token(node, LuaTokenKind::TkRightBrace), + ) +} + +fn first_direct_token(node: &LuaSyntaxNode, kind: LuaTokenKind) -> Option { + node.children_with_tokens().find_map(|element| { + let token = element.into_token()?; + (token.kind().to_token() == kind).then_some(token) + }) +} + +fn last_direct_token(node: &LuaSyntaxNode, kind: LuaTokenKind) -> Option { + let mut result = None; + for element in node.children_with_tokens() { + let Some(token) = element.into_token() else { + continue; + }; + if token.kind().to_token() == kind { + result = Some(token); + } + } + result +} + +fn token_left_spacing_docs(plan: &RootFormatPlan, token: Option<&LuaSyntaxToken>) -> Vec { + let Some(token) = token else { + return Vec::new(); + }; + spacing_docs_from_expected(plan.spacing.left_expected(LuaSyntaxId::from_token(token))) +} + +fn token_right_spacing_docs(plan: &RootFormatPlan, token: Option<&LuaSyntaxToken>) -> Vec { + let Some(token) = token else { + return Vec::new(); + }; + spacing_docs_from_expected(plan.spacing.right_expected(LuaSyntaxId::from_token(token))) +} + +fn spacing_docs_from_expected(expected: Option<&TokenSpacingExpected>) -> Vec { + match expected { + Some(TokenSpacingExpected::Space(count)) | Some(TokenSpacingExpected::MaxSpace(count)) => { + (0..*count).map(|_| ir::space()).collect() + } + None => Vec::new(), + } +} + +fn grouped_padding_from_pair( + plan: &RootFormatPlan, + open: Option<&LuaSyntaxToken>, + close: Option<&LuaSyntaxToken>, +) -> DocIR { + let has_inner_space = !token_right_spacing_docs(plan, open).is_empty() + || !token_left_spacing_docs(plan, close).is_empty(); + if has_inner_space { + ir::soft_line() + } else { + ir::soft_line_or_empty() + } +} + +fn comma_token_docs(token: Option<&LuaSyntaxToken>) -> Vec { + vec![token_or_kind_doc(token, LuaTokenKind::TkComma)] +} + +fn comma_flat_separator(plan: &RootFormatPlan, token: Option<&LuaSyntaxToken>) -> Vec { + let mut docs = comma_token_docs(token); + docs.extend(token_right_spacing_docs(plan, token)); + docs +} + +fn comma_fill_separator(token: Option<&LuaSyntaxToken>) -> Vec { + let mut docs = comma_token_docs(token); + docs.push(ir::soft_line()); + docs +} + +fn comma_break_separator(token: Option<&LuaSyntaxToken>) -> Vec { + let mut docs = comma_token_docs(token); + docs.push(ir::hard_line()); + docs +} + +fn trailing_comment_prefix(ctx: &FormatContext) -> Vec { + let gap = ctx.config.comments.line_comment_min_spaces_before.max(1); + (0..gap).map(|_| ir::space()).collect() +} + +fn extract_trailing_comment( + ctx: &FormatContext, + node: &LuaSyntaxNode, +) -> Option<(Vec, TextRange)> { + for child in node.children() { + if child.kind() != LuaKind::Syntax(LuaSyntaxKind::Comment) { + continue; + } + + let comment = LuaComment::cast(child.clone())?; + if !has_inline_non_trivia_before(comment.syntax()) + || has_inline_non_trivia_after(comment.syntax()) + { + continue; + } + if child.text().contains_char('\n') { + return None; + } + + let text = trim_end_owned(child.text().to_string()); + return Some(( + normalize_single_line_comment_text(ctx, &text), + child.text_range(), + )); + } + + let mut next = node.next_sibling_or_token(); + 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()?; + if comment_node.text().contains_char('\n') { + return None; + } + let text = trim_end_owned(comment_node.text().to_string()); + return Some(( + normalize_single_line_comment_text(ctx, &text), + comment_node.text_range(), + )); + } + _ => return None, + } + next = sibling.next_sibling_or_token(); + } + + None +} + +fn extract_trailing_comment_text(node: &LuaSyntaxNode) -> Option<(Vec, TextRange)> { + let mut next = node.next_sibling_or_token(); + 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()?; + if comment_node.text().contains_char('\n') { + return None; + } + let text = trim_end_owned(comment_node.text().to_string()); + return Some((vec![ir::text(text)], comment_node.text_range())); + } + _ => return None, + } + next = sibling.next_sibling_or_token(); + } + + None +} + +fn normalize_single_line_comment_text(ctx: &FormatContext, text: &str) -> Vec { + if text.starts_with("---") || !text.starts_with("--") { + return vec![ir::text(text.to_string())]; + } + + let body = text[2..].trim_start(); + let prefix = if ctx.config.comments.space_after_comment_dash { + if body.is_empty() { + "--".to_string() + } else { + "-- ".to_string() + } + } else { + "--".to_string() + }; + + vec![ir::text(format!("{prefix}{body}"))] +} + +fn trim_end_owned(mut text: String) -> String { + while matches!(text.chars().last(), Some(' ' | '\t' | '\r' | '\n')) { + text.pop(); + } + text +} + +fn has_inline_non_trivia_before(node: &LuaSyntaxNode) -> bool { + let Some(first_token) = node.first_token() else { + return false; + }; + let mut previous = first_token.prev_token(); + while let Some(token) = previous { + match token.kind().to_token() { + LuaTokenKind::TkWhitespace => previous = token.prev_token(), + LuaTokenKind::TkEndOfLine => return false, + _ => return true, + } + } + false +} + +fn has_inline_non_trivia_after(node: &LuaSyntaxNode) -> bool { + let Some(last_token) = node.last_token() else { + return false; + }; + let mut next = last_token.next_token(); + while let Some(token) = next { + match token.kind().to_token() { + LuaTokenKind::TkWhitespace => next = token.next_token(), + LuaTokenKind::TkEndOfLine => return false, + _ => return true, + } + } + false +} diff --git a/crates/emmylua_formatter/src/formatter_new/layout/mod.rs b/crates/emmylua_formatter/src/formatter_new/layout/mod.rs new file mode 100644 index 000000000..7e2289b4a --- /dev/null +++ b/crates/emmylua_formatter/src/formatter_new/layout/mod.rs @@ -0,0 +1,474 @@ +mod tree; + +use emmylua_parser::{ + LuaAssignStat, LuaAst, LuaAstNode, LuaCallArgList, LuaChunk, LuaComment, LuaExpr, + LuaForRangeStat, LuaForStat, LuaIfStat, LuaLocalStat, LuaParamList, LuaRepeatStat, + LuaReturnStat, LuaSyntaxId, LuaTableExpr, LuaWhileStat, +}; + +use super::FormatContext; +use super::model::{ + ControlHeaderLayoutPlan, ExprSequenceLayoutPlan, RootFormatPlan, StatementExprListLayoutKind, + StatementExprListLayoutPlan, StatementTriviaLayoutPlan, +}; +use super::trivia::{ + has_non_trivia_before_on_same_line_tokenwise, node_has_direct_comment_child, + source_line_prefix_width, +}; + +pub fn analyze_root_layout( + _ctx: &FormatContext, + chunk: &LuaChunk, + mut plan: RootFormatPlan, +) -> RootFormatPlan { + plan.layout.format_block_with_legacy = true; + plan.layout.root_nodes = tree::collect_root_layout_nodes(chunk); + analyze_node_layouts(chunk, &mut plan); + plan +} + +fn analyze_node_layouts(chunk: &LuaChunk, plan: &mut RootFormatPlan) { + for node in chunk.descendants::() { + match node { + LuaAst::LuaLocalStat(stat) => { + analyze_local_stat_layout(&stat, plan); + } + LuaAst::LuaAssignStat(stat) => { + analyze_assign_stat_layout(&stat, plan); + } + LuaAst::LuaReturnStat(stat) => { + analyze_return_stat_layout(&stat, plan); + } + LuaAst::LuaWhileStat(stat) => { + analyze_while_stat_layout(&stat, plan); + } + LuaAst::LuaForStat(stat) => { + analyze_for_stat_layout(&stat, plan); + } + LuaAst::LuaForRangeStat(stat) => { + analyze_for_range_stat_layout(&stat, plan); + } + LuaAst::LuaRepeatStat(stat) => { + analyze_repeat_stat_layout(&stat, plan); + } + LuaAst::LuaIfStat(stat) => { + analyze_if_stat_layout(&stat, plan); + } + LuaAst::LuaParamList(param) => { + analyze_param_list_layout(¶m, plan); + } + LuaAst::LuaCallArgList(args) => { + analyze_call_arg_list_layout(&args, plan); + } + LuaAst::LuaTableExpr(table) => { + analyze_table_expr_layout(&table, plan); + } + _ => {} + } + } +} + +fn analyze_local_stat_layout(stat: &LuaLocalStat, plan: &mut RootFormatPlan) { + let syntax_id = LuaSyntaxId::from_node(stat.syntax()); + analyze_statement_trivia_layout(stat.syntax(), syntax_id, plan); + let exprs: Vec<_> = stat.get_value_exprs().collect(); + analyze_statement_expr_list_layout(syntax_id, &exprs, plan); +} + +fn analyze_assign_stat_layout(stat: &LuaAssignStat, plan: &mut RootFormatPlan) { + let syntax_id = LuaSyntaxId::from_node(stat.syntax()); + analyze_statement_trivia_layout(stat.syntax(), syntax_id, plan); + let (_, exprs) = stat.get_var_and_expr_list(); + analyze_statement_expr_list_layout(syntax_id, &exprs, plan); +} + +fn analyze_return_stat_layout(stat: &LuaReturnStat, plan: &mut RootFormatPlan) { + let syntax_id = LuaSyntaxId::from_node(stat.syntax()); + analyze_statement_trivia_layout(stat.syntax(), syntax_id, plan); + let exprs: Vec<_> = stat.get_expr_list().collect(); + analyze_statement_expr_list_layout(syntax_id, &exprs, plan); +} + +fn analyze_while_stat_layout(stat: &LuaWhileStat, plan: &mut RootFormatPlan) { + let syntax_id = LuaSyntaxId::from_node(stat.syntax()); + analyze_control_header_layout(stat.syntax(), syntax_id, plan); +} + +fn analyze_for_stat_layout(stat: &LuaForStat, plan: &mut RootFormatPlan) { + let syntax_id = LuaSyntaxId::from_node(stat.syntax()); + analyze_control_header_layout(stat.syntax(), syntax_id, plan); + let exprs: Vec<_> = stat.get_iter_expr().collect(); + analyze_control_header_expr_list_layout(syntax_id, &exprs, plan); +} + +fn analyze_for_range_stat_layout(stat: &LuaForRangeStat, plan: &mut RootFormatPlan) { + let syntax_id = LuaSyntaxId::from_node(stat.syntax()); + analyze_control_header_layout(stat.syntax(), syntax_id, plan); + let exprs: Vec<_> = stat.get_expr_list().collect(); + analyze_control_header_expr_list_layout(syntax_id, &exprs, plan); +} + +fn analyze_repeat_stat_layout(stat: &LuaRepeatStat, plan: &mut RootFormatPlan) { + let syntax_id = LuaSyntaxId::from_node(stat.syntax()); + analyze_control_header_layout(stat.syntax(), syntax_id, plan); +} + +fn analyze_if_stat_layout(stat: &LuaIfStat, plan: &mut RootFormatPlan) { + let syntax_id = LuaSyntaxId::from_node(stat.syntax()); + analyze_control_header_layout(stat.syntax(), syntax_id, plan); + + for clause in stat.get_else_if_clause_list() { + let clause_id = LuaSyntaxId::from_node(clause.syntax()); + analyze_control_header_layout(clause.syntax(), clause_id, plan); + } +} + +fn analyze_param_list_layout(params: &LuaParamList, plan: &mut RootFormatPlan) { + let syntax_id = LuaSyntaxId::from_node(params.syntax()); + let first_line_prefix_width = params + .get_params() + .next() + .map(|param| source_line_prefix_width(param.syntax())) + .unwrap_or(0); + + plan.layout.expr_sequences.insert( + syntax_id, + ExprSequenceLayoutPlan { + first_line_prefix_width, + preserve_multiline: false, + }, + ); +} + +fn analyze_call_arg_list_layout(args: &LuaCallArgList, plan: &mut RootFormatPlan) { + let syntax_id = LuaSyntaxId::from_node(args.syntax()); + let first_line_prefix_width = args + .get_args() + .next() + .map(|arg| source_line_prefix_width(arg.syntax())) + .unwrap_or(0); + + plan.layout.expr_sequences.insert( + syntax_id, + ExprSequenceLayoutPlan { + first_line_prefix_width, + preserve_multiline: args.syntax().text().contains_char('\n'), + }, + ); +} + +fn analyze_table_expr_layout(table: &LuaTableExpr, plan: &mut RootFormatPlan) { + if table.is_empty() { + return; + } + + let syntax_id = LuaSyntaxId::from_node(table.syntax()); + let first_line_prefix_width = table + .get_fields() + .next() + .map(|field| source_line_prefix_width(field.syntax())) + .unwrap_or(0); + + plan.layout.expr_sequences.insert( + syntax_id, + ExprSequenceLayoutPlan { + first_line_prefix_width, + preserve_multiline: false, + }, + ); +} + +fn analyze_statement_trivia_layout( + node: &emmylua_parser::LuaSyntaxNode, + syntax_id: LuaSyntaxId, + plan: &mut RootFormatPlan, +) { + if !node_has_direct_comment_child(node) { + return; + } + + let has_inline_comment = node + .children() + .filter_map(LuaComment::cast) + .any(|comment| has_non_trivia_before_on_same_line_tokenwise(comment.syntax())); + + plan.layout + .statement_trivia + .insert(syntax_id, StatementTriviaLayoutPlan { has_inline_comment }); +} + +fn analyze_control_header_layout( + node: &emmylua_parser::LuaSyntaxNode, + syntax_id: LuaSyntaxId, + plan: &mut RootFormatPlan, +) { + if !node_has_direct_comment_child(node) { + return; + } + + let has_inline_comment = node + .children() + .filter_map(LuaComment::cast) + .any(|comment| has_non_trivia_before_on_same_line_tokenwise(comment.syntax())); + + plan.layout + .control_headers + .insert(syntax_id, ControlHeaderLayoutPlan { has_inline_comment }); +} + +fn analyze_statement_expr_list_layout( + syntax_id: LuaSyntaxId, + exprs: &[LuaExpr], + plan: &mut RootFormatPlan, +) { + if exprs.is_empty() { + return; + } + + let first_line_prefix_width = exprs + .first() + .map(|expr| source_line_prefix_width(expr.syntax())) + .unwrap_or(0); + let kind = if should_preserve_first_multiline_statement_value(exprs) { + StatementExprListLayoutKind::PreserveFirstMultiline + } else { + StatementExprListLayoutKind::Sequence + }; + + plan.layout.statement_expr_lists.insert( + syntax_id, + build_expr_list_layout_plan( + kind, + first_line_prefix_width, + should_attach_single_value_head(exprs), + exprs.len() > 2, + ), + ); +} + +fn analyze_control_header_expr_list_layout( + syntax_id: LuaSyntaxId, + exprs: &[LuaExpr], + plan: &mut RootFormatPlan, +) { + if exprs.is_empty() { + return; + } + + let first_line_prefix_width = exprs + .first() + .map(|expr| source_line_prefix_width(expr.syntax())) + .unwrap_or(0); + let kind = if should_preserve_first_multiline_statement_value(exprs) { + StatementExprListLayoutKind::PreserveFirstMultiline + } else { + StatementExprListLayoutKind::Sequence + }; + + plan.layout.control_header_expr_lists.insert( + syntax_id, + build_expr_list_layout_plan(kind, first_line_prefix_width, false, exprs.len() > 2), + ); +} + +fn build_expr_list_layout_plan( + kind: StatementExprListLayoutKind, + first_line_prefix_width: usize, + attach_single_value_head: bool, + allow_packed: bool, +) -> StatementExprListLayoutPlan { + StatementExprListLayoutPlan { + kind, + first_line_prefix_width, + attach_single_value_head, + allow_fill: true, + allow_packed, + allow_one_per_line: true, + prefer_balanced_break_lines: true, + } +} + +fn should_preserve_first_multiline_statement_value(exprs: &[LuaExpr]) -> bool { + exprs.len() > 1 + && exprs.first().is_some_and(|expr| { + is_block_like_expr(expr) && expr.syntax().text().contains_char('\n') + }) +} + +fn is_block_like_expr(expr: &LuaExpr) -> bool { + matches!(expr, LuaExpr::ClosureExpr(_) | LuaExpr::TableExpr(_)) +} + +fn should_attach_single_value_head(exprs: &[LuaExpr]) -> bool { + exprs.len() == 1 + && exprs.first().is_some_and(|expr| { + is_block_like_expr(expr) || node_has_direct_comment_child(expr.syntax()) + }) +} + +#[cfg(test)] +mod tests { + use emmylua_parser::{LuaAstNode, LuaLanguageLevel, LuaParser, LuaSyntaxKind, ParserConfig}; + + use crate::config::LuaFormatConfig; + use crate::formatter_new::model::{LayoutNodePlan, StatementExprListLayoutKind}; + + use super::*; + + #[test] + fn test_layout_collects_recursive_node_tree_with_comment_exception() { + let config = LuaFormatConfig::default(); + let tree = LuaParser::parse( + "-- hello\nlocal x = 1\n", + ParserConfig::with_level(LuaLanguageLevel::Lua54), + ); + let chunk = tree.get_chunk_node(); + let spacing_plan = crate::formatter_new::spacing::analyze_root_spacing( + &crate::formatter_new::FormatContext::new(&config), + &chunk, + ); + let plan = analyze_root_layout( + &crate::formatter_new::FormatContext::new(&config), + &chunk, + spacing_plan, + ); + + assert_eq!(plan.layout.root_nodes.len(), 1); + let LayoutNodePlan::Syntax(block) = &plan.layout.root_nodes[0] else { + panic!("expected block syntax node"); + }; + assert_eq!(block.kind, LuaSyntaxKind::Block); + assert_eq!(block.children.len(), 2); + assert!(matches!(block.children[0], LayoutNodePlan::Comment(_))); + assert!(matches!(block.children[1], LayoutNodePlan::Syntax(_))); + + let LayoutNodePlan::Comment(comment) = &block.children[0] else { + panic!("expected comment child"); + }; + assert_eq!(comment.syntax_id.get_kind(), LuaSyntaxKind::Comment); + } + + #[test] + fn test_layout_collects_statement_trivia_and_expr_list_metadata() { + let config = LuaFormatConfig::default(); + let tree = LuaParser::parse( + "local a, -- lhs\n b = {\n 1,\n 2,\n }, c\nreturn -- head\n foo, bar\nreturn\n -- standalone\n baz\n", + ParserConfig::with_level(LuaLanguageLevel::Lua54), + ); + let chunk = tree.get_chunk_node(); + let ctx = crate::formatter_new::FormatContext::new(&config); + let spacing_plan = crate::formatter_new::spacing::analyze_root_spacing(&ctx, &chunk); + let plan = analyze_root_layout(&ctx, &chunk, spacing_plan); + + let local_stat = chunk + .syntax() + .descendants() + .find_map(emmylua_parser::LuaLocalStat::cast) + .expect("expected local stat"); + let local_layout = plan + .layout + .statement_trivia + .get(&LuaSyntaxId::from_node(local_stat.syntax())) + .expect("expected local trivia layout"); + assert!(local_layout.has_inline_comment); + + let local_expr_layout = plan + .layout + .statement_expr_lists + .get(&LuaSyntaxId::from_node(local_stat.syntax())) + .expect("expected local expr layout"); + assert_eq!( + local_expr_layout.kind, + StatementExprListLayoutKind::PreserveFirstMultiline + ); + assert!(!local_expr_layout.attach_single_value_head); + assert!(local_expr_layout.allow_fill); + assert!(!local_expr_layout.allow_packed); + assert!(local_expr_layout.allow_one_per_line); + + let return_stats: Vec<_> = chunk + .syntax() + .descendants() + .filter_map(emmylua_parser::LuaReturnStat::cast) + .collect(); + assert_eq!(return_stats.len(), 2); + + let inline_return_layout = plan + .layout + .statement_trivia + .get(&LuaSyntaxId::from_node(return_stats[0].syntax())) + .expect("expected inline return trivia layout"); + assert!(inline_return_layout.has_inline_comment); + + let standalone_return_layout = plan + .layout + .statement_trivia + .get(&LuaSyntaxId::from_node(return_stats[1].syntax())) + .expect("expected standalone return trivia layout"); + assert!(!standalone_return_layout.has_inline_comment); + + let while_stat = chunk + .syntax() + .descendants() + .find_map(emmylua_parser::LuaWhileStat::cast); + assert!(while_stat.is_none()); + } + + #[test] + fn test_layout_collects_expr_sequence_metadata() { + let config = LuaFormatConfig::default(); + let tree = LuaParser::parse( + "local function foo(\n a,\n b\n)\n return call(\n foo,\n bar\n ), {\n x = 1,\n y = 2,\n }\nend\n", + ParserConfig::with_level(LuaLanguageLevel::Lua54), + ); + let chunk = tree.get_chunk_node(); + let ctx = crate::formatter_new::FormatContext::new(&config); + let spacing_plan = crate::formatter_new::spacing::analyze_root_spacing(&ctx, &chunk); + let plan = analyze_root_layout(&ctx, &chunk, spacing_plan); + + let param_list = chunk + .descendants::() + .find_map(|node| match node { + LuaAst::LuaParamList(node) => Some(node), + _ => None, + }) + .expect("expected param list"); + let param_layout = plan + .layout + .expr_sequences + .get(&LuaSyntaxId::from_node(param_list.syntax())) + .expect("expected param layout"); + assert!(!param_layout.preserve_multiline); + assert!(param_layout.first_line_prefix_width > 0); + + let call_args = chunk + .descendants::() + .find_map(|node| match node { + LuaAst::LuaCallArgList(node) => Some(node), + _ => None, + }) + .expect("expected call arg list"); + let call_layout = plan + .layout + .expr_sequences + .get(&LuaSyntaxId::from_node(call_args.syntax())) + .expect("expected call arg layout"); + assert!(call_layout.preserve_multiline); + assert!(call_layout.first_line_prefix_width > 0); + + let table_expr = chunk + .descendants::() + .find_map(|node| match node { + LuaAst::LuaTableExpr(node) => Some(node), + _ => None, + }) + .expect("expected table expr"); + let table_layout = plan + .layout + .expr_sequences + .get(&LuaSyntaxId::from_node(table_expr.syntax())) + .expect("expected table layout"); + assert!(!table_layout.preserve_multiline); + assert!(table_layout.first_line_prefix_width > 0); + } +} diff --git a/crates/emmylua_formatter/src/formatter_new/layout/tree.rs b/crates/emmylua_formatter/src/formatter_new/layout/tree.rs new file mode 100644 index 000000000..418bf362c --- /dev/null +++ b/crates/emmylua_formatter/src/formatter_new/layout/tree.rs @@ -0,0 +1,28 @@ +use emmylua_parser::{LuaAstNode, LuaChunk, LuaSyntaxId, LuaSyntaxKind, LuaSyntaxNode}; + +use crate::formatter_new::model::{CommentLayoutPlan, LayoutNodePlan, SyntaxNodeLayoutPlan}; + +pub fn collect_root_layout_nodes(chunk: &LuaChunk) -> Vec { + collect_child_layout_nodes(chunk.syntax()) +} + +fn collect_child_layout_nodes(node: &LuaSyntaxNode) -> Vec { + node.children().filter_map(collect_layout_node).collect() +} + +fn collect_layout_node(node: LuaSyntaxNode) -> Option { + match node.kind().into() { + LuaSyntaxKind::Comment => Some(LayoutNodePlan::Comment(collect_comment_layout(node))), + kind => Some(LayoutNodePlan::Syntax(SyntaxNodeLayoutPlan { + syntax_id: LuaSyntaxId::from_node(&node), + kind, + children: collect_child_layout_nodes(&node), + })), + } +} + +fn collect_comment_layout(node: LuaSyntaxNode) -> CommentLayoutPlan { + CommentLayoutPlan { + syntax_id: LuaSyntaxId::from_node(&node), + } +} diff --git a/crates/emmylua_formatter/src/formatter_new/line_breaks.rs b/crates/emmylua_formatter/src/formatter_new/line_breaks.rs new file mode 100644 index 000000000..298dac6ae --- /dev/null +++ b/crates/emmylua_formatter/src/formatter_new/line_breaks.rs @@ -0,0 +1,13 @@ +use emmylua_parser::LuaChunk; + +use super::FormatContext; +use super::model::RootFormatPlan; + +pub fn analyze_root_line_breaks( + ctx: &FormatContext, + _chunk: &LuaChunk, + mut plan: RootFormatPlan, +) -> RootFormatPlan { + plan.line_breaks.insert_final_newline = ctx.config.output.insert_final_newline; + plan +} diff --git a/crates/emmylua_formatter/src/formatter_new/mod.rs b/crates/emmylua_formatter/src/formatter_new/mod.rs new file mode 100644 index 000000000..92deff3f5 --- /dev/null +++ b/crates/emmylua_formatter/src/formatter_new/mod.rs @@ -0,0 +1,41 @@ +mod expr; +mod layout; +mod line_breaks; +mod model; +mod render; +mod sequence; +mod spacing; +mod trivia; + +use std::cell::Cell; + +use crate::config::LuaFormatConfig; +use crate::ir::{DocIR, GroupId}; +use emmylua_parser::LuaChunk; + +pub struct FormatContext<'a> { + pub config: &'a LuaFormatConfig, + next_group_id: Cell, +} + +impl<'a> FormatContext<'a> { + pub fn new(config: &'a LuaFormatConfig) -> Self { + Self { + config, + next_group_id: Cell::new(0), + } + } + + pub fn next_group_id(&self) -> GroupId { + let next = self.next_group_id.get(); + self.next_group_id.set(next + 1); + GroupId(next) + } +} + +pub fn format_chunk(ctx: &FormatContext, chunk: &LuaChunk) -> Vec { + let spacing_plan = spacing::analyze_root_spacing(ctx, chunk); + let layout_plan = layout::analyze_root_layout(ctx, chunk, spacing_plan); + let final_plan = line_breaks::analyze_root_line_breaks(ctx, chunk, layout_plan); + render::render_root(ctx, chunk, &final_plan) +} diff --git a/crates/emmylua_formatter/src/formatter_new/model.rs b/crates/emmylua_formatter/src/formatter_new/model.rs new file mode 100644 index 000000000..7c77890f3 --- /dev/null +++ b/crates/emmylua_formatter/src/formatter_new/model.rs @@ -0,0 +1,147 @@ +use std::collections::HashMap; + +use emmylua_parser::{LuaSyntaxId, LuaSyntaxKind}; + +use crate::config::LuaFormatConfig; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TokenSpacingExpected { + Space(usize), + MaxSpace(usize), +} + +#[derive(Clone, Debug, Default)] +pub struct RootSpacingModel { + pub has_shebang: bool, + left_expected: HashMap, + right_expected: HashMap, + replace_tokens: HashMap, +} + +impl RootSpacingModel { + pub fn add_token_left_expected( + &mut self, + syntax_id: LuaSyntaxId, + expected: TokenSpacingExpected, + ) { + self.left_expected.insert(syntax_id, expected); + } + + pub fn add_token_right_expected( + &mut self, + syntax_id: LuaSyntaxId, + expected: TokenSpacingExpected, + ) { + self.right_expected.insert(syntax_id, expected); + } + + pub fn left_expected(&self, syntax_id: LuaSyntaxId) -> Option<&TokenSpacingExpected> { + self.left_expected.get(&syntax_id) + } + + pub fn right_expected(&self, syntax_id: LuaSyntaxId) -> Option<&TokenSpacingExpected> { + self.right_expected.get(&syntax_id) + } + + pub fn add_token_replace(&mut self, syntax_id: LuaSyntaxId, replacement: String) { + self.replace_tokens.insert(syntax_id, replacement); + } + + pub fn token_replace(&self, syntax_id: LuaSyntaxId) -> Option<&str> { + self.replace_tokens.get(&syntax_id).map(String::as_str) + } +} + +#[derive(Clone, Debug)] +pub struct SyntaxNodeLayoutPlan { + pub syntax_id: LuaSyntaxId, + pub kind: LuaSyntaxKind, + pub children: Vec, +} + +#[derive(Clone, Debug)] +pub struct CommentLayoutPlan { + pub syntax_id: LuaSyntaxId, +} + +#[derive(Clone, Debug)] +pub enum LayoutNodePlan { + Syntax(SyntaxNodeLayoutPlan), + Comment(CommentLayoutPlan), +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct StatementTriviaLayoutPlan { + pub has_inline_comment: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ControlHeaderLayoutPlan { + pub has_inline_comment: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StatementExprListLayoutKind { + Sequence, + PreserveFirstMultiline, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StatementExprListLayoutPlan { + pub kind: StatementExprListLayoutKind, + pub first_line_prefix_width: usize, + pub attach_single_value_head: bool, + pub allow_fill: bool, + pub allow_packed: bool, + pub allow_one_per_line: bool, + pub prefer_balanced_break_lines: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ExprSequenceLayoutPlan { + pub first_line_prefix_width: usize, + pub preserve_multiline: bool, +} + +#[derive(Clone, Debug, Default)] +pub struct RootLayoutModel { + pub format_block_with_legacy: bool, + pub root_nodes: Vec, + pub statement_trivia: HashMap, + pub statement_expr_lists: HashMap, + pub expr_sequences: HashMap, + pub control_headers: HashMap, + pub control_header_expr_lists: HashMap, +} + +#[derive(Clone, Debug, Default)] +pub struct RootLineBreakModel { + pub insert_final_newline: bool, +} + +#[derive(Clone, Debug, Default)] +pub struct RootFormatPlan { + pub spacing: RootSpacingModel, + pub layout: RootLayoutModel, + pub line_breaks: RootLineBreakModel, +} + +impl RootFormatPlan { + pub fn from_config(config: &LuaFormatConfig) -> Self { + Self { + spacing: RootSpacingModel::default(), + layout: RootLayoutModel { + format_block_with_legacy: true, + root_nodes: Vec::new(), + statement_trivia: HashMap::new(), + statement_expr_lists: HashMap::new(), + expr_sequences: HashMap::new(), + control_headers: HashMap::new(), + control_header_expr_lists: HashMap::new(), + }, + line_breaks: RootLineBreakModel { + insert_final_newline: config.output.insert_final_newline, + }, + } + } +} diff --git a/crates/emmylua_formatter/src/formatter_new/render.rs b/crates/emmylua_formatter/src/formatter_new/render.rs new file mode 100644 index 000000000..d3d7aa620 --- /dev/null +++ b/crates/emmylua_formatter/src/formatter_new/render.rs @@ -0,0 +1,1796 @@ +use emmylua_parser::{ + LuaAssignStat, LuaAstNode, LuaAstToken, LuaChunk, LuaComment, LuaExpr, LuaForRangeStat, + LuaForStat, LuaIfStat, LuaKind, LuaLocalName, LuaLocalStat, LuaRepeatStat, LuaReturnStat, + LuaStat, LuaSyntaxId, LuaSyntaxKind, LuaSyntaxNode, LuaSyntaxToken, LuaTokenKind, LuaVarExpr, + LuaWhileStat, +}; + +use crate::ir::{self, DocIR}; + +use super::FormatContext; +use super::expr; +use super::model::{LayoutNodePlan, RootFormatPlan, SyntaxNodeLayoutPlan, TokenSpacingExpected}; +use super::sequence::{ + SequenceComment, SequenceEntry, SequenceLayoutCandidates, SequenceLayoutPolicy, + choose_sequence_layout, render_sequence, sequence_ends_with_comment, sequence_has_comment, + sequence_starts_with_inline_comment, +}; +use super::trivia::{ + count_blank_lines_before, has_non_trivia_before_on_same_line_tokenwise, + node_has_direct_comment_child, +}; + +pub fn render_root(ctx: &FormatContext, chunk: &LuaChunk, plan: &RootFormatPlan) -> Vec { + let mut docs = Vec::new(); + + if plan.spacing.has_shebang + && let Some(first_token) = chunk.syntax().first_token() + { + docs.push(ir::text(first_token.text().to_string())); + docs.push(DocIR::HardLine); + } + + if !plan.layout.root_nodes.is_empty() { + docs.extend(render_layout_nodes( + ctx, + chunk.syntax(), + &plan.layout.root_nodes, + plan, + false, + )); + } + + if plan.line_breaks.insert_final_newline { + docs.push(DocIR::HardLine); + } + + docs +} + +fn render_layout_nodes( + ctx: &FormatContext, + root: &LuaSyntaxNode, + nodes: &[LayoutNodePlan], + plan: &RootFormatPlan, + inside_block: bool, +) -> Vec { + let mut docs = Vec::new(); + + for (index, node) in nodes.iter().enumerate() { + if inside_block && index > 0 { + let blank_lines = count_blank_lines_before_layout_node(root, node) + .min(ctx.config.layout.max_blank_lines); + docs.push(ir::hard_line()); + for _ in 0..blank_lines { + docs.push(ir::hard_line()); + } + } + + docs.extend(render_layout_node(ctx, root, node, plan)); + } + + docs +} + +fn render_layout_node( + ctx: &FormatContext, + root: &LuaSyntaxNode, + node: &LayoutNodePlan, + plan: &RootFormatPlan, +) -> Vec { + match node { + LayoutNodePlan::Comment(comment) => { + let Some(syntax) = find_node_by_id(root, comment.syntax_id) else { + return Vec::new(); + }; + let Some(comment) = LuaComment::cast(syntax) else { + return Vec::new(); + }; + render_comment_with_spacing(ctx, &comment, plan) + } + LayoutNodePlan::Syntax(syntax_plan) => match syntax_plan.kind { + LuaSyntaxKind::Block => { + render_layout_nodes(ctx, root, &syntax_plan.children, plan, true) + } + LuaSyntaxKind::LocalStat => { + render_local_stat_new(ctx, root, syntax_plan.syntax_id, plan) + } + LuaSyntaxKind::AssignStat => { + render_assign_stat_new(ctx, root, syntax_plan.syntax_id, plan) + } + LuaSyntaxKind::ReturnStat => { + render_return_stat_new(ctx, root, syntax_plan.syntax_id, plan) + } + LuaSyntaxKind::WhileStat => render_while_stat_new(ctx, root, syntax_plan, plan), + LuaSyntaxKind::ForStat => render_for_stat_new(ctx, root, syntax_plan, plan), + LuaSyntaxKind::ForRangeStat => render_for_range_stat_new(ctx, root, syntax_plan, plan), + LuaSyntaxKind::RepeatStat => render_repeat_stat_new(ctx, root, syntax_plan, plan), + LuaSyntaxKind::IfStat => render_if_stat_new(ctx, root, syntax_plan, plan), + _ => render_unmigrated_syntax_leaf(root, syntax_plan.syntax_id), + }, + } +} + +struct StatementAssignSplit { + lhs_entries: Vec, + assign_op: Option, + rhs_entries: Vec, +} + +fn render_local_stat_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + syntax_id: LuaSyntaxId, + plan: &RootFormatPlan, +) -> Vec { + let Some(node) = find_node_by_id(root, syntax_id) else { + return Vec::new(); + }; + let Some(stat) = LuaLocalStat::cast(node) else { + return Vec::new(); + }; + + if node_has_direct_comment_child(stat.syntax()) { + return format_local_stat_trivia_aware_new(ctx, plan, &stat); + } + + let local_token = first_direct_token(stat.syntax(), LuaTokenKind::TkLocal); + let comma_token = first_direct_token(stat.syntax(), LuaTokenKind::TkComma); + let assign_token = first_direct_token(stat.syntax(), LuaTokenKind::TkAssign); + let mut docs = vec![token_or_kind_doc( + local_token.as_ref(), + LuaTokenKind::TkLocal, + )]; + docs.extend(token_right_spacing_docs(plan, local_token.as_ref())); + let local_names: Vec<_> = stat.get_local_name_list().collect(); + for (index, local_name) in local_names.iter().enumerate() { + if index > 0 { + docs.extend(comma_flat_separator(plan, comma_token.as_ref())); + } + docs.extend(format_local_name_ir_new(local_name)); + } + + let exprs: Vec<_> = stat.get_value_exprs().collect(); + if !exprs.is_empty() { + let expr_list_plan = plan + .layout + .statement_expr_lists + .get(&syntax_id) + .copied() + .expect("missing local statement expr-list layout plan"); + docs.extend(token_left_spacing_docs(plan, assign_token.as_ref())); + docs.push(token_or_kind_doc( + assign_token.as_ref(), + LuaTokenKind::TkAssign, + )); + + let expr_docs: Vec> = exprs + .iter() + .enumerate() + .map(|(index, expr)| { + format_statement_value_expr_new( + ctx, + plan, + expr, + index == 0 + && matches!( + expr_list_plan.kind, + super::model::StatementExprListLayoutKind::PreserveFirstMultiline + ), + ) + }) + .collect(); + + docs.extend(render_statement_exprs_new( + ctx, + plan, + expr_list_plan, + assign_token.as_ref(), + comma_token.as_ref(), + expr_docs, + )); + } + + docs +} + +fn render_assign_stat_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + syntax_id: LuaSyntaxId, + plan: &RootFormatPlan, +) -> Vec { + let Some(node) = find_node_by_id(root, syntax_id) else { + return Vec::new(); + }; + let Some(stat) = LuaAssignStat::cast(node) else { + return Vec::new(); + }; + + if node_has_direct_comment_child(stat.syntax()) { + return format_assign_stat_trivia_aware_new(ctx, plan, &stat); + } + + let mut docs = Vec::new(); + let (vars, exprs) = stat.get_var_and_expr_list(); + let expr_list_plan = plan + .layout + .statement_expr_lists + .get(&syntax_id) + .copied() + .expect("missing assign statement expr-list layout plan"); + let comma_token = first_direct_token(stat.syntax(), LuaTokenKind::TkComma); + let assign_token = stat.get_assign_op().map(|op| op.syntax().clone()); + let var_docs: Vec> = vars + .iter() + .map(|var| render_expr_new(ctx, plan, &var.clone().into())) + .collect(); + docs.extend(ir::intersperse( + var_docs, + comma_flat_separator(plan, comma_token.as_ref()), + )); + + if let Some(op) = stat.get_assign_op() { + docs.extend(token_left_spacing_docs(plan, assign_token.as_ref())); + docs.push(ir::source_token(op.syntax().clone())); + } + + let expr_docs: Vec> = exprs + .iter() + .enumerate() + .map(|(index, expr)| { + format_statement_value_expr_new( + ctx, + plan, + expr, + index == 0 + && matches!( + expr_list_plan.kind, + super::model::StatementExprListLayoutKind::PreserveFirstMultiline + ), + ) + }) + .collect(); + + docs.extend(render_statement_exprs_new( + ctx, + plan, + expr_list_plan, + assign_token.as_ref(), + comma_token.as_ref(), + expr_docs, + )); + + docs +} + +fn render_return_stat_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + syntax_id: LuaSyntaxId, + plan: &RootFormatPlan, +) -> Vec { + let Some(node) = find_node_by_id(root, syntax_id) else { + return Vec::new(); + }; + let Some(stat) = LuaReturnStat::cast(node) else { + return Vec::new(); + }; + + if node_has_direct_comment_child(stat.syntax()) { + return format_return_stat_trivia_aware_new(ctx, plan, &stat); + } + + let return_token = first_direct_token(stat.syntax(), LuaTokenKind::TkReturn); + let comma_token = first_direct_token(stat.syntax(), LuaTokenKind::TkComma); + let mut docs = vec![token_or_kind_doc( + return_token.as_ref(), + LuaTokenKind::TkReturn, + )]; + let exprs: Vec<_> = stat.get_expr_list().collect(); + if !exprs.is_empty() { + let expr_list_plan = plan + .layout + .statement_expr_lists + .get(&syntax_id) + .copied() + .expect("missing return statement expr-list layout plan"); + let expr_docs: Vec> = exprs + .iter() + .enumerate() + .map(|(index, expr)| { + format_statement_value_expr_new( + ctx, + plan, + expr, + index == 0 + && matches!( + expr_list_plan.kind, + super::model::StatementExprListLayoutKind::PreserveFirstMultiline + ), + ) + }) + .collect(); + + docs.extend(render_statement_exprs_new( + ctx, + plan, + expr_list_plan, + return_token.as_ref(), + comma_token.as_ref(), + expr_docs, + )); + } + + docs +} + +fn render_while_stat_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + syntax_plan: &SyntaxNodeLayoutPlan, + plan: &RootFormatPlan, +) -> Vec { + let Some(node) = find_node_by_id(root, syntax_plan.syntax_id) else { + return Vec::new(); + }; + let Some(stat) = LuaWhileStat::cast(node) else { + return Vec::new(); + }; + + let while_token = first_direct_token(stat.syntax(), LuaTokenKind::TkWhile); + let do_token = first_direct_token(stat.syntax(), LuaTokenKind::TkDo); + let mut docs = vec![token_or_kind_doc( + while_token.as_ref(), + LuaTokenKind::TkWhile, + )]; + + if node_has_direct_comment_child(stat.syntax()) { + let entries = collect_while_stat_entries_new(ctx, plan, &stat); + if sequence_has_comment(&entries) { + docs.extend(token_right_spacing_docs(plan, while_token.as_ref())); + render_sequence(&mut docs, &entries, false); + if !sequence_ends_with_comment(&entries) { + docs.push(ir::hard_line()); + } + docs.push(token_or_kind_doc(do_token.as_ref(), LuaTokenKind::TkDo)); + } else { + docs.extend(token_right_spacing_docs(plan, while_token.as_ref())); + render_sequence(&mut docs, &entries, false); + docs.extend(token_left_spacing_docs(plan, do_token.as_ref())); + docs.push(token_or_kind_doc(do_token.as_ref(), LuaTokenKind::TkDo)); + } + } else { + docs.extend(token_right_spacing_docs(plan, while_token.as_ref())); + if let Some(cond) = stat.get_condition_expr() { + docs.extend(render_expr_new(ctx, plan, &cond)); + } + docs.extend(token_left_spacing_docs(plan, do_token.as_ref())); + docs.push(token_or_kind_doc(do_token.as_ref(), LuaTokenKind::TkDo)); + } + + docs.extend(render_control_body_end_new( + ctx, + root, + syntax_plan, + plan, + LuaTokenKind::TkEnd, + )); + docs +} + +fn render_for_stat_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + syntax_plan: &SyntaxNodeLayoutPlan, + plan: &RootFormatPlan, +) -> Vec { + let Some(node) = find_node_by_id(root, syntax_plan.syntax_id) else { + return Vec::new(); + }; + let Some(stat) = LuaForStat::cast(node) else { + return Vec::new(); + }; + + let for_token = first_direct_token(stat.syntax(), LuaTokenKind::TkFor); + let assign_token = first_direct_token(stat.syntax(), LuaTokenKind::TkAssign); + let comma_token = first_direct_token(stat.syntax(), LuaTokenKind::TkComma); + let do_token = first_direct_token(stat.syntax(), LuaTokenKind::TkDo); + let mut docs = vec![token_or_kind_doc(for_token.as_ref(), LuaTokenKind::TkFor)]; + + if node_has_direct_comment_child(stat.syntax()) { + return vec![ir::source_node_trimmed(stat.syntax().clone())]; + } else { + docs.extend(token_right_spacing_docs(plan, for_token.as_ref())); + if let Some(var_name) = stat.get_var_name() { + docs.push(ir::source_token(var_name.syntax().clone())); + } + docs.extend(token_left_spacing_docs(plan, assign_token.as_ref())); + docs.push(token_or_kind_doc( + assign_token.as_ref(), + LuaTokenKind::TkAssign, + )); + + let iter_exprs: Vec<_> = stat.get_iter_expr().collect(); + let expr_list_plan = plan + .layout + .control_header_expr_lists + .get(&syntax_plan.syntax_id) + .copied() + .expect("missing for header expr-list layout plan"); + let expr_docs: Vec> = iter_exprs + .iter() + .enumerate() + .map(|(index, expr)| { + format_statement_value_expr_new( + ctx, + plan, + expr, + index == 0 + && matches!( + expr_list_plan.kind, + super::model::StatementExprListLayoutKind::PreserveFirstMultiline + ), + ) + }) + .collect(); + docs.extend(render_header_exprs_new( + ctx, + plan, + expr_list_plan, + assign_token.as_ref(), + comma_token.as_ref(), + expr_docs, + )); + docs.extend(token_left_spacing_docs(plan, do_token.as_ref())); + docs.push(token_or_kind_doc(do_token.as_ref(), LuaTokenKind::TkDo)); + } + + docs.extend(render_control_body_end_new( + ctx, + root, + syntax_plan, + plan, + LuaTokenKind::TkEnd, + )); + docs +} + +fn render_for_range_stat_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + syntax_plan: &SyntaxNodeLayoutPlan, + plan: &RootFormatPlan, +) -> Vec { + let Some(node) = find_node_by_id(root, syntax_plan.syntax_id) else { + return Vec::new(); + }; + let Some(stat) = LuaForRangeStat::cast(node) else { + return Vec::new(); + }; + + let for_token = first_direct_token(stat.syntax(), LuaTokenKind::TkFor); + let in_token = first_direct_token(stat.syntax(), LuaTokenKind::TkIn); + let comma_token = first_direct_token(stat.syntax(), LuaTokenKind::TkComma); + let do_token = first_direct_token(stat.syntax(), LuaTokenKind::TkDo); + let mut docs = vec![token_or_kind_doc(for_token.as_ref(), LuaTokenKind::TkFor)]; + + if node_has_direct_comment_child(stat.syntax()) { + return vec![ir::source_node_trimmed(stat.syntax().clone())]; + } else { + docs.extend(token_right_spacing_docs(plan, for_token.as_ref())); + let var_names: Vec<_> = stat.get_var_name_list().collect(); + for (index, name) in var_names.iter().enumerate() { + if index > 0 { + docs.extend(comma_flat_separator(plan, comma_token.as_ref())); + } + docs.push(ir::source_token(name.syntax().clone())); + } + docs.extend(token_left_spacing_docs(plan, in_token.as_ref())); + docs.push(token_or_kind_doc(in_token.as_ref(), LuaTokenKind::TkIn)); + + let exprs: Vec<_> = stat.get_expr_list().collect(); + let expr_list_plan = plan + .layout + .control_header_expr_lists + .get(&syntax_plan.syntax_id) + .copied() + .expect("missing for-range header expr-list layout plan"); + let expr_docs: Vec> = exprs + .iter() + .enumerate() + .map(|(index, expr)| { + format_statement_value_expr_new( + ctx, + plan, + expr, + index == 0 + && matches!( + expr_list_plan.kind, + super::model::StatementExprListLayoutKind::PreserveFirstMultiline + ), + ) + }) + .collect(); + docs.extend(render_header_exprs_new( + ctx, + plan, + expr_list_plan, + in_token.as_ref(), + comma_token.as_ref(), + expr_docs, + )); + docs.extend(token_left_spacing_docs(plan, do_token.as_ref())); + docs.push(token_or_kind_doc(do_token.as_ref(), LuaTokenKind::TkDo)); + } + + docs.extend(render_control_body_end_new( + ctx, + root, + syntax_plan, + plan, + LuaTokenKind::TkEnd, + )); + docs +} + +fn render_repeat_stat_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + syntax_plan: &SyntaxNodeLayoutPlan, + plan: &RootFormatPlan, +) -> Vec { + let Some(node) = find_node_by_id(root, syntax_plan.syntax_id) else { + return Vec::new(); + }; + let Some(stat) = LuaRepeatStat::cast(node) else { + return Vec::new(); + }; + + let repeat_token = first_direct_token(stat.syntax(), LuaTokenKind::TkRepeat); + let until_token = first_direct_token(stat.syntax(), LuaTokenKind::TkUntil); + let has_inline_comment = plan + .layout + .control_headers + .get(&syntax_plan.syntax_id) + .is_some_and(|layout| layout.has_inline_comment); + let mut docs = vec![token_or_kind_doc( + repeat_token.as_ref(), + LuaTokenKind::TkRepeat, + )]; + + docs.extend(render_control_body_new(ctx, root, syntax_plan, plan)); + docs.push(token_or_kind_doc( + until_token.as_ref(), + LuaTokenKind::TkUntil, + )); + + if node_has_direct_comment_child(stat.syntax()) { + let entries = collect_repeat_stat_entries_new(ctx, plan, &stat); + let tail = render_trivia_aware_sequence_tail_new( + plan, + token_right_spacing_docs(plan, until_token.as_ref()), + &entries, + ); + if has_inline_comment { + docs.push(ir::indent(tail)); + } else { + docs.extend(tail); + } + } else if let Some(cond) = stat.get_condition_expr() { + docs.extend(token_right_spacing_docs(plan, until_token.as_ref())); + docs.extend(render_expr_new(ctx, plan, &cond)); + } + + docs +} + +fn render_if_stat_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + syntax_plan: &SyntaxNodeLayoutPlan, + plan: &RootFormatPlan, +) -> Vec { + let Some(node) = find_node_by_id(root, syntax_plan.syntax_id) else { + return Vec::new(); + }; + let Some(stat) = LuaIfStat::cast(node) else { + return Vec::new(); + }; + + if let Some(preserved) = try_preserve_single_line_if_body_new(ctx, &stat) { + return preserved; + } + + if should_preserve_raw_if_stat_new(&stat) { + return vec![ir::source_node_trimmed(stat.syntax().clone())]; + } + + let if_token = first_direct_token(stat.syntax(), LuaTokenKind::TkIf); + let then_token = first_direct_token(stat.syntax(), LuaTokenKind::TkThen); + let mut docs = vec![token_or_kind_doc(if_token.as_ref(), LuaTokenKind::TkIf)]; + docs.extend(token_right_spacing_docs(plan, if_token.as_ref())); + if let Some(cond) = stat.get_condition_expr() { + docs.extend(render_expr_new(ctx, plan, &cond)); + } + docs.extend(token_left_spacing_docs(plan, then_token.as_ref())); + docs.push(token_or_kind_doc(then_token.as_ref(), LuaTokenKind::TkThen)); + docs.extend(render_block_from_parent_plan_new( + ctx, + root, + syntax_plan, + plan, + )); + + let else_if_plans: Vec<_> = syntax_plan + .children + .iter() + .filter_map(|child| match child { + LayoutNodePlan::Syntax(plan) if plan.kind == LuaSyntaxKind::ElseIfClauseStat => { + Some(plan) + } + _ => None, + }) + .collect(); + for (clause, clause_plan) in stat.get_else_if_clause_list().zip(else_if_plans) { + let else_if_token = first_direct_token(clause.syntax(), LuaTokenKind::TkElseIf); + let then_token = first_direct_token(clause.syntax(), LuaTokenKind::TkThen); + docs.push(token_or_kind_doc( + else_if_token.as_ref(), + LuaTokenKind::TkElseIf, + )); + docs.extend(token_right_spacing_docs(plan, else_if_token.as_ref())); + if let Some(cond) = clause.get_condition_expr() { + docs.extend(render_expr_new(ctx, plan, &cond)); + } + docs.extend(token_left_spacing_docs(plan, then_token.as_ref())); + docs.push(token_or_kind_doc(then_token.as_ref(), LuaTokenKind::TkThen)); + docs.extend(render_block_from_parent_plan_new( + ctx, + root, + clause_plan, + plan, + )); + } + + if let Some(else_clause) = stat.get_else_clause() { + let else_token = first_direct_token(else_clause.syntax(), LuaTokenKind::TkElse); + docs.push(token_or_kind_doc(else_token.as_ref(), LuaTokenKind::TkElse)); + if let Some(else_plan) = + find_direct_child_plan_by_kind(syntax_plan, LuaSyntaxKind::ElseClauseStat) + { + docs.extend(render_block_from_parent_plan_new( + ctx, root, else_plan, plan, + )); + } else { + docs.push(ir::hard_line()); + } + } + + docs.push(ir::syntax_token(LuaTokenKind::TkEnd)); + docs +} + +fn format_local_stat_trivia_aware_new( + ctx: &FormatContext, + plan: &RootFormatPlan, + stat: &LuaLocalStat, +) -> Vec { + let StatementAssignSplit { + lhs_entries, + assign_op, + rhs_entries, + } = collect_local_stat_entries_new(ctx, plan, stat); + let syntax_id = LuaSyntaxId::from_node(stat.syntax()); + let local_token = first_direct_token(stat.syntax(), LuaTokenKind::TkLocal); + let mut docs = vec![token_or_kind_doc( + local_token.as_ref(), + LuaTokenKind::TkLocal, + )]; + let has_inline_comment = plan + .layout + .statement_trivia + .get(&syntax_id) + .is_some_and(|layout| layout.has_inline_comment); + + if has_inline_comment { + docs.push(ir::indent(render_trivia_aware_split_sequence_tail_new( + plan, + token_right_spacing_docs(plan, local_token.as_ref()), + &lhs_entries, + assign_op.as_ref(), + &rhs_entries, + ))); + return docs; + } + + if !lhs_entries.is_empty() { + docs.extend(token_right_spacing_docs(plan, local_token.as_ref())); + 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(ir::source_token(assign_op.clone())); + } else { + docs.extend(token_left_spacing_docs(plan, Some(&assign_op))); + docs.push(ir::source_token(assign_op.clone())); + } + + 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.extend(token_right_spacing_docs(plan, Some(&assign_op))); + render_sequence(&mut docs, &rhs_entries, false); + } + } + } + + docs +} + +fn format_assign_stat_trivia_aware_new( + ctx: &FormatContext, + plan: &RootFormatPlan, + stat: &LuaAssignStat, +) -> Vec { + let StatementAssignSplit { + lhs_entries, + assign_op, + rhs_entries, + } = collect_assign_stat_entries_new(ctx, plan, stat); + let syntax_id = LuaSyntaxId::from_node(stat.syntax()); + let has_inline_comment = plan + .layout + .statement_trivia + .get(&syntax_id) + .is_some_and(|layout| layout.has_inline_comment); + + if has_inline_comment { + return vec![ir::indent(render_trivia_aware_split_sequence_tail_new( + plan, + Vec::new(), + &lhs_entries, + assign_op.as_ref(), + &rhs_entries, + ))]; + } + 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(ir::source_token(assign_op.clone())); + } else { + docs.extend(token_left_spacing_docs(plan, Some(&assign_op))); + docs.push(ir::source_token(assign_op.clone())); + } + + 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.extend(token_right_spacing_docs(plan, Some(&assign_op))); + render_sequence(&mut docs, &rhs_entries, false); + } + } + } + + docs +} + +fn format_return_stat_trivia_aware_new( + ctx: &FormatContext, + plan: &RootFormatPlan, + stat: &LuaReturnStat, +) -> Vec { + let entries = collect_return_stat_entries_new(ctx, plan, stat); + let syntax_id = LuaSyntaxId::from_node(stat.syntax()); + let return_token = first_direct_token(stat.syntax(), LuaTokenKind::TkReturn); + let mut docs = vec![token_or_kind_doc( + return_token.as_ref(), + LuaTokenKind::TkReturn, + )]; + let has_inline_comment = plan + .layout + .statement_trivia + .get(&syntax_id) + .is_some_and(|layout| layout.has_inline_comment); + if entries.is_empty() { + return docs; + } + + if has_inline_comment { + docs.push(ir::indent(render_trivia_aware_sequence_tail_new( + plan, + token_right_spacing_docs(plan, return_token.as_ref()), + &entries, + ))); + return docs; + } + + if sequence_has_comment(&entries) { + docs.push(ir::hard_line()); + render_sequence(&mut docs, &entries, true); + } else { + docs.extend(token_right_spacing_docs(plan, return_token.as_ref())); + render_sequence(&mut docs, &entries, false); + } + + docs +} + +fn collect_local_stat_entries_new( + ctx: &FormatContext, + plan: &RootFormatPlan, + 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().cloned(); + } + LuaKind::Token(LuaTokenKind::TkComma) => { + let entry = separator_entry_from_token(plan, child.as_token()); + if meet_assign { + rhs_entries.push(entry); + } else { + lhs_entries.push(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_new(&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(SequenceComment { + docs: vec![ir::source_node_trimmed(comment.syntax().clone())], + inline_after_previous: has_non_trivia_before_on_same_line_tokenwise( + comment.syntax(), + ), + }); + 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(render_expr_new(ctx, plan, &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_new( + ctx: &FormatContext, + plan: &RootFormatPlan, + 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().cloned(); + } + LuaKind::Token(LuaTokenKind::TkComma) => { + let entry = separator_entry_from_token(plan, child.as_token()); + 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(SequenceComment { + docs: vec![ir::source_node_trimmed(comment.syntax().clone())], + inline_after_previous: has_non_trivia_before_on_same_line_tokenwise( + comment.syntax(), + ), + }); + 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(render_expr_new( + ctx, + plan, + &var.into(), + ))); + } + } else if let Some(expr) = LuaExpr::cast(node.clone()) { + rhs_entries.push(SequenceEntry::Item(render_expr_new(ctx, plan, &expr))); + } + } + } + } + } + + StatementAssignSplit { + lhs_entries, + assign_op, + rhs_entries, + } +} + +fn collect_return_stat_entries_new( + ctx: &FormatContext, + plan: &RootFormatPlan, + 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(separator_entry_from_token(plan, child.as_token())); + } + LuaKind::Syntax(LuaSyntaxKind::Comment) => { + if let Some(node) = child.as_node() + && let Some(comment) = LuaComment::cast(node.clone()) + { + entries.push(SequenceEntry::Comment(SequenceComment { + docs: vec![ir::source_node_trimmed(comment.syntax().clone())], + inline_after_previous: has_non_trivia_before_on_same_line_tokenwise( + comment.syntax(), + ), + })); + } + } + _ => { + if let Some(node) = child.as_node() + && let Some(expr) = LuaExpr::cast(node.clone()) + { + entries.push(SequenceEntry::Item(render_expr_new(ctx, plan, &expr))); + } + } + } + } + entries +} + +fn collect_while_stat_entries_new( + ctx: &FormatContext, + plan: &RootFormatPlan, + 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(SequenceComment { + docs: vec![ir::source_node_trimmed(comment.syntax().clone())], + inline_after_previous: has_non_trivia_before_on_same_line_tokenwise( + comment.syntax(), + ), + })); + } + } + _ => { + if let Some(node) = child.as_node() + && let Some(expr) = LuaExpr::cast(node.clone()) + { + entries.push(SequenceEntry::Item(render_expr_new(ctx, plan, &expr))); + } + } + } + } + entries +} + +fn collect_repeat_stat_entries_new( + ctx: &FormatContext, + plan: &RootFormatPlan, + stat: &LuaRepeatStat, +) -> 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(SequenceComment { + docs: vec![ir::source_node_trimmed(comment.syntax().clone())], + inline_after_previous: has_non_trivia_before_on_same_line_tokenwise( + comment.syntax(), + ), + })); + } + } + _ => { + if let Some(node) = child.as_node() + && let Some(expr) = LuaExpr::cast(node.clone()) + { + entries.push(SequenceEntry::Item(render_expr_new(ctx, plan, &expr))); + } + } + } + } + entries +} + +fn format_local_name_ir_new(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 +} + +fn format_statement_expr_list_new( + ctx: &FormatContext, + plan: &RootFormatPlan, + expr_list_plan: super::model::StatementExprListLayoutPlan, + comma_token: Option<&LuaSyntaxToken>, + 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 fill_parts = + build_statement_expr_fill_parts_new(comma_token, leading_docs.clone(), expr_docs.clone()); + let packed = expr_list_plan.allow_packed.then(|| { + build_statement_expr_packed_new(plan, comma_token, leading_docs.clone(), expr_docs.clone()) + }); + let one_per_line = expr_list_plan + .allow_one_per_line + .then(|| build_statement_expr_one_per_line_new(comma_token, leading_docs, expr_docs)); + + choose_sequence_layout( + ctx, + SequenceLayoutCandidates { + fill: Some(vec![ir::group(vec![ir::indent(vec![ir::fill( + fill_parts, + )])])]), + packed, + one_per_line, + ..Default::default() + }, + SequenceLayoutPolicy { + allow_alignment: false, + allow_fill: expr_list_plan.allow_fill, + allow_preserve: false, + prefer_preserve_multiline: false, + force_break_on_standalone_comments: false, + prefer_balanced_break_lines: expr_list_plan.prefer_balanced_break_lines, + first_line_prefix_width: expr_list_plan.first_line_prefix_width, + }, + ) +} + +fn format_statement_expr_list_with_attached_first_multiline_new( + comma_token: Option<&LuaSyntaxToken>, + leading_docs: Vec, + expr_docs: Vec>, +) -> Vec { + if expr_docs.is_empty() { + return Vec::new(); + } + let mut docs = leading_docs; + let mut iter = expr_docs.into_iter(); + let first_expr = iter.next().unwrap_or_default(); + docs.extend(first_expr); + let remaining: Vec> = iter.collect(); + if remaining.is_empty() { + return docs; + } + docs.extend(comma_token_docs(comma_token)); + let mut tail = Vec::new(); + let remaining_len = remaining.len(); + for (index, expr_doc) in remaining.into_iter().enumerate() { + tail.push(ir::hard_line()); + tail.extend(expr_doc); + if index + 1 < remaining_len { + tail.extend(comma_token_docs(comma_token)); + } + } + docs.push(ir::indent(tail)); + docs +} + +fn render_statement_exprs_new( + ctx: &FormatContext, + plan: &RootFormatPlan, + expr_list_plan: super::model::StatementExprListLayoutPlan, + leading_token: Option<&LuaSyntaxToken>, + comma_token: Option<&LuaSyntaxToken>, + expr_docs: Vec>, +) -> Vec { + if expr_list_plan.attach_single_value_head { + let mut docs = token_right_spacing_docs(plan, leading_token); + docs.push(ir::list(expr_docs.into_iter().next().unwrap_or_default())); + return docs; + } + + let leading_docs = token_right_spacing_docs(plan, leading_token); + if matches!( + expr_list_plan.kind, + super::model::StatementExprListLayoutKind::PreserveFirstMultiline + ) { + format_statement_expr_list_with_attached_first_multiline_new( + comma_token, + leading_docs, + expr_docs, + ) + } else { + format_statement_expr_list_new( + ctx, + plan, + expr_list_plan, + comma_token, + leading_docs, + expr_docs, + ) + } +} + +fn render_header_exprs_new( + ctx: &FormatContext, + plan: &RootFormatPlan, + expr_list_plan: super::model::StatementExprListLayoutPlan, + leading_token: Option<&LuaSyntaxToken>, + comma_token: Option<&LuaSyntaxToken>, + expr_docs: Vec>, +) -> Vec { + let leading_docs = token_right_spacing_docs(plan, leading_token); + if matches!( + expr_list_plan.kind, + super::model::StatementExprListLayoutKind::PreserveFirstMultiline + ) { + format_statement_expr_list_with_attached_first_multiline_new( + comma_token, + leading_docs, + expr_docs, + ) + } else { + format_statement_expr_list_new( + ctx, + plan, + expr_list_plan, + comma_token, + leading_docs, + expr_docs, + ) + } +} + +fn build_statement_expr_fill_parts_new( + comma_token: Option<&LuaSyntaxToken>, + 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; + 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(comma_fill_separator(comma_token))); + parts.push(ir::list(expr_doc)); + } + parts +} + +fn build_statement_expr_one_per_line_new( + comma_token: Option<&LuaSyntaxToken>, + leading_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(comma_token_docs(comma_token))); + docs.push(ir::hard_line()); + docs.push(ir::list(expr_doc)); + } + vec![ir::group_break(vec![ir::indent(docs)])] +} + +fn build_statement_expr_packed_new( + plan: &RootFormatPlan, + comma_token: Option<&LuaSyntaxToken>, + 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.extend(comma_token_docs(comma_token)); + } + 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.extend(token_right_spacing_docs(plan, comma_token)); + } + line.extend(expr_doc.clone()); + if *has_more { + line.extend(comma_token_docs(comma_token)); + } + } + docs.push(ir::hard_line()); + docs.push(ir::list(line)); + } + vec![ir::group_break(vec![ir::indent(docs)])] +} + +fn is_block_like_expr_new(expr: &LuaExpr) -> bool { + matches!(expr, LuaExpr::ClosureExpr(_) | LuaExpr::TableExpr(_)) +} + +fn try_preserve_single_line_if_body_new( + ctx: &FormatContext, + stat: &LuaIfStat, +) -> Option> { + if stat.syntax().text().contains_char('\n') { + return None; + } + + let text_len: u32 = stat.syntax().text().len().into(); + 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; + } + + 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_new(&only_stat) { + return None; + } + + Some(vec![ir::source_node(stat.syntax().clone())]) +} + +fn is_simple_single_line_if_body_new(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_new(expr)) + } + LuaStat::AssignStat(assign) => { + let (_, exprs) = assign.get_var_and_expr_list(); + exprs.len() <= 1 && exprs.iter().all(|expr| !is_block_like_expr_new(expr)) + } + _ => false, + } +} + +fn should_preserve_raw_if_stat_new(stat: &LuaIfStat) -> bool { + if node_has_direct_comment_child(stat.syntax()) { + return true; + } + + if stat + .get_else_if_clause_list() + .clone() + .any(|clause| node_has_direct_comment_child(clause.syntax())) + { + return true; + } + + if stat + .get_else_clause() + .is_some_and(|clause| node_has_direct_comment_child(clause.syntax())) + { + return true; + } + + stat.get_else_if_clause_list().next().is_some() + && syntax_has_descendant_comment_new(stat.syntax()) +} + +fn syntax_has_descendant_comment_new(syntax: &LuaSyntaxNode) -> bool { + syntax + .descendants() + .any(|node| node.kind() == LuaKind::Syntax(LuaSyntaxKind::Comment)) +} + +fn format_statement_value_expr_new( + ctx: &FormatContext, + plan: &RootFormatPlan, + expr: &LuaExpr, + preserve_first_multiline: bool, +) -> Vec { + if preserve_first_multiline { + vec![ir::source_node_trimmed(expr.syntax().clone())] + } else { + render_expr_new(ctx, plan, expr) + } +} + +fn render_unmigrated_syntax_leaf(root: &LuaSyntaxNode, syntax_id: LuaSyntaxId) -> Vec { + let Some(node) = find_node_by_id(root, syntax_id) else { + return Vec::new(); + }; + + vec![ir::source_node_trimmed(node)] +} + +fn render_control_body_end_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + syntax_plan: &SyntaxNodeLayoutPlan, + plan: &RootFormatPlan, + end_kind: LuaTokenKind, +) -> Vec { + let mut docs = render_control_body_new(ctx, root, syntax_plan, plan); + docs.push(ir::syntax_token(end_kind)); + docs +} + +fn render_control_body_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + syntax_plan: &SyntaxNodeLayoutPlan, + plan: &RootFormatPlan, +) -> Vec { + let block_children = syntax_plan.children.iter().find_map(|child| match child { + LayoutNodePlan::Syntax(block) if block.kind == LuaSyntaxKind::Block => { + Some(block.children.as_slice()) + } + _ => None, + }); + + render_block_children_new(ctx, root, block_children, plan) +} + +fn render_block_from_parent_plan_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + syntax_plan: &SyntaxNodeLayoutPlan, + plan: &RootFormatPlan, +) -> Vec { + let block_children = syntax_plan.children.iter().find_map(|child| match child { + LayoutNodePlan::Syntax(block) if block.kind == LuaSyntaxKind::Block => { + Some(block.children.as_slice()) + } + _ => None, + }); + + render_block_children_new(ctx, root, block_children, plan) +} + +fn render_block_children_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + block_children: Option<&[LayoutNodePlan]>, + plan: &RootFormatPlan, +) -> Vec { + let mut docs = Vec::new(); + + if let Some(children) = block_children { + if !children.is_empty() { + let mut body = vec![ir::hard_line()]; + body.extend(render_layout_nodes(ctx, root, children, plan, true)); + docs.push(ir::indent(body)); + docs.push(ir::hard_line()); + } else { + docs.push(ir::hard_line()); + } + } else { + docs.push(ir::hard_line()); + } + docs +} + +fn render_expr_new(_ctx: &FormatContext, plan: &RootFormatPlan, expr: &LuaExpr) -> Vec { + expr::format_expr(_ctx, plan, expr) +} + +fn find_direct_child_plan_by_kind( + syntax_plan: &SyntaxNodeLayoutPlan, + kind: LuaSyntaxKind, +) -> Option<&SyntaxNodeLayoutPlan> { + syntax_plan.children.iter().find_map(|child| match child { + LayoutNodePlan::Syntax(plan) if plan.kind == kind => Some(plan), + _ => None, + }) +} + +fn token_or_kind_doc(token: Option<&LuaSyntaxToken>, fallback_kind: LuaTokenKind) -> DocIR { + token + .map(|token| ir::source_token(token.clone())) + .unwrap_or_else(|| ir::syntax_token(fallback_kind)) +} + +fn first_direct_token(node: &LuaSyntaxNode, kind: LuaTokenKind) -> Option { + node.children_with_tokens().find_map(|element| { + let token = element.into_token()?; + (token.kind().to_token() == kind).then_some(token) + }) +} + +fn token_left_spacing_docs(plan: &RootFormatPlan, token: Option<&LuaSyntaxToken>) -> Vec { + let Some(token) = token else { + return Vec::new(); + }; + spacing_docs_from_expected(plan.spacing.left_expected(LuaSyntaxId::from_token(token))) +} + +fn token_right_spacing_docs(plan: &RootFormatPlan, token: Option<&LuaSyntaxToken>) -> Vec { + let Some(token) = token else { + return Vec::new(); + }; + spacing_docs_from_expected(plan.spacing.right_expected(LuaSyntaxId::from_token(token))) +} + +fn spacing_docs_from_expected(expected: Option<&TokenSpacingExpected>) -> Vec { + match expected { + Some(TokenSpacingExpected::Space(count)) | Some(TokenSpacingExpected::MaxSpace(count)) => { + (0..*count).map(|_| ir::space()).collect() + } + None => Vec::new(), + } +} + +fn comma_token_docs(token: Option<&LuaSyntaxToken>) -> Vec { + vec![token_or_kind_doc(token, LuaTokenKind::TkComma)] +} + +fn comma_flat_separator(plan: &RootFormatPlan, token: Option<&LuaSyntaxToken>) -> Vec { + let mut docs = comma_token_docs(token); + docs.extend(token_right_spacing_docs(plan, token)); + docs +} + +fn comma_fill_separator(token: Option<&LuaSyntaxToken>) -> Vec { + let mut docs = comma_token_docs(token); + docs.push(ir::soft_line()); + docs +} + +fn separator_entry_from_token( + plan: &RootFormatPlan, + token: Option<&LuaSyntaxToken>, +) -> SequenceEntry { + SequenceEntry::Separator { + docs: token + .map(|token| vec![ir::source_token(token.clone())]) + .unwrap_or_else(|| comma_token_docs(None)), + after_docs: token_right_spacing_docs(plan, token), + } +} + +fn render_trivia_aware_sequence_tail_new( + _plan: &RootFormatPlan, + leading_docs: Vec, + entries: &[SequenceEntry], +) -> Vec { + let mut tail = if sequence_starts_with_inline_comment(entries) { + Vec::new() + } else { + leading_docs + }; + if sequence_has_comment(entries) { + if sequence_starts_with_inline_comment(entries) { + render_sequence(&mut tail, entries, false); + } else { + tail.push(ir::hard_line()); + render_sequence(&mut tail, entries, true); + } + } else { + render_sequence(&mut tail, entries, false); + } + tail +} + +fn render_trivia_aware_split_sequence_tail_new( + plan: &RootFormatPlan, + leading_docs: Vec, + lhs_entries: &[SequenceEntry], + split_token: Option<&LuaSyntaxToken>, + rhs_entries: &[SequenceEntry], +) -> Vec { + let mut tail = leading_docs; + if !lhs_entries.is_empty() { + render_sequence(&mut tail, lhs_entries, false); + } + + if let Some(split_token) = split_token { + if sequence_ends_with_comment(lhs_entries) { + tail.push(ir::hard_line()); + tail.push(ir::source_token(split_token.clone())); + } else if sequence_has_comment(lhs_entries) { + tail.push(ir::space()); + tail.push(ir::source_token(split_token.clone())); + } else { + tail.extend(token_left_spacing_docs(plan, Some(split_token))); + tail.push(ir::source_token(split_token.clone())); + } + + if !rhs_entries.is_empty() { + if sequence_has_comment(rhs_entries) { + if sequence_starts_with_inline_comment(rhs_entries) { + render_sequence(&mut tail, rhs_entries, false); + } else { + tail.push(ir::hard_line()); + render_sequence(&mut tail, rhs_entries, true); + } + } else { + tail.extend(token_right_spacing_docs(plan, Some(split_token))); + render_sequence(&mut tail, rhs_entries, false); + } + } + } + + tail +} + +fn render_comment_with_spacing( + _ctx: &FormatContext, + comment: &LuaComment, + plan: &RootFormatPlan, +) -> Vec { + if should_preserve_comment_raw(comment) { + return vec![ir::source_node_trimmed(comment.syntax().clone())]; + } + + let lines = collect_comment_render_lines(comment, plan); + lines + .into_iter() + .enumerate() + .flat_map(|(index, line)| { + let mut docs = Vec::new(); + if index > 0 { + docs.push(ir::hard_line()); + } + if !line.is_empty() { + docs.push(ir::text(line)); + } + docs + }) + .collect() +} + +#[derive(Default)] +struct RenderCommentLine { + tokens: Vec<(LuaSyntaxId, String)>, + gaps: Vec, +} + +fn collect_comment_render_lines(comment: &LuaComment, plan: &RootFormatPlan) -> Vec { + let mut lines = Vec::new(); + let mut current = RenderCommentLine::default(); + let mut pending_gap = String::new(); + let mut ended_with_newline = false; + + for element in comment.syntax().descendants_with_tokens() { + let Some(token) = element.into_token() else { + continue; + }; + + match token.kind().to_token() { + LuaTokenKind::TkWhitespace => pending_gap.push_str(token.text()), + LuaTokenKind::TkEndOfLine => { + apply_comment_spacing_line(plan, &mut current); + lines.push(render_comment_line(current)); + current = RenderCommentLine::default(); + pending_gap.clear(); + ended_with_newline = true; + } + _ => { + let syntax_id = LuaSyntaxId::from_token(&token); + if !current.tokens.is_empty() { + current.gaps.push(std::mem::take(&mut pending_gap)); + } else { + pending_gap.clear(); + } + let text = plan + .spacing + .token_replace(syntax_id) + .map(str::to_string) + .unwrap_or_else(|| token.text().to_string()); + current.tokens.push((syntax_id, text)); + ended_with_newline = false; + } + } + } + + if !current.tokens.is_empty() || ended_with_newline { + apply_comment_spacing_line(plan, &mut current); + lines.push(render_comment_line(current)); + } + + lines +} + +fn apply_comment_spacing_line(plan: &RootFormatPlan, line: &mut RenderCommentLine) { + for index in 0..line.gaps.len() { + let prev_id = line.tokens[index].0; + let token_id = line.tokens[index + 1].0; + line.gaps[index] = resolve_comment_gap(plan, Some(prev_id), token_id, &line.gaps[index]); + } +} + +fn resolve_comment_gap( + plan: &RootFormatPlan, + prev_token_id: Option, + token_id: LuaSyntaxId, + gap: &str, +) -> String { + let mut exact_space = None; + let mut max_space = None; + + if let Some(prev_token_id) = prev_token_id + && let Some(expected) = plan.spacing.right_expected(prev_token_id) + { + match expected { + TokenSpacingExpected::Space(count) => exact_space = Some(*count), + TokenSpacingExpected::MaxSpace(count) => max_space = Some(*count), + } + } + + if let Some(expected) = plan.spacing.left_expected(token_id) { + match expected { + TokenSpacingExpected::Space(count) => { + exact_space = Some(exact_space.map_or(*count, |current| current.max(*count))); + } + TokenSpacingExpected::MaxSpace(count) => { + max_space = Some(max_space.map_or(*count, |current| current.min(*count))); + } + } + } + + if let Some(exact_space) = exact_space { + return " ".repeat(exact_space); + } + if let Some(max_space) = max_space { + let original_space_count = gap.chars().take_while(|ch| *ch == ' ').count(); + return " ".repeat(original_space_count.min(max_space)); + } + + gap.to_string() +} + +fn render_comment_line(line: RenderCommentLine) -> String { + let mut tokens = line.tokens.into_iter(); + let Some((_, first_text)) = tokens.next() else { + return String::new(); + }; + + let mut rendered = first_text; + for (gap, (_, token_text)) in line.gaps.into_iter().zip(tokens) { + rendered.push_str(&gap); + rendered.push_str(&token_text); + } + rendered +} + +fn should_preserve_comment_raw(comment: &LuaComment) -> bool { + let Some(first_token) = comment.syntax().first_token() else { + return false; + }; + + matches!( + first_token.kind().to_token(), + LuaTokenKind::TkLongCommentStart | LuaTokenKind::TkDocLongStart + ) || dash_prefix_len(first_token.text()) > 3 +} + +fn dash_prefix_len(prefix_text: &str) -> usize { + prefix_text.bytes().take_while(|byte| *byte == b'-').count() +} + +fn count_blank_lines_before_layout_node(root: &LuaSyntaxNode, node: &LayoutNodePlan) -> usize { + let syntax_id = match node { + LayoutNodePlan::Comment(comment) => comment.syntax_id, + LayoutNodePlan::Syntax(syntax) => syntax.syntax_id, + }; + let Some(node) = find_node_by_id(root, syntax_id) else { + return 0; + }; + + count_blank_lines_before(&node) +} + +fn find_node_by_id(root: &LuaSyntaxNode, syntax_id: LuaSyntaxId) -> Option { + if LuaSyntaxId::from_node(root) == syntax_id { + return Some(root.clone()); + } + + root.descendants() + .find(|node| LuaSyntaxId::from_node(node) == syntax_id) +} diff --git a/crates/emmylua_formatter/src/formatter_new/sequence.rs b/crates/emmylua_formatter/src/formatter_new/sequence.rs new file mode 100644 index 000000000..dced2f223 --- /dev/null +++ b/crates/emmylua_formatter/src/formatter_new/sequence.rs @@ -0,0 +1,446 @@ +use crate::config::ExpandStrategy; +use crate::ir::{self, DocIR, ir_flat_width, ir_has_forced_line_break}; +use crate::printer::Printer; + +#[derive(Clone)] +pub struct SequenceComment { + pub docs: Vec, + pub inline_after_previous: bool, +} + +use super::FormatContext; + +#[derive(Clone)] +pub enum SequenceEntry { + Item(Vec), + Comment(SequenceComment), + Separator { + docs: Vec, + after_docs: Vec, + }, +} + +pub fn render_sequence(docs: &mut Vec, entries: &[SequenceEntry], mut line_start: bool) { + let mut pending_docs_before_item: Vec = Vec::new(); + + for entry in entries { + match entry { + SequenceEntry::Item(item_docs) => { + if !line_start && !pending_docs_before_item.is_empty() { + docs.extend(pending_docs_before_item.clone()); + } + docs.extend(item_docs.clone()); + line_start = false; + pending_docs_before_item.clear(); + } + SequenceEntry::Comment(comment) => { + if comment.inline_after_previous && !line_start { + let mut suffix = vec![ir::space()]; + suffix.extend(comment.docs.clone()); + docs.push(ir::line_suffix(suffix)); + docs.push(ir::hard_line()); + } else { + if !line_start { + docs.push(ir::hard_line()); + } + docs.extend(comment.docs.clone()); + docs.push(ir::hard_line()); + } + line_start = true; + pending_docs_before_item.clear(); + } + SequenceEntry::Separator { + docs: separator_docs, + after_docs, + } => { + docs.extend(separator_docs.clone()); + line_start = false; + pending_docs_before_item = after_docs.clone(); + } + } + } +} + +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_inline_comment(entries: &[SequenceEntry]) -> bool { + matches!( + entries.first(), + Some(SequenceEntry::Comment(SequenceComment { + inline_after_previous: true, + .. + })) + ) +} + +#[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, +} + +#[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 fn choose_sequence_layout( + ctx: &FormatContext, + candidates: SequenceLayoutCandidates, + policy: SequenceLayoutPolicy, +) -> Vec { + let ordered = ordered_sequence_candidates(candidates, policy); + + if ordered.is_empty() { + return vec![]; + } + + if ordered.len() == 1 { + return ordered + .into_iter() + .next() + .map(|candidate| candidate.docs) + .unwrap_or_default(); + } + + if let Some(flat_candidate) = ordered.first() + && flat_candidate.kind == SequenceLayoutKind::Flat + && !ir_has_forced_line_break(&flat_candidate.docs) + && ir_flat_width(&flat_candidate.docs) + policy.first_line_prefix_width + <= ctx.config.layout.max_line_width + { + return flat_candidate.docs.clone(); + } + + 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( + _ctx: &FormatContext, + 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 flat_doc = build_flat_doc( + &layout.open, + &layout.close, + &layout.flat_open_padding, + flat_inner, + &layout.flat_trailing, + &layout.flat_close_padding, + ); + + match layout.strategy { + ExpandStrategy::Never => flat_doc, + ExpandStrategy::Always => format_expanded_delimited_sequence( + layout.open, + layout.close, + default_break_contents( + ir::intersperse(layout.items, layout.break_separator), + layout.grouped_trailing, + ), + ), + ExpandStrategy::Auto if layout.preserve_multiline => format_expanded_delimited_sequence( + layout.open, + layout.close, + default_break_contents( + ir::intersperse(layout.items, layout.break_separator), + layout.grouped_trailing, + ), + ), + 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_new/spacing.rs b/crates/emmylua_formatter/src/formatter_new/spacing.rs new file mode 100644 index 000000000..3b85592b9 --- /dev/null +++ b/crates/emmylua_formatter/src/formatter_new/spacing.rs @@ -0,0 +1,662 @@ +use crate::config::LuaFormatConfig; +use crate::ir::{self, DocIR}; +use emmylua_parser::{ + BinaryOperator, LuaAstNode, LuaChunk, LuaKind, LuaSyntaxId, LuaSyntaxKind, LuaSyntaxToken, + LuaTokenKind, +}; + +use super::FormatContext; +use super::model::{RootFormatPlan, RootSpacingModel, TokenSpacingExpected}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SpaceRule { + Space, + NoSpace, + SoftLine, + SoftLineOrEmpty, +} + +impl SpaceRule { + pub(crate) 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(), + } + } +} + +pub(crate) fn space_around_binary_op(op: BinaryOperator, config: &LuaFormatConfig) -> SpaceRule { + match op { + BinaryOperator::OpAdd + | BinaryOperator::OpSub + | BinaryOperator::OpMul + | BinaryOperator::OpDiv + | BinaryOperator::OpIDiv + | BinaryOperator::OpMod + | BinaryOperator::OpPow => { + if config.spacing.space_around_math_operator { + SpaceRule::Space + } else { + SpaceRule::NoSpace + } + } + BinaryOperator::OpEq + | BinaryOperator::OpNe + | BinaryOperator::OpLt + | BinaryOperator::OpGt + | BinaryOperator::OpLe + | BinaryOperator::OpGe + | BinaryOperator::OpAnd + | BinaryOperator::OpOr + | BinaryOperator::OpBAnd + | BinaryOperator::OpBOr + | BinaryOperator::OpBXor + | BinaryOperator::OpShl + | BinaryOperator::OpShr + | BinaryOperator::OpNop => SpaceRule::Space, + BinaryOperator::OpConcat => { + if config.spacing.space_around_concat_operator { + SpaceRule::Space + } else { + SpaceRule::NoSpace + } + } + } +} + +pub(crate) fn space_around_assign(config: &LuaFormatConfig) -> SpaceRule { + if config.spacing.space_around_assign_operator { + SpaceRule::Space + } else { + SpaceRule::NoSpace + } +} + +pub fn analyze_root_spacing(ctx: &FormatContext, chunk: &LuaChunk) -> RootFormatPlan { + let mut plan = RootFormatPlan::from_config(ctx.config); + plan.spacing.has_shebang = chunk + .syntax() + .first_token() + .is_some_and(|token| token.kind() == LuaKind::Token(LuaTokenKind::TkShebang)); + + analyze_chunk_token_spacing(ctx, chunk, &mut plan.spacing); + + plan +} + +fn analyze_chunk_token_spacing( + ctx: &FormatContext, + chunk: &LuaChunk, + spacing: &mut RootSpacingModel, +) { + for element in chunk.syntax().descendants_with_tokens() { + let Some(token) = element.into_token() else { + continue; + }; + + if should_skip_spacing_token(&token) { + continue; + } + + analyze_token_spacing(ctx, spacing, &token); + } +} + +fn should_skip_spacing_token(token: &LuaSyntaxToken) -> bool { + matches!( + token.kind().to_token(), + LuaTokenKind::TkWhitespace | LuaTokenKind::TkEndOfLine | LuaTokenKind::TkShebang + ) +} + +fn analyze_token_spacing( + ctx: &FormatContext, + spacing: &mut RootSpacingModel, + token: &LuaSyntaxToken, +) { + let syntax_id = LuaSyntaxId::from_token(token); + match token.kind().to_token() { + LuaTokenKind::TkNormalStart => apply_comment_start_spacing(ctx, spacing, token, syntax_id), + LuaTokenKind::TkDocStart => { + spacing.add_token_replace(syntax_id, normalized_doc_tag_prefix(ctx)); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); + } + LuaTokenKind::TkDocContinue => { + spacing.add_token_replace(syntax_id, normalized_doc_continue_prefix(ctx, token.text())); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); + } + LuaTokenKind::TkDocContinueOr => { + spacing.add_token_replace( + syntax_id, + normalized_doc_continue_or_prefix(ctx, token.text()), + ); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); + } + LuaTokenKind::TkLeftParen => apply_left_paren_spacing(ctx, spacing, token, syntax_id), + LuaTokenKind::TkRightParen => apply_right_paren_spacing(ctx, spacing, token, syntax_id), + LuaTokenKind::TkLeftBracket => apply_left_bracket_spacing(ctx, spacing, token, syntax_id), + LuaTokenKind::TkRightBracket => { + spacing.add_token_left_expected( + syntax_id, + TokenSpacingExpected::Space(space_inside_brackets(token, ctx)), + ); + } + LuaTokenKind::TkLeftBrace => { + spacing.add_token_right_expected( + syntax_id, + TokenSpacingExpected::Space(space_inside_braces(token, ctx)), + ); + } + LuaTokenKind::TkRightBrace => { + spacing.add_token_left_expected( + syntax_id, + TokenSpacingExpected::Space(space_inside_braces(token, ctx)), + ); + } + LuaTokenKind::TkComma => { + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(0)); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(1)); + } + LuaTokenKind::TkSemicolon => { + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(0)); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(1)); + } + LuaTokenKind::TkColon => { + if is_parent_syntax(token, LuaSyntaxKind::IndexExpr) { + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(0)); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); + } else if in_comment(token) { + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::MaxSpace(1)); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::MaxSpace(1)); + } + } + LuaTokenKind::TkDot => { + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(0)); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); + } + LuaTokenKind::TkPlus | LuaTokenKind::TkMinus => { + if is_parent_syntax(token, LuaSyntaxKind::UnaryExpr) { + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); + } else { + apply_space_rule( + spacing, + syntax_id, + space_around_binary_op(binary_op_for_plus_minus(token), ctx.config), + ); + } + } + LuaTokenKind::TkMul + | LuaTokenKind::TkDiv + | LuaTokenKind::TkIDiv + | LuaTokenKind::TkMod + | LuaTokenKind::TkPow + | LuaTokenKind::TkConcat + | LuaTokenKind::TkBitAnd + | LuaTokenKind::TkBitOr + | LuaTokenKind::TkBitXor + | LuaTokenKind::TkShl + | LuaTokenKind::TkShr + | LuaTokenKind::TkEq + | LuaTokenKind::TkGe + | LuaTokenKind::TkGt + | LuaTokenKind::TkLe + | LuaTokenKind::TkLt + | LuaTokenKind::TkNe + | LuaTokenKind::TkAnd + | LuaTokenKind::TkOr => apply_operator_spacing(ctx, spacing, token, syntax_id), + LuaTokenKind::TkAssign => { + apply_space_rule(spacing, syntax_id, space_around_assign(ctx.config)); + } + 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 => { + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(1)); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(1)); + } + _ => {} + } +} + +fn apply_left_paren_spacing( + ctx: &FormatContext, + spacing: &mut RootSpacingModel, + token: &LuaSyntaxToken, + syntax_id: LuaSyntaxId, +) { + let left_space = if is_parent_syntax(token, LuaSyntaxKind::ParamList) { + usize::from(ctx.config.spacing.space_before_func_paren) + } else if is_parent_syntax(token, LuaSyntaxKind::CallArgList) { + usize::from(ctx.config.spacing.space_before_call_paren) + } else if let Some(prev_token) = get_prev_sibling_token_without_space(token) { + match prev_token.kind().to_token() { + LuaTokenKind::TkName + | LuaTokenKind::TkRightParen + | LuaTokenKind::TkRightBracket + | LuaTokenKind::TkFunction => 0, + LuaTokenKind::TkString | LuaTokenKind::TkRightBrace | LuaTokenKind::TkLongString => 1, + _ => 0, + } + } else { + 0 + }; + + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(left_space)); + spacing.add_token_right_expected( + syntax_id, + TokenSpacingExpected::Space(space_inside_parens(token, ctx)), + ); +} + +fn apply_right_paren_spacing( + ctx: &FormatContext, + spacing: &mut RootSpacingModel, + token: &LuaSyntaxToken, + syntax_id: LuaSyntaxId, +) { + spacing.add_token_left_expected( + syntax_id, + TokenSpacingExpected::Space(space_inside_parens(token, ctx)), + ); +} + +fn apply_left_bracket_spacing( + ctx: &FormatContext, + spacing: &mut RootSpacingModel, + token: &LuaSyntaxToken, + syntax_id: LuaSyntaxId, +) { + let left_space = if let Some(prev_token) = get_prev_sibling_token_without_space(token) { + match prev_token.kind().to_token() { + LuaTokenKind::TkName + | LuaTokenKind::TkRightParen + | LuaTokenKind::TkRightBracket + | LuaTokenKind::TkDot + | LuaTokenKind::TkColon => 0, + LuaTokenKind::TkString | LuaTokenKind::TkRightBrace | LuaTokenKind::TkLongString => 1, + _ => 0, + } + } else { + 0 + }; + + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(left_space)); + spacing.add_token_right_expected( + syntax_id, + TokenSpacingExpected::Space(space_inside_brackets(token, ctx)), + ); +} + +fn apply_operator_spacing( + ctx: &FormatContext, + spacing: &mut RootSpacingModel, + token: &LuaSyntaxToken, + syntax_id: LuaSyntaxId, +) { + match token.kind().to_token() { + LuaTokenKind::TkLt | LuaTokenKind::TkGt + if is_parent_syntax(token, LuaSyntaxKind::Attribute) => + { + let (left, right) = if token.kind().to_token() == LuaTokenKind::TkLt { + (1, 0) + } else { + (0, 1) + }; + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(left)); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(right)); + } + _ => { + let Some(rule) = binary_space_rule_for_token(ctx, token) else { + return; + }; + apply_space_rule(spacing, syntax_id, rule); + } + } +} + +fn apply_comment_start_spacing( + ctx: &FormatContext, + spacing: &mut RootSpacingModel, + token: &LuaSyntaxToken, + syntax_id: LuaSyntaxId, +) { + if !in_comment(token) { + return; + } + + if let Some(replacement) = normalized_comment_prefix(ctx, token.text()) { + spacing.add_token_replace(syntax_id, replacement); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); + } +} + +fn binary_space_rule_for_token(ctx: &FormatContext, token: &LuaSyntaxToken) -> Option { + let op = match token.kind().to_token() { + LuaTokenKind::TkPlus => BinaryOperator::OpAdd, + LuaTokenKind::TkMinus => BinaryOperator::OpSub, + LuaTokenKind::TkMul => BinaryOperator::OpMul, + LuaTokenKind::TkDiv => BinaryOperator::OpDiv, + LuaTokenKind::TkIDiv => BinaryOperator::OpIDiv, + LuaTokenKind::TkMod => BinaryOperator::OpMod, + LuaTokenKind::TkPow => BinaryOperator::OpPow, + LuaTokenKind::TkConcat => BinaryOperator::OpConcat, + LuaTokenKind::TkBitAnd => BinaryOperator::OpBAnd, + LuaTokenKind::TkBitOr => BinaryOperator::OpBOr, + LuaTokenKind::TkBitXor => BinaryOperator::OpBXor, + LuaTokenKind::TkShl => BinaryOperator::OpShl, + LuaTokenKind::TkShr => BinaryOperator::OpShr, + LuaTokenKind::TkEq => BinaryOperator::OpEq, + LuaTokenKind::TkGe => BinaryOperator::OpGe, + LuaTokenKind::TkGt => BinaryOperator::OpGt, + LuaTokenKind::TkLe => BinaryOperator::OpLe, + LuaTokenKind::TkLt => BinaryOperator::OpLt, + LuaTokenKind::TkNe => BinaryOperator::OpNe, + LuaTokenKind::TkAnd => BinaryOperator::OpAnd, + LuaTokenKind::TkOr => BinaryOperator::OpOr, + _ => return None, + }; + + Some(space_around_binary_op(op, ctx.config)) +} + +fn binary_op_for_plus_minus(token: &LuaSyntaxToken) -> BinaryOperator { + match token.kind().to_token() { + LuaTokenKind::TkPlus => BinaryOperator::OpAdd, + LuaTokenKind::TkMinus => BinaryOperator::OpSub, + _ => BinaryOperator::OpNop, + } +} + +fn apply_space_rule(spacing: &mut RootSpacingModel, syntax_id: LuaSyntaxId, rule: SpaceRule) { + match rule { + SpaceRule::Space | SpaceRule::SoftLine => { + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(1)); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(1)); + } + SpaceRule::NoSpace | SpaceRule::SoftLineOrEmpty => { + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(0)); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); + } + } +} + +fn space_inside_parens(token: &LuaSyntaxToken, ctx: &FormatContext) -> usize { + if is_parent_syntax(token, LuaSyntaxKind::ParenExpr) { + usize::from(ctx.config.spacing.space_inside_parens) + } else { + 0 + } +} + +fn space_inside_brackets(_token: &LuaSyntaxToken, ctx: &FormatContext) -> usize { + usize::from(ctx.config.spacing.space_inside_brackets) +} + +fn space_inside_braces(_token: &LuaSyntaxToken, ctx: &FormatContext) -> usize { + usize::from(ctx.config.spacing.space_inside_braces) +} + +fn is_parent_syntax(token: &LuaSyntaxToken, kind: LuaSyntaxKind) -> bool { + token + .parent() + .is_some_and(|parent| parent.kind().to_syntax() == kind) +} + +fn in_comment(token: &LuaSyntaxToken) -> bool { + let mut current = token.parent(); + while let Some(node) = current { + if node.kind().to_syntax() == LuaSyntaxKind::Comment { + return true; + } + current = node.parent(); + } + + false +} + +fn get_prev_sibling_token_without_space(token: &LuaSyntaxToken) -> Option { + let mut current = token.clone(); + while let Some(prev) = current.prev_token() { + if !matches!( + prev.kind().to_token(), + LuaTokenKind::TkWhitespace | LuaTokenKind::TkEndOfLine + ) { + return Some(prev); + } + current = prev; + } + + None +} + +fn normalized_comment_prefix(ctx: &FormatContext, prefix_text: &str) -> Option { + match dash_prefix_len(prefix_text) { + 2 => Some(if ctx.config.comments.space_after_comment_dash { + "-- ".to_string() + } else { + "--".to_string() + }), + 3 => Some(if ctx.config.emmy_doc.space_after_description_dash { + "--- ".to_string() + } else { + "---".to_string() + }), + _ => None, + } +} + +fn normalized_doc_tag_prefix(ctx: &FormatContext) -> String { + if ctx.config.emmy_doc.space_after_description_dash { + "--- @".to_string() + } else { + "---@".to_string() + } +} + +fn normalized_doc_continue_prefix(ctx: &FormatContext, prefix_text: &str) -> String { + if prefix_text == "---" || prefix_text == "--- " { + if ctx.config.emmy_doc.space_after_description_dash { + "--- ".to_string() + } else { + "---".to_string() + } + } else { + prefix_text.to_string() + } +} + +fn normalized_doc_continue_or_prefix(ctx: &FormatContext, prefix_text: &str) -> String { + if !prefix_text.starts_with("---") { + return prefix_text.to_string(); + } + + let suffix = prefix_text[3..].trim_start(); + if ctx.config.emmy_doc.space_after_description_dash { + format!("--- {suffix}") + } else { + format!("---{suffix}") + } +} + +fn dash_prefix_len(prefix_text: &str) -> usize { + prefix_text.bytes().take_while(|byte| *byte == b'-').count() +} + +#[cfg(test)] +mod tests { + use emmylua_parser::{LuaLanguageLevel, LuaParser, ParserConfig}; + + use crate::config::LuaFormatConfig; + + use super::*; + + fn analyze(input: &str, config: LuaFormatConfig) -> RootSpacingModel { + let tree = LuaParser::parse(input, ParserConfig::with_level(LuaLanguageLevel::Lua54)); + let chunk = tree.get_chunk_node(); + let ctx = FormatContext::new(&config); + analyze_root_spacing(&ctx, &chunk).spacing + } + + fn find_token(chunk: &LuaChunk, kind: LuaTokenKind) -> LuaSyntaxToken { + chunk + .syntax() + .descendants_with_tokens() + .filter_map(|element| element.into_token()) + .find(|token| token.kind().to_token() == kind) + .unwrap() + } + + #[test] + fn test_spacing_assign_defaults_to_single_spaces() { + let config = LuaFormatConfig::default(); + let tree = LuaParser::parse( + "local x=1\n", + ParserConfig::with_level(LuaLanguageLevel::Lua54), + ); + let chunk = tree.get_chunk_node(); + let spacing = analyze_root_spacing(&FormatContext::new(&config), &chunk).spacing; + let assign = find_token(&chunk, LuaTokenKind::TkAssign); + let assign_id = LuaSyntaxId::from_token(&assign); + + assert_eq!( + spacing.left_expected(assign_id), + Some(&TokenSpacingExpected::Space(1)) + ); + assert_eq!( + spacing.right_expected(assign_id), + Some(&TokenSpacingExpected::Space(1)) + ); + } + + #[test] + fn test_spacing_uses_call_paren_config() { + let config = LuaFormatConfig { + spacing: crate::config::SpacingConfig { + space_before_call_paren: true, + ..Default::default() + }, + ..Default::default() + }; + let tree = LuaParser::parse( + "foo(a)\n", + ParserConfig::with_level(LuaLanguageLevel::Lua54), + ); + let chunk = tree.get_chunk_node(); + let spacing = analyze_root_spacing(&FormatContext::new(&config), &chunk).spacing; + let left_paren = find_token(&chunk, LuaTokenKind::TkLeftParen); + let paren_id = LuaSyntaxId::from_token(&left_paren); + + assert_eq!( + spacing.left_expected(paren_id), + Some(&TokenSpacingExpected::Space(1)) + ); + assert_eq!( + spacing.right_expected(paren_id), + Some(&TokenSpacingExpected::Space(0)) + ); + } + + #[test] + fn test_spacing_respects_paren_expr_inner_space() { + let config = LuaFormatConfig { + spacing: crate::config::SpacingConfig { + space_inside_parens: true, + ..Default::default() + }, + ..Default::default() + }; + let tree = LuaParser::parse( + "local x = (a)\n", + ParserConfig::with_level(LuaLanguageLevel::Lua54), + ); + let chunk = tree.get_chunk_node(); + let spacing = analyze_root_spacing(&FormatContext::new(&config), &chunk).spacing; + let left_paren = find_token(&chunk, LuaTokenKind::TkLeftParen); + let right_paren = find_token(&chunk, LuaTokenKind::TkRightParen); + + assert_eq!( + spacing.right_expected(LuaSyntaxId::from_token(&left_paren)), + Some(&TokenSpacingExpected::Space(1)) + ); + assert_eq!( + spacing.left_expected(LuaSyntaxId::from_token(&right_paren)), + Some(&TokenSpacingExpected::Space(1)) + ); + } + + #[test] + fn test_spacing_respects_math_operator_config() { + let config = LuaFormatConfig { + spacing: crate::config::SpacingConfig { + space_around_math_operator: false, + ..Default::default() + }, + ..Default::default() + }; + let tree = LuaParser::parse( + "local x = a+b\n", + ParserConfig::with_level(LuaLanguageLevel::Lua54), + ); + let chunk = tree.get_chunk_node(); + let spacing = analyze_root_spacing(&FormatContext::new(&config), &chunk).spacing; + let plus = find_token(&chunk, LuaTokenKind::TkPlus); + let plus_id = LuaSyntaxId::from_token(&plus); + + assert_eq!( + spacing.left_expected(plus_id), + Some(&TokenSpacingExpected::Space(0)) + ); + assert_eq!( + spacing.right_expected(plus_id), + Some(&TokenSpacingExpected::Space(0)) + ); + } + + #[test] + fn test_spacing_collects_comment_prefix_replacement() { + let config = LuaFormatConfig::default(); + let tree = LuaParser::parse( + "--hello\n", + ParserConfig::with_level(LuaLanguageLevel::Lua54), + ); + let chunk = tree.get_chunk_node(); + let spacing = analyze_root_spacing(&FormatContext::new(&config), &chunk).spacing; + let start = find_token(&chunk, LuaTokenKind::TkNormalStart); + let start_id = LuaSyntaxId::from_token(&start); + + assert_eq!(spacing.token_replace(start_id), Some("-- ")); + assert_eq!( + spacing.right_expected(start_id), + Some(&TokenSpacingExpected::Space(0)) + ); + } + + #[test] + fn test_spacing_collects_doc_prefix_replacement() { + let config = LuaFormatConfig::default(); + let tree = LuaParser::parse( + "---@param x string\n", + ParserConfig::with_level(LuaLanguageLevel::Lua54), + ); + let chunk = tree.get_chunk_node(); + let spacing = analyze_root_spacing(&FormatContext::new(&config), &chunk).spacing; + let start = find_token(&chunk, LuaTokenKind::TkDocStart); + + assert_eq!( + spacing.token_replace(LuaSyntaxId::from_token(&start)), + Some("--- @") + ); + } +} diff --git a/crates/emmylua_formatter/src/formatter_new/trivia.rs b/crates/emmylua_formatter/src/formatter_new/trivia.rs new file mode 100644 index 000000000..f21cdde24 --- /dev/null +++ b/crates/emmylua_formatter/src/formatter_new/trivia.rs @@ -0,0 +1,71 @@ +use emmylua_parser::{LuaKind, LuaSyntaxKind, LuaSyntaxNode, LuaTokenKind}; + +pub fn count_blank_lines_before(node: &LuaSyntaxNode) -> usize { + let mut blank_lines = 0; + let mut consecutive_newlines = 0; + + 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 => {} + _ => break, + } + token = t.prev_token(); + } + } + + blank_lines +} + +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_tokenwise(node: &LuaSyntaxNode) -> bool { + let Some(first_token) = node.first_token() else { + return false; + }; + + let mut previous = first_token.prev_token(); + while let Some(token) = previous { + match token.kind().to_token() { + LuaTokenKind::TkWhitespace => previous = token.prev_token(), + LuaTokenKind::TkEndOfLine => return false, + _ => return true, + } + } + + false +} + +pub fn source_line_prefix_width(node: &LuaSyntaxNode) -> usize { + let mut width = 0usize; + let Some(mut token) = node.first_token() else { + return 0; + }; + + while let Some(prev) = token.prev_token() { + let text = prev.text(); + let mut chars_since_break = 0usize; + + for ch in text.chars().rev() { + if matches!(ch, '\n' | '\r') { + return width; + } + chars_since_break += 1; + } + + width += chars_since_break; + token = prev; + } + + width +} diff --git a/crates/emmylua_formatter/src/lib.rs b/crates/emmylua_formatter/src/lib.rs index 05ead1543..a757da55f 100644 --- a/crates/emmylua_formatter/src/lib.rs +++ b/crates/emmylua_formatter/src/lib.rs @@ -2,6 +2,8 @@ pub mod cmd_args; pub mod config; mod formatter; +#[allow(dead_code)] +mod formatter_new; pub mod ir; mod printer; mod test; @@ -44,3 +46,20 @@ pub fn reformat_chunk(chunk: &LuaChunk, config: &LuaFormatConfig) -> String { Printer::new(config).print(&ir) } + +pub fn reformat_lua_code_new(source: &SourceText, config: &LuaFormatConfig) -> String { + let tree = LuaParser::parse(source.text, ParserConfig::with_level(source.level)); + + let ctx = formatter_new::FormatContext::new(config); + let chunk = tree.get_chunk_node(); + let ir = formatter_new::format_chunk(&ctx, &chunk); + + Printer::new(config).print(&ir) +} + +pub fn reformat_chunk_new(chunk: &LuaChunk, config: &LuaFormatConfig) -> String { + let ctx = formatter_new::FormatContext::new(config); + let ir = formatter_new::format_chunk(&ctx, chunk); + + Printer::new(config).print(&ir) +} diff --git a/crates/emmylua_formatter/src/test/expression_tests.rs b/crates/emmylua_formatter/src/test/expression_tests.rs index 7746aa305..f65e244d4 100644 --- a/crates/emmylua_formatter/src/test/expression_tests.rs +++ b/crates/emmylua_formatter/src/test/expression_tests.rs @@ -284,6 +284,14 @@ local b = t[1] assert_format!("foo(\na,\n-- tail\n)\n", "foo(\n a\n -- tail\n)\n"); } + #[test] + fn test_call_expr_formats_inline_comment_between_prefix_and_args() { + assert_format!( + "local value = foo -- note\n(a, b)\n", + "local value = foo -- note\n(a, b)\n" + ); + } + #[test] fn test_closure_expr_preserves_inline_comment_in_params() { assert_format!( @@ -308,6 +316,14 @@ local b = t[1] ); } + #[test] + fn test_closure_expr_formats_inline_comment_before_end() { + assert_format!( + "local f = function() -- note\nend\n", + "local f = function() -- note\nend\n" + ); + } + #[test] fn test_multiline_call_args_layout_reflow_when_width_allows() { assert_format!( diff --git a/crates/emmylua_formatter/src/test/misc_tests.rs b/crates/emmylua_formatter/src/test/misc_tests.rs index 6aa89172e..3a19565e4 100644 --- a/crates/emmylua_formatter/src/test/misc_tests.rs +++ b/crates/emmylua_formatter/src/test/misc_tests.rs @@ -2,7 +2,10 @@ mod tests { use emmylua_parser::LuaLanguageLevel; - use crate::{SourceText, assert_format, config::LuaFormatConfig, reformat_lua_code}; + use crate::{ + SourceText, assert_format, config::LuaFormatConfig, reformat_lua_code, + reformat_lua_code_new, + }; // ========== shebang ========== @@ -243,4 +246,352 @@ local cc = 3 -- comment c "Formatter is not idempotent with shebang!\nFirst pass:\n{first}\nSecond pass:\n{second}" ); } + + #[test] + fn test_new_formatter_root_pipeline_smoke() { + let config = LuaFormatConfig::default(); + let source = SourceText { + text: "local value = 1\n", + level: LuaLanguageLevel::default(), + }; + + assert_eq!( + reformat_lua_code(&source, &config), + reformat_lua_code_new(&source, &config) + ); + } + + #[test] + fn test_new_formatter_renders_comment_and_block_path() { + let config = LuaFormatConfig::default(); + let source = SourceText { + text: "--hello\nlocal value=1\n", + level: LuaLanguageLevel::default(), + }; + + assert_eq!( + reformat_lua_code(&source, &config), + reformat_lua_code_new(&source, &config) + ); + } + + #[test] + fn test_new_formatter_renders_local_assign_return_statements() { + let config = LuaFormatConfig::default(); + let source = SourceText { + text: "local a,b=foo,bar\na,b=foo(),bar()\nreturn foo, bar, baz\n", + level: LuaLanguageLevel::default(), + }; + + assert_eq!( + reformat_lua_code(&source, &config), + reformat_lua_code_new(&source, &config) + ); + } + + #[test] + fn test_new_formatter_statement_spacing_config_parity() { + let mut config = LuaFormatConfig::default(); + config.spacing.space_around_assign_operator = false; + + let source = SourceText { + text: "local a, b = foo, bar\nx, y = 1, 2\nreturn a, y\n", + level: LuaLanguageLevel::default(), + }; + + assert_eq!( + reformat_lua_code(&source, &config), + reformat_lua_code_new(&source, &config) + ); + } + + #[test] + fn test_new_formatter_renders_trivia_aware_statement_sequences() { + let config = LuaFormatConfig::default(); + let source = SourceText { + text: "local a, -- lhs\n b = -- eq\n foo, -- rhs\n bar\na, -- lhs\n b = -- eq\n foo, -- rhs\n bar\nreturn -- head\n foo, -- rhs\n bar\n", + level: LuaLanguageLevel::default(), + }; + + let first = reformat_lua_code_new(&source, &config); + let second = reformat_lua_code_new( + &SourceText { + text: &first, + level: LuaLanguageLevel::default(), + }, + &config, + ); + + assert_eq!(first, second); + } + + #[test] + fn test_new_formatter_trivia_aware_statement_spacing_config_parity() { + let mut config = LuaFormatConfig::default(); + config.spacing.space_around_assign_operator = false; + + let source = SourceText { + text: "local a, -- lhs\n b = -- eq\n foo, -- rhs\n bar\nreturn -- head\n foo, -- rhs\n bar\n", + level: LuaLanguageLevel::default(), + }; + + assert_eq!( + reformat_lua_code(&source, &config), + reformat_lua_code_new(&source, &config) + ); + } + + #[test] + fn test_new_formatter_renders_call_and_table_sequences() { + let config = LuaFormatConfig::default(); + let source = SourceText { + text: "local result=foo(1,2,3)\nlocal tbl={1,2,3}\n", + level: LuaLanguageLevel::default(), + }; + + assert_eq!( + reformat_lua_code(&source, &config), + reformat_lua_code_new(&source, &config) + ); + } + + #[test] + fn test_new_formatter_renders_while_statements() { + let config = LuaFormatConfig::default(); + let source = SourceText { + text: "while foo(a, b) do\n local x = 1\n return x\nend\n", + level: LuaLanguageLevel::default(), + }; + + assert_eq!( + reformat_lua_code(&source, &config), + reformat_lua_code_new(&source, &config) + ); + } + + #[test] + fn test_new_formatter_renders_for_statements() { + let config = LuaFormatConfig::default(); + let source = SourceText { + text: "for i = foo(), bar, baz do\n local x = i\nend\nfor k, v in pairs(tbl), next(tbl) do\n return v\nend\n", + level: LuaLanguageLevel::default(), + }; + + assert_eq!( + reformat_lua_code(&source, &config), + reformat_lua_code_new(&source, &config) + ); + } + + #[test] + fn test_new_formatter_renders_repeat_statements() { + let config = LuaFormatConfig::default(); + let source = SourceText { + text: "repeat\n local x = foo()\nuntil bar(x)\n", + level: LuaLanguageLevel::default(), + }; + + assert_eq!( + reformat_lua_code(&source, &config), + reformat_lua_code_new(&source, &config) + ); + } + + #[test] + fn test_new_formatter_renders_if_statements() { + let config = LuaFormatConfig::default(); + let source = SourceText { + text: "if ok then return value end\nif foo(a, b) then\n local x = 1\nelseif bar then\n return baz\nelse\n return qux\nend\n", + level: LuaLanguageLevel::default(), + }; + + assert_eq!( + reformat_lua_code(&source, &config), + reformat_lua_code_new(&source, &config) + ); + } + + #[test] + fn test_new_formatter_trivia_aware_while_header_parity() { + let config = LuaFormatConfig::default(); + let source = SourceText { + text: "while foo -- cond\ndo\n return bar\nend\n", + level: LuaLanguageLevel::default(), + }; + + assert_eq!( + reformat_lua_code(&source, &config), + reformat_lua_code_new(&source, &config) + ); + } + + #[test] + fn test_new_formatter_trivia_aware_for_header_parity() { + let config = LuaFormatConfig::default(); + let source = SourceText { + text: "for i, -- lhs\n j = -- eq\n foo, -- rhs\n bar do\n return i\nend\nfor k, -- lhs\n v in -- in\n pairs(tbl), -- rhs\n next(tbl) do\n return v\nend\n", + level: LuaLanguageLevel::default(), + }; + + assert_eq!( + reformat_lua_code(&source, &config), + reformat_lua_code_new(&source, &config) + ); + } + + #[test] + fn test_new_formatter_trivia_aware_repeat_header_parity() { + let config = LuaFormatConfig::default(); + let source = SourceText { + text: "repeat\n return foo\nuntil -- cond\n bar(baz)\n", + level: LuaLanguageLevel::default(), + }; + + assert_eq!( + reformat_lua_code(&source, &config), + reformat_lua_code_new(&source, &config) + ); + } + + #[test] + fn test_new_formatter_trivia_aware_if_parity() { + let config = LuaFormatConfig::default(); + let source = SourceText { + text: "if foo -- cond\nthen\n return a\nelseif bar -- cond\nthen\n return b\nelse\n return c\nend\n", + level: LuaLanguageLevel::default(), + }; + + assert_eq!( + reformat_lua_code(&source, &config), + reformat_lua_code_new(&source, &config) + ); + } + + #[test] + fn test_new_formatter_renders_basic_call_arg_shapes() { + let config = LuaFormatConfig::default(); + let source = SourceText { + text: "local result = foo(a, {1,2}, bar(b, c))\n", + level: LuaLanguageLevel::default(), + }; + + assert_eq!( + reformat_lua_code(&source, &config), + reformat_lua_code_new(&source, &config) + ); + } + + #[test] + fn test_new_formatter_call_arg_comment_attachment_idempotent() { + let config = LuaFormatConfig::default(); + let source = SourceText { + text: "local result = foo(\n -- first\n a, -- trailing a\n b,\n -- last\n)\n", + level: LuaLanguageLevel::default(), + }; + + let first = reformat_lua_code_new(&source, &config); + let second = reformat_lua_code_new( + &SourceText { + text: &first, + level: LuaLanguageLevel::default(), + }, + &config, + ); + + assert_eq!(first, second); + } + + #[test] + fn test_new_formatter_closure_params_idempotent() { + let config = LuaFormatConfig::default(); + let source = SourceText { + text: "local fn = function(a,b,c)\nreturn a\nend\n", + level: LuaLanguageLevel::default(), + }; + + let first = reformat_lua_code_new(&source, &config); + let second = reformat_lua_code_new( + &SourceText { + text: &first, + level: LuaLanguageLevel::default(), + }, + &config, + ); + + assert_eq!(first, second); + } + + #[test] + fn test_new_formatter_param_comment_attachment_idempotent() { + let config = LuaFormatConfig::default(); + let source = SourceText { + text: "local fn = function(\n -- first\n a, -- trailing a\n b,\n -- tail\n)\nreturn a\nend\n", + level: LuaLanguageLevel::default(), + }; + + let first = reformat_lua_code_new(&source, &config); + let second = reformat_lua_code_new( + &SourceText { + text: &first, + level: LuaLanguageLevel::default(), + }, + &config, + ); + + assert_eq!(first, second); + } + + #[test] + fn test_new_formatter_closure_shell_comments_idempotent() { + let config = LuaFormatConfig::default(); + let source = SourceText { + text: "local fn = function -- before params\n(a) -- before body\n-- body comment\nreturn a\nend\n", + level: LuaLanguageLevel::default(), + }; + + let first = reformat_lua_code_new(&source, &config); + let second = reformat_lua_code_new( + &SourceText { + text: &first, + level: LuaLanguageLevel::default(), + }, + &config, + ); + + assert_eq!(first, second); + } + + #[test] + fn test_new_formatter_renders_table_field_key_value_shapes() { + let config = LuaFormatConfig::default(); + let source = SourceText { + text: "local tbl={a=1,[\"b\"]=2,[3]=4,[foo]=bar}\n", + level: LuaLanguageLevel::default(), + }; + + assert_eq!( + reformat_lua_code(&source, &config), + reformat_lua_code_new(&source, &config) + ); + } + + #[test] + fn test_new_formatter_table_comment_attachment_idempotent() { + let config = LuaFormatConfig::default(); + let source = SourceText { + text: "local tbl = {\n -- lead\n a = 1, -- trailing\n b = 2,\n -- tail\n}\n", + level: LuaLanguageLevel::default(), + }; + + let first = reformat_lua_code_new(&source, &config); + let second = reformat_lua_code_new( + &SourceText { + text: &first, + level: LuaLanguageLevel::default(), + }, + &config, + ); + + assert_eq!(first, second); + } } From 29bbf36ca33c18c6e731f11b869edf1d5db02d10 Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Fri, 27 Mar 2026 19:04:59 +0800 Subject: [PATCH 22/23] clean code --- .../emmylua_formatter/src/formatter/block.rs | 352 -- .../formatter/comments/comment_formatter.rs | 263 -- .../src/formatter/comments/mod.rs | 1779 ---------- .../src/{formatter_new => formatter}/expr.rs | 0 .../src/formatter/expression.rs | 2999 ----------------- .../layout/mod.rs | 16 +- .../layout/tree.rs | 2 +- .../line_breaks.rs | 0 crates/emmylua_formatter/src/formatter/mod.rs | 45 +- .../src/{formatter_new => formatter}/model.rs | 0 .../{formatter_new => formatter}/render.rs | 21 +- .../src/formatter/sequence.rs | 498 +-- .../src/formatter/spacing.rs | 648 +++- .../src/formatter/statement.rs | 2101 ------------ .../emmylua_formatter/src/formatter/tokens.rs | 15 - .../emmylua_formatter/src/formatter/trivia.rs | 83 +- .../src/formatter_new/mod.rs | 41 - .../src/formatter_new/sequence.rs | 446 --- .../src/formatter_new/spacing.rs | 662 ---- .../src/formatter_new/trivia.rs | 71 - crates/emmylua_formatter/src/lib.rs | 19 - .../emmylua_formatter/src/test/misc_tests.rs | 61 +- 22 files changed, 744 insertions(+), 9378 deletions(-) delete mode 100644 crates/emmylua_formatter/src/formatter/block.rs delete mode 100644 crates/emmylua_formatter/src/formatter/comments/comment_formatter.rs delete mode 100644 crates/emmylua_formatter/src/formatter/comments/mod.rs rename crates/emmylua_formatter/src/{formatter_new => formatter}/expr.rs (100%) delete mode 100644 crates/emmylua_formatter/src/formatter/expression.rs rename crates/emmylua_formatter/src/{formatter_new => formatter}/layout/mod.rs (96%) rename crates/emmylua_formatter/src/{formatter_new => formatter}/layout/tree.rs (90%) rename crates/emmylua_formatter/src/{formatter_new => formatter}/line_breaks.rs (100%) rename crates/emmylua_formatter/src/{formatter_new => formatter}/model.rs (100%) rename crates/emmylua_formatter/src/{formatter_new => formatter}/render.rs (98%) delete mode 100644 crates/emmylua_formatter/src/formatter/statement.rs delete mode 100644 crates/emmylua_formatter/src/formatter/tokens.rs delete mode 100644 crates/emmylua_formatter/src/formatter_new/mod.rs delete mode 100644 crates/emmylua_formatter/src/formatter_new/sequence.rs delete mode 100644 crates/emmylua_formatter/src/formatter_new/spacing.rs delete mode 100644 crates/emmylua_formatter/src/formatter_new/trivia.rs diff --git a/crates/emmylua_formatter/src/formatter/block.rs b/crates/emmylua_formatter/src/formatter/block.rs deleted file mode 100644 index bf5555ccf..000000000 --- a/crates/emmylua_formatter/src/formatter/block.rs +++ /dev/null @@ -1,352 +0,0 @@ -use emmylua_parser::{ - LuaAstNode, LuaBlock, LuaComment, LuaKind, LuaStat, LuaSyntaxKind, LuaSyntaxNode, -}; -use rowan::TextRange; - -use crate::ir::{self, AlignEntry, DocIR}; - -use super::FormatContext; -use super::comments::{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; - -/// 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(), - } - } -} - -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(ctx.config, 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(ctx.config, 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(ctx.config, 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(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, - }); - } - } - } - - entries -} - -/// 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 { - 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(); - - 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.layout.max_blank_lines); - for _ in 0..normalized { - docs.push(ir::hard_line()); - } - } - - docs.extend(format_comment(ctx.config, 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(ctx.config, stat) - { - let group_start = i; - let mut group_end = i + 1; - while group_end < children.len() { - if can_join_eq_alignment_group(ctx, stat, &children[group_end]) { - group_end += 1; - } else { - break; - } - } - - 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.layout.max_blank_lines); - for _ in 0..normalized { - docs.push(ir::hard_line()); - } - } - - 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()); - is_first = false; - i = group_end; - continue; - } - } - - // Try to form a comment-only alignment group - if ctx.config.should_align_statement_line_comments() - && extract_trailing_comment(ctx.config, stat.syntax()).is_some() - { - let group_start = i; - let mut group_end = i + 1; - while group_end < children.len() { - if can_join_comment_alignment_group(ctx, stat, &children[group_end]) { - group_end += 1; - } else { - break; - } - } - - 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.layout.max_blank_lines); - for _ in 0..normalized { - docs.push(ir::hard_line()); - } - } - - 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()); - 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.layout.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(ctx.config, 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/comments/comment_formatter.rs b/crates/emmylua_formatter/src/formatter/comments/comment_formatter.rs deleted file mode 100644 index df29723cb..000000000 --- a/crates/emmylua_formatter/src/formatter/comments/comment_formatter.rs +++ /dev/null @@ -1,263 +0,0 @@ -use std::collections::HashMap; - -use emmylua_parser::{LuaAstNode, LuaComment, LuaSyntaxId, LuaTokenKind}; - -use crate::formatter::comments::TokenExpected; -use crate::ir::{self, DocIR}; - -pub struct CommentFormatter { - left_expected: HashMap, - right_expected: HashMap, - align_left_expected: HashMap, - align_right_expected: HashMap, - replace_tokens: HashMap, -} - -#[derive(Default)] -struct CommentLine { - tokens: Vec, - gaps: Vec, -} - -struct CommentToken { - syntax_id: LuaSyntaxId, - text: String, -} - -impl CommentFormatter { - pub fn new() -> Self { - Self { - left_expected: HashMap::new(), - right_expected: HashMap::new(), - align_left_expected: HashMap::new(), - align_right_expected: HashMap::new(), - replace_tokens: HashMap::new(), - } - } - - pub fn add_token_left_expected(&mut self, syntax_id: LuaSyntaxId, expected: TokenExpected) { - self.left_expected.insert(syntax_id, expected); - } - - pub fn add_token_right_expected(&mut self, syntax_id: LuaSyntaxId, expected: TokenExpected) { - self.right_expected.insert(syntax_id, expected); - } - - pub fn add_token_left_alignment_expected( - &mut self, - syntax_id: LuaSyntaxId, - expected: TokenExpected, - ) { - self.align_left_expected.insert(syntax_id, expected); - } - - pub fn add_token_right_alignment_expected( - &mut self, - syntax_id: LuaSyntaxId, - expected: TokenExpected, - ) { - self.align_right_expected.insert(syntax_id, expected); - } - - pub fn get_left_expected(&self, syntax_id: LuaSyntaxId) -> Option<&TokenExpected> { - self.left_expected.get(&syntax_id) - } - - pub fn get_right_expected(&self, syntax_id: LuaSyntaxId) -> Option<&TokenExpected> { - self.right_expected.get(&syntax_id) - } - - pub fn get_left_alignment_expected(&self, syntax_id: LuaSyntaxId) -> Option<&TokenExpected> { - self.align_left_expected.get(&syntax_id) - } - - pub fn get_right_alignment_expected(&self, syntax_id: LuaSyntaxId) -> Option<&TokenExpected> { - self.align_right_expected.get(&syntax_id) - } - - pub fn add_token_replace(&mut self, syntax_id: LuaSyntaxId, replacement: String) { - self.replace_tokens.insert(syntax_id, replacement); - } - - pub fn get_token_replace(&self, syntax_id: LuaSyntaxId) -> Option<&str> { - self.replace_tokens.get(&syntax_id).map(String::as_str) - } - - pub fn render_comment(&self, comment: &LuaComment) -> Vec { - self.render_comment_lines(comment) - .into_iter() - .enumerate() - .flat_map(|(index, line)| { - let mut docs = Vec::new(); - if index > 0 { - docs.push(ir::hard_line()); - } - if !line.is_empty() { - docs.push(ir::text(line)); - } - docs - }) - .collect() - } - - pub fn render_comment_text(&self, comment: &LuaComment) -> String { - let mut lines = self.render_comment_lines(comment).into_iter(); - let Some(first_line) = lines.next() else { - return String::new(); - }; - - if lines.len() == 0 { - return first_line; - } - - let mut rendered = first_line; - for line in lines { - rendered.push('\n'); - rendered.push_str(&line); - } - - rendered - } - - fn render_comment_lines(&self, comment: &LuaComment) -> Vec { - let mut lines = self.collect_comment_lines(comment); - - for line in &mut lines { - self.apply_spacing_pass(line, false); - } - - for line in &mut lines { - self.apply_spacing_pass(line, true); - } - - lines.into_iter().map(|line| line.into_string()).collect() - } - - fn collect_comment_lines(&self, comment: &LuaComment) -> Vec { - let mut lines = Vec::new(); - let mut current_line = CommentLine::default(); - let mut pending_gap = String::new(); - let mut ended_with_newline = false; - - for element in comment.syntax().descendants_with_tokens() { - let Some(token) = element.into_token() else { - continue; - }; - - match token.kind().into() { - LuaTokenKind::TkWhitespace => { - pending_gap.push_str(token.text()); - } - LuaTokenKind::TkEndOfLine => { - lines.push(std::mem::take(&mut current_line)); - pending_gap.clear(); - ended_with_newline = true; - } - _ => { - let syntax_id = LuaSyntaxId::from_token(&token); - if !current_line.tokens.is_empty() { - current_line.gaps.push(std::mem::take(&mut pending_gap)); - } else { - pending_gap.clear(); - } - - current_line.tokens.push(CommentToken { - syntax_id, - text: self - .get_token_replace(syntax_id) - .unwrap_or_else(|| token.text()) - .to_string(), - }); - ended_with_newline = false; - } - } - } - - if !current_line.tokens.is_empty() || ended_with_newline { - lines.push(current_line); - } - - lines - } - - fn apply_spacing_pass(&self, line: &mut CommentLine, use_alignment: bool) { - for gap_index in 0..line.gaps.len() { - let prev_token_id = line.tokens[gap_index].syntax_id; - let token_id = line.tokens[gap_index + 1].syntax_id; - let resolved_gap = self.resolve_gap( - Some(prev_token_id), - token_id, - &line.gaps[gap_index], - use_alignment, - ); - line.gaps[gap_index] = resolved_gap; - } - } - - fn resolve_gap( - &self, - prev_token_id: Option, - token_id: LuaSyntaxId, - gap: &str, - use_alignment: bool, - ) -> String { - let mut exact_space = None; - let mut max_space = None; - - let (left_expected, right_expected) = if use_alignment { - (&self.align_left_expected, &self.align_right_expected) - } else { - (&self.left_expected, &self.right_expected) - }; - - if let Some(prev_token_id) = prev_token_id - && let Some(expected) = right_expected.get(&prev_token_id) - { - match expected { - TokenExpected::Space(count) => exact_space = Some(*count), - TokenExpected::MaxSpace(count) => max_space = Some(*count), - } - } - - if let Some(expected) = left_expected.get(&token_id) { - match expected { - TokenExpected::Space(count) => { - exact_space = Some(exact_space.map_or(*count, |current| current.max(*count))); - } - TokenExpected::MaxSpace(count) => { - max_space = Some(max_space.map_or(*count, |current| current.min(*count))); - } - } - } - - if let Some(exact_space) = exact_space { - return " ".repeat(exact_space); - } - - if let Some(max_space) = max_space { - let original_space_count = gap.chars().take_while(|ch| *ch == ' ').count(); - return " ".repeat(original_space_count.min(max_space)); - } - - gap.to_string() - } -} - -impl CommentLine { - fn into_string(self) -> String { - let mut rendered = String::new(); - let mut tokens = self.tokens.into_iter(); - let Some(first_token) = tokens.next() else { - return rendered; - }; - - rendered.push_str(&first_token.text); - - for (gap, token) in self.gaps.into_iter().zip(tokens) { - rendered.push_str(&gap); - rendered.push_str(&token.text); - } - - rendered - } -} diff --git a/crates/emmylua_formatter/src/formatter/comments/mod.rs b/crates/emmylua_formatter/src/formatter/comments/mod.rs deleted file mode 100644 index 9a09fb8c5..000000000 --- a/crates/emmylua_formatter/src/formatter/comments/mod.rs +++ /dev/null @@ -1,1779 +0,0 @@ -#[allow(dead_code)] -mod comment_formatter; - -use emmylua_parser::{ - LuaAstNode, LuaAstToken, LuaComment, LuaDocFieldKey, LuaDocGenericDeclList, LuaDocTag, - LuaDocTagAlias, LuaDocTagClass, LuaDocTagField, LuaDocTagGeneric, LuaDocTagOverload, - LuaDocTagParam, LuaDocTagReturn, LuaDocTagType, LuaKind, LuaSyntaxElement, LuaSyntaxId, - LuaSyntaxKind, LuaSyntaxNode, LuaTokenKind, -}; -use rowan::TextRange; - -use crate::config::LuaFormatConfig; -use crate::ir::{self, DocIR}; - -use self::comment_formatter::CommentFormatter; -use super::trivia::has_non_trivia_before_on_same_line; - -enum TokenExpected { - Space(usize), - MaxSpace(usize), -} - -pub fn format_comment(config: &LuaFormatConfig, comment: &LuaComment) -> Vec { - let is_doc = is_doc_comment(comment); - - if has_nonstandard_dash_prefix(comment) || (is_doc && should_preserve_doc_comment_raw(comment)) - { - return vec![ir::source_node_trimmed(comment.syntax().clone())]; - } - - if is_long_comment(comment) { - return vec![ir::source_node_trimmed(comment.syntax().clone())]; - } - - if !is_doc { - return format_normal_comment(config, comment); - } - - format_doc_comment(config, comment) -} - -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) - && let Some(comment) = LuaComment::cast(child) - { - if !docs.is_empty() { - docs.push(ir::hard_line()); - } - docs.extend(format_comment(config, &comment)); - } - } - docs -} - -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) - || 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(config, &comment) - .unwrap_or_else(|| trim_end_owned(child.text())); - - return Some((vec![ir::text(comment_text)], child.text_range())); - } - - let mut next = node.next_sibling_or_token(); - 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 = LuaComment::cast(comment_node.clone())?; - if comment_node.text().contains_char('\n') { - return None; - } - - let comment_text = render_single_line_comment_text(config, &comment) - .unwrap_or_else(|| trim_end_owned(comment_node.text())); - - return Some((vec![ir::text(comment_text)], comment_node.text_range())); - } - _ => return None, - } - next = sibling.next_sibling_or_token(); - } - - 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() -} - -pub fn format_trailing_comment( - config: &LuaFormatConfig, - node: &LuaSyntaxNode, -) -> Option<(DocIR, TextRange)> { - 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)) -} - -pub fn should_keep_comment_inline_in_expression(comment: &LuaComment) -> bool { - is_long_comment(comment) && !comment.syntax().text().contains_char('\n') -} - -fn should_preserve_doc_comment_raw(comment: &LuaComment) -> bool { - let mut seen_prefix_on_line = false; - - for element in comment.syntax().descendants_with_tokens() { - let Some(token) = element.into_token() else { - continue; - }; - - match token.kind().into() { - LuaTokenKind::TkEndOfLine => { - seen_prefix_on_line = false; - } - LuaTokenKind::TkDocStart - | LuaTokenKind::TkDocLongStart - | LuaTokenKind::TkDocContinue - | LuaTokenKind::TkDocContinueOr - | LuaTokenKind::TkNormalStart => { - if seen_prefix_on_line { - return true; - } - seen_prefix_on_line = true; - } - _ => {} - } - } - - false -} - -fn is_doc_comment(comment: &LuaComment) -> bool { - let Some(first_token) = comment.syntax().first_token() else { - return false; - }; - - match first_token.kind().into() { - LuaTokenKind::TkDocStart | LuaTokenKind::TkDocContinue | LuaTokenKind::TkDocContinueOr => { - true - } - LuaTokenKind::TkNormalStart => is_doc_normal_start(first_token.text()), - _ => comment.get_doc_tags().next().is_some(), - } -} - -fn is_long_comment(comment: &LuaComment) -> bool { - let Some(first_token) = comment.syntax().first_token() else { - return false; - }; - - matches!( - first_token.kind().into(), - LuaTokenKind::TkLongCommentStart | LuaTokenKind::TkDocLongStart - ) -} - -fn format_normal_comment(config: &LuaFormatConfig, comment: &LuaComment) -> Vec { - let formatter = build_comment_formatter( - config, - comment, - !comment.syntax().text().contains_char('\n'), - ); - formatter.render_comment(comment) -} - -fn build_comment_formatter( - config: &LuaFormatConfig, - comment: &LuaComment, - normalize_start_tokens: bool, -) -> CommentFormatter { - let mut formatter = CommentFormatter::new(); - - for element in comment.syntax().descendants_with_tokens() { - let Some(token) = element.into_token() else { - continue; - }; - - let syntax_id = LuaSyntaxId::from_token(&token); - match token.kind().to_token() { - LuaTokenKind::TkNormalStart if normalize_start_tokens => { - if let Some(replacement) = normalized_comment_prefix(config, token.text()) { - formatter.add_token_replace(syntax_id, replacement); - formatter.add_token_right_expected(syntax_id, TokenExpected::Space(0)); - } - } - LuaTokenKind::TkDocStart if normalize_start_tokens => { - formatter.add_token_replace(syntax_id, normalized_doc_tag_prefix(config)); - formatter.add_token_right_expected(syntax_id, TokenExpected::Space(0)); - } - LuaTokenKind::TkDocContinue if normalize_start_tokens => { - formatter.add_token_replace( - syntax_id, - normalized_doc_continue_prefix(config, token.text()), - ); - formatter.add_token_right_expected(syntax_id, TokenExpected::Space(0)); - } - LuaTokenKind::TkDocContinueOr if normalize_start_tokens => { - formatter.add_token_replace( - syntax_id, - normalized_doc_continue_or_prefix(config, token.text()), - ); - formatter.add_token_right_expected(syntax_id, TokenExpected::Space(0)); - } - 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 => { - formatter.add_token_left_expected(syntax_id, TokenExpected::Space(0)); - } - LuaTokenKind::TkString - | LuaTokenKind::TkRightBrace - | LuaTokenKind::TkLongString => { - formatter.add_token_left_expected(syntax_id, TokenExpected::Space(1)); - } - _ => {} - } - } - formatter.add_token_right_expected(syntax_id, TokenExpected::Space(0)); - } - LuaTokenKind::TkRightBracket | LuaTokenKind::TkRightParen => { - formatter.add_token_left_expected(syntax_id, TokenExpected::Space(0)); - } - LuaTokenKind::TkLeftBrace => { - formatter.add_token_right_expected(syntax_id, TokenExpected::Space(1)); - } - LuaTokenKind::TkRightBrace => { - formatter.add_token_left_expected(syntax_id, TokenExpected::Space(1)); - } - LuaTokenKind::TkComma => { - formatter.add_token_left_expected(syntax_id, TokenExpected::Space(0)); - formatter.add_token_right_expected(syntax_id, TokenExpected::Space(1)); - } - LuaTokenKind::TkPlus | LuaTokenKind::TkMinus => { - if is_parent_syntax(&token, LuaSyntaxKind::UnaryExpr) { - formatter.add_token_right_expected(syntax_id, TokenExpected::Space(0)); - continue; - } - formatter.add_token_left_expected(syntax_id, TokenExpected::Space(1)); - formatter.add_token_right_expected(syntax_id, TokenExpected::Space(1)); - } - LuaTokenKind::TkLt => { - if is_parent_syntax(&token, LuaSyntaxKind::Attribute) { - formatter.add_token_left_expected(syntax_id, TokenExpected::Space(1)); - formatter.add_token_right_expected(syntax_id, TokenExpected::Space(0)); - continue; - } - formatter.add_token_left_expected(syntax_id, TokenExpected::Space(1)); - formatter.add_token_right_expected(syntax_id, TokenExpected::Space(1)); - } - LuaTokenKind::TkGt => { - if is_parent_syntax(&token, LuaSyntaxKind::Attribute) { - formatter.add_token_left_expected(syntax_id, TokenExpected::Space(0)); - formatter.add_token_right_expected(syntax_id, TokenExpected::Space(1)); - continue; - } - formatter.add_token_left_expected(syntax_id, TokenExpected::Space(1)); - formatter.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 => { - formatter.add_token_left_expected(syntax_id, TokenExpected::Space(1)); - formatter.add_token_right_expected(syntax_id, TokenExpected::Space(1)); - } - LuaTokenKind::TkColon => { - if is_parent_syntax(&token, LuaSyntaxKind::IndexExpr) { - formatter.add_token_left_expected(syntax_id, TokenExpected::Space(0)); - formatter.add_token_right_expected(syntax_id, TokenExpected::Space(0)); - continue; - } - formatter.add_token_left_expected(syntax_id, TokenExpected::MaxSpace(1)); - formatter.add_token_right_expected(syntax_id, TokenExpected::MaxSpace(1)); - } - LuaTokenKind::TkDot => { - formatter.add_token_left_expected(syntax_id, TokenExpected::Space(0)); - formatter.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 => { - formatter.add_token_left_expected(syntax_id, TokenExpected::Space(1)); - formatter.add_token_right_expected(syntax_id, TokenExpected::Space(1)); - } - _ => {} - } - } - - formatter -} - -fn render_single_line_comment_text( - config: &LuaFormatConfig, - comment: &LuaComment, -) -> Option { - if is_long_comment(comment) { - return Some(trim_end_owned(comment.syntax().text())); - } - - if has_nonstandard_dash_prefix(comment) { - return Some(trim_end_owned(comment.syntax().text())); - } - - if is_doc_comment(comment) { - return None; - } - - if comment.syntax().text().contains_char('\n') { - return None; - } - - let formatter = build_comment_formatter(config, comment, true); - Some(formatter.render_comment_text(comment)) -} - -fn format_doc_comment(config: &LuaFormatConfig, comment: &LuaComment) -> Vec { - if let Some(docs) = try_format_doc_comment_with_tokens(config, comment) { - return docs; - } - - if let Some(docs) = try_format_doc_comment_with_token_alignment(config, comment) { - return docs; - } - - let lines = parse_doc_comment_lines(comment); - let mut docs = Vec::new(); - for (index, line) in lines - .iter() - .map(|line| render_single_doc_comment_line(config, line)) - .enumerate() - { - if index > 0 { - docs.push(ir::hard_line()); - } - if !line.is_empty() { - docs.push(ir::text(line)); - } - } - docs -} - -fn try_format_doc_comment_with_tokens( - config: &LuaFormatConfig, - comment: &LuaComment, -) -> Option> { - let is_single_line = !comment.syntax().text().contains_char('\n'); - let mut doc_tags = comment.get_doc_tags(); - let first_tag = doc_tags.next(); - if doc_tags.next().is_some() { - return None; - } - - let normalize_start_tokens = is_single_line || first_tag.is_some(); - let mut formatter = build_comment_formatter(config, comment, normalize_start_tokens); - - match first_tag { - None => { - let description = comment.get_description()?; - if is_single_line { - normalize_doc_description_tokens(&mut formatter, description.syntax()); - } - return Some(formatter.render_comment(comment)); - } - Some(LuaDocTag::Param(tag)) if is_single_line => { - configure_doc_tag_token_spacing( - &mut formatter, - config, - &tag.syntax().clone(), - tag.get_name_token().map(|token| token.syntax().clone()), - tag.get_type().and_then(|ty| ty.syntax().first_token()), - find_inline_doc_description_after(tag.syntax()), - )?; - } - Some(LuaDocTag::Type(tag)) if is_single_line => { - let first_type_token = tag - .get_type_list() - .next() - .and_then(|ty| ty.syntax().first_token()); - configure_doc_tag_token_spacing( - &mut formatter, - config, - &tag.syntax().clone(), - None, - first_type_token, - find_inline_doc_description_after(tag.syntax()), - )?; - } - Some(LuaDocTag::Overload(tag)) if is_single_line => { - configure_doc_tag_token_spacing( - &mut formatter, - config, - &tag.syntax().clone(), - None, - tag.get_type().and_then(|ty| ty.syntax().first_token()), - find_inline_doc_description_after(tag.syntax()), - )?; - } - _ => return None, - } - - Some(formatter.render_comment(comment)) -} - -fn try_format_doc_comment_with_token_alignment( - config: &LuaFormatConfig, - comment: &LuaComment, -) -> Option> { - if !comment.syntax().text().contains_char('\n') { - return None; - } - - let lines = parse_doc_comment_lines(comment); - let tag_count = comment.get_doc_tags().count(); - let supported_tag_count = lines - .iter() - .filter(|line| { - matches!( - line, - DocCommentLine::Class { .. } - | DocCommentLine::Alias { .. } - | DocCommentLine::Type { .. } - | DocCommentLine::Generic { .. } - | DocCommentLine::Overload { .. } - | DocCommentLine::Param { .. } - | DocCommentLine::Field { .. } - | DocCommentLine::Return { .. } - ) - }) - .count(); - - if tag_count == 0 || tag_count != supported_tag_count { - return None; - } - - let mut formatter = build_comment_formatter(config, comment, true); - let gap = config.emmy_doc.tag_spacing.max(1); - let mut tags = comment.get_doc_tags(); - let mut line_tags: Vec> = Vec::with_capacity(lines.len()); - - for line in &lines { - match line { - DocCommentLine::Class { .. } => { - let LuaDocTag::Class(tag) = tags.next()? else { - return None; - }; - configure_declaration_doc_tag_token_spacing( - &mut formatter, - config, - &tag.syntax().clone(), - tag.get_name_token().map(|token| token.syntax().clone()), - find_inline_doc_description_after(tag.syntax()), - )?; - if let Some(generic_decl) = tag.get_generic_decl() { - configure_generic_decl_token_spacing(&mut formatter, generic_decl.syntax()); - } - line_tags.push(Some(LuaDocTag::Class(tag))); - } - DocCommentLine::Alias { .. } => { - let LuaDocTag::Alias(tag) = tags.next()? else { - return None; - }; - configure_declaration_doc_tag_token_spacing( - &mut formatter, - config, - &tag.syntax().clone(), - tag.get_name_token().map(|token| token.syntax().clone()), - find_inline_doc_description_after(tag.syntax()), - )?; - if let Some(generic_decl_list) = tag.get_generic_decl_list() { - configure_generic_decl_token_spacing( - &mut formatter, - generic_decl_list.syntax(), - ); - } - line_tags.push(Some(LuaDocTag::Alias(tag))); - } - DocCommentLine::Type { .. } => { - let LuaDocTag::Type(tag) = tags.next()? else { - return None; - }; - configure_declaration_doc_tag_token_spacing( - &mut formatter, - config, - &tag.syntax().clone(), - tag.get_type_list() - .next() - .and_then(|ty| ty.syntax().first_token()), - find_inline_doc_description_after(tag.syntax()), - )?; - line_tags.push(Some(LuaDocTag::Type(tag))); - } - DocCommentLine::Generic { .. } => { - let LuaDocTag::Generic(tag) = tags.next()? else { - return None; - }; - let generic_decl_list = tag.get_generic_decl_list(); - configure_declaration_doc_tag_token_spacing( - &mut formatter, - config, - &tag.syntax().clone(), - generic_decl_list - .as_ref() - .and_then(|decls| decls.syntax().first_token()), - find_inline_doc_description_after(tag.syntax()), - )?; - if let Some(generic_decl_list) = generic_decl_list { - configure_generic_decl_token_spacing( - &mut formatter, - generic_decl_list.syntax(), - ); - } - line_tags.push(Some(LuaDocTag::Generic(tag))); - } - DocCommentLine::Overload { .. } => { - let LuaDocTag::Overload(tag) = tags.next()? else { - return None; - }; - configure_declaration_doc_tag_token_spacing( - &mut formatter, - config, - &tag.syntax().clone(), - tag.get_type().and_then(|ty| ty.syntax().first_token()), - find_inline_doc_description_after(tag.syntax()), - )?; - line_tags.push(Some(LuaDocTag::Overload(tag))); - } - DocCommentLine::Param { .. } => { - let LuaDocTag::Param(tag) = tags.next()? else { - return None; - }; - configure_param_doc_tag_token_spacing(&mut formatter, config, &tag)?; - line_tags.push(Some(LuaDocTag::Param(tag))); - } - DocCommentLine::Field { .. } => { - let LuaDocTag::Field(tag) = tags.next()? else { - return None; - }; - configure_field_doc_tag_token_spacing(&mut formatter, config, &tag)?; - line_tags.push(Some(LuaDocTag::Field(tag))); - } - DocCommentLine::Return { .. } => { - let LuaDocTag::Return(tag) = tags.next()? else { - return None; - }; - configure_return_doc_tag_token_spacing(&mut formatter, config, &tag)?; - line_tags.push(Some(LuaDocTag::Return(tag))); - } - _ => line_tags.push(None), - } - } - - if tags.next().is_some() { - return None; - } - - let mut applied_group = false; - let mut index = 0; - while index < lines.len() { - let Some((kind, group_end)) = find_interleaved_aligned_group(config, &lines, index) else { - index += 1; - continue; - }; - - applied_group = true; - match kind { - AlignableDocTagKind::Class - | AlignableDocTagKind::Alias - | AlignableDocTagKind::Type - | AlignableDocTagKind::Generic - | AlignableDocTagKind::Overload => apply_declaration_alignment_group( - &mut formatter, - &lines[index..group_end], - &line_tags[index..group_end], - gap, - )?, - AlignableDocTagKind::Param => apply_param_alignment_group( - &mut formatter, - &lines[index..group_end], - &line_tags[index..group_end], - gap, - )?, - AlignableDocTagKind::Field => apply_field_alignment_group( - &mut formatter, - &lines[index..group_end], - &line_tags[index..group_end], - gap, - )?, - AlignableDocTagKind::Return => apply_return_alignment_group( - &mut formatter, - &lines[index..group_end], - &line_tags[index..group_end], - gap, - )?, - } - - index = group_end; - } - - if !applied_group { - return None; - } - - Some(formatter.render_comment(comment)) -} - -fn configure_doc_tag_token_spacing( - formatter: &mut CommentFormatter, - config: &LuaFormatConfig, - tag_syntax: &LuaSyntaxNode, - middle_token: Option, - body_first_token: Option, - inline_description: Option, -) -> Option<()> { - let tag_token = tag_syntax.first_token()?; - formatter.add_token_right_expected( - emmylua_parser::LuaSyntaxId::from_token(&tag_token), - TokenExpected::Space(config.emmy_doc.tag_spacing.max(1)), - ); - - if let Some(middle_token) = middle_token { - formatter.add_token_right_expected( - emmylua_parser::LuaSyntaxId::from_token(&middle_token), - TokenExpected::Space(1), - ); - } - - if let Some(body_first_token) = body_first_token { - formatter.add_token_left_expected( - emmylua_parser::LuaSyntaxId::from_token(&body_first_token), - TokenExpected::Space(1), - ); - } - - if let Some(description) = inline_description { - normalize_doc_description_tokens(formatter, &description); - if let Some(first_description_token) = first_non_whitespace_token(&description) { - formatter.add_token_left_expected( - emmylua_parser::LuaSyntaxId::from_token(&first_description_token), - TokenExpected::Space(1), - ); - } - } - - Some(()) -} - -fn configure_param_doc_tag_token_spacing( - formatter: &mut CommentFormatter, - config: &LuaFormatConfig, - tag: &LuaDocTagParam, -) -> Option<()> { - configure_doc_tag_token_spacing( - formatter, - config, - &tag.syntax().clone(), - tag.get_name_token().map(|token| token.syntax().clone()), - tag.get_type().and_then(|ty| ty.syntax().first_token()), - find_inline_doc_description_after(tag.syntax()), - ) -} - -fn configure_declaration_doc_tag_token_spacing( - formatter: &mut CommentFormatter, - config: &LuaFormatConfig, - tag_syntax: &LuaSyntaxNode, - body_first_token: Option, - inline_description: Option, -) -> Option<()> { - configure_doc_tag_token_spacing( - formatter, - config, - tag_syntax, - None, - body_first_token, - inline_description, - ) -} - -fn configure_generic_decl_token_spacing( - formatter: &mut CommentFormatter, - generic_decl_syntax: &LuaSyntaxNode, -) { - for element in generic_decl_syntax.descendants_with_tokens() { - let Some(token) = element.into_token() else { - continue; - }; - - let syntax_id = LuaSyntaxId::from_token(&token); - match token.kind().to_token() { - LuaTokenKind::TkLt | LuaTokenKind::TkGt => { - formatter.add_token_left_expected(syntax_id, TokenExpected::Space(0)); - formatter.add_token_right_expected(syntax_id, TokenExpected::Space(0)); - } - LuaTokenKind::TkComma => { - formatter.add_token_left_expected(syntax_id, TokenExpected::Space(0)); - formatter.add_token_right_expected(syntax_id, TokenExpected::Space(1)); - } - _ => {} - } - } -} - -fn configure_field_doc_tag_token_spacing( - formatter: &mut CommentFormatter, - config: &LuaFormatConfig, - tag: &LuaDocTagField, -) -> Option<()> { - let tag_token = tag.syntax().first_token()?; - formatter.add_token_right_expected( - LuaSyntaxId::from_token(&tag_token), - TokenExpected::Space(config.emmy_doc.tag_spacing.max(1)), - ); - - if let Some(body_first_token) = tag.get_type().and_then(|ty| ty.syntax().first_token()) { - formatter.add_token_left_expected( - LuaSyntaxId::from_token(&body_first_token), - TokenExpected::Space(1), - ); - } - - if let Some(description) = find_inline_doc_description_after(tag.syntax()) { - normalize_doc_description_tokens(formatter, &description); - if let Some(first_description_token) = first_non_whitespace_token(&description) { - formatter.add_token_left_expected( - LuaSyntaxId::from_token(&first_description_token), - TokenExpected::Space(1), - ); - } - } - - Some(()) -} - -fn configure_return_doc_tag_token_spacing( - formatter: &mut CommentFormatter, - config: &LuaFormatConfig, - tag: &LuaDocTagReturn, -) -> Option<()> { - let tag_token = tag.syntax().first_token()?; - formatter.add_token_right_expected( - LuaSyntaxId::from_token(&tag_token), - TokenExpected::Space(config.emmy_doc.tag_spacing.max(1)), - ); - - if let Some(body_first_token) = tag - .get_first_type() - .and_then(|ty| ty.syntax().first_token()) - { - formatter.add_token_left_expected( - LuaSyntaxId::from_token(&body_first_token), - TokenExpected::Space(1), - ); - } - - if let Some(description) = find_inline_doc_description_after(tag.syntax()) { - normalize_doc_description_tokens(formatter, &description); - if let Some(first_description_token) = first_non_whitespace_token(&description) { - formatter.add_token_left_expected( - LuaSyntaxId::from_token(&first_description_token), - TokenExpected::Space(1), - ); - } - } - - Some(()) -} - -fn apply_param_alignment_group( - formatter: &mut CommentFormatter, - lines: &[DocCommentLine], - tags: &[Option], - gap: usize, -) -> Option<()> { - 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); - - for (line, tag) in lines.iter().zip(tags.iter()) { - let (name, ty, tag) = match (line, tag) { - (DocCommentLine::Param { name, ty, .. }, Some(LuaDocTag::Param(tag))) => { - (name, ty, tag) - } - _ => continue, - }; - - let body_first_token = tag.get_type().and_then(|ty| ty.syntax().first_token())?; - formatter.add_token_left_alignment_expected( - LuaSyntaxId::from_token(&body_first_token), - TokenExpected::Space(gap + max_name.saturating_sub(name.len())), - ); - - if let Some(description) = find_inline_doc_description_after(tag.syntax()) - && let Some(first_description_token) = first_non_whitespace_token(&description) - { - formatter.add_token_left_alignment_expected( - LuaSyntaxId::from_token(&first_description_token), - TokenExpected::Space(gap + max_type.saturating_sub(ty.len())), - ); - } - } - - Some(()) -} - -fn apply_declaration_alignment_group( - formatter: &mut CommentFormatter, - lines: &[DocCommentLine], - tags: &[Option], - gap: usize, -) -> Option<()> { - let max_body = lines - .iter() - .filter_map(|line| match line { - DocCommentLine::Class { body, .. } - | DocCommentLine::Alias { body, .. } - | DocCommentLine::Type { body, .. } - | DocCommentLine::Generic { body, .. } - | DocCommentLine::Overload { body, .. } => Some(body.len()), - _ => None, - }) - .max() - .unwrap_or(0); - - for (line, tag) in lines.iter().zip(tags.iter()) { - let (body, tag_syntax) = match (line, tag) { - (DocCommentLine::Class { body, .. }, Some(LuaDocTag::Class(tag))) => { - (body, tag.syntax()) - } - (DocCommentLine::Alias { body, .. }, Some(LuaDocTag::Alias(tag))) => { - (body, tag.syntax()) - } - (DocCommentLine::Type { body, .. }, Some(LuaDocTag::Type(tag))) => (body, tag.syntax()), - (DocCommentLine::Generic { body, .. }, Some(LuaDocTag::Generic(tag))) => { - (body, tag.syntax()) - } - (DocCommentLine::Overload { body, .. }, Some(LuaDocTag::Overload(tag))) => { - (body, tag.syntax()) - } - _ => continue, - }; - - if let Some(description) = find_inline_doc_description_after(tag_syntax) - && let Some(first_description_token) = first_non_whitespace_token(&description) - { - formatter.add_token_left_alignment_expected( - LuaSyntaxId::from_token(&first_description_token), - TokenExpected::Space(gap + max_body.saturating_sub(body.len())), - ); - } - } - - Some(()) -} - -fn apply_field_alignment_group( - formatter: &mut CommentFormatter, - lines: &[DocCommentLine], - tags: &[Option], - gap: usize, -) -> Option<()> { - 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); - - for (line, tag) in lines.iter().zip(tags.iter()) { - let (key, ty, tag) = match (line, tag) { - (DocCommentLine::Field { key, ty, .. }, Some(LuaDocTag::Field(tag))) => (key, ty, tag), - _ => continue, - }; - - let body_first_token = tag.get_type().and_then(|ty| ty.syntax().first_token())?; - formatter.add_token_left_alignment_expected( - LuaSyntaxId::from_token(&body_first_token), - TokenExpected::Space(gap + max_key.saturating_sub(key.len())), - ); - - if let Some(description) = find_inline_doc_description_after(tag.syntax()) - && let Some(first_description_token) = first_non_whitespace_token(&description) - { - formatter.add_token_left_alignment_expected( - LuaSyntaxId::from_token(&first_description_token), - TokenExpected::Space(gap + max_type.saturating_sub(ty.len())), - ); - } - } - - Some(()) -} - -fn apply_return_alignment_group( - formatter: &mut CommentFormatter, - lines: &[DocCommentLine], - tags: &[Option], - gap: usize, -) -> Option<()> { - let max_body = lines - .iter() - .filter_map(|line| match line { - DocCommentLine::Return { body, .. } => Some(body.len()), - _ => None, - }) - .max() - .unwrap_or(0); - - for (line, tag) in lines.iter().zip(tags.iter()) { - let (body, tag) = match (line, tag) { - (DocCommentLine::Return { body, .. }, Some(LuaDocTag::Return(tag))) => (body, tag), - _ => continue, - }; - - if let Some(description) = find_inline_doc_description_after(tag.syntax()) - && let Some(first_description_token) = first_non_whitespace_token(&description) - { - formatter.add_token_left_alignment_expected( - LuaSyntaxId::from_token(&first_description_token), - TokenExpected::Space(gap + max_body.saturating_sub(body.len())), - ); - } - } - - Some(()) -} - -fn normalize_doc_description_tokens(formatter: &mut CommentFormatter, description: &LuaSyntaxNode) { - for element in description.descendants_with_tokens() { - let Some(token) = element.into_token() else { - continue; - }; - - if token.kind().to_token() == LuaTokenKind::TkDocDetail { - formatter.add_token_replace( - emmylua_parser::LuaSyntaxId::from_token(&token), - normalize_single_line_spaces(token.text()), - ); - } - } -} - -fn first_non_whitespace_token(node: &LuaSyntaxNode) -> Option { - node.descendants_with_tokens() - .filter_map(|element| element.into_token()) - .find(|token| { - token.kind().to_token() != LuaTokenKind::TkWhitespace - && token.kind().to_token() != LuaTokenKind::TkEndOfLine - }) -} - -fn find_inline_doc_description_after(node: &LuaSyntaxNode) -> Option { - let mut next_sibling = node.next_sibling_or_token(); - for _ in 0..=3 { - let sibling = next_sibling.as_ref()?; - match sibling.kind() { - LuaKind::Token(LuaTokenKind::TkWhitespace) => {} - LuaKind::Syntax(LuaSyntaxKind::DocDescription) => { - return sibling.clone().into_node(); - } - _ => return None, - } - next_sibling = sibling.next_sibling_or_token(); - } - - None -} - -#[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, - preserve_description_raw: bool, -} - -#[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 { - 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 => { - append_doc_description_lines(&mut lines, &mut pending, &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 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 { - trim_end_owned(current_text.as_str()) - } 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 { - trim_end_owned(current_text.as_str()) - } 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(text) = description { - if preserve_description_raw { - DocCommentLine::Raw(trim_end_owned(format!("{prefix}{text}"))) - } else if text.is_empty() { - DocCommentLine::Raw(trim_end_owned(prefix.as_str())) - } else { - DocCommentLine::Description(text) - } - } else if prefix.is_empty() { - DocCommentLine::Empty - } else { - DocCommentLine::Raw(trim_end_owned(prefix.as_str())) - } -} - -fn build_doc_tag_line(prefix: &str, tag: LuaDocTag, description: Option) -> DocCommentLine { - if !is_structured_doc_tag_prefix(prefix) { - return raw_doc_tag_line(prefix, tag.syntax().text().to_string(), description); - } - - match tag { - LuaDocTag::Class(class_tag) => build_class_doc_line(&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(&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(&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(&generic, description.clone()) - .unwrap_or_else(|| { - raw_doc_tag_line(prefix, generic.syntax().text().to_string(), description) - }), - LuaDocTag::Overload(overload) => build_overload_doc_line(&overload, description.clone()) - .unwrap_or_else(|| { - raw_doc_tag_line(prefix, overload.syntax().text().to_string(), description) - }), - LuaDocTag::Param(param) => build_param_doc_line(¶m, description.clone()) - .unwrap_or_else(|| { - raw_doc_tag_line(prefix, param.syntax().text().to_string(), description) - }), - LuaDocTag::Field(field) => build_field_doc_line(&field, description.clone()) - .unwrap_or_else(|| { - raw_doc_tag_line(prefix, field.syntax().text().to_string(), description) - }), - LuaDocTag::Return(ret) => { - build_return_doc_line(&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( - 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 = non_empty_description_text(description); - Some(DocCommentLine::Class { body, desc }) -} - -fn build_alias_doc_line( - tag: &LuaDocTagAlias, - description: Option, -) -> Option { - let body = raw_doc_tag_body_text("alias", tag)?; - let desc = non_empty_description_text(description); - Some(DocCommentLine::Alias { body, desc }) -} - -fn build_type_doc_line(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 = non_empty_description_text(description); - Some(DocCommentLine::Type { - body: parts.join(", "), - desc, - }) -} - -fn build_generic_doc_line( - tag: &LuaDocTagGeneric, - description: Option, -) -> Option { - let body = generic_decl_list_text(&tag.get_generic_decl_list()?)?; - let desc = non_empty_description_text(description); - Some(DocCommentLine::Generic { body, desc }) -} - -fn build_overload_doc_line( - tag: &LuaDocTagOverload, - description: Option, -) -> Option { - let body = single_line_syntax_text(&tag.get_type()?)?; - let desc = non_empty_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(trim_end_owned(format!("{prefix}{body}"))); - } - - let mut line = format!("{prefix}{}", normalize_single_line_spaces(&body)); - if let Some(desc) = non_empty_description_text(description) - && !desc.is_empty() - { - line.push(' '); - line.push_str(&desc); - } - DocCommentLine::Raw(line) -} - -fn build_param_doc_line( - 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 = non_empty_description_text(description); - Some(DocCommentLine::Param { name, ty, desc }) -} - -fn build_field_doc_line( - 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 = non_empty_description_text(description); - Some(DocCommentLine::Field { key, ty, desc }) -} - -fn build_return_doc_line( - 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 = non_empty_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 { - Some(normalize_single_line_spaces(&single_line_node_text(node)?)) -} - -fn non_empty_description_text(description: Option) -> Option { - let text = description?; - 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 = single_line_node_text(node)?; - let body = text.trim().strip_prefix(tag_name)?.trim_start(); - Some(trim_end_owned(body)) -} - -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 find_interleaved_aligned_group( - config: &LuaFormatConfig, - lines: &[DocCommentLine], - start: usize, -) -> Option<(AlignableDocTagKind, usize)> { - let mut cursor = start; - let kind = loop { - let line = lines.get(cursor)?; - if let Some(kind) = alignable_doc_tag_kind(line) { - break kind; - } - - if !matches!(line, DocCommentLine::Description(_) | DocCommentLine::Empty) - && !matches!(line, DocCommentLine::Raw(text) if is_raw_doc_description_line(text)) - { - return None; - } - - cursor += 1; - }; - - if !should_align_doc_tag_kind(config, kind) { - return None; - } - - let mut group_end = cursor + 1; - let mut alignable_count = 1usize; - while group_end < lines.len() { - if alignable_doc_tag_kind(&lines[group_end]) == Some(kind) { - alignable_count += 1; - group_end += 1; - continue; - } - - if should_keep_doc_line_inside_aligned_group(&lines[group_end], kind) { - group_end += 1; - continue; - } - - break; - } - - (alignable_count >= 2).then_some((kind, group_end)) -} - -fn should_keep_doc_line_inside_aligned_group( - line: &DocCommentLine, - _kind: AlignableDocTagKind, -) -> bool { - match line { - DocCommentLine::Description(_) | DocCommentLine::Empty => true, - DocCommentLine::Raw(text) if is_raw_doc_description_line(text) => true, - _ => false, - } -} - -fn is_raw_doc_description_line(text: &str) -> bool { - let trimmed = text.trim(); - trimmed == "---" - || (dash_prefix_len(trimmed) == 3 - && !trimmed.starts_with("---@") - && !trimmed.starts_with("--- @")) -} - -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_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}") - } - } - DocCommentLine::Raw(text) => normalize_embedded_doc_prefixes(config, text), - DocCommentLine::Class { body, desc } => { - render_structured_doc_line(config, "class", body, desc.as_deref()) - } - DocCommentLine::Alias { body, desc } => render_structured_doc_line( - config, - "alias", - &normalize_embedded_doc_prefixes(config, body), - desc.as_deref(), - ), - DocCommentLine::Type { body, desc } => { - render_structured_doc_line(config, "type", body, desc.as_deref()) - } - DocCommentLine::Generic { body, desc } => { - render_structured_doc_line(config, "generic", body, desc.as_deref()) - } - DocCommentLine::Overload { body, desc } => { - render_structured_doc_line(config, "overload", body, desc.as_deref()) - } - DocCommentLine::Param { name, ty, desc } => render_structured_doc_line( - config, - "param", - &format!("{name}{gap}{ty}"), - desc.as_deref(), - ), - DocCommentLine::Field { key, ty, desc } => { - render_structured_doc_line(config, "field", &format!("{key}{gap}{ty}"), desc.as_deref()) - } - DocCommentLine::Return { body, desc } => { - render_structured_doc_line(config, "return", body, desc.as_deref()) - } - } -} - -fn render_structured_doc_line( - config: &LuaFormatConfig, - tag_name: &str, - body: &str, - desc: Option<&str>, -) -> String { - let gap = " ".repeat(config.emmy_doc.tag_spacing.max(1)); - let mut rendered = format!( - "{}{gap}{body}", - normalized_doc_tag_with_name_prefix(config, tag_name) - ); - if let Some(desc) = desc { - rendered.push_str(&gap); - rendered.push_str(desc); - } - rendered -} - -fn normalized_comment_prefix(config: &LuaFormatConfig, prefix_text: &str) -> Option { - match dash_prefix_len(prefix_text) { - 2 => Some(if config.comments.space_after_comment_dash { - "-- ".to_string() - } else { - "--".to_string() - }), - 3 => Some(if config.emmy_doc.space_after_description_dash { - "--- ".to_string() - } else { - "---".to_string() - }), - _ => None, - } -} - -fn normalized_doc_tag_prefix(config: &LuaFormatConfig) -> String { - if config.emmy_doc.space_after_description_dash { - "--- @".to_string() - } else { - "---@".to_string() - } -} - -fn normalized_doc_tag_with_name_prefix(config: &LuaFormatConfig, tag_name: &str) -> String { - format!("{}{tag_name}", normalized_doc_tag_prefix(config)) -} - -fn normalized_doc_continue_prefix(config: &LuaFormatConfig, prefix_text: &str) -> String { - if prefix_text == "---" || prefix_text == "--- " { - if config.emmy_doc.space_after_description_dash { - "--- ".to_string() - } else { - "---".to_string() - } - } else { - prefix_text.to_string() - } -} - -fn normalized_doc_continue_or_prefix(config: &LuaFormatConfig, prefix_text: &str) -> String { - if !prefix_text.starts_with("---") { - return prefix_text.to_string(); - } - - let suffix = prefix_text[3..].trim_start(); - if config.emmy_doc.space_after_description_dash { - format!("--- {suffix}") - } else { - format!("---{suffix}") - } -} - -fn is_structured_doc_tag_prefix(prefix: &str) -> bool { - let trimmed = prefix.trim_end(); - trimmed == "---@" || trimmed == "---" -} - -fn normalize_raw_doc_line(config: &LuaFormatConfig, text: &str) -> String { - let Some(rest) = text.strip_prefix("---@") else { - if let Some(rest) = text.strip_prefix("--- @") { - return if config.emmy_doc.space_after_description_dash { - format!("--- @{rest}") - } else { - format!("---@{rest}") - }; - } - - if let Some(rest) = text.strip_prefix("---|") { - return if config.emmy_doc.space_after_description_dash { - format!("--- |{rest}") - } else { - format!("---|{rest}") - }; - } - - if let Some(rest) = text.strip_prefix("--- |") { - return if config.emmy_doc.space_after_description_dash { - format!("--- |{rest}") - } else { - format!("---|{rest}") - }; - } - - return text.to_string(); - }; - - if config.emmy_doc.space_after_description_dash { - format!("--- @{rest}") - } else { - format!("---@{rest}") - } -} - -fn normalize_embedded_doc_prefixes(config: &LuaFormatConfig, text: &str) -> String { - text.lines() - .map(|line| normalize_raw_doc_line(config, line)) - .collect::>() - .join("\n") -} - -fn trim_end_owned(text: impl ToString) -> String { - let mut text = text.to_string(); - let trimmed_len = text.trim_end().len(); - text.truncate(trimmed_len); - text -} - -fn has_nonstandard_dash_prefix(comment: &LuaComment) -> bool { - let Some(first_token) = comment.syntax().first_token() else { - return false; - }; - - if !matches!(first_token.kind().into(), LuaTokenKind::TkNormalStart) { - return false; - } - - let dash_len = dash_prefix_len(first_token.text()); - if dash_len > 3 { - return true; - } - - dash_len == 3 - && !first_token - .text() - .chars() - .last() - .is_some_and(char::is_whitespace) - && comment - .syntax() - .descendants_with_tokens() - .filter_map(|element| element.into_token()) - .skip(1) - .take_while(|token| token.kind().to_token() != LuaTokenKind::TkEndOfLine) - .find(|token| token.kind().to_token() != LuaTokenKind::TkWhitespace) - .is_some_and(|token| token.text().starts_with('-')) -} - -fn is_doc_normal_start(prefix_text: &str) -> bool { - dash_prefix_len(prefix_text) == 3 -} - -fn dash_prefix_len(prefix_text: &str) -> usize { - prefix_text.bytes().take_while(|byte| *byte == b'-').count() -} - -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 is_parent_syntax(token: &emmylua_parser::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: &emmylua_parser::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/formatter_new/expr.rs b/crates/emmylua_formatter/src/formatter/expr.rs similarity index 100% rename from crates/emmylua_formatter/src/formatter_new/expr.rs rename to crates/emmylua_formatter/src/formatter/expr.rs diff --git a/crates/emmylua_formatter/src/formatter/expression.rs b/crates/emmylua_formatter/src/formatter/expression.rs deleted file mode 100644 index b7fc4b2c7..000000000 --- a/crates/emmylua_formatter/src/formatter/expression.rs +++ /dev/null @@ -1,2999 +0,0 @@ -use emmylua_parser::{ - BinaryOperator, LuaAstNode, LuaAstToken, LuaBinaryExpr, LuaCallExpr, LuaClosureExpr, - 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, QuoteStyle, SingleArgCallParens}; -use crate::ir::{self, AlignEntry, DocIR, EqSplit, ir_flat_width, ir_has_forced_line_break}; - -use super::FormatContext; -use super::comments::{ - extract_trailing_comment, format_comment, should_keep_comment_inline_in_expression, - trailing_comment_prefix, -}; -use super::sequence::{ - DelimitedSequenceAttachments, DelimitedSequenceCommentState, DelimitedSequenceLayout, - DelimitedSequenceMultilineWrapOptions, SequenceEntry, SequenceLayoutCandidates, - SequenceLayoutPolicy, build_delimited_sequence_break_candidate, - build_delimited_sequence_default_break_candidate, build_delimited_sequence_flat_candidate, - choose_sequence_layout, format_delimited_sequence, push_comment_lines, render_sequence, - sequence_ends_with_comment, sequence_has_comment, sequence_starts_with_comment, - wrap_multiline_delimited_sequence_docs, -}; -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::{ - has_non_trivia_before_on_same_line_tokenwise, node_has_direct_comment_child, - source_line_prefix_width, trailing_gap_requests_alignment, -}; - -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, -} - -struct InlineCommentFragment { - docs: Vec, - same_line_before: bool, -} - -struct CallArgsRenderPlan { - docs: Vec, - inline_space_before: bool, -} - -struct CallExprShellPlan { - prefix: Vec, - comments: Vec, - args: CallArgsRenderPlan, -} - -struct ClosureExprShellPlan { - params: Vec, - before_params_comments: Vec, - before_body_comments: Vec, -} - -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), - } -} - -fn format_table_expr_with_forced_expand( - ctx: &FormatContext, - expr: &LuaTableExpr, - force_expand_from_context: bool, -) -> Vec { - if expr.is_empty() { - return vec![ - tok(LuaTokenKind::TkLeftBrace), - tok(LuaTokenKind::TkRightBrace), - ]; - } - - let collected = collect_table_layout(ctx, expr); - let entries = collected.entries; - let attachments = collected.attachments; - let has_standalone_comments = collected.has_standalone_comments; - - let trailing = format_trailing_comma_ir(ctx.config.trailing_table_comma()); - - let space_inside = if ctx.config.spacing.space_inside_braces { - ir::soft_line() - } else { - ir::soft_line_or_empty() - }; - - let has_trailing_comments = entries.iter().any(|e| { - matches!( - e, - TableEntry::Field { - trailing_comment: Some(_), - .. - } - ) - }); - - let has_multiline_field_docs = entries.iter().any(|entry| match entry { - TableEntry::Field { - doc, force_expand, .. - } => *force_expand || ir_has_forced_line_break(doc), - }); - - let force_expand = force_expand_from_context - || has_standalone_comments - || has_trailing_comments - || attachments.after_open_comment.is_some() - || !attachments.before_close_comments.is_empty() - || has_multiline_field_docs; - - match ctx.config.layout.table_expand { - ExpandStrategy::Always => format_table_multiline_candidates( - ctx, - entries, - &attachments, - trailing, - ctx.config.align.table_field, - true, - has_standalone_comments, - source_line_prefix_width(expr.syntax()), - ), - ExpandStrategy::Never if !force_expand => format_delimited_sequence( - ctx, - DelimitedSequenceLayout { - open: tok(LuaTokenKind::TkLeftBrace), - close: tok(LuaTokenKind::TkRightBrace), - items: entries - .into_iter() - .map(|e| match e { - TableEntry::Field { doc, .. } => doc, - }) - .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 => format_table_multiline_candidates( - ctx, - entries, - &attachments, - trailing, - ctx.config.align.table_field, - true, - has_standalone_comments, - source_line_prefix_width(expr.syntax()), - ), - ExpandStrategy::Auto if force_expand => format_table_multiline_candidates( - ctx, - entries, - &attachments, - trailing, - ctx.config.align.table_field, - true, - has_standalone_comments, - source_line_prefix_width(expr.syntax()), - ), - ExpandStrategy::Auto => { - let flat_field_docs: Vec> = entries - .iter() - .map(|e| match e { - TableEntry::Field { doc, .. } => doc.clone(), - }) - .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 { - 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: has_assign_alignment, - allow_fill: false, - allow_preserve: false, - prefer_preserve_multiline: false, - force_break_on_standalone_comments: false, - prefer_balanced_break_lines: false, - first_line_prefix_width: source_line_prefix_width(expr.syntax()), - }, - ) - } else { - format_delimited_sequence(ctx, layout) - } - } - } -} - -struct CollectedTableLayout { - entries: Vec, - attachments: DelimitedSequenceAttachments, - has_standalone_comments: bool, -} - -fn collect_table_layout(ctx: &FormatContext, expr: &LuaTableExpr) -> CollectedTableLayout { - let mut entries = Vec::new(); - let mut comment_state = DelimitedSequenceCommentState::default(); - - for child in expr.syntax().children() { - if let Some(field) = LuaTableField::cast(child.clone()) { - let fdoc = format_table_field_ir(ctx, &field); - let force_expand = field - .get_value_expr() - .as_ref() - .is_some_and(should_preserve_multiline_table_field_value); - let eq_split = if ctx.config.align.table_field { - format_table_field_eq_split(ctx, &field) - } else { - None - }; - let align_hint = field_requests_alignment(&field); - let (trailing_comment, comment_align_hint) = - if let Some((docs, range)) = extract_trailing_comment(ctx.config, field.syntax()) { - comment_state.record_consumed_comment_range(range); - ( - Some(docs), - trailing_comment_requests_alignment( - field.syntax(), - range, - ctx.config.comments.line_comment_min_spaces_before.max(1), - ), - ) - } else { - (None, false) - }; - entries.push(TableEntry::Field { - leading_comments: comment_state.take_leading_comments(), - doc: fdoc, - eq_split, - force_expand, - align_hint, - comment_align_hint, - trailing_comment, - }); - comment_state.mark_item_seen(); - } else if child.kind() == LuaKind::Syntax(LuaSyntaxKind::Comment) { - let comment = LuaComment::cast(child.clone()).unwrap(); - if comment_state.should_skip_comment(&comment) { - continue; - } - comment_state.handle_comment(ctx, &comment); - } - } - - let (attachments, has_standalone_comments) = comment_state.finish(); - - CollectedTableLayout { - entries, - attachments, - has_standalone_comments, - } -} - -fn format_name_expr(_ctx: &FormatContext, expr: &LuaNameExpr) -> Vec { - 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 { - 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, ... -/// -/// 当表达式太长时,在操作符前断行并缩进: -/// ```text -/// very_long_left -/// + right -/// ``` -fn format_binary_expr(ctx: &FormatContext, expr: &LuaBinaryExpr) -> 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 = 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 mut force_space_before = false; - if op == BinaryOperator::OpConcat - && space_rule == SpaceRule::NoSpace - && let Some(last_token) = left.syntax().last_token() - && last_token.kind() == LuaTokenKind::TkFloat.into() - { - force_space_before = true; - } - - if ir_has_forced_line_break(&left_docs) - && should_attach_short_binary_tail(op, &right, &right_docs) - { - let mut docs = left_docs; - if force_space_before { - docs.push(ir::space()); - } else { - docs.push(space_rule.to_ir()); - } - docs.push(ir::source_token(op_token.syntax().clone())); - docs.push(space_ir); - docs.extend(right_docs); - return docs; - } - - // 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(force_space_before || space_rule != SpaceRule::NoSpace); - - return vec![ir::group(vec![ - ir::list(left_docs), - ir::indent(vec![ - break_ir, - ir::source_token(op_token.syntax().clone()), - space_ir, - ir::list(right_docs), - ]), - ])]; - } - } - - vec![] -} - -fn should_attach_short_binary_tail( - op: BinaryOperator, - right: &LuaExpr, - right_docs: &[DocIR], -) -> bool { - if ir_has_forced_line_break(right_docs) { - return false; - } - - match op { - BinaryOperator::OpEq - | BinaryOperator::OpNe - | BinaryOperator::OpLt - | BinaryOperator::OpLe - | BinaryOperator::OpGt - | BinaryOperator::OpGe => { - ir_flat_width(right_docs) <= 16 - && matches!( - right, - LuaExpr::LiteralExpr(_) | LuaExpr::NameExpr(_) | LuaExpr::ParenExpr(_) - ) - } - BinaryOperator::OpAnd | BinaryOperator::OpOr => { - ir_flat_width(right_docs) <= 24 - && matches!( - right, - LuaExpr::LiteralExpr(_) - | LuaExpr::NameExpr(_) - | LuaExpr::ParenExpr(_) - | LuaExpr::IndexExpr(_) - | LuaExpr::CallExpr(_) - ) - } - _ => false, - } -} - -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 comment_docs = format_comment(ctx.config, &comment); - let entry = if should_keep_comment_inline_in_expression(&comment) { - SequenceEntry::Item(comment_docs) - } else { - SequenceEntry::Comment(comment_docs) - }; - 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 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 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 (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 space_before_segment { - first_chunk.push(ir::space()); - } - first_chunk.extend(segment); - fill_parts.push(ir::list(first_chunk.clone())); - } else { - fill_parts.push(break_ir); - fill_parts.push(ir::list(segment)); - } - - previous = operand; - } - - 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 && *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) { - 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(); - docs.push(ir::source_token(op_token.syntax().clone())); - - // `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 !node_has_direct_comment_child(expr.syntax()) - && let Some(chain) = try_format_chain(ctx, expr) - { - return chain; - } - - render_call_expr_shell(ctx, collect_call_expr_shell_plan(ctx, expr)) -} - -/// 索引表达式: 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(); - - // 前缀 - if let Some(prefix) = expr.get_prefix_expr() { - docs.extend(format_expr(ctx, &prefix)); - } - - // 索引操作符和 key - docs.extend(format_index_access_ir(ctx, expr)); - - 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 comment_docs = format_comment(ctx.config, &comment); - let entry = if should_keep_comment_inline_in_expression(&comment) { - SequenceEntry::Item(comment_docs) - } else { - SequenceEntry::Comment(comment_docs) - }; - 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, - } -} - -fn format_call_args_ir_with_options( - ctx: &FormatContext, - expr: &LuaCallExpr, - preserve_chain_attached_table_source: bool, -) -> Vec { - let plan = format_call_args_render_plan(ctx, expr, preserve_chain_attached_table_source); - let mut docs = Vec::new(); - if plan.inline_space_before { - docs.push(ir::space()); - } - docs.extend(plan.docs); - docs -} - -fn format_call_args_render_plan( - ctx: &FormatContext, - expr: &LuaCallExpr, - preserve_chain_attached_table_source: bool, -) -> CallArgsRenderPlan { - let mut docs = Vec::new(); - - if let Some(args_list) = expr.get_args_list() { - let args: Vec<_> = args_list.get_args().collect(); - if let Some(single_arg_docs) = format_single_arg_call_without_parens(ctx, &args_list, &args) - { - return CallArgsRenderPlan { - docs: single_arg_docs, - inline_space_before: true, - }; - } - - if args.is_empty() { - docs.push(tok(LuaTokenKind::TkLeftParen)); - docs.push(tok(LuaTokenKind::TkRightParen)); - } else { - let collected = collect_call_arg_entries(ctx, &args_list); - let arg_entries = collected.entries; - let arg_attachments = collected.attachments; - let has_standalone_comments = collected.has_standalone_comments; - let has_comments = arg_entries.iter().any(|entry| match entry { - CallArgEntry::Arg { - trailing_comment, - leading_comments, - .. - } => trailing_comment.is_some() || !leading_comments.is_empty(), - }) || arg_attachments.after_open_comment.is_some() - || !arg_attachments.before_close_comments.is_empty(); - 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, align_comments) - } else { - let arg_docs: Vec> = - args.iter().map(|a| format_expr(ctx, a)).collect(); - return CallArgsRenderPlan { - docs: format_delimited_sequence( - ctx, - 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, - }, - ), - inline_space_before: ctx.config.spacing.space_before_call_paren, - }; - }; - docs.extend(wrap_multiline_call_arg_docs( - ctx, - inner, - trailing, - &arg_attachments, - )); - } - ExpandStrategy::Never => { - if has_comments { - let inner = - build_multiline_call_arg_entries(ctx, arg_entries, align_comments); - docs.extend(wrap_multiline_call_arg_docs( - ctx, - inner, - trailing, - &arg_attachments, - )); - } else { - let arg_docs: Vec> = - args.iter().map(|a| format_expr(ctx, a)).collect(); - docs.extend(format_delimited_sequence( - ctx, - 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 { - docs.extend(format_call_args_multiline_candidates( - ctx, - arg_entries, - &arg_attachments, - trailing, - align_comments, - has_standalone_comments, - source_line_prefix_width(args_list.syntax()), - )); - } else { - let attach_first_arg = should_attach_first_call_arg(&args); - let preserve_multiline_args = args_list.syntax().text().contains_char('\n'); - let arg_docs: Vec> = args - .iter() - .enumerate() - .map(|(index, arg)| { - format_call_arg_value_ir( - ctx, - arg, - attach_first_arg, - preserve_multiline_args, - index, - preserve_chain_attached_table_source, - ) - }) - .collect(); - if attach_first_arg { - docs.extend(format_call_args_with_attached_first_arg( - ctx, - arg_docs, - trailing, - preserve_multiline_args, - )); - } else if arg_docs.iter().any(|doc| ir_has_forced_line_break(doc)) { - let multiline_entries = arg_docs - .into_iter() - .enumerate() - .map(|(index, doc)| CallArgEntry::Arg { - leading_comments: Vec::new(), - doc, - trailing_comment: None, - align_hint: false, - has_following_arg: index + 1 < args.len(), - }) - .collect(); - docs.extend(format_call_args_multiline_candidates( - ctx, - multiline_entries, - &DelimitedSequenceAttachments::default(), - trailing, - false, - false, - source_line_prefix_width(args_list.syntax()), - )); - } else { - docs.extend(format_delimited_sequence( - ctx, - 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, - }, - )); - } - } - } - } - } - } - - CallArgsRenderPlan { - docs, - inline_space_before: ctx.config.spacing.space_before_call_paren, - } -} - -fn should_attach_first_call_arg(args: &[LuaExpr]) -> bool { - matches!( - args.first(), - Some(LuaExpr::TableExpr(_) | LuaExpr::ClosureExpr(_)) - ) -} - -fn format_call_arg_value_ir( - ctx: &FormatContext, - arg: &LuaExpr, - attach_first_arg: bool, - preserve_multiline_args: bool, - index: usize, - preserve_chain_attached_table_source: bool, -) -> Vec { - if preserve_multiline_args && arg.syntax().text().contains_char('\n') { - if let LuaExpr::TableExpr(table) = arg { - if preserve_chain_attached_table_source && attach_first_arg && index == 0 { - return format_preserved_multiline_attached_table_arg(ctx, table); - } - - return format_table_expr_with_forced_expand(ctx, table, true); - } - - if attach_first_arg && index == 0 { - return format_expr(ctx, arg); - } - } - - format_expr(ctx, arg) -} - -fn format_preserved_multiline_attached_table_arg( - ctx: &FormatContext, - table: &LuaTableExpr, -) -> Vec { - let text = table.syntax().text().to_string(); - let normalized = normalize_multiline_table_trailing_separator( - text.trim_end_matches(['\r', '\n', ' ', '\t']), - ctx.config.trailing_table_comma(), - ); - - vec![ir::text(normalized)] -} - -fn normalize_multiline_table_trailing_separator( - source: &str, - policy: crate::config::TrailingComma, -) -> String { - let mut normalized = source.to_string(); - let close_index = normalized.rfind('}'); - let Some(close_index) = close_index else { - return normalized; - }; - - let before_close = &normalized[..close_index]; - let content_end = before_close.trim_end_matches(['\r', '\n', ' ', '\t']).len(); - if content_end == 0 { - return normalized; - } - - let has_trailing_comma = normalized[..content_end].ends_with(','); - match policy { - crate::config::TrailingComma::Never => { - if has_trailing_comma { - normalized.remove(content_end - 1); - } - } - crate::config::TrailingComma::Always | crate::config::TrailingComma::Multiline => { - if !has_trailing_comma { - normalized.insert(content_end, ','); - } - } - } - - normalized -} - -fn format_call_args_with_attached_first_arg( - ctx: &FormatContext, - arg_docs: Vec>, - trailing: DocIR, - preserve_multiline: bool, -) -> Vec { - let layout = DelimitedSequenceLayout { - open: tok(LuaTokenKind::TkLeftParen), - close: tok(LuaTokenKind::TkRightParen), - items: arg_docs.clone(), - 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.clone(), - custom_break_contents: None, - prefer_custom_break_in_auto: false, - }; - - let flat_docs = build_delimited_sequence_flat_candidate(&layout); - let break_docs = build_call_args_attached_first_break_doc(arg_docs, trailing); - - if preserve_multiline { - break_docs - } else { - let gid = ctx.next_group_id(); - vec![ir::group_with_id( - vec![ir::if_break_with_group( - ir::list(break_docs), - ir::list(flat_docs), - gid, - )], - gid, - )] - } -} - -fn build_call_args_attached_first_break_doc( - arg_docs: Vec>, - trailing: DocIR, -) -> Vec { - if arg_docs.is_empty() { - return vec![]; - } - - let mut docs = vec![tok(LuaTokenKind::TkLeftParen)]; - docs.extend(arg_docs[0].clone()); - - if arg_docs.len() == 1 { - docs.push(trailing); - docs.push(tok(LuaTokenKind::TkRightParen)); - return vec![ir::group_break(docs)]; - } else { - docs.push(tok(LuaTokenKind::TkComma)); - let mut rest = Vec::new(); - for (index, item_docs) in arg_docs.iter().enumerate().skip(1) { - rest.push(ir::hard_line()); - rest.extend(item_docs.clone()); - if index + 1 < arg_docs.len() { - rest.push(tok(LuaTokenKind::TkComma)); - } - } - rest.push(trailing); - docs.push(ir::indent(rest)); - } - - docs.push(ir::hard_line()); - docs.push(tok(LuaTokenKind::TkRightParen)); - - vec![ir::group_break(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(tok(LuaTokenKind::TkDot)); - if let Some(key) = expr.get_index_key() { - docs.push(ir::text(key.get_path_part())); - } - } else if index_token.is_colon() { - docs.push(tok(LuaTokenKind::TkColon)); - 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(tok(LuaTokenKind::TkLeftBracket)); - if ctx.config.spacing.space_inside_brackets { - docs.push(ir::space()); - } - if let Some(key) = expr.get_index_key() { - match key { - LuaIndexKey::Expr(e) => { - docs.extend(format_expr(ctx, &e)); - } - LuaIndexKey::Integer(n) => { - docs.push(ir::source_token(n.syntax().clone())); - } - LuaIndexKey::String(s) => { - docs.push(ir::source_token(s.syntax().clone())); - } - LuaIndexKey::Name(name) => { - docs.push(ir::source_token(name.syntax().clone())); - } - _ => {} - } - } - if ctx.config.spacing.space_inside_brackets { - docs.push(ir::space()); - } - docs.push(tok(LuaTokenKind::TkRightBracket)); - } - } - - 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_with_options(ctx, call, true); - 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); - - let mut fill_parts = Vec::new(); - for (index, seg) in segments.iter().enumerate() { - let mut segment = Vec::new(); - segment.extend(seg.access.clone()); - if let Some(args) = &seg.call_args { - segment.extend(args.clone()); - } - fill_parts.push(ir::list(segment)); - if index + 1 < segments.len() { - fill_parts.push(ir::soft_line_or_empty()); - } - } - - let mut docs = Vec::new(); - docs.extend(base); - docs.push(ir::group(vec![ir::indent(vec![ - ir::soft_line_or_empty(), - ir::fill(fill_parts), - ])])); - - Some(docs) -} - -/// Table literal: {}, { 1, 2, 3 }, { key = value, ... } -fn format_table_expr(ctx: &FormatContext, expr: &LuaTableExpr) -> Vec { - format_table_expr_with_forced_expand(ctx, expr, false) -} - -fn format_table_multiline_candidates( - ctx: &FormatContext, - entries: Vec, - attachments: &DelimitedSequenceAttachments, - trailing: DocIR, - 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(|| { - wrap_multiline_table_docs( - ctx, - build_table_expanded_inner(ctx, &entries, &trailing, true, align_comments), - attachments, - ) - }); - let one_per_line = Some(wrap_multiline_table_docs( - ctx, - build_table_expanded_inner(ctx, &entries, &trailing, false, false), - attachments, - )); - - 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, - }, - ) - } else { - aligned.or(one_per_line).unwrap_or_default() - } -} - -fn continuation_break_ir(flat_space: bool) -> DocIR { - 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(); - - if field.is_assign_field() { - fdoc.extend(format_table_field_key_ir(ctx, field)); - let assign_space = space_around_assign(ctx.config).to_ir(); - fdoc.push(assign_space.clone()); - fdoc.push(tok(LuaTokenKind::TkAssign)); - fdoc.push(assign_space); - - if let Some(value) = field.get_value_expr() { - fdoc.extend(format_table_field_value_ir(ctx, &value)); - } - } else { - // value only - if let Some(value) = field.get_value_expr() { - fdoc.extend(format_table_field_value_ir(ctx, &value)); - } - } - - fdoc -} - -fn format_table_field_value_ir(ctx: &FormatContext, value: &LuaExpr) -> Vec { - if let LuaExpr::TableExpr(table) = value - && should_preserve_multiline_table_field_table_value(value) - { - format_table_expr_with_forced_expand(ctx, table, true) - } else { - format_expr(ctx, value) - } -} - -/// 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 { - LuaIndexKey::Name(name) => { - docs.push(ir::source_token(name.syntax().clone())); - } - LuaIndexKey::String(s) => { - docs.push(tok(LuaTokenKind::TkLeftBracket)); - docs.push(ir::source_token(s.syntax().clone())); - docs.push(tok(LuaTokenKind::TkRightBracket)); - } - LuaIndexKey::Integer(n) => { - docs.push(tok(LuaTokenKind::TkLeftBracket)); - docs.push(ir::source_token(n.syntax().clone())); - docs.push(tok(LuaTokenKind::TkRightBracket)); - } - LuaIndexKey::Expr(e) => { - docs.push(tok(LuaTokenKind::TkLeftBracket)); - docs.extend(format_expr(ctx, e)); - docs.push(tok(LuaTokenKind::TkRightBracket)); - } - 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; - } - - if field - .get_value_expr() - .as_ref() - .is_some_and(should_preserve_multiline_table_field_value) - { - return None; - } - - let before = format_table_field_key_ir(ctx, field); - if before.is_empty() { - return None; - } - - let assign_space = space_around_assign(ctx.config).to_ir(); - let mut after = vec![tok(LuaTokenKind::TkAssign), assign_space]; - if let Some(value) = field.get_value_expr() { - after.extend(format_expr(ctx, &value)); - } - - Some((before, after)) -} - -fn should_preserve_multiline_table_field_value(expr: &LuaExpr) -> bool { - matches!(expr, LuaExpr::ClosureExpr(_) | LuaExpr::TableExpr(_)) - && expr.syntax().text().contains_char('\n') -} - -fn should_preserve_multiline_table_field_table_value(expr: &LuaExpr) -> bool { - matches!(expr, LuaExpr::TableExpr(_)) && expr.syntax().text().contains_char('\n') -} - -/// Table entry: field or standalone comment -enum TableEntry { - Field { - leading_comments: Vec>, - doc: Vec, - /// Split at `=` for alignment: (key_docs, eq_value_docs) - eq_split: Option, - /// The field value should keep its multiline source shape, so the outer - /// table must not stay in a flat candidate. - force_expand: bool, - /// 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>, - }, -} - -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. -fn build_table_expanded_inner( - ctx: &FormatContext, - entries: &[TableEntry], - trailing: &DocIR, - align_eq: bool, - align_comments: 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 { - leading_comments, - eq_split: Some(_), - .. - } if leading_comments.is_empty() => { - group_end += 1; - } - _ => break, - } - } - - if group_end - group_start >= 2 - && table_group_requests_alignment(&entries[group_start..group_end]) - { - let TableEntry::Field { - leading_comments, .. - } = &entries[group_start]; - push_comment_lines(&mut inner, leading_comments); - 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, - .. - } => { - 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(tok(LuaTokenKind::TkComma)); - } - 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, - }); - } - TableEntry::Field { - doc, - align_hint: _, - comment_align_hint: _, - 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(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, - }); - } - } - } - } - inner.push(ir::align_group(align_entries)); - i = group_end; - continue; - } - } - - match &entries[i] { - TableEntry::Field { - leading_comments, - doc, - align_hint: _, - comment_align_hint: _, - trailing_comment, - .. - } => { - push_comment_lines(&mut inner, leading_comments); - 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(tok(LuaTokenKind::TkComma)); - } - if let Some(comment_docs) = trailing_comment { - let mut suffix = trailing_comment_prefix(ctx.config); - suffix.extend(comment_docs.clone()); - inner.push(ir::line_suffix(suffix)); - } - } - } - i += 1; - } - } else { - for (i, entry) in entries.iter().enumerate() { - match entry { - TableEntry::Field { - leading_comments, - doc, - align_hint: _, - comment_align_hint: _, - trailing_comment, - .. - } => { - push_comment_lines(&mut inner, leading_comments); - 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(tok(LuaTokenKind::TkComma)); - } - - if let Some(comment_docs) = trailing_comment { - let mut suffix = trailing_comment_prefix(ctx.config); - suffix.extend(comment_docs.clone()); - inner.push(ir::line_suffix(suffix)); - } - } - } - } - } - - inner -} - -/// 匿名函数: function(params) ... end -fn format_closure_expr(ctx: &FormatContext, expr: &LuaClosureExpr) -> Vec { - render_closure_expr_shell(ctx, expr, collect_closure_expr_shell_plan(ctx, expr)) -} - -/// 括号表达式: (expr) -fn format_paren_expr(ctx: &FormatContext, expr: &LuaParenExpr) -> Vec { - 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.spacing.space_inside_parens { - docs.push(ir::space()); - } - 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()) - { - let comment_docs = format_comment(ctx.config, &comment); - entries.push(if should_keep_comment_inline_in_expression(&comment) { - SequenceEntry::Item(comment_docs) - } else { - SequenceEntry::Comment(comment_docs) - }); - } - } - _ => { - 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(tok(LuaTokenKind::TkComma), ir::list(vec![])), - TrailingComma::Always => tok(LuaTokenKind::TkComma), - } -} - -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 collect_call_expr_shell_plan(ctx: &FormatContext, expr: &LuaCallExpr) -> CallExprShellPlan { - let mut prefix = Vec::new(); - let mut comments = Vec::new(); - - for child in expr.syntax().children() { - if let Some(prefix_expr) = LuaExpr::cast(child.clone()) { - prefix = format_expr(ctx, &prefix_expr); - } else if let Some(comment) = LuaComment::cast(child) { - comments.push(InlineCommentFragment { - docs: format_comment(ctx.config, &comment), - same_line_before: has_non_trivia_before_on_same_line_tokenwise(comment.syntax()), - }); - } - } - - CallExprShellPlan { - prefix, - comments, - args: format_call_args_render_plan(ctx, expr, false), - } -} - -fn render_call_expr_shell(ctx: &FormatContext, plan: CallExprShellPlan) -> Vec { - let mut docs = plan.prefix; - - if plan.comments.is_empty() { - if plan.args.inline_space_before { - docs.push(ir::space()); - } - docs.extend(plan.args.docs); - return docs; - } - - let mut broke_before_args = false; - for comment in plan.comments { - if comment.same_line_before && !broke_before_args { - let mut suffix = trailing_comment_prefix(ctx.config); - suffix.extend(comment.docs); - docs.push(ir::line_suffix(suffix)); - } else { - docs.push(ir::hard_line()); - docs.extend(comment.docs); - } - broke_before_args = true; - } - - if broke_before_args { - docs.push(ir::hard_line()); - docs.extend(plan.args.docs); - } else { - if plan.args.inline_space_before { - docs.push(ir::space()); - } - docs.extend(plan.args.docs); - } - - docs -} - -fn collect_closure_expr_shell_plan( - ctx: &FormatContext, - expr: &LuaClosureExpr, -) -> ClosureExprShellPlan { - let mut params = vec![ - tok(LuaTokenKind::TkLeftParen), - tok(LuaTokenKind::TkRightParen), - ]; - let mut before_params_comments = Vec::new(); - let mut before_body_comments = Vec::new(); - let mut seen_params = false; - - for child in expr.syntax().children() { - if let Some(params_list) = emmylua_parser::LuaParamList::cast(child.clone()) { - params = format_param_list_ir(ctx, ¶ms_list); - seen_params = true; - } else if LuaComment::cast(child.clone()).is_some() { - let comment = LuaComment::cast(child).unwrap(); - let fragment = InlineCommentFragment { - docs: format_comment(ctx.config, &comment), - same_line_before: has_non_trivia_before_on_same_line_tokenwise(comment.syntax()), - }; - if seen_params { - before_body_comments.push(fragment); - } else { - before_params_comments.push(fragment); - } - } - } - - ClosureExprShellPlan { - params, - before_params_comments, - before_body_comments, - } -} - -fn render_closure_expr_shell( - ctx: &FormatContext, - expr: &LuaClosureExpr, - plan: ClosureExprShellPlan, -) -> Vec { - let mut docs = vec![tok(LuaTokenKind::TkFunction)]; - let mut broke_before_params = false; - - for comment in plan.before_params_comments { - if comment.same_line_before && !broke_before_params { - let mut suffix = trailing_comment_prefix(ctx.config); - suffix.extend(comment.docs); - docs.push(ir::line_suffix(suffix)); - } else { - docs.push(ir::hard_line()); - docs.extend(comment.docs); - } - broke_before_params = true; - } - - if broke_before_params { - docs.push(ir::hard_line()); - } else if ctx.config.spacing.space_before_func_paren { - docs.push(ir::space()); - } - docs.extend(plan.params); - - let has_comment_before_body = !plan.before_body_comments.is_empty(); - let mut body_comment_lines = Vec::new(); - let mut saw_same_line_body_comment = false; - for comment in plan.before_body_comments { - if comment.same_line_before && body_comment_lines.is_empty() { - let mut suffix = trailing_comment_prefix(ctx.config); - suffix.extend(comment.docs); - docs.push(ir::line_suffix(suffix)); - saw_same_line_body_comment = true; - } else { - body_comment_lines.push(comment.docs); - } - } - - if has_comment_before_body { - let block_docs = expr - .get_block() - .map(|block| super::format_block(ctx, &block)); - if let Some(block_docs) = block_docs - && !block_docs.is_empty() - { - let mut indented = vec![ir::hard_line()]; - for comment_docs in body_comment_lines { - indented.extend(comment_docs); - indented.push(ir::hard_line()); - } - indented.extend(block_docs); - docs.push(ir::indent(indented)); - docs.push(ir::hard_line()); - docs.push(tok(LuaTokenKind::TkEnd)); - return docs; - } - - if !body_comment_lines.is_empty() { - let mut indented = vec![ir::hard_line()]; - let body_comment_count = body_comment_lines.len(); - for (index, comment_docs) in body_comment_lines.into_iter().enumerate() { - indented.extend(comment_docs); - if index + 1 < body_comment_count { - indented.push(ir::hard_line()); - } - } - docs.push(ir::indent(indented)); - docs.push(ir::hard_line()); - docs.push(tok(LuaTokenKind::TkEnd)); - return docs; - } - - if saw_same_line_body_comment { - docs.push(ir::hard_line()); - docs.push(tok(LuaTokenKind::TkEnd)); - return docs; - } - } - - super::format_body_end_with_parent( - ctx, - expr.get_block().as_ref(), - Some(expr.syntax()), - &mut docs, - ); - docs -} - -enum CallArgEntry { - Arg { - leading_comments: Vec>, - doc: Vec, - trailing_comment: Option>, - align_hint: bool, - has_following_arg: bool, - }, -} - -impl Clone for CallArgEntry { - fn clone(&self) -> Self { - match self { - Self::Arg { - leading_comments, - doc, - trailing_comment, - align_hint, - has_following_arg, - } => Self::Arg { - leading_comments: leading_comments.clone(), - doc: doc.clone(), - trailing_comment: trailing_comment.clone(), - align_hint: *align_hint, - has_following_arg: *has_following_arg, - }, - } - } -} - -struct CollectedCallArgLayout { - entries: Vec, - attachments: DelimitedSequenceAttachments, - has_standalone_comments: bool, -} - -fn wrap_multiline_call_arg_docs( - ctx: &FormatContext, - inner: Vec, - trailing: DocIR, - attachments: &DelimitedSequenceAttachments, -) -> Vec { - wrap_multiline_delimited_sequence_docs( - ctx, - tok(LuaTokenKind::TkLeftParen), - tok(LuaTokenKind::TkRightParen), - inner, - Some(trailing), - attachments, - DelimitedSequenceMultilineWrapOptions { - leading_hard_line: true, - indent_before_close_comments: false, - }, - ) -} - -fn format_call_args_multiline_candidates( - ctx: &FormatContext, - entries: Vec, - attachments: &DelimitedSequenceAttachments, - 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( - ctx, - build_multiline_call_arg_entries(ctx, entries.clone(), true), - trailing.clone(), - attachments, - ) - }); - let one_per_line = Some(wrap_multiline_call_arg_docs( - ctx, - build_multiline_call_arg_entries(ctx, entries, false), - trailing, - attachments, - )); - - 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, - }, - ) -} - -fn trailing_comment_requests_alignment( - node: &LuaSyntaxNode, - comment_range: TextRange, - required_min_gap: usize, -) -> bool { - trailing_gap_requests_alignment(node, comment_range, 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, -) -> CollectedCallArgLayout { - let args: Vec<_> = args_list.get_args().collect(); - let mut entries = Vec::new(); - let mut comment_state = DelimitedSequenceCommentState::default(); - let mut arg_index = 0usize; - - 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(ctx.config, arg.syntax()) { - comment_state.record_consumed_comment_range(range); - ( - Some(docs), - trailing_comment_requests_alignment( - arg.syntax(), - range, - ctx.config.comments.line_comment_min_spaces_before.max(1), - ), - ) - } else { - (None, false) - }; - - let has_following_arg = arg_index + 1 < args.len(); - arg_index += 1; - entries.push(CallArgEntry::Arg { - leading_comments: comment_state.take_leading_comments(), - doc: format_expr(ctx, &arg), - trailing_comment, - align_hint, - has_following_arg, - }); - comment_state.mark_item_seen(); - } else if child.kind() == LuaKind::Syntax(LuaSyntaxKind::Comment) - && let Some(comment) = LuaComment::cast(child) - { - if comment_state.should_skip_comment(&comment) { - continue; - } - comment_state.handle_comment(ctx, &comment); - } - } - - let (attachments, has_standalone_comments) = comment_state.finish(); - - CollectedCallArgLayout { - entries, - attachments, - has_standalone_comments, - } -} - -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 { - leading_comments, - mut doc, - trailing_comment, - align_hint: _, - has_following_arg, - } => { - for comment_docs in leading_comments { - align_entries.push(AlignEntry::Line { - content: comment_docs, - trailing: None, - }); - } - if has_following_arg { - doc.push(tok(LuaTokenKind::TkComma)); - } - align_entries.push(AlignEntry::Line { - content: doc, - trailing: trailing_comment, - }); - } - } - } - - return vec![ir::align_group(align_entries)]; - } - - let mut inner = Vec::new(); - - for (index, entry) in entries.into_iter().enumerate() { - if index > 0 { - inner.push(ir::hard_line()); - } - - match entry { - CallArgEntry::Arg { - leading_comments, - doc, - trailing_comment, - align_hint: _, - has_following_arg, - } => { - for comment_docs in leading_comments { - inner.extend(comment_docs); - inner.push(ir::hard_line()); - } - 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)); - } - } - } - } - - inner -} - -/// 格式化函数参数列表(支持参数注释) -/// -/// 当参数之间有注释时,自动强制展开为多行。 -pub fn format_param_list_ir( - ctx: &FormatContext, - params: &emmylua_parser::LuaParamList, -) -> Vec { - let collected = collect_param_entries(ctx, params); - let entries = collected.entries; - let attachments = collected.attachments; - let has_standalone_comments = collected.has_standalone_comments; - if entries.is_empty() { - return vec![ - tok(LuaTokenKind::TkLeftParen), - tok(LuaTokenKind::TkRightParen), - ]; - } - - let has_comments = entries.iter().any(|entry| match entry { - ParamEntry::Param { - trailing_comment, - leading_comments, - .. - } => trailing_comment.is_some() || !leading_comments.is_empty(), - }) || attachments.after_open_comment.is_some() - || !attachments.before_close_comments.is_empty(); - - if has_comments { - let align_comments = ctx.config.should_align_param_line_comments() - && !has_standalone_comments - && param_group_requests_alignment(&entries); - - format_param_multiline_candidates( - ctx, - entries, - &attachments, - 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 - .into_iter() - .map(|entry| match entry { - ParamEntry::Param { doc, .. } => doc, - }) - .collect(); - format_delimited_sequence( - ctx, - 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, - }, - ) - } -} - -enum ParamEntry { - Param { - leading_comments: Vec>, - doc: Vec, - trailing_comment: Option>, - align_hint: bool, - has_following_param: bool, - }, -} - -impl Clone for ParamEntry { - fn clone(&self) -> Self { - match self { - Self::Param { - leading_comments, - doc, - trailing_comment, - align_hint, - has_following_param, - } => Self::Param { - leading_comments: leading_comments.clone(), - doc: doc.clone(), - trailing_comment: trailing_comment.clone(), - align_hint: *align_hint, - has_following_param: *has_following_param, - }, - } - } -} - -struct CollectedParamLayout { - entries: Vec, - attachments: DelimitedSequenceAttachments, - has_standalone_comments: bool, -} - -fn wrap_multiline_param_docs( - ctx: &FormatContext, - inner: Vec, - trailing: DocIR, - attachments: &DelimitedSequenceAttachments, -) -> Vec { - wrap_multiline_delimited_sequence_docs( - ctx, - tok(LuaTokenKind::TkLeftParen), - tok(LuaTokenKind::TkRightParen), - inner, - Some(trailing), - attachments, - DelimitedSequenceMultilineWrapOptions { - leading_hard_line: true, - indent_before_close_comments: false, - }, - ) -} - -fn format_param_multiline_candidates( - ctx: &FormatContext, - entries: Vec, - attachments: &DelimitedSequenceAttachments, - 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(); - for entry in entries.clone() { - let ParamEntry::Param { - leading_comments, - mut doc, - trailing_comment, - align_hint: _, - has_following_param, - } = entry; - for comment_docs in leading_comments { - align_entries.push(AlignEntry::Line { - content: comment_docs, - trailing: None, - }); - } - if has_following_param { - doc.push(tok(LuaTokenKind::TkComma)); - } - align_entries.push(AlignEntry::Line { - content: doc, - trailing: trailing_comment, - }); - } - - wrap_multiline_param_docs( - ctx, - vec![ir::align_group(align_entries)], - trailing.clone(), - attachments, - ) - }); - let one_per_line = Some(wrap_multiline_param_docs( - ctx, - build_multiline_param_entries(ctx, entries), - trailing, - attachments, - )); - - 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, - }, - ) -} - -fn wrap_multiline_table_docs( - ctx: &FormatContext, - inner: Vec, - attachments: &DelimitedSequenceAttachments, -) -> Vec { - wrap_multiline_delimited_sequence_docs( - ctx, - tok(LuaTokenKind::TkLeftBrace), - tok(LuaTokenKind::TkRightBrace), - inner, - None, - attachments, - DelimitedSequenceMultilineWrapOptions { - leading_hard_line: false, - indent_before_close_comments: true, - }, - ) -} - -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, -) -> CollectedParamLayout { - let param_nodes: Vec<_> = params.get_params().collect(); - let mut entries = Vec::new(); - let mut comment_state = DelimitedSequenceCommentState::default(); - 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, align_hint) = - if let Some((docs, range)) = extract_trailing_comment(ctx.config, param.syntax()) { - comment_state.record_consumed_comment_range(range); - ( - Some(docs), - trailing_comment_requests_alignment( - param.syntax(), - range, - ctx.config.comments.line_comment_min_spaces_before.max(1), - ), - ) - } else { - (None, false) - }; - - let has_following_param = param_index + 1 < param_nodes.len(); - param_index += 1; - entries.push(ParamEntry::Param { - leading_comments: comment_state.take_leading_comments(), - doc, - trailing_comment, - align_hint, - has_following_param, - }); - comment_state.mark_item_seen(); - } else if child.kind() == LuaKind::Syntax(LuaSyntaxKind::Comment) - && let Some(comment) = LuaComment::cast(child) - { - if comment_state.should_skip_comment(&comment) { - continue; - } - comment_state.handle_comment(ctx, &comment); - } - } - - let (attachments, has_standalone_comments) = comment_state.finish(); - - CollectedParamLayout { - entries, - attachments, - has_standalone_comments, - } -} - -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 { - leading_comments, - doc, - trailing_comment, - align_hint: _, - has_following_param, - } => { - for comment_docs in leading_comments { - inner.extend(comment_docs); - inner.push(ir::hard_line()); - } - 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)); - } - } - } - } - - inner -} diff --git a/crates/emmylua_formatter/src/formatter_new/layout/mod.rs b/crates/emmylua_formatter/src/formatter/layout/mod.rs similarity index 96% rename from crates/emmylua_formatter/src/formatter_new/layout/mod.rs rename to crates/emmylua_formatter/src/formatter/layout/mod.rs index 7e2289b4a..8a3725992 100644 --- a/crates/emmylua_formatter/src/formatter_new/layout/mod.rs +++ b/crates/emmylua_formatter/src/formatter/layout/mod.rs @@ -311,7 +311,7 @@ mod tests { use emmylua_parser::{LuaAstNode, LuaLanguageLevel, LuaParser, LuaSyntaxKind, ParserConfig}; use crate::config::LuaFormatConfig; - use crate::formatter_new::model::{LayoutNodePlan, StatementExprListLayoutKind}; + use crate::formatter::model::{LayoutNodePlan, StatementExprListLayoutKind}; use super::*; @@ -323,12 +323,12 @@ mod tests { ParserConfig::with_level(LuaLanguageLevel::Lua54), ); let chunk = tree.get_chunk_node(); - let spacing_plan = crate::formatter_new::spacing::analyze_root_spacing( - &crate::formatter_new::FormatContext::new(&config), + let spacing_plan = crate::formatter::spacing::analyze_root_spacing( + &crate::formatter::FormatContext::new(&config), &chunk, ); let plan = analyze_root_layout( - &crate::formatter_new::FormatContext::new(&config), + &crate::formatter::FormatContext::new(&config), &chunk, spacing_plan, ); @@ -356,8 +356,8 @@ mod tests { ParserConfig::with_level(LuaLanguageLevel::Lua54), ); let chunk = tree.get_chunk_node(); - let ctx = crate::formatter_new::FormatContext::new(&config); - let spacing_plan = crate::formatter_new::spacing::analyze_root_spacing(&ctx, &chunk); + let ctx = crate::formatter::FormatContext::new(&config); + let spacing_plan = crate::formatter::spacing::analyze_root_spacing(&ctx, &chunk); let plan = analyze_root_layout(&ctx, &chunk, spacing_plan); let local_stat = chunk @@ -422,8 +422,8 @@ mod tests { ParserConfig::with_level(LuaLanguageLevel::Lua54), ); let chunk = tree.get_chunk_node(); - let ctx = crate::formatter_new::FormatContext::new(&config); - let spacing_plan = crate::formatter_new::spacing::analyze_root_spacing(&ctx, &chunk); + let ctx = crate::formatter::FormatContext::new(&config); + let spacing_plan = crate::formatter::spacing::analyze_root_spacing(&ctx, &chunk); let plan = analyze_root_layout(&ctx, &chunk, spacing_plan); let param_list = chunk diff --git a/crates/emmylua_formatter/src/formatter_new/layout/tree.rs b/crates/emmylua_formatter/src/formatter/layout/tree.rs similarity index 90% rename from crates/emmylua_formatter/src/formatter_new/layout/tree.rs rename to crates/emmylua_formatter/src/formatter/layout/tree.rs index 418bf362c..9fa9fae7f 100644 --- a/crates/emmylua_formatter/src/formatter_new/layout/tree.rs +++ b/crates/emmylua_formatter/src/formatter/layout/tree.rs @@ -1,6 +1,6 @@ use emmylua_parser::{LuaAstNode, LuaChunk, LuaSyntaxId, LuaSyntaxKind, LuaSyntaxNode}; -use crate::formatter_new::model::{CommentLayoutPlan, LayoutNodePlan, SyntaxNodeLayoutPlan}; +use crate::formatter::model::{CommentLayoutPlan, LayoutNodePlan, SyntaxNodeLayoutPlan}; pub fn collect_root_layout_nodes(chunk: &LuaChunk) -> Vec { collect_child_layout_nodes(chunk.syntax()) diff --git a/crates/emmylua_formatter/src/formatter_new/line_breaks.rs b/crates/emmylua_formatter/src/formatter/line_breaks.rs similarity index 100% rename from crates/emmylua_formatter/src/formatter_new/line_breaks.rs rename to crates/emmylua_formatter/src/formatter/line_breaks.rs diff --git a/crates/emmylua_formatter/src/formatter/mod.rs b/crates/emmylua_formatter/src/formatter/mod.rs index fadb8a5cd..92deff3f5 100644 --- a/crates/emmylua_formatter/src/formatter/mod.rs +++ b/crates/emmylua_formatter/src/formatter/mod.rs @@ -1,22 +1,18 @@ -mod block; -mod comments; -mod expression; +mod expr; +mod layout; +mod line_breaks; +mod model; +mod render; mod sequence; -pub mod spacing; -mod statement; -mod tokens; +mod spacing; mod trivia; use std::cell::Cell; use crate::config::LuaFormatConfig; -use crate::ir::{self, DocIR, GroupId}; -use emmylua_parser::{LuaAstNode, LuaChunk, LuaKind, LuaTokenKind}; +use crate::ir::{DocIR, GroupId}; +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, next_group_id: Cell, @@ -37,26 +33,9 @@ impl<'a> FormatContext<'a> { } } -/// Format a chunk (root node of the file) 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)); - } - - // Ensure file ends with a newline - if ctx.config.output.insert_final_newline { - docs.push(DocIR::HardLine); - } - - docs + let spacing_plan = spacing::analyze_root_spacing(ctx, chunk); + let layout_plan = layout::analyze_root_layout(ctx, chunk, spacing_plan); + let final_plan = line_breaks::analyze_root_line_breaks(ctx, chunk, layout_plan); + render::render_root(ctx, chunk, &final_plan) } diff --git a/crates/emmylua_formatter/src/formatter_new/model.rs b/crates/emmylua_formatter/src/formatter/model.rs similarity index 100% rename from crates/emmylua_formatter/src/formatter_new/model.rs rename to crates/emmylua_formatter/src/formatter/model.rs diff --git a/crates/emmylua_formatter/src/formatter_new/render.rs b/crates/emmylua_formatter/src/formatter/render.rs similarity index 98% rename from crates/emmylua_formatter/src/formatter_new/render.rs rename to crates/emmylua_formatter/src/formatter/render.rs index d3d7aa620..a2fa2b0c9 100644 --- a/crates/emmylua_formatter/src/formatter_new/render.rs +++ b/crates/emmylua_formatter/src/formatter/render.rs @@ -5,6 +5,7 @@ use emmylua_parser::{ LuaWhileStat, }; +use crate::formatter::model::StatementExprListLayoutKind; use crate::ir::{self, DocIR}; use super::FormatContext; @@ -175,7 +176,7 @@ fn render_local_stat_new( index == 0 && matches!( expr_list_plan.kind, - super::model::StatementExprListLayoutKind::PreserveFirstMultiline + StatementExprListLayoutKind::PreserveFirstMultiline ), ) }) @@ -246,7 +247,7 @@ fn render_assign_stat_new( index == 0 && matches!( expr_list_plan.kind, - super::model::StatementExprListLayoutKind::PreserveFirstMultiline + StatementExprListLayoutKind::PreserveFirstMultiline ), ) }) @@ -306,7 +307,7 @@ fn render_return_stat_new( index == 0 && matches!( expr_list_plan.kind, - super::model::StatementExprListLayoutKind::PreserveFirstMultiline + StatementExprListLayoutKind::PreserveFirstMultiline ), ) }) @@ -429,7 +430,7 @@ fn render_for_stat_new( index == 0 && matches!( expr_list_plan.kind, - super::model::StatementExprListLayoutKind::PreserveFirstMultiline + StatementExprListLayoutKind::PreserveFirstMultiline ), ) }) @@ -507,7 +508,7 @@ fn render_for_range_stat_new( index == 0 && matches!( expr_list_plan.kind, - super::model::StatementExprListLayoutKind::PreserveFirstMultiline + StatementExprListLayoutKind::PreserveFirstMultiline ), ) }) @@ -1085,7 +1086,7 @@ fn format_local_name_ir_new(local_name: &LuaLocalName) -> Vec { docs } -fn format_statement_expr_list_new( +fn format_statement_expr_list( ctx: &FormatContext, plan: &RootFormatPlan, expr_list_plan: super::model::StatementExprListLayoutPlan, @@ -1180,7 +1181,7 @@ fn render_statement_exprs_new( let leading_docs = token_right_spacing_docs(plan, leading_token); if matches!( expr_list_plan.kind, - super::model::StatementExprListLayoutKind::PreserveFirstMultiline + StatementExprListLayoutKind::PreserveFirstMultiline ) { format_statement_expr_list_with_attached_first_multiline_new( comma_token, @@ -1188,7 +1189,7 @@ fn render_statement_exprs_new( expr_docs, ) } else { - format_statement_expr_list_new( + format_statement_expr_list( ctx, plan, expr_list_plan, @@ -1210,7 +1211,7 @@ fn render_header_exprs_new( let leading_docs = token_right_spacing_docs(plan, leading_token); if matches!( expr_list_plan.kind, - super::model::StatementExprListLayoutKind::PreserveFirstMultiline + StatementExprListLayoutKind::PreserveFirstMultiline ) { format_statement_expr_list_with_attached_first_multiline_new( comma_token, @@ -1218,7 +1219,7 @@ fn render_header_exprs_new( expr_docs, ) } else { - format_statement_expr_list_new( + format_statement_expr_list( ctx, plan, expr_list_plan, diff --git a/crates/emmylua_formatter/src/formatter/sequence.rs b/crates/emmylua_formatter/src/formatter/sequence.rs index 1bbd7710a..dced2f223 100644 --- a/crates/emmylua_formatter/src/formatter/sequence.rs +++ b/crates/emmylua_formatter/src/formatter/sequence.rs @@ -1,57 +1,61 @@ -use emmylua_parser::{LuaAstNode, LuaComment, LuaTokenKind}; -use rowan::TextRange; - use crate::config::ExpandStrategy; use crate::ir::{self, DocIR, ir_flat_width, ir_has_forced_line_break}; use crate::printer::Printer; +#[derive(Clone)] +pub struct SequenceComment { + pub docs: Vec, + pub inline_after_previous: bool, +} + use super::FormatContext; -use super::comments::{format_comment, trailing_comment_prefix}; -use super::trivia::has_non_trivia_before_on_same_line_tokenwise; #[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, - } + Comment(SequenceComment), + Separator { + docs: Vec, + after_docs: Vec, + }, } pub fn render_sequence(docs: &mut Vec, entries: &[SequenceEntry], mut line_start: bool) { - let mut needs_space_before_item = false; + let mut pending_docs_before_item: Vec = Vec::new(); for entry in entries { match entry { SequenceEntry::Item(item_docs) => { - if !line_start && needs_space_before_item { - docs.push(ir::space()); + if !line_start && !pending_docs_before_item.is_empty() { + docs.extend(pending_docs_before_item.clone()); } docs.extend(item_docs.clone()); line_start = false; - needs_space_before_item = false; + pending_docs_before_item.clear(); } - SequenceEntry::Comment(comment_docs) => { - if !line_start { + SequenceEntry::Comment(comment) => { + if comment.inline_after_previous && !line_start { + let mut suffix = vec![ir::space()]; + suffix.extend(comment.docs.clone()); + docs.push(ir::line_suffix(suffix)); + docs.push(ir::hard_line()); + } else { + if !line_start { + docs.push(ir::hard_line()); + } + docs.extend(comment.docs.clone()); docs.push(ir::hard_line()); } - docs.extend(comment_docs.clone()); - docs.push(ir::hard_line()); line_start = true; - needs_space_before_item = false; + pending_docs_before_item.clear(); } SequenceEntry::Separator { docs: separator_docs, - space_after, + after_docs, } => { docs.extend(separator_docs.clone()); line_start = false; - needs_space_before_item = *space_after; + pending_docs_before_item = after_docs.clone(); } } } @@ -60,141 +64,21 @@ pub fn render_sequence(docs: &mut Vec, entries: &[SequenceEntry], mut lin pub fn sequence_has_comment(entries: &[SequenceEntry]) -> bool { entries .iter() - .any(|entry| matches!(entry, SequenceEntry::Comment(_))) + .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(_))) -} - -#[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, -} - -#[derive(Clone, Default)] -pub struct DelimitedSequenceAttachments { - pub after_open_comment: Option>, - pub before_close_comments: Vec>, -} - -#[derive(Default)] -pub struct DelimitedSequenceCommentState { - attachments: DelimitedSequenceAttachments, - consumed_comment_ranges: Vec, - pending_leading_comments: Vec>, - has_standalone_comments: bool, - seen_item: bool, -} - -impl DelimitedSequenceCommentState { - pub fn record_consumed_comment_range(&mut self, range: TextRange) { - self.consumed_comment_ranges.push(range); - } - - pub fn should_skip_comment(&self, comment: &LuaComment) -> bool { - self.consumed_comment_ranges - .iter() - .any(|range| *range == comment.syntax().text_range()) - } - - pub fn handle_comment(&mut self, ctx: &FormatContext, comment: &LuaComment) { - let comment_docs = format_comment(ctx.config, comment); - if !self.seen_item - && self.attachments.after_open_comment.is_none() - && has_non_trivia_before_on_same_line_tokenwise(comment.syntax()) - { - self.attachments.after_open_comment = Some(comment_docs); - return; - } - - self.pending_leading_comments.push(comment_docs); - self.has_standalone_comments = true; - } - - pub fn take_leading_comments(&mut self) -> Vec> { - std::mem::take(&mut self.pending_leading_comments) - } - - pub fn mark_item_seen(&mut self) { - self.seen_item = true; - } - - pub fn finish(mut self) -> (DelimitedSequenceAttachments, bool) { - self.attachments.before_close_comments = self.pending_leading_comments; - (self.attachments, self.has_standalone_comments) - } -} - -#[derive(Clone, Copy)] -pub struct DelimitedSequenceMultilineWrapOptions { - pub leading_hard_line: bool, - pub indent_before_close_comments: bool, -} - -pub fn push_comment_lines(target: &mut Vec, comments: &[Vec]) { - for comment_docs in comments { - target.push(ir::hard_line()); - target.extend(comment_docs.clone()); - } + matches!(entries.last(), Some(SequenceEntry::Comment(..))) } -pub fn wrap_multiline_delimited_sequence_docs( - ctx: &FormatContext, - open: DocIR, - close: DocIR, - inner: Vec, - trailing: Option, - attachments: &DelimitedSequenceAttachments, - options: DelimitedSequenceMultilineWrapOptions, -) -> Vec { - let mut indented = Vec::new(); - if options.leading_hard_line { - indented.push(ir::hard_line()); - } - indented.push(ir::list(inner)); - if let Some(trailing) = trailing { - indented.push(trailing); - } - if !options.indent_before_close_comments { - push_comment_lines(&mut indented, &attachments.before_close_comments); - } - - let mut docs = vec![open]; - if let Some(comment_docs) = &attachments.after_open_comment { - let mut suffix = trailing_comment_prefix(ctx.config); - suffix.extend(comment_docs.clone()); - docs.push(ir::line_suffix(suffix)); - } - docs.push(ir::indent(indented)); - - if options.indent_before_close_comments && !attachments.before_close_comments.is_empty() { - let mut closing_comments = Vec::new(); - push_comment_lines(&mut closing_comments, &attachments.before_close_comments); - docs.push(ir::indent(closing_comments)); - } - - docs.push(ir::hard_line()); - docs.push(close); - vec![ir::group_break(docs)] +pub fn sequence_starts_with_inline_comment(entries: &[SequenceEntry]) -> bool { + matches!( + entries.first(), + Some(SequenceEntry::Comment(SequenceComment { + inline_after_previous: true, + .. + })) + ) } #[derive(Clone, Default)] @@ -243,6 +127,23 @@ pub struct SequenceLayoutPolicy { pub first_line_prefix_width: usize, } +#[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 fn choose_sequence_layout( ctx: &FormatContext, candidates: SequenceLayoutCandidates, @@ -357,14 +258,12 @@ fn push_flat_and_fill_candidates( 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 { @@ -453,7 +352,7 @@ fn sequence_layout_kind_penalty(kind: SequenceLayoutKind) -> usize { } pub fn format_delimited_sequence( - ctx: &FormatContext, + _ctx: &FormatContext, layout: DelimitedSequenceLayout, ) -> Vec { if layout.items.is_empty() { @@ -462,7 +361,7 @@ pub fn format_delimited_sequence( 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, @@ -471,31 +370,25 @@ pub fn format_delimited_sequence( &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 = ctx.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::Always => format_expanded_delimited_sequence( + layout.open, + layout.close, + default_break_contents( + ir::intersperse(layout.items, layout.break_separator), + layout.grouped_trailing, + ), + ), + ExpandStrategy::Auto if layout.preserve_multiline => format_expanded_delimited_sequence( + layout.open, + layout.close, + default_break_contents( + ir::intersperse(layout.items, layout.break_separator), + layout.grouped_trailing, + ), + ), ExpandStrategy::Auto => vec![ir::group(vec![ layout.open, ir::indent(vec![ @@ -509,37 +402,6 @@ pub fn format_delimited_sequence( } } -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, @@ -582,215 +444,3 @@ fn build_fill_parts(items: &[Vec], separator: &[DocIR]) -> Vec { parts } - -#[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" - ); - } -} diff --git a/crates/emmylua_formatter/src/formatter/spacing.rs b/crates/emmylua_formatter/src/formatter/spacing.rs index 726259b01..3b85592b9 100644 --- a/crates/emmylua_formatter/src/formatter/spacing.rs +++ b/crates/emmylua_formatter/src/formatter/spacing.rs @@ -1,31 +1,23 @@ -use emmylua_parser::BinaryOperator; - use crate::config::LuaFormatConfig; use crate::ir::{self, DocIR}; +use emmylua_parser::{ + BinaryOperator, LuaAstNode, LuaChunk, LuaKind, LuaSyntaxId, LuaSyntaxKind, LuaSyntaxToken, + LuaTokenKind, +}; + +use super::FormatContext; +use super::model::{RootFormatPlan, RootSpacingModel, TokenSpacingExpected}; -/// 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 +pub(crate) enum SpaceRule { 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 { + pub(crate) fn to_ir(self) -> DocIR { match self { SpaceRule::Space => ir::space(), SpaceRule::NoSpace => ir::list(vec![]), @@ -35,12 +27,8 @@ impl SpaceRule { } } -/// Resolve spacing around a binary operator. -/// -/// Controls whether spaces appear around `+`, `-`, `*`, `/`, `and`, `..`, etc. -pub fn space_around_binary_op(op: BinaryOperator, config: &LuaFormatConfig) -> SpaceRule { +pub(crate) fn space_around_binary_op(op: BinaryOperator, config: &LuaFormatConfig) -> SpaceRule { match op { - // Arithmetic: + - * / // % ^ BinaryOperator::OpAdd | BinaryOperator::OpSub | BinaryOperator::OpMul @@ -54,19 +42,20 @@ pub fn space_around_binary_op(op: BinaryOperator, config: &LuaFormatConfig) -> S 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::OpGe + | BinaryOperator::OpAnd + | BinaryOperator::OpOr + | BinaryOperator::OpBAnd + | BinaryOperator::OpBOr + | BinaryOperator::OpBXor + | BinaryOperator::OpShl + | BinaryOperator::OpShr + | BinaryOperator::OpNop => SpaceRule::Space, BinaryOperator::OpConcat => { if config.spacing.space_around_concat_operator { SpaceRule::Space @@ -74,23 +63,600 @@ pub fn space_around_binary_op(op: BinaryOperator, config: &LuaFormatConfig) -> S 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 { +pub(crate) fn space_around_assign(config: &LuaFormatConfig) -> SpaceRule { if config.spacing.space_around_assign_operator { SpaceRule::Space } else { SpaceRule::NoSpace } } + +pub fn analyze_root_spacing(ctx: &FormatContext, chunk: &LuaChunk) -> RootFormatPlan { + let mut plan = RootFormatPlan::from_config(ctx.config); + plan.spacing.has_shebang = chunk + .syntax() + .first_token() + .is_some_and(|token| token.kind() == LuaKind::Token(LuaTokenKind::TkShebang)); + + analyze_chunk_token_spacing(ctx, chunk, &mut plan.spacing); + + plan +} + +fn analyze_chunk_token_spacing( + ctx: &FormatContext, + chunk: &LuaChunk, + spacing: &mut RootSpacingModel, +) { + for element in chunk.syntax().descendants_with_tokens() { + let Some(token) = element.into_token() else { + continue; + }; + + if should_skip_spacing_token(&token) { + continue; + } + + analyze_token_spacing(ctx, spacing, &token); + } +} + +fn should_skip_spacing_token(token: &LuaSyntaxToken) -> bool { + matches!( + token.kind().to_token(), + LuaTokenKind::TkWhitespace | LuaTokenKind::TkEndOfLine | LuaTokenKind::TkShebang + ) +} + +fn analyze_token_spacing( + ctx: &FormatContext, + spacing: &mut RootSpacingModel, + token: &LuaSyntaxToken, +) { + let syntax_id = LuaSyntaxId::from_token(token); + match token.kind().to_token() { + LuaTokenKind::TkNormalStart => apply_comment_start_spacing(ctx, spacing, token, syntax_id), + LuaTokenKind::TkDocStart => { + spacing.add_token_replace(syntax_id, normalized_doc_tag_prefix(ctx)); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); + } + LuaTokenKind::TkDocContinue => { + spacing.add_token_replace(syntax_id, normalized_doc_continue_prefix(ctx, token.text())); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); + } + LuaTokenKind::TkDocContinueOr => { + spacing.add_token_replace( + syntax_id, + normalized_doc_continue_or_prefix(ctx, token.text()), + ); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); + } + LuaTokenKind::TkLeftParen => apply_left_paren_spacing(ctx, spacing, token, syntax_id), + LuaTokenKind::TkRightParen => apply_right_paren_spacing(ctx, spacing, token, syntax_id), + LuaTokenKind::TkLeftBracket => apply_left_bracket_spacing(ctx, spacing, token, syntax_id), + LuaTokenKind::TkRightBracket => { + spacing.add_token_left_expected( + syntax_id, + TokenSpacingExpected::Space(space_inside_brackets(token, ctx)), + ); + } + LuaTokenKind::TkLeftBrace => { + spacing.add_token_right_expected( + syntax_id, + TokenSpacingExpected::Space(space_inside_braces(token, ctx)), + ); + } + LuaTokenKind::TkRightBrace => { + spacing.add_token_left_expected( + syntax_id, + TokenSpacingExpected::Space(space_inside_braces(token, ctx)), + ); + } + LuaTokenKind::TkComma => { + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(0)); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(1)); + } + LuaTokenKind::TkSemicolon => { + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(0)); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(1)); + } + LuaTokenKind::TkColon => { + if is_parent_syntax(token, LuaSyntaxKind::IndexExpr) { + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(0)); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); + } else if in_comment(token) { + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::MaxSpace(1)); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::MaxSpace(1)); + } + } + LuaTokenKind::TkDot => { + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(0)); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); + } + LuaTokenKind::TkPlus | LuaTokenKind::TkMinus => { + if is_parent_syntax(token, LuaSyntaxKind::UnaryExpr) { + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); + } else { + apply_space_rule( + spacing, + syntax_id, + space_around_binary_op(binary_op_for_plus_minus(token), ctx.config), + ); + } + } + LuaTokenKind::TkMul + | LuaTokenKind::TkDiv + | LuaTokenKind::TkIDiv + | LuaTokenKind::TkMod + | LuaTokenKind::TkPow + | LuaTokenKind::TkConcat + | LuaTokenKind::TkBitAnd + | LuaTokenKind::TkBitOr + | LuaTokenKind::TkBitXor + | LuaTokenKind::TkShl + | LuaTokenKind::TkShr + | LuaTokenKind::TkEq + | LuaTokenKind::TkGe + | LuaTokenKind::TkGt + | LuaTokenKind::TkLe + | LuaTokenKind::TkLt + | LuaTokenKind::TkNe + | LuaTokenKind::TkAnd + | LuaTokenKind::TkOr => apply_operator_spacing(ctx, spacing, token, syntax_id), + LuaTokenKind::TkAssign => { + apply_space_rule(spacing, syntax_id, space_around_assign(ctx.config)); + } + 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 => { + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(1)); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(1)); + } + _ => {} + } +} + +fn apply_left_paren_spacing( + ctx: &FormatContext, + spacing: &mut RootSpacingModel, + token: &LuaSyntaxToken, + syntax_id: LuaSyntaxId, +) { + let left_space = if is_parent_syntax(token, LuaSyntaxKind::ParamList) { + usize::from(ctx.config.spacing.space_before_func_paren) + } else if is_parent_syntax(token, LuaSyntaxKind::CallArgList) { + usize::from(ctx.config.spacing.space_before_call_paren) + } else if let Some(prev_token) = get_prev_sibling_token_without_space(token) { + match prev_token.kind().to_token() { + LuaTokenKind::TkName + | LuaTokenKind::TkRightParen + | LuaTokenKind::TkRightBracket + | LuaTokenKind::TkFunction => 0, + LuaTokenKind::TkString | LuaTokenKind::TkRightBrace | LuaTokenKind::TkLongString => 1, + _ => 0, + } + } else { + 0 + }; + + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(left_space)); + spacing.add_token_right_expected( + syntax_id, + TokenSpacingExpected::Space(space_inside_parens(token, ctx)), + ); +} + +fn apply_right_paren_spacing( + ctx: &FormatContext, + spacing: &mut RootSpacingModel, + token: &LuaSyntaxToken, + syntax_id: LuaSyntaxId, +) { + spacing.add_token_left_expected( + syntax_id, + TokenSpacingExpected::Space(space_inside_parens(token, ctx)), + ); +} + +fn apply_left_bracket_spacing( + ctx: &FormatContext, + spacing: &mut RootSpacingModel, + token: &LuaSyntaxToken, + syntax_id: LuaSyntaxId, +) { + let left_space = if let Some(prev_token) = get_prev_sibling_token_without_space(token) { + match prev_token.kind().to_token() { + LuaTokenKind::TkName + | LuaTokenKind::TkRightParen + | LuaTokenKind::TkRightBracket + | LuaTokenKind::TkDot + | LuaTokenKind::TkColon => 0, + LuaTokenKind::TkString | LuaTokenKind::TkRightBrace | LuaTokenKind::TkLongString => 1, + _ => 0, + } + } else { + 0 + }; + + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(left_space)); + spacing.add_token_right_expected( + syntax_id, + TokenSpacingExpected::Space(space_inside_brackets(token, ctx)), + ); +} + +fn apply_operator_spacing( + ctx: &FormatContext, + spacing: &mut RootSpacingModel, + token: &LuaSyntaxToken, + syntax_id: LuaSyntaxId, +) { + match token.kind().to_token() { + LuaTokenKind::TkLt | LuaTokenKind::TkGt + if is_parent_syntax(token, LuaSyntaxKind::Attribute) => + { + let (left, right) = if token.kind().to_token() == LuaTokenKind::TkLt { + (1, 0) + } else { + (0, 1) + }; + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(left)); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(right)); + } + _ => { + let Some(rule) = binary_space_rule_for_token(ctx, token) else { + return; + }; + apply_space_rule(spacing, syntax_id, rule); + } + } +} + +fn apply_comment_start_spacing( + ctx: &FormatContext, + spacing: &mut RootSpacingModel, + token: &LuaSyntaxToken, + syntax_id: LuaSyntaxId, +) { + if !in_comment(token) { + return; + } + + if let Some(replacement) = normalized_comment_prefix(ctx, token.text()) { + spacing.add_token_replace(syntax_id, replacement); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); + } +} + +fn binary_space_rule_for_token(ctx: &FormatContext, token: &LuaSyntaxToken) -> Option { + let op = match token.kind().to_token() { + LuaTokenKind::TkPlus => BinaryOperator::OpAdd, + LuaTokenKind::TkMinus => BinaryOperator::OpSub, + LuaTokenKind::TkMul => BinaryOperator::OpMul, + LuaTokenKind::TkDiv => BinaryOperator::OpDiv, + LuaTokenKind::TkIDiv => BinaryOperator::OpIDiv, + LuaTokenKind::TkMod => BinaryOperator::OpMod, + LuaTokenKind::TkPow => BinaryOperator::OpPow, + LuaTokenKind::TkConcat => BinaryOperator::OpConcat, + LuaTokenKind::TkBitAnd => BinaryOperator::OpBAnd, + LuaTokenKind::TkBitOr => BinaryOperator::OpBOr, + LuaTokenKind::TkBitXor => BinaryOperator::OpBXor, + LuaTokenKind::TkShl => BinaryOperator::OpShl, + LuaTokenKind::TkShr => BinaryOperator::OpShr, + LuaTokenKind::TkEq => BinaryOperator::OpEq, + LuaTokenKind::TkGe => BinaryOperator::OpGe, + LuaTokenKind::TkGt => BinaryOperator::OpGt, + LuaTokenKind::TkLe => BinaryOperator::OpLe, + LuaTokenKind::TkLt => BinaryOperator::OpLt, + LuaTokenKind::TkNe => BinaryOperator::OpNe, + LuaTokenKind::TkAnd => BinaryOperator::OpAnd, + LuaTokenKind::TkOr => BinaryOperator::OpOr, + _ => return None, + }; + + Some(space_around_binary_op(op, ctx.config)) +} + +fn binary_op_for_plus_minus(token: &LuaSyntaxToken) -> BinaryOperator { + match token.kind().to_token() { + LuaTokenKind::TkPlus => BinaryOperator::OpAdd, + LuaTokenKind::TkMinus => BinaryOperator::OpSub, + _ => BinaryOperator::OpNop, + } +} + +fn apply_space_rule(spacing: &mut RootSpacingModel, syntax_id: LuaSyntaxId, rule: SpaceRule) { + match rule { + SpaceRule::Space | SpaceRule::SoftLine => { + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(1)); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(1)); + } + SpaceRule::NoSpace | SpaceRule::SoftLineOrEmpty => { + spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(0)); + spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); + } + } +} + +fn space_inside_parens(token: &LuaSyntaxToken, ctx: &FormatContext) -> usize { + if is_parent_syntax(token, LuaSyntaxKind::ParenExpr) { + usize::from(ctx.config.spacing.space_inside_parens) + } else { + 0 + } +} + +fn space_inside_brackets(_token: &LuaSyntaxToken, ctx: &FormatContext) -> usize { + usize::from(ctx.config.spacing.space_inside_brackets) +} + +fn space_inside_braces(_token: &LuaSyntaxToken, ctx: &FormatContext) -> usize { + usize::from(ctx.config.spacing.space_inside_braces) +} + +fn is_parent_syntax(token: &LuaSyntaxToken, kind: LuaSyntaxKind) -> bool { + token + .parent() + .is_some_and(|parent| parent.kind().to_syntax() == kind) +} + +fn in_comment(token: &LuaSyntaxToken) -> bool { + let mut current = token.parent(); + while let Some(node) = current { + if node.kind().to_syntax() == LuaSyntaxKind::Comment { + return true; + } + current = node.parent(); + } + + false +} + +fn get_prev_sibling_token_without_space(token: &LuaSyntaxToken) -> Option { + let mut current = token.clone(); + while let Some(prev) = current.prev_token() { + if !matches!( + prev.kind().to_token(), + LuaTokenKind::TkWhitespace | LuaTokenKind::TkEndOfLine + ) { + return Some(prev); + } + current = prev; + } + + None +} + +fn normalized_comment_prefix(ctx: &FormatContext, prefix_text: &str) -> Option { + match dash_prefix_len(prefix_text) { + 2 => Some(if ctx.config.comments.space_after_comment_dash { + "-- ".to_string() + } else { + "--".to_string() + }), + 3 => Some(if ctx.config.emmy_doc.space_after_description_dash { + "--- ".to_string() + } else { + "---".to_string() + }), + _ => None, + } +} + +fn normalized_doc_tag_prefix(ctx: &FormatContext) -> String { + if ctx.config.emmy_doc.space_after_description_dash { + "--- @".to_string() + } else { + "---@".to_string() + } +} + +fn normalized_doc_continue_prefix(ctx: &FormatContext, prefix_text: &str) -> String { + if prefix_text == "---" || prefix_text == "--- " { + if ctx.config.emmy_doc.space_after_description_dash { + "--- ".to_string() + } else { + "---".to_string() + } + } else { + prefix_text.to_string() + } +} + +fn normalized_doc_continue_or_prefix(ctx: &FormatContext, prefix_text: &str) -> String { + if !prefix_text.starts_with("---") { + return prefix_text.to_string(); + } + + let suffix = prefix_text[3..].trim_start(); + if ctx.config.emmy_doc.space_after_description_dash { + format!("--- {suffix}") + } else { + format!("---{suffix}") + } +} + +fn dash_prefix_len(prefix_text: &str) -> usize { + prefix_text.bytes().take_while(|byte| *byte == b'-').count() +} + +#[cfg(test)] +mod tests { + use emmylua_parser::{LuaLanguageLevel, LuaParser, ParserConfig}; + + use crate::config::LuaFormatConfig; + + use super::*; + + fn analyze(input: &str, config: LuaFormatConfig) -> RootSpacingModel { + let tree = LuaParser::parse(input, ParserConfig::with_level(LuaLanguageLevel::Lua54)); + let chunk = tree.get_chunk_node(); + let ctx = FormatContext::new(&config); + analyze_root_spacing(&ctx, &chunk).spacing + } + + fn find_token(chunk: &LuaChunk, kind: LuaTokenKind) -> LuaSyntaxToken { + chunk + .syntax() + .descendants_with_tokens() + .filter_map(|element| element.into_token()) + .find(|token| token.kind().to_token() == kind) + .unwrap() + } + + #[test] + fn test_spacing_assign_defaults_to_single_spaces() { + let config = LuaFormatConfig::default(); + let tree = LuaParser::parse( + "local x=1\n", + ParserConfig::with_level(LuaLanguageLevel::Lua54), + ); + let chunk = tree.get_chunk_node(); + let spacing = analyze_root_spacing(&FormatContext::new(&config), &chunk).spacing; + let assign = find_token(&chunk, LuaTokenKind::TkAssign); + let assign_id = LuaSyntaxId::from_token(&assign); + + assert_eq!( + spacing.left_expected(assign_id), + Some(&TokenSpacingExpected::Space(1)) + ); + assert_eq!( + spacing.right_expected(assign_id), + Some(&TokenSpacingExpected::Space(1)) + ); + } + + #[test] + fn test_spacing_uses_call_paren_config() { + let config = LuaFormatConfig { + spacing: crate::config::SpacingConfig { + space_before_call_paren: true, + ..Default::default() + }, + ..Default::default() + }; + let tree = LuaParser::parse( + "foo(a)\n", + ParserConfig::with_level(LuaLanguageLevel::Lua54), + ); + let chunk = tree.get_chunk_node(); + let spacing = analyze_root_spacing(&FormatContext::new(&config), &chunk).spacing; + let left_paren = find_token(&chunk, LuaTokenKind::TkLeftParen); + let paren_id = LuaSyntaxId::from_token(&left_paren); + + assert_eq!( + spacing.left_expected(paren_id), + Some(&TokenSpacingExpected::Space(1)) + ); + assert_eq!( + spacing.right_expected(paren_id), + Some(&TokenSpacingExpected::Space(0)) + ); + } + + #[test] + fn test_spacing_respects_paren_expr_inner_space() { + let config = LuaFormatConfig { + spacing: crate::config::SpacingConfig { + space_inside_parens: true, + ..Default::default() + }, + ..Default::default() + }; + let tree = LuaParser::parse( + "local x = (a)\n", + ParserConfig::with_level(LuaLanguageLevel::Lua54), + ); + let chunk = tree.get_chunk_node(); + let spacing = analyze_root_spacing(&FormatContext::new(&config), &chunk).spacing; + let left_paren = find_token(&chunk, LuaTokenKind::TkLeftParen); + let right_paren = find_token(&chunk, LuaTokenKind::TkRightParen); + + assert_eq!( + spacing.right_expected(LuaSyntaxId::from_token(&left_paren)), + Some(&TokenSpacingExpected::Space(1)) + ); + assert_eq!( + spacing.left_expected(LuaSyntaxId::from_token(&right_paren)), + Some(&TokenSpacingExpected::Space(1)) + ); + } + + #[test] + fn test_spacing_respects_math_operator_config() { + let config = LuaFormatConfig { + spacing: crate::config::SpacingConfig { + space_around_math_operator: false, + ..Default::default() + }, + ..Default::default() + }; + let tree = LuaParser::parse( + "local x = a+b\n", + ParserConfig::with_level(LuaLanguageLevel::Lua54), + ); + let chunk = tree.get_chunk_node(); + let spacing = analyze_root_spacing(&FormatContext::new(&config), &chunk).spacing; + let plus = find_token(&chunk, LuaTokenKind::TkPlus); + let plus_id = LuaSyntaxId::from_token(&plus); + + assert_eq!( + spacing.left_expected(plus_id), + Some(&TokenSpacingExpected::Space(0)) + ); + assert_eq!( + spacing.right_expected(plus_id), + Some(&TokenSpacingExpected::Space(0)) + ); + } + + #[test] + fn test_spacing_collects_comment_prefix_replacement() { + let config = LuaFormatConfig::default(); + let tree = LuaParser::parse( + "--hello\n", + ParserConfig::with_level(LuaLanguageLevel::Lua54), + ); + let chunk = tree.get_chunk_node(); + let spacing = analyze_root_spacing(&FormatContext::new(&config), &chunk).spacing; + let start = find_token(&chunk, LuaTokenKind::TkNormalStart); + let start_id = LuaSyntaxId::from_token(&start); + + assert_eq!(spacing.token_replace(start_id), Some("-- ")); + assert_eq!( + spacing.right_expected(start_id), + Some(&TokenSpacingExpected::Space(0)) + ); + } + + #[test] + fn test_spacing_collects_doc_prefix_replacement() { + let config = LuaFormatConfig::default(); + let tree = LuaParser::parse( + "---@param x string\n", + ParserConfig::with_level(LuaLanguageLevel::Lua54), + ); + let chunk = tree.get_chunk_node(); + let spacing = analyze_root_spacing(&FormatContext::new(&config), &chunk).spacing; + let start = find_token(&chunk, LuaTokenKind::TkDocStart); + + assert_eq!( + spacing.token_replace(LuaSyntaxId::from_token(&start)), + Some("--- @") + ); + } +} diff --git a/crates/emmylua_formatter/src/formatter/statement.rs b/crates/emmylua_formatter/src/formatter/statement.rs deleted file mode 100644 index 32329ebd1..000000000 --- a/crates/emmylua_formatter/src/formatter/statement.rs +++ /dev/null @@ -1,2101 +0,0 @@ -use emmylua_parser::{ - 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::config::LuaFormatConfig; -use crate::ir::{self, DocIR, EqSplit, ir_has_forced_line_break}; - -use super::FormatContext; -use super::block::format_block; -use super::comments::{collect_orphan_comments, extract_trailing_comment, format_comment}; -use super::expression::format_expr; -use super::sequence::{ - 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; -use super::tokens::{comma_space_sep, tok}; -use super::trivia::{ - has_non_trivia_before_on_same_line_tokenwise, node_has_direct_comment_child, - node_has_direct_same_line_inline_comment, source_line_prefix_width, - syntax_has_descendant_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), - 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![], - 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 { - 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(tok(LuaTokenKind::TkComma)); - docs.push(ir::space()); - } - if let Some(token) = local_name.get_name_token() { - 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::source_token(name_token.syntax().clone())); - } - docs.push(ir::text(">")); - } - } - - // Value list - let exprs: Vec<_> = stat.get_value_exprs().collect(); - if !exprs.is_empty() { - let assign_space = space_around_assign(ctx.config).to_ir(); - docs.push(assign_space); - docs.push(tok(LuaTokenKind::TkAssign)); - - let expr_docs: Vec> = exprs - .iter() - .enumerate() - .map(|(index, expr)| format_statement_value_expr(ctx, expr, index, exprs.len())) - .collect(); - - // 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(expr_docs.into_iter().next().unwrap_or_default())); - } else { - let leading_docs = if ctx.config.spacing.space_around_assign_operator { - vec![ir::space()] - } else { - vec![] - }; - let prefix_width = exprs - .first() - .map(|expr| source_line_prefix_width(expr.syntax())) - .unwrap_or(0); - if should_preserve_first_multiline_statement_value(&exprs[0], 0, exprs.len()) { - docs.extend(format_statement_expr_list_with_attached_first_multiline( - leading_docs, - expr_docs, - )); - } else { - docs.extend(format_statement_expr_list( - ctx, - leading_docs, - expr_docs, - prefix_width, - )); - } - } - } - - docs -} - -/// 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(); - - // Variable list - let var_docs: Vec> = vars - .iter() - .map(|v| format_expr(ctx, &v.clone().into())) - .collect(); - - 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::source_token(op.syntax().clone())); - } - - // Value list - let expr_docs: Vec> = exprs - .iter() - .enumerate() - .map(|(index, expr)| format_statement_value_expr(ctx, expr, index, exprs.len())) - .collect(); - - // 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(expr_docs.into_iter().next().unwrap_or_default())); - } else { - let leading_docs = if ctx.config.spacing.space_around_assign_operator { - vec![ir::space()] - } else { - vec![] - }; - let prefix_width = exprs - .first() - .map(|expr| source_line_prefix_width(expr.syntax())) - .unwrap_or(0); - if should_preserve_first_multiline_statement_value(&exprs[0], 0, exprs.len()) { - docs.extend(format_statement_expr_list_with_attached_first_multiline( - leading_docs, - expr_docs, - )); - } else { - docs.extend(format_statement_expr_list( - ctx, - leading_docs, - expr_docs, - prefix_width, - )); - } - } - - 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 { - 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 -} - -/// 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 { - 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 head_docs = vec![ir::space()]; - - if let Some(name) = stat.get_func_name() { - head_docs.extend(format_expr(ctx, &name.into())); - } - - if let Some(closure) = stat.get_closure() { - head_docs.extend(format_closure_body(ctx, &closure)); - } - - format_keyword_header(vec![tok(LuaTokenKind::TkFunction)], head_docs) -} - -/// 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 leading_docs = vec![ - tok(LuaTokenKind::TkLocal), - ir::space(), - tok(LuaTokenKind::TkFunction), - ]; - let mut head_docs = vec![ir::space()]; - - if let Some(name) = stat.get_local_name() - && let Some(token) = name.get_name_token() - { - head_docs.push(ir::source_token(token.syntax().clone())); - } - - if let Some(closure) = stat.get_closure() { - head_docs.extend(format_closure_body(ctx, &closure)); - } - - format_keyword_header(leading_docs, head_docs) -} - -fn format_func_stat_trivia_aware(ctx: &FormatContext, stat: &LuaFuncStat) -> Vec { - let entries = collect_func_stat_header_entries(ctx, stat); - 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); - format_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 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; - - 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.clone()); - 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.clone()); - 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.clone()); - prev_was_comment = false; - has_seen_header_content = true; - } - } - } - - 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> { - 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![tok(LuaTokenKind::TkFunction), ir::space()]; - if let Some(name) = stat.get_func_name() { - docs.extend(format_expr(ctx, &name.into())); - } - - if ctx.config.spacing.space_before_func_paren { - docs.push(ir::space()); - } - - 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::source_token(token.syntax().clone())]); - } - } - if !param_docs.is_empty() { - let inner = ir::intersperse(param_docs, comma_space_sep()); - docs.extend(inner); - } - } - docs.push(tok(LuaTokenKind::TkRightParen)); - docs.push(ir::space()); - docs.push(tok(LuaTokenKind::TkEnd)); - 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![ - tok(LuaTokenKind::TkLocal), - ir::space(), - tok(LuaTokenKind::TkFunction), - ir::space(), - ]; - - if let Some(name) = stat.get_local_name() - && let Some(token) = name.get_name_token() - { - docs.push(ir::source_token(token.syntax().clone())); - } - - if ctx.config.spacing.space_before_func_paren { - docs.push(ir::space()); - } - - 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::source_token(token.syntax().clone())]); - } - } - if !param_docs.is_empty() { - let inner = ir::intersperse(param_docs, comma_space_sep()); - docs.extend(inner); - } - } - docs.push(tok(LuaTokenKind::TkRightParen)); - docs.push(ir::space()); - docs.push(tok(LuaTokenKind::TkEnd)); - Some(docs) -} - -/// if cond then ... elseif cond then ... else ... end -fn format_if_stat(ctx: &FormatContext, stat: &LuaIfStat) -> Vec { - if let Some(preserved) = try_preserve_single_line_if_body(ctx, stat) { - 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())]; - } - - 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 head_docs = vec![ir::space()]; - - if let Some(cond) = stat.get_condition_expr() { - head_docs.extend(format_expr(ctx, &cond)); - } - - 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); - - // elseif branches - for clause in stat.get_else_if_clause_list() { - docs.push(ir::hard_line()); - let mut clause_head_docs = vec![ir::space()]; - if let Some(cond) = clause.get_condition_expr() { - clause_head_docs.extend(format_expr(ctx, &cond)); - } - docs.extend(format_control_header( - LuaTokenKind::TkElseIf, - clause_head_docs, - LuaTokenKind::TkThen, - )); - 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(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 { - stat.get_else_if_clause_list().next().is_some() && syntax_has_descendant_comment(stat.syntax()) -} - -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 = 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); - - 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()); - docs.extend(format_sequence_control_header( - vec![tok(LuaTokenKind::TkElseIf)], - &clause_entries, - 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(), - else_clause.syntax(), - &mut docs, - ); - } - - docs.push(ir::hard_line()); - 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 try_format_raw_clause_header_until_block( - syntax: &LuaSyntaxNode, - block: Option<&LuaBlock>, -) -> Option> { - let block = block?; - if !syntax_has_descendant_comment(syntax) { - return None; - } - - let mut header = String::new(); - let block_range = block.syntax().text_range(); - for child in syntax.children_with_tokens() { - if let Some(node) = child.as_node() - && node.text_range() == block_range - { - break; - } - - match child { - rowan::NodeOrToken::Node(node) => { - node.text().for_each_chunk(|chunk| header.push_str(chunk)); - } - rowan::NodeOrToken::Token(token) => header.push_str(token.text()), - } - } - - let header = header.trim_end_matches(['\r', '\n', ' ', '\t']); - if header.is_empty() { - None - } else { - 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; - } - - let text_len: u32 = stat.syntax().text().len().into(); - 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; - } - - 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 { - 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 head_docs = vec![ir::space()]; - if let Some(cond) = stat.get_condition_expr() { - head_docs.extend(format_expr(ctx, &cond)); - } - - let mut docs = format_control_header(LuaTokenKind::TkWhile, head_docs, LuaTokenKind::TkDo); - - 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![tok(LuaTokenKind::TkDo)]; - - 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 { - 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 head_docs = vec![ir::space()]; - - if let Some(var_name) = stat.get_var_name() { - head_docs.push(ir::source_token(var_name.syntax().clone())); - } - - 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(); - let prefix_width = iter_exprs - .first() - .map(|expr| source_line_prefix_width(expr.syntax())) - .unwrap_or(0); - if iter_exprs - .first() - .zip(iter_docs.first()) - .is_some_and(|(expr, doc)| { - should_preserve_first_multiline_header_expr(expr, doc, 0, iter_exprs.len()) - }) - { - head_docs.extend(format_statement_expr_list_with_attached_first_multiline( - vec![ir::space()], - iter_docs, - )); - } else { - 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); - - 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 { - 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_range_stat_trivia_aware(ctx, stat); - } - - let mut head_docs = vec![ir::space()]; - - let var_names: Vec<_> = stat.get_var_name_list().collect(); - for (i, name) in var_names.iter().enumerate() { - if i > 0 { - head_docs.push(tok(LuaTokenKind::TkComma)); - head_docs.push(ir::space()); - } - head_docs.push(ir::source_token(name.syntax().clone())); - } - - 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(); - let prefix_width = expr_list - .first() - .map(|expr| source_line_prefix_width(expr.syntax())) - .unwrap_or(0); - if expr_list - .first() - .zip(expr_docs.first()) - .is_some_and(|(expr, doc)| { - should_preserve_first_multiline_header_expr(expr, doc, 0, expr_list.len()) - }) - { - head_docs.extend(format_statement_expr_list_with_attached_first_multiline( - vec![ir::space()], - expr_docs, - )); - } else { - 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); - - format_body_end_with_parent( - ctx, - stat.get_block().as_ref(), - Some(stat.syntax()), - &mut docs, - ); - - docs -} - -fn format_while_stat_trivia_aware(ctx: &FormatContext, stat: &LuaWhileStat) -> Vec { - let entries = collect_while_stat_entries(ctx, stat); - let mut docs = format_sequence_control_header( - vec![tok(LuaTokenKind::TkWhile)], - &entries, - 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 = format_split_control_header( - vec![tok(LuaTokenKind::TkFor)], - &lhs_entries, - assign_op.as_ref(), - &rhs_entries, - 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 = format_split_control_header( - vec![tok(LuaTokenKind::TkFor)], - &lhs_entries, - assign_op.as_ref(), - &rhs_entries, - 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![tok(LuaTokenKind::TkRepeat)]; - - 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(ctx.config, 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()); - - let mut head_docs = vec![ir::space()]; - - if let Some(cond) = stat.get_condition_expr() { - head_docs.extend(format_expr(ctx, &cond)); - } - - docs.extend(format_keyword_header( - vec![tok(LuaTokenKind::TkUntil)], - head_docs, - )); - - docs -} - -/// break -fn format_break_stat(_ctx: &FormatContext, _stat: &LuaBreakStat) -> Vec { - vec![tok(LuaTokenKind::TkBreak)] -} - -/// return expr1, expr2, ... -fn format_return_stat(ctx: &FormatContext, stat: &LuaReturnStat) -> Vec { - 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() - .enumerate() - .map(|(index, expr)| format_statement_value_expr(ctx, expr, index, exprs.len())) - .collect(); - - if exprs.len() == 1 && should_attach_single_value_head(&exprs[0]) { - docs.push(ir::space()); - docs.push(ir::list(expr_docs.into_iter().next().unwrap_or_default())); - } else { - let prefix_width = exprs - .first() - .map(|expr| source_line_prefix_width(expr.syntax())) - .unwrap_or(0); - if should_preserve_first_multiline_statement_value(&exprs[0], 0, exprs.len()) { - docs.extend(format_statement_expr_list_with_attached_first_multiline( - vec![ir::space()], - expr_docs, - )); - } else { - docs.extend(format_statement_expr_list( - ctx, - vec![ir::space()], - expr_docs, - prefix_width, - )); - } - } - } - - 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 -} - -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(); - } - - if expr_docs.len() == 1 { - let mut docs = leading_docs; - docs.extend(expr_docs.into_iter().next().unwrap_or_default()); - return docs; - } - - let fill_parts = build_statement_expr_fill_parts(leading_docs.clone(), expr_docs.clone()); - let packed = build_statement_expr_packed(leading_docs.clone(), expr_docs.clone()); - let one_per_line = build_statement_expr_one_per_line(leading_docs, expr_docs); - - 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, - }, - ) -} - -fn format_statement_expr_list_with_attached_first_multiline( - leading_docs: Vec, - expr_docs: Vec>, -) -> Vec { - if expr_docs.is_empty() { - return Vec::new(); - } - - let mut docs = leading_docs; - let mut iter = expr_docs.into_iter(); - let first_expr = iter.next().unwrap_or_default(); - docs.extend(first_expr); - - let remaining: Vec> = iter.collect(); - if remaining.is_empty() { - return docs; - } - - docs.push(tok(LuaTokenKind::TkComma)); - - let mut tail = Vec::new(); - let remaining_len = remaining.len(); - for (index, expr_doc) in remaining.into_iter().enumerate() { - tail.push(ir::hard_line()); - tail.extend(expr_doc); - if index + 1 < remaining_len { - tail.push(tok(LuaTokenKind::TkComma)); - } - } - - docs.push(ir::indent(tail)); - docs -} - -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; - 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)); - } - - parts -} - -fn build_statement_expr_one_per_line( - leading_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( - 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()]; - if let Some(label) = stat.get_label_name_token() { - docs.push(ir::source_token(label.syntax().clone())); - } - 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::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(ctx: &FormatContext, closure: &LuaClosureExpr) -> Vec { - format_closure_body_with_prefix_space(ctx, closure, true) -} - -fn format_closure_body_with_prefix_space( - ctx: &FormatContext, - closure: &LuaClosureExpr, - prefix_space_before_paren: bool, -) -> Vec { - let mut docs = Vec::new(); - - if prefix_space_before_paren && ctx.config.spacing.space_before_func_paren { - docs.push(ir::space()); - } - - // Parameter list - if let Some(params) = closure.get_params_list() { - docs.extend(super::expression::format_param_list_ir(ctx, ¶ms)); - } else { - docs.push(tok(LuaTokenKind::TkLeftParen)); - docs.push(tok(LuaTokenKind::TkRightParen)); - } - - // 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![tok(LuaTokenKind::TkGlobal)]; - - 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::source_token(name_token.syntax().clone())); - } - 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(); - - for (i, name) in names.iter().enumerate() { - if i == 0 { - docs.push(ir::space()); - } else { - docs.push(tok(LuaTokenKind::TkComma)); - docs.push(ir::space()); - } - docs.extend(format_local_name_ir(name)); - } - - 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<&LuaBlock>, - parent: Option<&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(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(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(tok(LuaTokenKind::TkEnd)); - return; - } - } - // Empty block: compact output ` end` - docs.push(ir::space()); - 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<&LuaBlock>, - parent: &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(ctx.config, 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 alignment-only paths. -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) || node_has_direct_comment_child(expr.syntax()) -} - -fn format_statement_value_expr( - ctx: &FormatContext, - expr: &LuaExpr, - index: usize, - total_exprs: usize, -) -> Vec { - if should_preserve_first_multiline_statement_value(expr, index, total_exprs) { - vec![ir::source_node_trimmed(expr.syntax().clone())] - } else { - format_expr(ctx, expr) - } -} - -fn should_preserve_first_multiline_statement_value( - expr: &LuaExpr, - index: usize, - total_exprs: usize, -) -> bool { - index == 0 - && total_exprs > 1 - && is_block_like_expr(expr) - && expr.syntax().text().contains_char('\n') -} - -fn should_preserve_first_multiline_header_expr( - expr: &LuaExpr, - expr_doc: &[DocIR], - index: usize, - total_exprs: usize, -) -> bool { - index == 0 - && total_exprs > 1 - && expr.syntax().text().contains_char('\n') - && ir_has_forced_line_break(expr_doc) - && matches!( - expr, - LuaExpr::CallExpr(_) - | LuaExpr::BinaryExpr(_) - | 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_block() - .as_ref() - .is_some_and(block_has_leading_same_line_inline_comment) - }) - .unwrap_or(false), - LuaStat::LocalFuncStat(func) => func - .get_closure() - .map(|closure| { - node_has_direct_same_line_inline_comment(closure.syntax()) - || closure - .get_block() - .as_ref() - .is_some_and(block_has_leading_same_line_inline_comment) - }) - .unwrap_or(false), - _ => false, - } -} - -fn block_has_leading_same_line_inline_comment(block: &LuaBlock) -> bool { - block - .syntax() - .children() - .find(|child| child.kind() == LuaKind::Syntax(LuaSyntaxKind::Comment)) - .is_some_and(|comment| has_non_trivia_before_on_same_line_tokenwise(&comment)) -} - -/// Check if a statement can participate in `=` alignment. -/// Only simple local/assign statements with values qualify. -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(config, s.syntax()).is_none() - { - return false; - } - // 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) => { - if node_has_direct_comment_child(s.syntax()) - && extract_trailing_comment(config, s.syntax()).is_none() - { - return false; - } - 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![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(tok(LuaTokenKind::TkComma)); - before.push(ir::space()); - } - if let Some(token) = local_name.get_name_token() { - 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::source_token(name_token.syntax().clone())); - } - before.push(ir::text(">")); - } - } - - // Build RHS: "= value1, value2" - let assign_space = space_around_assign(ctx.config).to_ir(); - 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, comma_space_sep())); - - 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, comma_space_sep()); - - // Build RHS - let mut after = Vec::new(); - if let Some(op) = stat.get_assign_op() { - 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, comma_space_sep())); - - Some((before, after)) -} diff --git a/crates/emmylua_formatter/src/formatter/tokens.rs b/crates/emmylua_formatter/src/formatter/tokens.rs deleted file mode 100644 index 271354ff3..000000000 --- a/crates/emmylua_formatter/src/formatter/tokens.rs +++ /dev/null @@ -1,15 +0,0 @@ -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 e0b10dc8a..f21cdde24 100644 --- a/crates/emmylua_formatter/src/formatter/trivia.rs +++ b/crates/emmylua_formatter/src/formatter/trivia.rs @@ -1,12 +1,9 @@ use emmylua_parser::{LuaKind, LuaSyntaxKind, LuaSyntaxNode, LuaTokenKind}; -use rowan::TextRange; -/// 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 { @@ -17,9 +14,7 @@ pub fn count_blank_lines_before(node: &LuaSyntaxNode) -> usize { blank_lines += 1; } } - LuaTokenKind::TkWhitespace => { - // Skip whitespace - } + LuaTokenKind::TkWhitespace => {} _ => break, } token = t.prev_token(); @@ -29,49 +24,20 @@ 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 -} - pub fn has_non_trivia_before_on_same_line_tokenwise(node: &LuaSyntaxNode) -> bool { let Some(first_token) = node.first_token() else { return false; }; let mut previous = first_token.prev_token(); - while let Some(token) = previous { match token.kind().to_token() { - LuaTokenKind::TkWhitespace => { - previous = token.prev_token(); - } + LuaTokenKind::TkWhitespace => previous = token.prev_token(), LuaTokenKind::TkEndOfLine => return false, _ => return true, } @@ -103,48 +69,3 @@ pub fn source_line_prefix_width(node: &LuaSyntaxNode) -> usize { width } - -pub fn syntax_has_descendant_comment(node: &LuaSyntaxNode) -> bool { - node.descendants() - .any(|child| child.kind() == LuaKind::Syntax(LuaSyntaxKind::Comment)) -} - -pub fn trailing_gap_requests_alignment( - node: &LuaSyntaxNode, - comment_range: TextRange, - required_min_gap: usize, -) -> bool { - let mut gap_width = 0usize; - let mut next = node.next_sibling_or_token(); - - while let Some(element) = next { - if element.text_range().start() >= comment_range.start() { - break; - } - - match element.kind() { - LuaKind::Token(LuaTokenKind::TkEndOfLine) => return false, - LuaKind::Token(LuaTokenKind::TkWhitespace) => { - if let Some(token) = element.as_token() { - for ch in token.text().chars() { - if matches!(ch, '\n' | '\r') { - return false; - } - if matches!(ch, ' ' | '\t') { - gap_width += 1; - } - } - } - } - _ => { - if element.text_range().end() > comment_range.start() { - return false; - } - } - } - - next = element.next_sibling_or_token(); - } - - gap_width > required_min_gap -} diff --git a/crates/emmylua_formatter/src/formatter_new/mod.rs b/crates/emmylua_formatter/src/formatter_new/mod.rs deleted file mode 100644 index 92deff3f5..000000000 --- a/crates/emmylua_formatter/src/formatter_new/mod.rs +++ /dev/null @@ -1,41 +0,0 @@ -mod expr; -mod layout; -mod line_breaks; -mod model; -mod render; -mod sequence; -mod spacing; -mod trivia; - -use std::cell::Cell; - -use crate::config::LuaFormatConfig; -use crate::ir::{DocIR, GroupId}; -use emmylua_parser::LuaChunk; - -pub struct FormatContext<'a> { - pub config: &'a LuaFormatConfig, - next_group_id: Cell, -} - -impl<'a> FormatContext<'a> { - pub fn new(config: &'a LuaFormatConfig) -> Self { - Self { - config, - next_group_id: Cell::new(0), - } - } - - pub fn next_group_id(&self) -> GroupId { - let next = self.next_group_id.get(); - self.next_group_id.set(next + 1); - GroupId(next) - } -} - -pub fn format_chunk(ctx: &FormatContext, chunk: &LuaChunk) -> Vec { - let spacing_plan = spacing::analyze_root_spacing(ctx, chunk); - let layout_plan = layout::analyze_root_layout(ctx, chunk, spacing_plan); - let final_plan = line_breaks::analyze_root_line_breaks(ctx, chunk, layout_plan); - render::render_root(ctx, chunk, &final_plan) -} diff --git a/crates/emmylua_formatter/src/formatter_new/sequence.rs b/crates/emmylua_formatter/src/formatter_new/sequence.rs deleted file mode 100644 index dced2f223..000000000 --- a/crates/emmylua_formatter/src/formatter_new/sequence.rs +++ /dev/null @@ -1,446 +0,0 @@ -use crate::config::ExpandStrategy; -use crate::ir::{self, DocIR, ir_flat_width, ir_has_forced_line_break}; -use crate::printer::Printer; - -#[derive(Clone)] -pub struct SequenceComment { - pub docs: Vec, - pub inline_after_previous: bool, -} - -use super::FormatContext; - -#[derive(Clone)] -pub enum SequenceEntry { - Item(Vec), - Comment(SequenceComment), - Separator { - docs: Vec, - after_docs: Vec, - }, -} - -pub fn render_sequence(docs: &mut Vec, entries: &[SequenceEntry], mut line_start: bool) { - let mut pending_docs_before_item: Vec = Vec::new(); - - for entry in entries { - match entry { - SequenceEntry::Item(item_docs) => { - if !line_start && !pending_docs_before_item.is_empty() { - docs.extend(pending_docs_before_item.clone()); - } - docs.extend(item_docs.clone()); - line_start = false; - pending_docs_before_item.clear(); - } - SequenceEntry::Comment(comment) => { - if comment.inline_after_previous && !line_start { - let mut suffix = vec![ir::space()]; - suffix.extend(comment.docs.clone()); - docs.push(ir::line_suffix(suffix)); - docs.push(ir::hard_line()); - } else { - if !line_start { - docs.push(ir::hard_line()); - } - docs.extend(comment.docs.clone()); - docs.push(ir::hard_line()); - } - line_start = true; - pending_docs_before_item.clear(); - } - SequenceEntry::Separator { - docs: separator_docs, - after_docs, - } => { - docs.extend(separator_docs.clone()); - line_start = false; - pending_docs_before_item = after_docs.clone(); - } - } - } -} - -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_inline_comment(entries: &[SequenceEntry]) -> bool { - matches!( - entries.first(), - Some(SequenceEntry::Comment(SequenceComment { - inline_after_previous: true, - .. - })) - ) -} - -#[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, -} - -#[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 fn choose_sequence_layout( - ctx: &FormatContext, - candidates: SequenceLayoutCandidates, - policy: SequenceLayoutPolicy, -) -> Vec { - let ordered = ordered_sequence_candidates(candidates, policy); - - if ordered.is_empty() { - return vec![]; - } - - if ordered.len() == 1 { - return ordered - .into_iter() - .next() - .map(|candidate| candidate.docs) - .unwrap_or_default(); - } - - if let Some(flat_candidate) = ordered.first() - && flat_candidate.kind == SequenceLayoutKind::Flat - && !ir_has_forced_line_break(&flat_candidate.docs) - && ir_flat_width(&flat_candidate.docs) + policy.first_line_prefix_width - <= ctx.config.layout.max_line_width - { - return flat_candidate.docs.clone(); - } - - 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( - _ctx: &FormatContext, - 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 flat_doc = build_flat_doc( - &layout.open, - &layout.close, - &layout.flat_open_padding, - flat_inner, - &layout.flat_trailing, - &layout.flat_close_padding, - ); - - match layout.strategy { - ExpandStrategy::Never => flat_doc, - ExpandStrategy::Always => format_expanded_delimited_sequence( - layout.open, - layout.close, - default_break_contents( - ir::intersperse(layout.items, layout.break_separator), - layout.grouped_trailing, - ), - ), - ExpandStrategy::Auto if layout.preserve_multiline => format_expanded_delimited_sequence( - layout.open, - layout.close, - default_break_contents( - ir::intersperse(layout.items, layout.break_separator), - layout.grouped_trailing, - ), - ), - 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_new/spacing.rs b/crates/emmylua_formatter/src/formatter_new/spacing.rs deleted file mode 100644 index 3b85592b9..000000000 --- a/crates/emmylua_formatter/src/formatter_new/spacing.rs +++ /dev/null @@ -1,662 +0,0 @@ -use crate::config::LuaFormatConfig; -use crate::ir::{self, DocIR}; -use emmylua_parser::{ - BinaryOperator, LuaAstNode, LuaChunk, LuaKind, LuaSyntaxId, LuaSyntaxKind, LuaSyntaxToken, - LuaTokenKind, -}; - -use super::FormatContext; -use super::model::{RootFormatPlan, RootSpacingModel, TokenSpacingExpected}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum SpaceRule { - Space, - NoSpace, - SoftLine, - SoftLineOrEmpty, -} - -impl SpaceRule { - pub(crate) 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(), - } - } -} - -pub(crate) fn space_around_binary_op(op: BinaryOperator, config: &LuaFormatConfig) -> SpaceRule { - match op { - BinaryOperator::OpAdd - | BinaryOperator::OpSub - | BinaryOperator::OpMul - | BinaryOperator::OpDiv - | BinaryOperator::OpIDiv - | BinaryOperator::OpMod - | BinaryOperator::OpPow => { - if config.spacing.space_around_math_operator { - SpaceRule::Space - } else { - SpaceRule::NoSpace - } - } - BinaryOperator::OpEq - | BinaryOperator::OpNe - | BinaryOperator::OpLt - | BinaryOperator::OpGt - | BinaryOperator::OpLe - | BinaryOperator::OpGe - | BinaryOperator::OpAnd - | BinaryOperator::OpOr - | BinaryOperator::OpBAnd - | BinaryOperator::OpBOr - | BinaryOperator::OpBXor - | BinaryOperator::OpShl - | BinaryOperator::OpShr - | BinaryOperator::OpNop => SpaceRule::Space, - BinaryOperator::OpConcat => { - if config.spacing.space_around_concat_operator { - SpaceRule::Space - } else { - SpaceRule::NoSpace - } - } - } -} - -pub(crate) fn space_around_assign(config: &LuaFormatConfig) -> SpaceRule { - if config.spacing.space_around_assign_operator { - SpaceRule::Space - } else { - SpaceRule::NoSpace - } -} - -pub fn analyze_root_spacing(ctx: &FormatContext, chunk: &LuaChunk) -> RootFormatPlan { - let mut plan = RootFormatPlan::from_config(ctx.config); - plan.spacing.has_shebang = chunk - .syntax() - .first_token() - .is_some_and(|token| token.kind() == LuaKind::Token(LuaTokenKind::TkShebang)); - - analyze_chunk_token_spacing(ctx, chunk, &mut plan.spacing); - - plan -} - -fn analyze_chunk_token_spacing( - ctx: &FormatContext, - chunk: &LuaChunk, - spacing: &mut RootSpacingModel, -) { - for element in chunk.syntax().descendants_with_tokens() { - let Some(token) = element.into_token() else { - continue; - }; - - if should_skip_spacing_token(&token) { - continue; - } - - analyze_token_spacing(ctx, spacing, &token); - } -} - -fn should_skip_spacing_token(token: &LuaSyntaxToken) -> bool { - matches!( - token.kind().to_token(), - LuaTokenKind::TkWhitespace | LuaTokenKind::TkEndOfLine | LuaTokenKind::TkShebang - ) -} - -fn analyze_token_spacing( - ctx: &FormatContext, - spacing: &mut RootSpacingModel, - token: &LuaSyntaxToken, -) { - let syntax_id = LuaSyntaxId::from_token(token); - match token.kind().to_token() { - LuaTokenKind::TkNormalStart => apply_comment_start_spacing(ctx, spacing, token, syntax_id), - LuaTokenKind::TkDocStart => { - spacing.add_token_replace(syntax_id, normalized_doc_tag_prefix(ctx)); - spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); - } - LuaTokenKind::TkDocContinue => { - spacing.add_token_replace(syntax_id, normalized_doc_continue_prefix(ctx, token.text())); - spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); - } - LuaTokenKind::TkDocContinueOr => { - spacing.add_token_replace( - syntax_id, - normalized_doc_continue_or_prefix(ctx, token.text()), - ); - spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); - } - LuaTokenKind::TkLeftParen => apply_left_paren_spacing(ctx, spacing, token, syntax_id), - LuaTokenKind::TkRightParen => apply_right_paren_spacing(ctx, spacing, token, syntax_id), - LuaTokenKind::TkLeftBracket => apply_left_bracket_spacing(ctx, spacing, token, syntax_id), - LuaTokenKind::TkRightBracket => { - spacing.add_token_left_expected( - syntax_id, - TokenSpacingExpected::Space(space_inside_brackets(token, ctx)), - ); - } - LuaTokenKind::TkLeftBrace => { - spacing.add_token_right_expected( - syntax_id, - TokenSpacingExpected::Space(space_inside_braces(token, ctx)), - ); - } - LuaTokenKind::TkRightBrace => { - spacing.add_token_left_expected( - syntax_id, - TokenSpacingExpected::Space(space_inside_braces(token, ctx)), - ); - } - LuaTokenKind::TkComma => { - spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(0)); - spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(1)); - } - LuaTokenKind::TkSemicolon => { - spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(0)); - spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(1)); - } - LuaTokenKind::TkColon => { - if is_parent_syntax(token, LuaSyntaxKind::IndexExpr) { - spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(0)); - spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); - } else if in_comment(token) { - spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::MaxSpace(1)); - spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::MaxSpace(1)); - } - } - LuaTokenKind::TkDot => { - spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(0)); - spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); - } - LuaTokenKind::TkPlus | LuaTokenKind::TkMinus => { - if is_parent_syntax(token, LuaSyntaxKind::UnaryExpr) { - spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); - } else { - apply_space_rule( - spacing, - syntax_id, - space_around_binary_op(binary_op_for_plus_minus(token), ctx.config), - ); - } - } - LuaTokenKind::TkMul - | LuaTokenKind::TkDiv - | LuaTokenKind::TkIDiv - | LuaTokenKind::TkMod - | LuaTokenKind::TkPow - | LuaTokenKind::TkConcat - | LuaTokenKind::TkBitAnd - | LuaTokenKind::TkBitOr - | LuaTokenKind::TkBitXor - | LuaTokenKind::TkShl - | LuaTokenKind::TkShr - | LuaTokenKind::TkEq - | LuaTokenKind::TkGe - | LuaTokenKind::TkGt - | LuaTokenKind::TkLe - | LuaTokenKind::TkLt - | LuaTokenKind::TkNe - | LuaTokenKind::TkAnd - | LuaTokenKind::TkOr => apply_operator_spacing(ctx, spacing, token, syntax_id), - LuaTokenKind::TkAssign => { - apply_space_rule(spacing, syntax_id, space_around_assign(ctx.config)); - } - 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 => { - spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(1)); - spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(1)); - } - _ => {} - } -} - -fn apply_left_paren_spacing( - ctx: &FormatContext, - spacing: &mut RootSpacingModel, - token: &LuaSyntaxToken, - syntax_id: LuaSyntaxId, -) { - let left_space = if is_parent_syntax(token, LuaSyntaxKind::ParamList) { - usize::from(ctx.config.spacing.space_before_func_paren) - } else if is_parent_syntax(token, LuaSyntaxKind::CallArgList) { - usize::from(ctx.config.spacing.space_before_call_paren) - } else if let Some(prev_token) = get_prev_sibling_token_without_space(token) { - match prev_token.kind().to_token() { - LuaTokenKind::TkName - | LuaTokenKind::TkRightParen - | LuaTokenKind::TkRightBracket - | LuaTokenKind::TkFunction => 0, - LuaTokenKind::TkString | LuaTokenKind::TkRightBrace | LuaTokenKind::TkLongString => 1, - _ => 0, - } - } else { - 0 - }; - - spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(left_space)); - spacing.add_token_right_expected( - syntax_id, - TokenSpacingExpected::Space(space_inside_parens(token, ctx)), - ); -} - -fn apply_right_paren_spacing( - ctx: &FormatContext, - spacing: &mut RootSpacingModel, - token: &LuaSyntaxToken, - syntax_id: LuaSyntaxId, -) { - spacing.add_token_left_expected( - syntax_id, - TokenSpacingExpected::Space(space_inside_parens(token, ctx)), - ); -} - -fn apply_left_bracket_spacing( - ctx: &FormatContext, - spacing: &mut RootSpacingModel, - token: &LuaSyntaxToken, - syntax_id: LuaSyntaxId, -) { - let left_space = if let Some(prev_token) = get_prev_sibling_token_without_space(token) { - match prev_token.kind().to_token() { - LuaTokenKind::TkName - | LuaTokenKind::TkRightParen - | LuaTokenKind::TkRightBracket - | LuaTokenKind::TkDot - | LuaTokenKind::TkColon => 0, - LuaTokenKind::TkString | LuaTokenKind::TkRightBrace | LuaTokenKind::TkLongString => 1, - _ => 0, - } - } else { - 0 - }; - - spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(left_space)); - spacing.add_token_right_expected( - syntax_id, - TokenSpacingExpected::Space(space_inside_brackets(token, ctx)), - ); -} - -fn apply_operator_spacing( - ctx: &FormatContext, - spacing: &mut RootSpacingModel, - token: &LuaSyntaxToken, - syntax_id: LuaSyntaxId, -) { - match token.kind().to_token() { - LuaTokenKind::TkLt | LuaTokenKind::TkGt - if is_parent_syntax(token, LuaSyntaxKind::Attribute) => - { - let (left, right) = if token.kind().to_token() == LuaTokenKind::TkLt { - (1, 0) - } else { - (0, 1) - }; - spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(left)); - spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(right)); - } - _ => { - let Some(rule) = binary_space_rule_for_token(ctx, token) else { - return; - }; - apply_space_rule(spacing, syntax_id, rule); - } - } -} - -fn apply_comment_start_spacing( - ctx: &FormatContext, - spacing: &mut RootSpacingModel, - token: &LuaSyntaxToken, - syntax_id: LuaSyntaxId, -) { - if !in_comment(token) { - return; - } - - if let Some(replacement) = normalized_comment_prefix(ctx, token.text()) { - spacing.add_token_replace(syntax_id, replacement); - spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); - } -} - -fn binary_space_rule_for_token(ctx: &FormatContext, token: &LuaSyntaxToken) -> Option { - let op = match token.kind().to_token() { - LuaTokenKind::TkPlus => BinaryOperator::OpAdd, - LuaTokenKind::TkMinus => BinaryOperator::OpSub, - LuaTokenKind::TkMul => BinaryOperator::OpMul, - LuaTokenKind::TkDiv => BinaryOperator::OpDiv, - LuaTokenKind::TkIDiv => BinaryOperator::OpIDiv, - LuaTokenKind::TkMod => BinaryOperator::OpMod, - LuaTokenKind::TkPow => BinaryOperator::OpPow, - LuaTokenKind::TkConcat => BinaryOperator::OpConcat, - LuaTokenKind::TkBitAnd => BinaryOperator::OpBAnd, - LuaTokenKind::TkBitOr => BinaryOperator::OpBOr, - LuaTokenKind::TkBitXor => BinaryOperator::OpBXor, - LuaTokenKind::TkShl => BinaryOperator::OpShl, - LuaTokenKind::TkShr => BinaryOperator::OpShr, - LuaTokenKind::TkEq => BinaryOperator::OpEq, - LuaTokenKind::TkGe => BinaryOperator::OpGe, - LuaTokenKind::TkGt => BinaryOperator::OpGt, - LuaTokenKind::TkLe => BinaryOperator::OpLe, - LuaTokenKind::TkLt => BinaryOperator::OpLt, - LuaTokenKind::TkNe => BinaryOperator::OpNe, - LuaTokenKind::TkAnd => BinaryOperator::OpAnd, - LuaTokenKind::TkOr => BinaryOperator::OpOr, - _ => return None, - }; - - Some(space_around_binary_op(op, ctx.config)) -} - -fn binary_op_for_plus_minus(token: &LuaSyntaxToken) -> BinaryOperator { - match token.kind().to_token() { - LuaTokenKind::TkPlus => BinaryOperator::OpAdd, - LuaTokenKind::TkMinus => BinaryOperator::OpSub, - _ => BinaryOperator::OpNop, - } -} - -fn apply_space_rule(spacing: &mut RootSpacingModel, syntax_id: LuaSyntaxId, rule: SpaceRule) { - match rule { - SpaceRule::Space | SpaceRule::SoftLine => { - spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(1)); - spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(1)); - } - SpaceRule::NoSpace | SpaceRule::SoftLineOrEmpty => { - spacing.add_token_left_expected(syntax_id, TokenSpacingExpected::Space(0)); - spacing.add_token_right_expected(syntax_id, TokenSpacingExpected::Space(0)); - } - } -} - -fn space_inside_parens(token: &LuaSyntaxToken, ctx: &FormatContext) -> usize { - if is_parent_syntax(token, LuaSyntaxKind::ParenExpr) { - usize::from(ctx.config.spacing.space_inside_parens) - } else { - 0 - } -} - -fn space_inside_brackets(_token: &LuaSyntaxToken, ctx: &FormatContext) -> usize { - usize::from(ctx.config.spacing.space_inside_brackets) -} - -fn space_inside_braces(_token: &LuaSyntaxToken, ctx: &FormatContext) -> usize { - usize::from(ctx.config.spacing.space_inside_braces) -} - -fn is_parent_syntax(token: &LuaSyntaxToken, kind: LuaSyntaxKind) -> bool { - token - .parent() - .is_some_and(|parent| parent.kind().to_syntax() == kind) -} - -fn in_comment(token: &LuaSyntaxToken) -> bool { - let mut current = token.parent(); - while let Some(node) = current { - if node.kind().to_syntax() == LuaSyntaxKind::Comment { - return true; - } - current = node.parent(); - } - - false -} - -fn get_prev_sibling_token_without_space(token: &LuaSyntaxToken) -> Option { - let mut current = token.clone(); - while let Some(prev) = current.prev_token() { - if !matches!( - prev.kind().to_token(), - LuaTokenKind::TkWhitespace | LuaTokenKind::TkEndOfLine - ) { - return Some(prev); - } - current = prev; - } - - None -} - -fn normalized_comment_prefix(ctx: &FormatContext, prefix_text: &str) -> Option { - match dash_prefix_len(prefix_text) { - 2 => Some(if ctx.config.comments.space_after_comment_dash { - "-- ".to_string() - } else { - "--".to_string() - }), - 3 => Some(if ctx.config.emmy_doc.space_after_description_dash { - "--- ".to_string() - } else { - "---".to_string() - }), - _ => None, - } -} - -fn normalized_doc_tag_prefix(ctx: &FormatContext) -> String { - if ctx.config.emmy_doc.space_after_description_dash { - "--- @".to_string() - } else { - "---@".to_string() - } -} - -fn normalized_doc_continue_prefix(ctx: &FormatContext, prefix_text: &str) -> String { - if prefix_text == "---" || prefix_text == "--- " { - if ctx.config.emmy_doc.space_after_description_dash { - "--- ".to_string() - } else { - "---".to_string() - } - } else { - prefix_text.to_string() - } -} - -fn normalized_doc_continue_or_prefix(ctx: &FormatContext, prefix_text: &str) -> String { - if !prefix_text.starts_with("---") { - return prefix_text.to_string(); - } - - let suffix = prefix_text[3..].trim_start(); - if ctx.config.emmy_doc.space_after_description_dash { - format!("--- {suffix}") - } else { - format!("---{suffix}") - } -} - -fn dash_prefix_len(prefix_text: &str) -> usize { - prefix_text.bytes().take_while(|byte| *byte == b'-').count() -} - -#[cfg(test)] -mod tests { - use emmylua_parser::{LuaLanguageLevel, LuaParser, ParserConfig}; - - use crate::config::LuaFormatConfig; - - use super::*; - - fn analyze(input: &str, config: LuaFormatConfig) -> RootSpacingModel { - let tree = LuaParser::parse(input, ParserConfig::with_level(LuaLanguageLevel::Lua54)); - let chunk = tree.get_chunk_node(); - let ctx = FormatContext::new(&config); - analyze_root_spacing(&ctx, &chunk).spacing - } - - fn find_token(chunk: &LuaChunk, kind: LuaTokenKind) -> LuaSyntaxToken { - chunk - .syntax() - .descendants_with_tokens() - .filter_map(|element| element.into_token()) - .find(|token| token.kind().to_token() == kind) - .unwrap() - } - - #[test] - fn test_spacing_assign_defaults_to_single_spaces() { - let config = LuaFormatConfig::default(); - let tree = LuaParser::parse( - "local x=1\n", - ParserConfig::with_level(LuaLanguageLevel::Lua54), - ); - let chunk = tree.get_chunk_node(); - let spacing = analyze_root_spacing(&FormatContext::new(&config), &chunk).spacing; - let assign = find_token(&chunk, LuaTokenKind::TkAssign); - let assign_id = LuaSyntaxId::from_token(&assign); - - assert_eq!( - spacing.left_expected(assign_id), - Some(&TokenSpacingExpected::Space(1)) - ); - assert_eq!( - spacing.right_expected(assign_id), - Some(&TokenSpacingExpected::Space(1)) - ); - } - - #[test] - fn test_spacing_uses_call_paren_config() { - let config = LuaFormatConfig { - spacing: crate::config::SpacingConfig { - space_before_call_paren: true, - ..Default::default() - }, - ..Default::default() - }; - let tree = LuaParser::parse( - "foo(a)\n", - ParserConfig::with_level(LuaLanguageLevel::Lua54), - ); - let chunk = tree.get_chunk_node(); - let spacing = analyze_root_spacing(&FormatContext::new(&config), &chunk).spacing; - let left_paren = find_token(&chunk, LuaTokenKind::TkLeftParen); - let paren_id = LuaSyntaxId::from_token(&left_paren); - - assert_eq!( - spacing.left_expected(paren_id), - Some(&TokenSpacingExpected::Space(1)) - ); - assert_eq!( - spacing.right_expected(paren_id), - Some(&TokenSpacingExpected::Space(0)) - ); - } - - #[test] - fn test_spacing_respects_paren_expr_inner_space() { - let config = LuaFormatConfig { - spacing: crate::config::SpacingConfig { - space_inside_parens: true, - ..Default::default() - }, - ..Default::default() - }; - let tree = LuaParser::parse( - "local x = (a)\n", - ParserConfig::with_level(LuaLanguageLevel::Lua54), - ); - let chunk = tree.get_chunk_node(); - let spacing = analyze_root_spacing(&FormatContext::new(&config), &chunk).spacing; - let left_paren = find_token(&chunk, LuaTokenKind::TkLeftParen); - let right_paren = find_token(&chunk, LuaTokenKind::TkRightParen); - - assert_eq!( - spacing.right_expected(LuaSyntaxId::from_token(&left_paren)), - Some(&TokenSpacingExpected::Space(1)) - ); - assert_eq!( - spacing.left_expected(LuaSyntaxId::from_token(&right_paren)), - Some(&TokenSpacingExpected::Space(1)) - ); - } - - #[test] - fn test_spacing_respects_math_operator_config() { - let config = LuaFormatConfig { - spacing: crate::config::SpacingConfig { - space_around_math_operator: false, - ..Default::default() - }, - ..Default::default() - }; - let tree = LuaParser::parse( - "local x = a+b\n", - ParserConfig::with_level(LuaLanguageLevel::Lua54), - ); - let chunk = tree.get_chunk_node(); - let spacing = analyze_root_spacing(&FormatContext::new(&config), &chunk).spacing; - let plus = find_token(&chunk, LuaTokenKind::TkPlus); - let plus_id = LuaSyntaxId::from_token(&plus); - - assert_eq!( - spacing.left_expected(plus_id), - Some(&TokenSpacingExpected::Space(0)) - ); - assert_eq!( - spacing.right_expected(plus_id), - Some(&TokenSpacingExpected::Space(0)) - ); - } - - #[test] - fn test_spacing_collects_comment_prefix_replacement() { - let config = LuaFormatConfig::default(); - let tree = LuaParser::parse( - "--hello\n", - ParserConfig::with_level(LuaLanguageLevel::Lua54), - ); - let chunk = tree.get_chunk_node(); - let spacing = analyze_root_spacing(&FormatContext::new(&config), &chunk).spacing; - let start = find_token(&chunk, LuaTokenKind::TkNormalStart); - let start_id = LuaSyntaxId::from_token(&start); - - assert_eq!(spacing.token_replace(start_id), Some("-- ")); - assert_eq!( - spacing.right_expected(start_id), - Some(&TokenSpacingExpected::Space(0)) - ); - } - - #[test] - fn test_spacing_collects_doc_prefix_replacement() { - let config = LuaFormatConfig::default(); - let tree = LuaParser::parse( - "---@param x string\n", - ParserConfig::with_level(LuaLanguageLevel::Lua54), - ); - let chunk = tree.get_chunk_node(); - let spacing = analyze_root_spacing(&FormatContext::new(&config), &chunk).spacing; - let start = find_token(&chunk, LuaTokenKind::TkDocStart); - - assert_eq!( - spacing.token_replace(LuaSyntaxId::from_token(&start)), - Some("--- @") - ); - } -} diff --git a/crates/emmylua_formatter/src/formatter_new/trivia.rs b/crates/emmylua_formatter/src/formatter_new/trivia.rs deleted file mode 100644 index f21cdde24..000000000 --- a/crates/emmylua_formatter/src/formatter_new/trivia.rs +++ /dev/null @@ -1,71 +0,0 @@ -use emmylua_parser::{LuaKind, LuaSyntaxKind, LuaSyntaxNode, LuaTokenKind}; - -pub fn count_blank_lines_before(node: &LuaSyntaxNode) -> usize { - let mut blank_lines = 0; - let mut consecutive_newlines = 0; - - 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 => {} - _ => break, - } - token = t.prev_token(); - } - } - - blank_lines -} - -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_tokenwise(node: &LuaSyntaxNode) -> bool { - let Some(first_token) = node.first_token() else { - return false; - }; - - let mut previous = first_token.prev_token(); - while let Some(token) = previous { - match token.kind().to_token() { - LuaTokenKind::TkWhitespace => previous = token.prev_token(), - LuaTokenKind::TkEndOfLine => return false, - _ => return true, - } - } - - false -} - -pub fn source_line_prefix_width(node: &LuaSyntaxNode) -> usize { - let mut width = 0usize; - let Some(mut token) = node.first_token() else { - return 0; - }; - - while let Some(prev) = token.prev_token() { - let text = prev.text(); - let mut chars_since_break = 0usize; - - for ch in text.chars().rev() { - if matches!(ch, '\n' | '\r') { - return width; - } - chars_since_break += 1; - } - - width += chars_since_break; - token = prev; - } - - width -} diff --git a/crates/emmylua_formatter/src/lib.rs b/crates/emmylua_formatter/src/lib.rs index a757da55f..05ead1543 100644 --- a/crates/emmylua_formatter/src/lib.rs +++ b/crates/emmylua_formatter/src/lib.rs @@ -2,8 +2,6 @@ pub mod cmd_args; pub mod config; mod formatter; -#[allow(dead_code)] -mod formatter_new; pub mod ir; mod printer; mod test; @@ -46,20 +44,3 @@ pub fn reformat_chunk(chunk: &LuaChunk, config: &LuaFormatConfig) -> String { Printer::new(config).print(&ir) } - -pub fn reformat_lua_code_new(source: &SourceText, config: &LuaFormatConfig) -> String { - let tree = LuaParser::parse(source.text, ParserConfig::with_level(source.level)); - - let ctx = formatter_new::FormatContext::new(config); - let chunk = tree.get_chunk_node(); - let ir = formatter_new::format_chunk(&ctx, &chunk); - - Printer::new(config).print(&ir) -} - -pub fn reformat_chunk_new(chunk: &LuaChunk, config: &LuaFormatConfig) -> String { - let ctx = formatter_new::FormatContext::new(config); - let ir = formatter_new::format_chunk(&ctx, chunk); - - Printer::new(config).print(&ir) -} diff --git a/crates/emmylua_formatter/src/test/misc_tests.rs b/crates/emmylua_formatter/src/test/misc_tests.rs index 3a19565e4..df8524c85 100644 --- a/crates/emmylua_formatter/src/test/misc_tests.rs +++ b/crates/emmylua_formatter/src/test/misc_tests.rs @@ -2,10 +2,7 @@ mod tests { use emmylua_parser::LuaLanguageLevel; - use crate::{ - SourceText, assert_format, config::LuaFormatConfig, reformat_lua_code, - reformat_lua_code_new, - }; + use crate::{SourceText, assert_format, config::LuaFormatConfig, reformat_lua_code}; // ========== shebang ========== @@ -257,7 +254,7 @@ local cc = 3 -- comment c assert_eq!( reformat_lua_code(&source, &config), - reformat_lua_code_new(&source, &config) + reformat_lua_code(&source, &config) ); } @@ -271,7 +268,7 @@ local cc = 3 -- comment c assert_eq!( reformat_lua_code(&source, &config), - reformat_lua_code_new(&source, &config) + reformat_lua_code(&source, &config) ); } @@ -285,7 +282,7 @@ local cc = 3 -- comment c assert_eq!( reformat_lua_code(&source, &config), - reformat_lua_code_new(&source, &config) + reformat_lua_code(&source, &config) ); } @@ -301,7 +298,7 @@ local cc = 3 -- comment c assert_eq!( reformat_lua_code(&source, &config), - reformat_lua_code_new(&source, &config) + reformat_lua_code(&source, &config) ); } @@ -313,8 +310,8 @@ local cc = 3 -- comment c level: LuaLanguageLevel::default(), }; - let first = reformat_lua_code_new(&source, &config); - let second = reformat_lua_code_new( + let first = reformat_lua_code(&source, &config); + let second = reformat_lua_code( &SourceText { text: &first, level: LuaLanguageLevel::default(), @@ -337,7 +334,7 @@ local cc = 3 -- comment c assert_eq!( reformat_lua_code(&source, &config), - reformat_lua_code_new(&source, &config) + reformat_lua_code(&source, &config) ); } @@ -351,7 +348,7 @@ local cc = 3 -- comment c assert_eq!( reformat_lua_code(&source, &config), - reformat_lua_code_new(&source, &config) + reformat_lua_code(&source, &config) ); } @@ -365,7 +362,7 @@ local cc = 3 -- comment c assert_eq!( reformat_lua_code(&source, &config), - reformat_lua_code_new(&source, &config) + reformat_lua_code(&source, &config) ); } @@ -379,7 +376,7 @@ local cc = 3 -- comment c assert_eq!( reformat_lua_code(&source, &config), - reformat_lua_code_new(&source, &config) + reformat_lua_code(&source, &config) ); } @@ -393,7 +390,7 @@ local cc = 3 -- comment c assert_eq!( reformat_lua_code(&source, &config), - reformat_lua_code_new(&source, &config) + reformat_lua_code(&source, &config) ); } @@ -407,7 +404,7 @@ local cc = 3 -- comment c assert_eq!( reformat_lua_code(&source, &config), - reformat_lua_code_new(&source, &config) + reformat_lua_code(&source, &config) ); } @@ -421,7 +418,7 @@ local cc = 3 -- comment c assert_eq!( reformat_lua_code(&source, &config), - reformat_lua_code_new(&source, &config) + reformat_lua_code(&source, &config) ); } @@ -435,7 +432,7 @@ local cc = 3 -- comment c assert_eq!( reformat_lua_code(&source, &config), - reformat_lua_code_new(&source, &config) + reformat_lua_code(&source, &config) ); } @@ -449,7 +446,7 @@ local cc = 3 -- comment c assert_eq!( reformat_lua_code(&source, &config), - reformat_lua_code_new(&source, &config) + reformat_lua_code(&source, &config) ); } @@ -463,7 +460,7 @@ local cc = 3 -- comment c assert_eq!( reformat_lua_code(&source, &config), - reformat_lua_code_new(&source, &config) + reformat_lua_code(&source, &config) ); } @@ -477,7 +474,7 @@ local cc = 3 -- comment c assert_eq!( reformat_lua_code(&source, &config), - reformat_lua_code_new(&source, &config) + reformat_lua_code(&source, &config) ); } @@ -489,8 +486,8 @@ local cc = 3 -- comment c level: LuaLanguageLevel::default(), }; - let first = reformat_lua_code_new(&source, &config); - let second = reformat_lua_code_new( + let first = reformat_lua_code(&source, &config); + let second = reformat_lua_code( &SourceText { text: &first, level: LuaLanguageLevel::default(), @@ -509,8 +506,8 @@ local cc = 3 -- comment c level: LuaLanguageLevel::default(), }; - let first = reformat_lua_code_new(&source, &config); - let second = reformat_lua_code_new( + let first = reformat_lua_code(&source, &config); + let second = reformat_lua_code( &SourceText { text: &first, level: LuaLanguageLevel::default(), @@ -529,8 +526,8 @@ local cc = 3 -- comment c level: LuaLanguageLevel::default(), }; - let first = reformat_lua_code_new(&source, &config); - let second = reformat_lua_code_new( + let first = reformat_lua_code(&source, &config); + let second = reformat_lua_code( &SourceText { text: &first, level: LuaLanguageLevel::default(), @@ -549,8 +546,8 @@ local cc = 3 -- comment c level: LuaLanguageLevel::default(), }; - let first = reformat_lua_code_new(&source, &config); - let second = reformat_lua_code_new( + let first = reformat_lua_code(&source, &config); + let second = reformat_lua_code( &SourceText { text: &first, level: LuaLanguageLevel::default(), @@ -571,7 +568,7 @@ local cc = 3 -- comment c assert_eq!( reformat_lua_code(&source, &config), - reformat_lua_code_new(&source, &config) + reformat_lua_code(&source, &config) ); } @@ -583,8 +580,8 @@ local cc = 3 -- comment c level: LuaLanguageLevel::default(), }; - let first = reformat_lua_code_new(&source, &config); - let second = reformat_lua_code_new( + let first = reformat_lua_code(&source, &config); + let second = reformat_lua_code( &SourceText { text: &first, level: LuaLanguageLevel::default(), From 64306d70c9c8dbe0bf700c326edebb869164f3aa Mon Sep 17 00:00:00 2001 From: CppCXY <812125110@qq.com> Date: Fri, 27 Mar 2026 20:52:48 +0800 Subject: [PATCH 23/23] update --- .../emmylua_formatter/src/formatter/expr.rs | 1279 ++++++++++++++++- crates/emmylua_formatter/src/formatter/mod.rs | 16 +- .../emmylua_formatter/src/formatter/render.rs | 1227 ++++++++++++++-- .../emmylua_formatter/src/formatter/trivia.rs | 83 +- 4 files changed, 2423 insertions(+), 182 deletions(-) diff --git a/crates/emmylua_formatter/src/formatter/expr.rs b/crates/emmylua_formatter/src/formatter/expr.rs index 7ba36b026..d64981e08 100644 --- a/crates/emmylua_formatter/src/formatter/expr.rs +++ b/crates/emmylua_formatter/src/formatter/expr.rs @@ -1,33 +1,295 @@ use emmylua_parser::{ - LuaAstNode, LuaAstToken, LuaCallArgList, LuaCallExpr, LuaClosureExpr, LuaComment, LuaExpr, - LuaIndexKey, LuaKind, LuaLiteralToken, LuaParamList, LuaSingleArgExpr, LuaSyntaxId, + BinaryOperator, LuaAstNode, LuaAstToken, LuaBinaryExpr, LuaCallArgList, LuaCallExpr, + LuaClosureExpr, LuaComment, LuaExpr, LuaIndexExpr, LuaIndexKey, LuaKind, LuaLiteralExpr, + LuaLiteralToken, LuaNameExpr, LuaParamList, LuaParenExpr, LuaSingleArgExpr, LuaSyntaxId, LuaSyntaxKind, LuaSyntaxNode, LuaSyntaxToken, LuaTableExpr, LuaTableField, LuaTokenKind, + LuaUnaryExpr, UnaryOperator, }; use rowan::TextRange; -use crate::config::{ExpandStrategy, SingleArgCallParens, TrailingComma}; -use crate::ir::{self, DocIR}; +use crate::config::{ExpandStrategy, QuoteStyle, SingleArgCallParens, TrailingComma}; +use crate::ir::{self, AlignEntry, DocIR}; use super::FormatContext; use super::model::{ExprSequenceLayoutPlan, RootFormatPlan, TokenSpacingExpected}; -use super::sequence::{DelimitedSequenceLayout, format_delimited_sequence}; -use super::trivia::{has_non_trivia_before_on_same_line_tokenwise, node_has_direct_comment_child}; +use super::sequence::{ + DelimitedSequenceLayout, SequenceLayoutCandidates, SequenceLayoutPolicy, + choose_sequence_layout, format_delimited_sequence, +}; +use super::spacing::{SpaceRule, space_around_binary_op}; +use super::trivia::{ + has_non_trivia_before_on_same_line_tokenwise, node_has_direct_comment_child, + source_line_prefix_width, trailing_gap_requests_alignment, +}; pub fn format_expr(ctx: &FormatContext, plan: &RootFormatPlan, expr: &LuaExpr) -> Vec { match expr { + LuaExpr::NameExpr(expr) => format_name_expr(expr), + LuaExpr::LiteralExpr(expr) => format_literal_expr(ctx, expr), + LuaExpr::BinaryExpr(expr) => format_binary_expr(ctx, plan, expr), + LuaExpr::UnaryExpr(expr) => format_unary_expr(ctx, plan, expr), + LuaExpr::ParenExpr(expr) => format_paren_expr(ctx, plan, expr), + LuaExpr::IndexExpr(expr) => format_index_expr(ctx, plan, expr), LuaExpr::CallExpr(expr) => format_call_expr(ctx, plan, expr), LuaExpr::TableExpr(expr) => format_table_expr(ctx, plan, expr), LuaExpr::ClosureExpr(expr) => format_closure_expr(ctx, plan, expr), - _ => vec![ir::source_node_trimmed(expr.syntax().clone())], } } +fn format_name_expr(expr: &LuaNameExpr) -> Vec { + expr.get_name_token() + .map(|token| vec![ir::source_token(token.syntax().clone())]) + .unwrap_or_default() +} + +type EqSplitDocs = (Vec, Vec); + +fn format_literal_expr(ctx: &FormatContext, expr: &LuaLiteralExpr) -> Vec { + let Some(LuaLiteralToken::String(token)) = expr.get_literal() else { + return vec![ir::source_node_trimmed(expr.syntax().clone())]; + }; + + let text = token.syntax().text().to_string(); + let Some(original_quote) = text.chars().next() else { + return vec![ir::source_node_trimmed(expr.syntax().clone())]; + }; + if token.syntax().kind() == LuaTokenKind::TkLongString.into() + || !matches!(original_quote, '\'' | '"') + { + return vec![ir::source_node_trimmed(expr.syntax().clone())]; + } + + let preferred_quote = match ctx.config.output.quote_style { + QuoteStyle::Preserve => return vec![ir::source_node_trimmed(expr.syntax().clone())], + QuoteStyle::Double => '"', + QuoteStyle::Single => '\'', + }; + if preferred_quote == original_quote { + return vec![ir::source_node_trimmed(expr.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_node_trimmed(expr.syntax().clone())]; + } + + vec![ir::text(rewrite_short_string_quotes( + raw_body, + original_quote, + preferred_quote, + ))] +} + +fn format_binary_expr( + ctx: &FormatContext, + plan: &RootFormatPlan, + expr: &LuaBinaryExpr, +) -> Vec { + if node_has_direct_comment_child(expr.syntax()) { + return vec![ir::source_node_trimmed(expr.syntax().clone())]; + } + + if let Some(flattened) = try_format_flat_binary_chain(ctx, plan, expr) { + return flattened; + } + + let Some((left, right)) = expr.get_exprs() else { + return vec![ir::source_node_trimmed(expr.syntax().clone())]; + }; + let Some(op_token) = expr.get_op_token() else { + return vec![ir::source_node_trimmed(expr.syntax().clone())]; + }; + + let left_docs = format_expr(ctx, plan, &left); + let right_docs = format_expr(ctx, plan, &right); + let space_rule = space_around_binary_op(op_token.get_op(), ctx.config); + let force_space_before = op_token.get_op() == BinaryOperator::OpConcat + && space_rule == SpaceRule::NoSpace + && left + .syntax() + .last_token() + .is_some_and(|token| token.kind() == LuaTokenKind::TkFloat.into()); + + if crate::ir::ir_has_forced_line_break(&left_docs) + && should_attach_short_binary_tail(op_token.get_op(), &right, &right_docs) + { + let mut docs = left_docs; + if force_space_before { + docs.push(ir::space()); + } else { + docs.push(space_rule.to_ir()); + } + docs.push(ir::source_token(op_token.syntax().clone())); + docs.push(space_rule.to_ir()); + docs.extend(right_docs); + return docs; + } + + vec![ir::group(vec![ + ir::list(left_docs), + ir::indent(vec![ + continuation_break_ir(force_space_before || space_rule != SpaceRule::NoSpace), + ir::source_token(op_token.syntax().clone()), + space_rule.to_ir(), + ir::list(right_docs), + ]), + ])] +} + +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 +} + +fn should_attach_short_binary_tail( + op: BinaryOperator, + right: &LuaExpr, + right_docs: &[DocIR], +) -> bool { + if crate::ir::ir_has_forced_line_break(right_docs) { + return false; + } + + match op { + BinaryOperator::OpAnd | BinaryOperator::OpOr => { + crate::ir::ir_flat_width(right_docs) <= 24 + && matches!( + right, + LuaExpr::LiteralExpr(_) + | LuaExpr::NameExpr(_) + | LuaExpr::ParenExpr(_) + | LuaExpr::IndexExpr(_) + | LuaExpr::CallExpr(_) + ) + } + BinaryOperator::OpEq + | BinaryOperator::OpNe + | BinaryOperator::OpLt + | BinaryOperator::OpLe + | BinaryOperator::OpGt + | BinaryOperator::OpGe => { + crate::ir::ir_flat_width(right_docs) <= 16 + && matches!( + right, + LuaExpr::LiteralExpr(_) | LuaExpr::NameExpr(_) | LuaExpr::ParenExpr(_) + ) + } + _ => false, + } +} + +fn format_unary_expr( + ctx: &FormatContext, + plan: &RootFormatPlan, + expr: &LuaUnaryExpr, +) -> Vec { + let mut docs = Vec::new(); + if let Some(op_token) = expr.get_op_token() { + docs.push(ir::source_token(op_token.syntax().clone())); + if matches!(op_token.get_op(), UnaryOperator::OpNot) { + docs.push(ir::space()); + } + } + if let Some(inner) = expr.get_expr() { + docs.extend(format_expr(ctx, plan, &inner)); + } + docs +} + +fn format_paren_expr( + ctx: &FormatContext, + plan: &RootFormatPlan, + expr: &LuaParenExpr, +) -> Vec { + if node_has_direct_comment_child(expr.syntax()) { + return vec![ir::source_node_trimmed(expr.syntax().clone())]; + } + + let mut docs = vec![ir::syntax_token(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, plan, &inner)); + } + if ctx.config.spacing.space_inside_parens { + docs.push(ir::space()); + } + docs.push(ir::syntax_token(LuaTokenKind::TkRightParen)); + docs +} + +fn format_index_expr( + ctx: &FormatContext, + plan: &RootFormatPlan, + expr: &LuaIndexExpr, +) -> Vec { + if node_has_direct_comment_child(expr.syntax()) { + return vec![ir::source_node_trimmed(expr.syntax().clone())]; + } + + let mut docs = expr + .get_prefix_expr() + .map(|prefix| format_expr(ctx, plan, &prefix)) + .unwrap_or_default(); + docs.extend(format_index_access_ir(ctx, plan, expr)); + docs +} + pub fn format_param_list_ir( ctx: &FormatContext, plan: &RootFormatPlan, params: &LuaParamList, ) -> Vec { - let collected = collect_param_entries(params); + let collected = collect_param_entries(ctx, params); if collected.has_comments { return format_param_list_with_comments(ctx, plan, params, collected); @@ -40,8 +302,6 @@ pub fn format_param_list_ir( .collect(); let (open, close) = paren_tokens(params.syntax()); let comma = first_direct_token(params.syntax(), LuaTokenKind::TkComma); - let layout_plan = expr_sequence_layout_plan(plan, params.syntax()); - format_delimited_sequence( ctx, DelimitedSequenceLayout { @@ -49,7 +309,7 @@ pub fn format_param_list_ir( close: token_or_kind_doc(close.as_ref(), LuaTokenKind::TkRightParen), items: param_docs, strategy: ctx.config.layout.func_params_expand.clone(), - preserve_multiline: layout_plan.preserve_multiline, + preserve_multiline: false, flat_separator: comma_flat_separator(plan, comma.as_ref()), fill_separator: comma_fill_separator(comma.as_ref()), break_separator: comma_break_separator(comma.as_ref()), @@ -65,19 +325,25 @@ pub fn format_param_list_ir( #[derive(Default)] struct CollectedParamEntries { entries: Vec, - comments_after_open: Vec>, + comments_after_open: Vec, comments_before_close: Vec>, has_comments: bool, consumed_comment_ranges: Vec, } +struct DelimitedComment { + docs: Vec, + same_line_after_open: bool, +} + struct ParamEntry { leading_comments: Vec>, doc: Vec, trailing_comment: Option>, + trailing_align_hint: bool, } -fn collect_param_entries(params: &LuaParamList) -> CollectedParamEntries { +fn collect_param_entries(ctx: &FormatContext, params: &LuaParamList) -> CollectedParamEntries { let mut collected = CollectedParamEntries::default(); let mut pending_comments = Vec::new(); let mut seen_param = false; @@ -94,7 +360,12 @@ fn collect_param_entries(params: &LuaParamList) -> CollectedParamEntries { let docs = vec![ir::source_node_trimmed(comment.syntax().clone())]; collected.has_comments = true; if !seen_param { - collected.comments_after_open.push(docs); + collected.comments_after_open.push(DelimitedComment { + docs, + same_line_after_open: has_non_trivia_before_on_same_line_tokenwise( + comment.syntax(), + ), + }); } else { pending_comments.push(docs); } @@ -106,6 +377,13 @@ fn collect_param_entries(params: &LuaParamList) -> CollectedParamEntries { if trailing_comment.is_some() { collected.has_comments = true; } + let trailing_align_hint = trailing_comment.as_ref().is_some_and(|(_, range)| { + trailing_gap_requests_alignment( + param.syntax(), + *range, + ctx.config.comments.line_comment_min_spaces_before.max(1), + ) + }); if let Some((_, range)) = &trailing_comment { collected.consumed_comment_ranges.push(*range); } @@ -120,6 +398,7 @@ fn collect_param_entries(params: &LuaParamList) -> CollectedParamEntries { leading_comments: std::mem::take(&mut pending_comments), doc, trailing_comment: trailing_comment.map(|(docs, _)| docs), + trailing_align_hint, }); seen_param = true; } @@ -144,28 +423,58 @@ fn format_param_list_with_comments( let trailing = trailing_comma_ir(ctx.config.output.trailing_comma.clone()); if !collected.comments_after_open.is_empty() || !collected.entries.is_empty() { + let entry_count = collected.entries.len(); let mut inner = Vec::new(); + let trailing_widths = aligned_trailing_comment_widths( + ctx.config.should_align_param_line_comments() + && collected + .entries + .iter() + .any(|entry| entry.trailing_align_hint), + collected.entries.iter().enumerate().map(|(index, entry)| { + let mut content = entry.doc.clone(); + if index + 1 < entry_count { + content.extend(comma_token_docs(comma.as_ref())); + } else { + content.push(trailing.clone()); + } + (content, entry.trailing_comment.is_some()) + }), + ); - for comment_docs in collected.comments_after_open { - inner.push(ir::hard_line()); - inner.extend(comment_docs); + let mut first_inner_line_started = false; + for comment in collected.comments_after_open { + if comment.same_line_after_open && !first_inner_line_started { + let mut suffix = trailing_comment_prefix(ctx); + suffix.extend(comment.docs); + docs.push(ir::line_suffix(suffix)); + } else { + inner.push(ir::hard_line()); + inner.extend(comment.docs); + first_inner_line_started = true; + } } - - let entry_count = collected.entries.len(); for (index, entry) in collected.entries.into_iter().enumerate() { inner.push(ir::hard_line()); for comment_docs in entry.leading_comments { inner.extend(comment_docs); inner.push(ir::hard_line()); } - inner.extend(entry.doc); + let mut line_content = entry.doc; + inner.extend(line_content.clone()); if index + 1 < entry_count { inner.extend(comma_token_docs(comma.as_ref())); + line_content.extend(comma_token_docs(comma.as_ref())); } else { inner.push(trailing.clone()); + line_content.push(trailing.clone()); } if let Some(comment_docs) = entry.trailing_comment { - let mut suffix = trailing_comment_prefix(ctx); + let mut suffix = trailing_comment_prefix_for_width( + ctx, + crate::ir::ir_flat_width(&line_content), + trailing_widths[index], + ); suffix.extend(comment_docs); inner.push(ir::line_suffix(suffix)); } @@ -221,21 +530,41 @@ fn format_call_arg_list( plan: &RootFormatPlan, args_list: &LuaCallArgList, ) -> Vec { + let args: Vec<_> = args_list.get_args().collect(); let collected = collect_call_arg_entries(ctx, plan, args_list); if collected.has_comments { return format_call_arg_list_with_comments(ctx, plan, args_list, collected); } - let arg_docs: Vec> = collected - .entries - .into_iter() - .map(|entry| entry.doc) + let preserve_multiline_args = args_list.syntax().text().contains_char('\n'); + let attach_first_arg = preserve_multiline_args && should_attach_first_call_arg(&args); + let arg_docs: Vec> = args + .iter() + .enumerate() + .map(|(index, arg)| { + format_call_arg_value_ir( + ctx, + plan, + arg, + attach_first_arg, + preserve_multiline_args, + index, + ) + }) .collect(); let (open, close) = paren_tokens(args_list.syntax()); let comma = first_direct_token(args_list.syntax(), LuaTokenKind::TkComma); let layout_plan = expr_sequence_layout_plan(plan, args_list.syntax()); + if attach_first_arg { + return format_call_args_with_attached_first_arg( + arg_docs, + token_or_kind_doc(close.as_ref(), LuaTokenKind::TkRightParen), + comma.as_ref(), + ); + } + format_delimited_sequence( ctx, DelimitedSequenceLayout { @@ -243,7 +572,7 @@ fn format_call_arg_list( close: token_or_kind_doc(close.as_ref(), LuaTokenKind::TkRightParen), items: arg_docs, strategy: ctx.config.layout.call_args_expand.clone(), - preserve_multiline: layout_plan.preserve_multiline, + preserve_multiline: false, flat_separator: comma_flat_separator(plan, comma.as_ref()), fill_separator: comma_fill_separator(comma.as_ref()), break_separator: comma_break_separator(comma.as_ref()), @@ -256,10 +585,73 @@ fn format_call_arg_list( ) } +fn should_attach_first_call_arg(args: &[LuaExpr]) -> bool { + matches!( + args.first(), + Some(LuaExpr::TableExpr(_) | LuaExpr::ClosureExpr(_)) + ) +} + +fn format_call_arg_value_ir( + ctx: &FormatContext, + plan: &RootFormatPlan, + arg: &LuaExpr, + attach_first_arg: bool, + preserve_multiline_args: bool, + index: usize, +) -> Vec { + if preserve_multiline_args && arg.syntax().text().contains_char('\n') { + if let LuaExpr::TableExpr(table) = arg + && attach_first_arg + && index == 0 + { + return format_multiline_table_expr(ctx, plan, table); + } + + if attach_first_arg && index == 0 { + return format_expr(ctx, plan, arg); + } + } + + format_expr(ctx, plan, arg) +} + +fn format_call_args_with_attached_first_arg( + arg_docs: Vec>, + close: DocIR, + comma: Option<&LuaSyntaxToken>, +) -> Vec { + if arg_docs.is_empty() { + return vec![ir::syntax_token(LuaTokenKind::TkLeftParen), close]; + } + + let mut docs = vec![ir::syntax_token(LuaTokenKind::TkLeftParen)]; + docs.extend(arg_docs[0].clone()); + + if arg_docs.len() == 1 { + docs.push(close); + return docs; + } + + docs.extend(comma_token_docs(comma)); + let mut rest = Vec::new(); + for (index, item_docs) in arg_docs.iter().enumerate().skip(1) { + rest.push(ir::hard_line()); + rest.extend(item_docs.clone()); + if index + 1 < arg_docs.len() { + rest.extend(comma_token_docs(comma)); + } + } + docs.push(ir::indent(rest)); + docs.push(ir::hard_line()); + docs.push(close); + vec![ir::group_break(docs)] +} + #[derive(Default)] struct CollectedCallArgEntries { entries: Vec, - comments_after_open: Vec>, + comments_after_open: Vec, comments_before_close: Vec>, has_comments: bool, consumed_comment_ranges: Vec, @@ -269,6 +661,7 @@ struct CallArgEntry { leading_comments: Vec>, doc: Vec, trailing_comment: Option>, + trailing_align_hint: bool, } fn collect_call_arg_entries( @@ -292,7 +685,12 @@ fn collect_call_arg_entries( let docs = vec![ir::source_node_trimmed(comment.syntax().clone())]; collected.has_comments = true; if !seen_arg { - collected.comments_after_open.push(docs); + collected.comments_after_open.push(DelimitedComment { + docs, + same_line_after_open: has_non_trivia_before_on_same_line_tokenwise( + comment.syntax(), + ), + }); } else { pending_comments.push(docs); } @@ -304,6 +702,13 @@ fn collect_call_arg_entries( if trailing_comment.is_some() { collected.has_comments = true; } + let trailing_align_hint = trailing_comment.as_ref().is_some_and(|(_, range)| { + trailing_gap_requests_alignment( + arg.syntax(), + *range, + ctx.config.comments.line_comment_min_spaces_before.max(1), + ) + }); if let Some((_, range)) = &trailing_comment { collected.consumed_comment_ranges.push(*range); } @@ -311,6 +716,7 @@ fn collect_call_arg_entries( leading_comments: std::mem::take(&mut pending_comments), doc: format_expr(ctx, plan, &arg), trailing_comment: trailing_comment.map(|(docs, _)| docs), + trailing_align_hint, }); seen_arg = true; } @@ -335,28 +741,58 @@ fn format_call_arg_list_with_comments( let trailing = trailing_comma_ir(ctx.config.output.trailing_comma.clone()); if !collected.comments_after_open.is_empty() || !collected.entries.is_empty() { + let entry_count = collected.entries.len(); let mut inner = Vec::new(); + let trailing_widths = aligned_trailing_comment_widths( + ctx.config.should_align_call_arg_line_comments() + && collected + .entries + .iter() + .any(|entry| entry.trailing_align_hint), + collected.entries.iter().enumerate().map(|(index, entry)| { + let mut content = entry.doc.clone(); + if index + 1 < entry_count { + content.extend(comma_token_docs(comma.as_ref())); + } else { + content.push(trailing.clone()); + } + (content, entry.trailing_comment.is_some()) + }), + ); - for comment_docs in collected.comments_after_open { - inner.push(ir::hard_line()); - inner.extend(comment_docs); + let mut first_inner_line_started = false; + for comment in collected.comments_after_open { + if comment.same_line_after_open && !first_inner_line_started { + let mut suffix = trailing_comment_prefix(ctx); + suffix.extend(comment.docs); + docs.push(ir::line_suffix(suffix)); + } else { + inner.push(ir::hard_line()); + inner.extend(comment.docs); + first_inner_line_started = true; + } } - - let entry_count = collected.entries.len(); for (index, entry) in collected.entries.into_iter().enumerate() { inner.push(ir::hard_line()); for comment_docs in entry.leading_comments { inner.extend(comment_docs); inner.push(ir::hard_line()); } - inner.extend(entry.doc); + let mut line_content = entry.doc; + inner.extend(line_content.clone()); if index + 1 < entry_count { inner.extend(comma_token_docs(comma.as_ref())); + line_content.extend(comma_token_docs(comma.as_ref())); } else { inner.push(trailing.clone()); + line_content.push(trailing.clone()); } if let Some(comment_docs) = entry.trailing_comment { - let mut suffix = trailing_comment_prefix(ctx); + let mut suffix = trailing_comment_prefix_for_width( + ctx, + crate::ir::ir_flat_width(&line_content), + trailing_widths[index], + ); suffix.extend(comment_docs); inner.push(ir::line_suffix(suffix)); } @@ -419,6 +855,13 @@ fn format_table_expr( } let collected = collect_table_entries(ctx, plan, expr); + let has_assign_fields = collected + .entries + .iter() + .any(|entry| entry.eq_split.is_some()); + let has_assign_alignment = ctx.config.align.table_field + && has_assign_fields + && table_group_requests_alignment(&collected.entries); if collected.has_comments { return format_table_with_comments(ctx, expr, collected); @@ -426,19 +869,18 @@ fn format_table_expr( let field_docs: Vec> = collected .entries - .into_iter() - .map(|entry| entry.doc) + .iter() + .map(|entry| entry.doc.clone()) .collect(); let (open, close) = brace_tokens(expr.syntax()); let comma = first_direct_token(expr.syntax(), LuaTokenKind::TkComma); let layout_plan = expr_sequence_layout_plan(plan, expr.syntax()); - format_delimited_sequence( - ctx, - DelimitedSequenceLayout { + if has_assign_alignment { + let layout = DelimitedSequenceLayout { open: token_or_kind_doc(open.as_ref(), LuaTokenKind::TkLeftBrace), close: token_or_kind_doc(close.as_ref(), LuaTokenKind::TkRightBrace), - items: field_docs, + items: field_docs.clone(), strategy: if expr.is_empty() { ExpandStrategy::Never } else { @@ -453,6 +895,129 @@ fn format_table_expr( grouped_padding: grouped_padding_from_pair(plan, open.as_ref(), close.as_ref()), flat_trailing: vec![], grouped_trailing: trailing_comma_ir(ctx.config.trailing_table_comma()), + }; + + return match ctx.config.layout.table_expand { + ExpandStrategy::Always => wrap_table_multiline_docs( + token_or_kind_doc(open.as_ref(), LuaTokenKind::TkLeftBrace), + token_or_kind_doc(close.as_ref(), LuaTokenKind::TkRightBrace), + build_table_expanded_inner( + ctx, + &collected.entries, + &trailing_comma_ir(ctx.config.trailing_table_comma()), + true, + ctx.config.should_align_table_line_comments(), + ), + ), + ExpandStrategy::Never => format_delimited_sequence(ctx, layout), + ExpandStrategy::Auto => { + let mut flat_layout = layout; + flat_layout.strategy = ExpandStrategy::Never; + let flat_docs = format_delimited_sequence(ctx, flat_layout); + if crate::ir::ir_flat_width(&flat_docs) + source_line_prefix_width(expr.syntax()) + <= ctx.config.layout.max_line_width + { + flat_docs + } else { + wrap_table_multiline_docs( + token_or_kind_doc(open.as_ref(), LuaTokenKind::TkLeftBrace), + token_or_kind_doc(close.as_ref(), LuaTokenKind::TkRightBrace), + build_table_expanded_inner( + ctx, + &collected.entries, + &trailing_comma_ir(ctx.config.trailing_table_comma()), + true, + ctx.config.should_align_table_line_comments(), + ), + ) + } + } + }; + } + + let layout = DelimitedSequenceLayout { + open: token_or_kind_doc(open.as_ref(), LuaTokenKind::TkLeftBrace), + close: token_or_kind_doc(close.as_ref(), LuaTokenKind::TkRightBrace), + items: field_docs, + strategy: if expr.is_empty() { + ExpandStrategy::Never + } else { + ctx.config.layout.table_expand.clone() + }, + preserve_multiline: layout_plan.preserve_multiline, + flat_separator: comma_flat_separator(plan, comma.as_ref()), + fill_separator: comma_fill_separator(comma.as_ref()), + break_separator: comma_break_separator(comma.as_ref()), + flat_open_padding: token_right_spacing_docs(plan, open.as_ref()), + flat_close_padding: token_left_spacing_docs(plan, close.as_ref()), + grouped_padding: grouped_padding_from_pair(plan, open.as_ref(), close.as_ref()), + flat_trailing: vec![], + grouped_trailing: trailing_comma_ir(ctx.config.trailing_table_comma()), + }; + + if has_assign_fields && matches!(ctx.config.layout.table_expand, ExpandStrategy::Auto) { + let mut flat_layout = layout.clone(); + flat_layout.strategy = ExpandStrategy::Never; + let flat_docs = format_delimited_sequence(ctx, flat_layout); + if crate::ir::ir_flat_width(&flat_docs) + source_line_prefix_width(expr.syntax()) + <= ctx.config.layout.max_line_width + { + return flat_docs; + } + + return wrap_table_multiline_docs( + token_or_kind_doc(open.as_ref(), LuaTokenKind::TkLeftBrace), + token_or_kind_doc(close.as_ref(), LuaTokenKind::TkRightBrace), + build_table_expanded_inner( + ctx, + &collected.entries, + &trailing_comma_ir(ctx.config.trailing_table_comma()), + false, + false, + ), + ); + } + + format_delimited_sequence(ctx, layout) +} + +fn format_multiline_table_expr( + ctx: &FormatContext, + plan: &RootFormatPlan, + expr: &LuaTableExpr, +) -> Vec { + let collected = collect_table_entries(ctx, plan, expr); + + if collected.has_comments + || (ctx.config.align.table_field && table_group_requests_alignment(&collected.entries)) + { + return format_table_with_comments(ctx, expr, collected); + } + + let field_docs: Vec> = collected + .entries + .into_iter() + .map(|entry| entry.doc) + .collect(); + let (open, close) = brace_tokens(expr.syntax()); + let comma = first_direct_token(expr.syntax(), LuaTokenKind::TkComma); + + format_delimited_sequence( + ctx, + DelimitedSequenceLayout { + open: token_or_kind_doc(open.as_ref(), LuaTokenKind::TkLeftBrace), + close: token_or_kind_doc(close.as_ref(), LuaTokenKind::TkRightBrace), + items: field_docs, + strategy: ExpandStrategy::Always, + preserve_multiline: false, + flat_separator: comma_flat_separator(plan, comma.as_ref()), + fill_separator: comma_fill_separator(comma.as_ref()), + break_separator: comma_break_separator(comma.as_ref()), + flat_open_padding: token_right_spacing_docs(plan, open.as_ref()), + flat_close_padding: token_left_spacing_docs(plan, close.as_ref()), + grouped_padding: grouped_padding_from_pair(plan, open.as_ref(), close.as_ref()), + flat_trailing: vec![], + grouped_trailing: trailing_comma_ir(ctx.config.trailing_table_comma()), }, ) } @@ -460,7 +1025,7 @@ fn format_table_expr( #[derive(Default)] struct CollectedTableEntries { entries: Vec, - comments_after_open: Vec>, + comments_after_open: Vec, comments_before_close: Vec>, has_comments: bool, consumed_comment_ranges: Vec, @@ -469,6 +1034,9 @@ struct CollectedTableEntries { struct TableEntry { leading_comments: Vec>, doc: Vec, + eq_split: Option, + align_hint: bool, + comment_align_hint: bool, trailing_comment: Option>, } @@ -493,7 +1061,12 @@ fn collect_table_entries( let docs = vec![ir::source_node_trimmed(comment.syntax().clone())]; collected.has_comments = true; if !seen_field { - collected.comments_after_open.push(docs); + collected.comments_after_open.push(DelimitedComment { + docs, + same_line_after_open: has_non_trivia_before_on_same_line_tokenwise( + comment.syntax(), + ), + }); } else { pending_comments.push(docs); } @@ -508,9 +1081,23 @@ fn collect_table_entries( if let Some((_, range)) = &trailing_comment { collected.consumed_comment_ranges.push(*range); } + let comment_align_hint = trailing_comment.as_ref().is_some_and(|(_, range)| { + trailing_gap_requests_alignment( + field.syntax(), + *range, + ctx.config.comments.line_comment_min_spaces_before.max(1), + ) + }); collected.entries.push(TableEntry { leading_comments: std::mem::take(&mut pending_comments), doc: format_table_field_ir(ctx, plan, &field), + eq_split: if ctx.config.align.table_field { + format_table_field_eq_split(ctx, plan, &field) + } else { + None + }, + align_hint: field_requests_alignment(&field), + comment_align_hint, trailing_comment: trailing_comment.map(|(docs, _)| docs), }); seen_field = true; @@ -530,37 +1117,39 @@ fn format_table_with_comments( collected: CollectedTableEntries, ) -> Vec { let (open, close) = brace_tokens(expr.syntax()); - let comma = first_direct_token(expr.syntax(), LuaTokenKind::TkComma); let mut docs = vec![token_or_kind_doc(open.as_ref(), LuaTokenKind::TkLeftBrace)]; + let trailing = trailing_comma_ir(ctx.config.trailing_table_comma()); + let should_align_eq = ctx.config.align.table_field + && collected + .entries + .iter() + .any(|entry| entry.eq_split.is_some()) + && table_group_requests_alignment(&collected.entries); if !collected.comments_after_open.is_empty() || !collected.entries.is_empty() { let mut inner = Vec::new(); - for comment_docs in collected.comments_after_open { - inner.push(ir::hard_line()); - inner.extend(comment_docs); - } - - let entry_count = collected.entries.len(); - for (index, entry) in collected.entries.into_iter().enumerate() { - inner.push(ir::hard_line()); - for comment_docs in entry.leading_comments { - inner.extend(comment_docs); - inner.push(ir::hard_line()); - } - inner.extend(entry.doc); - if index + 1 < entry_count - || !matches!(ctx.config.trailing_table_comma(), TrailingComma::Never) - { - inner.extend(comma_token_docs(comma.as_ref())); - } - if let Some(comment_docs) = entry.trailing_comment { + let mut first_inner_line_started = false; + for comment in collected.comments_after_open { + if comment.same_line_after_open && !first_inner_line_started { let mut suffix = trailing_comment_prefix(ctx); - suffix.extend(comment_docs); - inner.push(ir::line_suffix(suffix)); + suffix.extend(comment.docs); + docs.push(ir::line_suffix(suffix)); + } else { + inner.push(ir::hard_line()); + inner.extend(comment.docs); + first_inner_line_started = true; } } + inner.extend(build_table_expanded_inner( + ctx, + &collected.entries, + &trailing, + should_align_eq, + ctx.config.should_align_table_line_comments(), + )); + for comment_docs in collected.comments_before_close { inner.push(ir::hard_line()); inner.extend(comment_docs); @@ -647,6 +1236,318 @@ fn format_table_field_value_ir( format_expr(ctx, plan, value) } +fn format_table_field_eq_split( + ctx: &FormatContext, + plan: &RootFormatPlan, + field: &LuaTableField, +) -> Option { + if !field.is_assign_field() { + return None; + } + + let before = format_table_field_key_ir(ctx, plan, field); + if before.is_empty() { + return None; + } + + let assign_space = if ctx.config.spacing.space_around_assign_operator { + ir::space() + } else { + ir::list(vec![]) + }; + let mut after = vec![ + ir::syntax_token(LuaTokenKind::TkAssign), + assign_space.clone(), + ]; + if let Some(value) = field.get_value_expr() { + after.extend(format_table_field_value_ir(ctx, plan, &value)); + } + Some((before, after)) +} + +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| entry.align_hint) +} + +fn table_comment_group_requests_alignment(entries: &[TableEntry]) -> bool { + entries + .iter() + .any(|entry| entry.trailing_comment.is_some() && entry.comment_align_hint) +} + +fn wrap_table_multiline_docs(open: DocIR, close: DocIR, inner: Vec) -> Vec { + let mut docs = vec![open]; + if !inner.is_empty() { + docs.push(ir::indent(inner)); + docs.push(ir::hard_line()); + } + docs.push(close); + docs +} + +fn build_table_expanded_inner( + ctx: &FormatContext, + entries: &[TableEntry], + trailing: &DocIR, + align_eq: bool, + align_comments: bool, +) -> Vec { + let mut inner = Vec::new(); + let last_field_idx = entries.iter().rposition(|_| true); + + if align_eq { + let mut index = 0usize; + while index < entries.len() { + if entries[index].eq_split.is_some() { + let group_start = index; + let mut group_end = index + 1; + while group_end < entries.len() + && entries[group_end].eq_split.is_some() + && entries[group_end].leading_comments.is_empty() + { + group_end += 1; + } + + if group_end - group_start >= 2 + && table_group_requests_alignment(&entries[group_start..group_end]) + { + for comment_docs in &entries[group_start].leading_comments { + inner.push(ir::hard_line()); + inner.extend(comment_docs.clone()); + } + inner.push(ir::hard_line()); + + let comment_widths = if align_comments { + aligned_table_comment_widths( + ctx, + entries, + group_start, + group_end, + last_field_idx, + trailing, + ) + } else { + vec![None; group_end - group_start] + }; + + let mut align_entries = Vec::new(); + for current in group_start..group_end { + let entry = &entries[current]; + if let Some((before, after)) = &entry.eq_split { + let is_last = last_field_idx == Some(current); + let mut after_docs = after.clone(); + if is_last { + after_docs.push(trailing.clone()); + } else { + after_docs.push(ir::syntax_token(LuaTokenKind::TkComma)); + } + + if let Some(comment_docs) = &entry.trailing_comment { + if let Some(padding) = comment_widths[current - group_start] { + after_docs + .push(aligned_table_comment_suffix(comment_docs, padding)); + align_entries.push(AlignEntry::Aligned { + before: before.clone(), + after: after_docs, + trailing: None, + }); + } else { + let mut suffix = trailing_comment_prefix(ctx); + suffix.extend(comment_docs.clone()); + after_docs.push(ir::line_suffix(suffix)); + align_entries.push(AlignEntry::Aligned { + before: before.clone(), + after: after_docs, + trailing: None, + }); + } + } else { + align_entries.push(AlignEntry::Aligned { + before: before.clone(), + after: after_docs, + trailing: None, + }); + } + } + } + inner.push(ir::align_group(align_entries)); + index = group_end; + continue; + } + } + + push_table_entry_line( + ctx, + &mut inner, + &entries[index], + index, + last_field_idx, + trailing, + ); + index += 1; + } + + return inner; + } + + for (index, entry) in entries.iter().enumerate() { + push_table_entry_line(ctx, &mut inner, entry, index, last_field_idx, trailing); + } + + inner +} + +fn push_table_entry_line( + ctx: &FormatContext, + inner: &mut Vec, + entry: &TableEntry, + index: usize, + last_field_idx: Option, + trailing: &DocIR, +) { + inner.push(ir::hard_line()); + for comment_docs in &entry.leading_comments { + inner.extend(comment_docs.clone()); + inner.push(ir::hard_line()); + } + inner.extend(entry.doc.clone()); + if last_field_idx == Some(index) { + inner.push(trailing.clone()); + } else { + inner.push(ir::syntax_token(LuaTokenKind::TkComma)); + } + if let Some(comment_docs) = &entry.trailing_comment { + let mut suffix = trailing_comment_prefix(ctx); + suffix.extend(comment_docs.clone()); + inner.push(ir::line_suffix(suffix)); + } +} + +fn aligned_table_comment_widths( + ctx: &FormatContext, + entries: &[TableEntry], + group_start: usize, + group_end: usize, + last_field_idx: Option, + trailing: &DocIR, +) -> 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 && entries[subgroup_start].trailing_comment.is_none() { + subgroup_start += 1; + } + if subgroup_start >= group_end { + break; + } + + let mut subgroup_end = subgroup_start + 1; + while subgroup_end < group_end && entries[subgroup_end].trailing_comment.is_some() { + subgroup_end += 1; + } + + if table_comment_group_requests_alignment(&entries[subgroup_start..subgroup_end]) { + let max_content_width = (subgroup_start..subgroup_end) + .filter_map(|index| { + let entry = &entries[index]; + let (before, after) = entry.eq_split.as_ref()?; + let mut content = before.clone(); + content.push(ir::space()); + content.extend(after.clone()); + if last_field_idx == Some(index) { + content.push(trailing.clone()); + } else { + content.push(ir::syntax_token(LuaTokenKind::TkComma)); + } + Some(crate::ir::ir_flat_width(&content)) + }) + .max() + .unwrap_or(0); + + for index in subgroup_start..subgroup_end { + let entry = &entries[index]; + if let Some((before, after)) = &entry.eq_split { + let mut content = before.clone(); + content.push(ir::space()); + content.extend(after.clone()); + if last_field_idx == Some(index) { + content.push(trailing.clone()); + } else { + content.push(ir::syntax_token(LuaTokenKind::TkComma)); + } + widths[index - group_start] = Some(trailing_comment_padding_for_config( + ctx, + crate::ir::ir_flat_width(&content), + max_content_width, + )); + } + } + } + + subgroup_start = subgroup_end; + } + + widths +} + +fn aligned_table_comment_suffix(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 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 format_closure_expr( ctx: &FormatContext, plan: &RootFormatPlan, @@ -787,6 +1688,209 @@ fn render_closure_shell( docs } +fn try_format_flat_binary_chain( + ctx: &FormatContext, + plan: &RootFormatPlan, + 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 fill_parts = + build_binary_chain_fill_parts(ctx, plan, &operands, &op_token.syntax().clone(), op); + let packed = build_binary_chain_packed(ctx, plan, &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), + ..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 collect_binary_chain_operands(expr: &LuaExpr, op: BinaryOperator, out: &mut Vec) { + if let LuaExpr::BinaryExpr(binary) = expr + && !node_has_direct_comment_child(binary.syntax()) + && binary + .get_op_token() + .is_some_and(|token| token.get_op() == op) + && let Some((left, right)) = binary.get_exprs() + { + collect_binary_chain_operands(&left, op, out); + collect_binary_chain_operands(&right, op, out); + return; + } + + out.push(expr.clone()); +} + +fn build_binary_chain_fill_parts( + ctx: &FormatContext, + plan: &RootFormatPlan, + operands: &[LuaExpr], + op_token: &LuaSyntaxToken, + op: BinaryOperator, +) -> Vec { + let mut parts = Vec::new(); + let mut previous = &operands[0]; + let mut first_chunk = format_expr(ctx, plan, &operands[0]); + + for (index, operand) in operands.iter().enumerate().skip(1) { + let (space_before_segment, segment) = + build_binary_chain_segment(ctx, plan, previous, operand, op_token, op); + + if index == 1 { + if space_before_segment { + first_chunk.push(ir::space()); + } + first_chunk.extend(segment); + parts.push(ir::list(first_chunk.clone())); + } else { + parts.push(ir::list(vec![continuation_break_ir(space_before_segment)])); + parts.push(ir::list(segment)); + } + + previous = operand; + } + + if parts.is_empty() { + parts.push(ir::list(first_chunk)); + } + + parts +} + +fn build_binary_chain_packed( + ctx: &FormatContext, + plan: &RootFormatPlan, + operands: &[LuaExpr], + op_token: &LuaSyntaxToken, + op: BinaryOperator, +) -> Vec { + let mut first_line = format_expr(ctx, plan, &operands[0]); + let (space_before, segment) = + build_binary_chain_segment(ctx, plan, &operands[0], &operands[1], op_token, op); + if space_before { + first_line.push(ir::space()); + } + first_line.extend(segment); + + let mut tail = Vec::new(); + let mut previous = &operands[1]; + let mut remaining = Vec::new(); + for operand in operands.iter().skip(2) { + remaining.push(build_binary_chain_segment( + ctx, plan, previous, operand, op_token, op, + )); + previous = operand; + } + + for chunk in remaining.chunks(2) { + let mut line = Vec::new(); + for (index, (space_before_segment, segment)) in chunk.iter().enumerate() { + if index > 0 && *space_before_segment { + line.push(ir::space()); + } + line.extend(segment.clone()); + } + tail.push(ir::hard_line()); + tail.extend(line); + } + + vec![ir::group_break(vec![ + ir::list(first_line), + ir::indent(tail), + ])] +} + +fn build_binary_chain_segment( + ctx: &FormatContext, + plan: &RootFormatPlan, + _previous: &LuaExpr, + operand: &LuaExpr, + op_token: &LuaSyntaxToken, + op: BinaryOperator, +) -> (bool, Vec) { + let space_rule = space_around_binary_op(op, ctx.config); + let mut segment = Vec::new(); + segment.push(ir::source_token(op_token.clone())); + segment.push(space_rule.to_ir()); + segment.extend(format_expr(ctx, plan, operand)); + (space_rule != SpaceRule::NoSpace, segment) +} + +fn continuation_break_ir(flat_space: bool) -> DocIR { + if flat_space { + ir::soft_line() + } else { + ir::soft_line_or_empty() + } +} + +fn format_index_access_ir( + ctx: &FormatContext, + plan: &RootFormatPlan, + 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::syntax_token(LuaTokenKind::TkDot)); + if let Some(name_token) = expr.get_index_name_token() { + docs.push(ir::source_token(name_token)); + } + } else if index_token.is_colon() { + docs.push(ir::syntax_token(LuaTokenKind::TkColon)); + if let Some(name_token) = expr.get_index_name_token() { + docs.push(ir::source_token(name_token)); + } + } else if index_token.is_left_bracket() { + docs.push(ir::syntax_token(LuaTokenKind::TkLeftBracket)); + if ctx.config.spacing.space_inside_brackets { + docs.push(ir::space()); + } + if let Some(key) = expr.get_index_key() { + match key { + LuaIndexKey::Expr(expr) => docs.extend(format_expr(ctx, plan, &expr)), + LuaIndexKey::Integer(number) => { + docs.push(ir::source_token(number.syntax().clone())); + } + LuaIndexKey::String(string) => { + docs.push(ir::source_token(string.syntax().clone())); + } + LuaIndexKey::Name(name) => { + docs.push(ir::source_token(name.syntax().clone())); + } + LuaIndexKey::Idx(_) => {} + } + } + if ctx.config.spacing.space_inside_brackets { + docs.push(ir::space()); + } + docs.push(ir::syntax_token(LuaTokenKind::TkRightBracket)); + } + } + docs +} + fn trailing_comma_ir(policy: TrailingComma) -> DocIR { match policy { TrailingComma::Never => ir::list(vec![]), @@ -908,8 +2012,49 @@ fn comma_break_separator(token: Option<&LuaSyntaxToken>) -> Vec { } fn trailing_comment_prefix(ctx: &FormatContext) -> Vec { - let gap = ctx.config.comments.line_comment_min_spaces_before.max(1); - (0..gap).map(|_| ir::space()).collect() + trailing_comment_prefix_for_width(ctx, 0, None) +} + +fn trailing_comment_prefix_for_width( + ctx: &FormatContext, + content_width: usize, + aligned_content_width: Option, +) -> Vec { + let aligned_content_width = aligned_content_width.unwrap_or(content_width); + let natural_padding = aligned_content_width.saturating_sub(content_width) + + ctx.config.comments.line_comment_min_spaces_before.max(1); + let padding = 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), + ) + }; + (0..padding).map(|_| ir::space()).collect() +} + +fn aligned_trailing_comment_widths(allow_alignment: bool, entries: I) -> Vec> +where + I: IntoIterator, bool)>, +{ + let entries: Vec<_> = entries.into_iter().collect(); + if !allow_alignment { + return entries.into_iter().map(|_| None).collect(); + } + + let max_width = entries + .iter() + .filter(|(_, has_comment)| *has_comment) + .map(|(docs, _)| crate::ir::ir_flat_width(docs)) + .max(); + + entries + .into_iter() + .map(|(_, has_comment)| has_comment.then_some(max_width.unwrap_or(0))) + .collect() } fn extract_trailing_comment( diff --git a/crates/emmylua_formatter/src/formatter/mod.rs b/crates/emmylua_formatter/src/formatter/mod.rs index 92deff3f5..b299d5976 100644 --- a/crates/emmylua_formatter/src/formatter/mod.rs +++ b/crates/emmylua_formatter/src/formatter/mod.rs @@ -7,29 +7,17 @@ mod sequence; mod spacing; mod trivia; -use std::cell::Cell; - use crate::config::LuaFormatConfig; -use crate::ir::{DocIR, GroupId}; +use crate::ir::DocIR; use emmylua_parser::LuaChunk; pub struct FormatContext<'a> { pub config: &'a LuaFormatConfig, - next_group_id: Cell, } impl<'a> FormatContext<'a> { pub fn new(config: &'a LuaFormatConfig) -> Self { - Self { - config, - next_group_id: Cell::new(0), - } - } - - pub fn next_group_id(&self) -> GroupId { - let next = self.next_group_id.get(); - self.next_group_id.set(next + 1); - GroupId(next) + Self { config } } } diff --git a/crates/emmylua_formatter/src/formatter/render.rs b/crates/emmylua_formatter/src/formatter/render.rs index a2fa2b0c9..f8fe9d1bd 100644 --- a/crates/emmylua_formatter/src/formatter/render.rs +++ b/crates/emmylua_formatter/src/formatter/render.rs @@ -1,12 +1,14 @@ use emmylua_parser::{ - LuaAssignStat, LuaAstNode, LuaAstToken, LuaChunk, LuaComment, LuaExpr, LuaForRangeStat, - LuaForStat, LuaIfStat, LuaKind, LuaLocalName, LuaLocalStat, LuaRepeatStat, LuaReturnStat, - LuaStat, LuaSyntaxId, LuaSyntaxKind, LuaSyntaxNode, LuaSyntaxToken, LuaTokenKind, LuaVarExpr, - LuaWhileStat, + LuaAssignStat, LuaAstNode, LuaAstToken, LuaCallExprStat, LuaChunk, LuaComment, LuaDoStat, + LuaExpr, LuaForRangeStat, LuaForStat, LuaFuncStat, LuaIfStat, LuaKind, LuaLocalFuncStat, + LuaLocalName, LuaLocalStat, LuaRepeatStat, LuaReturnStat, LuaStat, LuaSyntaxId, LuaSyntaxKind, + LuaSyntaxNode, LuaSyntaxToken, LuaTokenKind, LuaVarExpr, LuaWhileStat, }; +use rowan::TextRange; +use std::collections::HashMap; use crate::formatter::model::StatementExprListLayoutKind; -use crate::ir::{self, DocIR}; +use crate::ir::{self, AlignEntry, DocIR}; use super::FormatContext; use super::expr; @@ -18,7 +20,7 @@ use super::sequence::{ }; use super::trivia::{ count_blank_lines_before, has_non_trivia_before_on_same_line_tokenwise, - node_has_direct_comment_child, + node_has_direct_comment_child, trailing_gap_requests_alignment, }; pub fn render_root(ctx: &FormatContext, chunk: &LuaChunk, plan: &RootFormatPlan) -> Vec { @@ -32,12 +34,11 @@ pub fn render_root(ctx: &FormatContext, chunk: &LuaChunk, plan: &RootFormatPlan) } if !plan.layout.root_nodes.is_empty() { - docs.extend(render_layout_nodes( + docs.extend(render_aligned_block_layout_nodes_new( ctx, chunk.syntax(), &plan.layout.root_nodes, plan, - false, )); } @@ -48,31 +49,6 @@ pub fn render_root(ctx: &FormatContext, chunk: &LuaChunk, plan: &RootFormatPlan) docs } -fn render_layout_nodes( - ctx: &FormatContext, - root: &LuaSyntaxNode, - nodes: &[LayoutNodePlan], - plan: &RootFormatPlan, - inside_block: bool, -) -> Vec { - let mut docs = Vec::new(); - - for (index, node) in nodes.iter().enumerate() { - if inside_block && index > 0 { - let blank_lines = count_blank_lines_before_layout_node(root, node) - .min(ctx.config.layout.max_blank_lines); - docs.push(ir::hard_line()); - for _ in 0..blank_lines { - docs.push(ir::hard_line()); - } - } - - docs.extend(render_layout_node(ctx, root, node, plan)); - } - - docs -} - fn render_layout_node( ctx: &FormatContext, root: &LuaSyntaxNode, @@ -91,7 +67,7 @@ fn render_layout_node( } LayoutNodePlan::Syntax(syntax_plan) => match syntax_plan.kind { LuaSyntaxKind::Block => { - render_layout_nodes(ctx, root, &syntax_plan.children, plan, true) + render_aligned_block_layout_nodes_new(ctx, root, &syntax_plan.children, plan) } LuaSyntaxKind::LocalStat => { render_local_stat_new(ctx, root, syntax_plan.syntax_id, plan) @@ -107,6 +83,14 @@ fn render_layout_node( LuaSyntaxKind::ForRangeStat => render_for_range_stat_new(ctx, root, syntax_plan, plan), LuaSyntaxKind::RepeatStat => render_repeat_stat_new(ctx, root, syntax_plan, plan), LuaSyntaxKind::IfStat => render_if_stat_new(ctx, root, syntax_plan, plan), + LuaSyntaxKind::FuncStat => render_func_stat_new(ctx, root, syntax_plan, plan), + LuaSyntaxKind::LocalFuncStat => { + render_local_func_stat_new(ctx, root, syntax_plan, plan) + } + LuaSyntaxKind::DoStat => render_do_stat_new(ctx, root, syntax_plan, plan), + LuaSyntaxKind::CallExprStat => { + render_call_expr_stat_new(ctx, root, syntax_plan.syntax_id, plan) + } _ => render_unmigrated_syntax_leaf(root, syntax_plan.syntax_id), }, } @@ -118,6 +102,9 @@ struct StatementAssignSplit { rhs_entries: Vec, } +type DocPair = (Vec, Vec); +type RenderedTrailingComment = (Vec, TextRange, bool); + fn render_local_stat_new( ctx: &FormatContext, root: &LuaSyntaxNode, @@ -192,6 +179,8 @@ fn render_local_stat_new( )); } + append_trailing_comment_suffix_new(ctx, plan, &mut docs, stat.syntax()); + docs } @@ -262,6 +251,8 @@ fn render_assign_stat_new( expr_docs, )); + append_trailing_comment_suffix_new(ctx, plan, &mut docs, stat.syntax()); + docs } @@ -323,6 +314,8 @@ fn render_return_stat_new( )); } + append_trailing_comment_suffix_new(ctx, plan, &mut docs, stat.syntax()); + docs } @@ -339,6 +332,10 @@ fn render_while_stat_new( return Vec::new(); }; + if syntax_has_descendant_comment_new(stat.syntax()) { + return vec![ir::source_node_trimmed(stat.syntax().clone())]; + } + let while_token = first_direct_token(stat.syntax(), LuaTokenKind::TkWhile); let do_token = first_direct_token(stat.syntax(), LuaTokenKind::TkDo); let mut docs = vec![token_or_kind_doc( @@ -548,6 +545,10 @@ fn render_repeat_stat_new( return Vec::new(); }; + if syntax_has_descendant_comment_new(stat.syntax()) { + return vec![ir::source_node_trimmed(stat.syntax().clone())]; + } + let repeat_token = first_direct_token(stat.syntax(), LuaTokenKind::TkRepeat); let until_token = first_direct_token(stat.syntax(), LuaTokenKind::TkUntil); let has_inline_comment = plan @@ -672,6 +673,204 @@ fn render_if_stat_new( docs } +fn render_func_stat_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + syntax_plan: &SyntaxNodeLayoutPlan, + plan: &RootFormatPlan, +) -> Vec { + let Some(node) = find_node_by_id(root, syntax_plan.syntax_id) else { + return Vec::new(); + }; + let Some(stat) = LuaFuncStat::cast(node) else { + return Vec::new(); + }; + let Some(closure) = stat.get_closure() else { + return vec![ir::source_node_trimmed(stat.syntax().clone())]; + }; + + if node_has_direct_comment_child(stat.syntax()) + || node_has_direct_comment_child(closure.syntax()) + || closure + .get_block() + .as_ref() + .is_some_and(|block| syntax_has_descendant_comment_new(block.syntax())) + { + return vec![ir::source_node_trimmed(stat.syntax().clone())]; + } + + let global_token = first_direct_token(stat.syntax(), LuaTokenKind::TkGlobal); + let function_token = first_direct_token(stat.syntax(), LuaTokenKind::TkFunction); + let mut docs = Vec::new(); + + if let Some(global_token) = global_token.as_ref() { + docs.push(ir::source_token(global_token.clone())); + docs.extend(token_right_spacing_docs(plan, Some(global_token))); + } + + docs.push(token_or_kind_doc( + function_token.as_ref(), + LuaTokenKind::TkFunction, + )); + docs.extend(token_right_spacing_docs(plan, function_token.as_ref())); + + if let Some(name) = stat.get_func_name() { + docs.extend(render_expr_new(ctx, plan, &name.into())); + } + + docs.extend(render_named_function_closure_tail_new( + ctx, + root, + syntax_plan, + plan, + &closure, + )); + docs +} + +fn render_local_func_stat_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + syntax_plan: &SyntaxNodeLayoutPlan, + plan: &RootFormatPlan, +) -> Vec { + let Some(node) = find_node_by_id(root, syntax_plan.syntax_id) else { + return Vec::new(); + }; + let Some(stat) = LuaLocalFuncStat::cast(node) else { + return Vec::new(); + }; + let Some(closure) = stat.get_closure() else { + return vec![ir::source_node_trimmed(stat.syntax().clone())]; + }; + + if node_has_direct_comment_child(stat.syntax()) + || node_has_direct_comment_child(closure.syntax()) + || closure + .get_block() + .as_ref() + .is_some_and(|block| syntax_has_descendant_comment_new(block.syntax())) + { + return vec![ir::source_node_trimmed(stat.syntax().clone())]; + } + + let local_token = first_direct_token(stat.syntax(), LuaTokenKind::TkLocal); + let function_token = first_direct_token(stat.syntax(), LuaTokenKind::TkFunction); + let mut docs = vec![token_or_kind_doc( + local_token.as_ref(), + LuaTokenKind::TkLocal, + )]; + docs.extend(token_right_spacing_docs(plan, local_token.as_ref())); + docs.push(token_or_kind_doc( + function_token.as_ref(), + LuaTokenKind::TkFunction, + )); + docs.extend(token_right_spacing_docs(plan, function_token.as_ref())); + + if let Some(name) = stat.get_local_name() { + docs.extend(format_local_name_ir_new(&name)); + } + + docs.extend(render_named_function_closure_tail_new( + ctx, + root, + syntax_plan, + plan, + &closure, + )); + docs +} + +fn render_do_stat_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + syntax_plan: &SyntaxNodeLayoutPlan, + plan: &RootFormatPlan, +) -> Vec { + let Some(node) = find_node_by_id(root, syntax_plan.syntax_id) else { + return Vec::new(); + }; + let Some(stat) = LuaDoStat::cast(node) else { + return Vec::new(); + }; + + if node_has_direct_comment_child(stat.syntax()) { + return vec![ir::source_node_trimmed(stat.syntax().clone())]; + } + + let do_token = first_direct_token(stat.syntax(), LuaTokenKind::TkDo); + let mut docs = vec![token_or_kind_doc(do_token.as_ref(), LuaTokenKind::TkDo)]; + docs.extend(render_control_body_end_new( + ctx, + root, + syntax_plan, + plan, + LuaTokenKind::TkEnd, + )); + docs +} + +fn render_call_expr_stat_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + syntax_id: LuaSyntaxId, + plan: &RootFormatPlan, +) -> Vec { + let Some(node) = find_node_by_id(root, syntax_id) else { + return Vec::new(); + }; + let Some(stat) = LuaCallExprStat::cast(node) else { + return Vec::new(); + }; + + if node_has_direct_comment_child(stat.syntax()) { + return vec![ir::source_node_trimmed(stat.syntax().clone())]; + } + + stat.get_call_expr() + .map(|expr| render_expr_new(ctx, plan, &expr.into())) + .unwrap_or_default() +} + +fn render_named_function_closure_tail_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + syntax_plan: &SyntaxNodeLayoutPlan, + plan: &RootFormatPlan, + closure: &emmylua_parser::LuaClosureExpr, +) -> Vec { + let mut docs = if let Some(params) = closure.get_params_list() { + let open = first_direct_token(params.syntax(), LuaTokenKind::TkLeftParen); + let mut docs = token_left_spacing_docs(plan, open.as_ref()); + docs.extend(expr::format_param_list_ir(ctx, plan, ¶ms)); + docs + } else { + vec![ + ir::syntax_token(LuaTokenKind::TkLeftParen), + ir::syntax_token(LuaTokenKind::TkRightParen), + ] + }; + + if let Some(closure_plan) = + find_direct_child_plan_by_kind(syntax_plan, LuaSyntaxKind::ClosureExpr) + { + let body_docs = render_block_from_parent_plan_new(ctx, root, closure_plan, plan); + if matches!(body_docs.as_slice(), [DocIR::HardLine]) { + docs.push(ir::space()); + docs.push(ir::syntax_token(LuaTokenKind::TkEnd)); + return docs; + } + + docs.extend(body_docs); + docs.push(ir::syntax_token(LuaTokenKind::TkEnd)); + return docs; + } + + docs.push(ir::space()); + docs.push(ir::syntax_token(LuaTokenKind::TkEnd)); + docs +} + fn format_local_stat_trivia_aware_new( ctx: &FormatContext, plan: &RootFormatPlan, @@ -695,14 +894,7 @@ fn format_local_stat_trivia_aware_new( .is_some_and(|layout| layout.has_inline_comment); if has_inline_comment { - docs.push(ir::indent(render_trivia_aware_split_sequence_tail_new( - plan, - token_right_spacing_docs(plan, local_token.as_ref()), - &lhs_entries, - assign_op.as_ref(), - &rhs_entries, - ))); - return docs; + return vec![ir::source_node_trimmed(stat.syntax().clone())]; } if !lhs_entries.is_empty() { @@ -732,6 +924,8 @@ fn format_local_stat_trivia_aware_new( } } + append_trailing_comment_suffix_new(ctx, plan, &mut docs, stat.syntax()); + docs } @@ -786,6 +980,8 @@ fn format_assign_stat_trivia_aware_new( } } + append_trailing_comment_suffix_new(ctx, plan, &mut docs, stat.syntax()); + docs } @@ -827,6 +1023,8 @@ fn format_return_stat_trivia_aware_new( render_sequence(&mut docs, &entries, false); } + append_trailing_comment_suffix_new(ctx, plan, &mut docs, stat.syntax()); + docs } @@ -870,6 +1068,11 @@ fn collect_local_stat_entries_new( if let Some(node) = child.as_node() && let Some(comment) = LuaComment::cast(node.clone()) { + if has_inline_non_trivia_before_new(comment.syntax()) + && !has_inline_non_trivia_after_new(comment.syntax()) + { + continue; + } let entry = SequenceEntry::Comment(SequenceComment { docs: vec![ir::source_node_trimmed(comment.syntax().clone())], inline_after_previous: has_non_trivia_before_on_same_line_tokenwise( @@ -933,6 +1136,11 @@ fn collect_assign_stat_entries_new( if let Some(node) = child.as_node() && let Some(comment) = LuaComment::cast(node.clone()) { + if has_inline_non_trivia_before_new(comment.syntax()) + && !has_inline_non_trivia_after_new(comment.syntax()) + { + continue; + } let entry = SequenceEntry::Comment(SequenceComment { docs: vec![ir::source_node_trimmed(comment.syntax().clone())], inline_after_previous: has_non_trivia_before_on_same_line_tokenwise( @@ -986,6 +1194,11 @@ fn collect_return_stat_entries_new( if let Some(node) = child.as_node() && let Some(comment) = LuaComment::cast(node.clone()) { + if has_inline_non_trivia_before_new(comment.syntax()) + && !has_inline_non_trivia_after_new(comment.syntax()) + { + continue; + } entries.push(SequenceEntry::Comment(SequenceComment { docs: vec![ir::source_node_trimmed(comment.syntax().clone())], inline_after_previous: has_non_trivia_before_on_same_line_tokenwise( @@ -1209,10 +1422,14 @@ fn render_header_exprs_new( expr_docs: Vec>, ) -> Vec { let leading_docs = token_right_spacing_docs(plan, leading_token); - if matches!( - expr_list_plan.kind, - StatementExprListLayoutKind::PreserveFirstMultiline - ) { + let attach_first_multiline = expr_docs + .first() + .is_some_and(|docs| crate::ir::ir_has_forced_line_break(docs)) + || matches!( + expr_list_plan.kind, + StatementExprListLayoutKind::PreserveFirstMultiline + ); + if attach_first_multiline { format_statement_expr_list_with_attached_first_multiline_new( comma_token, leading_docs, @@ -1360,6 +1577,10 @@ fn is_simple_single_line_if_body_new(stat: &LuaStat) -> bool { } fn should_preserve_raw_if_stat_new(stat: &LuaIfStat) -> bool { + if syntax_has_descendant_comment_new(stat.syntax()) { + return true; + } + if node_has_direct_comment_child(stat.syntax()) { return true; } @@ -1417,7 +1638,12 @@ fn render_control_body_end_new( plan: &RootFormatPlan, end_kind: LuaTokenKind, ) -> Vec { - let mut docs = render_control_body_new(ctx, root, syntax_plan, plan); + let body_docs = render_control_body_new(ctx, root, syntax_plan, plan); + if matches!(body_docs.as_slice(), [DocIR::HardLine]) { + return vec![ir::space(), ir::syntax_token(end_kind)]; + } + + let mut docs = body_docs; docs.push(ir::syntax_token(end_kind)); docs } @@ -1428,12 +1654,7 @@ fn render_control_body_new( syntax_plan: &SyntaxNodeLayoutPlan, plan: &RootFormatPlan, ) -> Vec { - let block_children = syntax_plan.children.iter().find_map(|child| match child { - LayoutNodePlan::Syntax(block) if block.kind == LuaSyntaxKind::Block => { - Some(block.children.as_slice()) - } - _ => None, - }); + let block_children = block_children_from_parent_plan(syntax_plan); render_block_children_new(ctx, root, block_children, plan) } @@ -1444,14 +1665,20 @@ fn render_block_from_parent_plan_new( syntax_plan: &SyntaxNodeLayoutPlan, plan: &RootFormatPlan, ) -> Vec { - let block_children = syntax_plan.children.iter().find_map(|child| match child { + let block_children = block_children_from_parent_plan(syntax_plan); + + render_block_children_new(ctx, root, block_children, plan) +} + +fn block_children_from_parent_plan( + syntax_plan: &SyntaxNodeLayoutPlan, +) -> Option<&[LayoutNodePlan]> { + syntax_plan.children.iter().find_map(|child| match child { LayoutNodePlan::Syntax(block) if block.kind == LuaSyntaxKind::Block => { Some(block.children.as_slice()) } _ => None, - }); - - render_block_children_new(ctx, root, block_children, plan) + }) } fn render_block_children_new( @@ -1463,9 +1690,10 @@ fn render_block_children_new( let mut docs = Vec::new(); if let Some(children) = block_children { - if !children.is_empty() { + let rendered_children = render_aligned_block_layout_nodes_new(ctx, root, children, plan); + if !rendered_children.is_empty() { let mut body = vec![ir::hard_line()]; - body.extend(render_layout_nodes(ctx, root, children, plan, true)); + body.extend(rendered_children); docs.push(ir::indent(body)); docs.push(ir::hard_line()); } else { @@ -1477,49 +1705,512 @@ fn render_block_children_new( docs } -fn render_expr_new(_ctx: &FormatContext, plan: &RootFormatPlan, expr: &LuaExpr) -> Vec { - expr::format_expr(_ctx, plan, expr) -} +fn render_aligned_block_layout_nodes_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + nodes: &[LayoutNodePlan], + plan: &RootFormatPlan, +) -> Vec { + let mut docs = Vec::new(); + let mut index = 0usize; -fn find_direct_child_plan_by_kind( - syntax_plan: &SyntaxNodeLayoutPlan, - kind: LuaSyntaxKind, -) -> Option<&SyntaxNodeLayoutPlan> { - syntax_plan.children.iter().find_map(|child| match child { - LayoutNodePlan::Syntax(plan) if plan.kind == kind => Some(plan), - _ => None, - }) -} + while index < nodes.len() { + if layout_comment_is_inline_trailing_new(root, nodes, index) { + index += 1; + continue; + } -fn token_or_kind_doc(token: Option<&LuaSyntaxToken>, fallback_kind: LuaTokenKind) -> DocIR { - token - .map(|token| ir::source_token(token.clone())) - .unwrap_or_else(|| ir::syntax_token(fallback_kind)) -} + if index > 0 { + let blank_lines = count_blank_lines_before_layout_node(root, &nodes[index]) + .min(ctx.config.layout.max_blank_lines); + docs.push(ir::hard_line()); + for _ in 0..blank_lines { + docs.push(ir::hard_line()); + } + } -fn first_direct_token(node: &LuaSyntaxNode, kind: LuaTokenKind) -> Option { - node.children_with_tokens().find_map(|element| { - let token = element.into_token()?; - (token.kind().to_token() == kind).then_some(token) - }) -} + if let Some((group_docs, next_index)) = + try_render_aligned_statement_group_new(ctx, root, nodes, index, plan) + { + docs.extend(group_docs); + index = next_index; + continue; + } -fn token_left_spacing_docs(plan: &RootFormatPlan, token: Option<&LuaSyntaxToken>) -> Vec { - let Some(token) = token else { - return Vec::new(); - }; - spacing_docs_from_expected(plan.spacing.left_expected(LuaSyntaxId::from_token(token))) -} + docs.extend(render_layout_node(ctx, root, &nodes[index], plan)); + index += 1; + } -fn token_right_spacing_docs(plan: &RootFormatPlan, token: Option<&LuaSyntaxToken>) -> Vec { - let Some(token) = token else { - return Vec::new(); - }; - spacing_docs_from_expected(plan.spacing.right_expected(LuaSyntaxId::from_token(token))) + docs } -fn spacing_docs_from_expected(expected: Option<&TokenSpacingExpected>) -> Vec { - match expected { +fn try_render_aligned_statement_group_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + nodes: &[LayoutNodePlan], + start: usize, + plan: &RootFormatPlan, +) -> Option<(Vec, usize)> { + let anchor = statement_alignment_node_kind_new(&nodes[start])?; + let allow_eq_alignment = ctx.config.align.continuous_assign_statement; + let allow_comment_alignment = ctx.config.should_align_statement_line_comments(); + if !allow_eq_alignment && !allow_comment_alignment { + return None; + } + + let mut end = start + 1; + while end < nodes.len() { + if layout_comment_is_inline_trailing_new(root, nodes, end) { + end += 1; + continue; + } + + if count_blank_lines_before_layout_node(root, &nodes[end]) > 0 { + break; + } + + if !can_join_statement_alignment_group_new(ctx, root, anchor, &nodes[end], plan) { + break; + } + + end += 1; + } + + let statement_count = nodes[start..end] + .iter() + .filter(|node| statement_alignment_node_kind_new(node).is_some()) + .count(); + if statement_count < 2 { + return None; + } + + let mut entries = Vec::new(); + let mut has_aligned_split = false; + let mut has_aligned_comment_signal = false; + + for node in &nodes[start..end] { + if let LayoutNodePlan::Comment(_) = node + && let Some(index) = nodes[start..end] + .iter() + .position(|candidate| std::ptr::eq(candidate, node)) + && layout_comment_is_inline_trailing_new(root, nodes, start + index) + { + continue; + } + + match node { + LayoutNodePlan::Comment(comment_plan) => { + let syntax = find_node_by_id(root, comment_plan.syntax_id)?; + let comment = LuaComment::cast(syntax)?; + entries.push(AlignEntry::Line { + content: render_comment_with_spacing(ctx, &comment, plan), + trailing: None, + }); + } + LayoutNodePlan::Syntax(syntax_plan) => { + let syntax = find_node_by_id(root, syntax_plan.syntax_id)?; + let trailing_comment = + extract_trailing_comment_rendered_new(ctx, syntax_plan, &syntax, plan).map( + |(docs, _, align_hint)| { + if align_hint { + has_aligned_comment_signal = true; + } + docs + }, + ); + + if allow_eq_alignment + && let Some((before, after)) = + render_statement_align_split_new(ctx, root, syntax_plan, plan) + { + has_aligned_split = true; + entries.push(AlignEntry::Aligned { + before, + after, + trailing: trailing_comment, + }); + } else { + entries.push(AlignEntry::Line { + content: render_statement_line_content_new(ctx, root, syntax_plan, plan) + .unwrap_or_else(|| render_layout_node(ctx, root, node, plan)), + trailing: trailing_comment, + }); + } + } + } + } + + if !has_aligned_split && !has_aligned_comment_signal { + return None; + } + + Some((vec![ir::align_group(entries)], end)) +} + +fn layout_comment_is_inline_trailing_new( + root: &LuaSyntaxNode, + nodes: &[LayoutNodePlan], + index: usize, +) -> bool { + let Some(LayoutNodePlan::Comment(comment_plan)) = nodes.get(index) else { + return false; + }; + let Some(comment_node) = find_node_by_id(root, comment_plan.syntax_id) else { + return false; + }; + + has_non_trivia_before_on_same_line_tokenwise(&comment_node) + && !comment_node.text().contains_char('\n') + && !has_inline_non_trivia_after_new(&comment_node) +} + +fn can_join_statement_alignment_group_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + anchor_kind: LuaSyntaxKind, + node: &LayoutNodePlan, + plan: &RootFormatPlan, +) -> bool { + match node { + LayoutNodePlan::Comment(_) => ctx.config.comments.align_across_standalone_comments, + LayoutNodePlan::Syntax(syntax_plan) => { + if let Some(kind) = statement_alignment_node_kind_new(node) { + if ctx.config.comments.align_same_kind_only && kind != anchor_kind { + return false; + } + + if ctx.config.align.continuous_assign_statement { + return true; + } + + let Some(syntax) = find_node_by_id(root, syntax_plan.syntax_id) else { + return false; + }; + extract_trailing_comment_rendered_new(ctx, syntax_plan, &syntax, plan).is_some() + } else { + false + } + } + } +} + +fn statement_alignment_node_kind_new(node: &LayoutNodePlan) -> Option { + match node { + LayoutNodePlan::Syntax(syntax_plan) + if matches!( + syntax_plan.kind, + LuaSyntaxKind::LocalStat | LuaSyntaxKind::AssignStat + ) => + { + Some(syntax_plan.kind) + } + _ => None, + } +} + +fn render_statement_align_split_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + syntax_plan: &SyntaxNodeLayoutPlan, + plan: &RootFormatPlan, +) -> Option { + match syntax_plan.kind { + LuaSyntaxKind::LocalStat => { + let node = find_node_by_id(root, syntax_plan.syntax_id)?; + let stat = LuaLocalStat::cast(node)?; + render_local_stat_align_split_new(ctx, plan, syntax_plan.syntax_id, &stat) + } + LuaSyntaxKind::AssignStat => { + let node = find_node_by_id(root, syntax_plan.syntax_id)?; + let stat = LuaAssignStat::cast(node)?; + render_assign_stat_align_split_new(ctx, plan, syntax_plan.syntax_id, &stat) + } + _ => None, + } +} + +fn render_statement_line_content_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + syntax_plan: &SyntaxNodeLayoutPlan, + plan: &RootFormatPlan, +) -> Option> { + let (before, after) = render_statement_align_split_new(ctx, root, syntax_plan, plan)?; + let mut docs = before; + docs.push(ir::space()); + docs.extend(after); + Some(docs) +} + +fn render_local_stat_align_split_new( + ctx: &FormatContext, + plan: &RootFormatPlan, + syntax_id: LuaSyntaxId, + stat: &LuaLocalStat, +) -> Option { + let exprs: Vec<_> = stat.get_value_exprs().collect(); + if exprs.is_empty() { + return None; + } + + let expr_list_plan = plan.layout.statement_expr_lists.get(&syntax_id).copied()?; + let local_token = first_direct_token(stat.syntax(), LuaTokenKind::TkLocal); + let comma_token = first_direct_token(stat.syntax(), LuaTokenKind::TkComma); + let assign_token = first_direct_token(stat.syntax(), LuaTokenKind::TkAssign); + + let mut before = vec![token_or_kind_doc( + local_token.as_ref(), + LuaTokenKind::TkLocal, + )]; + before.extend(token_right_spacing_docs(plan, local_token.as_ref())); + let local_names: Vec<_> = stat.get_local_name_list().collect(); + for (index, local_name) in local_names.iter().enumerate() { + if index > 0 { + before.extend(comma_flat_separator(plan, comma_token.as_ref())); + } + before.extend(format_local_name_ir_new(local_name)); + } + + let expr_docs: Vec> = exprs + .iter() + .enumerate() + .map(|(index, expr)| { + format_statement_value_expr_new( + ctx, + plan, + expr, + index == 0 + && matches!( + expr_list_plan.kind, + StatementExprListLayoutKind::PreserveFirstMultiline + ), + ) + }) + .collect(); + + let mut after = vec![token_or_kind_doc( + assign_token.as_ref(), + LuaTokenKind::TkAssign, + )]; + after.extend(render_statement_exprs_new( + ctx, + plan, + expr_list_plan, + assign_token.as_ref(), + comma_token.as_ref(), + expr_docs, + )); + + Some((before, after)) +} + +fn render_assign_stat_align_split_new( + ctx: &FormatContext, + plan: &RootFormatPlan, + syntax_id: LuaSyntaxId, + stat: &LuaAssignStat, +) -> Option { + let (vars, exprs) = stat.get_var_and_expr_list(); + if exprs.is_empty() { + return None; + } + + let expr_list_plan = plan.layout.statement_expr_lists.get(&syntax_id).copied()?; + let comma_token = first_direct_token(stat.syntax(), LuaTokenKind::TkComma); + let assign_token = stat.get_assign_op().map(|op| op.syntax().clone()); + let var_docs: Vec> = vars + .iter() + .map(|var| render_expr_new(ctx, plan, &var.clone().into())) + .collect(); + let before = ir::intersperse(var_docs, comma_flat_separator(plan, comma_token.as_ref())); + + let expr_docs: Vec> = exprs + .iter() + .enumerate() + .map(|(index, expr)| { + format_statement_value_expr_new( + ctx, + plan, + expr, + index == 0 + && matches!( + expr_list_plan.kind, + StatementExprListLayoutKind::PreserveFirstMultiline + ), + ) + }) + .collect(); + + let mut after = vec![token_or_kind_doc( + assign_token.as_ref(), + LuaTokenKind::TkAssign, + )]; + after.extend(render_statement_exprs_new( + ctx, + plan, + expr_list_plan, + assign_token.as_ref(), + comma_token.as_ref(), + expr_docs, + )); + + Some((before, after)) +} + +fn extract_trailing_comment_rendered_new( + ctx: &FormatContext, + syntax_plan: &SyntaxNodeLayoutPlan, + node: &LuaSyntaxNode, + plan: &RootFormatPlan, +) -> Option { + let comment = find_inline_trailing_comment_node_new(node)?; + if comment.text().contains_char('\n') { + return None; + } + let comment = LuaComment::cast(comment.clone())?; + let docs = render_comment_with_spacing(ctx, &comment, plan); + let align_hint = matches!( + syntax_plan.kind, + LuaSyntaxKind::LocalStat | LuaSyntaxKind::AssignStat + ) && trailing_gap_requests_alignment( + node, + comment.syntax().text_range(), + ctx.config.comments.line_comment_min_spaces_before.max(1), + ); + Some((docs, comment.syntax().text_range(), align_hint)) +} + +fn append_trailing_comment_suffix_new( + ctx: &FormatContext, + plan: &RootFormatPlan, + docs: &mut Vec, + node: &LuaSyntaxNode, +) { + let Some(comment_node) = find_inline_trailing_comment_node_new(node) else { + return; + }; + let Some(comment) = LuaComment::cast(comment_node) else { + return; + }; + + let content_width = crate::ir::ir_flat_width(docs); + let padding = if ctx.config.comments.line_comment_min_column == 0 { + ctx.config.comments.line_comment_min_spaces_before.max(1) + } else { + ctx.config + .comments + .line_comment_min_spaces_before + .max(1) + .max( + ctx.config + .comments + .line_comment_min_column + .saturating_sub(content_width), + ) + }; + let mut suffix = (0..padding).map(|_| ir::space()).collect::>(); + suffix.extend(render_comment_with_spacing(ctx, &comment, plan)); + docs.push(ir::line_suffix(suffix)); +} + +fn find_inline_trailing_comment_node_new(node: &LuaSyntaxNode) -> Option { + for child in node.children() { + if child.kind() != LuaKind::Syntax(LuaSyntaxKind::Comment) { + continue; + } + + if has_inline_non_trivia_before_new(&child) && !has_inline_non_trivia_after_new(&child) { + return Some(child); + } + } + + let mut next = node.next_sibling_or_token(); + 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) => return sibling.as_node().cloned(), + _ => return None, + } + next = sibling.next_sibling_or_token(); + } + + None +} + +fn has_inline_non_trivia_before_new(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 +} + +fn has_inline_non_trivia_after_new(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) => return false, + LuaKind::Syntax(LuaSyntaxKind::Comment) => next = element.next_sibling_or_token(), + _ => return true, + } + } + false +} + +fn render_expr_new(_ctx: &FormatContext, plan: &RootFormatPlan, expr: &LuaExpr) -> Vec { + expr::format_expr(_ctx, plan, expr) +} + +fn find_direct_child_plan_by_kind( + syntax_plan: &SyntaxNodeLayoutPlan, + kind: LuaSyntaxKind, +) -> Option<&SyntaxNodeLayoutPlan> { + syntax_plan.children.iter().find_map(|child| match child { + LayoutNodePlan::Syntax(plan) if plan.kind == kind => Some(plan), + _ => None, + }) +} + +fn token_or_kind_doc(token: Option<&LuaSyntaxToken>, fallback_kind: LuaTokenKind) -> DocIR { + token + .map(|token| ir::source_token(token.clone())) + .unwrap_or_else(|| ir::syntax_token(fallback_kind)) +} + +fn first_direct_token(node: &LuaSyntaxNode, kind: LuaTokenKind) -> Option { + node.children_with_tokens().find_map(|element| { + let token = element.into_token()?; + (token.kind().to_token() == kind).then_some(token) + }) +} + +fn token_left_spacing_docs(plan: &RootFormatPlan, token: Option<&LuaSyntaxToken>) -> Vec { + let Some(token) = token else { + return Vec::new(); + }; + spacing_docs_from_expected(plan.spacing.left_expected(LuaSyntaxId::from_token(token))) +} + +fn token_right_spacing_docs(plan: &RootFormatPlan, token: Option<&LuaSyntaxToken>) -> Vec { + let Some(token) = token else { + return Vec::new(); + }; + spacing_docs_from_expected(plan.spacing.right_expected(LuaSyntaxId::from_token(token))) +} + +fn spacing_docs_from_expected(expected: Option<&TokenSpacingExpected>) -> Vec { + match expected { Some(TokenSpacingExpected::Space(count)) | Some(TokenSpacingExpected::MaxSpace(count)) => { (0..*count).map(|_| ir::space()).collect() } @@ -1621,15 +2312,20 @@ fn render_trivia_aware_split_sequence_tail_new( } fn render_comment_with_spacing( - _ctx: &FormatContext, + ctx: &FormatContext, comment: &LuaComment, - plan: &RootFormatPlan, + _plan: &RootFormatPlan, ) -> Vec { - if should_preserve_comment_raw(comment) { + if should_preserve_comment_raw(comment) || should_preserve_doc_comment_block_raw(comment) { return vec![ir::source_node_trimmed(comment.syntax().clone())]; } - let lines = collect_comment_render_lines(comment, plan); + let raw = trim_end_comment_text(comment.syntax().text().to_string()); + let lines = if raw.starts_with("---") { + normalize_doc_comment_block(ctx, &raw) + } else { + normalize_normal_comment_block(ctx, &raw) + }; lines .into_iter() .enumerate() @@ -1646,6 +2342,336 @@ fn render_comment_with_spacing( .collect() } +fn trim_end_comment_text(mut text: String) -> String { + while matches!(text.chars().last(), Some(' ' | '\t' | '\r' | '\n')) { + text.pop(); + } + text +} + +fn normalize_normal_comment_block(ctx: &FormatContext, raw: &str) -> Vec { + let lines: Vec<_> = raw.lines().map(str::to_string).collect(); + if lines.len() <= 1 { + return vec![normalize_single_normal_comment_line(ctx, raw)]; + } + lines +} + +fn normalize_single_normal_comment_line(ctx: &FormatContext, line: &str) -> String { + if !line.starts_with("--") || line.starts_with("---") { + return line.to_string(); + } + let body = line[2..].trim_start(); + if ctx.config.comments.space_after_comment_dash { + if body.is_empty() { + "--".to_string() + } else { + format!("-- {body}") + } + } else { + format!("--{body}") + } +} + +#[derive(Clone)] +enum DocLineKind { + Description { + content: String, + preserve_spacing: bool, + }, + ContinueOr { + content: String, + }, + Tag(DocTagLine), +} + +#[derive(Clone)] +struct DocTagLine { + tag: String, + raw_rest: String, + columns: Vec, + align_key: Option, + preserve_body_spacing: bool, +} + +fn should_preserve_doc_comment_block_raw(comment: &LuaComment) -> bool { + let raw = comment.syntax().text().to_string(); + raw.lines().any(|line| { + let trimmed = line.trim_start(); + (trimmed.starts_with("---@type") || trimmed.starts_with("--- @type")) + && trimmed.contains(" --") + }) +} + +fn normalize_doc_comment_block(ctx: &FormatContext, raw: &str) -> Vec { + let raw_lines: Vec<&str> = raw.lines().collect(); + let parsed: Vec = raw_lines + .iter() + .enumerate() + .map(|(index, line)| parse_doc_comment_line(ctx, line, index == 0, raw_lines.len() == 1)) + .collect(); + + let mut widths: HashMap> = HashMap::new(); + for line in &parsed { + let DocLineKind::Tag(tag) = line else { + continue; + }; + let Some(key) = &tag.align_key else { + continue; + }; + let entry = widths + .entry(key.clone()) + .or_insert_with(|| vec![0; tag.columns.len().saturating_sub(1)]); + if entry.len() < tag.columns.len().saturating_sub(1) { + entry.resize(tag.columns.len().saturating_sub(1), 0); + } + for (index, column) in tag + .columns + .iter() + .take(tag.columns.len().saturating_sub(1)) + .enumerate() + { + entry[index] = entry[index].max(column.len()); + } + } + + parsed + .into_iter() + .map(|line| format_doc_comment_line(ctx, line, &widths)) + .collect() +} + +fn parse_doc_comment_line( + ctx: &FormatContext, + line: &str, + is_first_line: bool, + single_line_block: bool, +) -> DocLineKind { + let suffix = line.strip_prefix("---").unwrap_or(line); + let trimmed = suffix.trim_start(); + + if let Some(rest) = trimmed.strip_prefix('@') { + return DocLineKind::Tag(parse_doc_tag_line(ctx, rest.trim_start())); + } + if let Some(rest) = trimmed.strip_prefix('|') { + return DocLineKind::ContinueOr { + content: collapse_spaces(rest.trim_start()), + }; + } + + let preserve_spacing = !single_line_block && !is_first_line; + let content = if preserve_spacing { + suffix.to_string() + } else { + collapse_spaces(trimmed) + }; + DocLineKind::Description { + content, + preserve_spacing, + } +} + +fn parse_doc_tag_line(ctx: &FormatContext, rest: &str) -> DocTagLine { + let mut parts = rest.split_whitespace(); + let tag = parts.next().unwrap_or_default().to_string(); + let raw_rest = rest[tag.len()..].trim_start().to_string(); + let mut columns = match tag.as_str() { + "param" => split_columns(&raw_rest, &[1, 1]), + "field" => parse_field_columns(&raw_rest), + "return" => parse_return_columns(&raw_rest), + "class" => split_columns(&raw_rest, &[1]), + "alias" => parse_alias_columns(&raw_rest), + "generic" => parse_generic_columns(&raw_rest), + "type" | "overload" => vec![collapse_spaces(&raw_rest)], + _ => vec![collapse_spaces(&raw_rest)], + }; + columns.retain(|column| !column.is_empty()); + + let align_key = match tag.as_str() { + "class" | "alias" | "field" | "generic" + if ctx.config.should_align_emmy_doc_declaration_tags() => + { + Some(tag.clone()) + } + "param" | "return" if ctx.config.should_align_emmy_doc_reference_tags() => { + Some(tag.clone()) + } + _ => None, + }; + + let preserve_body_spacing = tag == "alias" && !ctx.config.emmy_doc.align_tag_columns; + + DocTagLine { + tag, + raw_rest, + columns, + align_key, + preserve_body_spacing, + } +} + +fn format_doc_comment_line( + ctx: &FormatContext, + line: DocLineKind, + widths: &HashMap>, +) -> String { + match line { + DocLineKind::Description { + content, + preserve_spacing, + } => { + let prefix = if ctx.config.emmy_doc.space_after_description_dash { + "--- " + } else { + "---" + }; + if preserve_spacing { + format!("---{content}") + } else if content.is_empty() { + prefix.trim_end().to_string() + } else { + format!("{prefix}{content}") + } + } + DocLineKind::ContinueOr { content } => { + let prefix = if ctx.config.emmy_doc.space_after_description_dash { + "--- |" + } else { + "---|" + }; + if content.is_empty() { + prefix.to_string() + } else { + format!("{prefix} {content}") + } + } + DocLineKind::Tag(tag) => { + let prefix = if ctx.config.emmy_doc.space_after_description_dash { + format!("--- @{}", tag.tag) + } else { + format!("---@{}", tag.tag) + }; + if tag.preserve_body_spacing { + return if tag.raw_rest.is_empty() { + prefix + } else { + format!("{prefix} {}", tag.raw_rest) + }; + } + let Some(key) = &tag.align_key else { + return if tag.columns.is_empty() { + prefix + } else { + format!("{prefix} {}", tag.columns.join(" ")) + }; + }; + let target_widths = widths.get(key); + let mut rendered = prefix; + if let Some((first, rest)) = tag.columns.split_first() { + rendered.push(' '); + rendered.push_str(first); + for (index, column) in rest.iter().enumerate() { + let source_index = index; + let padding = target_widths + .and_then(|widths| widths.get(source_index)) + .map(|width| { + width.saturating_sub(tag.columns[source_index].len()) + + ctx.config.emmy_doc.tag_spacing + }) + .unwrap_or(1); + rendered.extend(std::iter::repeat_n(' ', padding)); + rendered.push_str(column); + } + } + rendered + } + } +} + +fn split_columns(input: &str, head_sizes: &[usize]) -> Vec { + let tokens: Vec<_> = input.split_whitespace().collect(); + if tokens.is_empty() { + return Vec::new(); + } + let mut columns = Vec::new(); + let mut index = 0; + for head_size in head_sizes { + if index >= tokens.len() { + break; + } + let end = (index + *head_size).min(tokens.len()); + columns.push(tokens[index..end].join(" ")); + index = end; + } + if index < tokens.len() { + columns.push(tokens[index..].join(" ")); + } + columns +} + +fn parse_field_columns(input: &str) -> Vec { + let tokens: Vec<_> = input.split_whitespace().collect(); + if tokens.is_empty() { + return Vec::new(); + } + let visibility = matches!( + tokens.first().copied(), + Some("public" | "private" | "protected") + ); + if visibility && tokens.len() >= 2 { + let mut columns = vec![format!("{} {}", tokens[0], tokens[1])]; + if tokens.len() >= 3 { + columns.push(tokens[2].to_string()); + } + if tokens.len() >= 4 { + columns.push(tokens[3..].join(" ")); + } + columns + } else { + split_columns(input, &[1, 1]) + } +} + +fn parse_return_columns(input: &str) -> Vec { + let tokens: Vec<_> = input.split_whitespace().collect(); + match tokens.len() { + 0 => Vec::new(), + 1 => vec![tokens[0].to_string()], + 2 => vec![tokens.join(" ")], + _ => vec![ + tokens[..tokens.len() - 1].join(" "), + tokens[tokens.len() - 1].to_string(), + ], + } +} + +fn parse_alias_columns(input: &str) -> Vec { + let tokens: Vec<_> = input.split_whitespace().collect(); + match tokens.len() { + 0 => Vec::new(), + 1 => vec![tokens[0].to_string()], + 2 => vec![tokens.join(" ")], + _ => vec![tokens[..2].join(" "), tokens[2..].join(" ")], + } +} + +fn parse_generic_columns(input: &str) -> Vec { + let tokens: Vec<_> = input.split_whitespace().collect(); + match tokens.len() { + 0 => Vec::new(), + 1 => vec![tokens[0].to_string()], + 2 => vec![tokens[0].to_string(), tokens[1].to_string()], + _ => vec![ + tokens[..tokens.len() - 2].join(" "), + tokens[tokens.len() - 2..].join(" "), + ], + } +} + +fn collapse_spaces(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + #[derive(Default)] struct RenderCommentLine { tokens: Vec<(LuaSyntaxId, String)>, @@ -1761,6 +2787,9 @@ fn render_comment_line(line: RenderCommentLine) -> String { } fn should_preserve_comment_raw(comment: &LuaComment) -> bool { + if comment.syntax().text().to_string().starts_with("----") { + return true; + } let Some(first_token) = comment.syntax().first_token() else { return false; }; diff --git a/crates/emmylua_formatter/src/formatter/trivia.rs b/crates/emmylua_formatter/src/formatter/trivia.rs index f21cdde24..e0b10dc8a 100644 --- a/crates/emmylua_formatter/src/formatter/trivia.rs +++ b/crates/emmylua_formatter/src/formatter/trivia.rs @@ -1,9 +1,12 @@ use emmylua_parser::{LuaKind, LuaSyntaxKind, LuaSyntaxNode, LuaTokenKind}; +use rowan::TextRange; +/// 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 { @@ -14,7 +17,9 @@ pub fn count_blank_lines_before(node: &LuaSyntaxNode) -> usize { blank_lines += 1; } } - LuaTokenKind::TkWhitespace => {} + LuaTokenKind::TkWhitespace => { + // Skip whitespace + } _ => break, } token = t.prev_token(); @@ -24,20 +29,49 @@ 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 +} + pub fn has_non_trivia_before_on_same_line_tokenwise(node: &LuaSyntaxNode) -> bool { let Some(first_token) = node.first_token() else { return false; }; let mut previous = first_token.prev_token(); + while let Some(token) = previous { match token.kind().to_token() { - LuaTokenKind::TkWhitespace => previous = token.prev_token(), + LuaTokenKind::TkWhitespace => { + previous = token.prev_token(); + } LuaTokenKind::TkEndOfLine => return false, _ => return true, } @@ -69,3 +103,48 @@ pub fn source_line_prefix_width(node: &LuaSyntaxNode) -> usize { width } + +pub fn syntax_has_descendant_comment(node: &LuaSyntaxNode) -> bool { + node.descendants() + .any(|child| child.kind() == LuaKind::Syntax(LuaSyntaxKind::Comment)) +} + +pub fn trailing_gap_requests_alignment( + node: &LuaSyntaxNode, + comment_range: TextRange, + required_min_gap: usize, +) -> bool { + let mut gap_width = 0usize; + let mut next = node.next_sibling_or_token(); + + while let Some(element) = next { + if element.text_range().start() >= comment_range.start() { + break; + } + + match element.kind() { + LuaKind::Token(LuaTokenKind::TkEndOfLine) => return false, + LuaKind::Token(LuaTokenKind::TkWhitespace) => { + if let Some(token) = element.as_token() { + for ch in token.text().chars() { + if matches!(ch, '\n' | '\r') { + return false; + } + if matches!(ch, ' ' | '\t') { + gap_width += 1; + } + } + } + } + _ => { + if element.text_range().end() > comment_range.start() { + return false; + } + } + } + + next = element.next_sibling_or_token(); + } + + gap_width > required_min_gap +}