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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/README.md +7 -0
- data/app/assets/javascripts/mbeditor/application.js +3 -0
- data/app/assets/javascripts/mbeditor/components/ChangelogView.js +145 -0
- data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +359 -31
- data/app/assets/javascripts/mbeditor/components/FileTree.js +177 -116
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +952 -143
- data/app/assets/javascripts/mbeditor/components/TabBar.js +9 -0
- data/app/assets/javascripts/mbeditor/conflict_parser.js +48 -0
- data/app/assets/javascripts/mbeditor/editor_plugins.js +420 -67
- data/app/assets/javascripts/mbeditor/editor_store.js +1 -0
- data/app/assets/javascripts/mbeditor/file_service.js +34 -6
- data/app/assets/javascripts/mbeditor/git_service.js +2 -1
- data/app/assets/javascripts/mbeditor/history_service.js +177 -0
- data/app/assets/javascripts/mbeditor/search_service.js +1 -0
- data/app/assets/javascripts/mbeditor/tab_manager.js +8 -5
- data/app/assets/stylesheets/mbeditor/application.css +112 -0
- data/app/assets/stylesheets/mbeditor/editor.css +443 -78
- data/app/channels/mbeditor/editor_channel.rb +5 -41
- data/app/controllers/mbeditor/application_controller.rb +8 -1
- data/app/controllers/mbeditor/editors_controller.rb +276 -654
- data/app/controllers/mbeditor/git_controller.rb +2 -61
- data/app/services/mbeditor/availability_probe.rb +83 -0
- data/app/services/mbeditor/code_search_service.rb +42 -0
- data/app/services/mbeditor/editor_state_service.rb +91 -0
- data/app/services/mbeditor/exclusion_matcher.rb +23 -0
- data/app/services/mbeditor/file_operation_service.rb +68 -0
- data/app/services/mbeditor/file_tree_service.rb +69 -0
- data/app/services/mbeditor/git_combined_diff_service.rb +43 -0
- data/app/services/mbeditor/git_commit_detail_service.rb +46 -0
- data/app/services/mbeditor/git_info_service.rb +151 -0
- data/app/services/mbeditor/git_service.rb +36 -26
- data/app/services/mbeditor/js_definition_service.rb +59 -0
- data/app/services/mbeditor/js_members_service.rb +62 -0
- data/app/services/mbeditor/process_runner.rb +48 -0
- data/app/services/mbeditor/rails_related_files_service.rb +282 -0
- data/app/services/mbeditor/ruby_definition_service.rb +77 -101
- data/app/services/mbeditor/schema_service.rb +270 -0
- data/app/services/mbeditor/search_replace_service.rb +184 -0
- data/app/services/mbeditor/test_runner_service.rb +5 -27
- data/app/views/layouts/mbeditor/application.html.erb +2 -2
- data/config/routes.rb +8 -1
- data/lib/mbeditor/configuration.rb +4 -2
- data/lib/mbeditor/version.rb +1 -1
- data/public/monaco-editor/vs/language/css/cssMode.js +13 -0
- data/public/monaco-editor/vs/language/css/cssWorker.js +77 -0
- data/public/monaco-editor/vs/language/html/htmlMode.js +13 -0
- data/public/monaco-editor/vs/language/html/htmlWorker.js +454 -0
- data/public/monaco-editor/vs/language/json/jsonMode.js +19 -0
- data/public/monaco-editor/vs/language/json/jsonWorker.js +42 -0
- metadata +26 -3
- 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
|
-
#
|
|
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,
|
|
53
|
+
def call(workspace_root, symbol, excluded_paths: [], included_dirs: [])
|
|
55
54
|
new(workspace_root, symbol,
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
141
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
210
|
+
persist_cache if @new_entries
|
|
215
211
|
end
|
|
216
212
|
|
|
217
213
|
def call
|
|
218
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
278
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
410
|
-
|
|
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
|