mbeditor 0.12.8 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +207 -0
- data/app/assets/javascripts/mbeditor/application.js +7 -1
- data/app/assets/javascripts/mbeditor/collaboration_service.js +31 -0
- data/app/assets/javascripts/mbeditor/color_provider.js +5 -0
- data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +3 -1
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +135 -40
- data/app/assets/javascripts/mbeditor/components/FileTree.js +6 -1
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +10 -1
- data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +15 -8
- data/app/assets/javascripts/mbeditor/components/ImportDialog.js +216 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +629 -137
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +218 -13
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +39 -33
- data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
- data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +312 -0
- data/app/assets/javascripts/mbeditor/editor_plugins.js +444 -116
- data/app/assets/javascripts/mbeditor/file_import.js +78 -0
- data/app/assets/javascripts/mbeditor/file_service.js +122 -18
- data/app/assets/javascripts/mbeditor/git_service.js +130 -8
- data/app/assets/javascripts/mbeditor/history_service.js +33 -38
- data/app/assets/javascripts/mbeditor/log_service.js +4 -0
- data/app/assets/javascripts/mbeditor/search_service.js +30 -2
- data/app/assets/javascripts/mbeditor/tab_manager.js +59 -6
- data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
- data/app/assets/stylesheets/mbeditor/editor.css +302 -55
- data/app/channels/mbeditor/channel_authentication.rb +7 -0
- data/app/channels/mbeditor/editor_channel.rb +6 -3
- data/app/controllers/mbeditor/application_controller.rb +5 -0
- data/app/controllers/mbeditor/editors_controller.rb +173 -29
- data/app/controllers/mbeditor/git_controller.rb +3 -3
- data/app/controllers/mbeditor/logs_controller.rb +3 -1
- data/app/services/mbeditor/collaboration_doc_store.rb +7 -0
- data/app/services/mbeditor/editor_state_service.rb +36 -26
- data/app/services/mbeditor/exclusion_matcher.rb +12 -10
- data/app/services/mbeditor/file_operation_service.rb +38 -3
- data/app/services/mbeditor/file_tree_service.rb +30 -2
- data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
- data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
- data/app/services/mbeditor/git_diff_service.rb +5 -1
- data/app/services/mbeditor/git_info_service.rb +20 -8
- data/app/services/mbeditor/git_line_diff_service.rb +6 -2
- data/app/services/mbeditor/git_service.rb +65 -15
- data/app/services/mbeditor/js_definition_service.rb +3 -1
- data/app/services/mbeditor/js_globals_service.rb +7 -4
- data/app/services/mbeditor/js_members_service.rb +3 -2
- data/app/services/mbeditor/js_program_service.rb +15 -5
- data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
- data/app/services/mbeditor/process_runner.rb +42 -12
- data/app/services/mbeditor/ri_definition_service.rb +8 -1
- data/app/services/mbeditor/route_service.rb +45 -8
- data/app/services/mbeditor/rubocop_run_service.rb +86 -0
- data/app/services/mbeditor/ruby_definition_service.rb +23 -4
- data/app/services/mbeditor/schema_service.rb +65 -64
- data/app/services/mbeditor/search_replace_service.rb +75 -10
- data/app/services/mbeditor/test_runner_service.rb +98 -10
- data/lib/mbeditor/cable_log_filter.rb +8 -2
- data/lib/mbeditor/configuration.rb +12 -1
- data/lib/mbeditor/editor_bootstrap.rb +18 -12
- data/lib/mbeditor/engine.rb +22 -3
- data/lib/mbeditor/exception_log.rb +2 -2
- data/lib/mbeditor/pending_migrations.rb +33 -0
- data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
- data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
- data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
- data/lib/mbeditor/route_map.rb +2 -0
- data/lib/mbeditor/ruby_lsp_client.rb +71 -12
- data/lib/mbeditor/version.rb +1 -1
- metadata +7 -2
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
16
|
-
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
+
flight = nil
|
|
20
22
|
GIT_INFO_MUTEX.synchronize do
|
|
21
23
|
@git_info_flights ||= {}
|
|
22
|
-
|
|
23
|
-
@git_info_flights[repo_path] =
|
|
24
|
+
flight = @git_info_flights[repo_path]
|
|
25
|
+
@git_info_flights[repo_path] = Queue.new unless flight
|
|
24
26
|
end
|
|
25
27
|
|
|
26
|
-
if
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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:
|
|
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 || "")
|
|
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?(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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
|
|