diff --git a/Cargo.lock b/Cargo.lock index f5a395ed6..7dfd55f24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -592,19 +592,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" @@ -645,6 +632,24 @@ dependencies = [ "walkdir", ] +[[package]] +name = "emmylua_formatter" +version = "0.1.0" +dependencies = [ + "clap", + "emmylua_parser", + "glob", + "mimalloc", + "rowan", + "serde", + "serde_json", + "serde_yml", + "similar", + "smol_str", + "toml_edit", + "walkdir", +] + [[package]] name = "emmylua_ls" version = "0.21.0" @@ -2453,6 +2458,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/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/README.md b/README.md index 8efdd56eb..a595de6e4 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,6 @@ Completion · Go to Definition · Find References · Go to Implementation · Hov - Static analysis with 40+ diagnostic rules - Code formatting and style enforcement - EmmyLua / Luacats annotation support - --- ## Usage @@ -132,13 +131,12 @@ emmylua_doc_cli ./src --output ./docs ## Documentation -| Resource | Link | -|----------|------| -| Features Guide | [features_EN.md](./docs/features/features_EN.md) | -| Configuration | [emmyrc_json_EN.md](./docs/config/emmyrc_json_EN.md) | -| Annotations Reference | [annotations_EN](./docs/emmylua_doc/annotations_EN/README.md) | -| Code Style | [EmmyLuaCodeStyle](https://github.com/CppCXY/EmmyLuaCodeStyle/blob/master/README_EN.md) | -| External Formatters | [external_formatter_options_EN.md](./docs/external_format/external_formatter_options_EN.md) | +- [**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 +- [**Annotations Reference**](./docs/emmylua_doc/annotations_EN/README.md) - Detailed annotation documentation +- [**Old Formatter**](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_code_style/README.md b/crates/emmylua_code_style/README.md deleted file mode 100644 index 5eba69a8f..000000000 --- a/crates/emmylua_code_style/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# EmmyLua Code Style - -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_code_style/src/bin/emmylua_format.rs deleted file mode 100644 index 0053cd665..000000000 --- a/crates/emmylua_code_style/src/bin/emmylua_format.rs +++ /dev/null @@ -1,169 +0,0 @@ -use std::{ - fs, - io::{self, Read, Write}, - path::PathBuf, - process::exit, -}; - -use clap::Parser; -use emmylua_code_style::{LuaCodeStyle, cmd_args, reformat_lua_code}; - -#[global_allocator] -static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; - -fn read_stdin_to_string() -> io::Result { - let mut s = String::new(); - io::stdin().read_to_string(&mut s)?; - Ok(s) -} - -fn format_content(content: &str, style: &LuaCodeStyle) -> String { - reformat_lua_code(content, style) -} - -#[allow(unused)] -fn process_file( - path: &PathBuf, - style: &LuaCodeStyle, - 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); - } - }; - - let is_stdin = args.stdin || args.paths.is_empty(); - - if is_stdin { - let content = match read_stdin_to_string() { - Ok(s) => s, - Err(e) => { - eprintln!("Failed to read stdin: {e}"); - exit(2); - } - }; - - let formatted = format_content(&content, &style); - let changed = formatted != content; - - 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) { - eprintln!("Failed to write output to {out:?}: {e}"); - exit(2); - } - } else if args.write { - eprintln!("--write with stdin requires --output "); - exit(2); - } else { - let mut stdout = io::stdout(); - if let Err(e) = stdout.write_all(formatted.as_bytes()) { - eprintln!("Failed to write to stdout: {e}"); - exit(2); - } - } - - exit(exit_code); - } - - if args.paths.len() > 1 && args.output.is_some() { - 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"); - exit(2); - } - - let mut different_paths: Vec = Vec::new(); - - 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; - } - } - - match fs::read_to_string(path) { - Ok(original) => { - let formatted = format_content(&original, &style); - let changed = formatted != original; - - if args.check || args.list_different { - if 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) { - 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) { - 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()) { - eprintln!("Failed to write to stdout: {e}"); - exit(2); - } - } - } - Err(e) => { - eprintln!("Failed to read {}: {e}", path.to_string_lossy()); - exit_code = 2; - } - } - } - - if args.list_different && !different_paths.is_empty() { - for p in different_paths { - println!("{p}"); - } - } - - exit(exit_code); -} diff --git a/crates/emmylua_code_style/src/cmd_args.rs b/crates/emmylua_code_style/src/cmd_args.rs deleted file mode 100644 index 05527063e..000000000 --- a/crates/emmylua_code_style/src/cmd_args.rs +++ /dev/null @@ -1,101 +0,0 @@ -use std::{fs, path::PathBuf}; - -use clap::{ArgGroup, Parser}; - -use crate::styles::{LuaCodeStyle, LuaIndent}; - -#[derive(Debug, Clone, Parser)] -#[command( - name = "emmylua_format", - version, - about = "Format Lua source code using EmmyLua code style rules", - disable_help_subcommand = true -)] -#[command(group( - ArgGroup::new("indent_choice") - .args(["tab", "spaces"]) - .multiple(false) -))] -pub struct CliArgs { - /// Input paths to format (files only). If omitted, reads from stdin. - #[arg(value_name = "PATH", value_hint = clap::ValueHint::FilePath)] - pub paths: Vec, - - /// Read source from stdin instead of files - #[arg(long)] - pub stdin: bool, - - /// Write formatted result back to the file(s) - #[arg(long)] - pub write: bool, - - /// Check if files would be reformatted. Exit with code 1 if any would change. - #[arg(long)] - pub check: bool, - - /// Print paths of files that would be reformatted - #[arg(long, alias = "list-different")] - pub list_different: bool, - - /// 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, - - /// Load style config from a file (json/yml/yaml) - #[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath)] - pub config: Option, - - /// Use tabs for indentation - #[arg(long)] - pub tab: bool, - - /// Use N spaces for indentation (mutually exclusive with --tab) - #[arg(long, value_name = "N")] - pub spaces: Option, - - /// Set maximum line width - #[arg(long, value_name = "N")] - pub max_line_width: Option, -} - -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()))?; - 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}"))?, - _ => { - // 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!("未知扩展名,按 JSON/YAML 解析均失败: {e}"))?, - } - } - } - } else { - LuaCodeStyle::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), - _ => {} - } - - if let Some(w) = args.max_line_width { - style.max_line_width = w; - } - - Ok(style) -} diff --git a/crates/emmylua_code_style/src/format/formatter_context.rs b/crates/emmylua_code_style/src/format/formatter_context.rs deleted file mode 100644 index cc9bfa725..000000000 --- a/crates/emmylua_code_style/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_code_style/src/format/mod.rs b/crates/emmylua_code_style/src/format/mod.rs deleted file mode 100644 index 02b855af8..000000000 --- a/crates/emmylua_code_style/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_code_style/src/format/syntax_node_change.rs b/crates/emmylua_code_style/src/format/syntax_node_change.rs deleted file mode 100644 index 902da67dc..000000000 --- a/crates/emmylua_code_style/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_code_style/src/lib.rs b/crates/emmylua_code_style/src/lib.rs deleted file mode 100644 index c9f2cd5f6..000000000 --- a/crates/emmylua_code_style/src/lib.rs +++ /dev/null @@ -1,26 +0,0 @@ -pub mod cmd_args; -mod format; -mod style_ruler; -mod styles; -mod test; - -use emmylua_parser::{LuaAst, LuaParser, ParserConfig}; - -pub fn reformat_lua_code(code: &str, styles: &LuaCodeStyle) -> String { - let tree = LuaParser::parse(code, ParserConfig::default()); - - let mut formatter = format::LuaFormatter::new(LuaAst::LuaChunk(tree.get_chunk_node())); - style_ruler::apply_styles(&mut formatter, styles); - - formatter.get_formatted_text() -} - -pub fn reformat_node(node: &LuaAst, styles: &LuaCodeStyle) -> String { - let mut formatter = format::LuaFormatter::new(node.clone()); - style_ruler::apply_styles(&mut formatter, styles); - - formatter.get_formatted_text() -} - -// Re-export commonly used types for consumers/binaries -pub use styles::LuaCodeStyle; diff --git a/crates/emmylua_code_style/src/style_ruler/basic_space.rs b/crates/emmylua_code_style/src/style_ruler/basic_space.rs deleted file mode 100644 index 00deb84a4..000000000 --- a/crates/emmylua_code_style/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_code_style/src/style_ruler/mod.rs b/crates/emmylua_code_style/src/style_ruler/mod.rs deleted file mode 100644 index c4531c783..000000000 --- a/crates/emmylua_code_style/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_code_style/src/styles/lua_indent.rs b/crates/emmylua_code_style/src/styles/lua_indent.rs deleted file mode 100644 index 1e2ae6bd3..000000000 --- a/crates/emmylua_code_style/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_code_style/src/styles/mod.rs b/crates/emmylua_code_style/src/styles/mod.rs deleted file mode 100644 index f73dde7a4..000000000 --- a/crates/emmylua_code_style/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_code_style/src/test/mod.rs b/crates/emmylua_code_style/src/test/mod.rs deleted file mode 100644 index 2b2fd2a80..000000000 --- a/crates/emmylua_code_style/src/test/mod.rs +++ /dev/null @@ -1,19 +0,0 @@ -#[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); - } -} diff --git a/crates/emmylua_code_style/Cargo.toml b/crates/emmylua_formatter/Cargo.toml similarity index 59% rename from crates/emmylua_code_style/Cargo.toml rename to crates/emmylua_formatter/Cargo.toml index bc60e94d4..0700531d9 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" @@ -9,6 +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 @@ -18,10 +22,14 @@ optional = true workspace = true optional = true +[dependencies.similar] +workspace = true +optional = true + [[bin]] -name = "emmylua_format" +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/README.md b/crates/emmylua_formatter/README.md new file mode 100644 index 000000000..0fad3d903 --- /dev/null +++ b/crates/emmylua_formatter/README.md @@ -0,0 +1,238 @@ +# EmmyLua Formatter + +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. + +The formatter pipeline is built in three stages: + +1. Parse source text into syntax nodes. +2. Lower syntax into DocIR. +3. Print DocIR back to text with configurable layout selection. + +The current implementation covers statements, expressions, table literals, chained calls, binary-expression chains, trailing comments, and a practical subset of EmmyLua doc tags. + +Sequence-layout redesign notes are documented in `SEQUENCE_LAYOUT_DESIGN.md`. + +## Design Goals + +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"` +- `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` + +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 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 + +Structured handling currently exists for: + +- `@param` +- `@field` +- `@return` +- `@class` +- `@alias` +- `@type` +- `@generic` +- `@overload` + +Doc-tag 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 speculative rewriting + +## CLI + +The `luafmt` binary supports: + +- `--config ` with `toml`, `json`, `yml`, or `yaml` +- automatic discovery of `.luafmt.toml` or `luafmt.toml` +- `--dump-default-config` +- recursive directory input +- `--include` and `--exclude` glob filters +- `.luafmtignore` +- `--check` and `--list-different` +- `--color auto|always|never` +- `--diff-style marker|git` + +Typical usage: + +```powershell +luafmt src --write +luafmt . --check --exclude "vendor/**" +luafmt game --list-different +``` + +## Library API + +The crate exposes workspace-friendly helpers so callers do not need to shell out to `luafmt`: + +- `resolve_config_for_path` +- `format_text_for_path` +- `check_text_for_path` +- `format_file` +- `check_file` +- `collect_lua_files` + +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!(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 +[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" +trailing_table_separator = "Inherit" +quote_style = "Preserve" +single_arg_call_parens = "Preserve" +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 = 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 + +[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 = false +table_field = true +``` diff --git a/crates/emmylua_formatter/src/bin/luafmt.rs b/crates/emmylua_formatter/src/bin/luafmt.rs new file mode 100644 index 000000000..212870e39 --- /dev/null +++ b/crates/emmylua_formatter/src/bin/luafmt.rs @@ -0,0 +1,428 @@ +use std::{ + fs, + io::{self, IsTerminal, Read, Write}, + process::exit, +}; + +use clap::Parser; +use emmylua_formatter::{ + 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; + +fn read_stdin_to_string() -> io::Result { + let mut s = String::new(); + io::stdin().read_to_string(&mut s)?; + 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() { + 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(); + + if is_stdin { + let content = match read_stdin_to_string() { + Ok(s) => s, + Err(e) => { + eprintln!("Failed to read stdin: {e}"); + exit(2); + } + }; + + let result = match check_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; + 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) { + eprintln!("Failed to write output to {out:?}: {e}"); + exit(2); + } + } else if args.write { + eprintln!("--write with stdin requires --output "); + exit(2); + } else { + let mut stdout = io::stdout(); + if let Err(e) = stdout.write_all(result.output.formatted.as_bytes()) { + eprintln!("Failed to write to stdout: {e}"); + exit(2); + } + } + + exit(exit_code); + } + + if args.output.is_some() && args.paths.len() != 1 { + eprintln!("--output can only be used with a single input or stdin"); + exit(2); + } + + 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); + } + + if files.is_empty() { + eprintln!("No Lua files matched the provided inputs"); + exit(2); + } + + let mut different_paths: Vec = Vec::new(); + + for path in &files { + 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 (result_path, source, formatted, changed) = result; + + if args.check || args.list_different { + if changed { + exit_code = 1; + if args.list_different { + 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 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, formatted) { + eprintln!("Failed to write output to {out:?}: {e}"); + exit(2); + } + } else { + let mut stdout = io::stdout(); + if let Err(e) = stdout.write_all(formatted.as_bytes()) { + eprintln!("Failed to write to stdout: {e}"); + exit(2); + } + } + } + Err(err) => { + eprintln!("Failed to format {}: {err}", path.to_string_lossy()); + exit_code = 2; + } + } + } + + if args.list_different && !different_paths.is_empty() { + for p in different_paths { + println!("{p}"); + } + } + + 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 new file mode 100644 index 000000000..211ee66ff --- /dev/null +++ b/crates/emmylua_formatter/src/cmd_args.rs @@ -0,0 +1,146 @@ +use std::path::PathBuf; + +use clap::{ArgGroup, Parser, ValueEnum}; + +use crate::{FileCollectorOptions, IndentKind, ResolvedConfig, resolve_config_for_path}; + +#[derive(Debug, Clone, Parser)] +#[command( + name = "luafmt", + version, + about = "Format Lua source code with structured EmmyLua formatter settings", + disable_help_subcommand = true +)] +#[command(group( + ArgGroup::new("indent_choice") + .args(["tab", "spaces"]) + .multiple(false) +))] +pub struct CliArgs { + /// Input paths to format (files only). If omitted, reads from stdin. + #[arg(value_name = "PATH", value_hint = clap::ValueHint::FilePath)] + pub paths: Vec, + + /// Read source from stdin instead of files + #[arg(long)] + pub stdin: bool, + + /// Write formatted result back to the file(s) + #[arg(long)] + pub write: bool, + + /// Check if files would be reformatted. Exit with code 1 if any would change. + #[arg(long)] + pub check: bool, + + /// Print paths of files that would be reformatted + #[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, + + /// 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, + + /// Use N spaces for indentation (mutually exclusive with --tab) + #[arg(long, value_name = "N")] + pub spaces: Option, + + /// 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, +} + +#[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), + 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) => 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 { + resolved.config.layout.max_line_width = w; + } + + 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 new file mode 100644 index 000000000..8d606630c --- /dev/null +++ b/crates/emmylua_formatter/src/config/mod.rs @@ -0,0 +1,274 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct LuaFormatConfig { + pub indent: IndentConfig, + pub layout: LayoutConfig, + pub output: OutputConfig, + pub spacing: SpacingConfig, + pub comments: CommentConfig, + pub emmy_doc: EmmyDocConfig, + pub align: AlignConfig, +} + +impl LuaFormatConfig { + pub fn indent_width(&self) -> usize { + self.indent.width + } + + 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_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 + } + + 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 + } + + 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)] +#[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, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +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, +} + +impl Default for OutputConfig { + fn default() -> Self { + 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, + } + } +} + +#[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, + pub space_around_math_operator: bool, + pub space_around_concat_operator: bool, + pub space_around_assign_operator: bool, +} + +impl Default for SpacingConfig { + fn default() -> Self { + Self { + 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, + } + } +} + +#[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_call_args: bool, + 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, +} + +impl Default for CommentConfig { + fn default() -> Self { + Self { + 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, + space_after_comment_dash: true, + line_comment_min_spaces_before: 1, + line_comment_min_column: 0, + } + } +} + +#[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, + } + } +} + +#[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: false, + table_field: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum IndentKind { + Tab, + Space, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum TrailingComma { + Never, + Multiline, + 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, + Always, + Auto, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum EndOfLine { + LF, + CRLF, +} diff --git a/crates/emmylua_formatter/src/formatter/expr.rs b/crates/emmylua_formatter/src/formatter/expr.rs new file mode 100644 index 000000000..d64981e08 --- /dev/null +++ b/crates/emmylua_formatter/src/formatter/expr.rs @@ -0,0 +1,2190 @@ +use emmylua_parser::{ + 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, QuoteStyle, SingleArgCallParens, TrailingComma}; +use crate::ir::{self, AlignEntry, DocIR}; + +use super::FormatContext; +use super::model::{ExprSequenceLayoutPlan, RootFormatPlan, TokenSpacingExpected}; +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), + } +} + +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(ctx, 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); + 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: 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.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 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(ctx: &FormatContext, 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(DelimitedComment { + docs, + same_line_after_open: has_non_trivia_before_on_same_line_tokenwise( + comment.syntax(), + ), + }); + } 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; + } + 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); + } + 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), + trailing_align_hint, + }); + 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 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()) + }), + ); + + 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; + } + } + 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()); + } + 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_for_width( + ctx, + crate::ir::ir_flat_width(&line_content), + trailing_widths[index], + ); + 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 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 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 { + 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: 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.output.trailing_comma.clone()), + }, + ) +} + +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_before_close: Vec>, + has_comments: bool, + consumed_comment_ranges: Vec, +} + +struct CallArgEntry { + leading_comments: Vec>, + doc: Vec, + trailing_comment: Option>, + trailing_align_hint: bool, +} + +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(DelimitedComment { + docs, + same_line_after_open: has_non_trivia_before_on_same_line_tokenwise( + comment.syntax(), + ), + }); + } 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; + } + 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); + } + 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), + trailing_align_hint, + }); + 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 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()) + }), + ); + + 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; + } + } + 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()); + } + 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_for_width( + ctx, + crate::ir::ir_flat_width(&line_content), + trailing_widths[index], + ); + 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); + 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); + } + + let field_docs: Vec> = collected + .entries + .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()); + + 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.clone(), + 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()), + }; + + 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()), + }, + ) +} + +#[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, + eq_split: Option, + align_hint: bool, + comment_align_hint: bool, + 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(DelimitedComment { + docs, + same_line_after_open: has_non_trivia_before_on_same_line_tokenwise( + comment.syntax(), + ), + }); + } 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); + } + 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; + } + } + + 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 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(); + + 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; + } + } + + 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); + } + + 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_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, + 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 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![]), + 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 { + 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( + 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/layout/mod.rs b/crates/emmylua_formatter/src/formatter/layout/mod.rs new file mode 100644 index 000000000..8a3725992 --- /dev/null +++ b/crates/emmylua_formatter/src/formatter/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::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::spacing::analyze_root_spacing( + &crate::formatter::FormatContext::new(&config), + &chunk, + ); + let plan = analyze_root_layout( + &crate::formatter::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::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 + .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::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 + .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/layout/tree.rs b/crates/emmylua_formatter/src/formatter/layout/tree.rs new file mode 100644 index 000000000..9fa9fae7f --- /dev/null +++ b/crates/emmylua_formatter/src/formatter/layout/tree.rs @@ -0,0 +1,28 @@ +use emmylua_parser::{LuaAstNode, LuaChunk, LuaSyntaxId, LuaSyntaxKind, LuaSyntaxNode}; + +use crate::formatter::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/line_breaks.rs b/crates/emmylua_formatter/src/formatter/line_breaks.rs new file mode 100644 index 000000000..298dac6ae --- /dev/null +++ b/crates/emmylua_formatter/src/formatter/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/mod.rs b/crates/emmylua_formatter/src/formatter/mod.rs new file mode 100644 index 000000000..b299d5976 --- /dev/null +++ b/crates/emmylua_formatter/src/formatter/mod.rs @@ -0,0 +1,29 @@ +mod expr; +mod layout; +mod line_breaks; +mod model; +mod render; +mod sequence; +mod spacing; +mod trivia; + +use crate::config::LuaFormatConfig; +use crate::ir::DocIR; +use emmylua_parser::LuaChunk; + +pub struct FormatContext<'a> { + pub config: &'a LuaFormatConfig, +} + +impl<'a> FormatContext<'a> { + pub fn new(config: &'a LuaFormatConfig) -> Self { + Self { config } + } +} + +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/model.rs b/crates/emmylua_formatter/src/formatter/model.rs new file mode 100644 index 000000000..7c77890f3 --- /dev/null +++ b/crates/emmylua_formatter/src/formatter/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/render.rs b/crates/emmylua_formatter/src/formatter/render.rs new file mode 100644 index 000000000..f8fe9d1bd --- /dev/null +++ b/crates/emmylua_formatter/src/formatter/render.rs @@ -0,0 +1,2826 @@ +use emmylua_parser::{ + 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, AlignEntry, 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, trailing_gap_requests_alignment, +}; + +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_aligned_block_layout_nodes_new( + ctx, + chunk.syntax(), + &plan.layout.root_nodes, + plan, + )); + } + + if plan.line_breaks.insert_final_newline { + docs.push(DocIR::HardLine); + } + + 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_aligned_block_layout_nodes_new(ctx, root, &syntax_plan.children, plan) + } + 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), + 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), + }, + } +} + +struct StatementAssignSplit { + lhs_entries: Vec, + assign_op: Option, + rhs_entries: Vec, +} + +type DocPair = (Vec, Vec); +type RenderedTrailingComment = (Vec, TextRange, bool); + +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, + StatementExprListLayoutKind::PreserveFirstMultiline + ), + ) + }) + .collect(); + + docs.extend(render_statement_exprs_new( + ctx, + plan, + expr_list_plan, + assign_token.as_ref(), + comma_token.as_ref(), + expr_docs, + )); + } + + append_trailing_comment_suffix_new(ctx, plan, &mut docs, stat.syntax()); + + 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, + StatementExprListLayoutKind::PreserveFirstMultiline + ), + ) + }) + .collect(); + + docs.extend(render_statement_exprs_new( + ctx, + plan, + expr_list_plan, + assign_token.as_ref(), + comma_token.as_ref(), + expr_docs, + )); + + append_trailing_comment_suffix_new(ctx, plan, &mut docs, stat.syntax()); + + 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, + StatementExprListLayoutKind::PreserveFirstMultiline + ), + ) + }) + .collect(); + + docs.extend(render_statement_exprs_new( + ctx, + plan, + expr_list_plan, + return_token.as_ref(), + comma_token.as_ref(), + expr_docs, + )); + } + + append_trailing_comment_suffix_new(ctx, plan, &mut docs, stat.syntax()); + + 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(); + }; + + 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( + 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, + 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, + 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(); + }; + + 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 + .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 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, + 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 { + return vec![ir::source_node_trimmed(stat.syntax().clone())]; + } + + 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); + } + } + } + + append_trailing_comment_suffix_new(ctx, plan, &mut docs, stat.syntax()); + + 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); + } + } + } + + append_trailing_comment_suffix_new(ctx, plan, &mut docs, stat.syntax()); + + 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); + } + + append_trailing_comment_suffix_new(ctx, plan, &mut docs, stat.syntax()); + + 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()) + { + 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( + 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()) + { + 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( + 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()) + { + 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( + 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( + 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, + StatementExprListLayoutKind::PreserveFirstMultiline + ) { + format_statement_expr_list_with_attached_first_multiline_new( + comma_token, + leading_docs, + expr_docs, + ) + } else { + format_statement_expr_list( + 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); + 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, + expr_docs, + ) + } else { + format_statement_expr_list( + 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 syntax_has_descendant_comment_new(stat.syntax()) { + return true; + } + + 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 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 +} + +fn render_control_body_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + syntax_plan: &SyntaxNodeLayoutPlan, + plan: &RootFormatPlan, +) -> Vec { + let block_children = block_children_from_parent_plan(syntax_plan); + + 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 = 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, + }) +} + +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 { + 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(rendered_children); + 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_aligned_block_layout_nodes_new( + ctx: &FormatContext, + root: &LuaSyntaxNode, + nodes: &[LayoutNodePlan], + plan: &RootFormatPlan, +) -> Vec { + let mut docs = Vec::new(); + let mut index = 0usize; + + while index < nodes.len() { + if layout_comment_is_inline_trailing_new(root, nodes, index) { + index += 1; + continue; + } + + 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()); + } + } + + 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; + } + + docs.extend(render_layout_node(ctx, root, &nodes[index], plan)); + index += 1; + } + + docs +} + +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() + } + 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) || should_preserve_doc_comment_block_raw(comment) { + return vec![ir::source_node_trimmed(comment.syntax().clone())]; + } + + 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() + .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() +} + +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)>, + 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 { + if comment.syntax().text().to_string().starts_with("----") { + return true; + } + 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/sequence.rs b/crates/emmylua_formatter/src/formatter/sequence.rs new file mode 100644 index 000000000..dced2f223 --- /dev/null +++ b/crates/emmylua_formatter/src/formatter/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/spacing.rs b/crates/emmylua_formatter/src/formatter/spacing.rs new file mode 100644 index 000000000..3b85592b9 --- /dev/null +++ b/crates/emmylua_formatter/src/formatter/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/trivia.rs b/crates/emmylua_formatter/src/formatter/trivia.rs new file mode 100644 index 000000000..e0b10dc8a --- /dev/null +++ b/crates/emmylua_formatter/src/formatter/trivia.rs @@ -0,0 +1,150 @@ +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 { + 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 +} + +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::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 +} + +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/builder.rs b/crates/emmylua_formatter/src/ir/builder.rs new file mode 100644 index 000000000..620ebd33d --- /dev/null +++ b/crates/emmylua_formatter/src/ir/builder.rs @@ -0,0 +1,126 @@ +use smol_str::SmolStr; +use std::rc::Rc; + +use emmylua_parser::{LuaSyntaxNode, LuaSyntaxToken, LuaTokenKind}; + +use super::{AlignEntry, AlignGroupData, DocIR, GroupId}; + +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 +} + +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..67e71afc1 --- /dev/null +++ b/crates/emmylua_formatter/src/ir/doc_ir.rs @@ -0,0 +1,231 @@ +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 +#[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), + + /// 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, + + /// 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. + /// `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>, + }, +} + +/// Compute the flat (single-line) width of an IR slice. +/// +/// 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), + 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 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 { + 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/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 new file mode 100644 index 000000000..05ead1543 --- /dev/null +++ b/crates/emmylua_formatter/src/lib.rs @@ -0,0 +1,46 @@ +#![cfg(feature = "cli")] +pub mod cmd_args; +pub mod config; +mod formatter; +pub mod ir; +mod printer; +mod test; +mod workspace; + +use emmylua_parser::{LuaChunk, LuaLanguageLevel, LuaParser, ParserConfig}; +use formatter::FormatContext; +use printer::Printer; + +pub use config::{ + AlignConfig, CommentConfig, EmmyDocConfig, EndOfLine, ExpandStrategy, IndentConfig, IndentKind, + LayoutConfig, LuaFormatConfig, OutputConfig, QuoteStyle, SingleArgCallParens, SpacingConfig, + TrailingComma, TrailingTableSeparator, +}; +pub use workspace::{ + 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, +}; + +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(); + let ir = formatter::format_chunk(&ctx, &chunk); + + 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 new file mode 100644 index 000000000..d3d7e785d --- /dev/null +++ b/crates/emmylua_formatter/src/printer/mod.rs @@ -0,0 +1,508 @@ +mod test; + +use std::collections::HashMap; + +use crate::config::LuaFormatConfig; +use crate::ir::{AlignEntry, DocIR, GroupId, ir_flat_width, syntax_text_trimmed_end}; + +#[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, + line_comment_min_spaces_before: usize, + line_comment_min_column: usize, + 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.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, + 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::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(" "); + } + 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_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(' '); + 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); + } + } + + 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); + 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::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; + } + 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, + 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, 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)); + } + } + } + } + } + } + } + } + + 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 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) { + // 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, + }) + .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, + trailing, + } => { + 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); + + if let Some(trail) = trailing { + let content_width = max_before + 1 + ir_flat_width(after); + let trail_padding = + self.trailing_comment_padding(content_width, max_content_width); + if trail_padding > 0 { + self.push_text(&" ".repeat(trail_padding)); + } + self.print_docs(trail, mode); + } + } + AlignEntry::Line { content, trailing } => { + self.print_docs(content, mode); + + if let Some(trail) = trailing { + let content_width = ir_flat_width(content); + let trail_padding = + self.trailing_comment_padding(content_width, max_content_width); + if trail_padding > 0 { + self.push_text(&" ".repeat(trail_padding)); + } + self.print_docs(trail, 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..b0b6f8b12 --- /dev/null +++ b/crates/emmylua_formatter/src/printer/test.rs @@ -0,0 +1,82 @@ +#[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 { + layout: crate::config::LayoutConfig { + max_line_width: 10, + ..Default::default() + }, + ..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/test/breaking_tests.rs b/crates/emmylua_formatter/src/test/breaking_tests.rs new file mode 100644 index 000000000..6342d7843 --- /dev/null +++ b/crates/emmylua_formatter/src/test/breaking_tests.rs @@ -0,0 +1,103 @@ +#[cfg(test)] +mod tests { + use crate::{ + assert_format_with_config, + config::{LayoutConfig, LuaFormatConfig}, + }; + + #[test] + fn test_long_binary_expr_breaking() { + let config = LuaFormatConfig { + layout: LayoutConfig { + max_line_width: 80, + ..Default::default() + }, + ..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 { + layout: LayoutConfig { + max_line_width: 60, + ..Default::default() + }, + ..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 { + layout: LayoutConfig { + max_line_width: 60, + ..Default::default() + }, + ..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 + ); + } + + #[test] + fn test_multiline_table_input_reflows_in_auto_mode_when_width_allows() { + 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 = { a = 1, b = 2 }\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 new file mode 100644 index 000000000..adf33d942 --- /dev/null +++ b/crates/emmylua_formatter/src/test/comment_tests.rs @@ -0,0 +1,1231 @@ +#[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_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!( + 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::{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 = 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 +"# + ); + } + + #[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 +"# + ); + } + + #[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] + 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_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_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!( + r#" +local f = function( + a, -- first + b -- second +) + return a + b +end +"#, + r#" +local f = function( + a, -- first + b -- second +) + return a + b +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() { + 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 +local cc = 3 -- medium +"#, + r#" +local a = 1 -- short +local bbb = 2 -- long var +local cc = 3 -- medium +"#, + config + ); + } + + #[test] + fn test_assign_alignment() { + 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 +local zzz = 3 +"#, + r#" +local x = 1 +local yy = 2 +local zzz = 3 +"#, + config + ); + } + + #[test] + fn test_table_field_alignment() { + 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 + ); + } + + #[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}; + + let config = LuaFormatConfig { + 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!( + r#" +local a = 1 -- x +local bbb = 2 -- y +"#, + r#" +local a = 1 -- x +local bbb = 2 -- y +"#, + config + ); + } + + #[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_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}; + + 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 { + align_in_statements: true, + align_across_standalone_comments: true, + 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() { + 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 + +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 +"#, + config + ); + } + + #[test] + fn test_alignment_group_preserves_standalone_comment() { + 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 +local long_name = 2 -- y +"#, + r#" +local a = 1 -- x +-- divider +local long_name = 2 -- y +"#, + config + ); + } + + #[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_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!( + 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_in_statements: true, + 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 + ); + } + + #[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] + 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_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!( + "---@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_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_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!( + "---@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_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!( + "---@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_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}; + + 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_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!( + "--- spaced words\nlocal value = nil\n", + "--- spaced words\nlocal value = nil\n" + ); + } + + #[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" + ); + } + + #[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_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!( + "---@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) + 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 new file mode 100644 index 000000000..e58cecc75 --- /dev/null +++ b/crates/emmylua_formatter/src/test/config_tests.rs @@ -0,0 +1,602 @@ +#[cfg(test)] +mod tests { + use crate::{ + assert_format_with_config, + config::{ + EndOfLine, ExpandStrategy, IndentConfig, IndentKind, LayoutConfig, LuaFormatConfig, + OutputConfig, QuoteStyle, SingleArgCallParens, SpacingConfig, TrailingComma, + TrailingTableSeparator, + }, + }; + + // ========== spacing options ========== + + #[test] + fn test_space_before_func_paren() { + let config = LuaFormatConfig { + spacing: SpacingConfig { + space_before_func_paren: true, + ..Default::default() + }, + ..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 { + spacing: SpacingConfig { + space_before_call_paren: true, + ..Default::default() + }, + ..Default::default() + }; + assert_format_with_config!("print(1)\n", "print (1)\n", config); + } + + #[test] + fn test_space_inside_parens() { + let config = LuaFormatConfig { + 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); + } + + #[test] + fn test_space_inside_braces() { + let config = LuaFormatConfig { + 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); + } + + #[test] + fn test_no_space_inside_braces() { + let config = LuaFormatConfig { + 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); + } + + // ========== table expand strategy ========== + + #[test] + fn test_table_expand_always() { + let config = LuaFormatConfig { + layout: LayoutConfig { + table_expand: ExpandStrategy::Always, + ..Default::default() + }, + ..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 { + layout: LayoutConfig { + table_expand: ExpandStrategy::Never, + ..Default::default() + }, + ..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 { + output: OutputConfig { + trailing_comma: TrailingComma::Always, + ..Default::default() + }, + layout: LayoutConfig { + table_expand: ExpandStrategy::Always, + ..Default::default() + }, + ..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 { + output: OutputConfig { + trailing_comma: TrailingComma::Never, + ..Default::default() + }, + layout: LayoutConfig { + table_expand: ExpandStrategy::Always, + ..Default::default() + }, + ..Default::default() + }; + assert_format_with_config!( + r#" +local t = { +a = 1, +b = 2, +} +"#, + r#" +local t = { + a = 1, + b = 2 +} +"#, + config + ); + } + + #[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] + fn test_tab_indent() { + let config = LuaFormatConfig { + indent: IndentConfig { + kind: IndentKind::Tab, + ..Default::default() + }, + ..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 { + layout: LayoutConfig { + max_blank_lines: 1, + ..Default::default() + }, + ..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 { + output: OutputConfig { + end_of_line: EndOfLine::CRLF, + ..Default::default() + }, + ..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 + ); + } + + // ========== operator spacing options ========== + + #[test] + fn test_no_space_around_math_operator() { + let config = LuaFormatConfig { + spacing: SpacingConfig { + space_around_math_operator: false, + ..Default::default() + }, + ..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 { + 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); + } + + #[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 { + spacing: SpacingConfig { + space_around_concat_operator: false, + ..Default::default() + }, + ..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 { + 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); + } + + #[test] + fn test_no_math_space_keeps_logical_space() { + // Disabling math operator spaces should NOT affect logical operators + let config = LuaFormatConfig { + spacing: SpacingConfig { + space_around_math_operator: false, + ..Default::default() + }, + ..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 { + spacing: SpacingConfig { + space_around_assign_operator: false, + ..Default::default() + }, + ..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 { + 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); + } + + #[test] + 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" + +[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 + +[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_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/crates/emmylua_formatter/src/test/expression_tests.rs b/crates/emmylua_formatter/src/test/expression_tests.rs new file mode 100644 index 000000000..f65e244d4 --- /dev/null +++ b/crates/emmylua_formatter/src/test/expression_tests.rs @@ -0,0 +1,579 @@ +#[cfg(test)] +mod tests { + // ========== unary / binary / concat ========== + + use crate::{ + assert_format, assert_format_with_config, + config::{LayoutConfig, LuaFormatConfig}, + }; + + #[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"); + } + + #[test] + fn test_multiline_binary_layout_reflows_when_width_allows() { + assert_format!( + "local result = first\n + second\n + third\n", + "local result = first + second + 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_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 { + 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 = 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 + ); + } + + #[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] + fn test_index_expr() { + assert_format!( + r#" +local a = t.x +local b = t[1] +"#, + r#" +local a = t.x +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] + 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_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"); + } + + #[test] + fn test_multiline_table_layout_reflows_when_width_allows() { + assert_format!( + "local t = {\n a = 1,\n b = 2,\n}\n", + "local t = { a = 1, b = 2 }\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] + 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"); + } + + #[test] + fn test_call_expr_preserves_inline_comment_in_args() { + 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_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!( + "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" + ); + } + + #[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!( + "some_function(\n first,\n second,\n third\n)\n", + "some_function(first, second, third)\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 + ); + } + + #[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_callback_arg_with_multiline_closure_breaks_one_arg_per_line() { + assert_format!( + "check(function()\n return not not k3\nend, 'LOADTRUE', 'RETURN1')\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 + ); + } + + #[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 + ); + } + + #[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] + 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"); + } + + #[test] + fn test_multiline_chain_layout_reflows_when_width_allows() { + assert_format!( + "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 + ); + } + + #[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 + ); + } + + #[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] + 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..df8524c85 --- /dev/null +++ b/crates/emmylua_formatter/src/test/misc_tests.rs @@ -0,0 +1,594 @@ +#[cfg(test)] +mod tests { + use emmylua_parser::LuaLanguageLevel; + + use crate::{SourceText, assert_format, config::LuaFormatConfig, reformat_lua_code}; + + // ========== 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"); + } + + // ========== 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] + 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( + &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}" + ); + } + + #[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 = 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}" + ); + } + + #[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( + &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}" + ); + } + + #[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( + &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}" + ); + } + + #[test] + fn test_idempotency_method_chain() { + let config = LuaFormatConfig { + 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( + &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}" + ); + } + + #[test] + fn test_idempotency_shebang() { + let config = LuaFormatConfig::default(); + let input = "#!/usr/bin/lua\nlocal a = 1\n"; + + 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}" + ); + } + + #[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(&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(&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(&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(&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(&source, &config); + let second = reformat_lua_code( + &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(&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(&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(&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(&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(&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(&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(&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(&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(&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(&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(&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(&source, &config); + let second = reformat_lua_code( + &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(&source, &config); + let second = reformat_lua_code( + &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(&source, &config); + let second = reformat_lua_code( + &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(&source, &config); + let second = reformat_lua_code( + &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(&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(&source, &config); + let second = reformat_lua_code( + &SourceText { + text: &first, + level: LuaLanguageLevel::default(), + }, + &config, + ); + + assert_eq!(first, second); + } +} diff --git a/crates/emmylua_formatter/src/test/mod.rs b/crates/emmylua_formatter/src/test/mod.rs new file mode 100644 index 000000000..66f1cbfd3 --- /dev/null +++ b/crates/emmylua_formatter/src/test/mod.rs @@ -0,0 +1,7 @@ +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..8a739e46a --- /dev/null +++ b/crates/emmylua_formatter/src/test/statement_tests.rs @@ -0,0 +1,907 @@ +#[cfg(test)] +mod tests { + // ========== if statement ========== + + use crate::{ + assert_format, assert_format_with_config, + config::{LayoutConfig, LuaFormatConfig}, + }; + + #[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 +"# + ); + } + + #[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_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!( + "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, 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\n + epsilon + zeta then\n print(result)\nend\n", + config + ); + } + + #[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_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!( + "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] + 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 +"# + ); + } + + #[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" + ); + } + + #[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,\n very_long_stop_expr, 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 + ); + } + + #[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 { + 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] + 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_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_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!( + r#" +repeat +x = x + 1 +until x > 10 +"#, + r#" +repeat + x = x + 1 +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!( + 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_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(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 + ); + } + + #[test] + fn test_varargs_closure() { + assert_format!( + r#" +local f = function(...) +return ... +end +"#, + r#" +local f = function(...) + return ... +end +"# + ); + } + + #[test] + 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(first, second)\n return first + second\nend\n" + ); + } + + // ========== 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 +"# + ); + } + + #[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 +"# + ); + } + + #[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_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 { + 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 + ); + } + + #[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] + 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" + ); + } + + #[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"); + } + + #[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_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(\n a, -- first\n b\n)\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_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 { + 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!( + "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] + 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..068a735ac --- /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::format_text(input, &$config).formatted; + 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) + }}; +} diff --git a/crates/emmylua_formatter/src/workspace.rs b/crates/emmylua_formatter/src/workspace.rs new file mode 100644 index 000000000..9f0290657 --- /dev/null +++ b/crates/emmylua_formatter/src/workspace.rs @@ -0,0 +1,672 @@ +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 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, + pub output: FormatOutput, + 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, + 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 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; + 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( + code: &str, + source_path: Option<&Path>, + explicit_config_path: Option<&Path>, +) -> Result { + 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(), + output, + config_path: resolved.source_path, + }) +} + +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 = check_text(&source, &resolved.config); + Ok(FormatCheckPathResult { + 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() +} + +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}; + + 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(); + } + + #[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 + } + ); + } +} diff --git a/crates/emmylua_parser/src/kind/lua_token_kind.rs b/crates/emmylua_parser/src/kind/lua_token_kind.rs index 0f5e3ce1f..659a5bb3f 100644 --- a/crates/emmylua_parser/src/kind/lua_token_kind.rs +++ b/crates/emmylua_parser/src/kind/lua_token_kind.rs @@ -173,6 +173,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, diff --git a/crates/emmylua_parser/src/syntax/node/doc/test.rs b/crates/emmylua_parser/src/syntax/node/doc/test.rs index b2de6fecf..64810b118 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,43 @@ 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() + ), + ] + ); + } } 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..6be343779 --- /dev/null +++ b/docs/emmylua_formatter/examples_CN.md @@ -0,0 +1,241 @@ +# EmmyLua Formatter 效果示例 + +[English](./examples_EN.md) + +本页按场景展示当前格式化器的典型布局结果。示例重点不是“所有代码都会变成同一种样子”,而是说明 formatter 会怎样在 flat、fill、packed、aligned 与 one-per-line 之间做选择。 + +## 1. 基础单行规整 + +### 能放一行时保持单行 + +Before: + +```lua +local point={x=1,y=2} +``` + +After: + +```lua +local point = { x = 1, y = 2 } +``` + +小而稳定的结构会优先保持单行,只做空格、逗号和分隔符的规范化。 + +## 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 +) +``` + +这种布局会尽量保持紧凑,而不是一开始就退到一项一行。 + +### 嵌套调用只让外层换行,内层保持紧凑 + +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: + +```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 +``` + +第一项仍然贴在关键字所在行,后续项按更均衡的方式打包,而不是简单退到一项一行。 + +### 必要时退到一段一行 + +Before: + +```lua +builder:set_name(name):set_age(age):build() +``` + +After: + +```lua +builder + :set_name(name) + :set_age(age) + :build() +``` + +当 fill 或 packed 的结果明显更差时,格式化器仍然会退到更窄的一段一行布局。 + +## 5. 注释与保守策略 + +### 注释对齐是输入驱动的 + +Before: + +```lua +foo( + alpha, -- first + beta -- second +) +``` + +After: + +```lua +foo( + alpha, -- first + beta -- second +) +``` + +只有当输入已经体现出对齐意图时,格式化器才会对齐尾随注释;它不会在无关代码中主动制造宽对齐块。 + +### 语句头部的 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 new file mode 100644 index 000000000..e2647d4ca --- /dev/null +++ b/docs/emmylua_formatter/examples_EN.md @@ -0,0 +1,241 @@ +# EmmyLua Formatter Examples + +[中文文档](./examples_CN.md) + +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. + +## 1. Basic Flat Formatting + +### Flat when it fits + +Before: + +```lua +local point={x=1,y=2} +``` + +After: + +```lua +local point = { x = 1, y = 2 } +``` + +Small stable structures stay on one line, with spacing and separators normalized. + +## 2. Calls And Parameter Lists + +### 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. + +### 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: + +```lua +local value = aaaa + bbbb + cccc + dddd + eeee + ffff +``` + +After: + +```lua +local value = aaaa + bbbb + + cccc + dddd + + eeee + ffff +``` + +Binary-chain candidates are scored with the real first-line prefix width, so long anchors such as local value = affect candidate selection. + +### Statement expression lists also use balanced packed layouts + +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 keeps the first item attached to the keyword line and then packs later items more evenly. + +### 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 fill or packed layouts are clearly worse, the formatter still falls back to one segment per line. + +## 5. Comments And Conservative Preservation + +### Comment alignment is input-driven + +Before: + +```lua +foo( + alpha, -- first + beta -- second +) +``` + +After: + +```lua +foo( + alpha, -- first + beta -- second +) +``` + +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 new file mode 100644 index 000000000..65160456d --- /dev/null +++ b/docs/emmylua_formatter/options_CN.md @@ -0,0 +1,194 @@ +# 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` +- `trailing_table_separator`:`Inherit`、`Never`、`Multiline`、`Always` +- `quote_style`:`Preserve`、`Double`、`Single` +- `single_arg_call_parens`:`Preserve`、`Always`、`Omit` +- `end_of_line`:`LF` 或 `CRLF` + +默认值: + +```toml +[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` +- `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` +- `space_after_comment_dash` +- `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 +space_after_comment_dash = true +line_comment_min_spaces_before = 1 +line_comment_min_column = 0 +``` + +行为说明: + +- statement 尾随注释对齐默认关闭。 +- table、调用参数、函数参数中的尾随注释对齐是输入驱动的;只有源代码已经体现出额外空格的对齐意图时,才会启用。 +- standalone comment 默认会打断对齐分组。 +- table 字段尾随注释只在连续子组内部对齐,不会拖动整个表体。 +- `space_after_comment_dash` 只会在普通 `--comment` 这类“前缀后完全没有空格”的情况下补一个空格;已有多个空格的注释会保留原样。 + +## 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..5531b1198 --- /dev/null +++ b/docs/emmylua_formatter/options_EN.md @@ -0,0 +1,194 @@ +# 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` +- `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: + +```toml +[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` +- `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` +- `space_after_comment_dash` +- `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 +space_after_comment_dash = true +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. +- `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 + +- `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..dace30b35 --- /dev/null +++ b/docs/emmylua_formatter/profiles_CN.md @@ -0,0 +1,137 @@ +# 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" + +[output] +quote_style = "Preserve" +trailing_table_separator = "Multiline" +single_arg_call_parens = "Preserve" + +[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" + +[output] +quote_style = "Double" +trailing_table_separator = "Multiline" +single_arg_call_parens = "Always" + +[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" + +[output] +quote_style = "Preserve" +trailing_table_separator = "Multiline" +single_arg_call_parens = "Preserve" + +[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..a99505eae --- /dev/null +++ b/docs/emmylua_formatter/profiles_EN.md @@ -0,0 +1,137 @@ +# 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" + +[output] +quote_style = "Preserve" +trailing_table_separator = "Multiline" +single_arg_call_parens = "Preserve" + +[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" + +[output] +quote_style = "Double" +trailing_table_separator = "Multiline" +single_arg_call_parens = "Always" + +[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" + +[output] +quote_style = "Preserve" +trailing_table_separator = "Multiline" +single_arg_call_parens = "Preserve" + +[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..04d5c292a --- /dev/null +++ b/docs/emmylua_formatter/tutorial_CN.md @@ -0,0 +1,149 @@ +# 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" + +[output] +quote_style = "Preserve" +trailing_table_separator = "Multiline" +single_arg_call_parens = "Preserve" + +[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`。 + +如果你希望只让竖排 table 默认带尾逗号,但不影响调用参数和函数参数,可以只设置 `output.trailing_table_separator = "Multiline"`。 + +如果你希望统一短字符串引号,可以设置 `output.quote_style = "Double"` 或 `"Single"`。长字符串会继续保留原样。 + +## 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..24ad77758 --- /dev/null +++ b/docs/emmylua_formatter/tutorial_EN.md @@ -0,0 +1,149 @@ +# 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" + +[output] +quote_style = "Preserve" +trailing_table_separator = "Multiline" +single_arg_call_parens = "Preserve" + +[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. + +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: + +```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.