Skip to content

Commit 36092ea

Browse files
authored
Release 0.4.0: grammar fixes, auto-indent fix, updated docs (#24)
* fix: support ::TopLevel leading scope resolution (BUG-015b) Allow ScopeResolution to start with :: prefix for top-level constant references like ::TopLevel and ::TopLevel::Foo. Also support :: prefix in className for class/module definitions. Uses ~scopeOrClass GLR marker to resolve ambiguity between className's :: repetition and ScopeResolution. * fix: add HashPattern for pattern matching in case/in (BUG-026a) Add HashPattern and HashPatternPair rules reachable only from InClause for Ruby 3.0+ pattern matching. Supports {name: String => n} binding syntax and {name:} shorthand. Uses ~hashOrPattern GLR marker to resolve ambiguity with regular Hash/HashPair rules. * fix: add FindPattern for pattern matching [*, value, *] (BUG-026c) Add FindPattern rule reachable from InClause for Ruby 3.0+ find patterns. Requires leading * to unambiguously distinguish from regular Array. Supports [*, value, *] and [*pre, value, *post] forms. * chore: bump to 0.4.0, fix auto-indent, update docs and demo - Fix rubyIndentService to use cx.lineAt() instead of raw doc access, so auto-indent on Enter respects simulateBreak - Update README: add new features to "What's supported", remove stale limitations (symbol-key shorthand, ::TopLevel, hash/find patterns), add guard clause limitation, update Rails benchmark 94.2% → 94.3% - Rebuild demo for GitHub Pages - Bump version to 0.4.0
1 parent bb800b7 commit 36092ea

6 files changed

Lines changed: 205 additions & 125 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.0] - 2026-03-26
8+
9+
### Added
10+
11+
- **`::TopLevel` leading scope resolution**`::TopLevel`, `::TopLevel::Foo`, and `::Foo.bar` now parse correctly. Class/module definitions with leading `::` also supported.
12+
- **Hash patterns in `case/in`** — Pattern matching now supports `in {name: String => n}` and `in {name:}` shorthand via new `HashPattern` rule.
13+
- **Find patterns in `case/in`** — Pattern matching now supports `in [*, 2, *]` and `in [*pre, value, *post]` via new `FindPattern` rule.
14+
15+
### Fixed
16+
17+
- **Auto-indent on Enter** — The indent service now uses `IndentContext.lineAt()` instead of raw document access, correctly respecting `simulateBreak` for real-time indent-on-Enter behavior.
18+
- Rails parse accuracy improved from 94.2% to 94.3%.
19+
20+
### Changed
21+
22+
- Known limitations updated: removed fixed items (`::TopLevel`, hash patterns, find patterns), added guard clause limitation explanation.
23+
724
## [0.3.0] - 2026-03-26
825

926
### Added

README.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,20 @@ Tested against popular open source Ruby projects (large, representative files):
3535
| [Devise](https://github.com/heartcombo/devise) | devise.rb | 534 | **98.1%** |
3636
| [Jekyll](https://github.com/jekyll/jekyll) | site.rb | 577 | **97.6%** |
3737
| [Fastlane](https://github.com/fastlane/fastlane) | runner.rb | 379 | **95.0%** |
38-
| [Rails](https://github.com/rails/rails) | query_methods.rb | 2291 | **94.2%** |
38+
| [Rails](https://github.com/rails/rails) | query_methods.rb | 2291 | **94.3%** |
3939
| [Grape](https://github.com/ruby-grape/grape) | api.rb | 166 | **94.0%** |
4040
| [Sidekiq](https://github.com/sidekiq/sidekiq) | config.rb | 321 | **93.5%** |
4141

4242
## What's supported
4343

4444
- **Definitions**: methods (with params, endless `def f(x) = expr`), classes (with inheritance), modules
45-
- **Control flow**: if/elsif/else, unless, while, until, for/in, case/when, case/in (pattern matching)
45+
- **Control flow**: if/elsif/else, unless, while, until, for/in, case/when, case/in (pattern matching with pin operators, hash patterns, find patterns)
4646
- **Error handling**: begin/rescue/ensure/raise
4747
- **Strings**: single-quoted, double-quoted with `#{interpolation}`, heredocs (`<<~DELIM`), `%`-literals with any delimiter
4848
- **Literals**: integers, floats, symbols, character literals (`?a`), arrays, hashes, regex (`/pattern/flags`), nil, true, false
4949
- **Expressions**: assignment (including `||=`, `&&=`), multiple assignment (`a, b = 1, 2`), method calls (with receiver, args, splat `*args`/`**kwargs`/`&block`), chained calls, binary/unary/ternary operators, lambdas, ranges, conditional modifiers
5050
- **Blocks**: brace blocks and do/end blocks attached to method calls (`items.each { |x| x }`)
51-
- **Operators**: proper precedence (`**` > `*`/`/` > `+`/`-` > comparison > logic), safe navigation (`&.`), scope resolution (`::`)
51+
- **Operators**: proper precedence (`**` > `*`/`/` > `+`/`-` > comparison > logic), safe navigation (`&.`), scope resolution (`::` including leading `::TopLevel`)
5252
- **Bare method calls**: `puts "hello"`, `require "json"`, `attr_reader :name`, `include Comparable` (25 common Ruby methods)
5353
- **Variables**: local, `@instance`, `@@class`, `$global`, `Constants`
5454
- **Comments**: line `#` and block `=begin`/`=end`
@@ -57,12 +57,10 @@ Tested against popular open source Ruby projects (large, representative files):
5757
## Known limitations
5858

5959
- **Heredocs as arguments or in chains**`foo(<<~SQL)` and `<<~HEREDOC.strip` don't parse correctly. Heredocs assigned to variables (`x = <<~SQL`) work fine. The limitation is architectural: the heredoc body starts on the next line but the closing `)` or `.method` needs to be parsed on the current line, which requires a split-token approach not yet implemented.
60-
- **Leading `::` scope resolution**`::TopLevel` as a standalone expression doesn't parse. Scoped references with a receiver (`Foo::Bar`, `Foo::Bar::Baz`) work fine.
61-
- **Advanced pattern matching** — Basic `case/in` with array patterns and pin operators work. Hash patterns (`in {name: String => n}`), guard clauses (`in x if x > 0`), and find patterns (`in [*, 2, *]`) are not yet supported.
60+
- **Guard clauses in pattern matching**`in x if x > 0` is not supported. The `if`/`unless` keyword conflicts with `IfStatement`/`ConditionalModifier` in the LR parser and cannot be resolved without an external tokenizer.
6261
- **Heredoc and `%`-literal bodies** are opaque tokens (no interpolation highlighting inside).
6362
- **Inline rescue as a standalone expression**`value = foo rescue nil` works (rescue in assignments), but `foo rescue bar` as a standalone expression outside of assignment context is not supported.
6463
- **Newline as statement separator** — Ruby uses newlines to separate statements, but the grammar is whitespace-insensitive. An expression followed by `if` on the next line may be parsed as a conditional modifier (e.g., `x = 1\nif cond` parses as `x = (1 if cond)`).
65-
- **Symbol-key shorthand in method calls**`foo(name: "value")` with symbol-key syntax is not yet supported. Use rocket syntax `foo(:name => "value")` as a workaround.
6664
- **Line-based indentation** — The indent engine uses regex line scanning rather than tree-based analysis. Most patterns work well, but complex multi-line expressions (e.g., multi-line method args followed by a body, trailing commas inside nested delimiters) may not indent perfectly.
6765

6866
## Development

demo/demo.js

Lines changed: 91 additions & 67 deletions
Large diffs are not rendered by default.

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.3.0",
3+
"version": "0.4.0",
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: 74 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -73,25 +73,41 @@ function opensBlock(text: string): boolean {
7373
return (INDENT_AFTER.test(text) || INDENT_ASSIGN.test(text) || INDENT_END.test(text)) && !SINGLE_LINE.test(text)
7474
}
7575

76+
// Get the text of a line from an IndentContext (respects simulateBreak)
77+
function lineText(cx: IndentContext, lineFrom: number): string {
78+
const line = cx.lineAt(lineFrom)
79+
return line.text
80+
}
81+
82+
// Find the previous line's start position, or -1 if at the first line.
83+
// Uses cx.lineAt() which respects simulateBreak.
84+
function prevLineFrom(cx: IndentContext, lineFrom: number): number {
85+
if (lineFrom <= 0) return -1
86+
const prevLine = cx.lineAt(lineFrom - 1)
87+
return prevLine.from
88+
}
89+
7690
function rubyIndentService(cx: IndentContext, pos: number): number | undefined {
77-
const doc = cx.state.doc
78-
const line = doc.lineAt(pos)
91+
const line = cx.lineAt(pos)
7992
const text = line.text
80-
const lineNum = line.number
93+
const lineFrom = line.from
8194

8295
// Current line starts with a deindent keyword → find the right level
8396
if (DEINDENT_ON.test(text)) {
8497
const isEnd = /^\s*end\b/.test(text)
8598
// Scan backwards to find the matching opening keyword at the right nesting level
8699
let depth = 0
87100
let braceDepth = 0
88-
for (let i = lineNum - 1; i >= 1; i--) {
89-
const prev = doc.line(i).text
101+
let scanFrom = lineFrom
102+
while (true) {
103+
scanFrom = prevLineFrom(cx, scanFrom)
104+
if (scanFrom < 0) break
105+
const prev = lineText(cx, scanFrom)
90106
// Track } closers for brace-style blocks
91107
if (/^\s*\}/.test(prev)) braceDepth++
92108
if (/^\s*end\b/.test(prev)) depth++
93109
else if (INDENT_AFTER.test(prev) || INDENT_ASSIGN.test(prev)) {
94-
if (depth === 0) return cx.lineIndent(doc.line(i).from)
110+
if (depth === 0) return cx.lineIndent(scanFrom)
95111
depth--
96112
} else if (INDENT_END.test(prev)) {
97113
// Distinguish brace-style ({) from do-style openers
@@ -101,13 +117,13 @@ function rubyIndentService(cx: IndentContext, pos: number): number | undefined {
101117
braceDepth--
102118
} else if (!isEnd) {
103119
// Non-end keywords (else, rescue, etc.) can match brace openers
104-
if (depth === 0) return cx.lineIndent(doc.line(i).from)
120+
if (depth === 0) return cx.lineIndent(scanFrom)
105121
depth--
106122
}
107123
// `end` never closes a `{`, so skip this opener
108124
} else {
109125
// do-style opener
110-
if (depth === 0) return cx.lineIndent(doc.line(i).from)
126+
if (depth === 0) return cx.lineIndent(scanFrom)
111127
depth--
112128
}
113129
}
@@ -120,12 +136,15 @@ function rubyIndentService(cx: IndentContext, pos: number): number | undefined {
120136
const closeChar = text.trim()[0]
121137
const openChar = closeChar === "}" ? "{" : closeChar === "]" ? "[" : "("
122138
let depth = 0
123-
for (let i = lineNum - 1; i >= 1; i--) {
124-
const prev = doc.line(i).text
139+
let scanFrom = lineFrom
140+
while (true) {
141+
scanFrom = prevLineFrom(cx, scanFrom)
142+
if (scanFrom < 0) break
143+
const prev = lineText(cx, scanFrom)
125144
for (let j = prev.length - 1; j >= 0; j--) {
126145
if (prev[j] === closeChar) depth++
127146
else if (prev[j] === openChar) {
128-
if (depth === 0) return cx.lineIndent(doc.line(i).from)
147+
if (depth === 0) return cx.lineIndent(scanFrom)
129148
depth--
130149
}
131150
}
@@ -134,15 +153,16 @@ function rubyIndentService(cx: IndentContext, pos: number): number | undefined {
134153
}
135154

136155
// For blank/new lines: determine indent from previous non-blank line
137-
if (lineNum > 1) {
156+
if (lineFrom > 0) {
138157
// Find the previous non-blank line
139-
let prevNum = lineNum - 1
140-
while (prevNum >= 1 && doc.line(prevNum).text.trim() === "") prevNum--
141-
if (prevNum < 1) return 0
158+
let prevFrom = prevLineFrom(cx, lineFrom)
159+
while (prevFrom >= 0 && lineText(cx, prevFrom).trim() === "") {
160+
prevFrom = prevLineFrom(cx, prevFrom)
161+
}
162+
if (prevFrom < 0) return 0
142163

143-
const prevLine = doc.line(prevNum)
144-
const prevText = prevLine.text
145-
const prevIndent = cx.lineIndent(prevLine.from)
164+
const prevText = lineText(cx, prevFrom)
165+
const prevIndent = cx.lineIndent(prevFrom)
146166

147167
// Previous line opens a block → indent
148168
if (opensBlock(prevText)) {
@@ -172,29 +192,33 @@ function rubyIndentService(cx: IndentContext, pos: number): number | undefined {
172192
// Previous line starts with dot but current doesn't → chain ended, deindent
173193
if (LEADING_DOT.test(prevText)) {
174194
// Walk back to find the line that started the chain
175-
let chainStart = prevNum
176-
while (chainStart > 1) {
177-
let checkNum = chainStart - 1
178-
while (checkNum >= 1 && doc.line(checkNum).text.trim() === "") checkNum--
179-
if (checkNum < 1) break
180-
if (LEADING_DOT.test(doc.line(checkNum).text)) {
181-
chainStart = checkNum
195+
let chainFrom = prevFrom
196+
while (true) {
197+
let checkFrom = prevLineFrom(cx, chainFrom)
198+
while (checkFrom >= 0 && lineText(cx, checkFrom).trim() === "") {
199+
checkFrom = prevLineFrom(cx, checkFrom)
200+
}
201+
if (checkFrom < 0) break
202+
if (LEADING_DOT.test(lineText(cx, checkFrom))) {
203+
chainFrom = checkFrom
182204
} else {
183-
chainStart = checkNum
205+
chainFrom = checkFrom
184206
break
185207
}
186208
}
187-
return cx.lineIndent(doc.line(chainStart).from)
209+
return cx.lineIndent(chainFrom)
188210
}
189211

190212
// Previous line ends with continuation (trailing operator, comma, backslash)
191213
if (CONTINUATION.test(prevText)) {
192214
// Check if the line before that was also a continuation — if so, stay at same level
193-
if (prevNum > 1) {
194-
let prev2Num = prevNum - 1
195-
while (prev2Num >= 1 && doc.line(prev2Num).text.trim() === "") prev2Num--
196-
if (prev2Num >= 1) {
197-
const prev2Text = doc.line(prev2Num).text
215+
if (prevFrom > 0) {
216+
let prev2From = prevLineFrom(cx, prevFrom)
217+
while (prev2From >= 0 && lineText(cx, prev2From).trim() === "") {
218+
prev2From = prevLineFrom(cx, prev2From)
219+
}
220+
if (prev2From >= 0) {
221+
const prev2Text = lineText(cx, prev2From)
198222
if (CONTINUATION.test(prev2Text) || LEADING_DOT.test(prev2Text)) {
199223
return prevIndent
200224
}
@@ -204,28 +228,32 @@ function rubyIndentService(cx: IndentContext, pos: number): number | undefined {
204228
}
205229

206230
// Previous line is NOT a continuation, but the one before it was → deindent back
207-
if (prevNum > 1) {
208-
let prev2Num = prevNum - 1
209-
while (prev2Num >= 1 && doc.line(prev2Num).text.trim() === "") prev2Num--
210-
if (prev2Num >= 1) {
211-
const prev2Text = doc.line(prev2Num).text
231+
if (prevFrom > 0) {
232+
let prev2From = prevLineFrom(cx, prevFrom)
233+
while (prev2From >= 0 && lineText(cx, prev2From).trim() === "") {
234+
prev2From = prevLineFrom(cx, prev2From)
235+
}
236+
if (prev2From >= 0) {
237+
const prev2Text = lineText(cx, prev2From)
212238
if ((CONTINUATION.test(prev2Text) || LEADING_DOT.test(prev2Text)) && !opensBlock(prev2Text)) {
213239
// The continuation chain ended — go back to the original indent level
214240
// Walk back to find the start of the chain
215-
let chainStart = prev2Num
216-
while (chainStart > 1) {
217-
let checkNum = chainStart - 1
218-
while (checkNum >= 1 && doc.line(checkNum).text.trim() === "") checkNum--
219-
if (checkNum < 1) break
220-
const checkText = doc.line(checkNum).text
241+
let chainFrom = prev2From
242+
while (true) {
243+
let checkFrom = prevLineFrom(cx, chainFrom)
244+
while (checkFrom >= 0 && lineText(cx, checkFrom).trim() === "") {
245+
checkFrom = prevLineFrom(cx, checkFrom)
246+
}
247+
if (checkFrom < 0) break
248+
const checkText = lineText(cx, checkFrom)
221249
if (CONTINUATION.test(checkText) || LEADING_DOT.test(checkText)) {
222-
chainStart = checkNum
250+
chainFrom = checkFrom
223251
} else {
224-
chainStart = checkNum
252+
chainFrom = checkFrom
225253
break
226254
}
227255
}
228-
return cx.lineIndent(doc.line(chainStart).from)
256+
return cx.lineIndent(chainFrom)
229257
}
230258
}
231259
}

src/syntax.grammar

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ ModuleDef {
127127
}
128128

129129
// Scoped class/module names: Foo, Foo::Bar, ::TopLevel::Foo
130-
className { Constant ~rescue ("::" Constant)* }
130+
className { "::"? Constant ~rescue (~scopeOrClass "::" Constant)* ~scopeOrClass }
131131

132132
ModuleBody { statement* }
133133

@@ -174,12 +174,24 @@ WhenClause {
174174
// keyword conflicts with IfStatement/UnlessStatement in the LR parser.
175175
// Hash patterns ({name: Type => var}) and find patterns ([*, x, *]) are also deferred.
176176
InClause {
177-
kw<"in"> (PinExpression | PatternBinding | expression) statement*
177+
kw<"in"> (PinExpression | HashPattern | FindPattern | PatternBinding | expression) statement*
178178
}
179179

180+
// Find pattern for pattern matching: [*, value, *], [*pre, value, *post]
181+
// Must start with * to unambiguously distinguish from regular Array.
182+
FindPattern { "[" "*" Identifier? ("," findPatternElement)* "]" }
183+
findPatternElement { "*" Identifier? | expression }
184+
180185
// Pattern binding: match a type and bind to a variable (e.g., Integer => n)
181186
PatternBinding { expression "=>" Identifier }
182187

188+
// Hash pattern for pattern matching: {name: String => n}, {name:}
189+
HashPattern { ~blockOrHash "{" (HashPatternPair ("," HashPatternPair)* ","?)? "}" ~blockOrHash }
190+
HashPatternPair {
191+
Identifier colonOp expression ~hashOrPattern ("=>" Identifier)? |
192+
Identifier colonOp
193+
}
194+
183195
// Pin operator — scoped to InClause only, since ^ is bitwise XOR elsewhere
184196
PinExpression { "^" expression }
185197

@@ -289,9 +301,10 @@ MethodCall {
289301
Constant !call ArgList
290302
}
291303

292-
// Scope resolution: Foo::Bar, Foo::Bar::Baz, self::method
304+
// Scope resolution: Foo::Bar, Foo::Bar::Baz, self::method, ::TopLevel
293305
ScopeResolution {
294-
expression !member "::" (Constant | Identifier)
306+
expression !member "::" (Constant | Identifier) |
307+
"::" (Constant | Identifier)
295308
}
296309

297310
// Subscript access: arr[0], hash[:key], matrix[i][j]
@@ -412,7 +425,7 @@ InterpolationEnd[openedBy=InterpolationStart] { "}" }
412425

413426
Array { "[" commaSep<expression> "]" }
414427
Hash { ~blockOrHash "{" (HashPair ("," HashPair)* ","?)? "}" ~blockOrHash }
415-
HashPair { expression "=>" expression | Identifier colonOp expression }
428+
HashPair { expression "=>" expression | Identifier colonOp expression ~hashOrPattern }
416429

417430
@precedence {
418431
member @left,

0 commit comments

Comments
 (0)