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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +207 -0
  3. data/app/assets/javascripts/mbeditor/application.js +7 -1
  4. data/app/assets/javascripts/mbeditor/collaboration_service.js +31 -0
  5. data/app/assets/javascripts/mbeditor/color_provider.js +5 -0
  6. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +3 -1
  7. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +135 -40
  8. data/app/assets/javascripts/mbeditor/components/FileTree.js +6 -1
  9. data/app/assets/javascripts/mbeditor/components/GitPanel.js +10 -1
  10. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +15 -8
  11. data/app/assets/javascripts/mbeditor/components/ImportDialog.js +216 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +629 -137
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +218 -13
  15. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +39 -33
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
  17. data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +312 -0
  18. data/app/assets/javascripts/mbeditor/editor_plugins.js +444 -116
  19. data/app/assets/javascripts/mbeditor/file_import.js +78 -0
  20. data/app/assets/javascripts/mbeditor/file_service.js +122 -18
  21. data/app/assets/javascripts/mbeditor/git_service.js +130 -8
  22. data/app/assets/javascripts/mbeditor/history_service.js +33 -38
  23. data/app/assets/javascripts/mbeditor/log_service.js +4 -0
  24. data/app/assets/javascripts/mbeditor/search_service.js +30 -2
  25. data/app/assets/javascripts/mbeditor/tab_manager.js +59 -6
  26. data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
  27. data/app/assets/stylesheets/mbeditor/editor.css +302 -55
  28. data/app/channels/mbeditor/channel_authentication.rb +7 -0
  29. data/app/channels/mbeditor/editor_channel.rb +6 -3
  30. data/app/controllers/mbeditor/application_controller.rb +5 -0
  31. data/app/controllers/mbeditor/editors_controller.rb +173 -29
  32. data/app/controllers/mbeditor/git_controller.rb +3 -3
  33. data/app/controllers/mbeditor/logs_controller.rb +3 -1
  34. data/app/services/mbeditor/collaboration_doc_store.rb +7 -0
  35. data/app/services/mbeditor/editor_state_service.rb +36 -26
  36. data/app/services/mbeditor/exclusion_matcher.rb +12 -10
  37. data/app/services/mbeditor/file_operation_service.rb +38 -3
  38. data/app/services/mbeditor/file_tree_service.rb +30 -2
  39. data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
  40. data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
  41. data/app/services/mbeditor/git_diff_service.rb +5 -1
  42. data/app/services/mbeditor/git_info_service.rb +20 -8
  43. data/app/services/mbeditor/git_line_diff_service.rb +6 -2
  44. data/app/services/mbeditor/git_service.rb +65 -15
  45. data/app/services/mbeditor/js_definition_service.rb +3 -1
  46. data/app/services/mbeditor/js_globals_service.rb +7 -4
  47. data/app/services/mbeditor/js_members_service.rb +3 -2
  48. data/app/services/mbeditor/js_program_service.rb +15 -5
  49. data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
  50. data/app/services/mbeditor/process_runner.rb +42 -12
  51. data/app/services/mbeditor/ri_definition_service.rb +8 -1
  52. data/app/services/mbeditor/route_service.rb +45 -8
  53. data/app/services/mbeditor/rubocop_run_service.rb +86 -0
  54. data/app/services/mbeditor/ruby_definition_service.rb +23 -4
  55. data/app/services/mbeditor/schema_service.rb +65 -64
  56. data/app/services/mbeditor/search_replace_service.rb +75 -10
  57. data/app/services/mbeditor/test_runner_service.rb +98 -10
  58. data/lib/mbeditor/cable_log_filter.rb +8 -2
  59. data/lib/mbeditor/configuration.rb +12 -1
  60. data/lib/mbeditor/editor_bootstrap.rb +18 -12
  61. data/lib/mbeditor/engine.rb +22 -3
  62. data/lib/mbeditor/exception_log.rb +2 -2
  63. data/lib/mbeditor/pending_migrations.rb +33 -0
  64. data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
  65. data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
  66. data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
  67. data/lib/mbeditor/route_map.rb +2 -0
  68. data/lib/mbeditor/ruby_lsp_client.rb +71 -12
  69. data/lib/mbeditor/version.rb +1 -1
  70. metadata +7 -2
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module Mbeditor
4
6
  class FileTreeService
5
7
  MUTEX = Mutex.new
@@ -26,6 +28,26 @@ module Mbeditor
26
28
  data
27
29
  end
28
30
 
31
+ # The tree already serialized, plus a digest of that body, memoized inside
32
+ # the same cache entry. /files is polled every 10s and the payload is ~1 MB,
33
+ # so the digest is what turns an unchanged poll into a 304.
34
+ def self.cached_json(workspace_root)
35
+ root = workspace_root.to_s
36
+ data = build(root)
37
+
38
+ MUTEX.synchronize do
39
+ entry = (@cache ||= {})[root]
40
+ return entry[:json] if entry && entry[:json] && entry[:data].equal?(data)
41
+
42
+ # to_json, not JSON.generate: `render json:` used ActiveSupport's
43
+ # encoder, and the body must stay byte-identical.
44
+ body = data.to_json
45
+ payload = { body: body, digest: Digest::SHA1.hexdigest(body) }
46
+ entry[:json] = payload if entry
47
+ payload
48
+ end
49
+ end
50
+
29
51
  def self.invalidate(workspace_root)
30
52
  MUTEX.synchronize do
31
53
  @cache ||= {}
@@ -49,14 +71,20 @@ module Mbeditor
49
71
  rel = "" if rel == workspace_root
50
72
  is_excl = matcher.excluded?(rel)
51
73
 
52
- if File.directory?(full) && !File.symlink?(full)
74
+ # One lstat instead of directory?/symlink?/size three stats per entry
75
+ # over a whole tree. lstat does not follow, so a symlink is never a
76
+ # folder here, exactly as the directory?-and-not-symlink? test intended;
77
+ # a symlinked file still reports its *target's* size, as it always did.
78
+ st = (File.lstat(full) rescue nil)
79
+
80
+ if st&.directory?
53
81
  children = is_excl ? [] : traverse(full, workspace_root, matcher, max_depth: max_depth, depth: depth + 1)
54
82
  node = { name: name, type: "folder", path: rel, children: children }
55
83
  node[:excluded] = true if is_excl
56
84
  node
57
85
  else
58
86
  node = { name: name, type: "file", path: rel }
59
- node[:size] = (File.size(full) rescue nil) unless is_excl
87
+ node[:size] = ((st&.symlink? ? File.size(full) : st&.size) rescue nil) unless is_excl
60
88
  node[:excluded] = true if is_excl
61
89
  node
62
90
  end
@@ -33,17 +33,27 @@ module Mbeditor
33
33
  private
34
34
 
35
35
  def local_diff
36
- out, status = GitService.run_git(repo_path, "diff", "HEAD")
36
+ out, status = git_diff("HEAD")
37
37
  status.success? ? cap_diff(out) : ""
38
38
  end
39
39
 
40
+ # --no-color/--no-ext-diff on every invocation: a host gitconfig with
41
+ # color.ui = always, or a diff.external driver, otherwise feeds escape
42
+ # codes or foreign output into the viewer's parser. max_bytes stops a
43
+ # pathological diff being fully materialised just for cap_diff to throw
44
+ # most of it away.
45
+ def git_diff(*revs)
46
+ GitService.run_git(repo_path, "diff", "--no-color", "--no-ext-diff", *revs,
47
+ max_bytes: MAX_DIFF_BYTES)
48
+ end
49
+
40
50
  def branch_diff
41
51
  branch = GitService.current_branch(repo_path)
42
52
  base_sha, ref = GitService.find_branch_base(repo_path, branch)
43
53
 
44
54
  if base_sha.present?
45
55
  @base_ref = ref
46
- out, status = GitService.run_git(repo_path, "diff", "#{base_sha}..HEAD")
56
+ out, status = git_diff("#{base_sha}..HEAD")
47
57
  return status.success? ? cap_diff(out) : ""
48
58
  end
49
59
 
@@ -56,7 +66,7 @@ module Mbeditor
56
66
  upstream = GitService.upstream_branch(repo_path)
57
67
  if GitService.base_branch?(branch) && upstream.present?
58
68
  @base_ref = upstream
59
- out, status = GitService.run_git(repo_path, "diff", "#{upstream}..HEAD")
69
+ out, status = git_diff("#{upstream}..HEAD")
60
70
  return status.success? ? cap_diff(out) : ""
61
71
  end
62
72
 
@@ -11,21 +11,33 @@ module Mbeditor
11
11
  @sha = sha.to_s
12
12
  end
13
13
 
14
+ EMPTY = { "title" => "", "author" => "", "date" => "", "files" => [] }.freeze
15
+ private_constant :EMPTY
16
+
17
+ # Two spawns per commit click, not three: `git show --name-status` prints
18
+ # the pretty header then the raw name-status body. The numstat still needs
19
+ # its own call — asking for --name-status and --numstat together makes git
20
+ # emit only the former. --no-renames keeps a rename as the D+A pair the
21
+ # two-field parse below expects, rather than a three-field R100 record.
14
22
  def call
15
- meta = fetch_meta
16
- { "sha" => sha, "title" => meta["title"], "author" => meta["author"], "date" => meta["date"], "files" => fetch_files }
23
+ out, status = GitService.run_git(repo_path, "show", "--no-color", "--no-renames", "--name-status",
24
+ "--pretty=format:%s%x1f%an%x1f%aI", "--end-of-options", sha)
25
+ return EMPTY.merge("sha" => sha) unless status.success?
26
+
27
+ header, body = out.split("\n", 2)
28
+ fields = header.to_s.split("\x1f", 3)
29
+ { "sha" => sha, "title" => fields[0].to_s, "author" => fields[1].to_s, "date" => fields[2].to_s,
30
+ "files" => parse_files(body.to_s) }
17
31
  end
18
32
 
19
33
  private
20
34
 
21
- def fetch_files
22
- name_out, name_st = GitService.run_git(repo_path, "diff-tree", "--no-commit-id", "-r", "--name-status", sha)
23
- return [] unless name_st.success?
24
-
25
- num_out, num_st = GitService.run_git(repo_path, "diff-tree", "--no-commit-id", "-r", "--numstat", sha)
35
+ def parse_files(body)
36
+ num_out, num_st = GitService.run_git(repo_path, "show", "--no-color", "--no-renames", "--numstat",
37
+ "--pretty=format:", "--end-of-options", sha)
26
38
  numstat = num_st.success? ? GitService.parse_numstat(num_out) : {}
27
39
 
28
- name_out.lines.filter_map do |line|
40
+ body.lines.filter_map do |line|
29
41
  parts = line.strip.split("\t", 2)
30
42
  next if parts.length < 2
31
43
 
@@ -34,13 +46,5 @@ module Mbeditor
34
46
  { "status" => parts[0].strip, "path" => path, "added" => stats[:added], "removed" => stats[:removed] }
35
47
  end
36
48
  end
37
-
38
- def fetch_meta
39
- out, status = GitService.run_git(repo_path, "log", "-1", "--pretty=format:%s%x1f%an%x1f%aI", sha)
40
- return { "title" => "", "author" => "", "date" => "" } unless status.success?
41
-
42
- fields = out.strip.split("\x1f", 3)
43
- { "title" => fields[0].to_s, "author" => fields[1].to_s, "date" => fields[2].to_s }
44
- end
45
49
  end
46
50
  end
@@ -53,8 +53,12 @@ module Mbeditor
53
53
  end
54
54
 
55
55
  # Return the file content at a given git ref, or "" if it doesn't exist.
56
+ #
57
+ # --end-of-options (git >= 2.24, safely assumable for dev tooling) stops the
58
+ # rev being read as a command-line option, e.g. `--output=/tmp/x`, on top of
59
+ # the caller-side ref validation.
56
60
  def file_at_ref(ref, path)
57
- out, status = GitService.run_git(repo_path, "show", "#{ref}:#{path}")
61
+ out, status = GitService.run_git(repo_path, "show", "--end-of-options", "#{ref}:#{path}")
58
62
  return "" unless status.success?
59
63
 
60
64
  out
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "timeout"
4
+
3
5
  module Mbeditor
4
6
  module GitInfoService
5
7
  module_function
@@ -16,19 +18,26 @@ module Mbeditor
16
18
  cached = cached_git_info(repo_path)
17
19
  return cached if cached
18
20
 
19
- owner = nil
21
+ flight = nil
20
22
  GIT_INFO_MUTEX.synchronize do
21
23
  @git_info_flights ||= {}
22
- owner = @git_info_flights[repo_path]
23
- @git_info_flights[repo_path] = Thread.current unless owner
24
+ flight = @git_info_flights[repo_path]
25
+ @git_info_flights[repo_path] = Queue.new unless flight
24
26
  end
25
27
 
26
- if owner
28
+ if flight
27
29
  # Another thread is already running the fan-out; don't duplicate it.
28
30
  stale = stale_git_info(repo_path)
29
31
  return stale if stale
30
32
 
31
- owner.join(CACHE_TTL)
33
+ # The owner closes this queue when it is done, which releases every
34
+ # waiter at once. Waiting on the owner *thread* was the old mechanism,
35
+ # but a Puma pool thread never exits, so join always ran to CACHE_TTL.
36
+ begin
37
+ Timeout.timeout(CACHE_TTL) { flight.pop }
38
+ rescue Timeout::Error
39
+ nil
40
+ end
32
41
  return cached_git_info(repo_path) || stale_git_info(repo_path) ||
33
42
  { ok: false, error: "git info computation in progress" }
34
43
  end
@@ -36,7 +45,8 @@ module Mbeditor
36
45
  begin
37
46
  compute(repo_path)
38
47
  ensure
39
- GIT_INFO_MUTEX.synchronize { @git_info_flights.delete(repo_path) }
48
+ done = GIT_INFO_MUTEX.synchronize { @git_info_flights.delete(repo_path) }
49
+ done&.close
40
50
  end
41
51
  end
42
52
 
@@ -47,8 +57,10 @@ module Mbeditor
47
57
  end
48
58
 
49
59
  # Wave 1: all independent git reads run in parallel
50
- status_t = Thread.new { safe_git(repo_path, "status", "--porcelain") }
51
- numstat_t = Thread.new { safe_git(repo_path, "diff", "--numstat", "HEAD") }
60
+ # -z on both: it is the only form that leaves paths with spaces or
61
+ # non-ASCII bytes unquoted, and the two lists are paired by path below.
62
+ status_t = Thread.new { safe_git(repo_path, "status", "--porcelain", "-z") }
63
+ numstat_t = Thread.new { safe_git(repo_path, "diff", "--numstat", "-z", "HEAD") }
52
64
  upstream_t = Thread.new { safe_git(repo_path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") }
53
65
  base_t = Thread.new { GitService.find_branch_base(repo_path, branch) }
54
66
 
@@ -32,11 +32,15 @@ module Mbeditor
32
32
  end
33
33
 
34
34
  def call
35
- return untracked_result if untracked?
36
-
37
35
  output, status = GitService.run_git(
38
36
  repo_path, "diff", "--no-color", "--no-ext-diff", "-U0", "HEAD", "--", file_path
39
37
  )
38
+
39
+ # ls-files only distinguishes "no changes" from "never tracked", so it
40
+ # runs solely when the diff produced nothing. Asking first doubled the
41
+ # subprocess count on a path the editor polls every 10s.
42
+ return untracked_result if (!status.success? || output.empty?) && untracked?
43
+
40
44
  # A non-zero status here means the diff could not be produced at all (no
41
45
  # HEAD yet, path outside the repo). Report a clean file rather than
42
46
  # raising: line colouring is decoration, never a reason to fail a load.
@@ -10,17 +10,19 @@ module Mbeditor
10
10
  module_function
11
11
 
12
12
  # Safe pattern for git ref names (branch, remote/branch, tag).
13
- # Excludes @ to prevent reflog syntax like @{-1} or @{u}.
14
- SAFE_GIT_REF = %r{\A[\w./-]+\z}
13
+ # Excludes @ to prevent reflog syntax like @{-1} or @{u}, and a leading -
14
+ # so a ref can never be read as a command-line option by the git it is
15
+ # interpolated into.
16
+ SAFE_GIT_REF = %r{\A(?!-)[\w./-]+\z}
15
17
 
16
18
  # Run an arbitrary git command inside +repo_path+.
17
19
  # Returns [stdout, Process::Status]. stderr is discarded to prevent git
18
20
  # diagnostic messages from leaking into the Rails server log.
19
21
  # Honors config.git_timeout (seconds) when set.
20
- def run_git(repo_path, *args)
22
+ def run_git(repo_path, *args, max_bytes: nil)
21
23
  timeout_secs = Mbeditor.configuration.git_timeout&.to_i
22
24
  timeout = timeout_secs && timeout_secs > 0 ? timeout_secs : nil
23
- result = ProcessRunner.call(["git", "-C", repo_path, *args], timeout: timeout)
25
+ result = ProcessRunner.call(["git", "-C", repo_path, *args], timeout: timeout, max_bytes: max_bytes)
24
26
  [result[:stdout], result[:exit_status]]
25
27
  rescue ProcessRunner::TimeoutError
26
28
  raise Timeout::Error, "git timed out after #{timeout_secs}s"
@@ -70,12 +72,18 @@ module Mbeditor
70
72
  def find_branch_base(repo_path, current_branch, candidates: nil)
71
73
  candidates ||= Mbeditor.configuration.base_branch_candidates
72
74
 
75
+ # One listing instead of a rev-parse per candidate: with six defaults
76
+ # that was up to twelve sequential spawns on a path the git panel polls.
77
+ refs_out, refs_st = run_git(repo_path, "for-each-ref", "--format=%(refname:short)",
78
+ "refs/heads", "refs/remotes")
79
+ return [nil, nil] unless refs_st.success?
80
+
81
+ existing = refs_out.split("\n").map(&:strip)
82
+
73
83
  candidates.each do |ref|
74
84
  short = ref.delete_prefix("origin/")
75
85
  next if short == current_branch || ref == current_branch
76
-
77
- _o, st = run_git(repo_path, "rev-parse", "--verify", "--quiet", ref)
78
- next unless st.success?
86
+ next unless existing.include?(ref)
79
87
 
80
88
  base_out, base_st = run_git(repo_path, "merge-base", "HEAD", ref)
81
89
  next unless base_st.success?
@@ -101,17 +109,34 @@ module Mbeditor
101
109
  candidates.any? { |ref| ref == current_branch || ref.delete_prefix("origin/") == current_branch }
102
110
  end
103
111
 
104
- # Parse `git status --porcelain` output.
112
+ # Parse `git status --porcelain` output, in either the newline form or the
113
+ # NUL-delimited `-z` form.
114
+ #
115
+ # Only `-z` gives usable paths: without it git quotes anything containing a
116
+ # space or a non-ASCII byte (`"caf\303\251.rb"`) and writes a rename as
117
+ # `old -> new`, so the path came back unopenable and never matched numstat.
118
+ # In `-z` a rename/copy spans two records and the NEW name comes first.
119
+ #
105
120
  # Returns Array of { status: String, path: String }.
106
121
  def parse_porcelain_status(output)
107
- output.lines.filter_map do |line|
108
- next if line.length < 4
109
-
110
- path = line[3..].to_s.strip
122
+ nul = output.include?("\0")
123
+ records = nul ? output.split("\0") : output.lines.map(&:chomp)
124
+
125
+ result = []
126
+ i = 0
127
+ while i < records.length
128
+ record = records[i]
129
+ i += 1
130
+ next if record.length < 4
131
+
132
+ status = record[0..1].strip
133
+ i += 1 if nul && status.start_with?("R", "C") # skip the old name
134
+ path = nul ? record[3..].to_s : record[3..].to_s.strip
111
135
  next if path.blank?
112
136
 
113
- { status: line[0..1].strip, path: path }
137
+ result << { status: status, path: path }
114
138
  end
139
+ result
115
140
  end
116
141
 
117
142
  # Parse `git diff --name-status` output.
@@ -129,10 +154,15 @@ module Mbeditor
129
154
  end
130
155
  end
131
156
 
132
- # Parse `git diff --numstat` output.
157
+ # Parse `git diff --numstat` output, in either the newline form or the
158
+ # NUL-delimited `-z` form (which is what pairs with `status --porcelain -z`,
159
+ # since only it leaves non-ASCII paths unquoted).
133
160
  # Returns Hash of path => { added: Integer, removed: Integer }.
134
161
  def parse_numstat(output)
135
- (output || "").lines.each_with_object({}) do |line, map|
162
+ out = (output || "")
163
+ return parse_numstat_z(out) if out.include?("\0")
164
+
165
+ out.lines.each_with_object({}) do |line, map|
136
166
  parts = line.strip.split("\t", 3)
137
167
  next if parts.length < 3 || parts[0] == "-"
138
168
 
@@ -140,6 +170,26 @@ module Mbeditor
140
170
  end
141
171
  end
142
172
 
173
+ # In `-z` form a rename leaves the path field empty and follows the record
174
+ # with the old name then the new name, each NUL-terminated.
175
+ def parse_numstat_z(output)
176
+ records = output.split("\0")
177
+ map = {}
178
+ i = 0
179
+ while i < records.length
180
+ added, removed, path = records[i].split("\t", 3)
181
+ i += 1
182
+ if path.to_s.empty?
183
+ path = records[i + 1]
184
+ i += 2
185
+ end
186
+ next if path.nil? || added == "-"
187
+
188
+ map[path] = { added: added.to_i, removed: removed.to_i }
189
+ end
190
+ map
191
+ end
192
+
143
193
  # Parse compact `git log --pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%aI%x1e` output.
144
194
  # Returns Array of hashes with string keys.
145
195
  def self.parse_git_log_with_parents(raw_output)
@@ -27,6 +27,8 @@ module Mbeditor
27
27
  @symbol = symbol.to_s
28
28
  @workspace_root = workspace_root.to_s.chomp("/")
29
29
  @parent = parent.to_s.empty? ? nil : parent.to_s
30
+ # Compiled once: top_level? runs on every parsed result line.
31
+ @window_assign = /\Awindow\.#{Regexp.escape(@symbol)}\s*=/
30
32
  end
31
33
 
32
34
  def call
@@ -126,7 +128,7 @@ module Mbeditor
126
128
  # top-level by grammar.
127
129
  def top_level?(indent, snippet)
128
130
  return true if indent.empty?
129
- return true if snippet.match?(/\Awindow\.#{Regexp.escape(@symbol)}\s*=/)
131
+ return true if snippet.match?(@window_assign)
130
132
  return true if snippet.start_with?("export ")
131
133
 
132
134
  false
@@ -67,6 +67,13 @@ module Mbeditor
67
67
  symbols = {}
68
68
  truncated = false
69
69
 
70
+ # Seeded first so the cap can never drop them: they are the names the
71
+ # host app declared it needs, and appending them after the scan meant
72
+ # first(MAX_SYMBOLS) silently discarded every one on a big workspace.
73
+ configured_identifiers.each do |name|
74
+ symbols[name] = { name: name, file: nil, line: nil, kind: "configured" }
75
+ end
76
+
70
77
  CodeSearchService.call(PATTERN, root).each do |raw|
71
78
  if symbols.length >= MAX_SYMBOLS
72
79
  truncated = true
@@ -90,10 +97,6 @@ module Mbeditor
90
97
  end
91
98
  end
92
99
 
93
- configured_identifiers.each do |name|
94
- symbols[name] ||= { name: name, file: nil, line: nil, kind: "configured" }
95
- end
96
-
97
100
  {
98
101
  ok: true,
99
102
  generatedAt: Time.now.to_i,
@@ -18,6 +18,8 @@ module Mbeditor
18
18
  def initialize(symbol, workspace_root)
19
19
  @symbol = symbol.to_s
20
20
  @workspace_root = workspace_root.to_s.chomp("/")
21
+ # Compiled once: this is matched against every line of search output.
22
+ @member_regex = /#{Regexp.escape(@symbol)}\.(?:prototype\.)?([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/
21
23
  end
22
24
 
23
25
  def call
@@ -50,8 +52,7 @@ module Mbeditor
50
52
  snippet = m[3].strip
51
53
  next unless abs_path.start_with?(@workspace_root)
52
54
 
53
- s = Regexp.escape(@symbol)
54
- member_match = snippet.match(/#{s}\.(?:prototype\.)?([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/)
55
+ member_match = snippet.match(@member_regex)
55
56
  next unless member_match
56
57
 
57
58
  name = member_match[1]
@@ -83,8 +83,8 @@ module Mbeditor
83
83
  skipped = []
84
84
  total = 0
85
85
 
86
- each_candidate(root) do |rel, abs|
87
- if File.size(abs) > MAX_FILE_BYTES
86
+ each_candidate(root) do |rel, abs, stat|
87
+ if stat.size > MAX_FILE_BYTES
88
88
  skipped << { path: rel, reason: "larger than #{MAX_FILE_BYTES} bytes" }
89
89
  next
90
90
  end
@@ -127,12 +127,16 @@ module Mbeditor
127
127
  abs = File.join(dir, name)
128
128
  rel = abs.delete_prefix(root).delete_prefix("/")
129
129
  next if m.excluded?(rel)
130
- next if File.symlink?(abs)
131
130
 
132
- if File.directory?(abs)
131
+ # One lstat answers symlink?, directory? and size — and lstat is
132
+ # exactly right here, since a symlink must not be followed.
133
+ stat = lstat(abs)
134
+ next if stat.nil? || stat.symlink?
135
+
136
+ if stat.directory?
133
137
  stack.push(abs)
134
138
  elsif name.match?(SOURCE_EXT) && !name.match?(MINIFIED_NAME)
135
- yield rel, abs
139
+ yield rel, abs, stat
136
140
  end
137
141
  end
138
142
  end
@@ -144,6 +148,12 @@ module Mbeditor
144
148
  []
145
149
  end
146
150
 
151
+ def lstat(abs)
152
+ File.lstat(abs)
153
+ rescue SystemCallError
154
+ nil
155
+ end
156
+
147
157
  def matcher(root)
148
158
  ExclusionMatcher.new(exclusion_patterns, root: root)
149
159
  end
@@ -218,6 +218,12 @@ module Mbeditor
218
218
  def scope_lint(workspace_root, source)
219
219
  return [] unless available? && Mbeditor.configuration.js_scope_lint != false
220
220
 
221
+ # Gathered before taking MUTEX: both walk the workspace under their own
222
+ # locks, and holding this one across them serialises every JS save
223
+ # behind whichever save is rebuilding the program.
224
+ program = JsProgramService.call(workspace_root.to_s)
225
+ globals = JsGlobalsService.call(workspace_root.to_s)
226
+
221
227
  MUTEX.synchronize do
222
228
  begin
223
229
  ctx = context
@@ -227,7 +233,7 @@ module Mbeditor
227
233
  @lint_helpers_loaded = true
228
234
  return [] unless ctx.eval("typeof __mbLint !== 'undefined'")
229
235
 
230
- names = whitelist(ctx, workspace_root)
236
+ names = whitelist(ctx, program, globals)
231
237
  findings = ctx.eval("__mbLint.lint(#{source.to_json}, #{names.to_json}, #{MAX_SCOPE_FINDINGS})")
232
238
 
233
239
  @checks_run = (@checks_run || 0) + 1
@@ -268,6 +274,13 @@ module Mbeditor
268
274
  end
269
275
 
270
276
  def reset_context!
277
+ # Dropping the reference leaks the V8 isolate until the GC finalizer
278
+ # runs; dispose releases it now.
279
+ begin
280
+ @context&.dispose
281
+ rescue StandardError
282
+ nil
283
+ end
271
284
  @context = nil
272
285
  @checks_run = 0
273
286
  @lint_helpers_loaded = false
@@ -278,12 +291,11 @@ module Mbeditor
278
291
  # plus the browser and React layers. Per-file declaration names are
279
292
  # cached by content digest, so a steady-state save re-parses only the
280
293
  # files that changed since the last lint.
281
- def whitelist(ctx, workspace_root)
294
+ def whitelist(ctx, program, globals)
282
295
  names = []
283
296
  @decl_cache ||= {}
284
297
  live = {}
285
298
 
286
- program = JsProgramService.call(workspace_root.to_s)
287
299
  Array(program[:files]).each do |f|
288
300
  digest = f[:content].hash
289
301
  entry = @decl_cache[f[:path]]
@@ -293,7 +305,6 @@ module Mbeditor
293
305
  end
294
306
  @decl_cache = live
295
307
 
296
- globals = JsGlobalsService.call(workspace_root.to_s)
297
308
  names.concat(Array(globals[:symbols]).map { |s| s[:name].to_s })
298
309
 
299
310
  (names + BROWSER_GLOBALS + REACT_GLOBALS).uniq
@@ -8,7 +8,13 @@ module Mbeditor
8
8
 
9
9
  class TimeoutError < StandardError; end
10
10
 
11
- def call(cmd, timeout: nil, env: {}, stdin_data: nil, chdir: nil)
11
+ CHUNK_BYTES = 64 * 1024
12
+ private_constant :CHUNK_BYTES
13
+
14
+ # +max_bytes+ bounds how much of each stream is kept in memory (nil =
15
+ # unbounded). Anything past the cap is still read and discarded — stopping
16
+ # would block the child on a full pipe and hang the wait below.
17
+ def call(cmd, timeout: nil, env: {}, stdin_data: nil, chdir: nil, max_bytes: nil)
12
18
  out = +""
13
19
  err = +""
14
20
  exit_status = nil
@@ -21,28 +27,52 @@ module Mbeditor
21
27
  stdin.write(stdin_data) if stdin_data
22
28
  stdin.close
23
29
 
24
- timer = if timeout
25
- Thread.new do
26
- sleep timeout
27
- timed_out = true
28
- Process.kill("-KILL", wait_thr.pid)
29
- rescue Errno::ESRCH
30
- nil
30
+ out_thread = Thread.new { out = read_capped(stdout, max_bytes) }
31
+ err_thread = Thread.new { err = read_capped(stderr, max_bytes) }
32
+
33
+ # A deadline join rather than a timer thread: a timer racing normal
34
+ # exit could flag a timeout (and SIGKILL a recycled pid) after the
35
+ # process had already succeeded. The reader threads above keep the
36
+ # pipes drained meanwhile, so this cannot deadlock.
37
+ if timeout
38
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
39
+ timed_out = !wait_thr.join(timeout)
40
+
41
+ # A grandchild that inherited the pipe holds it open after the child
42
+ # exits, so the unbounded joins below could outlive the process
43
+ # itself. Bound them by the same deadline the timer thread enforced.
44
+ remaining = [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
45
+ overrun = !out_thread.join(remaining) || !err_thread.join(remaining)
46
+
47
+ if timed_out || overrun
48
+ begin
49
+ Process.kill("-KILL", wait_thr.pid)
50
+ rescue Errno::ESRCH
51
+ nil
52
+ end
31
53
  end
32
54
  end
33
55
 
34
- out_thread = Thread.new { out = stdout.read }
35
- err_thread = Thread.new { err = stderr.read }
36
56
  out_thread.join
37
57
  err_thread.join
38
-
39
58
  exit_status = wait_thr.value
40
- timer&.kill
41
59
  end
42
60
 
43
61
  raise TimeoutError, "process timed out after #{timeout}s" if timed_out
44
62
 
45
63
  { stdout: out, stderr: err, exit_status: exit_status }
46
64
  end
65
+
66
+ def read_capped(io, max_bytes)
67
+ return io.read.to_s unless max_bytes
68
+
69
+ buf = +""
70
+ while (chunk = io.read(CHUNK_BYTES))
71
+ buf << chunk if buf.bytesize < max_bytes
72
+ end
73
+ # IO#read with a length returns binary; IO#read without one applies the
74
+ # default external encoding, and callers expect the latter.
75
+ buf.force_encoding(Encoding.default_external)
76
+ end
47
77
  end
48
78
  end
@@ -30,6 +30,10 @@ module Mbeditor
30
30
  Struct Set Time File IO Exception Proc Method NilClass
31
31
  ].freeze
32
32
 
33
+ # Keyed by symbol, so a long session accumulates one entry per name ever
34
+ # hovered. Cleared wholesale at the cap — the next lookups just re-run ri.
35
+ MAX_CACHE_ENTRIES = 500
36
+
33
37
  @cache = {}
34
38
  @mutex = Mutex.new
35
39
 
@@ -39,7 +43,10 @@ module Mbeditor
39
43
  return cached unless cached.nil?
40
44
 
41
45
  result = new(symbol).call
42
- @mutex.synchronize { @cache[symbol] = result }
46
+ @mutex.synchronize do
47
+ @cache.clear if @cache.size >= MAX_CACHE_ENTRIES
48
+ @cache[symbol] = result
49
+ end
43
50
  result
44
51
  end
45
52