mui 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +18 -10
  3. data/CHANGELOG.md +162 -0
  4. data/README.md +309 -6
  5. data/docs/_config.yml +56 -0
  6. data/docs/configuration.md +314 -0
  7. data/docs/getting-started.md +140 -0
  8. data/docs/index.md +55 -0
  9. data/docs/jobs.md +297 -0
  10. data/docs/keybindings.md +229 -0
  11. data/docs/plugins.md +285 -0
  12. data/docs/syntax-highlighting.md +155 -0
  13. data/lib/mui/color_manager.rb +140 -6
  14. data/lib/mui/color_scheme.rb +1 -0
  15. data/lib/mui/command_completer.rb +11 -2
  16. data/lib/mui/command_history.rb +89 -0
  17. data/lib/mui/command_line.rb +32 -2
  18. data/lib/mui/command_registry.rb +21 -2
  19. data/lib/mui/config.rb +3 -1
  20. data/lib/mui/editor.rb +90 -2
  21. data/lib/mui/floating_window.rb +53 -1
  22. data/lib/mui/handler_result.rb +13 -7
  23. data/lib/mui/highlighters/search_highlighter.rb +2 -1
  24. data/lib/mui/highlighters/syntax_highlighter.rb +4 -1
  25. data/lib/mui/insert_completion_state.rb +15 -2
  26. data/lib/mui/key_handler/base.rb +87 -0
  27. data/lib/mui/key_handler/command_mode.rb +68 -0
  28. data/lib/mui/key_handler/insert_mode.rb +10 -41
  29. data/lib/mui/key_handler/normal_mode.rb +24 -51
  30. data/lib/mui/key_handler/operators/paste_operator.rb +9 -3
  31. data/lib/mui/key_handler/search_mode.rb +10 -7
  32. data/lib/mui/key_handler/visual_mode.rb +15 -10
  33. data/lib/mui/key_notation_parser.rb +159 -0
  34. data/lib/mui/key_sequence.rb +67 -0
  35. data/lib/mui/key_sequence_buffer.rb +85 -0
  36. data/lib/mui/key_sequence_handler.rb +163 -0
  37. data/lib/mui/key_sequence_matcher.rb +79 -0
  38. data/lib/mui/line_renderer.rb +52 -1
  39. data/lib/mui/mode_manager.rb +3 -2
  40. data/lib/mui/screen.rb +30 -6
  41. data/lib/mui/search_state.rb +74 -27
  42. data/lib/mui/syntax/language_detector.rb +33 -1
  43. data/lib/mui/syntax/lexers/c_lexer.rb +2 -0
  44. data/lib/mui/syntax/lexers/css_lexer.rb +121 -0
  45. data/lib/mui/syntax/lexers/go_lexer.rb +207 -0
  46. data/lib/mui/syntax/lexers/html_lexer.rb +118 -0
  47. data/lib/mui/syntax/lexers/javascript_lexer.rb +219 -0
  48. data/lib/mui/syntax/lexers/markdown_lexer.rb +210 -0
  49. data/lib/mui/syntax/lexers/ruby_lexer.rb +3 -0
  50. data/lib/mui/syntax/lexers/rust_lexer.rb +150 -0
  51. data/lib/mui/syntax/lexers/typescript_lexer.rb +225 -0
  52. data/lib/mui/terminal_adapter/base.rb +21 -0
  53. data/lib/mui/terminal_adapter/curses.rb +37 -11
  54. data/lib/mui/themes/default.rb +263 -132
  55. data/lib/mui/version.rb +1 -1
  56. data/lib/mui/window.rb +105 -39
  57. data/lib/mui/window_manager.rb +7 -0
  58. data/lib/mui/wrap_cache.rb +40 -0
  59. data/lib/mui/wrap_helper.rb +84 -0
  60. data/lib/mui.rb +15 -0
  61. metadata +26 -3
data/lib/mui/screen.rb CHANGED
@@ -25,13 +25,19 @@ module Mui
25
25
  @adapter.clear
26
26
  end
27
27
 
28
+ # Force a complete redraw of the screen
29
+ # This is needed when multibyte characters may have been corrupted
30
+ def touchwin
31
+ @adapter.touchwin
32
+ end
33
+
28
34
  def put(y, x, text)
29
35
  return if y.negative?
30
36
  return if y >= @height || x >= @width
31
37
 
32
38
  @adapter.setpos(y, x)
33
- max_len = @width - x
34
- @adapter.addstr(text.length > max_len ? text[0, max_len] : text)
39
+ max_width = @width - x
40
+ @adapter.addstr(truncate_to_width(text, max_width))
35
41
  end
36
42
 
37
43
  def put_with_highlight(y, x, text)
@@ -39,9 +45,9 @@ module Mui
39
45
  return if y >= @height || x >= @width
40
46
 
41
47
  @adapter.setpos(y, x)
42
- max_len = @width - x
48
+ max_width = @width - x
43
49
  @adapter.with_highlight do
44
- @adapter.addstr(text.length > max_len ? text[0, max_len] : text)
50
+ @adapter.addstr(truncate_to_width(text, max_width))
45
51
  end
46
52
  end
47
53
 
@@ -51,8 +57,8 @@ module Mui
51
57
  return put(y, x, text) unless @color_manager && style
52
58
 
53
59
  @adapter.setpos(y, x)
54
- max_len = @width - x
55
- truncated_text = text.length > max_len ? text[0, max_len] : text
60
+ max_width = @width - x
61
+ truncated_text = truncate_to_width(text, max_width)
56
62
 
57
63
  pair_index = ensure_color_pair(style[:fg], style[:bg])
58
64
  @adapter.with_color(pair_index, bold: style[:bold], underline: style[:underline]) do
@@ -81,5 +87,23 @@ module Mui
81
87
  @width = @adapter.width
82
88
  @height = @adapter.height
83
89
  end
90
+
91
+ # Truncates text to fit within max_width display columns
92
+ def truncate_to_width(text, max_width)
93
+ return text if max_width <= 0
94
+
95
+ current_width = 0
96
+ result = String.new
97
+
98
+ text.each_char do |char|
99
+ char_w = UnicodeWidth.char_width(char)
100
+ break if current_width + char_w > max_width
101
+
102
+ result << char
103
+ current_width += char_w
104
+ end
105
+
106
+ result
107
+ end
84
108
  end
85
109
  end
@@ -2,87 +2,134 @@
2
2
 
3
3
  module Mui
4
4
  class SearchState
5
- attr_reader :pattern, :direction, :matches
5
+ attr_reader :pattern, :direction
6
6
 
7
7
  def initialize
8
8
  @pattern = nil
9
9
  @direction = :forward
10
- @matches = []
10
+ @pattern_version = 0
11
+ @buffer_matches = {} # { buffer_object_id => { version:, matches: [] } }
11
12
  end
12
13
 
13
14
  def set_pattern(pattern, direction)
14
15
  @pattern = pattern
15
16
  @direction = direction
16
- @matches = []
17
+ @pattern_version += 1
18
+ @buffer_matches.clear # Invalidate all cached matches
17
19
  end
18
20
 
21
+ # Calculate matches for a specific buffer (used for n/N navigation)
19
22
  def find_all_matches(buffer)
20
- @matches = []
21
- return if @pattern.nil? || @pattern.empty?
23
+ return [] if @pattern.nil? || @pattern.empty? || buffer.nil?
22
24
 
23
- begin
24
- regex = Regexp.new(@pattern)
25
- buffer.line_count.times do |row|
26
- line = buffer.line(row)
27
- scan_line_matches(line, row, regex)
28
- end
29
- rescue RegexpError
30
- # Invalid regex pattern - no matches
31
- @matches = []
32
- end
25
+ get_or_calculate_matches(buffer)
33
26
  end
34
27
 
35
- def find_next(current_row, current_col)
36
- return nil if @matches.empty?
28
+ def find_next(current_row, current_col, buffer: nil)
29
+ matches = buffer ? get_or_calculate_matches(buffer) : []
30
+ return nil if matches.empty?
37
31
 
38
32
  # Find next match after current position
39
- match = @matches.find do |m|
33
+ match = matches.find do |m|
40
34
  m[:row] > current_row || (m[:row] == current_row && m[:col] > current_col)
41
35
  end
42
36
 
43
37
  # Wrap around to beginning if no match found
44
- match || @matches.first
38
+ match || matches.first
45
39
  end
46
40
 
47
- def find_previous(current_row, current_col)
48
- return nil if @matches.empty?
41
+ def find_previous(current_row, current_col, buffer: nil)
42
+ matches = buffer ? get_or_calculate_matches(buffer) : []
43
+ return nil if matches.empty?
49
44
 
50
45
  # Find previous match before current position
51
- match = @matches.reverse.find do |m|
46
+ match = matches.reverse.find do |m|
52
47
  m[:row] < current_row || (m[:row] == current_row && m[:col] < current_col)
53
48
  end
54
49
 
55
50
  # Wrap around to end if no match found
56
- match || @matches.last
51
+ match || matches.last
57
52
  end
58
53
 
59
54
  def clear
60
55
  @pattern = nil
61
- @matches = []
56
+ @pattern_version += 1
57
+ @buffer_matches.clear
62
58
  end
63
59
 
64
60
  def has_pattern?
65
61
  !@pattern.nil? && !@pattern.empty?
66
62
  end
67
63
 
68
- def matches_for_row(row)
69
- @matches.select { |m| m[:row] == row }
64
+ # Get matches for a specific row in a specific buffer
65
+ # O(1) lookup using row_index
66
+ def matches_for_row(row, buffer: nil)
67
+ return [] if buffer.nil?
68
+
69
+ cache = get_or_calculate_cache(buffer)
70
+ cache[:row_index][row] || []
70
71
  end
71
72
 
72
73
  private
73
74
 
75
+ def get_or_calculate_matches(buffer)
76
+ get_or_calculate_cache(buffer)[:matches]
77
+ end
78
+
79
+ def get_or_calculate_cache(buffer)
80
+ buffer_id = buffer.object_id
81
+ cached = @buffer_matches[buffer_id]
82
+
83
+ # Return cached data if valid (same pattern version and buffer hasn't changed)
84
+ return cached if cached && cached[:version] == @pattern_version && cached[:change_count] == buffer.change_count
85
+
86
+ # Calculate and cache matches for this buffer
87
+ matches, row_index = calculate_matches(buffer)
88
+ @buffer_matches[buffer_id] = {
89
+ version: @pattern_version,
90
+ change_count: buffer.change_count,
91
+ matches:,
92
+ row_index:
93
+ }
94
+ @buffer_matches[buffer_id]
95
+ end
96
+
97
+ def calculate_matches(buffer)
98
+ empty_result = [[], {}]
99
+ return empty_result if @pattern.nil? || @pattern.empty?
100
+
101
+ matches = []
102
+ row_index = {}
103
+ begin
104
+ regex = Regexp.new(@pattern)
105
+ buffer.line_count.times do |row|
106
+ line = buffer.line(row)
107
+ row_matches = scan_line_matches(line, row, regex)
108
+ unless row_matches.empty?
109
+ matches.concat(row_matches)
110
+ row_index[row] = row_matches
111
+ end
112
+ end
113
+ rescue RegexpError
114
+ # Invalid regex pattern - no matches
115
+ end
116
+ [matches, row_index]
117
+ end
118
+
74
119
  def scan_line_matches(line, row, regex)
120
+ matches = []
75
121
  offset = 0
76
122
  while (match_data = line.match(regex, offset))
77
123
  col = match_data.begin(0)
78
124
  end_col = match_data.end(0) - 1
79
- @matches << { row:, col:, end_col: }
125
+ matches << { row:, col:, end_col: }
80
126
  # Move offset past the end of the match to avoid overlapping matches
81
127
  offset = match_data.end(0)
82
128
  # Handle zero-length matches to prevent infinite loop
83
129
  offset += 1 if match_data[0].empty?
84
130
  break if offset >= line.length
85
131
  end
132
+ matches
86
133
  end
87
134
  end
88
135
  end
@@ -12,7 +12,25 @@ module Mui
12
12
  ".gemspec" => :ruby,
13
13
  ".c" => :c,
14
14
  ".h" => :c,
15
- ".y" => :c
15
+ ".y" => :c,
16
+ ".go" => :go,
17
+ ".rs" => :rust,
18
+ ".js" => :javascript,
19
+ ".mjs" => :javascript,
20
+ ".cjs" => :javascript,
21
+ ".jsx" => :javascript,
22
+ ".ts" => :typescript,
23
+ ".tsx" => :typescript,
24
+ ".mts" => :typescript,
25
+ ".cts" => :typescript,
26
+ ".md" => :markdown,
27
+ ".markdown" => :markdown,
28
+ ".html" => :html,
29
+ ".htm" => :html,
30
+ ".xhtml" => :html,
31
+ ".css" => :css,
32
+ ".scss" => :css,
33
+ ".sass" => :css
16
34
  }.freeze
17
35
 
18
36
  # Map basenames (files without extension) to language symbols
@@ -50,6 +68,20 @@ module Mui
50
68
  Lexers::RubyLexer.new
51
69
  when :c
52
70
  Lexers::CLexer.new
71
+ when :go
72
+ Lexers::GoLexer.new
73
+ when :rust
74
+ Lexers::RustLexer.new
75
+ when :javascript
76
+ Lexers::JavaScriptLexer.new
77
+ when :typescript
78
+ Lexers::TypeScriptLexer.new
79
+ when :markdown
80
+ Lexers::MarkdownLexer.new
81
+ when :html
82
+ Lexers::HtmlLexer.new
83
+ when :css
84
+ Lexers::CssLexer.new
53
85
  end
54
86
  end
55
87
 
@@ -30,6 +30,8 @@ module Mui
30
30
  [:type, /\G\b(?:char|double|float|int|long|short|signed|unsigned|void|_Bool|_Complex|_Imaginary)\b/],
31
31
  # Other keywords (if, for, return, const, static, etc.)
32
32
  [:keyword, /\G\b(?:auto|break|case|const|continue|default|do|else|enum|extern|for|goto|if|register|return|sizeof|static|struct|switch|typedef|union|volatile|while|inline|restrict|_Alignas|_Alignof|_Atomic|_Generic|_Noreturn|_Static_assert|_Thread_local)\b/],
33
+ # Function call/definition (identifier followed by parenthesis)
34
+ [:function_definition, /\G\b[a-zA-Z_][a-zA-Z0-9_]*(?=\s*\()/],
33
35
  # Identifiers
34
36
  [:identifier, /\G\b[a-zA-Z_][a-zA-Z0-9_]*\b/],
35
37
  # Operators
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Syntax
5
+ module Lexers
6
+ # Lexer for CSS source files
7
+ class CssLexer < LexerBase
8
+ # Pre-compiled patterns with \G anchor for position-specific matching
9
+ COMPILED_PATTERNS = [
10
+ # Single-line block comment /* ... */ on one line
11
+ [:comment, %r{\G/\*.*?\*/}],
12
+ # @rules (at-rules)
13
+ [:preprocessor, /\G@[a-zA-Z-]+/],
14
+ # Hex color (must be before ID selector - matches 3-8 hex digits only)
15
+ [:number, /\G#[0-9a-fA-F]{3,8}(?![a-zA-Z0-9_-])/],
16
+ # ID selector (starts with letter or underscore/hyphen after #)
17
+ [:constant, /\G#[a-zA-Z_-][a-zA-Z0-9_-]*/],
18
+ # Class selector
19
+ [:type, /\G\.[a-zA-Z_-][a-zA-Z0-9_-]*/],
20
+ # Pseudo-elements and pseudo-classes
21
+ [:keyword, /\G::?[a-zA-Z-]+(?:\([^)]*\))?/],
22
+ # Property name (followed by colon)
23
+ [:identifier, /\G[a-zA-Z-]+(?=\s*:)/],
24
+ # Double quoted string
25
+ [:string, /\G"(?:[^"\\]|\\.)*"/],
26
+ # Single quoted string
27
+ [:string, /\G'(?:[^'\\]|\\.)*'/],
28
+ # URL function
29
+ [:string, /\Gurl\([^)]*\)/i],
30
+ # Numbers with units
31
+ [:number, /\G-?\d+\.?\d*(?:px|em|rem|%|vh|vw|vmin|vmax|ch|ex|cm|mm|in|pt|pc|deg|rad|grad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx|fr)?/i],
32
+ # Functions (calc, rgb, rgba, hsl, var, etc.)
33
+ [:keyword, /\G[a-zA-Z-]+(?=\()/],
34
+ # Property values / keywords (important, inherit, etc.)
35
+ [:constant, /\G!important\b/i],
36
+ [:constant, /\G\b(?:inherit|initial|unset|revert|none|auto|normal)\b/],
37
+ # Element selectors and identifiers
38
+ [:identifier, /\G[a-zA-Z_-][a-zA-Z0-9_-]*/],
39
+ # Operators and symbols
40
+ [:operator, /\G[{}():;,>+~*=\[\]]/]
41
+ ].freeze
42
+
43
+ # Multiline comment patterns
44
+ BLOCK_COMMENT_END = %r{\*/}
45
+ BLOCK_COMMENT_START = %r{/\*}
46
+ BLOCK_COMMENT_START_ANCHOR = %r{\A/\*}
47
+
48
+ protected
49
+
50
+ def compiled_patterns
51
+ COMPILED_PATTERNS
52
+ end
53
+
54
+ # Handle /* ... */ block comments that span multiple lines
55
+ def handle_multiline_state(line, pos, state)
56
+ return [nil, nil, pos] unless state == :block_comment
57
+
58
+ end_match = line[pos..].match(BLOCK_COMMENT_END)
59
+ if end_match
60
+ end_pos = pos + end_match.begin(0) + 1
61
+ text = line[pos..end_pos]
62
+ token = Token.new(
63
+ type: :comment,
64
+ start_col: pos,
65
+ end_col: end_pos,
66
+ text:
67
+ )
68
+ [token, nil, end_pos + 1]
69
+ else
70
+ text = line[pos..]
71
+ token = if text.empty?
72
+ nil
73
+ else
74
+ Token.new(
75
+ type: :comment,
76
+ start_col: pos,
77
+ end_col: line.length - 1,
78
+ text:
79
+ )
80
+ end
81
+ [token, :block_comment, line.length]
82
+ end
83
+ end
84
+
85
+ def check_multiline_start(line, pos)
86
+ rest = line[pos..]
87
+
88
+ start_match = rest.match(BLOCK_COMMENT_START)
89
+ return [nil, nil, pos] unless start_match
90
+
91
+ start_pos = pos + start_match.begin(0)
92
+ after_start = line[(start_pos + 2)..]
93
+
94
+ if after_start&.include?("*/")
95
+ [nil, nil, pos]
96
+ else
97
+ text = line[start_pos..]
98
+ token = Token.new(
99
+ type: :comment,
100
+ start_col: start_pos,
101
+ end_col: line.length - 1,
102
+ text:
103
+ )
104
+ [:block_comment, token, line.length]
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def match_token(line, pos)
111
+ if line[pos..].match?(BLOCK_COMMENT_START_ANCHOR)
112
+ rest = line[(pos + 2)..]
113
+ return nil unless rest&.include?("*/")
114
+ end
115
+
116
+ super
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Syntax
5
+ module Lexers
6
+ # Lexer for Go source code
7
+ class GoLexer < LexerBase
8
+ # Go keywords
9
+ KEYWORDS = %w[
10
+ break case chan const continue default defer else fallthrough
11
+ for func go goto if import interface map package range return
12
+ select struct switch type var
13
+ ].freeze
14
+
15
+ # Go built-in types
16
+ TYPES = %w[
17
+ bool byte complex64 complex128 error float32 float64
18
+ int int8 int16 int32 int64 rune string
19
+ uint uint8 uint16 uint32 uint64 uintptr
20
+ any comparable
21
+ ].freeze
22
+
23
+ # Go constants
24
+ CONSTANTS = %w[true false nil iota].freeze
25
+
26
+ # Pre-compiled patterns with \G anchor for position-specific matching
27
+ COMPILED_PATTERNS = [
28
+ # Single line comment
29
+ [:comment, %r{\G//.*}],
30
+ # Single-line block comment /* ... */ on one line
31
+ [:comment, %r{\G/\*.*?\*/}],
32
+ # Raw string literal (backtick)
33
+ [:string, /\G`[^`]*`/],
34
+ # Double quoted string (with escape handling)
35
+ [:string, /\G"(?:[^"\\]|\\.)*"/],
36
+ # Character literal (rune)
37
+ [:char, /\G'(?:[^'\\]|\\.)*'/],
38
+ # Float numbers (must be before integer)
39
+ [:number, /\G\b\d+\.\d+(?:e[+-]?\d+)?\b/i],
40
+ # Hexadecimal
41
+ [:number, /\G\b0x[0-9a-fA-F]+\b/i],
42
+ # Octal
43
+ [:number, /\G\b0o[0-7]+\b/i],
44
+ # Binary
45
+ [:number, /\G\b0b[01]+\b/i],
46
+ # Integer
47
+ [:number, /\G\b\d+\b/],
48
+ # Constants (true, false, nil, iota)
49
+ [:constant, /\G\b(?:true|false|nil|iota)\b/],
50
+ # Types
51
+ [:type, /\G\b(?:bool|byte|complex64|complex128|error|float32|float64|int|int8|int16|int32|int64|rune|string|uint|uint8|uint16|uint32|uint64|uintptr|any|comparable)\b/],
52
+ # Keywords
53
+ [:keyword, /\G\b(?:break|case|chan|const|continue|default|defer|else|fallthrough|for|func|go|goto|if|import|interface|map|package|range|return|select|struct|switch|type|var)\b/],
54
+ # Function definition names (func の後)
55
+ [:function_definition, /\G(?<=func )[a-z_][a-zA-Z0-9_]*/],
56
+ # Exported identifiers (start with uppercase)
57
+ [:constant, /\G\b[A-Z][a-zA-Z0-9_]*\b/],
58
+ # Regular identifiers
59
+ [:identifier, /\G\b[a-z_][a-zA-Z0-9_]*\b/],
60
+ # Operators
61
+ [:operator, %r{\G(?:&&|\|\||<-|<<=?|>>=?|&\^=?|[+\-*/%&|^<>=!]=?|:=|\+\+|--)}]
62
+ ].freeze
63
+
64
+ # Multiline comment patterns (pre-compiled)
65
+ BLOCK_COMMENT_END = %r{\*/}
66
+ BLOCK_COMMENT_START = %r{/\*}
67
+ BLOCK_COMMENT_START_ANCHOR = %r{\A/\*}
68
+
69
+ # Raw string patterns (pre-compiled)
70
+ RAW_STRING_START = /\A`/
71
+ RAW_STRING_END = /`/
72
+
73
+ protected
74
+
75
+ def compiled_patterns
76
+ COMPILED_PATTERNS
77
+ end
78
+
79
+ # Handle /* ... */ block comments and raw strings that span multiple lines
80
+ def handle_multiline_state(line, pos, state)
81
+ case state
82
+ when :block_comment
83
+ handle_block_comment(line, pos)
84
+ when :raw_string
85
+ handle_raw_string(line, pos)
86
+ else
87
+ [nil, nil, pos]
88
+ end
89
+ end
90
+
91
+ def check_multiline_start(line, pos)
92
+ rest = line[pos..]
93
+
94
+ # Check for raw string start
95
+ if rest.match?(RAW_STRING_START)
96
+ after_start = line[(pos + 1)..]
97
+ unless after_start&.include?("`")
98
+ # No closing on this line, enter raw string state
99
+ text = line[pos..]
100
+ token = Token.new(
101
+ type: :string,
102
+ start_col: pos,
103
+ end_col: line.length - 1,
104
+ text:
105
+ )
106
+ return [:raw_string, token, line.length]
107
+ end
108
+ end
109
+
110
+ # Check for /* that doesn't have a matching */ on this line
111
+ start_match = rest.match(BLOCK_COMMENT_START)
112
+ return [nil, nil, pos] unless start_match
113
+
114
+ start_pos = pos + start_match.begin(0)
115
+ after_start = line[(start_pos + 2)..]
116
+
117
+ if after_start&.include?("*/")
118
+ [nil, nil, pos]
119
+ else
120
+ text = line[start_pos..]
121
+ token = Token.new(
122
+ type: :comment,
123
+ start_col: start_pos,
124
+ end_col: line.length - 1,
125
+ text:
126
+ )
127
+ [:block_comment, token, line.length]
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ def handle_block_comment(line, pos)
134
+ end_match = line[pos..].match(BLOCK_COMMENT_END)
135
+ if end_match
136
+ end_pos = pos + end_match.begin(0) + 1
137
+ text = line[pos..end_pos]
138
+ token = Token.new(
139
+ type: :comment,
140
+ start_col: pos,
141
+ end_col: end_pos,
142
+ text:
143
+ )
144
+ [token, nil, end_pos + 1]
145
+ else
146
+ text = line[pos..]
147
+ token = if text.empty?
148
+ nil
149
+ else
150
+ Token.new(
151
+ type: :comment,
152
+ start_col: pos,
153
+ end_col: line.length - 1,
154
+ text:
155
+ )
156
+ end
157
+ [token, :block_comment, line.length]
158
+ end
159
+ end
160
+
161
+ def handle_raw_string(line, pos)
162
+ end_match = line[pos..].match(RAW_STRING_END)
163
+ if end_match
164
+ end_pos = pos + end_match.begin(0)
165
+ text = line[pos..end_pos]
166
+ token = Token.new(
167
+ type: :string,
168
+ start_col: pos,
169
+ end_col: end_pos,
170
+ text:
171
+ )
172
+ [token, nil, end_pos + 1]
173
+ else
174
+ text = line[pos..]
175
+ token = if text.empty?
176
+ nil
177
+ else
178
+ Token.new(
179
+ type: :string,
180
+ start_col: pos,
181
+ end_col: line.length - 1,
182
+ text:
183
+ )
184
+ end
185
+ [token, :raw_string, line.length]
186
+ end
187
+ end
188
+
189
+ def match_token(line, pos)
190
+ # Check for start of raw string
191
+ if line[pos..].match?(RAW_STRING_START)
192
+ rest = line[(pos + 1)..]
193
+ return nil unless rest&.include?("`")
194
+ end
195
+
196
+ # Check for start of multiline comment
197
+ if line[pos..].match?(BLOCK_COMMENT_START_ANCHOR)
198
+ rest = line[(pos + 2)..]
199
+ return nil unless rest&.include?("*/")
200
+ end
201
+
202
+ super
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end