mbeditor 0.12.8 → 0.13.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/CHANGELOG.md +207 -0
- data/app/assets/javascripts/mbeditor/application.js +7 -1
- data/app/assets/javascripts/mbeditor/collaboration_service.js +31 -0
- data/app/assets/javascripts/mbeditor/color_provider.js +5 -0
- data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +3 -1
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +135 -40
- data/app/assets/javascripts/mbeditor/components/FileTree.js +6 -1
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +10 -1
- data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +15 -8
- data/app/assets/javascripts/mbeditor/components/ImportDialog.js +216 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +629 -137
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +218 -13
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +39 -33
- data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
- data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +312 -0
- data/app/assets/javascripts/mbeditor/editor_plugins.js +444 -116
- data/app/assets/javascripts/mbeditor/file_import.js +78 -0
- data/app/assets/javascripts/mbeditor/file_service.js +122 -18
- data/app/assets/javascripts/mbeditor/git_service.js +130 -8
- data/app/assets/javascripts/mbeditor/history_service.js +33 -38
- data/app/assets/javascripts/mbeditor/log_service.js +4 -0
- data/app/assets/javascripts/mbeditor/search_service.js +30 -2
- data/app/assets/javascripts/mbeditor/tab_manager.js +59 -6
- data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
- data/app/assets/stylesheets/mbeditor/editor.css +302 -55
- data/app/channels/mbeditor/channel_authentication.rb +7 -0
- data/app/channels/mbeditor/editor_channel.rb +6 -3
- data/app/controllers/mbeditor/application_controller.rb +5 -0
- data/app/controllers/mbeditor/editors_controller.rb +173 -29
- data/app/controllers/mbeditor/git_controller.rb +3 -3
- data/app/controllers/mbeditor/logs_controller.rb +3 -1
- data/app/services/mbeditor/collaboration_doc_store.rb +7 -0
- data/app/services/mbeditor/editor_state_service.rb +36 -26
- data/app/services/mbeditor/exclusion_matcher.rb +12 -10
- data/app/services/mbeditor/file_operation_service.rb +38 -3
- data/app/services/mbeditor/file_tree_service.rb +30 -2
- data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
- data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
- data/app/services/mbeditor/git_diff_service.rb +5 -1
- data/app/services/mbeditor/git_info_service.rb +20 -8
- data/app/services/mbeditor/git_line_diff_service.rb +6 -2
- data/app/services/mbeditor/git_service.rb +65 -15
- data/app/services/mbeditor/js_definition_service.rb +3 -1
- data/app/services/mbeditor/js_globals_service.rb +7 -4
- data/app/services/mbeditor/js_members_service.rb +3 -2
- data/app/services/mbeditor/js_program_service.rb +15 -5
- data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
- data/app/services/mbeditor/process_runner.rb +42 -12
- data/app/services/mbeditor/ri_definition_service.rb +8 -1
- data/app/services/mbeditor/route_service.rb +45 -8
- data/app/services/mbeditor/rubocop_run_service.rb +86 -0
- data/app/services/mbeditor/ruby_definition_service.rb +23 -4
- data/app/services/mbeditor/schema_service.rb +65 -64
- data/app/services/mbeditor/search_replace_service.rb +75 -10
- data/app/services/mbeditor/test_runner_service.rb +98 -10
- data/lib/mbeditor/cable_log_filter.rb +8 -2
- data/lib/mbeditor/configuration.rb +12 -1
- data/lib/mbeditor/editor_bootstrap.rb +18 -12
- data/lib/mbeditor/engine.rb +22 -3
- data/lib/mbeditor/exception_log.rb +2 -2
- data/lib/mbeditor/pending_migrations.rb +33 -0
- data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
- data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
- data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
- data/lib/mbeditor/route_map.rb +2 -0
- data/lib/mbeditor/ruby_lsp_client.rb +71 -12
- data/lib/mbeditor/version.rb +1 -1
- metadata +7 -2
|
@@ -12,13 +12,6 @@ module Mbeditor
|
|
|
12
12
|
module RouteService
|
|
13
13
|
module_function
|
|
14
14
|
|
|
15
|
-
# Actions with no route are worth calling out, but only for controllers Rails
|
|
16
|
-
# would actually dispatch to. These are the inherited ones every controller
|
|
17
|
-
# has and nobody routes.
|
|
18
|
-
NON_ACTION_METHODS = %w[
|
|
19
|
-
new inspect method_of to_s hash class dup freeze
|
|
20
|
-
].freeze
|
|
21
|
-
|
|
22
15
|
# "app/controllers/admin/users_controller.rb" -> "admin/users", which is the
|
|
23
16
|
# key Rails stores in a route's defaults.
|
|
24
17
|
def controller_key(relative_path)
|
|
@@ -29,18 +22,62 @@ module Mbeditor
|
|
|
29
22
|
match[1]
|
|
30
23
|
end
|
|
31
24
|
|
|
25
|
+
MUTEX = Mutex.new
|
|
26
|
+
private_constant :MUTEX
|
|
27
|
+
|
|
28
|
+
# Matches the other read-through caches in this engine (FileTreeService 15s,
|
|
29
|
+
# GitInfoService 10s). A write invalidates it outright, so the TTL only ever
|
|
30
|
+
# bounds staleness from a route change made outside the editor.
|
|
31
|
+
CACHE_TTL = 10
|
|
32
|
+
|
|
32
33
|
# => { "show" => [{ verb:, path:, name: }], ... }
|
|
34
|
+
#
|
|
35
|
+
# Cached because every call walks the host app's ENTIRE route set, and the
|
|
36
|
+
# caller is the inline route hints — which re-request on every activation of
|
|
37
|
+
# a controller tab and on every external content change. Switching between
|
|
38
|
+
# two controllers repeatedly was therefore a full O(routes) scan per switch,
|
|
39
|
+
# and a large app has thousands of routes. The scan itself is unchanged;
|
|
40
|
+
# it just stops happening once per glance.
|
|
33
41
|
def for_controller(key)
|
|
34
42
|
return {} if key.nil? || key.empty?
|
|
35
43
|
return {} unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
36
44
|
|
|
37
|
-
|
|
45
|
+
cached = cached_routes(key)
|
|
46
|
+
return cached if cached
|
|
47
|
+
|
|
48
|
+
# Built outside the mutex: it runs arbitrary host-app route code, and
|
|
49
|
+
# holding the lock across it would serialise every other controller's
|
|
50
|
+
# lookup behind the slowest one.
|
|
51
|
+
computed = routes_for(key)
|
|
52
|
+
store_routes(key, computed)
|
|
53
|
+
computed
|
|
38
54
|
rescue StandardError
|
|
39
55
|
# A broken route set must not take the editor down with it — the file still
|
|
40
56
|
# opens, just without hints.
|
|
41
57
|
{}
|
|
42
58
|
end
|
|
43
59
|
|
|
60
|
+
def invalidate
|
|
61
|
+
MUTEX.synchronize { @cache = {} }
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def cached_routes(key)
|
|
66
|
+
MUTEX.synchronize do
|
|
67
|
+
entry = (@cache ||= {})[key]
|
|
68
|
+
return entry[:data] if entry && (Process.clock_gettime(Process::CLOCK_MONOTONIC) - entry[:ts]) < CACHE_TTL
|
|
69
|
+
end
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
private_class_method :cached_routes
|
|
73
|
+
|
|
74
|
+
def store_routes(key, data)
|
|
75
|
+
MUTEX.synchronize do
|
|
76
|
+
(@cache ||= {})[key] = { ts: Process.clock_gettime(Process::CLOCK_MONOTONIC), data: data }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
private_class_method :store_routes
|
|
80
|
+
|
|
44
81
|
def routes_for(key)
|
|
45
82
|
out = {}
|
|
46
83
|
Rails.application.routes.routes.each do |route|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module Mbeditor
|
|
7
|
+
# Whole-workspace `rubocop` run — the counterpart to the per-buffer /lint
|
|
8
|
+
# endpoint, which can only report on files Monaco already has open.
|
|
9
|
+
#
|
|
10
|
+
# `-a` (safe autocorrect) rather than `-A`: unsafe corrections can change
|
|
11
|
+
# behaviour, and this runs over every file in the project at once, so an
|
|
12
|
+
# unreviewed `-A` is a much bigger blast radius than the per-buffer quick fix.
|
|
13
|
+
module RubocopRunService
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def run(workspace_root, mode: :check, timeout: 300)
|
|
17
|
+
matcher = ExclusionMatcher.new(Mbeditor.configuration.excluded_paths, root: workspace_root)
|
|
18
|
+
cmd = AvailabilityProbe.rubocop_command(workspace_root) +
|
|
19
|
+
[AvailabilityProbe.rubocop_server_flag(workspace_root), "--format", "json", "--no-color"]
|
|
20
|
+
cmd << "-a" if mode.to_sym == :autocorrect
|
|
21
|
+
|
|
22
|
+
result = ProcessRunner.call(
|
|
23
|
+
cmd,
|
|
24
|
+
timeout: timeout,
|
|
25
|
+
chdir: workspace_root.to_s,
|
|
26
|
+
env: { "RUBOCOP_CACHE_ROOT" => File.join(Dir.tmpdir, "rubocop") }
|
|
27
|
+
)
|
|
28
|
+
parse(result[:stdout], result[:stderr], matcher)
|
|
29
|
+
rescue ProcessRunner::TimeoutError
|
|
30
|
+
error("RuboCop timed out after #{timeout}s")
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
error(e.message)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# RuboCop writes its JSON to stdout, but anything the host app prints while
|
|
36
|
+
# loading (a deprecation warning, a bundler notice) lands in front of it —
|
|
37
|
+
# hence the seek to the first brace, same as the /lint path.
|
|
38
|
+
# +matcher+ drops offenses in paths the workspace excludes — vendored gems
|
|
39
|
+
# above all. RuboCop's own defaults exclude `vendor/**/*`, but a host
|
|
40
|
+
# .rubocop.yml that sets AllCops/Exclude without `inherit_mode: merge`
|
|
41
|
+
# silently replaces them, and the panel then fills with gem source.
|
|
42
|
+
#
|
|
43
|
+
# ponytail: filtered after the fact, so a host in that state still pays for
|
|
44
|
+
# the walk. Generate a config that inherits from theirs and re-adds the
|
|
45
|
+
# excludes if the run time ever becomes the complaint.
|
|
46
|
+
def parse(stdout, stderr = nil, matcher = nil)
|
|
47
|
+
idx = stdout.index("{")
|
|
48
|
+
return error(stderr.to_s.strip.split("\n").last || "RuboCop produced no output") unless idx
|
|
49
|
+
|
|
50
|
+
data = JSON.parse(stdout[idx..])
|
|
51
|
+
files = (data["files"] || []).filter_map do |file|
|
|
52
|
+
next if matcher&.excluded?(file["path"].to_s)
|
|
53
|
+
|
|
54
|
+
offenses = file["offenses"] || []
|
|
55
|
+
next if offenses.empty?
|
|
56
|
+
|
|
57
|
+
{ path: file["path"], offenses: offenses.map { |o| offense(o) } }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
ok: true,
|
|
62
|
+
files: files.sort_by { |f| f[:path].to_s },
|
|
63
|
+
summary: data["summary"] || {},
|
|
64
|
+
correctable: files.sum { |f| f[:offenses].count { |o| o[:correctable] } }
|
|
65
|
+
}
|
|
66
|
+
rescue JSON::ParserError => e
|
|
67
|
+
error(e.message)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def offense(o)
|
|
71
|
+
{
|
|
72
|
+
copName: o["cop_name"],
|
|
73
|
+
message: o["message"],
|
|
74
|
+
severity: o["severity"],
|
|
75
|
+
correctable: o["correctable"] == true,
|
|
76
|
+
corrected: o["corrected"] == true,
|
|
77
|
+
line: o.dig("location", "start_line") || o.dig("location", "line") || 1,
|
|
78
|
+
column: o.dig("location", "start_column") || o.dig("location", "column") || 1
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def error(message)
|
|
83
|
+
{ ok: false, error: message, files: [], summary: {}, correctable: 0 }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -34,6 +34,8 @@ module Mbeditor
|
|
|
34
34
|
# Upper bound on cached files. Entries carry full file contents, so this
|
|
35
35
|
# caps both the on-disk JSON and the resident hash.
|
|
36
36
|
MAX_CACHE_ENTRIES = 2_000
|
|
37
|
+
# Minimum gap between disk writes of that cache (seconds).
|
|
38
|
+
PERSIST_INTERVAL = 30
|
|
37
39
|
|
|
38
40
|
# In-process file-index cache.
|
|
39
41
|
# Structure: { absolute_path => {
|
|
@@ -63,6 +65,7 @@ module Mbeditor
|
|
|
63
65
|
def clear_cache!
|
|
64
66
|
@mutex.synchronize { @file_cache.clear; @cache_loaded = false }
|
|
65
67
|
@last_evict_at = nil
|
|
68
|
+
@last_persist_at = nil
|
|
66
69
|
path = @cache_path.to_s
|
|
67
70
|
File.delete(path) if !path.empty? && File.exist?(path)
|
|
68
71
|
rescue StandardError
|
|
@@ -154,10 +157,18 @@ module Mbeditor
|
|
|
154
157
|
end
|
|
155
158
|
|
|
156
159
|
# Atomically write the in-memory cache to disk (tmp-file + rename).
|
|
160
|
+
# Debounced: the snapshot carries every cached file's full source, so a
|
|
161
|
+
# lookup that parsed one new file would otherwise rewrite tens of MB of
|
|
162
|
+
# JSON. Skipping a write only delays it — the entries stay in memory and
|
|
163
|
+
# a later lookup flushes them.
|
|
157
164
|
def persist_cache
|
|
158
165
|
path = @cache_path.to_s
|
|
159
166
|
return if path.empty?
|
|
160
167
|
|
|
168
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
169
|
+
return if @last_persist_at && (now - @last_persist_at) < PERSIST_INTERVAL
|
|
170
|
+
|
|
171
|
+
@last_persist_at = now
|
|
161
172
|
snapshot = @mutex.synchronize do
|
|
162
173
|
# Each entry holds the file's full source lines, so an uncapped cache
|
|
163
174
|
# grows to tens of MB on a large workspace and is rewritten in full on
|
|
@@ -263,7 +274,10 @@ module Mbeditor
|
|
|
263
274
|
signature: (cached[:lines][def_line - 1] || "").strip,
|
|
264
275
|
comments: extract_comments(cached[:lines], def_line)
|
|
265
276
|
}
|
|
266
|
-
|
|
277
|
+
if results.length >= MAX_RESULTS
|
|
278
|
+
persist_cache if @new_entries
|
|
279
|
+
return results
|
|
280
|
+
end
|
|
267
281
|
end
|
|
268
282
|
rescue StandardError
|
|
269
283
|
# Malformed file or unreadable; skip silently
|
|
@@ -284,14 +298,19 @@ module Mbeditor
|
|
|
284
298
|
|
|
285
299
|
def evict_deleted_cache_entries
|
|
286
300
|
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
287
|
-
|
|
301
|
+
keys = @shared_mutex.synchronize do
|
|
288
302
|
last = self.class.instance_variable_get(:@last_evict_at)
|
|
289
303
|
next nil if last && (now - last) < EVICT_INTERVAL
|
|
290
304
|
|
|
291
305
|
self.class.instance_variable_set(:@last_evict_at, now)
|
|
292
|
-
@shared_cache.keys
|
|
306
|
+
@shared_cache.keys
|
|
293
307
|
end
|
|
294
|
-
return if
|
|
308
|
+
return if keys.nil?
|
|
309
|
+
|
|
310
|
+
# stat()ing outside the lock: up to MAX_CACHE_ENTRIES syscalls, and every
|
|
311
|
+
# other cache reader would otherwise wait on them.
|
|
312
|
+
stale_keys = keys.reject { |p| File.exist?(p) }
|
|
313
|
+
return if stale_keys.empty?
|
|
295
314
|
|
|
296
315
|
@shared_mutex.synchronize { stale_keys.each { |k| @shared_cache.delete(k) } }
|
|
297
316
|
@new_entries = true
|
|
@@ -235,10 +235,11 @@ module Mbeditor
|
|
|
235
235
|
def parse_sql_indexes(content, table_name)
|
|
236
236
|
indexes = []
|
|
237
237
|
|
|
238
|
-
# Match CREATE INDEX ... ON table_name (columns)
|
|
239
|
-
|
|
238
|
+
# Match CREATE INDEX ... ON table_name (columns). UNIQUE is captured here
|
|
239
|
+
# rather than re-scanned per index with a freshly compiled regex.
|
|
240
|
+
pattern = /CREATE\s+(UNIQUE\s+)?INDEX\s+["`]?(\w+)["`]?\s+ON\s+(?:public\.)?["`]?#{Regexp.escape(table_name)}["`]?\s*\((.*?)\)/mi
|
|
240
241
|
|
|
241
|
-
content.scan(pattern) do |index_name, columns_str|
|
|
242
|
+
content.scan(pattern) do |unique, index_name, columns_str|
|
|
242
243
|
cols = columns_str.split(',').map { |c| c.strip.gsub(/["`]/, '').split(/\s+/).first }.compact
|
|
243
244
|
next if cols.empty?
|
|
244
245
|
|
|
@@ -246,7 +247,7 @@ module Mbeditor
|
|
|
246
247
|
name: index_name,
|
|
247
248
|
columns: cols
|
|
248
249
|
}
|
|
249
|
-
idx[:unique] = true if
|
|
250
|
+
idx[:unique] = true if unique
|
|
250
251
|
|
|
251
252
|
indexes << idx
|
|
252
253
|
end
|
|
@@ -255,67 +256,67 @@ module Mbeditor
|
|
|
255
256
|
end
|
|
256
257
|
|
|
257
258
|
# Map SQL types to Rails column types
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}
|
|
259
|
+
SQL_TYPE_TO_RAILS = {
|
|
260
|
+
'integer' => 'integer',
|
|
261
|
+
'int' => 'integer',
|
|
262
|
+
'int4' => 'integer',
|
|
263
|
+
'int2' => 'integer',
|
|
264
|
+
'int8' => 'bigint',
|
|
265
|
+
'bigint' => 'bigint',
|
|
266
|
+
'smallint' => 'integer',
|
|
267
|
+
'bigserial' => 'bigint',
|
|
268
|
+
'serial' => 'integer',
|
|
269
|
+
'varchar' => 'string',
|
|
270
|
+
'character varying' => 'string',
|
|
271
|
+
'character' => 'string',
|
|
272
|
+
'char' => 'string',
|
|
273
|
+
'text' => 'text',
|
|
274
|
+
'citext' => 'string',
|
|
275
|
+
'boolean' => 'boolean',
|
|
276
|
+
'bool' => 'boolean',
|
|
277
|
+
'decimal' => 'decimal',
|
|
278
|
+
'numeric' => 'decimal',
|
|
279
|
+
'real' => 'float',
|
|
280
|
+
'float' => 'float',
|
|
281
|
+
'float4' => 'float',
|
|
282
|
+
'float8' => 'float',
|
|
283
|
+
'double precision' => 'float',
|
|
284
|
+
'double' => 'float',
|
|
285
|
+
'money' => 'decimal',
|
|
286
|
+
'timestamp' => 'datetime',
|
|
287
|
+
'timestamp without time zone' => 'datetime',
|
|
288
|
+
'timestamp with time zone' => 'datetime',
|
|
289
|
+
'timestamptz' => 'datetime',
|
|
290
|
+
'datetime' => 'datetime',
|
|
291
|
+
'date' => 'date',
|
|
292
|
+
'time' => 'time',
|
|
293
|
+
'time without time zone' => 'time',
|
|
294
|
+
'time with time zone' => 'time',
|
|
295
|
+
'interval' => 'string',
|
|
296
|
+
'json' => 'json',
|
|
297
|
+
'jsonb' => 'jsonb',
|
|
298
|
+
'uuid' => 'uuid',
|
|
299
|
+
'bytea' => 'binary',
|
|
300
|
+
'bit' => 'string',
|
|
301
|
+
'bit varying' => 'string',
|
|
302
|
+
'inet' => 'string',
|
|
303
|
+
'cidr' => 'string',
|
|
304
|
+
'macaddr' => 'string',
|
|
305
|
+
'xml' => 'string',
|
|
306
|
+
'hstore' => 'hstore',
|
|
307
|
+
'tsvector' => 'string',
|
|
308
|
+
'ltree' => 'string',
|
|
309
|
+
'point' => 'string',
|
|
310
|
+
'line' => 'string',
|
|
311
|
+
'lseg' => 'string',
|
|
312
|
+
'box' => 'string',
|
|
313
|
+
'path' => 'string',
|
|
314
|
+
'polygon' => 'string',
|
|
315
|
+
'circle' => 'string'
|
|
316
|
+
}.freeze
|
|
317
317
|
|
|
318
|
-
|
|
318
|
+
def sql_type_to_rails(sql_type)
|
|
319
|
+
SQL_TYPE_TO_RAILS[sql_type.downcase] || sql_type
|
|
319
320
|
end
|
|
320
321
|
end
|
|
321
322
|
end
|
|
@@ -23,6 +23,12 @@ module Mbeditor
|
|
|
23
23
|
MAX_RESULTS = 10_000
|
|
24
24
|
RESULT_CACHE_TTL = 30 # seconds; also invalidated on mbeditor file mutations
|
|
25
25
|
RESULT_CACHE_MAX_ENTRIES = 3
|
|
26
|
+
# A user-supplied regex is matched in-process, and Timeout.timeout cannot
|
|
27
|
+
# interrupt a CRuby regex — only Regexp's own timeout (Ruby >= 3.2) can.
|
|
28
|
+
# Below that the gemspec floor (3.0) leaves a catastrophic pattern
|
|
29
|
+
# unguarded, which is the pre-existing behaviour.
|
|
30
|
+
REGEXP_TIMEOUT = 1 # seconds
|
|
31
|
+
REGEXP_TIMEOUT_SUPPORTED = Regexp.method_defined?(:timeout)
|
|
26
32
|
|
|
27
33
|
STATE_MUTEX = Mutex.new
|
|
28
34
|
private_constant :STATE_MUTEX
|
|
@@ -112,6 +118,10 @@ module Mbeditor
|
|
|
112
118
|
else
|
|
113
119
|
@result_cache = {}
|
|
114
120
|
end
|
|
121
|
+
# Bumped so a scan already in flight can tell its results are stale
|
|
122
|
+
# and skip filling the cache it just emptied. One counter for every
|
|
123
|
+
# root: an unrelated invalidation only costs a cache miss.
|
|
124
|
+
@cache_generation = (@cache_generation || 0) + 1
|
|
115
125
|
end
|
|
116
126
|
end
|
|
117
127
|
|
|
@@ -153,7 +163,14 @@ module Mbeditor
|
|
|
153
163
|
content = File.binread(full_path).force_encoding("UTF-8")
|
|
154
164
|
.encode("UTF-8", invalid: :replace, undef: :replace)
|
|
155
165
|
replacements_in_file = content.scan(pattern).length
|
|
156
|
-
|
|
166
|
+
# Block form for a literal search: the two-argument gsub expands
|
|
167
|
+
# \1, \& and \\ in the replacement, which a non-regex replace
|
|
168
|
+
# must insert verbatim.
|
|
169
|
+
new_content = if use_regex
|
|
170
|
+
content.gsub(pattern, replacement)
|
|
171
|
+
else
|
|
172
|
+
content.gsub(pattern) { replacement }
|
|
173
|
+
end
|
|
157
174
|
if new_content != content
|
|
158
175
|
File.binwrite(full_path, new_content.encode("UTF-8", invalid: :replace, undef: :replace))
|
|
159
176
|
files_affected << rel_path
|
|
@@ -185,10 +202,12 @@ module Mbeditor
|
|
|
185
202
|
key = [workspace_root.to_s, query, use_regex, match_case, whole_word,
|
|
186
203
|
Array(excluded_paths).map(&:to_s).sort]
|
|
187
204
|
now = monotonic
|
|
188
|
-
STATE_MUTEX.synchronize do
|
|
205
|
+
generation = STATE_MUTEX.synchronize do
|
|
189
206
|
@result_cache ||= {}
|
|
190
207
|
entry = @result_cache[key]
|
|
191
208
|
return entry[:data] if entry && (now - entry[:ts]) < RESULT_CACHE_TTL
|
|
209
|
+
|
|
210
|
+
@cache_generation ||= 0
|
|
192
211
|
end
|
|
193
212
|
|
|
194
213
|
data = scan(workspace_root, query, use_regex: use_regex, match_case: match_case,
|
|
@@ -198,6 +217,10 @@ module Mbeditor
|
|
|
198
217
|
# Don't cache superseded scans — their results are truncated by the kill.
|
|
199
218
|
unless data[:superseded]
|
|
200
219
|
STATE_MUTEX.synchronize do
|
|
220
|
+
# A save landed while this scan ran: these rows predate it, and
|
|
221
|
+
# storing them now would serve pre-save matches for a full TTL.
|
|
222
|
+
next if (@cache_generation ||= 0) != generation
|
|
223
|
+
|
|
201
224
|
@result_cache[key] = { ts: monotonic, data: data }
|
|
202
225
|
@result_cache.delete(@result_cache.keys.first) while @result_cache.length > RESULT_CACHE_MAX_ENTRIES
|
|
203
226
|
end
|
|
@@ -229,11 +252,20 @@ module Mbeditor
|
|
|
229
252
|
end
|
|
230
253
|
end
|
|
231
254
|
|
|
255
|
+
# Used to locate the match within each hit line so a result can carry
|
|
256
|
+
# its columns. Invalid user regexes are already reported elsewhere;
|
|
257
|
+
# here a nil pattern just means the rows come back without columns.
|
|
258
|
+
pattern = begin
|
|
259
|
+
build_pattern(query, use_regex: use_regex, match_case: match_case, whole_word: whole_word)
|
|
260
|
+
rescue RegexpError
|
|
261
|
+
nil
|
|
262
|
+
end
|
|
263
|
+
|
|
232
264
|
begin
|
|
233
265
|
io.each_line do |raw|
|
|
234
266
|
break if results.length >= max
|
|
235
267
|
|
|
236
|
-
row = parse_line(tier, raw, root)
|
|
268
|
+
row = parse_line(tier, raw, root, pattern)
|
|
237
269
|
next unless row
|
|
238
270
|
next if matcher.excluded?(row[:file])
|
|
239
271
|
|
|
@@ -336,7 +368,7 @@ module Mbeditor
|
|
|
336
368
|
end
|
|
337
369
|
end
|
|
338
370
|
|
|
339
|
-
def parse_line(tier, raw, root)
|
|
371
|
+
def parse_line(tier, raw, root, pattern = nil)
|
|
340
372
|
if tier == :rg
|
|
341
373
|
begin
|
|
342
374
|
data = JSON.parse(raw)
|
|
@@ -346,11 +378,23 @@ module Mbeditor
|
|
|
346
378
|
return nil unless data["type"] == "match"
|
|
347
379
|
|
|
348
380
|
md = data["data"]
|
|
381
|
+
raw_text = md.dig("lines", "text").to_s
|
|
382
|
+
# rg reports submatch offsets in BYTES; Monaco columns are character
|
|
383
|
+
# based, so slice the prefix and measure it as characters.
|
|
384
|
+
sub = Array(md["submatches"]).first
|
|
385
|
+
cols = if sub && sub["start"] && sub["end"]
|
|
386
|
+
bytes = raw_text.dup.force_encoding(Encoding::BINARY)
|
|
387
|
+
start_chars = bytes[0, sub["start"]].to_s.force_encoding(Encoding::UTF_8).scrub.length
|
|
388
|
+
match_chars = bytes[sub["start"], sub["end"] - sub["start"]].to_s.force_encoding(Encoding::UTF_8).scrub.length
|
|
389
|
+
{ col: start_chars + 1, end_col: start_chars + match_chars + 1 }
|
|
390
|
+
else
|
|
391
|
+
match_columns(raw_text, pattern)
|
|
392
|
+
end
|
|
349
393
|
return {
|
|
350
394
|
file: relative_path(md.dig("path", "text").to_s, root),
|
|
351
395
|
line: md.dig("line_number"),
|
|
352
|
-
text:
|
|
353
|
-
}
|
|
396
|
+
text: raw_text.strip
|
|
397
|
+
}.merge(cols)
|
|
354
398
|
end
|
|
355
399
|
|
|
356
400
|
# git grep / grep emit "path:line:text" — possibly with bytes that are
|
|
@@ -367,7 +411,25 @@ module Mbeditor
|
|
|
367
411
|
file_path = relative_path(file_path, root)
|
|
368
412
|
end
|
|
369
413
|
|
|
370
|
-
|
|
414
|
+
raw_text = Regexp.last_match(3)
|
|
415
|
+
{ file: file_path, line: Regexp.last_match(2).to_i, text: raw_text.strip }
|
|
416
|
+
.merge(match_columns(raw_text, pattern))
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# 1-based Monaco columns for the first match on a hit line. Returns an
|
|
420
|
+
# empty hash when there is no usable pattern or it doesn't match — the
|
|
421
|
+
# row is still a valid result, it just opens at the start of the line.
|
|
422
|
+
# Measured against the RAW line, never the stripped `text`: the client
|
|
423
|
+
# cannot recover the leading whitespace the strip removed.
|
|
424
|
+
def match_columns(raw_text, pattern)
|
|
425
|
+
return {} unless pattern
|
|
426
|
+
|
|
427
|
+
m = pattern.match(raw_text)
|
|
428
|
+
return {} unless m
|
|
429
|
+
|
|
430
|
+
{ col: m.begin(0) + 1, end_col: m.end(0) + 1 }
|
|
431
|
+
rescue StandardError
|
|
432
|
+
{}
|
|
371
433
|
end
|
|
372
434
|
|
|
373
435
|
def register_search(root, pid)
|
|
@@ -407,11 +469,14 @@ module Mbeditor
|
|
|
407
469
|
|
|
408
470
|
def build_pattern(query, use_regex:, match_case:, whole_word:)
|
|
409
471
|
flags = match_case ? 0 : Regexp::IGNORECASE
|
|
410
|
-
if use_regex
|
|
411
|
-
|
|
472
|
+
source = if use_regex
|
|
473
|
+
whole_word ? "\\b(?:#{query})\\b" : query
|
|
412
474
|
else
|
|
413
|
-
|
|
475
|
+
whole_word ? "\\b#{Regexp.escape(query)}\\b" : Regexp.escape(query)
|
|
414
476
|
end
|
|
477
|
+
return Regexp.new(source, flags, timeout: REGEXP_TIMEOUT) if REGEXP_TIMEOUT_SUPPORTED
|
|
478
|
+
|
|
479
|
+
Regexp.new(source, flags)
|
|
415
480
|
end
|
|
416
481
|
|
|
417
482
|
def relative_path(absolute_path, workspace_root)
|