Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.0] - 2026-03-26

### Added

- **`::TopLevel` leading scope resolution** — `::TopLevel`, `::TopLevel::Foo`, and `::Foo.bar` now parse correctly. Class/module definitions with leading `::` also supported.
- **Hash patterns in `case/in`** — Pattern matching now supports `in {name: String => n}` and `in {name:}` shorthand via new `HashPattern` rule.
- **Find patterns in `case/in`** — Pattern matching now supports `in [*, 2, *]` and `in [*pre, value, *post]` via new `FindPattern` rule.

### Fixed

- **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.
- Rails parse accuracy improved from 94.2% to 94.3%.

### Changed

- Known limitations updated: removed fixed items (`::TopLevel`, hash patterns, find patterns), added guard clause limitation explanation.

## [0.3.0] - 2026-03-26

### Added
Expand Down
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,20 @@ Tested against popular open source Ruby projects (large, representative files):
| [Devise](https://github.com/heartcombo/devise) | devise.rb | 534 | **98.1%** |
| [Jekyll](https://github.com/jekyll/jekyll) | site.rb | 577 | **97.6%** |
| [Fastlane](https://github.com/fastlane/fastlane) | runner.rb | 379 | **95.0%** |
| [Rails](https://github.com/rails/rails) | query_methods.rb | 2291 | **94.2%** |
| [Rails](https://github.com/rails/rails) | query_methods.rb | 2291 | **94.3%** |
| [Grape](https://github.com/ruby-grape/grape) | api.rb | 166 | **94.0%** |
| [Sidekiq](https://github.com/sidekiq/sidekiq) | config.rb | 321 | **93.5%** |

## What's supported

- **Definitions**: methods (with params, endless `def f(x) = expr`), classes (with inheritance), modules
- **Control flow**: if/elsif/else, unless, while, until, for/in, case/when, case/in (pattern matching)
- **Control flow**: if/elsif/else, unless, while, until, for/in, case/when, case/in (pattern matching with pin operators, hash patterns, find patterns)
- **Error handling**: begin/rescue/ensure/raise
- **Strings**: single-quoted, double-quoted with `#{interpolation}`, heredocs (`<<~DELIM`), `%`-literals with any delimiter
- **Literals**: integers, floats, symbols, character literals (`?a`), arrays, hashes, regex (`/pattern/flags`), nil, true, false
- **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
- **Blocks**: brace blocks and do/end blocks attached to method calls (`items.each { |x| x }`)
- **Operators**: proper precedence (`**` > `*`/`/` > `+`/`-` > comparison > logic), safe navigation (`&.`), scope resolution (`::`)
- **Operators**: proper precedence (`**` > `*`/`/` > `+`/`-` > comparison > logic), safe navigation (`&.`), scope resolution (`::` including leading `::TopLevel`)
- **Bare method calls**: `puts "hello"`, `require "json"`, `attr_reader :name`, `include Comparable` (25 common Ruby methods)
- **Variables**: local, `@instance`, `@@class`, `$global`, `Constants`
- **Comments**: line `#` and block `=begin`/`=end`
Expand All @@ -57,12 +57,10 @@ Tested against popular open source Ruby projects (large, representative files):
## Known limitations

- **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.
- **Leading `::` scope resolution** — `::TopLevel` as a standalone expression doesn't parse. Scoped references with a receiver (`Foo::Bar`, `Foo::Bar::Baz`) work fine.
- **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.
- **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.
- **Heredoc and `%`-literal bodies** are opaque tokens (no interpolation highlighting inside).
- **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.
- **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)`).
- **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.
- **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.

## Development
Expand Down
158 changes: 91 additions & 67 deletions demo/demo.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codemirror-lang-ruby",
"version": "0.3.0",
"version": "0.4.0",
"description": "Ruby language support for CodeMirror 6, built on a Lezer grammar",
"type": "module",
"main": "dist/index.cjs",
Expand Down
120 changes: 74 additions & 46 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,25 +73,41 @@ function opensBlock(text: string): boolean {
return (INDENT_AFTER.test(text) || INDENT_ASSIGN.test(text) || INDENT_END.test(text)) && !SINGLE_LINE.test(text)
}

// Get the text of a line from an IndentContext (respects simulateBreak)
function lineText(cx: IndentContext, lineFrom: number): string {
const line = cx.lineAt(lineFrom)
return line.text
}

// Find the previous line's start position, or -1 if at the first line.
// Uses cx.lineAt() which respects simulateBreak.
function prevLineFrom(cx: IndentContext, lineFrom: number): number {
if (lineFrom <= 0) return -1
const prevLine = cx.lineAt(lineFrom - 1)
return prevLine.from
}

function rubyIndentService(cx: IndentContext, pos: number): number | undefined {
const doc = cx.state.doc
const line = doc.lineAt(pos)
const line = cx.lineAt(pos)
const text = line.text
const lineNum = line.number
const lineFrom = line.from

// Current line starts with a deindent keyword → find the right level
if (DEINDENT_ON.test(text)) {
const isEnd = /^\s*end\b/.test(text)
// Scan backwards to find the matching opening keyword at the right nesting level
let depth = 0
let braceDepth = 0
for (let i = lineNum - 1; i >= 1; i--) {
const prev = doc.line(i).text
let scanFrom = lineFrom
while (true) {
scanFrom = prevLineFrom(cx, scanFrom)
if (scanFrom < 0) break
const prev = lineText(cx, scanFrom)
// Track } closers for brace-style blocks
if (/^\s*\}/.test(prev)) braceDepth++
if (/^\s*end\b/.test(prev)) depth++
else if (INDENT_AFTER.test(prev) || INDENT_ASSIGN.test(prev)) {
if (depth === 0) return cx.lineIndent(doc.line(i).from)
if (depth === 0) return cx.lineIndent(scanFrom)
depth--
} else if (INDENT_END.test(prev)) {
// Distinguish brace-style ({) from do-style openers
Expand All @@ -101,13 +117,13 @@ function rubyIndentService(cx: IndentContext, pos: number): number | undefined {
braceDepth--
} else if (!isEnd) {
// Non-end keywords (else, rescue, etc.) can match brace openers
if (depth === 0) return cx.lineIndent(doc.line(i).from)
if (depth === 0) return cx.lineIndent(scanFrom)
depth--
}
// `end` never closes a `{`, so skip this opener
} else {
// do-style opener
if (depth === 0) return cx.lineIndent(doc.line(i).from)
if (depth === 0) return cx.lineIndent(scanFrom)
depth--
}
}
Expand All @@ -120,12 +136,15 @@ function rubyIndentService(cx: IndentContext, pos: number): number | undefined {
const closeChar = text.trim()[0]
const openChar = closeChar === "}" ? "{" : closeChar === "]" ? "[" : "("
let depth = 0
for (let i = lineNum - 1; i >= 1; i--) {
const prev = doc.line(i).text
let scanFrom = lineFrom
while (true) {
scanFrom = prevLineFrom(cx, scanFrom)
if (scanFrom < 0) break
const prev = lineText(cx, scanFrom)
for (let j = prev.length - 1; j >= 0; j--) {
if (prev[j] === closeChar) depth++
else if (prev[j] === openChar) {
if (depth === 0) return cx.lineIndent(doc.line(i).from)
if (depth === 0) return cx.lineIndent(scanFrom)
depth--
}
}
Expand All @@ -134,15 +153,16 @@ function rubyIndentService(cx: IndentContext, pos: number): number | undefined {
}

// For blank/new lines: determine indent from previous non-blank line
if (lineNum > 1) {
if (lineFrom > 0) {
// Find the previous non-blank line
let prevNum = lineNum - 1
while (prevNum >= 1 && doc.line(prevNum).text.trim() === "") prevNum--
if (prevNum < 1) return 0
let prevFrom = prevLineFrom(cx, lineFrom)
while (prevFrom >= 0 && lineText(cx, prevFrom).trim() === "") {
prevFrom = prevLineFrom(cx, prevFrom)
}
if (prevFrom < 0) return 0

const prevLine = doc.line(prevNum)
const prevText = prevLine.text
const prevIndent = cx.lineIndent(prevLine.from)
const prevText = lineText(cx, prevFrom)
const prevIndent = cx.lineIndent(prevFrom)

// Previous line opens a block → indent
if (opensBlock(prevText)) {
Expand Down Expand Up @@ -172,29 +192,33 @@ function rubyIndentService(cx: IndentContext, pos: number): number | undefined {
// Previous line starts with dot but current doesn't → chain ended, deindent
if (LEADING_DOT.test(prevText)) {
// Walk back to find the line that started the chain
let chainStart = prevNum
while (chainStart > 1) {
let checkNum = chainStart - 1
while (checkNum >= 1 && doc.line(checkNum).text.trim() === "") checkNum--
if (checkNum < 1) break
if (LEADING_DOT.test(doc.line(checkNum).text)) {
chainStart = checkNum
let chainFrom = prevFrom
while (true) {
let checkFrom = prevLineFrom(cx, chainFrom)
while (checkFrom >= 0 && lineText(cx, checkFrom).trim() === "") {
checkFrom = prevLineFrom(cx, checkFrom)
}
if (checkFrom < 0) break
if (LEADING_DOT.test(lineText(cx, checkFrom))) {
chainFrom = checkFrom
} else {
chainStart = checkNum
chainFrom = checkFrom
break
}
}
return cx.lineIndent(doc.line(chainStart).from)
return cx.lineIndent(chainFrom)
}

// Previous line ends with continuation (trailing operator, comma, backslash)
if (CONTINUATION.test(prevText)) {
// Check if the line before that was also a continuation — if so, stay at same level
if (prevNum > 1) {
let prev2Num = prevNum - 1
while (prev2Num >= 1 && doc.line(prev2Num).text.trim() === "") prev2Num--
if (prev2Num >= 1) {
const prev2Text = doc.line(prev2Num).text
if (prevFrom > 0) {
let prev2From = prevLineFrom(cx, prevFrom)
while (prev2From >= 0 && lineText(cx, prev2From).trim() === "") {
prev2From = prevLineFrom(cx, prev2From)
}
if (prev2From >= 0) {
const prev2Text = lineText(cx, prev2From)
if (CONTINUATION.test(prev2Text) || LEADING_DOT.test(prev2Text)) {
return prevIndent
}
Expand All @@ -204,28 +228,32 @@ function rubyIndentService(cx: IndentContext, pos: number): number | undefined {
}

// Previous line is NOT a continuation, but the one before it was → deindent back
if (prevNum > 1) {
let prev2Num = prevNum - 1
while (prev2Num >= 1 && doc.line(prev2Num).text.trim() === "") prev2Num--
if (prev2Num >= 1) {
const prev2Text = doc.line(prev2Num).text
if (prevFrom > 0) {
let prev2From = prevLineFrom(cx, prevFrom)
while (prev2From >= 0 && lineText(cx, prev2From).trim() === "") {
prev2From = prevLineFrom(cx, prev2From)
}
if (prev2From >= 0) {
const prev2Text = lineText(cx, prev2From)
if ((CONTINUATION.test(prev2Text) || LEADING_DOT.test(prev2Text)) && !opensBlock(prev2Text)) {
// The continuation chain ended — go back to the original indent level
// Walk back to find the start of the chain
let chainStart = prev2Num
while (chainStart > 1) {
let checkNum = chainStart - 1
while (checkNum >= 1 && doc.line(checkNum).text.trim() === "") checkNum--
if (checkNum < 1) break
const checkText = doc.line(checkNum).text
let chainFrom = prev2From
while (true) {
let checkFrom = prevLineFrom(cx, chainFrom)
while (checkFrom >= 0 && lineText(cx, checkFrom).trim() === "") {
checkFrom = prevLineFrom(cx, checkFrom)
}
if (checkFrom < 0) break
const checkText = lineText(cx, checkFrom)
if (CONTINUATION.test(checkText) || LEADING_DOT.test(checkText)) {
chainStart = checkNum
chainFrom = checkFrom
} else {
chainStart = checkNum
chainFrom = checkFrom
break
}
}
return cx.lineIndent(doc.line(chainStart).from)
return cx.lineIndent(chainFrom)
}
}
}
Expand Down
23 changes: 18 additions & 5 deletions src/syntax.grammar
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ ModuleDef {
}

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

ModuleBody { statement* }

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

// Find pattern for pattern matching: [*, value, *], [*pre, value, *post]
// Must start with * to unambiguously distinguish from regular Array.
FindPattern { "[" "*" Identifier? ("," findPatternElement)* "]" }
findPatternElement { "*" Identifier? | expression }

// Pattern binding: match a type and bind to a variable (e.g., Integer => n)
PatternBinding { expression "=>" Identifier }

// Hash pattern for pattern matching: {name: String => n}, {name:}
HashPattern { ~blockOrHash "{" (HashPatternPair ("," HashPatternPair)* ","?)? "}" ~blockOrHash }
HashPatternPair {
Identifier colonOp expression ~hashOrPattern ("=>" Identifier)? |
Identifier colonOp
}

// Pin operator — scoped to InClause only, since ^ is bitwise XOR elsewhere
PinExpression { "^" expression }

Expand Down Expand Up @@ -289,9 +301,10 @@ MethodCall {
Constant !call ArgList
}

// Scope resolution: Foo::Bar, Foo::Bar::Baz, self::method
// Scope resolution: Foo::Bar, Foo::Bar::Baz, self::method, ::TopLevel
ScopeResolution {
expression !member "::" (Constant | Identifier)
expression !member "::" (Constant | Identifier) |
"::" (Constant | Identifier)
}

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

Array { "[" commaSep<expression> "]" }
Hash { ~blockOrHash "{" (HashPair ("," HashPair)* ","?)? "}" ~blockOrHash }
HashPair { expression "=>" expression | Identifier colonOp expression }
HashPair { expression "=>" expression | Identifier colonOp expression ~hashOrPattern }

@precedence {
member @left,
Expand Down
Loading