Skip to content

Commit 68ce567

Browse files
authored
fix: trailing comma inside delimiters no longer over-indents (#27)
* 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. * chore: rebuild demo bundle with latest indent fix * chore: release 0.4.3
1 parent f2e42a0 commit 68ce567

5 files changed

Lines changed: 229 additions & 61 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
44

55
This project follows [Semantic Versioning](https://semver.org/).
66

7+
## [0.4.3] - 2026-03-27
8+
9+
### Fixed
10+
11+
- **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.
12+
- **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.
13+
14+
### Added
15+
16+
- 14 new indent test cases (section 33 + unskipped 28b) covering hashes, arrays, method args, nested constructs, bare call continuations, and interpolated strings
17+
18+
## [0.4.2] - 2026-03-27
19+
20+
### Fixed
21+
22+
- **Heredocs with trailing code**`foo(<<~SQL)`, `<<~HEREDOC.strip`, and similar patterns where code follows the heredoc opener now parse correctly.
23+
724
## [0.4.1] - 2026-03-26
825

926
### Fixed

demo/demo.js

Lines changed: 95 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -27348,10 +27348,15 @@
2734827348
// ============================================================
2734927349
// Less-than / Heredoc external tokenizer (#10)
2735027350
//
27351-
// `<` is ambiguous: comparison, `<=`, `<<` left shift, or heredoc start.
27351+
// `<` is ambiguous: comparison, `<=`, `<<` left shift, or heredoc.
2735227352
// When `<<` is followed by [-~]?IDENTIFIER (or quoted string), and the
27353-
// parser allows Heredoc, we scan to the matching closing delimiter
27354-
// and emit the entire heredoc as one token.
27353+
// parser allows Heredoc, we emit a Heredoc token.
27354+
//
27355+
// Two modes depending on trailing code after the opener:
27356+
// 1. No trailing code: token spans opener + body + closing delimiter
27357+
// 2. Trailing code (e.g. foo(<<~SQL) or <<~HEREDOC.strip): token spans
27358+
// only the opener, allowing the parser to handle trailing syntax
27359+
//
2735527360
// Otherwise emit lessThanOp or lessThanEqOp for comparison.
2735627361
// ============================================================
2735727362
const lessThanTokenizer = new ExternalTokenizer((input, stack) => {
@@ -27361,9 +27366,9 @@
2736127366
// Try heredoc: <<
2736227367
if (second === 60 /* '<' */) {
2736327368
if (stack.canShift(Heredoc)) {
27364-
const heredocLen = tryMatchHeredoc(input);
27365-
if (heredocLen > 0) {
27366-
input.acceptToken(Heredoc, heredocLen);
27369+
const result = tryMatchHeredoc(input);
27370+
if (result) {
27371+
input.acceptToken(Heredoc, result);
2736727372
return;
2736827373
}
2736927374
}
@@ -27388,13 +27393,20 @@
2738827393
input.acceptToken(lessThanOp, 1);
2738927394
}
2739027395
});
27391-
// Try to match a complete heredoc starting at the current position.
27392-
// Returns the total length including the closing delimiter, or 0 if no match.
27396+
// Try to match a heredoc at the current position.
27397+
// Returns the total token length, or null if no match.
27398+
//
27399+
// Two modes:
27400+
// 1. No trailing code after opener: token spans opener + body + closing delimiter
27401+
// e.g. <<~SQL\n SELECT 1\nSQL → entire thing is one token
27402+
// 2. Trailing code after opener: token spans only the opener
27403+
// e.g. <<~SQL in foo(<<~SQL) → just <<~SQL is the token
2739327404
function tryMatchHeredoc(input) {
2739427405
let pos = 2; // past <<
2739527406
// Optional - or ~
2739627407
const modifier = input.peek(pos);
27397-
if (modifier === 45 /* - */ || modifier === 126 /* ~ */)
27408+
const indented = modifier === 45 /* - */ || modifier === 126; /* ~ */
27409+
if (indented)
2739827410
pos++;
2739927411
// Read the delimiter
2740027412
let delimiter = "";
@@ -27405,7 +27417,7 @@
2740527417
while (true) {
2740627418
const ch = input.peek(pos);
2740727419
if (ch === -1 || ch === 10)
27408-
return 0; // unterminated quote
27420+
return null; // unterminated quote
2740927421
if (ch === quoteChar) {
2741027422
pos++;
2741127423
break;
@@ -27417,90 +27429,87 @@
2741727429
else {
2741827430
// Bare identifier delimiter: <<~DELIM
2741927431
if (!isIdentStart(input.peek(pos)))
27420-
return 0;
27432+
return null;
2742127433
while (isIdentChar(input.peek(pos))) {
2742227434
delimiter += String.fromCharCode(input.peek(pos));
2742327435
pos++;
2742427436
}
2742527437
}
2742627438
if (!delimiter)
27427-
return 0;
27428-
// After the delimiter, only whitespace/comments allowed on the rest of the line.
27429-
// If there's code (like .method or (args)), this is << left-shift, not a heredoc.
27439+
return null;
27440+
const openerLength = pos; // This is the length of just <<~DELIM
27441+
// Check what follows the delimiter on the same line.
27442+
// For bare identifiers without a modifier, non-whitespace trailing code
27443+
// means this is << operator, not heredoc. E.g. <<File.expand_path(...)
27444+
// With a modifier (<<~ or <<-) or quoted delimiter, trailing code is OK.
27445+
let scanPos = pos;
27446+
let hasTrailingCode = false;
2743027447
while (true) {
27431-
const ch = input.peek(pos);
27448+
const ch = input.peek(scanPos);
2743227449
if (ch === -1)
27433-
return 0;
27450+
return null; // EOF on opener line, no body possible
2743427451
if (ch === 10) {
27435-
pos++;
27452+
scanPos++;
2743627453
break;
2743727454
}
2743827455
if (ch === 13) {
27439-
pos++;
27440-
if (input.peek(pos) === 10)
27441-
pos++;
27456+
scanPos++;
27457+
if (input.peek(scanPos) === 10)
27458+
scanPos++;
2744227459
break;
2744327460
}
2744427461
if (ch === 32 || ch === 9) {
27445-
pos++;
27462+
scanPos++;
2744627463
continue;
27447-
} // whitespace OK
27464+
}
2744827465
if (ch === 35 /* # */) { // comment — skip rest of line
2744927466
while (true) {
27450-
const c = input.peek(pos);
27467+
const c = input.peek(scanPos);
2745127468
if (c === -1 || c === 10 || c === 13)
2745227469
break;
27453-
pos++;
27470+
scanPos++;
2745427471
}
2745527472
continue;
2745627473
}
27457-
// For bare identifiers (not quoted), any non-whitespace after delimiter
27458-
// means this is << operator, not heredoc. E.g. <<File.expand_path(...)
27459-
if (quoteChar !== 39 && quoteChar !== 34 && quoteChar !== 96)
27460-
return 0;
27461-
pos++; // quoted delimiters can have trailing content (e.g. <<~"SQL", other_arg)
27474+
// Bare identifier without modifier + trailing code = not a heredoc
27475+
if (quoteChar !== 39 && quoteChar !== 34 && quoteChar !== 96 &&
27476+
!indented)
27477+
return null;
27478+
hasTrailingCode = true;
27479+
scanPos++; // modifier/quoted → skip trailing code
2746227480
}
27463-
// Scan lines looking for the closing delimiter
27464-
const isIndented = modifier === 45 || modifier === 126; // <<- or <<~
2746527481
while (true) {
2746627482
let lineContent = "";
27467-
// For indented heredocs (<<- or <<~), skip leading whitespace
27468-
if (isIndented) {
27469-
while (input.peek(pos) === 32 || input.peek(pos) === 9)
27470-
pos++;
27483+
if (indented) {
27484+
while (input.peek(scanPos) === 32 || input.peek(scanPos) === 9)
27485+
scanPos++;
2747127486
}
27472-
// Read the rest of the line
2747327487
while (true) {
27474-
const ch = input.peek(pos);
27488+
const ch = input.peek(scanPos);
2747527489
if (ch === -1 || ch === 10 || ch === 13)
2747627490
break;
2747727491
lineContent += String.fromCharCode(ch);
27478-
pos++;
27492+
scanPos++;
2747927493
}
27480-
// Check if this line matches the delimiter (trimmed)
2748127494
if (lineContent === delimiter) {
27482-
// Include the delimiter line in the token
27483-
// Advance past newline if present
27484-
const ch = input.peek(pos);
27485-
if (ch === 10)
27486-
pos++;
27487-
else if (ch === 13) {
27488-
pos++;
27489-
if (input.peek(pos) === 10)
27490-
pos++;
27495+
// Found closing delimiter
27496+
if (hasTrailingCode) {
27497+
// Trailing code present — emit only the opener so the parser
27498+
// can handle ), .method, etc. on the same line
27499+
return openerLength;
2749127500
}
27492-
return pos;
27501+
// No trailing code — emit the full heredoc (opener + body + delimiter)
27502+
return scanPos;
2749327503
}
27494-
// Advance past newline
27495-
const ch = input.peek(pos);
27504+
const ch = input.peek(scanPos);
2749627505
if (ch === -1)
27497-
return pos; // unterminated heredoc — emit what we have
27506+
return null; // EOF without closing delimiter
2749827507
if (ch === 10)
27499-
pos++;
27508+
scanPos++;
2750027509
else if (ch === 13) {
27501-
pos++;
27502-
if (input.peek(pos) === 10)
27503-
pos++;
27510+
scanPos++;
27511+
if (input.peek(scanPos) === 10)
27512+
scanPos++;
2750427513
}
2750527514
}
2750627515
}
@@ -27859,7 +27868,8 @@
2785927868
// Intermediate keywords whose body should indent
2786027869
const INTERMEDIATE = /^\s*(else|elsif|when|in|rescue|ensure)\b/;
2786127870
// Line ends with a continuation indicator (trailing operator, comma, backslash)
27862-
const CONTINUATION = /(\+|-|\*|&&|\|\||\\|,|\.)\s*(#.*)?$/;
27871+
// The optional trailing comment uses (#(?:[^{].*)?)? to avoid matching #{interpolation} inside strings
27872+
const CONTINUATION = /(\+|-|\*|&&|\|\||\\|,|\.)\s*(#(?:[^{].*)?)?$/;
2786327873
// Line starts with a dot (method chaining continuation)
2786427874
const LEADING_DOT = /^\s*\./;
2786527875
function opensBlock(text) {
@@ -27878,6 +27888,29 @@
2787827888
const prevLine = cx.lineAt(lineFrom - 1);
2787927889
return prevLine.from;
2788027890
}
27891+
// Check if a line is inside an unclosed delimiter ({, [, ()
27892+
// by scanning backwards from lineFrom to count unmatched openers
27893+
function isInsideDelimiter(cx, lineFrom) {
27894+
let depth = 0;
27895+
let scanFrom = lineFrom;
27896+
while (true) {
27897+
scanFrom = prevLineFrom(cx, scanFrom);
27898+
if (scanFrom < 0)
27899+
break;
27900+
const text = lineText(cx, scanFrom);
27901+
for (let i = text.length - 1; i >= 0; i--) {
27902+
const ch = text[i];
27903+
if (ch === "}" || ch === "]" || ch === ")")
27904+
depth++;
27905+
else if (ch === "{" || ch === "[" || ch === "(") {
27906+
if (depth === 0)
27907+
return true;
27908+
depth--;
27909+
}
27910+
}
27911+
}
27912+
return false;
27913+
}
2788127914
function rubyIndentService(cx, pos) {
2788227915
const line = cx.lineAt(pos);
2788327916
const text = line.text;
@@ -28006,6 +28039,11 @@
2800628039
}
2800728040
// Previous line ends with continuation (trailing operator, comma, backslash)
2800828041
if (CONTINUATION.test(prevText)) {
28042+
// Inside delimited constructs ({}, [], ()), don't add extra indent —
28043+
// delimitedIndent already handles the correct level
28044+
if (isInsideDelimiter(cx, prevFrom)) {
28045+
return prevIndent;
28046+
}
2800928047
// Check if the line before that was also a continuation — if so, stay at same level
2801028048
if (prevFrom > 0) {
2801128049
let prev2From = prevLineFrom(cx, prevFrom);

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codemirror-lang-ruby",
3-
"version": "0.4.2",
3+
"version": "0.4.3",
44
"description": "Ruby language support for CodeMirror 6, built on a Lezer grammar",
55
"type": "module",
66
"main": "dist/index.cjs",

src/index.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ const DEINDENT_CLOSE = /^\s*[\}\]\)]/
6464
const INTERMEDIATE = /^\s*(else|elsif|when|in|rescue|ensure)\b/
6565

6666
// Line ends with a continuation indicator (trailing operator, comma, backslash)
67-
const CONTINUATION = /(\+|-|\*|&&|\|\||\\|,|\.)\s*(#.*)?$/
67+
// The optional trailing comment uses (#(?:[^{].*)?)? to avoid matching #{interpolation} inside strings
68+
const CONTINUATION = /(\+|-|\*|&&|\|\||\\|,|\.)\s*(#(?:[^{].*)?)?$/
6869

6970
// Line starts with a dot (method chaining continuation)
7071
const LEADING_DOT = /^\s*\./
@@ -87,6 +88,27 @@ function prevLineFrom(cx: IndentContext, lineFrom: number): number {
8788
return prevLine.from
8889
}
8990

91+
// Check if a line is inside an unclosed delimiter ({, [, ()
92+
// by scanning backwards from lineFrom to count unmatched openers
93+
function isInsideDelimiter(cx: IndentContext, lineFrom: number): boolean {
94+
let depth = 0
95+
let scanFrom = lineFrom
96+
while (true) {
97+
scanFrom = prevLineFrom(cx, scanFrom)
98+
if (scanFrom < 0) break
99+
const text = lineText(cx, scanFrom)
100+
for (let i = text.length - 1; i >= 0; i--) {
101+
const ch = text[i]
102+
if (ch === "}" || ch === "]" || ch === ")") depth++
103+
else if (ch === "{" || ch === "[" || ch === "(") {
104+
if (depth === 0) return true
105+
depth--
106+
}
107+
}
108+
}
109+
return false
110+
}
111+
90112
function rubyIndentService(cx: IndentContext, pos: number): number | undefined {
91113
const line = cx.lineAt(pos)
92114
const text = line.text
@@ -211,6 +233,11 @@ function rubyIndentService(cx: IndentContext, pos: number): number | undefined {
211233

212234
// Previous line ends with continuation (trailing operator, comma, backslash)
213235
if (CONTINUATION.test(prevText)) {
236+
// Inside delimited constructs ({}, [], ()), don't add extra indent —
237+
// delimitedIndent already handles the correct level
238+
if (isInsideDelimiter(cx, prevFrom)) {
239+
return prevIndent
240+
}
214241
// Check if the line before that was also a continuation — if so, stay at same level
215242
if (prevFrom > 0) {
216243
let prev2From = prevLineFrom(cx, prevFrom)

0 commit comments

Comments
 (0)