From 37129968d176a6d6344d6da06220857f26266ed6 Mon Sep 17 00:00:00 2001 From: jeanpaulsio Date: Fri, 27 Mar 2026 07:27:47 -0700 Subject: [PATCH 1/3] fix: trailing comma inside delimiters no longer over-indents Two bugs fixed: 1. Trailing commas inside delimited constructs ({}, [], ()) added an extra indent level because the CONTINUATION regex matched them and added cx.unit on top of what delimitedIndent already provided. Added isInsideDelimiter() to skip continuation indent when inside an unclosed bracket. 2. The CONTINUATION regex's trailing-comment capture (#.*)?$ matched #{interpolation} inside strings as a "comment", so lines like "Hello, #{@name}!" were treated as ending with a trailing comma. Changed to (#(?:[^{].*)?)?$ to exclude #{. 13 new test cases (section 33), 1 previously-skipped test unskipped. --- src/index.ts | 29 +++++++++++++- test/indent.test.mjs | 90 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5240f4b..d9b323b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,7 +64,8 @@ const DEINDENT_CLOSE = /^\s*[\}\]\)]/ const INTERMEDIATE = /^\s*(else|elsif|when|in|rescue|ensure)\b/ // Line ends with a continuation indicator (trailing operator, comma, backslash) -const CONTINUATION = /(\+|-|\*|&&|\|\||\\|,|\.)\s*(#.*)?$/ +// The optional trailing comment uses (#(?:[^{].*)?)? to avoid matching #{interpolation} inside strings +const CONTINUATION = /(\+|-|\*|&&|\|\||\\|,|\.)\s*(#(?:[^{].*)?)?$/ // Line starts with a dot (method chaining continuation) const LEADING_DOT = /^\s*\./ @@ -87,6 +88,27 @@ function prevLineFrom(cx: IndentContext, lineFrom: number): number { return prevLine.from } +// Check if a line is inside an unclosed delimiter ({, [, () +// by scanning backwards from lineFrom to count unmatched openers +function isInsideDelimiter(cx: IndentContext, lineFrom: number): boolean { + let depth = 0 + let scanFrom = lineFrom + while (true) { + scanFrom = prevLineFrom(cx, scanFrom) + if (scanFrom < 0) break + const text = lineText(cx, scanFrom) + for (let i = text.length - 1; i >= 0; i--) { + const ch = text[i] + if (ch === "}" || ch === "]" || ch === ")") depth++ + else if (ch === "{" || ch === "[" || ch === "(") { + if (depth === 0) return true + depth-- + } + } + } + return false +} + function rubyIndentService(cx: IndentContext, pos: number): number | undefined { const line = cx.lineAt(pos) const text = line.text @@ -211,6 +233,11 @@ function rubyIndentService(cx: IndentContext, pos: number): number | undefined { // Previous line ends with continuation (trailing operator, comma, backslash) if (CONTINUATION.test(prevText)) { + // Inside delimited constructs ({}, [], ()), don't add extra indent — + // delimitedIndent already handles the correct level + if (isInsideDelimiter(cx, prevFrom)) { + return prevIndent + } // Check if the line before that was also a continuation — if so, stay at same level if (prevFrom > 0) { let prev2From = prevLineFrom(cx, prevFrom) diff --git a/test/indent.test.mjs b/test/indent.test.mjs index d16618a..2aa126a 100644 --- a/test/indent.test.mjs +++ b/test/indent.test.mjs @@ -1522,11 +1522,16 @@ test("28a deeply nested hash", () => { expectIndent(code, 9, 0, "} at level 0") }) -skip("28b array of hashes", "trailing comma inside delimiters triggers CONTINUATION indent — scanner doesn't track delimiter depth to suppress it") +test("28b array of hashes (one hash per line)", () => { + // Each hash on its own line inside an array — trailing commas should not over-indent + const code = 'users = [\n { name: "Alice" },\n { name: "Bob" },\n]' + return expectIndent(code, 3, 2, "second hash at column 2") && + expectIndent(code, 4, 0, "closing ] at column 0") +}) skip("28c hash with array values and blocks", "complex continuation + chain inside hash") -console.log(" Section 28: 3 cases (2 skipped)") +console.log(" Section 28: 3 cases (1 skipped)") // ============================================================ // Section 29: Real-World Patterns @@ -1832,6 +1837,87 @@ test("32k proc call with brackets", () => { console.log(" Section 32: 11 cases (2 skipped)") +// ============================================================ +// Section 33: Trailing Comma Inside Delimiters (Bug Fix) +// ============================================================ + +console.log("\n33. Trailing Comma Inside Delimiters") + +test("33a hash entry after trailing comma stays at same level", () => { + // def foo + // baz = { + // foo: "bar", + // | ← cursor should be here (column 4) + const code = 'def foo\n baz = {\n foo: "bar",\n next_entry\n }\nend' + return expectIndent(code, 4, 4, "next hash entry at column 4, not 6") +}) + +test("33b array entry after trailing comma stays at same level", () => { + const code = 'items = [\n "first",\n "second"\n]' + return expectIndent(code, 3, 2, "next array entry at column 2") +}) + +test("33c method args after trailing comma stays at same level", () => { + const code = 'foo(\n arg1,\n arg2\n)' + return expectIndent(code, 3, 2, "next arg at column 2") +}) + +test("33d nested hash with trailing commas", () => { + const code = 'config = {\n db: {\n host: "localhost",\n port: 5432,\n name: "mydb"\n },\n cache: {}\n}' + return expectIndent(code, 4, 4, "port at column 4") && + expectIndent(code, 5, 4, "name at column 4") +}) + +test("33e hash inside method def with trailing comma", () => { + const code = 'def foo\n baz = {\n foo: "bar",\n baz: "qux",\n quux: "corge"\n }\nend' + return expectIndent(code, 4, 4, "baz: at column 4") && + expectIndent(code, 5, 4, "quux: at column 4") +}) + +test("33f array inside method def with trailing comma", () => { + const code = 'def foo\n items = [\n "a",\n "b",\n "c"\n ]\nend' + return expectIndent(code, 4, 4, "b at column 4") && + expectIndent(code, 5, 4, "c at column 4") +}) + +test("33g call inside method def with trailing comma", () => { + const code = 'def bar\n foo(\n 1,\n 2\n )\nend' + return expectIndent(code, 4, 4, "next arg at column 4") +}) + +test("33h config pattern: hash > hash > array nesting", () => { + const code = 'config = {\n database: {\n adapter: "postgresql",\n hosts: [\n "primary",\n "secondary"\n ]\n }\n}' + return expectIndent(code, 4, 4, "hosts key at column 4") && + expectIndent(code, 6, 6, "secondary at column 6") +}) + +test("33i bare call trailing comma still gets continuation indent", () => { + const code = 'validates :email,\n presence: true' + return expectIndent(code, 2, 2, "continuation after bare call trailing comma") +}) + +test("33j bare call trailing comma in def still gets continuation indent", () => { + const code = 'def foo\n puts a,\n b\nend' + return expectIndent(code, 3, 4, "continuation inside def after bare call comma") +}) + +test("33k puts trailing comma still gets continuation indent", () => { + const code = 'puts a,\n b' + return expectIndent(code, 2, 2, "continuation after puts comma") +}) + +test("33l interpolated string does not trigger continuation indent", () => { + const code = 'class Greeter\n def greet\n "Hello, #{@name}!"\n \n end\nend' + return expectIndent(code, 4, 4, "line after interpolated string at column 4") +}) + +test("33m simple string with comma does not trigger continuation indent", () => { + const code = 'def foo\n x = "hello, world"\n \nend' + return expectIndent(code, 3, 2, "line after string with comma at column 2") +}) + +console.log(" Section 33: 13 cases") + // ============================================================ // Summary // ============================================================ From 1275a913d38926fb39a526506d477582af009648 Mon Sep 17 00:00:00 2001 From: jeanpaulsio Date: Fri, 27 Mar 2026 07:34:51 -0700 Subject: [PATCH 2/3] chore: rebuild demo bundle with latest indent fix --- demo/demo.js | 152 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 95 insertions(+), 57 deletions(-) diff --git a/demo/demo.js b/demo/demo.js index 6716b21..bba6547 100644 --- a/demo/demo.js +++ b/demo/demo.js @@ -27348,10 +27348,15 @@ // ============================================================ // Less-than / Heredoc external tokenizer (#10) // - // `<` is ambiguous: comparison, `<=`, `<<` left shift, or heredoc start. + // `<` is ambiguous: comparison, `<=`, `<<` left shift, or heredoc. // When `<<` is followed by [-~]?IDENTIFIER (or quoted string), and the - // parser allows Heredoc, we scan to the matching closing delimiter - // and emit the entire heredoc as one token. + // parser allows Heredoc, we emit a Heredoc token. + // + // Two modes depending on trailing code after the opener: + // 1. No trailing code: token spans opener + body + closing delimiter + // 2. Trailing code (e.g. foo(<<~SQL) or <<~HEREDOC.strip): token spans + // only the opener, allowing the parser to handle trailing syntax + // // Otherwise emit lessThanOp or lessThanEqOp for comparison. // ============================================================ const lessThanTokenizer = new ExternalTokenizer((input, stack) => { @@ -27361,9 +27366,9 @@ // Try heredoc: << if (second === 60 /* '<' */) { if (stack.canShift(Heredoc)) { - const heredocLen = tryMatchHeredoc(input); - if (heredocLen > 0) { - input.acceptToken(Heredoc, heredocLen); + const result = tryMatchHeredoc(input); + if (result) { + input.acceptToken(Heredoc, result); return; } } @@ -27388,13 +27393,20 @@ input.acceptToken(lessThanOp, 1); } }); - // Try to match a complete heredoc starting at the current position. - // Returns the total length including the closing delimiter, or 0 if no match. + // Try to match a heredoc at the current position. + // Returns the total token length, or null if no match. + // + // Two modes: + // 1. No trailing code after opener: token spans opener + body + closing delimiter + // e.g. <<~SQL\n SELECT 1\nSQL → entire thing is one token + // 2. Trailing code after opener: token spans only the opener + // e.g. <<~SQL in foo(<<~SQL) → just <<~SQL is the token function tryMatchHeredoc(input) { let pos = 2; // past << // Optional - or ~ const modifier = input.peek(pos); - if (modifier === 45 /* - */ || modifier === 126 /* ~ */) + const indented = modifier === 45 /* - */ || modifier === 126; /* ~ */ + if (indented) pos++; // Read the delimiter let delimiter = ""; @@ -27405,7 +27417,7 @@ while (true) { const ch = input.peek(pos); if (ch === -1 || ch === 10) - return 0; // unterminated quote + return null; // unterminated quote if (ch === quoteChar) { pos++; break; @@ -27417,90 +27429,87 @@ else { // Bare identifier delimiter: <<~DELIM if (!isIdentStart(input.peek(pos))) - return 0; + return null; while (isIdentChar(input.peek(pos))) { delimiter += String.fromCharCode(input.peek(pos)); pos++; } } if (!delimiter) - return 0; - // After the delimiter, only whitespace/comments allowed on the rest of the line. - // If there's code (like .method or (args)), this is << left-shift, not a heredoc. + return null; + const openerLength = pos; // This is the length of just <<~DELIM + // Check what follows the delimiter on the same line. + // For bare identifiers without a modifier, non-whitespace trailing code + // means this is << operator, not heredoc. E.g. <= 0; i--) { + const ch = text[i]; + if (ch === "}" || ch === "]" || ch === ")") + depth++; + else if (ch === "{" || ch === "[" || ch === "(") { + if (depth === 0) + return true; + depth--; + } + } + } + return false; + } function rubyIndentService(cx, pos) { const line = cx.lineAt(pos); const text = line.text; @@ -28006,6 +28039,11 @@ } // Previous line ends with continuation (trailing operator, comma, backslash) if (CONTINUATION.test(prevText)) { + // Inside delimited constructs ({}, [], ()), don't add extra indent — + // delimitedIndent already handles the correct level + if (isInsideDelimiter(cx, prevFrom)) { + return prevIndent; + } // Check if the line before that was also a continuation — if so, stay at same level if (prevFrom > 0) { let prev2From = prevLineFrom(cx, prevFrom); From 513844666784c280834ed62577548c7b23c4bb67 Mon Sep 17 00:00:00 2001 From: jeanpaulsio Date: Fri, 27 Mar 2026 07:36:36 -0700 Subject: [PATCH 3/3] chore: release 0.4.3 --- CHANGELOG.md | 17 +++++++++++++++++ package.json | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ebf51c..0aba6a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. This project follows [Semantic Versioning](https://semver.org/). +## [0.4.3] - 2026-03-27 + +### Fixed + +- **Trailing comma inside delimiters** — Pressing Enter after a line ending with a trailing comma inside `{}`, `[]`, or `()` no longer adds an extra indent level. Added delimiter-depth detection so the continuation-indent logic defers to `delimitedIndent` inside brackets. +- **String interpolation false positive** — The continuation regex's trailing-comment capture `(#.*)?$` was matching `#{interpolation}` inside strings as a comment, causing lines like `"Hello, #{@name}!"` to be treated as trailing-comma continuations. Fixed by excluding `#{` from the comment pattern. + +### Added + +- 14 new indent test cases (section 33 + unskipped 28b) covering hashes, arrays, method args, nested constructs, bare call continuations, and interpolated strings + +## [0.4.2] - 2026-03-27 + +### Fixed + +- **Heredocs with trailing code** — `foo(<<~SQL)`, `<<~HEREDOC.strip`, and similar patterns where code follows the heredoc opener now parse correctly. + ## [0.4.1] - 2026-03-26 ### Fixed diff --git a/package.json b/package.json index df7eadb..b65fd36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codemirror-lang-ruby", - "version": "0.4.2", + "version": "0.4.3", "description": "Ruby language support for CodeMirror 6, built on a Lezer grammar", "type": "module", "main": "dist/index.cjs",