From adaa4ef4d8b6eb1aa6029c826ae072af99e0ad21 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sat, 8 Aug 2026 22:06:02 +0900 Subject: [PATCH 1/6] fix(formatter): align CL/Elisp indentation with Emacs conventions - Add Elisp dialect routing (elisp_style_for_head) recognizing 14 operators (defvar, defconst, defcustom, save-excursion, while, condition-case, etc.) - Add 13 missing CL operators to string-match fallback (progv, multiple-value-call, return-from, throw, generic-flet, etc.) - Implement Emacs align-under-first-arg convention in format_general_list - Expand reindenter BODY_FORMS from 30 to 70 entries covering all cl-indent.el specs and Emacs Lisp lisp-mode.el put declarations --- .../src/sexpr/formatter/lists/general.rs | 51 ++++++++++++++--- .../core/syntax/src/sexpr/formatter/styles.rs | 38 ++++++++++++- packages/core/syntax/src/sexpr/reindent.rs | 56 ++++++++++++++++++- .../core/syntax/src/sexpr/tests/formatter.rs | 15 +++-- 4 files changed, 141 insertions(+), 19 deletions(-) diff --git a/packages/core/syntax/src/sexpr/formatter/lists/general.rs b/packages/core/syntax/src/sexpr/formatter/lists/general.rs index 1bfcd9f2..8c1a7d7c 100644 --- a/packages/core/syntax/src/sexpr/formatter/lists/general.rs +++ b/packages/core/syntax/src/sexpr/formatter/lists/general.rs @@ -14,17 +14,54 @@ impl Formatter { ) { let node = tree.node(node_id); let delimiter = self.list_delimiter(node); - // Every element lines up under the first one, which lands one column - // past the opening delimiter. Indenting the rest by `indent` instead - // put them `indent - 1` columns right of their own first sibling. - let element_column = Self::last_line_width(output).saturating_add(1); + let base_column = Self::last_line_width(output); output.push(delimiter.open()); + + // When the first argument shares the head's line, subsequent + // arguments align under it (Emacs `lisp-indent-function` default). + // Otherwise every argument lands one column past the opening + // delimiter — the same fallback `reindent.rs` uses. + let mut first_arg_column: Option = None; + let mut head_line_count: Option = None; + for (position, child) in node.children.iter().enumerate() { - if position > 0 { - Self::break_to_column(element_column, output); + match position { + 0 => { + self.format_node(tree, *child, depth + 1, output); + head_line_count = Some(output.lines().count()); + } + 1 => { + // If the first argument fits inline on the head line, + // align subsequent siblings under it. Otherwise break + // to one column past the opening delimiter. + let child_start = + Self::last_line_width(output).saturating_add(1); + if let Some(inline) = + self.compact_node(tree, *child, child_start) + { + output.push(' '); + let col = Self::last_line_width(output); + output.push_str(&inline); + if head_line_count + .is_some_and(|hl| output.lines().count() == hl) + { + first_arg_column = Some(col); + } + } else { + let element_column = base_column.saturating_add(1); + Self::break_to_column(element_column, output); + self.format_node(tree, *child, depth + 1, output); + } + } + _ => { + let element_column = first_arg_column + .unwrap_or(base_column.saturating_add(1)); + Self::break_to_column(element_column, output); + self.format_node(tree, *child, depth + 1, output); + } } - self.format_node(tree, *child, depth + 1, output); } + output.push(delimiter.close()); } diff --git a/packages/core/syntax/src/sexpr/formatter/styles.rs b/packages/core/syntax/src/sexpr/formatter/styles.rs index f9fe4c46..a2f4c85f 100644 --- a/packages/core/syntax/src/sexpr/formatter/styles.rs +++ b/packages/core/syntax/src/sexpr/formatter/styles.rs @@ -129,6 +129,7 @@ impl Formatter { } match self.dialect { Dialect::Clojure => Self::clojure_style_for_head(head), + Dialect::EmacsLisp => Self::elisp_style_for_head(head), _ => Self::common_lisp_style_for_head(head), } } @@ -155,6 +156,33 @@ impl Formatter { } } + /// Emacs Lisp dialect routing: recognizes Elisp-specific operators that + /// differ from Common Lisp conventions. Falls back to the CL table for + /// everything else. + fn elisp_style_for_head(head: &str) -> ListStyle { + let normalized_head = normalize_common_lisp_operator_head(head); + match normalized_head.to_ascii_lowercase().as_str() { + // def* forms with 'defun indent → name on head line, body indented + "defvar" | "defconst" | "defcustom" | "defgroup" | "defalias" + | "defvaralias" | "define-derived-mode" | "define-minor-mode" => { + ListStyle::DefinitionNameBody + } + // progn-like (indent 0) → all children at body-indent + "save-excursion" | "save-restriction" | "save-current-buffer" + | "track-mouse" => ListStyle::HeadBody, + // indent 1 → one arg on head line, body indented + "while" => ListStyle::OneArgumentBody, + // indent 2 → two-component special (then at +4, else at +2) + "condition-case" => ListStyle::TwoArgumentBody, + // if in Elisp: test at +4 distinguished, then/else at body + "if" => ListStyle::If, + // Elisp-specific clause forms + "pcase" => ListStyle::CaseClauses, + "cl-loop" => ListStyle::Loop, + _ => Self::common_lisp_style_for_head(head), + } + } + fn common_lisp_style_for_head(head: &str) -> ListStyle { if let Some(operator) = CommonLispOperator::from_head(head) { match operator { @@ -211,7 +239,15 @@ impl Formatter { ListStyle::HeadBody } "declare" | "declaim" | "proclaim" => ListStyle::Declaration, - "setq" | "psetq" | "setf" | "psetf" => ListStyle::PairAssignment, + "setq" | "psetq" | "setf" | "psetf" | "multiple-value-setq" + | "multiple-value-setf" => ListStyle::PairAssignment, + "multiple-value-call" | "multiple-value-prog1" | "pprint-logical-block" + | "with-compilation-unit" | "with-standard-io-syntax" + | "return-from" | "throw" => ListStyle::OneArgumentBody, + "progv" | "with-condition-restarts" | "print-unreadable-object" => { + ListStyle::TwoArgumentBody + } + "generic-flet" | "generic-labels" => ListStyle::LocalFunctions, _ => ListStyle::General, } } diff --git a/packages/core/syntax/src/sexpr/reindent.rs b/packages/core/syntax/src/sexpr/reindent.rs index 86b1d7d1..a9683ec5 100644 --- a/packages/core/syntax/src/sexpr/reindent.rs +++ b/packages/core/syntax/src/sexpr/reindent.rs @@ -31,37 +31,87 @@ use super::types::{ByteOffset, ByteSpan, NodeId}; /// The count is what `lisp-indent-function` calls the form's "number of /// distinguished arguments": `defun` has two (name and lambda list), `when` has /// one (the test), `progn` has none. -const BODY_FORMS: [(&str, usize); 30] = [ +const BODY_FORMS: [(&str, usize); 70] = [ + // ── Definition forms ── ("defun", 2), ("defmacro", 2), ("defmethod", 2), ("defgeneric", 2), ("defclass", 3), ("define-condition", 3), + // ── Lambda ── ("lambda", 1), + ("named-lambda", 2), + // ── Binding forms ── ("let", 1), ("let*", 1), ("flet", 1), ("labels", 1), + ("generic-flet", 1), + ("generic-labels", 1), ("macrolet", 1), + ("compiler-macrolet", 1), ("symbol-macrolet", 1), + ("handler-bind", 1), + ("restart-bind", 1), + // ── Control flow ── ("when", 1), ("unless", 1), + ("block", 1), + ("catch", 1), + ("unwind-protect", 1), + ("eval-when", 1), + ("locally", 1), + // ── Iteration ── ("dolist", 1), ("dotimes", 1), ("do", 2), ("do*", 2), + ("prog", 2), + ("prog*", 2), + // ── Clause forms ── ("case", 1), ("ecase", 1), + ("ccase", 1), ("typecase", 1), ("etypecase", 1), + ("ctypecase", 1), + ("handler-case", 1), + // ── progn-like ── + ("progn", 0), + ("prog1", 1), + ("prog2", 2), + // ── with-* macros ── ("with-open-file", 1), + ("with-open-stream", 1), + ("with-input-from-string", 1), + ("with-output-to-string", 1), + ("with-hash-table-iterator", 1), + ("with-package-iterator", 1), ("with-slots", 2), ("with-accessors", 2), + ("with-compilation-unit", 1), + ("with-standard-io-syntax", 1), + ("with-condition-restarts", 2), + // ── Value binding ── ("multiple-value-bind", 2), ("destructuring-bind", 2), - ("handler-case", 1), - ("progn", 0), + ("multiple-value-call", 1), + ("multiple-value-prog1", 1), + ("progv", 2), + // ── Emacs Lisp ── + ("defvar", 2), + ("defconst", 2), + ("defcustom", 2), + ("defgroup", 2), + ("defalias", 2), + ("defvaralias", 2), + ("save-excursion", 0), + ("save-restriction", 0), + ("save-current-buffer", 0), + ("track-mouse", 0), + ("while", 1), + ("condition-case", 2), ]; /// How many distinguished arguments `head` takes before its body, or `None` diff --git a/packages/core/syntax/src/sexpr/tests/formatter.rs b/packages/core/syntax/src/sexpr/tests/formatter.rs index cfad1217..33b91da0 100644 --- a/packages/core/syntax/src/sexpr/tests/formatter.rs +++ b/packages/core/syntax/src/sexpr/tests/formatter.rs @@ -1372,15 +1372,14 @@ fn clojure_layouts_do_not_leak_into_other_dialects() { "(defn greet [first-name last-name] (log first-name) (str first-name \" \" last-name))"; let tree = SyntaxTree::parse_with_dialect(input, Dialect::Clojure).expect("valid Clojure"); - // Outside Clojure `defn` has no layout of its own, so this is the plain - // list layout: every element lines up under `defn`, one column past the - // opening delimiter. + // Outside Clojure `defn` has no layout of its own, so the plain list + // layout applies: `greet` stays on the head line because it fits, and + // subsequent elements align under it (Emacs convention for general lists). let common_lisp_layout = concat!( - "(defn\n", - " greet\n", - " [first-name last-name]\n", - " (log first-name)\n", - " (str first-name \" \" last-name))\n" + "(defn greet\n", + " [first-name last-name]\n", + " (log first-name)\n", + " (str first-name \" \" last-name))\n" ); assert_eq!(Formatter::new(2).format(&tree), common_lisp_layout); assert_eq!( From 593e3140f33d2d7379d55d29b3a7d89f77506609 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sat, 8 Aug 2026 22:16:03 +0900 Subject: [PATCH 2/6] fix(formatter): align CL if branches, add distinguished column to prefix_body - Add ListStyle::IfAligned for Common Lisp if with all branches at the same distinguished column (+2*indent), matching Emacs common-lisp-indent-function's (&rest nil) convention - Route CL if to IfAligned, keep Elisp if on ListStyle::If - Add distinguished_column to format_prefix_body: the child at position prefix_len breaks to +4 (distinguished) instead of body_column (+2) when it does not fit inline - Update test expectations for the new if layout --- .../core/syntax/src/sexpr/formatter/core.rs | 3 ++ .../src/sexpr/formatter/lists/definitions.rs | 50 ++++++++++++++++++- .../core/syntax/src/sexpr/formatter/styles.rs | 9 +++- .../core/syntax/src/sexpr/tests/formatter.rs | 37 ++++++-------- .../format/indent_table_and_quote_style.rs | 8 +-- ...numeric_case_alignment_and_editorconfig.rs | 6 +-- 6 files changed, 82 insertions(+), 31 deletions(-) diff --git a/packages/core/syntax/src/sexpr/formatter/core.rs b/packages/core/syntax/src/sexpr/formatter/core.rs index 1ea81994..6f50814f 100644 --- a/packages/core/syntax/src/sexpr/formatter/core.rs +++ b/packages/core/syntax/src/sexpr/formatter/core.rs @@ -921,6 +921,9 @@ impl Formatter { ListStyle::If => { self.format_prefix_body(tree, node_id, depth, 2, output); } + ListStyle::IfAligned => { + self.format_if_aligned(tree, node_id, depth, output); + } ListStyle::ClojureDefinition => { self.format_clojure_definition(tree, node_id, depth, output); } diff --git a/packages/core/syntax/src/sexpr/formatter/lists/definitions.rs b/packages/core/syntax/src/sexpr/formatter/lists/definitions.rs index b76f4f18..82b1578a 100644 --- a/packages/core/syntax/src/sexpr/formatter/lists/definitions.rs +++ b/packages/core/syntax/src/sexpr/formatter/lists/definitions.rs @@ -177,15 +177,29 @@ impl Formatter { ) { let node = tree.node(node_id); let delimiter = self.list_delimiter(node); - let body_column = self.add_indent(Self::last_line_width(output)); + let base_column = Self::last_line_width(output); + let body_column = self.add_indent(base_column); + let distinguished_column = self.add_indent(body_column); output.push(delimiter.open()); for (position, child) in node.children.iter().enumerate() { - if position <= prefix_len { + if position < prefix_len { if position > 0 { output.push(' '); } self.format_inline_or_node(tree, *child, depth + 1, output); + } else if position == prefix_len { + // The distinguished argument: stays on the head line + // when it fits, breaks to distinguished_column (+4) when + // it does not. + output.push(' '); + let col = Self::last_line_width(output); + if let Some(inline) = self.compact_node(tree, *child, col) { + output.push_str(&inline); + } else { + Self::break_to_column(distinguished_column, output); + self.format_node(tree, *child, depth + 1, output); + } } else { Self::break_to_column(body_column, output); self.format_node(tree, *child, depth + 1, output); @@ -195,6 +209,38 @@ impl Formatter { output.push(delimiter.close()); } + /// `if` in Common Lisp: the test shares the head line and all branches + /// (then, else) align at the same distinguished column — two indent + /// steps from the form's opening delimiter. + pub(in crate::sexpr::formatter) fn format_if_aligned( + &self, + tree: &SyntaxTree, + node_id: NodeId, + depth: usize, + output: &mut String, + ) { + let node = tree.node(node_id); + let delimiter = self.list_delimiter(node); + let base_column = Self::last_line_width(output); + let body_column = self.add_indent(base_column); + let branch_column = self.add_indent(body_column); + output.push(delimiter.open()); + for (position, child) in node.children.iter().enumerate() { + match position { + 0 => self.format_node(tree, *child, depth + 1, output), + 1 => { + output.push(' '); + self.format_inline_or_node(tree, *child, depth + 1, output); + } + _ => { + Self::break_to_column(branch_column, output); + self.format_node(tree, *child, depth + 1, output); + } + } + } + output.push(delimiter.close()); + } + pub(in crate::sexpr::formatter) fn format_head_body( &self, tree: &SyntaxTree, diff --git a/packages/core/syntax/src/sexpr/formatter/styles.rs b/packages/core/syntax/src/sexpr/formatter/styles.rs index a2f4c85f..a61a93ee 100644 --- a/packages/core/syntax/src/sexpr/formatter/styles.rs +++ b/packages/core/syntax/src/sexpr/formatter/styles.rs @@ -25,6 +25,11 @@ pub(super) enum ListStyle { Loop, HeadBody, If, + /// `if` in Common Lisp: the test is on the head line and all branches + /// (then, else) align at the same distinguished column (two indent + /// steps from the form's opening delimiter — the equivalent of Emacs + /// `common-lisp-indent-function`'s `(&rest nil)` for `if`). + IfAligned, /// `(defn name [params]` with the body indented below, falling back to only /// the name on the head line when a docstring, attribute map, or multi-arity /// clause list follows it. @@ -86,6 +91,7 @@ pub const STYLE_NAMES: &[&str] = &[ "one-argument-body", "two-argument-body", "if-then-else", + "if-aligned", "cond-clauses", "case-clauses", "head-body", @@ -103,6 +109,7 @@ pub(super) fn style_from_name(name: &str) -> Option { "one-argument-body" => Some(ListStyle::OneArgumentBody), "two-argument-body" => Some(ListStyle::TwoArgumentBody), "if-then-else" => Some(ListStyle::If), + "if-aligned" => Some(ListStyle::IfAligned), "cond-clauses" => Some(ListStyle::CondClauses), "case-clauses" => Some(ListStyle::CaseClauses), "head-body" => Some(ListStyle::HeadBody), @@ -218,7 +225,7 @@ impl Formatter { let normalized_head = normalize_common_lisp_operator_head(head); match normalized_head.to_ascii_lowercase().as_str() { "named-lambda" => ListStyle::NamedLambda, - "if" => ListStyle::If, + "if" => ListStyle::IfAligned, "when" | "unless" | "with-open-file" diff --git a/packages/core/syntax/src/sexpr/tests/formatter.rs b/packages/core/syntax/src/sexpr/tests/formatter.rs index 33b91da0..08fa355c 100644 --- a/packages/core/syntax/src/sexpr/tests/formatter.rs +++ b/packages/core/syntax/src/sexpr/tests/formatter.rs @@ -2132,14 +2132,9 @@ fn trim_trailing_whitespace_false_keeps_a_comments_trailing_spaces() { assert!(untrimmed.contains("space \n")); } -/// A form hugged onto its operator's line, then forced to break internally, -/// indents its body from the column its own opening delimiter landed on. -/// -/// Nothing in the existing suite exercised this: every other hugged form is -/// short enough to stay inline, so the body indentation derived from nesting -/// depth was never compared against a form that did not start its own line. -/// Derived from depth, `(unless p ...)` landed at column 6 — three columns -/// *left* of the `(multiple-value-bind` that contains it. +/// Common Lisp `if` aligns all branches at the same distinguished column +/// (two indent steps from the form's opening delimiter), matching Emacs +/// `common-lisp-indent-function`'s `(&rest nil)` convention for `if`. #[test] fn indents_a_hugged_form_that_breaks_from_the_column_it_landed_on() { let input = "(if (consp value) (multiple-value-bind (copy p) (gethash value copies) (unless p (setf copy value))) value)"; @@ -2147,12 +2142,11 @@ fn indents_a_hugged_form_that_breaks_from_the_column_it_landed_on() { assert_eq!( Formatter::new(2).format(&tree), concat!( - "(if (consp value) (multiple-value-bind (copy p) (gethash value copies)\n", - // ^ column 18: the `multiple-value-bind` list opens here, - // so its body belongs at column 20. - " (unless p\n", - " (setf copy value)))\n", - " value)\n", + "(if (consp value)\n", + " (multiple-value-bind (copy p) (gethash value copies)\n", + " (unless p\n", + " (setf copy value)))\n", + " value)\n", ) ); } @@ -2249,10 +2243,10 @@ fn binding_continuation_columns_count_display_width_not_bytes() { ); } -/// The same for a form hugged onto a line that already carries wide -/// characters: `(setf` opens at display column 14, so its second pair lines -/// up under the first at column 20. Counting the preceding `(適合 値)` in -/// bytes would have overshot by four columns. +/// Common Lisp `if` branches at the same distinguished column inside a CJK +/// context. `setf`'s second pair (`別変数`) aligns under the first (`変数`) +/// by display column — counting bytes would overshoot because `適合 値` is +/// 6 display columns but 9 UTF-8 bytes. #[test] fn a_hugged_form_starts_at_its_display_column_not_its_byte_offset() { let input = "(if (適合 値) (setf 変数 (compute-first alpha) 別変数 (compute-second beta)) nil)"; @@ -2260,9 +2254,10 @@ fn a_hugged_form_starts_at_its_display_column_not_its_byte_offset() { assert_eq!( Formatter::new(2).format(&tree), concat!( - "(if (適合 値) (setf 変数 (compute-first alpha)\n", - " 別変数 (compute-second beta))\n", - " nil)\n", + "(if (適合 値)\n", + " (setf 変数 (compute-first alpha)\n", + " 別変数 (compute-second beta))\n", + " nil)\n", ) ); } diff --git a/tests/cli/format/indent_table_and_quote_style.rs b/tests/cli/format/indent_table_and_quote_style.rs index 72b6d835..e40e76ac 100644 --- a/tests/cli/format/indent_table_and_quote_style.rs +++ b/tests/cli/format/indent_table_and_quote_style.rs @@ -20,8 +20,8 @@ fn cli_format_indent_table_flag_retargets_a_symbol_onto_a_style() { let file = dir.join(Path::new("source.lisp")); fs::write(&file, "(if a b c)\n").expect("write fixture"); - // `if` never inlines by default (`ListStyle::If` always shows its - // structure), so this is past the compiled-in width regardless. + // `if` never inlines by default (`ListStyle::IfAligned` always shows + // its structure with all branches at the same distinguished column). paredit() .arg("edit") .arg("format") @@ -29,7 +29,7 @@ fn cli_format_indent_table_flag_retargets_a_symbol_onto_a_style() { .arg(&file) .assert() .success() - .stdout(predicate::eq("(if a b\n c)\n")); + .stdout(predicate::eq("(if a\n b\n c)\n")); // `--indent-table if=general` retargets `if` onto the plain-call layout, // so a short `if` form inlines like any other call. @@ -58,7 +58,7 @@ fn cli_format_indent_table_config_key_reaches_the_command() { .arg("source.lisp") .assert() .success() - .stdout(predicate::eq("(if a b\n c)\n")); + .stdout(predicate::eq("(if a\n b\n c)\n")); fs::write( root.join("paredit.toml"), diff --git a/tests/cli/format/numeric_case_alignment_and_editorconfig.rs b/tests/cli/format/numeric_case_alignment_and_editorconfig.rs index c5b9c76d..14504778 100644 --- a/tests/cli/format/numeric_case_alignment_and_editorconfig.rs +++ b/tests/cli/format/numeric_case_alignment_and_editorconfig.rs @@ -254,7 +254,7 @@ fn cli_format_editorconfig_indent_size_reaches_the_command() { .arg("source.lisp") .assert() .success() - .stdout(predicate::eq("(if a b\n c)\n")); + .stdout(predicate::eq("(if a\n b\n c)\n")); } #[test] @@ -298,7 +298,7 @@ fn cli_format_paredit_toml_wins_over_editorconfig_on_the_same_key() { .arg("source.lisp") .assert() .success() - .stdout(predicate::eq("(if a b\n c)\n")); + .stdout(predicate::eq("(if a\n b\n c)\n")); } #[test] @@ -314,7 +314,7 @@ fn cli_format_with_no_editorconfig_behaves_exactly_as_before() { .arg("source.lisp") .assert() .success() - .stdout(predicate::eq("(if a b\n c)\n")); + .stdout(predicate::eq("(if a\n b\n c)\n")); } // --- FR-015: `format.insert-final-newline` / `format.trim-trailing-whitespace` --- From ae9931592c7300ed2e01a091e925fa6a1f81a8e2 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sat, 8 Aug 2026 22:22:28 +0900 Subject: [PATCH 3/6] fix(formatter): implement remaining 7 deferred indentation patterns - Route defvar/defconstant/defparameter/defglobal/defstruct/define-condition to DefinitionNameBody (name on head line, rest at body-indent) - Route defpackage/in-package/provide/require/use-package/import to General - Remove unreachable 'defpackage' entry from string-match fallback (D-8) - Change loop continuation_column from head_end+1 to base+6 matching Emacs default lisp-loop-keyword-indentation - Update test expectations for define-condition and defvar Closes all 7 deferred divergence patterns (D-4, D-8 through D-13). All 41 divergence patterns now resolved (36 fixed, 5 not-applicable). --- .../syntax/src/sexpr/formatter/lists/loops.rs | 8 ++++---- .../core/syntax/src/sexpr/formatter/styles.rs | 18 +++++++++++++++++- .../core/syntax/src/sexpr/tests/formatter.rs | 12 ++++++++---- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/core/syntax/src/sexpr/formatter/lists/loops.rs b/packages/core/syntax/src/sexpr/formatter/lists/loops.rs index 2f5984e5..6c6c8167 100644 --- a/packages/core/syntax/src/sexpr/formatter/lists/loops.rs +++ b/packages/core/syntax/src/sexpr/formatter/lists/loops.rs @@ -12,12 +12,12 @@ impl Formatter { ) { let node = tree.node(node_id); let delimiter = self.list_delimiter(node); + let base_column = Self::last_line_width(output); output.push(delimiter.open()); self.format_node(tree, node.children[0], depth + 1, output); - // Clauses line up under the first one, one column past the head as - // actually written — not past its byte length, which is not a column - // count for a non-ASCII head. - let continuation_column = Self::last_line_width(output).saturating_add(1); + // Emacs default `lisp-loop-keyword-indentation`: keywords and + // body forms at six columns from the opening delimiter. + let continuation_column = base_column.saturating_add(6); let mut position = 1; let mut conditional_clause_open = false; diff --git a/packages/core/syntax/src/sexpr/formatter/styles.rs b/packages/core/syntax/src/sexpr/formatter/styles.rs index a61a93ee..f98fbdc7 100644 --- a/packages/core/syntax/src/sexpr/formatter/styles.rs +++ b/packages/core/syntax/src/sexpr/formatter/styles.rs @@ -198,6 +198,22 @@ impl Formatter { } CommonLispOperator::Lambda => return ListStyle::Lambda, CommonLispOperator::DefineSymbolMacro => return ListStyle::DefinitionNameBody, + CommonLispOperator::Defvar + | CommonLispOperator::Defconstant + | CommonLispOperator::Defparameter + | CommonLispOperator::Defglobal + | CommonLispOperator::Defstruct + | CommonLispOperator::DefineCondition => { + return ListStyle::DefinitionNameBody; + } + CommonLispOperator::Defpackage + | CommonLispOperator::InPackage + | CommonLispOperator::Provide + | CommonLispOperator::Require + | CommonLispOperator::UsePackage + | CommonLispOperator::Import => { + return ListStyle::General; + } operator if operator.is_asdf_system_definition() => { return ListStyle::SystemDefinition; } @@ -242,7 +258,7 @@ impl Formatter { "case" | "ccase" | "ecase" | "typecase" | "ctypecase" | "etypecase" => { ListStyle::CaseClauses } - "progn" | "prog1" | "prog2" | "tagbody" | "defpackage" | "locally" => { + "progn" | "prog1" | "prog2" | "tagbody" | "locally" => { ListStyle::HeadBody } "declare" | "declaim" | "proclaim" => ListStyle::Declaration, diff --git a/packages/core/syntax/src/sexpr/tests/formatter.rs b/packages/core/syntax/src/sexpr/tests/formatter.rs index 08fa355c..06d97393 100644 --- a/packages/core/syntax/src/sexpr/tests/formatter.rs +++ b/packages/core/syntax/src/sexpr/tests/formatter.rs @@ -765,7 +765,10 @@ fn preserves_string_that_contains_a_semicolon() { let tree = SyntaxTree::parse(input).expect("valid"); assert_eq!( Formatter::new(2).format(&tree), - "(defvar path \";not-a-comment\")\n" + // `defvar` uses `DefinitionNameBody` (name on head line, value at + // body). The semicolon inside the string is preserved — it is never + // mistaken for a comment. + "(defvar path\n \";not-a-comment\")\n" ); } @@ -2169,8 +2172,8 @@ fn aligns_defclass_slots_in_one_column() { ); } -/// The same alignment for `define-condition`, whose slot list has the same -/// shape. +/// `define-condition` uses `DefinitionNameBody`: the name is on the head +/// line and everything else (supers, slot list, options) is at body-indent. #[test] fn aligns_define_condition_slots_in_one_column() { let input = "(define-condition parse-failure (error) ((offset :initarg :offset :reader parse-failure-offset) (message :initarg :message :reader parse-failure-message)) (:report report-parse-failure))"; @@ -2178,7 +2181,8 @@ fn aligns_define_condition_slots_in_one_column() { assert_eq!( Formatter::new(2).format(&tree), concat!( - "(define-condition parse-failure (error)\n", + "(define-condition parse-failure\n", + " (error)\n", " ((offset :initarg :offset :reader parse-failure-offset)\n", " (message :initarg :message :reader parse-failure-message))\n", " (:report report-parse-failure))\n", From a09467eb1255756ccb7387d7e5424354c05f9d0a Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sun, 9 Aug 2026 00:50:22 +0900 Subject: [PATCH 4/6] style: apply treefmt (rustfmt + paredit) formatting treefmt-pr-check caught unformatted output on these 3 files. --- .../core/syntax/src/sexpr/formatter/styles.rs | 36 ++++++++++------ .../migrate/recipes/nil-conditionals.lisp | 43 ++++++++++--------- ...numeric_case_alignment_and_editorconfig.rs | 4 +- 3 files changed, 47 insertions(+), 36 deletions(-) diff --git a/packages/core/syntax/src/sexpr/formatter/styles.rs b/packages/core/syntax/src/sexpr/formatter/styles.rs index f98fbdc7..286a730d 100644 --- a/packages/core/syntax/src/sexpr/formatter/styles.rs +++ b/packages/core/syntax/src/sexpr/formatter/styles.rs @@ -170,13 +170,18 @@ impl Formatter { let normalized_head = normalize_common_lisp_operator_head(head); match normalized_head.to_ascii_lowercase().as_str() { // def* forms with 'defun indent → name on head line, body indented - "defvar" | "defconst" | "defcustom" | "defgroup" | "defalias" - | "defvaralias" | "define-derived-mode" | "define-minor-mode" => { - ListStyle::DefinitionNameBody - } + "defvar" + | "defconst" + | "defcustom" + | "defgroup" + | "defalias" + | "defvaralias" + | "define-derived-mode" + | "define-minor-mode" => ListStyle::DefinitionNameBody, // progn-like (indent 0) → all children at body-indent - "save-excursion" | "save-restriction" | "save-current-buffer" - | "track-mouse" => ListStyle::HeadBody, + "save-excursion" | "save-restriction" | "save-current-buffer" | "track-mouse" => { + ListStyle::HeadBody + } // indent 1 → one arg on head line, body indented "while" => ListStyle::OneArgumentBody, // indent 2 → two-component special (then at +4, else at +2) @@ -258,15 +263,18 @@ impl Formatter { "case" | "ccase" | "ecase" | "typecase" | "ctypecase" | "etypecase" => { ListStyle::CaseClauses } - "progn" | "prog1" | "prog2" | "tagbody" | "locally" => { - ListStyle::HeadBody - } + "progn" | "prog1" | "prog2" | "tagbody" | "locally" => ListStyle::HeadBody, "declare" | "declaim" | "proclaim" => ListStyle::Declaration, - "setq" | "psetq" | "setf" | "psetf" | "multiple-value-setq" - | "multiple-value-setf" => ListStyle::PairAssignment, - "multiple-value-call" | "multiple-value-prog1" | "pprint-logical-block" - | "with-compilation-unit" | "with-standard-io-syntax" - | "return-from" | "throw" => ListStyle::OneArgumentBody, + "setq" | "psetq" | "setf" | "psetf" | "multiple-value-setq" | "multiple-value-setf" => { + ListStyle::PairAssignment + } + "multiple-value-call" + | "multiple-value-prog1" + | "pprint-logical-block" + | "with-compilation-unit" + | "with-standard-io-syntax" + | "return-from" + | "throw" => ListStyle::OneArgumentBody, "progv" | "with-condition-restarts" | "print-unreadable-object" => { ListStyle::TwoArgumentBody } diff --git a/packages/feature/migrate/recipes/nil-conditionals.lisp b/packages/feature/migrate/recipes/nil-conditionals.lisp index 47f44b86..994ea75c 100644 --- a/packages/feature/migrate/recipes/nil-conditionals.lisp +++ b/packages/feature/migrate/recipes/nil-conditionals.lisp @@ -19,24 +19,25 @@ ;;; `(when test a b)' is equivalent and shorter, but splicing a `progn' ;;; body out is a restructuring rather than a rename. `paredit refactor ;;; flatten-progn' does it, with its own review. -(defmigration - nil-conditionals - :description - "one-armed `if' with a nil else-branch to `when' and `unless'" - :dialects - (common-lisp emacs-lisp) - :steps - ((:query - (if (not ?test) ?then - nil) - :rewrite - (unless ?test - ?then) - :note - "first, so the general step below cannot claim a negated test") - (:query - (if ?test ?then - nil) - :rewrite - (when ?test - ?then)))) +(defmigration nil-conditionals + :description + "one-armed `if' with a nil else-branch to `when' and `unless'" + :dialects + (common-lisp emacs-lisp) + :steps + ((:query + (if (not ?test) + ?then + nil) + :rewrite + (unless ?test + ?then) + :note + "first, so the general step below cannot claim a negated test") + (:query + (if ?test + ?then + nil) + :rewrite + (when ?test + ?then)))) diff --git a/tests/cli/format/numeric_case_alignment_and_editorconfig.rs b/tests/cli/format/numeric_case_alignment_and_editorconfig.rs index 14504778..6dddcead 100644 --- a/tests/cli/format/numeric_case_alignment_and_editorconfig.rs +++ b/tests/cli/format/numeric_case_alignment_and_editorconfig.rs @@ -298,7 +298,9 @@ fn cli_format_paredit_toml_wins_over_editorconfig_on_the_same_key() { .arg("source.lisp") .assert() .success() - .stdout(predicate::eq("(if a\n b\n c)\n")); + .stdout(predicate::eq( + "(if a\n b\n c)\n", + )); } #[test] From 562b234addc9f808203f40cbc78621aec76d7c11 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sun, 9 Aug 2026 00:50:31 +0900 Subject: [PATCH 5/6] fix(formatter): charge the closing delimiter when compacting a list's sole argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit format_general_list's new "align subsequent siblings under the first argument" step (this PR) decided whether that first argument fits on the head line by measuring only the argument's own text. When it is also the list's last child, the list's own closing delimiter lands on that same line immediately afterward and was never charged against the width budget — a compact form exactly one column over the budget could still be chosen, breaking the documented `<=` boundary (core-syntax's cjk_width_one_column_over_the_max_width_boundary_wraps). Reserve one column for the trailing delimiter when the first argument is also the last child, matching the already-passing cjk_width_fits_exactly_at_the_max_width_boundary case. This shifted the CL/Elisp alignment for `list`-shaped calls that break (subsequent siblings now align under the first argument's column instead of one column past the opening delimiter, per this PR's own stated goal), which is why the remove-unused-binding fixtures asserting that shape's *exact* rewritten text needed their expected strings updated to match — nextest's fail-fast in CI only surfaced one of these four; this run used --no-fail-fast to find the rest. --- .../core/syntax/src/sexpr/formatter/core.rs | 2 +- .../src/sexpr/formatter/lists/general.rs | 25 +++++++++++-------- .../domain/tests/common_lisp/variables.rs | 6 ++--- .../remove_unused_binding/domain/tests/pbt.rs | 2 +- .../remove_unused_binding/scope/variables.rs | 6 ++--- 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/core/syntax/src/sexpr/formatter/core.rs b/packages/core/syntax/src/sexpr/formatter/core.rs index 6f50814f..e413f366 100644 --- a/packages/core/syntax/src/sexpr/formatter/core.rs +++ b/packages/core/syntax/src/sexpr/formatter/core.rs @@ -1144,7 +1144,7 @@ impl Formatter { /// resolves every style generically, rather than special-casing /// `General`, so a future phase that teaches another style to compact /// gets a working width profile for free. - fn effective_max_width(&self, tree: &SyntaxTree, node_id: NodeId) -> usize { + pub(super) fn effective_max_width(&self, tree: &SyntaxTree, node_id: NodeId) -> usize { let Some(head) = self.head_text(tree, node_id) else { return self.max_width; }; diff --git a/packages/core/syntax/src/sexpr/formatter/lists/general.rs b/packages/core/syntax/src/sexpr/formatter/lists/general.rs index 8c1a7d7c..edbca68f 100644 --- a/packages/core/syntax/src/sexpr/formatter/lists/general.rs +++ b/packages/core/syntax/src/sexpr/formatter/lists/general.rs @@ -34,17 +34,23 @@ impl Formatter { // If the first argument fits inline on the head line, // align subsequent siblings under it. Otherwise break // to one column past the opening delimiter. - let child_start = - Self::last_line_width(output).saturating_add(1); - if let Some(inline) = - self.compact_node(tree, *child, child_start) - { + let child_start = Self::last_line_width(output).saturating_add(1); + // When this argument is also the last child, the list's + // own closing delimiter lands right after it on the same + // line, so it must be charged against the budget too — + // `compact_node` only measures the argument's own text. + let is_last_child = position + 1 == node.children.len(); + let max_width = self.effective_max_width(tree, node_id); + let inline = self.compact_node(tree, *child, child_start).filter(|inline| { + !is_last_child + || child_start.saturating_add(UnicodeWidthStr::width(inline.as_str())) + < max_width + }); + if let Some(inline) = inline { output.push(' '); let col = Self::last_line_width(output); output.push_str(&inline); - if head_line_count - .is_some_and(|hl| output.lines().count() == hl) - { + if head_line_count.is_some_and(|hl| output.lines().count() == hl) { first_arg_column = Some(col); } } else { @@ -54,8 +60,7 @@ impl Formatter { } } _ => { - let element_column = first_arg_column - .unwrap_or(base_column.saturating_add(1)); + let element_column = first_arg_column.unwrap_or(base_column.saturating_add(1)); Self::break_to_column(element_column, output); self.format_node(tree, *child, depth + 1, output); } diff --git a/packages/feature/remove-unused/src/remove_unused_binding/domain/tests/common_lisp/variables.rs b/packages/feature/remove-unused/src/remove_unused_binding/domain/tests/common_lisp/variables.rs index c0564ac3..28c46588 100644 --- a/packages/feature/remove-unused/src/remove_unused_binding/domain/tests/common_lisp/variables.rs +++ b/packages/feature/remove-unused/src/remove_unused_binding/domain/tests/common_lisp/variables.rs @@ -71,7 +71,7 @@ fn plans_unused_binding_ignoring_shadowed_lambda_parameter() { assert_eq!(plan.reference_count, Some(0)); assert_eq!( plan.replacement, - "(let ((used 2))\n (list\n used\n (lambda (x)\n x)))" + "(let ((used 2))\n (list used\n (lambda (x)\n x)))" ); } @@ -105,7 +105,7 @@ fn plans_unused_binding_ignoring_shadowed_dolist_variable() { assert_eq!(plan.reference_count, Some(0)); assert_eq!( plan.replacement, - "(let ((used 2))\n (list\n used\n (dolist (value items value)\n value)))" + "(let ((used 2))\n (list used\n (dolist (value items value)\n value)))" ); } @@ -118,7 +118,7 @@ fn plans_unused_binding_ignoring_shadowed_with_slots_variable() { assert_eq!(plan.reference_count, Some(0)); assert_eq!( plan.replacement, - "(let ((used 2))\n (list\n used\n (with-slots (value)\n object\n value)))" + "(let ((used 2))\n (list used\n (with-slots (value)\n object\n value)))" ); } diff --git a/packages/feature/remove-unused/src/remove_unused_binding/domain/tests/pbt.rs b/packages/feature/remove-unused/src/remove_unused_binding/domain/tests/pbt.rs index dfe04268..56167ebd 100644 --- a/packages/feature/remove-unused/src/remove_unused_binding/domain/tests/pbt.rs +++ b/packages/feature/remove-unused/src/remove_unused_binding/domain/tests/pbt.rs @@ -76,7 +76,7 @@ proptest! { prop_assert_eq!(plan.reference_count, Some(0)); prop_assert!(plan.changed); - let expected_lambda = format!("(lambda ({name})\n {name})"); + let expected_lambda = format!("(lambda ({name})\n {name})"); prop_assert!(plan.rewritten.contains(&expected_lambda)); SyntaxTree::parse(&plan.rewritten) .map_err(|error| TestCaseError::fail(error.to_string()))?; diff --git a/tests/cli/remove_unused_binding/scope/variables.rs b/tests/cli/remove_unused_binding/scope/variables.rs index a50c2b30..c6a8598a 100644 --- a/tests/cli/remove_unused_binding/scope/variables.rs +++ b/tests/cli/remove_unused_binding/scope/variables.rs @@ -20,10 +20,10 @@ fn cli_plans_remove_unused_binding_ignoring_shadowed_lambda_parameter() { .success() .stdout(predicate::str::contains("\"binding_name\": \"x\"")) .stdout(predicate::str::contains("\"reference_count\": 0")) - // `used` and `(lambda ...)` are `list`'s siblings, so they share the - // column `list` itself starts at, one past the opening delimiter. + // `used` fits on the head line with `list`, so `(lambda ...)`, its + // sibling, aligns under `used`'s column rather than one past `list`. .stdout(predicate::str::contains( - "(let ((used 2))\\n (list\\n used\\n (lambda (x)\\n x)))", + "(let ((used 2))\\n (list used\\n (lambda (x)\\n x)))", )); } From e2cc29c8d68570d172903892bbc4964d116c3ea6 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sun, 9 Aug 2026 01:11:05 +0900 Subject: [PATCH 6/6] style: rustfmt the width-budget fix in general.rs --- .../syntax/src/sexpr/formatter/lists/general.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/core/syntax/src/sexpr/formatter/lists/general.rs b/packages/core/syntax/src/sexpr/formatter/lists/general.rs index edbca68f..15348113 100644 --- a/packages/core/syntax/src/sexpr/formatter/lists/general.rs +++ b/packages/core/syntax/src/sexpr/formatter/lists/general.rs @@ -41,11 +41,14 @@ impl Formatter { // `compact_node` only measures the argument's own text. let is_last_child = position + 1 == node.children.len(); let max_width = self.effective_max_width(tree, node_id); - let inline = self.compact_node(tree, *child, child_start).filter(|inline| { - !is_last_child - || child_start.saturating_add(UnicodeWidthStr::width(inline.as_str())) - < max_width - }); + let inline = self + .compact_node(tree, *child, child_start) + .filter(|inline| { + !is_last_child + || child_start + .saturating_add(UnicodeWidthStr::width(inline.as_str())) + < max_width + }); if let Some(inline) = inline { output.push(' '); let col = Self::last_line_width(output);