mbeditor 0.13.1 → 0.14.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 +91 -0
- data/app/assets/javascripts/mbeditor/application.js +3 -0
- data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
- data/app/assets/javascripts/mbeditor/collaboration_service.js +248 -26
- data/app/assets/javascripts/mbeditor/components/ChangelogView.js +89 -94
- data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +6 -9
- data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +12 -7
- data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +20 -0
- data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +429 -247
- data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -9
- data/app/assets/javascripts/mbeditor/components/FileTree.js +26 -12
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +3 -0
- data/app/assets/javascripts/mbeditor/components/Gutter.js +51 -0
- data/app/assets/javascripts/mbeditor/components/LogPanel.js +3 -44
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1168 -1243
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +47 -65
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +26 -8
- data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
- data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -1
- data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
- data/app/assets/javascripts/mbeditor/editor_plugins.js +687 -305
- data/app/assets/javascripts/mbeditor/file_import.js +13 -15
- data/app/assets/javascripts/mbeditor/file_service.js +38 -39
- data/app/assets/javascripts/mbeditor/git_service.js +15 -1
- data/app/assets/javascripts/mbeditor/history_service.js +5 -13
- data/app/assets/javascripts/mbeditor/search_service.js +9 -0
- data/app/assets/javascripts/mbeditor/tab_manager.js +127 -49
- data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
- data/app/assets/stylesheets/mbeditor/application.css +6 -1
- data/app/assets/stylesheets/mbeditor/editor.css +642 -248
- data/app/assets/stylesheets/mbeditor/glass.css +163 -0
- data/app/assets/stylesheets/mbeditor/themes.css +90 -30
- data/app/channels/mbeditor/collaboration_channel.rb +17 -4
- data/app/controllers/mbeditor/application_controller.rb +26 -3
- data/app/controllers/mbeditor/editors_controller.rb +117 -439
- data/app/services/mbeditor/archive_service.rb +137 -0
- data/app/services/mbeditor/collaboration_doc_store.rb +143 -14
- data/app/services/mbeditor/editor_state_service.rb +14 -51
- data/app/services/mbeditor/file_history_service.rb +222 -0
- data/app/services/mbeditor/git_info_service.rb +6 -0
- data/app/services/mbeditor/js_syntax_check_service.rb +42 -16
- data/app/services/mbeditor/lint_service.rb +137 -0
- data/app/services/mbeditor/locked_json_file.rb +67 -0
- data/app/services/mbeditor/process_runner.rb +32 -0
- data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
- data/app/services/mbeditor/search_replace_service.rb +8 -0
- data/app/views/layouts/mbeditor/application.html.erb +1 -1
- data/lib/mbeditor/audit_log.rb +203 -0
- data/lib/mbeditor/configuration.rb +6 -1
- data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
- data/lib/mbeditor/route_map.rb +4 -0
- data/lib/mbeditor/ruby_lsp_client.rb +82 -14
- data/lib/mbeditor/version.rb +1 -1
- data/lib/mbeditor.rb +1 -0
- metadata +12 -2
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mbeditor
|
|
4
|
+
# Per-branch, per-file undo history: the ops the client couldn't replay from
|
|
5
|
+
# its own in-memory undo stack (a reload, a second tab) get persisted here as
|
|
6
|
+
# a base snapshot plus an appended op log, keyed by branch+path so switching
|
|
7
|
+
# branches doesn't cross-contaminate history.
|
|
8
|
+
#
|
|
9
|
+
# Built on LockedJsonFile — the same sidecar-lock + atomic-write primitive
|
|
10
|
+
# EditorStateService uses — rather than a hand-rolled flock loop that wrote
|
|
11
|
+
# in place with truncate/rewind. A crash mid-write there truncated history
|
|
12
|
+
# instead of leaving the previous file untouched.
|
|
13
|
+
class FileHistoryService
|
|
14
|
+
LockTimeoutError = LockedJsonFile::LockTimeoutError
|
|
15
|
+
BaseRequiredError = Class.new(StandardError)
|
|
16
|
+
BaseTooLargeError = Class.new(StandardError)
|
|
17
|
+
|
|
18
|
+
# v1 (no "v") began tracking before the file content had arrived, so it
|
|
19
|
+
# recorded the whole-file load as an insert-at-origin op against an empty
|
|
20
|
+
# base — once per open. v2 begins tracking once the load has landed, so the
|
|
21
|
+
# load is the base and only real edits are ops. Legacy files are migrated on
|
|
22
|
+
# read (migrate_legacy!); legacy writes are normalized in #append.
|
|
23
|
+
FORMAT_VERSION = 2
|
|
24
|
+
MAX_OPS = 10_000
|
|
25
|
+
COMPACT_TARGET = 5_000
|
|
26
|
+
MAX_AGE_SECONDS = 7 * 24 * 3600
|
|
27
|
+
BASE_MAX_BYTES = EditorStateService::STATE_MAX_BYTES
|
|
28
|
+
# Op count alone doesn't bound size: a few huge pastes can outweigh MAX_OPS
|
|
29
|
+
# tiny edits, so compaction also triggers on the serialized payload size.
|
|
30
|
+
MAX_BYTES = 4 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
def initialize(workspace_root, lock_timeout: EditorStateService::DEFAULT_LOCK_TIMEOUT, max_bytes: MAX_BYTES)
|
|
33
|
+
@root = workspace_root
|
|
34
|
+
@lock_timeout = lock_timeout
|
|
35
|
+
@max_bytes = max_bytes
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns { "base" => ..., "ops" => [...] }, or nil if there is no history
|
|
39
|
+
# or it aged out (an aged-out file is pruned as a side effect).
|
|
40
|
+
def read(branch, rel_path)
|
|
41
|
+
path = history_path(branch, rel_path)
|
|
42
|
+
return nil unless File.exist?(path)
|
|
43
|
+
|
|
44
|
+
data = JSON.parse(File.read(path))
|
|
45
|
+
|
|
46
|
+
if data["t"] && (Time.now.utc - Time.parse(data["t"])) > MAX_AGE_SECONDS
|
|
47
|
+
FileUtils.rm_f(path)
|
|
48
|
+
return nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
data = migrate_legacy!(path, data)
|
|
52
|
+
return nil unless data
|
|
53
|
+
|
|
54
|
+
{ "base" => data["base"], "ops" => data["ops"] || [] }
|
|
55
|
+
rescue JSON::ParserError
|
|
56
|
+
FileUtils.rm_f(path)
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Appends ops, seeding the history with `base` on the first write for this
|
|
61
|
+
# branch+path. `base_given` distinguishes "no base param at all" (an
|
|
62
|
+
# error, except on a non-first write) from an explicit empty base (a
|
|
63
|
+
# legitimate first snapshot — e.g. a file that is empty on disk, tracked
|
|
64
|
+
# from "" before the first edit).
|
|
65
|
+
#
|
|
66
|
+
# `version` is the client's format version. A legacy client (absent or <
|
|
67
|
+
# FORMAT_VERSION) sends base "" plus the whole-file load as its first op;
|
|
68
|
+
# that is folded into the base here so the bad shape never reaches disk.
|
|
69
|
+
def append(branch, rel_path, ops:, base: nil, base_given: false, version: nil)
|
|
70
|
+
ops = Array(ops)
|
|
71
|
+
base = base.to_s
|
|
72
|
+
|
|
73
|
+
if version.to_i < FORMAT_VERSION && base_given && base.empty?
|
|
74
|
+
base = ops.shift[4].to_s if load_op?(ops.first)
|
|
75
|
+
ops = ops.reject { |op| load_op?(op) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
file = LockedJsonFile.new(history_path(branch, rel_path), lock_timeout: @lock_timeout, error_class: LockTimeoutError)
|
|
79
|
+
|
|
80
|
+
file.with_lock do
|
|
81
|
+
existing = file.read
|
|
82
|
+
|
|
83
|
+
if existing.empty?
|
|
84
|
+
raise BaseRequiredError unless base_given
|
|
85
|
+
raise BaseTooLargeError if base.bytesize > BASE_MAX_BYTES
|
|
86
|
+
|
|
87
|
+
existing = {
|
|
88
|
+
"branch" => branch,
|
|
89
|
+
"path" => rel_path,
|
|
90
|
+
"base" => base,
|
|
91
|
+
"ops" => [],
|
|
92
|
+
"v" => FORMAT_VERSION,
|
|
93
|
+
"t" => Time.now.utc.iso8601
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
existing["v"] = FORMAT_VERSION
|
|
98
|
+
existing["ops"] = (existing["ops"] || []) + ops
|
|
99
|
+
existing["t"] = Time.now.utc.iso8601
|
|
100
|
+
|
|
101
|
+
payload = existing.to_json
|
|
102
|
+
if existing["ops"].length > MAX_OPS || payload.bytesize > @max_bytes
|
|
103
|
+
compact_until_bounded!(existing)
|
|
104
|
+
payload = existing.to_json
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
file.write(payload)
|
|
108
|
+
end
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Deletes history files for branches no longer in active_branches.
|
|
113
|
+
def prune(active_branches:)
|
|
114
|
+
hist_dir = @root.join("tmp", "mbeditor_history")
|
|
115
|
+
return unless File.directory?(hist_dir)
|
|
116
|
+
|
|
117
|
+
Dir.glob(File.join(hist_dir, "*.json")) do |hist_file|
|
|
118
|
+
data = begin
|
|
119
|
+
JSON.parse(File.read(hist_file))
|
|
120
|
+
rescue JSON::ParserError => e
|
|
121
|
+
Rails.logger.error("[mbeditor] FileHistoryService#prune: skipping corrupt history file #{hist_file}: #{e.message}")
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
next unless data.is_a?(Hash) && data["branch"]
|
|
125
|
+
|
|
126
|
+
FileUtils.rm_f(hist_file) unless active_branches.include?(data["branch"])
|
|
127
|
+
end
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def history_path(branch, rel_path)
|
|
134
|
+
branch_hash = Digest::SHA256.hexdigest(branch.to_s)[0, 16]
|
|
135
|
+
file_hash = Digest::SHA256.hexdigest(rel_path.to_s)[0, 16]
|
|
136
|
+
@root.join("tmp", "mbeditor_history", "#{branch_hash}_#{file_hash}.json")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Rewrites a v1 history into the v2 shape and returns it, or nil if there is
|
|
140
|
+
# nothing left to replay (the file is then removed). A v2 file is returned
|
|
141
|
+
# untouched. Writes are atomic (rename), so a concurrent reader never sees a
|
|
142
|
+
# half-written file; the rewrite is idempotent, so a racing append that also
|
|
143
|
+
# normalizes is harmless.
|
|
144
|
+
def migrate_legacy!(path, data)
|
|
145
|
+
return data if data["v"].to_i >= FORMAT_VERSION
|
|
146
|
+
|
|
147
|
+
base = data["base"].to_s
|
|
148
|
+
ops = Array(data["ops"])
|
|
149
|
+
|
|
150
|
+
if base.empty? && load_op?(ops.first)
|
|
151
|
+
# The first load is exactly equivalent to the base it was applied to.
|
|
152
|
+
base = ops.shift[4].to_s
|
|
153
|
+
# Every later load starts another session whose preceding edits already
|
|
154
|
+
# reproduce it. Left in place they concatenate the file onto itself;
|
|
155
|
+
# dropping them reconstructs the last session's edits over the first
|
|
156
|
+
# session's content.
|
|
157
|
+
ops = ops.reject { |op| load_op?(op) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
migrated = data.merge("base" => base, "ops" => ops, "v" => FORMAT_VERSION)
|
|
161
|
+
|
|
162
|
+
if base.empty? && ops.empty?
|
|
163
|
+
FileUtils.rm_f(path)
|
|
164
|
+
nil
|
|
165
|
+
else
|
|
166
|
+
LockedJsonFile.new(path, lock_timeout: @lock_timeout, error_class: LockTimeoutError).write(migrated)
|
|
167
|
+
migrated
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# A whole-file load as the v1 client recorded it: an insertion at the very
|
|
172
|
+
# start of the document. Its text is the file content at that open.
|
|
173
|
+
def load_op?(op)
|
|
174
|
+
return false unless op.is_a?(Array) && op.length >= 5
|
|
175
|
+
|
|
176
|
+
op[0].to_i == 1 && op[1].to_i == 1 && op[2].to_i == 1 && op[3].to_i == 1
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Folds ops into the base until both the op count and the serialized size
|
|
180
|
+
# are back under budget. Stops early when the op log is empty — a base that
|
|
181
|
+
# alone exceeds the budget (a very large file) can't be shrunk further
|
|
182
|
+
# without the current file contents, which the service does not have.
|
|
183
|
+
def compact_until_bounded!(data)
|
|
184
|
+
data["ops"] ||= []
|
|
185
|
+
loop do
|
|
186
|
+
break if data["ops"].length <= MAX_OPS && data.to_json.bytesize <= @max_bytes
|
|
187
|
+
break if data["ops"].empty?
|
|
188
|
+
|
|
189
|
+
data["base"] = compact_ops(data["base"], data["ops"].shift(COMPACT_TARGET))
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Replays a batch of ops against `base` to fold them into a new snapshot
|
|
194
|
+
# once the op log outgrows MAX_OPS, so history keeps a bounded number of
|
|
195
|
+
# ops without losing anything: base + remaining ops still reconstructs the
|
|
196
|
+
# same document. Each op is [startLine, startCol, endLine, endCol, insertedText],
|
|
197
|
+
# 1-based, matching Monaco's model.onDidChangeContent ranges.
|
|
198
|
+
def compact_ops(base, ops)
|
|
199
|
+
text = base.to_s
|
|
200
|
+
ops.each do |op|
|
|
201
|
+
sl, sc, el, ec, ins = op[0].to_i, op[1].to_i, op[2].to_i, op[3].to_i, op[4].to_s
|
|
202
|
+
lines = text.split("\n", -1)
|
|
203
|
+
sl0 = [[sl - 1, 0].max, [lines.length - 1, 0].max].min
|
|
204
|
+
el0 = [[el - 1, 0].max, [lines.length - 1, 0].max].min
|
|
205
|
+
sc0 = sc - 1
|
|
206
|
+
ec0 = ec - 1
|
|
207
|
+
prefix = (lines[sl0] || "")[0, sc0] || ""
|
|
208
|
+
suffix = (lines[el0] || "")[ec0..] || ""
|
|
209
|
+
ins_lines = ins.split("\n", -1)
|
|
210
|
+
new_seg = if ins_lines.length <= 1
|
|
211
|
+
[prefix + (ins_lines[0] || "") + suffix]
|
|
212
|
+
else
|
|
213
|
+
[prefix + ins_lines[0]] + ins_lines[1..-2] + [ins_lines[-1] + suffix]
|
|
214
|
+
end
|
|
215
|
+
text = (lines[0...sl0] + new_seg + lines[(el0 + 1)..]).join("\n")
|
|
216
|
+
end
|
|
217
|
+
text
|
|
218
|
+
rescue StandardError
|
|
219
|
+
base.to_s
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -43,8 +43,14 @@ module Mbeditor
|
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
begin
|
|
46
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
46
47
|
compute(repo_path)
|
|
47
48
|
ensure
|
|
49
|
+
# One record per git-info wave, and only for the thread that owns the
|
|
50
|
+
# computation: `ms` is the wall time of the whole concurrent fan-out.
|
|
51
|
+
# Each git subprocess inside it is recorded separately by ProcessRunner.
|
|
52
|
+
AuditLog.record(:git_wave,
|
|
53
|
+
ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
|
|
48
54
|
done = GIT_INFO_MUTEX.synchronize { @git_info_flights.delete(repo_path) }
|
|
49
55
|
done&.close
|
|
50
56
|
end
|
|
@@ -165,10 +165,16 @@ module Mbeditor
|
|
|
165
165
|
MUTEX = Mutex.new
|
|
166
166
|
private_constant :MUTEX
|
|
167
167
|
|
|
168
|
+
# ponytail: one shared V8 context behind one global lock, so concurrent
|
|
169
|
+
# saves queue; a check skipped under contention is advisory-only. Hold a
|
|
170
|
+
# small pool of contexts if skips ever become common.
|
|
171
|
+
LOCK_WAIT_SECONDS = 0.1
|
|
172
|
+
|
|
168
173
|
class << self
|
|
169
174
|
def available?
|
|
170
175
|
return false if Mbeditor.configuration.js_syntax_check == false
|
|
171
176
|
return false unless defined?(::MiniRacer)
|
|
177
|
+
return false if @context == :broken
|
|
172
178
|
|
|
173
179
|
!babel_source_path.nil?
|
|
174
180
|
end
|
|
@@ -178,7 +184,7 @@ module Mbeditor
|
|
|
178
184
|
def check(source)
|
|
179
185
|
return nil unless available?
|
|
180
186
|
|
|
181
|
-
|
|
187
|
+
with_lock(nil) do
|
|
182
188
|
begin
|
|
183
189
|
ctx = context
|
|
184
190
|
return nil unless ctx
|
|
@@ -224,7 +230,7 @@ module Mbeditor
|
|
|
224
230
|
program = JsProgramService.call(workspace_root.to_s)
|
|
225
231
|
globals = JsGlobalsService.call(workspace_root.to_s)
|
|
226
232
|
|
|
227
|
-
|
|
233
|
+
with_lock([]) do
|
|
228
234
|
begin
|
|
229
235
|
ctx = context
|
|
230
236
|
return [] unless ctx
|
|
@@ -257,27 +263,47 @@ module Mbeditor
|
|
|
257
263
|
|
|
258
264
|
private
|
|
259
265
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
ctx.eval("if (typeof Babel === 'undefined') { throw new Error('Babel global missing'); }")
|
|
268
|
-
@checks_run = 0
|
|
269
|
-
ctx
|
|
270
|
-
rescue StandardError
|
|
271
|
-
@context = nil
|
|
272
|
-
nil
|
|
266
|
+
# Skipping the lock costs a lint run, never correctness.
|
|
267
|
+
def with_lock(unavailable)
|
|
268
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + LOCK_WAIT_SECONDS
|
|
269
|
+
until MUTEX.try_lock
|
|
270
|
+
return unavailable if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
271
|
+
|
|
272
|
+
sleep 0.005
|
|
273
273
|
end
|
|
274
|
+
|
|
275
|
+
begin
|
|
276
|
+
yield
|
|
277
|
+
ensure
|
|
278
|
+
MUTEX.unlock
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def context
|
|
283
|
+
return nil if @context == :broken
|
|
284
|
+
return @context if @context
|
|
285
|
+
|
|
286
|
+
path = babel_source_path
|
|
287
|
+
return nil unless path
|
|
288
|
+
|
|
289
|
+
ctx = ::MiniRacer::Context.new(timeout: EVAL_TIMEOUT_MS)
|
|
290
|
+
ctx.eval(File.read(path))
|
|
291
|
+
ctx.eval("if (typeof Babel === 'undefined') { throw new Error('Babel global missing'); }")
|
|
292
|
+
@checks_run = 0
|
|
293
|
+
@context = ctx
|
|
294
|
+
rescue StandardError => e
|
|
295
|
+
# A bundle that will not evaluate will not evaluate on the next save
|
|
296
|
+
# either; re-reading multi-MB of babel per save is pure waste.
|
|
297
|
+
@context = :broken
|
|
298
|
+
Rails.logger&.warn("[mbeditor] babel asset #{path.inspect} failed to load, JS syntax check disabled: #{e.message}")
|
|
299
|
+
nil
|
|
274
300
|
end
|
|
275
301
|
|
|
276
302
|
def reset_context!
|
|
277
303
|
# Dropping the reference leaks the V8 isolate until the GC finalizer
|
|
278
304
|
# runs; dispose releases it now.
|
|
279
305
|
begin
|
|
280
|
-
@context
|
|
306
|
+
@context.dispose if @context.respond_to?(:dispose)
|
|
281
307
|
rescue StandardError
|
|
282
308
|
nil
|
|
283
309
|
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Mbeditor
|
|
8
|
+
# The module CONTEXT.md's glossary already names: mbeditor's linting
|
|
9
|
+
# toolchain, rubocop (diagnostics via --stdin; autocorrect via a
|
|
10
|
+
# workspace-local tempfile so rubocop's config discovery finds the host
|
|
11
|
+
# app's .rubocop.yml) and haml-lint (diagnostics only). Every subprocess
|
|
12
|
+
# runs through ProcessRunner.
|
|
13
|
+
#
|
|
14
|
+
# Diagnostics come back Monaco-marker-shaped (startLine/copName/…) rather
|
|
15
|
+
# than as a neutral intermediate — matching LspDiagnosticsTranslator, the
|
|
16
|
+
# sibling module for ruby-lsp diagnostics, which does the same. Client-side
|
|
17
|
+
# JS linting (JsSyntaxCheckService) and the Monaco-edit shaping in
|
|
18
|
+
# EditorsController#compute_text_edit are presentation-layer concerns and
|
|
19
|
+
# stay out of this module.
|
|
20
|
+
module LintService
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def rubocop_diagnostics(workspace_root, path, code)
|
|
24
|
+
cmd = AvailabilityProbe.rubocop_command(workspace_root) +
|
|
25
|
+
[AvailabilityProbe.rubocop_server_flag(workspace_root), "--stdin", path.to_s, "--format", "json", "--no-color"]
|
|
26
|
+
output = run(cmd, env: rubocop_env, stdin_data: code)[:stdout]
|
|
27
|
+
|
|
28
|
+
idx = output.index("{")
|
|
29
|
+
result = idx ? JSON.parse(output[idx..]) : {}
|
|
30
|
+
result = {} unless result.is_a?(Hash)
|
|
31
|
+
offenses = result.dig("files", 0, "offenses") || []
|
|
32
|
+
|
|
33
|
+
markers = offenses.map do |offense|
|
|
34
|
+
{
|
|
35
|
+
severity: cop_severity(offense["severity"]),
|
|
36
|
+
copName: offense["cop_name"],
|
|
37
|
+
correctable: offense["correctable"] == true,
|
|
38
|
+
message: "[#{offense['cop_name']}] #{offense['message']}",
|
|
39
|
+
startLine: offense.dig("location", "start_line") || offense.dig("location", "line"),
|
|
40
|
+
startCol: offense.dig("location", "start_column") || offense.dig("location", "column") || 1,
|
|
41
|
+
endLine: offense.dig("location", "last_line") || offense.dig("location", "line"),
|
|
42
|
+
endCol: offense.dig("location", "last_column") || offense.dig("location", "column") || 1,
|
|
43
|
+
# Same predicate the ruby-lsp path uses, so dead code fades whichever
|
|
44
|
+
# linter produced the offense. Plain rubocop JSON carries no
|
|
45
|
+
# code_description, so there's no codeHref to pass on here.
|
|
46
|
+
unnecessary: LspDiagnosticsTranslator.unnecessary?(offense["cop_name"])
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
{ markers: markers, summary: result["summary"] }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def haml_diagnostics(workspace_root, code)
|
|
54
|
+
markers = []
|
|
55
|
+
Tempfile.create(["mbeditor_haml", ".haml"]) do |f|
|
|
56
|
+
f.write(code)
|
|
57
|
+
f.flush
|
|
58
|
+
cmd = AvailabilityProbe.haml_lint_command(workspace_root) + ["--reporter", "json", "--no-color", f.path]
|
|
59
|
+
output = run(cmd)[:stdout]
|
|
60
|
+
idx = output.index("{")
|
|
61
|
+
result = idx ? JSON.parse(output[idx..]) : {}
|
|
62
|
+
result = {} unless result.is_a?(Hash)
|
|
63
|
+
offenses = result.dig("files", 0, "offenses") || []
|
|
64
|
+
markers = offenses.map do |offense|
|
|
65
|
+
{
|
|
66
|
+
severity: haml_lint_severity(offense["severity"]),
|
|
67
|
+
message: "[#{offense['linter_name']}] #{offense['message']}",
|
|
68
|
+
startLine: offense.dig("location", "line"),
|
|
69
|
+
startCol: (offense.dig("location", "column") || 1) - 1,
|
|
70
|
+
endLine: offense.dig("location", "line"),
|
|
71
|
+
endCol: offense.dig("location", "column") || 1
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
markers
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Runs a full `rubocop -A` pass on +code+ (not the file on disk) via a
|
|
79
|
+
# workspace-local tempfile, and reports whether the pass completed.
|
|
80
|
+
# +ok: false+ means rubocop itself failed (exit status neither 0 nor 1),
|
|
81
|
+
# not that no offense was found — callers that care about an actual diff
|
|
82
|
+
# compare +content+ against the code they passed in.
|
|
83
|
+
def autocorrect(workspace_root, path, code)
|
|
84
|
+
ext = File.extname(File.basename(path))
|
|
85
|
+
Tempfile.create([".mbeditor_autocorrect_", ext], File.dirname(path)) do |f|
|
|
86
|
+
f.write(code)
|
|
87
|
+
f.flush
|
|
88
|
+
tmpfile = f.path
|
|
89
|
+
|
|
90
|
+
cmd = AvailabilityProbe.rubocop_command(workspace_root) +
|
|
91
|
+
[AvailabilityProbe.rubocop_server_flag(workspace_root), "-A", "--no-color", tmpfile]
|
|
92
|
+
status = run(cmd, env: rubocop_env)[:exit_status]
|
|
93
|
+
|
|
94
|
+
# exit 0 = no offenses, exit 1 = offenses corrected, exit 2 = error
|
|
95
|
+
next { ok: false, content: code } unless status.success? || status.exitstatus == 1
|
|
96
|
+
|
|
97
|
+
corrected = File.read(tmpfile, encoding: "UTF-8", invalid: :replace, undef: :replace)
|
|
98
|
+
{ ok: true, content: corrected }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Kept in step with LspDiagnosticsTranslator::SEVERITIES so a file linted
|
|
103
|
+
# through ruby-lsp and the same file linted through `rubocop --stdin` grade
|
|
104
|
+
# their offenses identically. rubocop's own `info` is the weakest level and
|
|
105
|
+
# maps to hint; convention/refactor fall through to info.
|
|
106
|
+
def cop_severity(severity)
|
|
107
|
+
case severity
|
|
108
|
+
when "error", "fatal" then "error"
|
|
109
|
+
when "warning" then "warning"
|
|
110
|
+
when "info" then "hint"
|
|
111
|
+
else "info"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def haml_lint_severity(severity)
|
|
116
|
+
case severity
|
|
117
|
+
when "error" then "error"
|
|
118
|
+
when "warning" then "warning"
|
|
119
|
+
else "info"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def rubocop_env
|
|
124
|
+
{ "RUBOCOP_CACHE_ROOT" => File.join(Dir.tmpdir, "rubocop") }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Same lint_timeout ceiling for every subprocess this module runs.
|
|
128
|
+
# quick_fix, format_file and haml-lint used to spawn with Open3.capture3
|
|
129
|
+
# and no timeout at all, so a wedged rubocop held a request thread until
|
|
130
|
+
# the client gave up.
|
|
131
|
+
def run(cmd, env: {}, stdin_data: nil)
|
|
132
|
+
timeout_seconds = Mbeditor.configuration.lint_timeout&.to_i
|
|
133
|
+
timeout = timeout_seconds && timeout_seconds > 0 ? timeout_seconds : nil
|
|
134
|
+
ProcessRunner.call(cmd, timeout: timeout, env: env, stdin_data: stdin_data)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mbeditor
|
|
4
|
+
# Sidecar-lock + atomic-write primitive for a single JSON file on disk.
|
|
5
|
+
# Extracted from EditorStateService so FileHistoryService could stop
|
|
6
|
+
# hand-rolling its own flock loop and, worse, writing in place with
|
|
7
|
+
# truncate/rewind — a crash mid-write there truncated history instead of
|
|
8
|
+
# leaving the previous file untouched.
|
|
9
|
+
class LockedJsonFile
|
|
10
|
+
LockTimeoutError = Class.new(StandardError)
|
|
11
|
+
LOCK_RETRY_INTERVAL = 0.01
|
|
12
|
+
|
|
13
|
+
def initialize(path, lock_timeout:, error_class: LockTimeoutError)
|
|
14
|
+
@path = path
|
|
15
|
+
@lock_timeout = lock_timeout
|
|
16
|
+
@error_class = error_class
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Readers take no lock at all: every write lands by rename, so a read sees
|
|
20
|
+
# either the whole previous file or the whole new one — never the empty
|
|
21
|
+
# window a truncate-then-write leaves, which readers would otherwise have
|
|
22
|
+
# to swallow as {}.
|
|
23
|
+
def read
|
|
24
|
+
return {} unless File.exist?(@path)
|
|
25
|
+
|
|
26
|
+
raw = File.read(@path)
|
|
27
|
+
raw.empty? ? {} : JSON.parse(raw)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The file is replaced by rename, not edited in place, so a crash mid-write
|
|
31
|
+
# leaves the previous contents intact rather than a truncated file.
|
|
32
|
+
def write(payload)
|
|
33
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
34
|
+
tmp = "#{@path}.tmp"
|
|
35
|
+
File.write(tmp, payload.is_a?(String) ? payload : payload.to_json)
|
|
36
|
+
File.rename(tmp, @path)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The lock sits on a sidecar file, not on the data file itself: the data
|
|
40
|
+
# file is replaced by rename, so a lock held on the inode it had before
|
|
41
|
+
# the write would exclude nobody afterwards.
|
|
42
|
+
#
|
|
43
|
+
# Acquires the lock without blocking forever: a blocking acquire would let
|
|
44
|
+
# a single stuck holder (e.g. a request paused at a breakpoint mid-write)
|
|
45
|
+
# wedge every later writer indefinitely, so this retries a non-blocking
|
|
46
|
+
# flock with a bounded deadline and raises rather than hanging the worker.
|
|
47
|
+
def with_lock
|
|
48
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
49
|
+
File.open("#{@path}.lock", File::RDWR | File::CREAT) do |f|
|
|
50
|
+
lock_exclusive!(f)
|
|
51
|
+
yield
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def lock_exclusive!(file)
|
|
58
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @lock_timeout
|
|
59
|
+
until file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
60
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
61
|
+
raise @error_class, "could not acquire lock within #{@lock_timeout}s"
|
|
62
|
+
end
|
|
63
|
+
sleep LOCK_RETRY_INTERVAL
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -11,10 +11,34 @@ module Mbeditor
|
|
|
11
11
|
CHUNK_BYTES = 64 * 1024
|
|
12
12
|
private_constant :CHUNK_BYTES
|
|
13
13
|
|
|
14
|
+
# Known subprocess executables, by basename, mapped to a fixed audit Symbol.
|
|
15
|
+
# Anything unlisted — including a host-configured command like
|
|
16
|
+
# `rubocop_command` — is :other. Interning the executable string instead
|
|
17
|
+
# would launder host data through the audit log's Symbol-only guard.
|
|
18
|
+
TOOL_SYMBOLS = {
|
|
19
|
+
"git" => :git,
|
|
20
|
+
"rg" => :rg,
|
|
21
|
+
"grep" => :grep,
|
|
22
|
+
"rubocop" => :rubocop,
|
|
23
|
+
"haml-lint" => :haml_lint,
|
|
24
|
+
"haml_lint" => :haml_lint,
|
|
25
|
+
"rspec" => :rspec,
|
|
26
|
+
"rails" => :rails,
|
|
27
|
+
"bundle" => :bundle,
|
|
28
|
+
"ruby" => :ruby
|
|
29
|
+
}.freeze
|
|
30
|
+
private_constant :TOOL_SYMBOLS
|
|
31
|
+
|
|
32
|
+
def tool_symbol(cmd)
|
|
33
|
+
TOOL_SYMBOLS[File.basename(Array(cmd).first.to_s)] || :other
|
|
34
|
+
end
|
|
35
|
+
private_class_method :tool_symbol
|
|
36
|
+
|
|
14
37
|
# +max_bytes+ bounds how much of each stream is kept in memory (nil =
|
|
15
38
|
# unbounded). Anything past the cap is still read and discarded — stopping
|
|
16
39
|
# would block the child on a full pipe and hang the wait below.
|
|
17
40
|
def call(cmd, timeout: nil, env: {}, stdin_data: nil, chdir: nil, max_bytes: nil)
|
|
41
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
18
42
|
out = +""
|
|
19
43
|
err = +""
|
|
20
44
|
exit_status = nil
|
|
@@ -61,6 +85,14 @@ module Mbeditor
|
|
|
61
85
|
raise TimeoutError, "process timed out after #{timeout}s" if timed_out
|
|
62
86
|
|
|
63
87
|
{ stdout: out, stderr: err, exit_status: exit_status }
|
|
88
|
+
ensure
|
|
89
|
+
# One record per spawn: `tool` is the mapped Symbol for a known
|
|
90
|
+
# executable (never the raw command), `ms` wall time, `status` a
|
|
91
|
+
# code-authored outcome — never the process's own output.
|
|
92
|
+
AuditLog.record(:subprocess,
|
|
93
|
+
tool: tool_symbol(cmd),
|
|
94
|
+
ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round,
|
|
95
|
+
status: timed_out ? :timeout : (exit_status&.success? ? :ok : :error))
|
|
64
96
|
end
|
|
65
97
|
|
|
66
98
|
def read_capped(io, max_bytes)
|