mbeditor 0.12.7 → 0.12.9
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 +103 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +48 -23
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +139 -50
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +56 -7
- data/app/assets/javascripts/mbeditor/editor_plugins.js +74 -92
- data/app/assets/javascripts/mbeditor/tab_manager.js +34 -0
- data/app/assets/stylesheets/mbeditor/editor.css +34 -2
- data/app/controllers/mbeditor/editors_controller.rb +14 -1
- data/app/services/mbeditor/js_syntax_check_service.rb +212 -0
- data/app/services/mbeditor/search_replace_service.rb +49 -8
- data/lib/mbeditor/configuration.rb +2 -1
- data/lib/mbeditor/version.rb +1 -1
- metadata +2 -2
|
@@ -14,6 +14,153 @@ module Mbeditor
|
|
|
14
14
|
# V8 heap grows across transforms; recreate the context periodically.
|
|
15
15
|
MAX_CHECKS_PER_CONTEXT = 50
|
|
16
16
|
BABEL_ASSET_CANDIDATES = %w[babel.min.js babel.js babel-standalone.js babel-standalone.min.js].freeze
|
|
17
|
+
MAX_SCOPE_FINDINGS = 50
|
|
18
|
+
|
|
19
|
+
# Names defined by the browser rather than by any workspace file. ES
|
|
20
|
+
# builtins (Array, Promise, ...) are NOT listed: babel's own
|
|
21
|
+
# Scope#hasBinding already knows them, so this only needs the DOM layer.
|
|
22
|
+
BROWSER_GLOBALS = %w[
|
|
23
|
+
window document navigator location history screen console
|
|
24
|
+
alert confirm prompt getComputedStyle matchMedia scrollTo scrollBy
|
|
25
|
+
innerWidth innerHeight devicePixelRatio
|
|
26
|
+
fetch Headers Request Response XMLHttpRequest WebSocket EventSource
|
|
27
|
+
FormData URL URLSearchParams Blob File FileReader FileList DataTransfer
|
|
28
|
+
AbortController AbortSignal DOMParser XMLSerializer
|
|
29
|
+
setTimeout setInterval clearTimeout clearInterval
|
|
30
|
+
requestAnimationFrame cancelAnimationFrame requestIdleCallback cancelIdleCallback
|
|
31
|
+
queueMicrotask structuredClone atob btoa
|
|
32
|
+
localStorage sessionStorage indexedDB crypto performance
|
|
33
|
+
Event CustomEvent KeyboardEvent MouseEvent TouchEvent ErrorEvent
|
|
34
|
+
MessageEvent PopStateEvent StorageEvent ProgressEvent ClipboardEvent
|
|
35
|
+
MutationObserver ResizeObserver IntersectionObserver
|
|
36
|
+
Node NodeList Element HTMLElement SVGElement Image Audio Option
|
|
37
|
+
CSS customElements
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
# The React UMD globals plus the bare hook aliases host apps conventionally
|
|
41
|
+
# pull out of React at the top of a Sprockets bundle.
|
|
42
|
+
REACT_GLOBALS = %w[
|
|
43
|
+
React ReactDOM PropTypes
|
|
44
|
+
useState useEffect useLayoutEffect useRef useMemo useCallback useContext
|
|
45
|
+
useReducer useId useTransition useDeferredValue useSyncExternalStore
|
|
46
|
+
useImperativeHandle useDebugValue
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
# Installed into the V8 context alongside babel-standalone. collect()
|
|
50
|
+
# returns a file's top-level declaration names (Sprockets concatenates
|
|
51
|
+
# every file into one scope, so these are the cross-file globals). lint()
|
|
52
|
+
# reports references babel can bind to no scope and no whitelist entry,
|
|
53
|
+
# plus bindings that are only ever assigned inside a
|
|
54
|
+
# useEffect/useLayoutEffect callback but read during render.
|
|
55
|
+
LINT_HELPERS_JS = <<~'JS'
|
|
56
|
+
(function () {
|
|
57
|
+
if (globalThis.__mbLint) return;
|
|
58
|
+
if (typeof Babel === "undefined" || !Babel.packages || !Babel.packages.parser || !Babel.packages.traverse) return;
|
|
59
|
+
var parser = Babel.packages.parser;
|
|
60
|
+
var traverse = Babel.packages.traverse["default"] || Babel.packages.traverse;
|
|
61
|
+
|
|
62
|
+
function parse(source) {
|
|
63
|
+
return parser.parse(source, { sourceType: "script", plugins: ["jsx"], errorRecovery: true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function bindNames(node, out) {
|
|
67
|
+
if (!node) return;
|
|
68
|
+
switch (node.type) {
|
|
69
|
+
case "Identifier": out.push(node.name); break;
|
|
70
|
+
case "ObjectPattern": node.properties.forEach(function (p) { bindNames(p.value || p.argument, out); }); break;
|
|
71
|
+
case "ArrayPattern": node.elements.forEach(function (el) { bindNames(el, out); }); break;
|
|
72
|
+
case "AssignmentPattern": bindNames(node.left, out); break;
|
|
73
|
+
case "RestElement": bindNames(node.argument, out); break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Is this path inside the callback argument of useEffect/useLayoutEffect?
|
|
78
|
+
function insideEffectCallback(path) {
|
|
79
|
+
var fn = path.getFunctionParent();
|
|
80
|
+
while (fn) {
|
|
81
|
+
var parent = fn.parentPath;
|
|
82
|
+
if (parent && parent.isCallExpression() && parent.node.arguments[0] === fn.node) {
|
|
83
|
+
var callee = parent.node.callee;
|
|
84
|
+
var name = callee.type === "Identifier" ? callee.name
|
|
85
|
+
: (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier"
|
|
86
|
+
? callee.property.name : null);
|
|
87
|
+
if (name === "useEffect" || name === "useLayoutEffect") return true;
|
|
88
|
+
}
|
|
89
|
+
fn = fn.getFunctionParent();
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
globalThis.__mbLint = {
|
|
95
|
+
collect: function (source) {
|
|
96
|
+
var names = [];
|
|
97
|
+
var ast;
|
|
98
|
+
try { ast = parse(source); } catch (e) { return names; }
|
|
99
|
+
ast.program.body.forEach(function (node) {
|
|
100
|
+
if (node.type === "VariableDeclaration") {
|
|
101
|
+
node.declarations.forEach(function (d) { bindNames(d.id, names); });
|
|
102
|
+
} else if (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") {
|
|
103
|
+
if (node.id) names.push(node.id.name);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
return names;
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
lint: function (source, whitelist, max) {
|
|
110
|
+
var findings = [];
|
|
111
|
+
var wl = {};
|
|
112
|
+
whitelist.forEach(function (n) { wl[n] = true; });
|
|
113
|
+
var ast;
|
|
114
|
+
try { ast = parse(source); } catch (e) { return findings; }
|
|
115
|
+
var seen = {};
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
traverse(ast, {
|
|
119
|
+
ReferencedIdentifier: function (p) {
|
|
120
|
+
if (findings.length >= max) { p.stop(); return; }
|
|
121
|
+
var name = p.node.name;
|
|
122
|
+
if (p.isJSXIdentifier() && !/^[A-Z]/.test(name)) return; // <div>, <span>
|
|
123
|
+
if (wl[name]) return;
|
|
124
|
+
if (p.scope.hasBinding(name)) return; // includes ES builtins
|
|
125
|
+
if (p.parentPath && p.parentPath.isUnaryExpression({ operator: "typeof" })) return;
|
|
126
|
+
var loc = p.node.loc && p.node.loc.start;
|
|
127
|
+
var key = name + ":" + (loc ? loc.line : 0);
|
|
128
|
+
if (seen[key]) return;
|
|
129
|
+
seen[key] = true;
|
|
130
|
+
findings.push({
|
|
131
|
+
kind: "undeclared", name: name,
|
|
132
|
+
line: loc ? loc.line : 1, column: loc ? loc.column : 0,
|
|
133
|
+
message: "'" + name + "' is not defined in any reachable scope"
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
Function: function (p) {
|
|
138
|
+
if (findings.length >= max) { p.stop(); return; }
|
|
139
|
+
var bindings = p.scope.bindings;
|
|
140
|
+
Object.keys(bindings).forEach(function (name) {
|
|
141
|
+
var b = bindings[name];
|
|
142
|
+
if (b.kind !== "let" && b.kind !== "var") return;
|
|
143
|
+
if (!b.path.isVariableDeclarator() || b.path.node.init) return;
|
|
144
|
+
var writes = b.constantViolations || [];
|
|
145
|
+
if (!writes.length) return;
|
|
146
|
+
if (!writes.every(insideEffectCallback)) return;
|
|
147
|
+
var renderReads = (b.referencePaths || []).filter(function (r) { return !insideEffectCallback(r); });
|
|
148
|
+
if (!renderReads.length) return;
|
|
149
|
+
var loc = renderReads[0].node.loc && renderReads[0].node.loc.start;
|
|
150
|
+
findings.push({
|
|
151
|
+
kind: "effect", name: name,
|
|
152
|
+
line: loc ? loc.line : 1, column: loc ? loc.column : 0,
|
|
153
|
+
message: "'" + name + "' is only assigned inside an effect but read during render — undefined on first render"
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
} catch (e) { /* traversal blew up on odd input — report what we have */ }
|
|
159
|
+
return findings;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
})();
|
|
163
|
+
JS
|
|
17
164
|
|
|
18
165
|
MUTEX = Mutex.new
|
|
19
166
|
private_constant :MUTEX
|
|
@@ -62,11 +209,43 @@ module Mbeditor
|
|
|
62
209
|
end
|
|
63
210
|
end
|
|
64
211
|
|
|
212
|
+
# Babel-based scope lint: warnings for identifier references that bind to
|
|
213
|
+
# no scope, no top-level declaration anywhere in the workspace's own JS
|
|
214
|
+
# (Sprockets: one shared scope), no known window.X global, and no
|
|
215
|
+
# browser/React name — the typos Monaco's TS worker misses once ambient
|
|
216
|
+
# globals are declared. Plus the effect-write/render-read hazard.
|
|
217
|
+
# Report-only; returns [] whenever anything is unavailable or fails.
|
|
218
|
+
def scope_lint(workspace_root, source)
|
|
219
|
+
return [] unless available? && Mbeditor.configuration.js_scope_lint != false
|
|
220
|
+
|
|
221
|
+
MUTEX.synchronize do
|
|
222
|
+
begin
|
|
223
|
+
ctx = context
|
|
224
|
+
return [] unless ctx
|
|
225
|
+
|
|
226
|
+
ctx.eval(LINT_HELPERS_JS) unless @lint_helpers_loaded
|
|
227
|
+
@lint_helpers_loaded = true
|
|
228
|
+
return [] unless ctx.eval("typeof __mbLint !== 'undefined'")
|
|
229
|
+
|
|
230
|
+
names = whitelist(ctx, workspace_root)
|
|
231
|
+
findings = ctx.eval("__mbLint.lint(#{source.to_json}, #{names.to_json}, #{MAX_SCOPE_FINDINGS})")
|
|
232
|
+
|
|
233
|
+
@checks_run = (@checks_run || 0) + 1
|
|
234
|
+
reset_context! if @checks_run >= MAX_CHECKS_PER_CONTEXT
|
|
235
|
+
Array(findings).select { |f| f.is_a?(Hash) }
|
|
236
|
+
rescue StandardError
|
|
237
|
+
reset_context!
|
|
238
|
+
[]
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
65
243
|
# Exposed for tests.
|
|
66
244
|
def reset!
|
|
67
245
|
MUTEX.synchronize do
|
|
68
246
|
reset_context!
|
|
69
247
|
@babel_path = :unresolved
|
|
248
|
+
@decl_cache = nil
|
|
70
249
|
end
|
|
71
250
|
end
|
|
72
251
|
|
|
@@ -91,6 +270,39 @@ module Mbeditor
|
|
|
91
270
|
def reset_context!
|
|
92
271
|
@context = nil
|
|
93
272
|
@checks_run = 0
|
|
273
|
+
@lint_helpers_loaded = false
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Cross-file whitelist: every top-level declaration in the workspace's
|
|
277
|
+
# own JS program, every window.X-style global JsGlobalsService knows,
|
|
278
|
+
# plus the browser and React layers. Per-file declaration names are
|
|
279
|
+
# cached by content digest, so a steady-state save re-parses only the
|
|
280
|
+
# files that changed since the last lint.
|
|
281
|
+
def whitelist(ctx, workspace_root)
|
|
282
|
+
names = []
|
|
283
|
+
@decl_cache ||= {}
|
|
284
|
+
live = {}
|
|
285
|
+
|
|
286
|
+
program = JsProgramService.call(workspace_root.to_s)
|
|
287
|
+
Array(program[:files]).each do |f|
|
|
288
|
+
digest = f[:content].hash
|
|
289
|
+
entry = @decl_cache[f[:path]]
|
|
290
|
+
entry = { digest: digest, names: collect_names(ctx, f[:content]) } unless entry && entry[:digest] == digest
|
|
291
|
+
live[f[:path]] = entry
|
|
292
|
+
names.concat(entry[:names])
|
|
293
|
+
end
|
|
294
|
+
@decl_cache = live
|
|
295
|
+
|
|
296
|
+
globals = JsGlobalsService.call(workspace_root.to_s)
|
|
297
|
+
names.concat(Array(globals[:symbols]).map { |s| s[:name].to_s })
|
|
298
|
+
|
|
299
|
+
(names + BROWSER_GLOBALS + REACT_GLOBALS).uniq
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def collect_names(ctx, source)
|
|
303
|
+
Array(ctx.eval("__mbLint.collect(#{source.to_json})")).grep(String)
|
|
304
|
+
rescue StandardError
|
|
305
|
+
[]
|
|
94
306
|
end
|
|
95
307
|
|
|
96
308
|
def babel_source_path
|
|
@@ -229,11 +229,20 @@ module Mbeditor
|
|
|
229
229
|
end
|
|
230
230
|
end
|
|
231
231
|
|
|
232
|
+
# Used to locate the match within each hit line so a result can carry
|
|
233
|
+
# its columns. Invalid user regexes are already reported elsewhere;
|
|
234
|
+
# here a nil pattern just means the rows come back without columns.
|
|
235
|
+
pattern = begin
|
|
236
|
+
build_pattern(query, use_regex: use_regex, match_case: match_case, whole_word: whole_word)
|
|
237
|
+
rescue RegexpError
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
232
241
|
begin
|
|
233
242
|
io.each_line do |raw|
|
|
234
243
|
break if results.length >= max
|
|
235
244
|
|
|
236
|
-
row = parse_line(tier, raw, root)
|
|
245
|
+
row = parse_line(tier, raw, root, pattern)
|
|
237
246
|
next unless row
|
|
238
247
|
next if matcher.excluded?(row[:file])
|
|
239
248
|
|
|
@@ -297,9 +306,11 @@ module Mbeditor
|
|
|
297
306
|
# Exclusions have to reach git as pathspecs, not just be dropped from
|
|
298
307
|
# the results by the matcher below: otherwise git walks node_modules
|
|
299
308
|
# and every other excluded tree in full before we discard the matches.
|
|
300
|
-
#
|
|
301
|
-
# "everything except these",
|
|
302
|
-
#
|
|
309
|
+
# The "." anchor is required: newer git reads a pathspec list of
|
|
310
|
+
# nothing but :(exclude) entries as "everything except these", but
|
|
311
|
+
# older git refuses it outright ("fatal: There is nothing to exclude
|
|
312
|
+
# from"), exits 128, and search silently returns empty.
|
|
313
|
+
args << "." if paths.nil? && exclusions.any?
|
|
303
314
|
args += exclusions.map { |p| ":(exclude)#{p}" }
|
|
304
315
|
# No LC_ALL=C: measured neutral for the -F -i default and 2.2x slower
|
|
305
316
|
# for -E, and the UTF-8 locale case-folds non-ASCII correctly.
|
|
@@ -334,7 +345,7 @@ module Mbeditor
|
|
|
334
345
|
end
|
|
335
346
|
end
|
|
336
347
|
|
|
337
|
-
def parse_line(tier, raw, root)
|
|
348
|
+
def parse_line(tier, raw, root, pattern = nil)
|
|
338
349
|
if tier == :rg
|
|
339
350
|
begin
|
|
340
351
|
data = JSON.parse(raw)
|
|
@@ -344,11 +355,23 @@ module Mbeditor
|
|
|
344
355
|
return nil unless data["type"] == "match"
|
|
345
356
|
|
|
346
357
|
md = data["data"]
|
|
358
|
+
raw_text = md.dig("lines", "text").to_s
|
|
359
|
+
# rg reports submatch offsets in BYTES; Monaco columns are character
|
|
360
|
+
# based, so slice the prefix and measure it as characters.
|
|
361
|
+
sub = Array(md["submatches"]).first
|
|
362
|
+
cols = if sub && sub["start"] && sub["end"]
|
|
363
|
+
bytes = raw_text.dup.force_encoding(Encoding::BINARY)
|
|
364
|
+
start_chars = bytes[0, sub["start"]].to_s.force_encoding(Encoding::UTF_8).scrub.length
|
|
365
|
+
match_chars = bytes[sub["start"], sub["end"] - sub["start"]].to_s.force_encoding(Encoding::UTF_8).scrub.length
|
|
366
|
+
{ col: start_chars + 1, end_col: start_chars + match_chars + 1 }
|
|
367
|
+
else
|
|
368
|
+
match_columns(raw_text, pattern)
|
|
369
|
+
end
|
|
347
370
|
return {
|
|
348
371
|
file: relative_path(md.dig("path", "text").to_s, root),
|
|
349
372
|
line: md.dig("line_number"),
|
|
350
|
-
text:
|
|
351
|
-
}
|
|
373
|
+
text: raw_text.strip
|
|
374
|
+
}.merge(cols)
|
|
352
375
|
end
|
|
353
376
|
|
|
354
377
|
# git grep / grep emit "path:line:text" — possibly with bytes that are
|
|
@@ -365,7 +388,25 @@ module Mbeditor
|
|
|
365
388
|
file_path = relative_path(file_path, root)
|
|
366
389
|
end
|
|
367
390
|
|
|
368
|
-
|
|
391
|
+
raw_text = Regexp.last_match(3)
|
|
392
|
+
{ file: file_path, line: Regexp.last_match(2).to_i, text: raw_text.strip }
|
|
393
|
+
.merge(match_columns(raw_text, pattern))
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# 1-based Monaco columns for the first match on a hit line. Returns an
|
|
397
|
+
# empty hash when there is no usable pattern or it doesn't match — the
|
|
398
|
+
# row is still a valid result, it just opens at the start of the line.
|
|
399
|
+
# Measured against the RAW line, never the stripped `text`: the client
|
|
400
|
+
# cannot recover the leading whitespace the strip removed.
|
|
401
|
+
def match_columns(raw_text, pattern)
|
|
402
|
+
return {} unless pattern
|
|
403
|
+
|
|
404
|
+
m = pattern.match(raw_text)
|
|
405
|
+
return {} unless m
|
|
406
|
+
|
|
407
|
+
{ col: m.begin(0) + 1, end_col: m.end(0) + 1 }
|
|
408
|
+
rescue StandardError
|
|
409
|
+
{}
|
|
369
410
|
end
|
|
370
411
|
|
|
371
412
|
def register_search(root, pid)
|
|
@@ -10,7 +10,7 @@ module Mbeditor
|
|
|
10
10
|
:ruby_def_include_dirs, :related_files_custom_paths,
|
|
11
11
|
:mount_path, :resilient_routing, :js_global_identifiers,
|
|
12
12
|
:js_program, :js_program_exclude,
|
|
13
|
-
:js_syntax_check, :babel_standalone_path,
|
|
13
|
+
:js_syntax_check, :babel_standalone_path, :js_scope_lint,
|
|
14
14
|
:ruby_lsp, :ruby_lsp_command, :ruby_lsp_timeout,
|
|
15
15
|
:exception_capture, :model_graph_max_models,
|
|
16
16
|
:search_respect_gitignore, :ripgrep_command
|
|
@@ -84,6 +84,7 @@ module Mbeditor
|
|
|
84
84
|
# third-party or generated JS here, e.g. "app/assets/javascripts/react".
|
|
85
85
|
@js_program_exclude = %w[vendor]
|
|
86
86
|
@js_syntax_check = :auto # save-time babel parse check via host mini_racer + babel-standalone; false disables
|
|
87
|
+
@js_scope_lint = true # save-time undeclared-identifier warnings (needs js_syntax_check active); false disables
|
|
87
88
|
@babel_standalone_path = nil # explicit path to babel-standalone JS; nil auto-detects via the asset pipeline
|
|
88
89
|
@ruby_lsp = :auto # use the host's ruby-lsp for Ruby definitions/hover/completion when available; false disables
|
|
89
90
|
@ruby_lsp_command = nil # override the ruby-lsp launch command (String or Array); nil auto-resolves bin/ruby-lsp > gem > bundle exec
|
data/lib/mbeditor/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mbeditor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.12.
|
|
4
|
+
version: 0.12.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Oliver Noonan
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|