mui 0.1.0 → 0.2.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +158 -0
  3. data/CHANGELOG.md +349 -0
  4. data/exe/mui +1 -2
  5. data/lib/mui/autocmd.rb +66 -0
  6. data/lib/mui/buffer.rb +275 -0
  7. data/lib/mui/buffer_word_cache.rb +131 -0
  8. data/lib/mui/buffer_word_completer.rb +77 -0
  9. data/lib/mui/color_manager.rb +136 -0
  10. data/lib/mui/color_scheme.rb +63 -0
  11. data/lib/mui/command_completer.rb +21 -0
  12. data/lib/mui/command_context.rb +90 -0
  13. data/lib/mui/command_line.rb +137 -0
  14. data/lib/mui/command_registry.rb +25 -0
  15. data/lib/mui/completion_renderer.rb +84 -0
  16. data/lib/mui/completion_state.rb +58 -0
  17. data/lib/mui/config.rb +56 -0
  18. data/lib/mui/editor.rb +319 -0
  19. data/lib/mui/error.rb +29 -0
  20. data/lib/mui/file_completer.rb +51 -0
  21. data/lib/mui/floating_window.rb +161 -0
  22. data/lib/mui/handler_result.rb +101 -0
  23. data/lib/mui/highlight.rb +22 -0
  24. data/lib/mui/highlighters/base.rb +23 -0
  25. data/lib/mui/highlighters/search_highlighter.rb +26 -0
  26. data/lib/mui/highlighters/selection_highlighter.rb +48 -0
  27. data/lib/mui/highlighters/syntax_highlighter.rb +105 -0
  28. data/lib/mui/input.rb +17 -0
  29. data/lib/mui/insert_completion_renderer.rb +92 -0
  30. data/lib/mui/insert_completion_state.rb +77 -0
  31. data/lib/mui/job.rb +81 -0
  32. data/lib/mui/job_manager.rb +113 -0
  33. data/lib/mui/key_code.rb +30 -0
  34. data/lib/mui/key_handler/base.rb +100 -0
  35. data/lib/mui/key_handler/command_mode.rb +443 -0
  36. data/lib/mui/key_handler/insert_mode.rb +354 -0
  37. data/lib/mui/key_handler/motions/motion_handler.rb +56 -0
  38. data/lib/mui/key_handler/normal_mode.rb +579 -0
  39. data/lib/mui/key_handler/operators/base_operator.rb +134 -0
  40. data/lib/mui/key_handler/operators/change_operator.rb +179 -0
  41. data/lib/mui/key_handler/operators/delete_operator.rb +176 -0
  42. data/lib/mui/key_handler/operators/paste_operator.rb +113 -0
  43. data/lib/mui/key_handler/operators/yank_operator.rb +127 -0
  44. data/lib/mui/key_handler/search_mode.rb +188 -0
  45. data/lib/mui/key_handler/visual_line_mode.rb +20 -0
  46. data/lib/mui/key_handler/visual_mode.rb +397 -0
  47. data/lib/mui/key_handler/window_command.rb +112 -0
  48. data/lib/mui/key_handler.rb +16 -0
  49. data/lib/mui/layout/calculator.rb +15 -0
  50. data/lib/mui/layout/leaf_node.rb +33 -0
  51. data/lib/mui/layout/node.rb +29 -0
  52. data/lib/mui/layout/split_node.rb +132 -0
  53. data/lib/mui/line_renderer.rb +122 -0
  54. data/lib/mui/mode.rb +13 -0
  55. data/lib/mui/mode_manager.rb +185 -0
  56. data/lib/mui/motion.rb +139 -0
  57. data/lib/mui/plugin.rb +35 -0
  58. data/lib/mui/plugin_manager.rb +106 -0
  59. data/lib/mui/register.rb +110 -0
  60. data/lib/mui/screen.rb +85 -0
  61. data/lib/mui/search_completer.rb +50 -0
  62. data/lib/mui/search_input.rb +40 -0
  63. data/lib/mui/search_state.rb +88 -0
  64. data/lib/mui/selection.rb +55 -0
  65. data/lib/mui/status_line_renderer.rb +40 -0
  66. data/lib/mui/syntax/language_detector.rb +74 -0
  67. data/lib/mui/syntax/lexer_base.rb +106 -0
  68. data/lib/mui/syntax/lexers/c_lexer.rb +127 -0
  69. data/lib/mui/syntax/lexers/ruby_lexer.rb +114 -0
  70. data/lib/mui/syntax/token.rb +42 -0
  71. data/lib/mui/syntax/token_cache.rb +91 -0
  72. data/lib/mui/tab_bar_renderer.rb +87 -0
  73. data/lib/mui/tab_manager.rb +96 -0
  74. data/lib/mui/tab_page.rb +35 -0
  75. data/lib/mui/terminal_adapter/base.rb +92 -0
  76. data/lib/mui/terminal_adapter/curses.rb +162 -0
  77. data/lib/mui/terminal_adapter.rb +4 -0
  78. data/lib/mui/themes/default.rb +315 -0
  79. data/lib/mui/undo_manager.rb +83 -0
  80. data/lib/mui/undoable_action.rb +175 -0
  81. data/lib/mui/unicode_width.rb +100 -0
  82. data/lib/mui/version.rb +1 -1
  83. data/lib/mui/window.rb +158 -0
  84. data/lib/mui/window_manager.rb +249 -0
  85. data/lib/mui.rb +156 -2
  86. metadata +98 -3
data/lib/mui/plugin.rb ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Base class for class-based plugins
5
+ class Plugin
6
+ class << self
7
+ attr_accessor :plugin_name, :plugin_dependencies
8
+
9
+ def name(n)
10
+ @plugin_name = n
11
+ end
12
+
13
+ def depends_on(*deps)
14
+ @plugin_dependencies = deps
15
+ end
16
+ end
17
+
18
+ def setup
19
+ # Override in subclass: plugin initialization
20
+ end
21
+
22
+ # API shortcuts
23
+ def command(name, &)
24
+ Mui.command(name, &)
25
+ end
26
+
27
+ def keymap(mode, key, &)
28
+ Mui.keymap(mode, key, &)
29
+ end
30
+
31
+ def autocmd(event, pattern: nil, &)
32
+ Mui.autocmd(event, pattern:, &)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/inline"
4
+
5
+ module Mui
6
+ # Manages plugin lifecycle: loading, initialization, dependency resolution
7
+ class PluginManager
8
+ attr_reader :plugins, :loaded_plugins, :pending_gems
9
+
10
+ def initialize
11
+ @plugins = {} # Registered plugin definitions (DSL/class)
12
+ @loaded_plugins = [] # Loaded plugin names
13
+ @pending_gems = [] # Gems waiting to be installed
14
+ @installed = false
15
+ end
16
+
17
+ # Called from Mui.use - register gem (don't install yet)
18
+ def add_gem(gem_name, version = nil)
19
+ @pending_gems << { gem: gem_name, version: }
20
+ end
21
+
22
+ # Register plugin definition (DSL or class)
23
+ def register(name, plugin_class_or_block, dependencies: [])
24
+ @plugins[name.to_sym] = {
25
+ handler: plugin_class_or_block,
26
+ dependencies: dependencies.map(&:to_sym)
27
+ }
28
+ end
29
+
30
+ # Called during Editor initialization - install and load all at once
31
+ def install_and_load
32
+ return if @installed
33
+
34
+ install_gems unless @pending_gems.empty?
35
+ load_all_plugins
36
+ @installed = true
37
+ end
38
+
39
+ def installed?
40
+ @installed
41
+ end
42
+
43
+ private
44
+
45
+ def install_gems
46
+ gems = @pending_gems
47
+ gemfile do
48
+ source "https://rubygems.org"
49
+ gems.each do |g|
50
+ if g[:version]
51
+ gem g[:gem], g[:version]
52
+ else
53
+ gem g[:gem]
54
+ end
55
+ end
56
+ end
57
+ rescue Bundler::GemNotFound => e
58
+ warn "Plugin gem not found: #{e.message}"
59
+ rescue Gem::MissingSpecError => e
60
+ warn "Plugin gem not found: #{e.message}"
61
+ end
62
+
63
+ def load_all_plugins
64
+ # Sort by dependencies and load
65
+ sorted_plugins = topological_sort(@plugins)
66
+ sorted_plugins.each { |name| load_plugin(name) }
67
+ end
68
+
69
+ def load_plugin(name)
70
+ return if @loaded_plugins.include?(name)
71
+
72
+ plugin_def = @plugins[name]
73
+ return unless plugin_def
74
+
75
+ instance = instantiate_plugin(plugin_def[:handler])
76
+ instance.setup if instance.respond_to?(:setup)
77
+ @loaded_plugins << name
78
+ end
79
+
80
+ def instantiate_plugin(handler)
81
+ case handler
82
+ when Class
83
+ handler.new
84
+ when Proc
85
+ handler.call
86
+ nil
87
+ end
88
+ end
89
+
90
+ def topological_sort(plugins)
91
+ sorted = []
92
+ visited = {}
93
+
94
+ visit = lambda do |name|
95
+ return if visited[name]
96
+
97
+ visited[name] = true
98
+ plugins[name]&.[](:dependencies)&.each { |dep| visit.call(dep) }
99
+ sorted << name
100
+ end
101
+
102
+ plugins.each_key { |name| visit.call(name) }
103
+ sorted
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Manages yank/delete registers for copy/paste operations
5
+ # Supports Vim-compatible registers:
6
+ # - "" (unnamed): default register
7
+ # - "a-"z: named registers
8
+ # - "0: yank register (stores last yank, not affected by delete)
9
+ # - "1-"9: delete history (shifted on each delete)
10
+ # - "_: black hole register (discards content)
11
+ class Register
12
+ YANK_REGISTER = "0"
13
+ DELETE_HISTORY_REGISTERS = ("1".."9").to_a.freeze
14
+ BLACK_HOLE_REGISTER = "_"
15
+ UNNAMED_REGISTER = '"'
16
+ NAMED_REGISTERS = ("a".."z").to_a.freeze
17
+
18
+ def initialize
19
+ @unnamed = { content: nil, linewise: false }
20
+ @yank_register = { content: nil, linewise: false }
21
+ @delete_history = []
22
+ @named_registers = {}
23
+ end
24
+
25
+ # Store text from yank operation
26
+ # Saves to unnamed register and "0 (yank register)
27
+ def yank(text, linewise: false, name: nil)
28
+ return if name == BLACK_HOLE_REGISTER
29
+
30
+ if name && NAMED_REGISTERS.include?(name)
31
+ @named_registers[name] = { content: text, linewise: }
32
+ else
33
+ @unnamed = { content: text, linewise: }
34
+ @yank_register = { content: text, linewise: }
35
+ end
36
+ end
37
+
38
+ # Store text from delete operation
39
+ # Saves to unnamed register and shifts delete history ("1-"9)
40
+ def delete(text, linewise: false, name: nil)
41
+ return if name == BLACK_HOLE_REGISTER
42
+
43
+ if name && NAMED_REGISTERS.include?(name)
44
+ @named_registers[name] = { content: text, linewise: }
45
+ else
46
+ @unnamed = { content: text, linewise: }
47
+ shift_delete_history(text, linewise)
48
+ end
49
+ end
50
+
51
+ # Legacy method for backward compatibility
52
+ def set(text, linewise: false, name: nil)
53
+ if name
54
+ @named_registers[name] = { content: text, linewise: }
55
+ else
56
+ @unnamed = { content: text, linewise: }
57
+ end
58
+ end
59
+
60
+ def get(name: nil)
61
+ case name
62
+ when nil, UNNAMED_REGISTER
63
+ @unnamed[:content]
64
+ when YANK_REGISTER
65
+ @yank_register[:content]
66
+ when *DELETE_HISTORY_REGISTERS
67
+ index = name.to_i - 1
68
+ @delete_history[index]&.fetch(:content, nil)
69
+ when BLACK_HOLE_REGISTER
70
+ nil
71
+ when *NAMED_REGISTERS
72
+ @named_registers[name]&.fetch(:content, nil)
73
+ end
74
+ end
75
+
76
+ def linewise?(name: nil)
77
+ case name
78
+ when nil, UNNAMED_REGISTER
79
+ @unnamed[:linewise]
80
+ when YANK_REGISTER
81
+ @yank_register[:linewise]
82
+ when *DELETE_HISTORY_REGISTERS
83
+ index = name.to_i - 1
84
+ @delete_history[index]&.fetch(:linewise, false) || false
85
+ when BLACK_HOLE_REGISTER
86
+ false
87
+ when *NAMED_REGISTERS
88
+ @named_registers[name]&.fetch(:linewise, false) || false
89
+ else
90
+ false
91
+ end
92
+ end
93
+
94
+ def empty?(name: nil)
95
+ get(name:).nil?
96
+ end
97
+
98
+ # For backward compatibility
99
+ def linewise
100
+ @unnamed[:linewise]
101
+ end
102
+
103
+ private
104
+
105
+ def shift_delete_history(text, linewise)
106
+ @delete_history.unshift({ content: text, linewise: })
107
+ @delete_history = @delete_history.first(9)
108
+ end
109
+ end
110
+ end
data/lib/mui/screen.rb ADDED
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ class Screen
5
+ attr_reader :width, :height
6
+
7
+ def initialize(adapter:, color_manager: nil)
8
+ @adapter = adapter
9
+ @color_manager = color_manager
10
+ @initialized_pairs = {}
11
+ @adapter.init
12
+ update_size
13
+ end
14
+
15
+ def refresh
16
+ update_size
17
+ @adapter.refresh
18
+ end
19
+
20
+ def close
21
+ @adapter.close
22
+ end
23
+
24
+ def clear
25
+ @adapter.clear
26
+ end
27
+
28
+ def put(y, x, text)
29
+ return if y.negative?
30
+ return if y >= @height || x >= @width
31
+
32
+ @adapter.setpos(y, x)
33
+ max_len = @width - x
34
+ @adapter.addstr(text.length > max_len ? text[0, max_len] : text)
35
+ end
36
+
37
+ def put_with_highlight(y, x, text)
38
+ return if y.negative?
39
+ return if y >= @height || x >= @width
40
+
41
+ @adapter.setpos(y, x)
42
+ max_len = @width - x
43
+ @adapter.with_highlight do
44
+ @adapter.addstr(text.length > max_len ? text[0, max_len] : text)
45
+ end
46
+ end
47
+
48
+ def put_with_style(y, x, text, style)
49
+ return if y.negative?
50
+ return if y >= @height || x >= @width
51
+ return put(y, x, text) unless @color_manager && style
52
+
53
+ @adapter.setpos(y, x)
54
+ max_len = @width - x
55
+ truncated_text = text.length > max_len ? text[0, max_len] : text
56
+
57
+ pair_index = ensure_color_pair(style[:fg], style[:bg])
58
+ @adapter.with_color(pair_index, bold: style[:bold], underline: style[:underline]) do
59
+ @adapter.addstr(truncated_text)
60
+ end
61
+ end
62
+
63
+ def move_cursor(y, x)
64
+ x = [[x, 0].max, @width - 1].min
65
+ y = [[y, 0].max, @height - 1].min
66
+ @adapter.setpos(y, x)
67
+ end
68
+
69
+ private
70
+
71
+ def ensure_color_pair(fg, bg)
72
+ pair_index = @color_manager.get_pair_index(fg, bg)
73
+ unless @initialized_pairs[pair_index]
74
+ @adapter.init_color_pair(pair_index, fg, bg)
75
+ @initialized_pairs[pair_index] = true
76
+ end
77
+ pair_index
78
+ end
79
+
80
+ def update_size
81
+ @width = @adapter.width
82
+ @height = @adapter.height
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Extracts search completion candidates from buffer content
5
+ class SearchCompleter
6
+ MAX_CANDIDATES = 50
7
+
8
+ def initialize
9
+ @word_cache = {}
10
+ @cache_buffer_id = nil
11
+ @cache_version = nil
12
+ end
13
+
14
+ # Extract words from buffer that match the given prefix
15
+ def complete(buffer, prefix)
16
+ return [] if prefix.nil? || prefix.empty?
17
+
18
+ words = extract_words(buffer)
19
+ matching = words.select { |word| word.start_with?(prefix) && word != prefix }
20
+
21
+ # Sort by length (shorter first) then alphabetically
22
+ matching.sort_by { |w| [w.length, w] }.take(MAX_CANDIDATES)
23
+ end
24
+
25
+ private
26
+
27
+ def extract_words(buffer)
28
+ # Simple cache invalidation based on buffer identity and modification
29
+ buffer_id = buffer.object_id
30
+ version = buffer.lines.hash
31
+
32
+ return @word_cache[buffer_id] if @cache_buffer_id == buffer_id && @cache_version == version
33
+
34
+ words = Set.new
35
+ buffer.line_count.times do |row|
36
+ line = buffer.line(row)
37
+ # Extract words (alphanumeric + underscore, minimum 2 characters)
38
+ line.scan(/\b[a-zA-Z_][a-zA-Z0-9_]+\b/) do |word|
39
+ words.add(word)
40
+ end
41
+ end
42
+
43
+ @cache_buffer_id = buffer_id
44
+ @cache_version = version
45
+ @word_cache[buffer_id] = words.to_a
46
+
47
+ @word_cache[buffer_id]
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ class SearchInput
5
+ attr_reader :buffer, :prompt
6
+
7
+ def initialize(prompt = "/")
8
+ @buffer = ""
9
+ @prompt = prompt
10
+ end
11
+
12
+ def input(char)
13
+ @buffer += char
14
+ end
15
+
16
+ def backspace
17
+ @buffer = @buffer.chop
18
+ end
19
+
20
+ def clear
21
+ @buffer = ""
22
+ end
23
+
24
+ def set_prompt(prompt)
25
+ @prompt = prompt
26
+ end
27
+
28
+ def to_s
29
+ "#{@prompt}#{@buffer}"
30
+ end
31
+
32
+ def pattern
33
+ @buffer
34
+ end
35
+
36
+ def empty?
37
+ @buffer.empty?
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ class SearchState
5
+ attr_reader :pattern, :direction, :matches
6
+
7
+ def initialize
8
+ @pattern = nil
9
+ @direction = :forward
10
+ @matches = []
11
+ end
12
+
13
+ def set_pattern(pattern, direction)
14
+ @pattern = pattern
15
+ @direction = direction
16
+ @matches = []
17
+ end
18
+
19
+ def find_all_matches(buffer)
20
+ @matches = []
21
+ return if @pattern.nil? || @pattern.empty?
22
+
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
33
+ end
34
+
35
+ def find_next(current_row, current_col)
36
+ return nil if @matches.empty?
37
+
38
+ # Find next match after current position
39
+ match = @matches.find do |m|
40
+ m[:row] > current_row || (m[:row] == current_row && m[:col] > current_col)
41
+ end
42
+
43
+ # Wrap around to beginning if no match found
44
+ match || @matches.first
45
+ end
46
+
47
+ def find_previous(current_row, current_col)
48
+ return nil if @matches.empty?
49
+
50
+ # Find previous match before current position
51
+ match = @matches.reverse.find do |m|
52
+ m[:row] < current_row || (m[:row] == current_row && m[:col] < current_col)
53
+ end
54
+
55
+ # Wrap around to end if no match found
56
+ match || @matches.last
57
+ end
58
+
59
+ def clear
60
+ @pattern = nil
61
+ @matches = []
62
+ end
63
+
64
+ def has_pattern?
65
+ !@pattern.nil? && !@pattern.empty?
66
+ end
67
+
68
+ def matches_for_row(row)
69
+ @matches.select { |m| m[:row] == row }
70
+ end
71
+
72
+ private
73
+
74
+ def scan_line_matches(line, row, regex)
75
+ offset = 0
76
+ while (match_data = line.match(regex, offset))
77
+ col = match_data.begin(0)
78
+ end_col = match_data.end(0) - 1
79
+ @matches << { row:, col:, end_col: }
80
+ # Move offset past the end of the match to avoid overlapping matches
81
+ offset = match_data.end(0)
82
+ # Handle zero-length matches to prevent infinite loop
83
+ offset += 1 if match_data[0].empty?
84
+ break if offset >= line.length
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ class Selection
5
+ attr_accessor :start_row, :start_col, :end_row, :end_col
6
+ attr_reader :line_mode
7
+
8
+ def initialize(start_row, start_col, line_mode: false)
9
+ @start_row = start_row
10
+ @start_col = start_col
11
+ @end_row = start_row
12
+ @end_col = start_col
13
+ @line_mode = line_mode
14
+ end
15
+
16
+ def update_end(row, col)
17
+ @end_row = row
18
+ @end_col = col
19
+ end
20
+
21
+ def normalized_range
22
+ if @start_row < @end_row || (@start_row == @end_row && @start_col <= @end_col)
23
+ { start_row: @start_row, start_col: @start_col, end_row: @end_row, end_col: @end_col }
24
+ else
25
+ { start_row: @end_row, start_col: @end_col, end_row: @start_row, end_col: @start_col }
26
+ end
27
+ end
28
+
29
+ def covers_position?(row, col, buffer)
30
+ range = normalized_range
31
+
32
+ if @line_mode
33
+ row.between?(range[:start_row], range[:end_row])
34
+ else
35
+ covers_character_position?(row, col, range, buffer)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def covers_character_position?(row, col, range, _buffer)
42
+ return false if row < range[:start_row] || row > range[:end_row]
43
+
44
+ if range[:start_row] == range[:end_row]
45
+ col.between?(range[:start_col], range[:end_col])
46
+ elsif row == range[:start_row]
47
+ col >= range[:start_col]
48
+ elsif row == range[:end_row]
49
+ col <= range[:end_col]
50
+ else
51
+ true
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ class StatusLineRenderer
5
+ def initialize(buffer, window, color_scheme)
6
+ @buffer = buffer
7
+ @window = window
8
+ @color_scheme = color_scheme
9
+ end
10
+
11
+ def render(screen, y_position)
12
+ full_status = format_status_line(build_status_text, build_position_text)
13
+
14
+ if @color_scheme
15
+ screen.put_with_style(y_position, @window.x, full_status, @color_scheme[:status_line])
16
+ else
17
+ screen.put(y_position, @window.x, full_status)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def build_status_text
24
+ status = " #{@buffer.name}"
25
+ status += " [+]" if @buffer.modified
26
+ status
27
+ end
28
+
29
+ def build_position_text
30
+ "#{@window.cursor_row + 1}:#{@window.cursor_col + 1} "
31
+ end
32
+
33
+ def format_status_line(status, position)
34
+ padding = @window.width - status.length - position.length
35
+ padding = 0 if padding.negative?
36
+ full_status = status + (" " * padding) + position
37
+ full_status[0, @window.width]
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Syntax
5
+ # Detects programming language from file path and provides appropriate lexer
6
+ class LanguageDetector
7
+ # Map file extensions to language symbols
8
+ EXTENSION_MAP = {
9
+ ".rb" => :ruby,
10
+ ".ru" => :ruby,
11
+ ".rake" => :ruby,
12
+ ".gemspec" => :ruby,
13
+ ".c" => :c,
14
+ ".h" => :c,
15
+ ".y" => :c
16
+ }.freeze
17
+
18
+ # Map basenames (files without extension) to language symbols
19
+ BASENAME_MAP = {
20
+ "Gemfile" => :ruby,
21
+ "Rakefile" => :ruby,
22
+ "Guardfile" => :ruby,
23
+ "Vagrantfile" => :ruby,
24
+ "Berksfile" => :ruby,
25
+ "Capfile" => :ruby,
26
+ "Thorfile" => :ruby,
27
+ "Podfile" => :ruby,
28
+ "Brewfile" => :ruby
29
+ }.freeze
30
+
31
+ class << self
32
+ # Detect language from file path
33
+ def detect(file_path)
34
+ return nil if file_path.nil? || file_path.empty?
35
+
36
+ # Try extension first
37
+ ext = File.extname(file_path).downcase
38
+ language = EXTENSION_MAP[ext]
39
+ return language if language
40
+
41
+ # Try basename
42
+ basename = File.basename(file_path)
43
+ BASENAME_MAP[basename]
44
+ end
45
+
46
+ # Get a lexer instance for a language
47
+ def lexer_for(language)
48
+ case language
49
+ when :ruby
50
+ Lexers::RubyLexer.new
51
+ when :c
52
+ Lexers::CLexer.new
53
+ end
54
+ end
55
+
56
+ # Get a lexer instance for a file path
57
+ def lexer_for_file(file_path)
58
+ language = detect(file_path)
59
+ lexer_for(language)
60
+ end
61
+
62
+ # List all supported languages
63
+ def supported_languages
64
+ (EXTENSION_MAP.values + BASENAME_MAP.values).uniq
65
+ end
66
+
67
+ # List all supported extensions
68
+ def supported_extensions
69
+ EXTENSION_MAP.keys
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end