mbeditor 0.5.3 → 0.7.1

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/README.md +7 -0
  4. data/app/assets/javascripts/mbeditor/application.js +3 -0
  5. data/app/assets/javascripts/mbeditor/components/ChangelogView.js +145 -0
  6. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
  7. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +359 -31
  8. data/app/assets/javascripts/mbeditor/components/FileTree.js +177 -116
  9. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +952 -143
  10. data/app/assets/javascripts/mbeditor/components/TabBar.js +9 -0
  11. data/app/assets/javascripts/mbeditor/conflict_parser.js +48 -0
  12. data/app/assets/javascripts/mbeditor/editor_plugins.js +420 -67
  13. data/app/assets/javascripts/mbeditor/editor_store.js +1 -0
  14. data/app/assets/javascripts/mbeditor/file_service.js +34 -6
  15. data/app/assets/javascripts/mbeditor/git_service.js +2 -1
  16. data/app/assets/javascripts/mbeditor/history_service.js +177 -0
  17. data/app/assets/javascripts/mbeditor/search_service.js +1 -0
  18. data/app/assets/javascripts/mbeditor/tab_manager.js +8 -5
  19. data/app/assets/stylesheets/mbeditor/application.css +112 -0
  20. data/app/assets/stylesheets/mbeditor/editor.css +443 -78
  21. data/app/channels/mbeditor/editor_channel.rb +5 -41
  22. data/app/controllers/mbeditor/application_controller.rb +8 -1
  23. data/app/controllers/mbeditor/editors_controller.rb +276 -654
  24. data/app/controllers/mbeditor/git_controller.rb +2 -61
  25. data/app/services/mbeditor/availability_probe.rb +83 -0
  26. data/app/services/mbeditor/code_search_service.rb +42 -0
  27. data/app/services/mbeditor/editor_state_service.rb +91 -0
  28. data/app/services/mbeditor/exclusion_matcher.rb +23 -0
  29. data/app/services/mbeditor/file_operation_service.rb +68 -0
  30. data/app/services/mbeditor/file_tree_service.rb +69 -0
  31. data/app/services/mbeditor/git_combined_diff_service.rb +43 -0
  32. data/app/services/mbeditor/git_commit_detail_service.rb +46 -0
  33. data/app/services/mbeditor/git_info_service.rb +151 -0
  34. data/app/services/mbeditor/git_service.rb +36 -26
  35. data/app/services/mbeditor/js_definition_service.rb +59 -0
  36. data/app/services/mbeditor/js_members_service.rb +62 -0
  37. data/app/services/mbeditor/process_runner.rb +48 -0
  38. data/app/services/mbeditor/rails_related_files_service.rb +282 -0
  39. data/app/services/mbeditor/ruby_definition_service.rb +77 -101
  40. data/app/services/mbeditor/schema_service.rb +270 -0
  41. data/app/services/mbeditor/search_replace_service.rb +184 -0
  42. data/app/services/mbeditor/test_runner_service.rb +5 -27
  43. data/app/views/layouts/mbeditor/application.html.erb +2 -2
  44. data/config/routes.rb +8 -1
  45. data/lib/mbeditor/configuration.rb +4 -2
  46. data/lib/mbeditor/version.rb +1 -1
  47. data/public/monaco-editor/vs/language/css/cssMode.js +13 -0
  48. data/public/monaco-editor/vs/language/css/cssWorker.js +77 -0
  49. data/public/monaco-editor/vs/language/html/htmlMode.js +13 -0
  50. data/public/monaco-editor/vs/language/html/htmlWorker.js +454 -0
  51. data/public/monaco-editor/vs/language/json/jsonMode.js +19 -0
  52. data/public/monaco-editor/vs/language/json/jsonWorker.js +42 -0
  53. metadata +26 -3
  54. data/app/services/mbeditor/unused_methods_service.rb +0 -139
@@ -18,7 +18,7 @@ module Mbeditor
18
18
  #
19
19
  # Usage:
20
20
  # results = RubyDefinitionService.call(workspace_root, "my_method",
21
- # excluded_dirnames: %w[tmp .git])
21
+ # excluded_paths: %w[tmp .git])
22
22
  #
23
23
  # Additional class methods for IntelliSense features:
24
24
  # RubyDefinitionService.defs_in_file(abs_path)
@@ -48,54 +48,12 @@ module Mbeditor
48
48
  @cache_loaded = false
49
49
 
50
50
  class << self
51
- attr_reader :file_cache, :mutex
52
51
  attr_accessor :cache_path
53
52
 
54
- def call(workspace_root, symbol, excluded_dirnames: [], excluded_paths: [], included_dirs: [])
53
+ def call(workspace_root, symbol, excluded_paths: [], included_dirs: [])
55
54
  new(workspace_root, symbol,
56
- excluded_dirnames: excluded_dirnames,
57
- excluded_paths: excluded_paths,
58
- included_dirs: included_dirs).call
59
- end
60
-
61
- # Load the JSON cache from disk exactly once per process (double-checked
62
- # under the mutex so concurrent first-calls don't double-load).
63
- def load_disk_cache_once
64
- return if @cache_loaded
65
-
66
- @mutex.synchronize do
67
- return if @cache_loaded
68
-
69
- @cache_loaded = true
70
- path = @cache_path.to_s
71
- return if path.empty? || !File.exist?(path)
72
-
73
- raw = JSON.parse(File.read(path))
74
- raw.each do |abs_path, entry|
75
- @file_cache[abs_path] = {
76
- mtime: entry["mtime"].to_f,
77
- lines: entry["lines"],
78
- all_defs: entry["all_defs"],
79
- module_names: entry["module_names"], # nil for old-format entries → triggers re-parse
80
- include_calls: entry["include_calls"] # nil for old-format entries → triggers re-parse
81
- }
82
- end
83
- rescue StandardError
84
- nil # corrupted or incompatible cache file — start fresh
85
- end
86
- end
87
-
88
- # Atomically write the in-memory cache to disk (tmp-file + rename).
89
- def persist_cache
90
- path = @cache_path.to_s
91
- return if path.empty?
92
-
93
- snapshot = @mutex.synchronize { @file_cache.dup }
94
- tmp_path = "#{path}.tmp"
95
- File.write(tmp_path, JSON.generate(snapshot))
96
- File.rename(tmp_path, path)
97
- rescue StandardError
98
- nil
55
+ excluded_paths: excluded_paths,
56
+ included_dirs: included_dirs).call
99
57
  end
100
58
 
101
59
  # Exposed for tests.
@@ -108,11 +66,13 @@ module Mbeditor
108
66
  end
109
67
 
110
68
  # Returns all method defs in a single file from the cache (no workspace walk).
111
- # The file must already be cached; returns {} if not found.
69
+ # Self-warms the cache for the file if not already present.
112
70
  # Result: { "method_name" => [{ line: Integer, signature: String }, ...] }
113
71
  def defs_in_file(file_path)
114
72
  load_disk_cache_once
115
- entry = @mutex.synchronize { @file_cache[file_path.to_s] }
73
+ file_path = file_path.to_s
74
+ entry = @mutex.synchronize { @file_cache[file_path] }
75
+ entry ||= new(File.dirname(file_path), nil).send(:cache_entry_for, file_path)
116
76
  return {} unless entry
117
77
 
118
78
  entry[:all_defs].transform_values do |lines_arr|
@@ -123,7 +83,7 @@ module Mbeditor
123
83
  # Searches the cache (and triggers a workspace scan if needed) to find
124
84
  # which file in +workspace_root+ defines the given module or class name.
125
85
  # Returns the absolute file path string or nil.
126
- def module_defined_in(workspace_root, module_name, excluded_dirnames: [], excluded_paths: [], included_dirs: [])
86
+ def module_defined_in(workspace_root, module_name, excluded_paths: [], included_dirs: [])
127
87
  load_disk_cache_once
128
88
  root_prefix = workspace_root.to_s.chomp("/")
129
89
  within_dirs = ->(path) {
@@ -137,9 +97,8 @@ module Mbeditor
137
97
 
138
98
  # Cache miss: scan workspace to populate cache entries with module_names.
139
99
  new(workspace_root, nil,
140
- excluded_dirnames: excluded_dirnames,
141
- excluded_paths: excluded_paths,
142
- included_dirs: included_dirs).scan_workspace
100
+ excluded_paths: excluded_paths,
101
+ included_dirs: included_dirs).scan_workspace
143
102
 
144
103
  result = @mutex.synchronize do
145
104
  @file_cache.find { |path, entry| within_dirs.call(path) && entry[:module_names]&.include?(module_name) }
@@ -148,39 +107,78 @@ module Mbeditor
148
107
  end
149
108
 
150
109
  # Returns the list of module/class names passed to include/extend/prepend
151
- # in the given file, from the cache. The file must already be cached;
152
- # returns [] if not found.
110
+ # in the given file. Self-warms the cache for the file if not already present.
153
111
  def includes_in_file(file_path)
154
112
  load_disk_cache_once
155
- entry = @mutex.synchronize { @file_cache[file_path.to_s] }
113
+ file_path = file_path.to_s
114
+ entry = @mutex.synchronize { @file_cache[file_path] }
115
+ entry ||= new(File.dirname(file_path), nil).send(:cache_entry_for, file_path)
156
116
  return [] unless entry
157
117
 
158
118
  entry[:include_calls] || []
159
119
  end
160
120
 
161
- # Convenience wrapper: scan the whole workspace to warm the cache.
162
- # Fast on subsequent calls (only re-parses files whose mtime changed).
163
- def scan(workspace_root, excluded_dirnames: [], excluded_paths: [], included_dirs: [])
164
- new(workspace_root, nil,
165
- excluded_dirnames: excluded_dirnames,
166
- excluded_paths: excluded_paths,
167
- included_dirs: included_dirs).scan_workspace
121
+ private
122
+
123
+ attr_reader :file_cache, :mutex
124
+
125
+ # Load the JSON cache from disk exactly once per process (double-checked
126
+ # under the mutex so concurrent first-calls don't double-load).
127
+ def load_disk_cache_once
128
+ return if @cache_loaded
129
+
130
+ @mutex.synchronize do
131
+ return if @cache_loaded
132
+
133
+ @cache_loaded = true
134
+ path = @cache_path.to_s
135
+ return if path.empty? || !File.exist?(path)
136
+
137
+ raw = JSON.parse(File.read(path))
138
+ raw.each do |abs_path, entry|
139
+ @file_cache[abs_path] = {
140
+ mtime: entry["mtime"].to_f,
141
+ lines: entry["lines"],
142
+ all_defs: entry["all_defs"],
143
+ module_names: entry["module_names"], # nil for old-format entries → triggers re-parse
144
+ include_calls: entry["include_calls"] # nil for old-format entries → triggers re-parse
145
+ }
146
+ end
147
+ rescue StandardError
148
+ nil # corrupted or incompatible cache file — start fresh
149
+ end
150
+ end
151
+
152
+ # Atomically write the in-memory cache to disk (tmp-file + rename).
153
+ def persist_cache
154
+ path = @cache_path.to_s
155
+ return if path.empty?
156
+
157
+ snapshot = @mutex.synchronize { @file_cache.dup }
158
+ tmp_path = "#{path}.tmp"
159
+ File.write(tmp_path, JSON.generate(snapshot))
160
+ File.rename(tmp_path, path)
161
+ rescue StandardError
162
+ nil
168
163
  end
164
+
169
165
  end
170
166
 
171
- def initialize(workspace_root, symbol, excluded_dirnames: [], excluded_paths: [], included_dirs: [])
167
+ def initialize(workspace_root, symbol, excluded_paths: [], included_dirs: [])
172
168
  @workspace_root = workspace_root.to_s.chomp("/")
173
169
  @symbol = symbol
174
- @excluded_dirnames = Array(excluded_dirnames)
175
170
  @excluded_paths = Array(excluded_paths)
176
171
  @included_dirs = Array(included_dirs)
172
+ @exclusion_matcher = ExclusionMatcher.new(@excluded_paths)
173
+ @shared_cache = self.class.send(:file_cache)
174
+ @shared_mutex = self.class.send(:mutex)
177
175
  end
178
176
 
179
177
  # Walks the entire workspace and populates the per-file cache (including the
180
178
  # new module_names and include_calls fields) without filtering by symbol.
181
179
  # Used by +module_defined_in+ to ensure the cache is warm.
182
180
  def scan_workspace
183
- self.class.load_disk_cache_once
181
+ load_disk_cache_once
184
182
  @new_entries = false
185
183
  files_scanned = 0
186
184
  evict_deleted_cache_entries
@@ -188,15 +186,13 @@ module Mbeditor
188
186
  search_roots.each do |root|
189
187
  Find.find(root) do |path|
190
188
  if File.directory?(path)
191
- dirname = File.basename(path)
192
- rel_dir = relative_path(path)
193
- Find.prune if path != root && excluded_dir?(dirname, rel_dir)
189
+ Find.prune if path != root && @exclusion_matcher.excluded?(relative_path(path))
194
190
  next
195
191
  end
196
192
  next unless path.end_with?(".rb")
197
193
 
198
194
  rel = relative_path(path)
199
- next if excluded_rel_path?(rel, File.basename(path))
195
+ next if @exclusion_matcher.excluded?(rel)
200
196
 
201
197
  files_scanned += 1
202
198
  if files_scanned > MAX_FILES_SCANNED
@@ -211,11 +207,11 @@ module Mbeditor
211
207
  end
212
208
  end
213
209
 
214
- self.class.persist_cache if @new_entries
210
+ persist_cache if @new_entries
215
211
  end
216
212
 
217
213
  def call
218
- self.class.load_disk_cache_once
214
+ load_disk_cache_once
219
215
 
220
216
  results = []
221
217
  @new_entries = false
@@ -225,18 +221,15 @@ module Mbeditor
225
221
 
226
222
  search_roots.each do |root|
227
223
  Find.find(root) do |path|
228
- # Prune excluded directories
229
224
  if File.directory?(path)
230
- dirname = File.basename(path)
231
- rel_dir = relative_path(path)
232
- Find.prune if path != root && excluded_dir?(dirname, rel_dir)
225
+ Find.prune if path != root && @exclusion_matcher.excluded?(relative_path(path))
233
226
  next
234
227
  end
235
228
 
236
229
  next unless path.end_with?(".rb")
237
230
 
238
231
  rel = relative_path(path)
239
- next if excluded_rel_path?(rel, File.basename(path))
232
+ next if @exclusion_matcher.excluded?(rel)
240
233
 
241
234
  files_scanned += 1
242
235
  if files_scanned > MAX_FILES_SCANNED
@@ -266,7 +259,7 @@ module Mbeditor
266
259
  end
267
260
  end
268
261
 
269
- self.class.persist_cache if @new_entries
262
+ persist_cache if @new_entries
270
263
  results
271
264
  end
272
265
 
@@ -274,12 +267,12 @@ module Mbeditor
274
267
 
275
268
  # Remove cache entries for files that no longer exist on disk.
276
269
  def evict_deleted_cache_entries
277
- stale_keys = self.class.mutex.synchronize do
278
- self.class.file_cache.keys.select { |p| !File.exist?(p) }
270
+ stale_keys = @shared_mutex.synchronize do
271
+ @shared_cache.keys.select { |p| !File.exist?(p) }
279
272
  end
280
273
  return if stale_keys.empty?
281
274
 
282
- self.class.mutex.synchronize { stale_keys.each { |k| self.class.file_cache.delete(k) } }
275
+ @shared_mutex.synchronize { stale_keys.each { |k| @shared_cache.delete(k) } }
283
276
  @new_entries = true
284
277
  end
285
278
 
@@ -287,7 +280,7 @@ module Mbeditor
287
280
  # been modified since the last parse. Returns nil on any read/parse error.
288
281
  def cache_entry_for(path)
289
282
  mtime = File.mtime(path).to_f
290
- cached = self.class.mutex.synchronize { self.class.file_cache[path] }
283
+ cached = @shared_mutex.synchronize { @shared_cache[path] }
291
284
  return cached if cached && cached[:mtime] == mtime && !cached[:module_names].nil?
292
285
 
293
286
  source = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
@@ -296,7 +289,7 @@ module Mbeditor
296
289
  all_defs, module_names, include_calls = sexp ? collect_all_defs(sexp) : [{}, [], []]
297
290
  entry = { mtime: mtime, lines: lines, all_defs: all_defs,
298
291
  module_names: module_names, include_calls: include_calls }
299
- self.class.mutex.synchronize { self.class.file_cache[path] = entry }
292
+ @shared_mutex.synchronize { @shared_cache[path] = entry }
300
293
  @new_entries = true
301
294
  entry
302
295
  rescue StandardError
@@ -406,25 +399,8 @@ module Mbeditor
406
399
  dirs.empty? ? [@workspace_root] : dirs
407
400
  end
408
401
 
409
- def excluded_dir?(dirname, rel_dir)
410
- @excluded_dirnames.include?(dirname) ||
411
- @excluded_paths.any? do |pattern|
412
- if pattern.include?("/")
413
- rel_dir == pattern || rel_dir.start_with?("#{pattern}/")
414
- else
415
- dirname == pattern
416
- end
417
- end
418
- end
402
+ def load_disk_cache_once = self.class.send(:load_disk_cache_once)
403
+ def persist_cache = self.class.send(:persist_cache)
419
404
 
420
- def excluded_rel_path?(rel, name)
421
- @excluded_paths.any? do |pattern|
422
- if pattern.include?("/")
423
- rel == pattern || rel.start_with?("#{pattern}/")
424
- else
425
- name == pattern || rel.split("/").include?(pattern)
426
- end
427
- end
428
- end
429
405
  end
430
406
  end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ # Parses db/schema.rb or db/structure.sql to extract table definitions.
5
+ # Returns nil when the schema file is missing or the table is not found.
6
+ #
7
+ # Return format:
8
+ # {
9
+ # table: "users",
10
+ # model: "User",
11
+ # columns: [{ name: "id", type: "integer" }, ...],
12
+ # indexes: [{ name: "idx_users_on_email", columns: ["email"], unique: true }]
13
+ # }
14
+ class SchemaService
15
+ SCHEMA_RB_PATH = "db/schema.rb"
16
+ STRUCTURE_SQL_PATH = "db/structure.sql"
17
+
18
+ def initialize(model_name, workspace_root)
19
+ @model_name = model_name.to_s.strip
20
+ @workspace_root = workspace_root.to_s
21
+ end
22
+
23
+ def call
24
+ return nil if @model_name.empty? || @workspace_root.empty?
25
+
26
+ table_name = derive_table_name(@model_name)
27
+
28
+ # Try schema.rb first (Ruby format)
29
+ result = try_schema_rb(table_name)
30
+ return result if result
31
+
32
+ # Try structure.sql (SQL format)
33
+ result = try_structure_sql(table_name)
34
+ return result if result
35
+
36
+ Rails.logger.debug("SchemaService: table '#{table_name}' (from model '#{@model_name}') not found in db/schema.rb or db/structure.sql. Check if model has custom table_name.")
37
+ nil
38
+ end
39
+
40
+ private
41
+
42
+ # "User" → "users", "OrderItem" → "order_items", "Order Item" → "order_items"
43
+ def derive_table_name(model_name)
44
+ normalized = model_name.delete(" ")
45
+ ActiveSupport::Inflector.tableize(normalized)
46
+ end
47
+
48
+ # Try to read and parse db/schema.rb
49
+ def try_schema_rb(table_name)
50
+ schema_path = File.join(@workspace_root, SCHEMA_RB_PATH)
51
+ return nil unless File.exist?(schema_path)
52
+
53
+ begin
54
+ content = File.read(schema_path, encoding: "utf-8")
55
+ parse_schema_rb(content, table_name)
56
+ rescue Errno::ENOENT, Errno::EACCES => e
57
+ Rails.logger.debug("SchemaService: failed to read #{schema_path}: #{e.message}")
58
+ nil
59
+ end
60
+ end
61
+
62
+ # Try to read and parse db/structure.sql
63
+ def try_structure_sql(table_name)
64
+ schema_path = File.join(@workspace_root, STRUCTURE_SQL_PATH)
65
+ return nil unless File.exist?(schema_path)
66
+
67
+ begin
68
+ content = File.read(schema_path, encoding: "utf-8")
69
+ parse_structure_sql(content, table_name)
70
+ rescue Errno::ENOENT, Errno::EACCES => e
71
+ Rails.logger.debug("SchemaService: failed to read #{schema_path}: #{e.message}")
72
+ nil
73
+ end
74
+ end
75
+
76
+ # ── Schema.rb parsing (Ruby DSL) ──────────────────────────────────────
77
+
78
+ # Finds the create_table block and parses it.
79
+ # Uses the leading-whitespace backreference to match the correct closing `end`.
80
+ def parse_schema_rb(content, table_name)
81
+ re = /^( *)create_table\s+"#{Regexp.escape(table_name)}"([^\n]*)\n(.*?)^\1end\b/m
82
+ m = content.match(re)
83
+ return nil unless m
84
+
85
+ body = m[3]
86
+
87
+ {
88
+ table: table_name,
89
+ model: @model_name,
90
+ columns: parse_schema_rb_columns(body),
91
+ indexes: parse_schema_rb_indexes(body)
92
+ }
93
+ end
94
+
95
+ # Parses `t.type "name", options...` column lines from schema.rb
96
+ def parse_schema_rb_columns(body)
97
+ columns = []
98
+ body.scan(/^\s+t\.(\w+)\s+"([^"]+)"(.*)$/) do |type, name, rest|
99
+ next if type == "index"
100
+
101
+ col = { name: name, type: type }
102
+ col[:null] = false if rest.include?("null: false")
103
+ col[:primary_key] = true if rest.include?("primary_key: true")
104
+
105
+ if (m = rest.match(/default:\s*(.+?)(?:,\s*\w|$)/))
106
+ col[:default] = m[1].strip
107
+ end
108
+ if (m = rest.match(/limit:\s*(\d+)/))
109
+ col[:limit] = m[1].to_i
110
+ end
111
+ if (m = rest.match(/precision:\s*(\d+)/))
112
+ col[:precision] = m[1].to_i
113
+ end
114
+ if (m = rest.match(/scale:\s*(\d+)/))
115
+ col[:scale] = m[1].to_i
116
+ end
117
+
118
+ columns << col
119
+ end
120
+ columns
121
+ end
122
+
123
+ # Parses `t.index ["col"], name: "idx", unique: true` index lines from schema.rb
124
+ def parse_schema_rb_indexes(body)
125
+ indexes = []
126
+ body.scan(/^\s+t\.index\s+\[([^\]]*)\](.*)$/) do |cols_str, rest|
127
+ cols = cols_str.scan(/"([^"]+)"/).flatten
128
+ next if cols.empty?
129
+
130
+ idx = { columns: cols }
131
+ if (m = rest.match(/name:\s*"([^"]+)"/))
132
+ idx[:name] = m[1]
133
+ end
134
+ idx[:unique] = true if rest.include?("unique: true")
135
+ indexes << idx
136
+ end
137
+ indexes
138
+ end
139
+
140
+ # ── Structure.sql parsing (SQL DDL) ──────────────────────────────────
141
+
142
+ # Parses structure.sql (SQL format) to extract table definition
143
+ def parse_structure_sql(content, table_name)
144
+ # Match CREATE TABLE ... ( ... ) with support for various SQL dialects
145
+ # Handles: PostgreSQL, MySQL, SQLite with quoted/unquoted names
146
+ # Ends with different delimiters: ); ENGINE...; or just );
147
+ quoted_name = Regexp.escape(table_name)
148
+ patterns = [
149
+ # PostgreSQL with public schema: CREATE TABLE public."users" ( ... );
150
+ /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?["`]?#{quoted_name}["`]?\s*\(([\s\S]*?)\)\s*;/mi,
151
+ # MySQL with ENGINE: CREATE TABLE `users` ( ... ) ENGINE=...;
152
+ /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?#{quoted_name}["`]?\s*\(([\s\S]*?)\)\s*(?:ENGINE|DEFAULT)/mi
153
+ ]
154
+
155
+ table_def = nil
156
+ patterns.each do |pattern|
157
+ if (m = content.match(pattern))
158
+ table_def = m[1]
159
+ break
160
+ end
161
+ end
162
+
163
+ return nil unless table_def
164
+
165
+ {
166
+ table: table_name,
167
+ model: @model_name,
168
+ columns: parse_sql_columns(table_def),
169
+ indexes: parse_sql_indexes(content, table_name)
170
+ }
171
+ end
172
+
173
+ # Parses column definitions from SQL CREATE TABLE body
174
+ def parse_sql_columns(table_def)
175
+ columns = []
176
+
177
+ # Process line by line to handle complex type declarations like decimal(10,2)
178
+ table_def.each_line do |line|
179
+ line = line.strip
180
+ next if line.empty?
181
+ next if line.match?(/^(PRIMARY|UNIQUE|FOREIGN|KEY|INDEX|CONSTRAINT)/i)
182
+
183
+ # Extract column name and full definition
184
+ # Matches: `name` type(...) constraints, name type NOT NULL, etc.
185
+ if (m = line.match(/^["`]?(\w+)["`]?\s+(.+?)\s*,?\s*$/i))
186
+ col_name = m[1]
187
+ definition = m[2]
188
+
189
+ # Split at constraint keywords to isolate the type
190
+ # Extract everything before NOT NULL, NULL, AUTO_INCREMENT, DEFAULT, PRIMARY, etc.
191
+ col_type_and_params = definition.split(/\s+(?:NOT\s+NULL|NULL|AUTO_INCREMENT|DEFAULT|PRIMARY|UNIQUE|CHECK|COLLATE|COMMENT|ON|USING)/i).first.strip.downcase
192
+
193
+ # Extract base type (before parentheses)
194
+ col_type = col_type_and_params.split('(').first.strip
195
+ col = { name: col_name, type: sql_type_to_rails(col_type) }
196
+
197
+ col[:null] = false if definition.match?(/\bNOT\s+NULL\b/i)
198
+ col[:primary_key] = true if definition.match?(/\bPRIMARY\s+KEY\b/i)
199
+
200
+ # Extract precision and scale for decimals/numeric types
201
+ if (pm = col_type_and_params.match(/\((\d+),\s*(\d+)\)/))
202
+ col[:precision] = pm[1].to_i
203
+ col[:scale] = pm[2].to_i
204
+ elsif (pm = col_type_and_params.match(/\((\d+)\)/))
205
+ col[:limit] = pm[1].to_i
206
+ end
207
+
208
+ columns << col
209
+ end
210
+ end
211
+
212
+ columns
213
+ end
214
+
215
+ # Parses index definitions from structure.sql
216
+ def parse_sql_indexes(content, table_name)
217
+ indexes = []
218
+
219
+ # Match CREATE INDEX ... ON table_name (columns)
220
+ pattern = /CREATE\s+(?:UNIQUE\s+)?INDEX\s+["`]?(\w+)["`]?\s+ON\s+(?:public\.)?["`]?#{Regexp.escape(table_name)}["`]?\s*\((.*?)\)/mi
221
+
222
+ content.scan(pattern) do |index_name, columns_str|
223
+ cols = columns_str.split(',').map { |c| c.strip.gsub(/["`]/, '').split(/\s+/).first }.compact
224
+ next if cols.empty?
225
+
226
+ idx = {
227
+ name: index_name,
228
+ columns: cols
229
+ }
230
+ idx[:unique] = true if content.match?(/CREATE\s+UNIQUE\s+INDEX\s+["`]?#{Regexp.escape(index_name)}/mi)
231
+
232
+ indexes << idx
233
+ end
234
+
235
+ indexes
236
+ end
237
+
238
+ # Map SQL types to Rails column types
239
+ def sql_type_to_rails(sql_type)
240
+ type_map = {
241
+ 'integer' => 'integer',
242
+ 'int' => 'integer',
243
+ 'bigint' => 'bigint',
244
+ 'smallint' => 'integer',
245
+ 'bigserial' => 'bigint',
246
+ 'serial' => 'integer',
247
+ 'varchar' => 'string',
248
+ 'character varying' => 'string',
249
+ 'character' => 'string',
250
+ 'text' => 'text',
251
+ 'boolean' => 'boolean',
252
+ 'bool' => 'boolean',
253
+ 'decimal' => 'decimal',
254
+ 'numeric' => 'decimal',
255
+ 'float' => 'float',
256
+ 'double' => 'float',
257
+ 'timestamp' => 'datetime',
258
+ 'datetime' => 'datetime',
259
+ 'date' => 'date',
260
+ 'time' => 'time',
261
+ 'json' => 'json',
262
+ 'jsonb' => 'jsonb',
263
+ 'uuid' => 'uuid',
264
+ 'bytea' => 'binary'
265
+ }
266
+
267
+ type_map[sql_type.downcase] || sql_type
268
+ end
269
+ end
270
+ end