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/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); 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", 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 // ============================================================