mbeditor 0.12.9 → 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 +150 -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 +102 -25
  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 +598 -130
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +162 -6
  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 +427 -111
  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 +268 -53
  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 +31 -5
  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
@@ -18,7 +18,7 @@ module Mbeditor
18
18
  def save(path, content)
19
19
  raise FileTooLargeError if content.bytesize > MAX_FILE_SIZE_BYTES
20
20
 
21
- File.write(path, content)
21
+ atomic_write(path, content)
22
22
  { ok: true, path: relative_path(path) }
23
23
  end
24
24
 
@@ -36,10 +36,15 @@ module Mbeditor
36
36
 
37
37
  def rename(old_path, new_path)
38
38
  raise PathNotFoundError unless File.exist?(old_path)
39
- raise TargetExistsError if File.exist?(new_path)
39
+ # A case-only rename on a case-insensitive filesystem (macOS) reports the
40
+ # target as existing because it IS the source.
41
+ same_file = File.identical?(old_path, new_path)
42
+ raise TargetExistsError if File.exist?(new_path) && !same_file
40
43
 
41
44
  FileUtils.mkdir_p(File.dirname(new_path))
42
- FileUtils.mv(old_path, new_path)
45
+ # FileUtils.mv refuses a "same file" move, so the case-only rename goes
46
+ # straight to rename(2); everything else keeps mv's cross-device fallback.
47
+ same_file ? File.rename(old_path, new_path) : FileUtils.mv(old_path, new_path)
43
48
  { ok: true, oldPath: relative_path(old_path), path: relative_path(new_path), name: File.basename(new_path) }
44
49
  end
45
50
 
@@ -61,6 +66,36 @@ module Mbeditor
61
66
 
62
67
  private
63
68
 
69
+ # Write through a temp file in the same directory and rename over the
70
+ # target: a truncate-then-write loses the file outright if the process dies
71
+ # mid-save. The symlink is resolved first — renaming onto the link itself
72
+ # would replace it with a regular file, and linked-in files are supported.
73
+ def atomic_write(path, content)
74
+ target = link_target(path)
75
+ tmp = File.join(File.dirname(target), ".#{File.basename(target)}.mbeditor-tmp")
76
+ mode = File.stat(target).mode & 0o7777 if File.exist?(target)
77
+ File.write(tmp, content)
78
+ File.chmod(mode, tmp) if mode
79
+ File.rename(tmp, target)
80
+ rescue StandardError
81
+ FileUtils.rm_f(tmp) if tmp
82
+ raise
83
+ end
84
+
85
+ def link_target(path)
86
+ return path.to_s unless File.symlink?(path)
87
+
88
+ begin
89
+ File.realpath(path)
90
+ rescue SystemCallError
91
+ # realpath refuses a dangling link, but a save through one still has to
92
+ # create the file it names — the same thing File.write did.
93
+ File.expand_path(File.readlink(path), File.dirname(path))
94
+ end
95
+ rescue SystemCallError
96
+ path.to_s
97
+ end
98
+
64
99
  def relative_path(path)
65
100
  Pathname(path).relative_path_from(@workspace_root).to_s
66
101
  end
@@ -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