mbeditor 0.5.3 → 0.7.1
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 +77 -0
- data/README.md +7 -0
- data/app/assets/javascripts/mbeditor/application.js +3 -0
- data/app/assets/javascripts/mbeditor/components/ChangelogView.js +145 -0
- data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +359 -31
- data/app/assets/javascripts/mbeditor/components/FileTree.js +177 -116
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +952 -143
- data/app/assets/javascripts/mbeditor/components/TabBar.js +9 -0
- data/app/assets/javascripts/mbeditor/conflict_parser.js +48 -0
- data/app/assets/javascripts/mbeditor/editor_plugins.js +420 -67
- data/app/assets/javascripts/mbeditor/editor_store.js +1 -0
- data/app/assets/javascripts/mbeditor/file_service.js +34 -6
- data/app/assets/javascripts/mbeditor/git_service.js +2 -1
- data/app/assets/javascripts/mbeditor/history_service.js +177 -0
- data/app/assets/javascripts/mbeditor/search_service.js +1 -0
- data/app/assets/javascripts/mbeditor/tab_manager.js +8 -5
- data/app/assets/stylesheets/mbeditor/application.css +112 -0
- data/app/assets/stylesheets/mbeditor/editor.css +443 -78
- data/app/channels/mbeditor/editor_channel.rb +5 -41
- data/app/controllers/mbeditor/application_controller.rb +8 -1
- data/app/controllers/mbeditor/editors_controller.rb +276 -654
- data/app/controllers/mbeditor/git_controller.rb +2 -61
- data/app/services/mbeditor/availability_probe.rb +83 -0
- data/app/services/mbeditor/code_search_service.rb +42 -0
- data/app/services/mbeditor/editor_state_service.rb +91 -0
- data/app/services/mbeditor/exclusion_matcher.rb +23 -0
- data/app/services/mbeditor/file_operation_service.rb +68 -0
- data/app/services/mbeditor/file_tree_service.rb +69 -0
- data/app/services/mbeditor/git_combined_diff_service.rb +43 -0
- data/app/services/mbeditor/git_commit_detail_service.rb +46 -0
- data/app/services/mbeditor/git_info_service.rb +151 -0
- data/app/services/mbeditor/git_service.rb +36 -26
- data/app/services/mbeditor/js_definition_service.rb +59 -0
- data/app/services/mbeditor/js_members_service.rb +62 -0
- data/app/services/mbeditor/process_runner.rb +48 -0
- data/app/services/mbeditor/rails_related_files_service.rb +282 -0
- data/app/services/mbeditor/ruby_definition_service.rb +77 -101
- data/app/services/mbeditor/schema_service.rb +270 -0
- data/app/services/mbeditor/search_replace_service.rb +184 -0
- data/app/services/mbeditor/test_runner_service.rb +5 -27
- data/app/views/layouts/mbeditor/application.html.erb +2 -2
- data/config/routes.rb +8 -1
- data/lib/mbeditor/configuration.rb +4 -2
- data/lib/mbeditor/version.rb +1 -1
- data/public/monaco-editor/vs/language/css/cssMode.js +13 -0
- data/public/monaco-editor/vs/language/css/cssWorker.js +77 -0
- data/public/monaco-editor/vs/language/html/htmlMode.js +13 -0
- data/public/monaco-editor/vs/language/html/htmlWorker.js +454 -0
- data/public/monaco-editor/vs/language/json/jsonMode.js +19 -0
- data/public/monaco-editor/vs/language/json/jsonWorker.js +42 -0
- metadata +26 -3
- data/app/services/mbeditor/unused_methods_service.rb +0 -139
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require "timeout"
|
|
3
|
+
require_relative "process_runner"
|
|
5
4
|
|
|
6
5
|
module Mbeditor
|
|
7
6
|
# Shared helpers for running git CLI commands read-only inside a repo.
|
|
@@ -15,33 +14,16 @@ module Mbeditor
|
|
|
15
14
|
SAFE_GIT_REF = %r{\A[\w./-]+\z}
|
|
16
15
|
|
|
17
16
|
# Run an arbitrary git command inside +repo_path+.
|
|
18
|
-
# Returns [stdout, Process::Status]. stderr is
|
|
19
|
-
#
|
|
17
|
+
# Returns [stdout, Process::Status]. stderr is discarded to prevent git
|
|
18
|
+
# diagnostic messages from leaking into the Rails server log.
|
|
20
19
|
# Honors config.git_timeout (seconds) when set.
|
|
21
20
|
def run_git(repo_path, *args)
|
|
22
21
|
timeout_secs = Mbeditor.configuration.git_timeout&.to_i
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
timer = if timeout_secs && timeout_secs > 0
|
|
29
|
-
Thread.new do
|
|
30
|
-
sleep timeout_secs
|
|
31
|
-
timed_out = true
|
|
32
|
-
Process.kill("-KILL", wait_thr.pid)
|
|
33
|
-
rescue Errno::ESRCH
|
|
34
|
-
nil
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
out = stdout.read
|
|
39
|
-
exit_status = wait_thr.value
|
|
40
|
-
timer&.kill
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
raise Timeout::Error, "git timed out after #{timeout_secs}s" if timed_out
|
|
44
|
-
[out, exit_status]
|
|
22
|
+
timeout = timeout_secs && timeout_secs > 0 ? timeout_secs : nil
|
|
23
|
+
result = ProcessRunner.call(["git", "-C", repo_path, *args], timeout: timeout)
|
|
24
|
+
[result[:stdout], result[:exit_status]]
|
|
25
|
+
rescue ProcessRunner::TimeoutError
|
|
26
|
+
raise Timeout::Error, "git timed out after #{timeout_secs}s"
|
|
45
27
|
end
|
|
46
28
|
|
|
47
29
|
# Current branch name, or nil if not in a git repo.
|
|
@@ -103,6 +85,34 @@ module Mbeditor
|
|
|
103
85
|
[nil, nil]
|
|
104
86
|
end
|
|
105
87
|
|
|
88
|
+
# Parse `git status --porcelain` output.
|
|
89
|
+
# Returns Array of { status: String, path: String }.
|
|
90
|
+
def parse_porcelain_status(output)
|
|
91
|
+
output.lines.filter_map do |line|
|
|
92
|
+
next if line.length < 4
|
|
93
|
+
|
|
94
|
+
path = line[3..].to_s.strip
|
|
95
|
+
next if path.blank?
|
|
96
|
+
|
|
97
|
+
{ status: line[0..1].strip, path: path }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Parse `git diff --name-status` output.
|
|
102
|
+
# Returns Array of { status: String, path: String }.
|
|
103
|
+
def parse_name_status(output)
|
|
104
|
+
output.lines.filter_map do |line|
|
|
105
|
+
parts = line.strip.split("\t")
|
|
106
|
+
next if parts.empty?
|
|
107
|
+
|
|
108
|
+
status = parts[0].to_s.strip
|
|
109
|
+
path = parts.last.to_s.strip
|
|
110
|
+
next if path.blank?
|
|
111
|
+
|
|
112
|
+
{ status: status, path: path }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
106
116
|
# Parse `git diff --numstat` output.
|
|
107
117
|
# Returns Hash of path => { added: Integer, removed: Integer }.
|
|
108
118
|
def parse_numstat(output)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mbeditor
|
|
4
|
+
# Searches JS/JSX/TS/TSX files in the workspace for definitions of a named
|
|
5
|
+
# JavaScript global (variable, function, class, or window property assignment).
|
|
6
|
+
#
|
|
7
|
+
# Delegates subprocess execution to CodeSearchService (rg/grep).
|
|
8
|
+
#
|
|
9
|
+
# Returns an array of hashes:
|
|
10
|
+
# { file: String, line: Integer, snippet: String }
|
|
11
|
+
class JsDefinitionService
|
|
12
|
+
MAX_RESULTS = 20
|
|
13
|
+
|
|
14
|
+
def initialize(symbol, workspace_root)
|
|
15
|
+
@symbol = symbol.to_s
|
|
16
|
+
@workspace_root = workspace_root.to_s.chomp("/")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
return [] if @symbol.empty? || @workspace_root.empty?
|
|
21
|
+
return [] unless File.directory?(@workspace_root)
|
|
22
|
+
|
|
23
|
+
lines = CodeSearchService.call(build_pattern, @workspace_root)
|
|
24
|
+
parse_results(lines)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def build_pattern
|
|
30
|
+
s = Regexp.escape(@symbol)
|
|
31
|
+
# Matches the most common JS global-definition forms, anchored so we
|
|
32
|
+
# don't pick up every usage — only assignment / declaration lines.
|
|
33
|
+
# Note: [\s=;,] is avoided inside character classes because BSD grep
|
|
34
|
+
# (macOS) does not support \s inside [...]. Use explicit [ \t=;,] instead.
|
|
35
|
+
"(?:window\\.#{s}\\s*=|\\b(?:var|let|const)\\s+#{s}[ \\t=;,]|\\bfunction\\s+#{s}[ \\t({]|\\bclass\\s+#{s}\\b|\\bexport\\s+(?:default\\s+)?(?:var|let|const|function|class)\\s+#{s}\\b)"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def parse_results(lines)
|
|
39
|
+
results = []
|
|
40
|
+
lines.each do |raw|
|
|
41
|
+
raw = raw.chomp
|
|
42
|
+
# ripgrep/grep output: /abs/path/file.js:42: window.ReactWindow = ...
|
|
43
|
+
m = raw.match(/\A(.+?):(\d+):(.+)\z/)
|
|
44
|
+
next unless m
|
|
45
|
+
|
|
46
|
+
abs_path = m[1]
|
|
47
|
+
line_num = m[2].to_i
|
|
48
|
+
snippet = m[3].strip
|
|
49
|
+
|
|
50
|
+
next unless abs_path.start_with?(@workspace_root)
|
|
51
|
+
|
|
52
|
+
rel_path = abs_path.delete_prefix(@workspace_root).delete_prefix("/")
|
|
53
|
+
results << { file: rel_path, line: line_num, snippet: snippet }
|
|
54
|
+
break if results.length >= MAX_RESULTS
|
|
55
|
+
end
|
|
56
|
+
results
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mbeditor
|
|
4
|
+
# Searches JS/JSX/TS/TSX files for properties/methods attached to a named
|
|
5
|
+
# global object (direct assignment and prototype assignment patterns).
|
|
6
|
+
#
|
|
7
|
+
# Examples matched for symbol "ReactWindow":
|
|
8
|
+
# ReactWindow.open = function() { ... }
|
|
9
|
+
# ReactWindow.prototype.close = function() { ... }
|
|
10
|
+
#
|
|
11
|
+
# Delegates subprocess execution to CodeSearchService (rg/grep).
|
|
12
|
+
#
|
|
13
|
+
# Returns an array of hashes:
|
|
14
|
+
# { name: String, snippet: String }
|
|
15
|
+
class JsMembersService
|
|
16
|
+
MAX_RESULTS = 50
|
|
17
|
+
|
|
18
|
+
def initialize(symbol, workspace_root)
|
|
19
|
+
@symbol = symbol.to_s
|
|
20
|
+
@workspace_root = workspace_root.to_s.chomp("/")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
return [] if @symbol.empty? || @workspace_root.empty?
|
|
25
|
+
return [] unless File.directory?(@workspace_root)
|
|
26
|
+
|
|
27
|
+
lines = CodeSearchService.call(build_pattern, @workspace_root)
|
|
28
|
+
parse_results(lines)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def build_pattern
|
|
34
|
+
s = Regexp.escape(@symbol)
|
|
35
|
+
"#{s}\\.(?:prototype\\.)?([a-zA-Z_$][a-zA-Z0-9_$]*)\\s*="
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def parse_results(lines)
|
|
39
|
+
results = []
|
|
40
|
+
seen = {}
|
|
41
|
+
lines.each do |raw|
|
|
42
|
+
raw = raw.chomp
|
|
43
|
+
m = raw.match(/\A.+?:\d+:(.+)\z/)
|
|
44
|
+
next unless m
|
|
45
|
+
|
|
46
|
+
snippet = m[1].strip
|
|
47
|
+
|
|
48
|
+
s = Regexp.escape(@symbol)
|
|
49
|
+
member_match = snippet.match(/#{s}\.(?:prototype\.)?([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/)
|
|
50
|
+
next unless member_match
|
|
51
|
+
|
|
52
|
+
name = member_match[1]
|
|
53
|
+
next if seen[name]
|
|
54
|
+
|
|
55
|
+
seen[name] = true
|
|
56
|
+
results << { name: name, snippet: snippet }
|
|
57
|
+
break if results.length >= MAX_RESULTS
|
|
58
|
+
end
|
|
59
|
+
results
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Mbeditor
|
|
6
|
+
module ProcessRunner
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
class TimeoutError < StandardError; end
|
|
10
|
+
|
|
11
|
+
def call(cmd, timeout: nil, env: {}, stdin_data: nil, chdir: nil)
|
|
12
|
+
out = +""
|
|
13
|
+
err = +""
|
|
14
|
+
exit_status = nil
|
|
15
|
+
timed_out = false
|
|
16
|
+
|
|
17
|
+
opts = { pgroup: true }
|
|
18
|
+
opts[:chdir] = chdir if chdir
|
|
19
|
+
|
|
20
|
+
Open3.popen3(env, *cmd, **opts) do |stdin, stdout, stderr, wait_thr|
|
|
21
|
+
stdin.write(stdin_data) if stdin_data
|
|
22
|
+
stdin.close
|
|
23
|
+
|
|
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
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
out_thread = Thread.new { out = stdout.read }
|
|
35
|
+
err_thread = Thread.new { err = stderr.read }
|
|
36
|
+
out_thread.join
|
|
37
|
+
err_thread.join
|
|
38
|
+
|
|
39
|
+
exit_status = wait_thr.value
|
|
40
|
+
timer&.kill
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
raise TimeoutError, "process timed out after #{timeout}s" if timed_out
|
|
44
|
+
|
|
45
|
+
{ stdout: out, stderr: err, exit_status: exit_status }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mbeditor
|
|
4
|
+
# Given a workspace-relative file path, finds related Rails files grouped by
|
|
5
|
+
# type: controller, model, views, helper, tests, and custom directories.
|
|
6
|
+
#
|
|
7
|
+
# Only groups with at least one existing file are included in the result.
|
|
8
|
+
# All returned paths are workspace-relative strings.
|
|
9
|
+
module RailsRelatedFilesService
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Returns a hash of groups. Example:
|
|
13
|
+
#
|
|
14
|
+
# {
|
|
15
|
+
# controller: [{path: "app/controllers/users_controller.rb", name: "users_controller.rb"}],
|
|
16
|
+
# model: [{path: "app/models/user.rb", name: "user.rb"}],
|
|
17
|
+
# views: [{path: "app/views/users/index.html.erb", name: "index.html.erb"}],
|
|
18
|
+
# helper: [{path: "app/helpers/users_helper.rb", name: "users_helper.rb"}],
|
|
19
|
+
# tests: [{path: "test/controllers/users_controller_test.rb", name: "users_controller_test.rb"}],
|
|
20
|
+
# custom: {"app/assets/javascripts/app" => [{path: "...", name: "..."}]}
|
|
21
|
+
# }
|
|
22
|
+
#
|
|
23
|
+
# Returns {} when the path does not match any known Rails convention.
|
|
24
|
+
def find(workspace_root, relative_path, custom_paths: [])
|
|
25
|
+
plural, singular = extract_resource_names(relative_path, custom_paths: custom_paths)
|
|
26
|
+
return {} unless plural && singular
|
|
27
|
+
|
|
28
|
+
result = {}
|
|
29
|
+
|
|
30
|
+
# ── controller ────────────────────────────────────────────────────────────
|
|
31
|
+
controller_path = "app/controllers/#{plural}_controller.rb"
|
|
32
|
+
if file_exists?(workspace_root, controller_path)
|
|
33
|
+
result[:controller] = [entry(controller_path, kind: 'Controller')]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# ── model ─────────────────────────────────────────────────────────────────
|
|
37
|
+
model_path = "app/models/#{singular}.rb"
|
|
38
|
+
if file_exists?(workspace_root, model_path)
|
|
39
|
+
result[:model] = [entry(model_path, kind: 'Model')]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# ── views ─────────────────────────────────────────────────────────────────
|
|
43
|
+
views_dir = File.join(workspace_root, "app", "views", plural)
|
|
44
|
+
if File.directory?(views_dir)
|
|
45
|
+
children = dir_children(workspace_root, "app/views/#{plural}", kind: 'View')
|
|
46
|
+
result[:views] = children unless children.empty?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# ── helper ────────────────────────────────────────────────────────────────
|
|
50
|
+
helper_path = "app/helpers/#{plural}_helper.rb"
|
|
51
|
+
if file_exists?(workspace_root, helper_path)
|
|
52
|
+
result[:helper] = [entry(helper_path, kind: 'Helper')]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# ── tests ─────────────────────────────────────────────────────────────────
|
|
56
|
+
test_candidates = [
|
|
57
|
+
"test/controllers/#{plural}_controller_test.rb",
|
|
58
|
+
"test/models/#{singular}_test.rb",
|
|
59
|
+
"spec/controllers/#{plural}_controller_spec.rb",
|
|
60
|
+
"spec/models/#{singular}_spec.rb"
|
|
61
|
+
]
|
|
62
|
+
tests = test_candidates.select { |p| file_exists?(workspace_root, p) }.map { |p| entry(p, kind: 'Test') }
|
|
63
|
+
result[:tests] = tests unless tests.empty?
|
|
64
|
+
|
|
65
|
+
# ── concerns ──────────────────────────────────────────────────────────────
|
|
66
|
+
plain_plural = plural.split('/').last
|
|
67
|
+
plain_singular = singular.split('/').last
|
|
68
|
+
concern_dirs = ['app/models/concerns', 'app/controllers/concerns']
|
|
69
|
+
concern_files = []
|
|
70
|
+
concern_dirs.each do |dir|
|
|
71
|
+
children = dir_children(workspace_root, dir, kind: 'Concern')
|
|
72
|
+
matching = children.select { |c|
|
|
73
|
+
base = File.basename(c[:path], '.*')
|
|
74
|
+
base == plain_plural || base == plain_singular ||
|
|
75
|
+
base.start_with?("#{plain_plural}_") || base.start_with?("#{plain_singular}_") ||
|
|
76
|
+
base.end_with?("_#{plain_plural}") || base.end_with?("_#{plain_singular}")
|
|
77
|
+
}
|
|
78
|
+
concern_files.concat(matching)
|
|
79
|
+
end
|
|
80
|
+
result[:concerns] = concern_files.uniq { |c| c[:path] } unless concern_files.empty?
|
|
81
|
+
|
|
82
|
+
# ── custom paths ──────────────────────────────────────────────────────────
|
|
83
|
+
custom_result = {}
|
|
84
|
+
Array(custom_paths).each do |base|
|
|
85
|
+
base = base.to_s.strip
|
|
86
|
+
next if base.empty?
|
|
87
|
+
|
|
88
|
+
# Derive a human-readable kind from the last segment of the base path
|
|
89
|
+
custom_kind = base.split('/').last.to_s.tr('_', ' ').split.map(&:capitalize).join(' ')
|
|
90
|
+
|
|
91
|
+
[plural, singular].uniq.each do |name|
|
|
92
|
+
rel_dir = "#{base}/#{name}"
|
|
93
|
+
abs_dir = File.join(workspace_root, rel_dir)
|
|
94
|
+
next unless File.directory?(abs_dir)
|
|
95
|
+
|
|
96
|
+
begin
|
|
97
|
+
real_ws = File.realpath(workspace_root)
|
|
98
|
+
real_dir = File.realpath(abs_dir)
|
|
99
|
+
next unless real_dir.start_with?("#{real_ws}/") || real_dir == real_ws
|
|
100
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
101
|
+
next
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
children = dir_children(workspace_root, rel_dir, kind: custom_kind)
|
|
105
|
+
next if children.empty?
|
|
106
|
+
|
|
107
|
+
custom_result[base] ||= []
|
|
108
|
+
custom_result[base].concat(children)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
result[:custom] = custom_result unless custom_result.empty?
|
|
112
|
+
|
|
113
|
+
result
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Helpers
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
# Returns [plural, singular] workspace-relative resource name strings
|
|
121
|
+
# (may include namespace prefix, e.g. "admin/users", "admin/user"),
|
|
122
|
+
# or nil when the path does not match a known Rails convention.
|
|
123
|
+
def extract_resource_names(relative_path, custom_paths: [])
|
|
124
|
+
parts = relative_path.to_s.split("/")
|
|
125
|
+
return nil unless parts.length >= 2
|
|
126
|
+
|
|
127
|
+
case parts[0]
|
|
128
|
+
when "app"
|
|
129
|
+
case parts[1]
|
|
130
|
+
when "controllers"
|
|
131
|
+
# app/controllers/[namespace/]name_controller.rb
|
|
132
|
+
# also handles app/controllers/concerns/name_concern.rb
|
|
133
|
+
if parts[2] == "concerns"
|
|
134
|
+
return nil unless parts.length >= 4 && parts.last.end_with?(".rb")
|
|
135
|
+
file_base = parts.last.delete_suffix(".rb")
|
|
136
|
+
singular = file_base.sub(/_concern$/, '').sub(/^concern_/, '')
|
|
137
|
+
plural = pluralize(singular)
|
|
138
|
+
return [plural, singular]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
return nil unless parts.last.end_with?("_controller.rb")
|
|
142
|
+
|
|
143
|
+
ns_and_file = parts[2..]
|
|
144
|
+
file = ns_and_file.last
|
|
145
|
+
ns = ns_and_file[0..-2] # may be empty
|
|
146
|
+
base = file.delete_suffix("_controller.rb")
|
|
147
|
+
plural = (ns + [base]).join("/")
|
|
148
|
+
singular = (ns + [singularize(base)]).join("/")
|
|
149
|
+
[plural, singular]
|
|
150
|
+
|
|
151
|
+
when "models"
|
|
152
|
+
# app/models/[namespace/]name.rb
|
|
153
|
+
# also handles app/models/concerns/name_concern.rb
|
|
154
|
+
if parts[2] == "concerns"
|
|
155
|
+
return nil unless parts.length >= 4 && parts.last.end_with?(".rb")
|
|
156
|
+
file_base = parts.last.delete_suffix(".rb")
|
|
157
|
+
singular = file_base.sub(/_concern$/, '').sub(/^concern_/, '')
|
|
158
|
+
plural = pluralize(singular)
|
|
159
|
+
return [plural, singular]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
return nil unless parts.last.end_with?(".rb")
|
|
163
|
+
|
|
164
|
+
ns_and_file = parts[2..]
|
|
165
|
+
file = ns_and_file.last.delete_suffix(".rb")
|
|
166
|
+
ns = ns_and_file[0..-2]
|
|
167
|
+
singular = (ns + [file]).join("/")
|
|
168
|
+
plural = (ns + [pluralize(file)]).join("/")
|
|
169
|
+
[plural, singular]
|
|
170
|
+
|
|
171
|
+
when "views"
|
|
172
|
+
# app/views/[namespace/]resource/anything
|
|
173
|
+
return nil unless parts.length >= 4
|
|
174
|
+
|
|
175
|
+
# All path segments between "views" and the view filename form the resource path.
|
|
176
|
+
# e.g. app/views/admin/users/index.html.erb → view_resource = ["admin","users"]
|
|
177
|
+
# e.g. app/views/users/index.html.erb → view_resource = ["users"]
|
|
178
|
+
view_resource = parts[2..parts.length - 2]
|
|
179
|
+
plural = view_resource.join("/")
|
|
180
|
+
singular = (view_resource[0..-2] + [singularize(view_resource.last)]).join("/")
|
|
181
|
+
[plural, singular]
|
|
182
|
+
|
|
183
|
+
when "helpers"
|
|
184
|
+
# app/helpers/[namespace/]name_helper.rb
|
|
185
|
+
return nil unless parts.last.end_with?("_helper.rb")
|
|
186
|
+
|
|
187
|
+
ns_and_file = parts[2..]
|
|
188
|
+
file = ns_and_file.last
|
|
189
|
+
ns = ns_and_file[0..-2]
|
|
190
|
+
base = file.delete_suffix("_helper.rb")
|
|
191
|
+
plural = (ns + [base]).join("/")
|
|
192
|
+
singular = (ns + [singularize(base)]).join("/")
|
|
193
|
+
[plural, singular]
|
|
194
|
+
|
|
195
|
+
else
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
when "test", "spec"
|
|
200
|
+
framework = parts[0]
|
|
201
|
+
case parts[1]
|
|
202
|
+
when "controllers"
|
|
203
|
+
# test/controllers/[ns/]name_controller_test.rb
|
|
204
|
+
suffix = framework == "test" ? "_controller_test.rb" : "_controller_spec.rb"
|
|
205
|
+
return nil unless parts.last.end_with?(suffix)
|
|
206
|
+
|
|
207
|
+
ns_and_file = parts[2..]
|
|
208
|
+
file = ns_and_file.last
|
|
209
|
+
ns = ns_and_file[0..-2]
|
|
210
|
+
base = file.delete_suffix(suffix)
|
|
211
|
+
plural = (ns + [base]).join("/")
|
|
212
|
+
singular = (ns + [singularize(base)]).join("/")
|
|
213
|
+
[plural, singular]
|
|
214
|
+
|
|
215
|
+
when "models"
|
|
216
|
+
# test/models/[ns/]name_test.rb
|
|
217
|
+
suffix = framework == "test" ? "_test.rb" : "_spec.rb"
|
|
218
|
+
return nil unless parts.last.end_with?(suffix)
|
|
219
|
+
|
|
220
|
+
ns_and_file = parts[2..]
|
|
221
|
+
file = ns_and_file.last
|
|
222
|
+
ns = ns_and_file[0..-2]
|
|
223
|
+
base = file.delete_suffix(suffix)
|
|
224
|
+
singular = (ns + [base]).join("/")
|
|
225
|
+
plural = (ns + [pluralize(base)]).join("/")
|
|
226
|
+
[plural, singular]
|
|
227
|
+
|
|
228
|
+
else
|
|
229
|
+
nil
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
else
|
|
233
|
+
# Custom path fallback — must be last
|
|
234
|
+
Array(custom_paths).each do |base|
|
|
235
|
+
base = base.to_s.strip
|
|
236
|
+
next if base.empty?
|
|
237
|
+
next unless relative_path.start_with?("#{base}/")
|
|
238
|
+
rest = relative_path.delete_prefix("#{base}/")
|
|
239
|
+
resource = rest.split('/').first.to_s.sub(/\.[^.]+$/, '') # first path segment, no extension
|
|
240
|
+
next if resource.empty?
|
|
241
|
+
return [pluralize(resource), singularize(resource)]
|
|
242
|
+
end
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def entry(rel_path, kind: nil)
|
|
248
|
+
{ path: rel_path, name: File.basename(rel_path), type: :file, kind: kind }
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def file_exists?(workspace_root, rel_path)
|
|
252
|
+
File.exist?(File.join(workspace_root, rel_path))
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Returns direct file children (not subdirectories) of a workspace-relative
|
|
256
|
+
# directory, sorted by name, as {path:, name:, type: :file, kind:} hashes.
|
|
257
|
+
def dir_children(workspace_root, rel_dir, kind: nil)
|
|
258
|
+
abs_dir = File.join(workspace_root, rel_dir)
|
|
259
|
+
return [] unless File.directory?(abs_dir)
|
|
260
|
+
|
|
261
|
+
Dir.entries(abs_dir)
|
|
262
|
+
.reject { |n| n.start_with?(".") }
|
|
263
|
+
.sort
|
|
264
|
+
.filter_map do |name|
|
|
265
|
+
full = File.join(abs_dir, name)
|
|
266
|
+
next unless File.file?(full)
|
|
267
|
+
|
|
268
|
+
{ path: "#{rel_dir}/#{name}", name: name, type: :file, kind: kind }
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def singularize(word)
|
|
273
|
+
ActiveSupport::Inflector.singularize(word)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def pluralize(word)
|
|
277
|
+
ActiveSupport::Inflector.pluralize(word)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
private_class_method :entry, :file_exists?, :dir_children, :singularize, :pluralize, :extract_resource_names
|
|
281
|
+
end
|
|
282
|
+
end
|