mui 0.2.0 → 0.3.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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +13 -8
- data/CHANGELOG.md +99 -0
- data/README.md +309 -6
- data/docs/_config.yml +56 -0
- data/docs/configuration.md +301 -0
- data/docs/getting-started.md +140 -0
- data/docs/index.md +55 -0
- data/docs/jobs.md +297 -0
- data/docs/keybindings.md +229 -0
- data/docs/plugins.md +285 -0
- data/docs/syntax-highlighting.md +149 -0
- data/lib/mui/command_completer.rb +11 -2
- data/lib/mui/command_history.rb +89 -0
- data/lib/mui/command_line.rb +32 -2
- data/lib/mui/command_registry.rb +21 -2
- data/lib/mui/config.rb +3 -1
- data/lib/mui/editor.rb +78 -2
- data/lib/mui/handler_result.rb +13 -7
- data/lib/mui/highlighters/search_highlighter.rb +2 -1
- data/lib/mui/highlighters/syntax_highlighter.rb +3 -1
- data/lib/mui/key_handler/base.rb +87 -0
- data/lib/mui/key_handler/command_mode.rb +68 -0
- data/lib/mui/key_handler/insert_mode.rb +10 -41
- data/lib/mui/key_handler/normal_mode.rb +24 -51
- data/lib/mui/key_handler/operators/paste_operator.rb +9 -3
- data/lib/mui/key_handler/search_mode.rb +10 -7
- data/lib/mui/key_handler/visual_mode.rb +15 -10
- data/lib/mui/key_notation_parser.rb +152 -0
- data/lib/mui/key_sequence.rb +67 -0
- data/lib/mui/key_sequence_buffer.rb +85 -0
- data/lib/mui/key_sequence_handler.rb +163 -0
- data/lib/mui/key_sequence_matcher.rb +79 -0
- data/lib/mui/line_renderer.rb +52 -1
- data/lib/mui/mode_manager.rb +3 -2
- data/lib/mui/screen.rb +24 -6
- data/lib/mui/search_state.rb +61 -28
- data/lib/mui/syntax/language_detector.rb +33 -1
- data/lib/mui/syntax/lexers/css_lexer.rb +121 -0
- data/lib/mui/syntax/lexers/go_lexer.rb +205 -0
- data/lib/mui/syntax/lexers/html_lexer.rb +118 -0
- data/lib/mui/syntax/lexers/javascript_lexer.rb +197 -0
- data/lib/mui/syntax/lexers/markdown_lexer.rb +210 -0
- data/lib/mui/syntax/lexers/rust_lexer.rb +148 -0
- data/lib/mui/syntax/lexers/typescript_lexer.rb +203 -0
- data/lib/mui/terminal_adapter/curses.rb +13 -11
- data/lib/mui/version.rb +1 -1
- data/lib/mui/window.rb +83 -40
- data/lib/mui/window_manager.rb +7 -0
- data/lib/mui/wrap_cache.rb +40 -0
- data/lib/mui/wrap_helper.rb +84 -0
- data/lib/mui.rb +15 -0
- metadata +26 -3
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Matches input key sequences against registered key mappings
|
|
5
|
+
class KeySequenceMatcher
|
|
6
|
+
# Match result types
|
|
7
|
+
MATCH_EXACT = :exact # Complete match found
|
|
8
|
+
MATCH_PARTIAL = :partial # Input is prefix of one or more registered sequences
|
|
9
|
+
MATCH_NONE = :none # No match possible
|
|
10
|
+
|
|
11
|
+
# @param keymaps [Hash] Mode => { KeySequence => handler }
|
|
12
|
+
# @param leader_key [String] The leader key to expand :leader symbols
|
|
13
|
+
def initialize(keymaps, leader_key)
|
|
14
|
+
@keymaps = keymaps
|
|
15
|
+
@leader_key = leader_key
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Match input keys against registered keymaps for a mode
|
|
19
|
+
# @param mode [Symbol] The current mode (:normal, :insert, etc.)
|
|
20
|
+
# @param input_keys [Array<String>] Array of normalized input keys
|
|
21
|
+
# @return [Array<Symbol, Object>] [match_type, handler_or_nil]
|
|
22
|
+
def match(mode, input_keys)
|
|
23
|
+
mode_keymaps = @keymaps[mode]
|
|
24
|
+
return [MATCH_NONE, nil] unless mode_keymaps
|
|
25
|
+
return [MATCH_NONE, nil] if input_keys.empty?
|
|
26
|
+
|
|
27
|
+
exact_match = nil
|
|
28
|
+
has_longer_match = false
|
|
29
|
+
|
|
30
|
+
mode_keymaps.each do |sequence, handler|
|
|
31
|
+
seq_keys = sequence.normalize(@leader_key)
|
|
32
|
+
|
|
33
|
+
if seq_keys == input_keys
|
|
34
|
+
# Exact match found
|
|
35
|
+
exact_match = handler
|
|
36
|
+
elsif prefix_match?(input_keys, seq_keys)
|
|
37
|
+
# Input is a prefix of this sequence (longer sequence exists)
|
|
38
|
+
has_longer_match = true
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if exact_match
|
|
43
|
+
# Exact match found - return it
|
|
44
|
+
# If there are also longer matches, caller may want to wait for timeout
|
|
45
|
+
[MATCH_EXACT, exact_match]
|
|
46
|
+
elsif has_longer_match
|
|
47
|
+
# No exact match, but input could lead to a match
|
|
48
|
+
[MATCH_PARTIAL, nil]
|
|
49
|
+
else
|
|
50
|
+
# No match possible
|
|
51
|
+
[MATCH_NONE, nil]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Check if there are any longer sequences that could match
|
|
56
|
+
# Used to determine if we should wait for more input
|
|
57
|
+
# @param mode [Symbol]
|
|
58
|
+
# @param input_keys [Array<String>]
|
|
59
|
+
# @return [Boolean]
|
|
60
|
+
def longer_sequences?(mode, input_keys)
|
|
61
|
+
mode_keymaps = @keymaps[mode]
|
|
62
|
+
return false unless mode_keymaps
|
|
63
|
+
|
|
64
|
+
mode_keymaps.any? do |sequence, _handler|
|
|
65
|
+
seq_keys = sequence.normalize(@leader_key)
|
|
66
|
+
seq_keys.length > input_keys.length && prefix_match?(input_keys, seq_keys)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Check if input_keys is a prefix of seq_keys
|
|
73
|
+
def prefix_match?(input_keys, seq_keys)
|
|
74
|
+
return false if input_keys.length >= seq_keys.length
|
|
75
|
+
|
|
76
|
+
seq_keys.take(input_keys.length) == input_keys
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/mui/line_renderer.rb
CHANGED
|
@@ -17,8 +17,59 @@ module Mui
|
|
|
17
17
|
render_with_highlights(screen, line, x, y, highlights)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# Renders a wrapped line segment with screen coordinate-based highlights
|
|
21
|
+
# wrap_info: { text:, start_col:, end_col: }
|
|
22
|
+
# options: { selection:, search_state:, logical_row:, visible_width: }
|
|
23
|
+
def render_wrapped_line(screen, y, x, wrap_info, options = {})
|
|
24
|
+
text = wrap_info[:text]
|
|
25
|
+
return if text.nil?
|
|
26
|
+
|
|
27
|
+
logical_row = options[:logical_row]
|
|
28
|
+
start_col = wrap_info[:start_col]
|
|
29
|
+
end_col = wrap_info[:end_col]
|
|
30
|
+
|
|
31
|
+
# Collect highlights for this row and clip to wrapped segment range
|
|
32
|
+
highlights = collect_highlights(logical_row, text, options)
|
|
33
|
+
clipped_highlights = clip_highlights_to_range(highlights, start_col, end_col)
|
|
34
|
+
|
|
35
|
+
# Adjust highlight positions to be relative to wrap segment start
|
|
36
|
+
adjusted_highlights = clipped_highlights.map do |h|
|
|
37
|
+
adjusted_start = h.start_col - start_col
|
|
38
|
+
adjusted_end = h.end_col - start_col
|
|
39
|
+
Highlight.new(
|
|
40
|
+
start_col: adjusted_start,
|
|
41
|
+
end_col: adjusted_end,
|
|
42
|
+
style: h.style,
|
|
43
|
+
priority: h.priority
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
render_with_highlights(screen, text, x, y, adjusted_highlights)
|
|
48
|
+
end
|
|
49
|
+
|
|
20
50
|
private
|
|
21
51
|
|
|
52
|
+
# Clips highlights to a column range and returns only overlapping portions
|
|
53
|
+
def clip_highlights_to_range(highlights, range_start, range_end)
|
|
54
|
+
highlights.filter_map do |h|
|
|
55
|
+
# Skip if highlight doesn't overlap with range
|
|
56
|
+
next if h.end_col < range_start || h.start_col >= range_end
|
|
57
|
+
|
|
58
|
+
# Clip to range
|
|
59
|
+
clipped_start = [h.start_col, range_start].max
|
|
60
|
+
clipped_end = [h.end_col, range_end - 1].min
|
|
61
|
+
|
|
62
|
+
next if clipped_start > clipped_end
|
|
63
|
+
|
|
64
|
+
Highlight.new(
|
|
65
|
+
start_col: clipped_start,
|
|
66
|
+
end_col: clipped_end,
|
|
67
|
+
style: h.style,
|
|
68
|
+
priority: h.priority
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
22
73
|
def collect_highlights(row, line, options)
|
|
23
74
|
@highlighters
|
|
24
75
|
.flat_map { |h| h.highlights_for(row, line, options) }
|
|
@@ -36,7 +87,7 @@ module Mui
|
|
|
36
87
|
|
|
37
88
|
segments.each do |segment|
|
|
38
89
|
put_text(screen, y, current_x, segment[:text], segment[:style])
|
|
39
|
-
current_x += segment[:text]
|
|
90
|
+
current_x += UnicodeWidth.string_width(segment[:text])
|
|
40
91
|
end
|
|
41
92
|
end
|
|
42
93
|
|
data/lib/mui/mode_manager.rb
CHANGED
|
@@ -4,9 +4,9 @@ module Mui
|
|
|
4
4
|
# Manages editor mode state and transitions
|
|
5
5
|
class ModeManager
|
|
6
6
|
attr_reader :mode, :selection, :register, :undo_manager, :search_state, :search_input, :editor,
|
|
7
|
-
:last_visual_selection
|
|
7
|
+
:last_visual_selection, :key_sequence_handler
|
|
8
8
|
|
|
9
|
-
def initialize(window:, buffer:, command_line:, undo_manager: nil, editor: nil, register: nil)
|
|
9
|
+
def initialize(window:, buffer:, command_line:, undo_manager: nil, editor: nil, register: nil, key_sequence_handler: nil)
|
|
10
10
|
@tab_manager = window.is_a?(TabManager) ? window : nil
|
|
11
11
|
@window_manager = window.is_a?(WindowManager) ? window : nil
|
|
12
12
|
@window = !@tab_manager && !@window_manager ? window : nil
|
|
@@ -15,6 +15,7 @@ module Mui
|
|
|
15
15
|
@register = register || Mui.register
|
|
16
16
|
@undo_manager = undo_manager
|
|
17
17
|
@editor = editor
|
|
18
|
+
@key_sequence_handler = key_sequence_handler
|
|
18
19
|
@search_state = SearchState.new
|
|
19
20
|
@search_input = SearchInput.new
|
|
20
21
|
@mode = Mode::NORMAL
|
data/lib/mui/screen.rb
CHANGED
|
@@ -30,8 +30,8 @@ module Mui
|
|
|
30
30
|
return if y >= @height || x >= @width
|
|
31
31
|
|
|
32
32
|
@adapter.setpos(y, x)
|
|
33
|
-
|
|
34
|
-
@adapter.addstr(text
|
|
33
|
+
max_width = @width - x
|
|
34
|
+
@adapter.addstr(truncate_to_width(text, max_width))
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def put_with_highlight(y, x, text)
|
|
@@ -39,9 +39,9 @@ module Mui
|
|
|
39
39
|
return if y >= @height || x >= @width
|
|
40
40
|
|
|
41
41
|
@adapter.setpos(y, x)
|
|
42
|
-
|
|
42
|
+
max_width = @width - x
|
|
43
43
|
@adapter.with_highlight do
|
|
44
|
-
@adapter.addstr(text
|
|
44
|
+
@adapter.addstr(truncate_to_width(text, max_width))
|
|
45
45
|
end
|
|
46
46
|
end
|
|
47
47
|
|
|
@@ -51,8 +51,8 @@ module Mui
|
|
|
51
51
|
return put(y, x, text) unless @color_manager && style
|
|
52
52
|
|
|
53
53
|
@adapter.setpos(y, x)
|
|
54
|
-
|
|
55
|
-
truncated_text = text
|
|
54
|
+
max_width = @width - x
|
|
55
|
+
truncated_text = truncate_to_width(text, max_width)
|
|
56
56
|
|
|
57
57
|
pair_index = ensure_color_pair(style[:fg], style[:bg])
|
|
58
58
|
@adapter.with_color(pair_index, bold: style[:bold], underline: style[:underline]) do
|
|
@@ -81,5 +81,23 @@ module Mui
|
|
|
81
81
|
@width = @adapter.width
|
|
82
82
|
@height = @adapter.height
|
|
83
83
|
end
|
|
84
|
+
|
|
85
|
+
# Truncates text to fit within max_width display columns
|
|
86
|
+
def truncate_to_width(text, max_width)
|
|
87
|
+
return text if max_width <= 0
|
|
88
|
+
|
|
89
|
+
current_width = 0
|
|
90
|
+
result = String.new
|
|
91
|
+
|
|
92
|
+
text.each_char do |char|
|
|
93
|
+
char_w = UnicodeWidth.char_width(char)
|
|
94
|
+
break if current_width + char_w > max_width
|
|
95
|
+
|
|
96
|
+
result << char
|
|
97
|
+
current_width += char_w
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
result
|
|
101
|
+
end
|
|
84
102
|
end
|
|
85
103
|
end
|
data/lib/mui/search_state.rb
CHANGED
|
@@ -2,81 +2,114 @@
|
|
|
2
2
|
|
|
3
3
|
module Mui
|
|
4
4
|
class SearchState
|
|
5
|
-
attr_reader :pattern, :direction
|
|
5
|
+
attr_reader :pattern, :direction
|
|
6
6
|
|
|
7
7
|
def initialize
|
|
8
8
|
@pattern = nil
|
|
9
9
|
@direction = :forward
|
|
10
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
21
|
-
return if @pattern.nil? || @pattern.empty?
|
|
23
|
+
return [] if @pattern.nil? || @pattern.empty? || buffer.nil?
|
|
22
24
|
|
|
23
|
-
|
|
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
|
-
|
|
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 =
|
|
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 ||
|
|
38
|
+
match || matches.first
|
|
45
39
|
end
|
|
46
40
|
|
|
47
|
-
def find_previous(current_row, current_col)
|
|
48
|
-
|
|
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 =
|
|
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 ||
|
|
51
|
+
match || matches.last
|
|
57
52
|
end
|
|
58
53
|
|
|
59
54
|
def clear
|
|
60
55
|
@pattern = nil
|
|
61
|
-
@
|
|
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
|
-
|
|
69
|
-
|
|
64
|
+
# Get matches for a specific row in a specific buffer
|
|
65
|
+
def matches_for_row(row, buffer: nil)
|
|
66
|
+
return [] if buffer.nil?
|
|
67
|
+
|
|
68
|
+
matches = get_or_calculate_matches(buffer)
|
|
69
|
+
matches.select { |m| m[:row] == row }
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
private
|
|
73
73
|
|
|
74
|
-
def
|
|
74
|
+
def get_or_calculate_matches(buffer)
|
|
75
|
+
buffer_id = buffer.object_id
|
|
76
|
+
cached = @buffer_matches[buffer_id]
|
|
77
|
+
|
|
78
|
+
# Return cached matches if valid (same pattern version and buffer hasn't changed)
|
|
79
|
+
return cached[:matches] if cached && cached[:version] == @pattern_version && cached[:change_count] == buffer.change_count
|
|
80
|
+
|
|
81
|
+
# Calculate and cache matches for this buffer
|
|
82
|
+
matches = calculate_matches(buffer)
|
|
83
|
+
@buffer_matches[buffer_id] = {
|
|
84
|
+
version: @pattern_version,
|
|
85
|
+
change_count: buffer.change_count,
|
|
86
|
+
matches:
|
|
87
|
+
}
|
|
88
|
+
matches
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def calculate_matches(buffer)
|
|
92
|
+
return [] if @pattern.nil? || @pattern.empty?
|
|
93
|
+
|
|
94
|
+
matches = []
|
|
95
|
+
begin
|
|
96
|
+
regex = Regexp.new(@pattern)
|
|
97
|
+
buffer.line_count.times do |row|
|
|
98
|
+
line = buffer.line(row)
|
|
99
|
+
scan_line_matches(matches, line, row, regex)
|
|
100
|
+
end
|
|
101
|
+
rescue RegexpError
|
|
102
|
+
# Invalid regex pattern - no matches
|
|
103
|
+
end
|
|
104
|
+
matches
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def scan_line_matches(matches, line, row, regex)
|
|
75
108
|
offset = 0
|
|
76
109
|
while (match_data = line.match(regex, offset))
|
|
77
110
|
col = match_data.begin(0)
|
|
78
111
|
end_col = match_data.end(0) - 1
|
|
79
|
-
|
|
112
|
+
matches << { row:, col:, end_col: }
|
|
80
113
|
# Move offset past the end of the match to avoid overlapping matches
|
|
81
114
|
offset = match_data.end(0)
|
|
82
115
|
# Handle zero-length matches to prevent infinite loop
|
|
@@ -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
|
|
|
@@ -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
|