ruby-lsp-extras 0.1.1 → 0.2.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 +4 -0
- data/lib/ruby_lsp/ruby_lsp_extras/addon.rb +83 -0
- data/lib/ruby_lsp/ruby_lsp_extras/definition.rb +287 -0
- data/lib/ruby_lsp/ruby_lsp_extras/server_addon.rb +152 -0
- data/lib/ruby_lsp/ruby_lsp_extras/version.rb +22 -0
- data/lib/ruby_lsp/ruby_lsp_rails_partial/addon.rb +99 -0
- data/lib/ruby_lsp/ruby_lsp_rails_partial/completion.rb +67 -0
- data/lib/ruby_lsp/ruby_lsp_rails_partial/definition.rb +63 -0
- data/lib/ruby_lsp/ruby_lsp_rails_partial/hover.rb +61 -0
- data/lib/ruby_lsp/ruby_lsp_rails_partial/partial_index.rb +89 -0
- data/lib/ruby_lsp/ruby_lsp_rails_partial/partial_resolver.rb +82 -0
- data/lib/ruby_lsp/ruby_lsp_rails_partial/util.rb +89 -0
- data/lib/ruby_lsp/ruby_lsp_rails_partial/version.rb +7 -0
- data/lib/ruby_lsp/ruby_lsp_rails_routes/addon.rb +73 -0
- data/lib/ruby_lsp/ruby_lsp_rails_routes/definition.rb +42 -0
- data/lib/ruby_lsp/ruby_lsp_rails_routes/server_addon.rb +63 -0
- data/lib/ruby_lsp/ruby_lsp_rails_routes/util.rb +34 -0
- data/lib/ruby_lsp/ruby_lsp_rails_routes/version.rb +7 -0
- metadata +75 -4
- data/lib/ruby-lsp-extras.rb +0 -6
- data/lib/ruby_lsp_extras/version.rb +0 -20
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLsp
|
|
4
|
+
module RailsPartial
|
|
5
|
+
# Completes partial names while typing the partial name string of a render call.
|
|
6
|
+
# Candidates are always offered in path form (e.g. 'admin/areas/form').
|
|
7
|
+
#
|
|
8
|
+
# NOTE: A completion request dispatches only the single node directly under the cursor, and the
|
|
9
|
+
# target node types do not include StringNode. When the cursor is inside the string, what gets
|
|
10
|
+
# dispatched is the enclosing CallNode (render), so this is handled in `on_call_node_enter`
|
|
11
|
+
# rather than `on_string_node_enter`.
|
|
12
|
+
class Completion
|
|
13
|
+
include Requests::Support::Common
|
|
14
|
+
|
|
15
|
+
# uri is part of the fixed listener signature but unused: candidates do not depend on the
|
|
16
|
+
# current file's directory (always path form).
|
|
17
|
+
def initialize(response_builder, node_context, resolver, _uri, dispatcher)
|
|
18
|
+
@response_builder = response_builder
|
|
19
|
+
@node_context = node_context
|
|
20
|
+
@resolver = resolver
|
|
21
|
+
|
|
22
|
+
dispatcher.register(self, :on_call_node_enter)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# :nodoc:
|
|
26
|
+
def on_call_node_enter(node)
|
|
27
|
+
return unless Util.render_call?(node)
|
|
28
|
+
|
|
29
|
+
string_node = Util.partial_string_argument(node)
|
|
30
|
+
return unless string_node
|
|
31
|
+
|
|
32
|
+
edit_range = content_range(string_node)
|
|
33
|
+
prefix = string_node.unescaped
|
|
34
|
+
|
|
35
|
+
@resolver.completion_candidates.each do |key, path|
|
|
36
|
+
# label, new_text, and the implicit filter_text (defaults to label) are all the path-form
|
|
37
|
+
# key, so the item survives the client-side filter regardless of how the user typed the
|
|
38
|
+
# reference. Mirrors ruby-lsp's require completion (build_completion).
|
|
39
|
+
next unless key.start_with?(prefix)
|
|
40
|
+
|
|
41
|
+
@response_builder << Interface::CompletionItem.new(
|
|
42
|
+
label: key,
|
|
43
|
+
kind: Constant::CompletionItemKind::FILE,
|
|
44
|
+
text_edit: Interface::TextEdit.new(range: edit_range, new_text: key),
|
|
45
|
+
detail: relative_detail(path),
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Range pointing at the inside of the string node's quotes. Falls back to the whole node if
|
|
53
|
+
# content_loc is absent.
|
|
54
|
+
def content_range(node)
|
|
55
|
+
location = node.content_loc || node.location
|
|
56
|
+
range_from_location(location)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# :nodoc:
|
|
60
|
+
def relative_detail(path)
|
|
61
|
+
return unless path
|
|
62
|
+
|
|
63
|
+
Util.relative_to_views(path)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLsp
|
|
4
|
+
module RailsPartial
|
|
5
|
+
# When the cursor is on the partial name string of a render call, provides go-to-definition
|
|
6
|
+
# to the matching partial file. When multiple formats match, returns all Locations and lets
|
|
7
|
+
# ruby-lsp present the candidate picker.
|
|
8
|
+
class Definition
|
|
9
|
+
include Requests::Support::Common
|
|
10
|
+
|
|
11
|
+
def initialize(response_builder, uri, node_context, resolver, dispatcher)
|
|
12
|
+
@response_builder = response_builder
|
|
13
|
+
@uri = uri
|
|
14
|
+
@node_context = node_context
|
|
15
|
+
@resolver = resolver
|
|
16
|
+
|
|
17
|
+
dispatcher.register(self, :on_string_node_enter)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# :nodoc:
|
|
21
|
+
def on_string_node_enter(node)
|
|
22
|
+
return unless render_partial_argument?(node)
|
|
23
|
+
|
|
24
|
+
reference = node.unescaped
|
|
25
|
+
return if reference.empty?
|
|
26
|
+
|
|
27
|
+
# Highlight range of the origin. Pointing at the whole partial name (inside the quotes)
|
|
28
|
+
# makes the entire string a link even for names containing `/` like `admin/areas/form`.
|
|
29
|
+
origin_range = range_from_location(node.content_loc || node.location)
|
|
30
|
+
|
|
31
|
+
# The partial body is not parsed, so a leading zero-width range is reused as the target for
|
|
32
|
+
# all candidates. target_selection_range must be contained within target_range, so both are
|
|
33
|
+
# made identical.
|
|
34
|
+
target_range = Interface::Range.new(
|
|
35
|
+
start: Interface::Position.new(line: 0, character: 0),
|
|
36
|
+
end: Interface::Position.new(line: 0, character: 0),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@resolver.resolve(reference, @uri.to_s).each do |path|
|
|
40
|
+
@response_builder << Interface::LocationLink.new(
|
|
41
|
+
target_uri: URI::Generic.from_path(path: path).to_s,
|
|
42
|
+
target_range: target_range,
|
|
43
|
+
target_selection_range: target_range,
|
|
44
|
+
origin_selection_range: origin_range,
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Whether this string node is the render call's partial argument (the first positional
|
|
52
|
+
# argument or the value of `partial:`). Decided by whether the StringNode under the cursor
|
|
53
|
+
# is identical to that render call's partial argument node.
|
|
54
|
+
def render_partial_argument?(node)
|
|
55
|
+
call_node = @node_context.call_node
|
|
56
|
+
return false unless call_node
|
|
57
|
+
return false unless Util.render_call?(call_node)
|
|
58
|
+
|
|
59
|
+
Util.partial_string_argument(call_node).equal?(node)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLsp
|
|
4
|
+
module RailsPartial
|
|
5
|
+
# When the cursor is on the partial name string of a render call, shows the list of resolved
|
|
6
|
+
# file paths in a tooltip (Markdown).
|
|
7
|
+
#
|
|
8
|
+
# NOTE: A hover request does not pass a uri to the listener (unlike definition/completion).
|
|
9
|
+
# A bare name `render 'form'` needs a uri to infer the current directory, so it is out of
|
|
10
|
+
# scope; only slash-qualified names like `render 'admin/areas/form'` are resolved.
|
|
11
|
+
class Hover
|
|
12
|
+
def initialize(response_builder, node_context, resolver, dispatcher)
|
|
13
|
+
@response_builder = response_builder
|
|
14
|
+
@node_context = node_context
|
|
15
|
+
@resolver = resolver
|
|
16
|
+
|
|
17
|
+
dispatcher.register(self, :on_string_node_enter)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# :nodoc:
|
|
21
|
+
def on_string_node_enter(node)
|
|
22
|
+
return unless render_partial_argument?(node)
|
|
23
|
+
|
|
24
|
+
reference = node.unescaped
|
|
25
|
+
return if reference.empty?
|
|
26
|
+
|
|
27
|
+
paths = @resolver.resolve_without_context(reference)
|
|
28
|
+
return if paths.empty?
|
|
29
|
+
|
|
30
|
+
@response_builder.push("**Rails partial: `#{reference}`**", category: :title)
|
|
31
|
+
@response_builder.push(links_markdown(paths), category: :links)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Renders each resolved file as a Markdown bullet link with its view-root-relative path.
|
|
37
|
+
def links_markdown(given_paths)
|
|
38
|
+
paths = given_paths.sort
|
|
39
|
+
paths.map { |path| link_line(path) }.join("\n")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# :nodoc:
|
|
43
|
+
def link_line(path)
|
|
44
|
+
uri = URI::Generic.from_path(path: path).to_s
|
|
45
|
+
"- [#{Util.relative_to_views(path)}](#{uri})"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Whether this string node is the render call's partial argument.
|
|
49
|
+
# This duplicates Definition's logic, but it is intentionally re-stated here so that the
|
|
50
|
+
# behavior can be followed within a single file (explicit duplication over implicit
|
|
51
|
+
# abstraction).
|
|
52
|
+
def render_partial_argument?(node)
|
|
53
|
+
call_node = @node_context.call_node
|
|
54
|
+
return false unless call_node
|
|
55
|
+
return false unless Util.render_call?(call_node)
|
|
56
|
+
|
|
57
|
+
Util.partial_string_argument(call_node).equal?(node)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLsp
|
|
4
|
+
module RailsPartial
|
|
5
|
+
# In-memory index of partials (`_*.*`) under the view root.
|
|
6
|
+
#
|
|
7
|
+
# ## Key normalization
|
|
8
|
+
# The path relative to the view root is normalized to "directory + basename without the
|
|
9
|
+
# leading underscore". All qualifiers that follow the partial name (format, handler, locale,
|
|
10
|
+
# variant) are dropped.
|
|
11
|
+
# app/views/admin/areas/_form.html.erb -> 'admin/areas/form'
|
|
12
|
+
# app/views/admin/areas/_form.en.html.erb -> 'admin/areas/form' (locale variant shares the key)
|
|
13
|
+
# app/views/admin/areas/_form.html+phone.erb -> 'admin/areas/form' (variant shares the key)
|
|
14
|
+
# Since multiple formats can map to the same key, the value is always an array of file paths.
|
|
15
|
+
#
|
|
16
|
+
# The index is updated incrementally via file-watching events rather than re-globbing on every
|
|
17
|
+
# request (performance requirement).
|
|
18
|
+
class PartialIndex
|
|
19
|
+
def initialize(views_path)
|
|
20
|
+
@views_path = views_path
|
|
21
|
+
# normalized key => Array[absolute path]
|
|
22
|
+
@entries = Hash.new { |hash, key| hash[key] = [] }
|
|
23
|
+
build
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns the array of partial file paths for the normalized key, or an empty array if none.
|
|
27
|
+
def lookup(key)
|
|
28
|
+
@entries.fetch(key, [])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# :nodoc:
|
|
32
|
+
def all_entries
|
|
33
|
+
@entries
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Incremental update from a file-watching event. Paths that are not partials are ignored.
|
|
37
|
+
def add(absolute_path)
|
|
38
|
+
key = key_for(absolute_path)
|
|
39
|
+
return unless key
|
|
40
|
+
|
|
41
|
+
paths = @entries[key]
|
|
42
|
+
paths << absolute_path unless paths.include?(absolute_path)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Opposite of +add+
|
|
46
|
+
def remove(absolute_path)
|
|
47
|
+
key = key_for(absolute_path)
|
|
48
|
+
return unless key
|
|
49
|
+
|
|
50
|
+
paths = @entries[key]
|
|
51
|
+
paths.delete(absolute_path)
|
|
52
|
+
@entries.delete(key) if paths.empty?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Builds the list
|
|
58
|
+
def build
|
|
59
|
+
Dir.glob(File.join(@views_path, '**', '_*.*')).each { |path| add(path) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Converts an absolute path to a normalized key. Returns nil unless it is a partial
|
|
63
|
+
# (basename starts with `_`).
|
|
64
|
+
def key_for(absolute_path)
|
|
65
|
+
relative = relative_from_views(absolute_path)
|
|
66
|
+
return unless relative
|
|
67
|
+
|
|
68
|
+
dir = File.dirname(relative)
|
|
69
|
+
basename = File.basename(relative)
|
|
70
|
+
return unless basename.start_with?('_')
|
|
71
|
+
|
|
72
|
+
# Strip the leading `_` and drop everything after the first `.`
|
|
73
|
+
# (format/handler/locale/variant).
|
|
74
|
+
name = basename[1..].split('.').first
|
|
75
|
+
return unless name
|
|
76
|
+
|
|
77
|
+
(dir == '.') ? name : "#{dir}/#{name}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# :nodoc:
|
|
81
|
+
def relative_from_views(absolute_path)
|
|
82
|
+
prefix = "#{@views_path}/"
|
|
83
|
+
return unless absolute_path.start_with?(prefix)
|
|
84
|
+
|
|
85
|
+
absolute_path[(prefix.length)..]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLsp
|
|
4
|
+
module RailsPartial
|
|
5
|
+
# Resolves the partial reference string of a `render` call to a PartialIndex key.
|
|
6
|
+
# Because resolution depends on the reference form and on the context of "which file the
|
|
7
|
+
# render is called from", this class is responsible only for bridging to the index's
|
|
8
|
+
# normalized key.
|
|
9
|
+
class PartialResolver
|
|
10
|
+
def initialize(index, views_path)
|
|
11
|
+
@index = index
|
|
12
|
+
@views_path = views_path
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Resolves a partial reference string ("foo" / "dir/foo") in the context of the current file
|
|
16
|
+
# and returns the array of absolute paths of the matching partial files.
|
|
17
|
+
def resolve(reference, current_uri)
|
|
18
|
+
key = key_for(reference, current_uri)
|
|
19
|
+
return [] unless key
|
|
20
|
+
|
|
21
|
+
@index.lookup(key)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# For contexts without a uri (hover). Only resolves references that contain a slash.
|
|
25
|
+
# Bare names are out of scope (they need a uri for directory inference) and return [].
|
|
26
|
+
def resolve_without_context(reference)
|
|
27
|
+
return [] unless reference.include?('/')
|
|
28
|
+
|
|
29
|
+
@index.lookup(reference)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns completion candidates as an array of [key (path form), representative path].
|
|
33
|
+
# The key is always the normalized path form so that the display label, filter text, and
|
|
34
|
+
# inserted text all match, which keeps the item past the client-side filter regardless of
|
|
35
|
+
# how the user typed the reference.
|
|
36
|
+
def completion_candidates
|
|
37
|
+
@index.all_entries.map { |key, paths| [key, paths.first] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Converts a reference string to a normalized key.
|
|
43
|
+
# A slash means root-relative; otherwise it is relative to the current file's directory.
|
|
44
|
+
def key_for(reference, current_uri)
|
|
45
|
+
return if reference.empty?
|
|
46
|
+
|
|
47
|
+
if reference.include?('/')
|
|
48
|
+
reference
|
|
49
|
+
else
|
|
50
|
+
dir = current_dir(current_uri)
|
|
51
|
+
dir ? "#{dir}/#{reference}" : reference
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Derives the view-root-relative directory from the current file's URI.
|
|
56
|
+
# A view (under the view root) uses its own directory; a controller derives it from the
|
|
57
|
+
# controller name.
|
|
58
|
+
def current_dir(current_uri)
|
|
59
|
+
path = Util.uri_to_path(current_uri)
|
|
60
|
+
return unless path
|
|
61
|
+
|
|
62
|
+
if path.start_with?("#{@views_path}/")
|
|
63
|
+
relative = path[(@views_path.length + 1)..]
|
|
64
|
+
dir = File.dirname(relative)
|
|
65
|
+
(dir == '.') ? nil : dir
|
|
66
|
+
elsif path.include?('/app/controllers/')
|
|
67
|
+
controller_dir_from(path)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Derives the view prefix from a controller file path.
|
|
72
|
+
# app/controllers/admin/areas_controller.rb -> "admin/areas"
|
|
73
|
+
def controller_dir_from(path)
|
|
74
|
+
relative = path.split('/app/controllers/', 2).last
|
|
75
|
+
return unless relative
|
|
76
|
+
|
|
77
|
+
dir = relative.delete_suffix('_controller.rb')
|
|
78
|
+
dir.empty? ? nil : dir
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module RubyLsp
|
|
6
|
+
module RailsPartial
|
|
7
|
+
# AST inspection / path conversion helpers. Shared by the Definition / Completion / Hover
|
|
8
|
+
# listeners and by Addon / PartialResolver.
|
|
9
|
+
module Util
|
|
10
|
+
RENDER_METHODS = %i[render render_to_string].freeze
|
|
11
|
+
VIEWS_MARKER = '/app/views/'
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Converts a file:// URI to a filesystem path. Non-file:// URIs are returned as-is.
|
|
15
|
+
def uri_to_path(uri)
|
|
16
|
+
return uri unless uri.start_with?('file://')
|
|
17
|
+
|
|
18
|
+
URI.parse(uri).path
|
|
19
|
+
rescue URI::InvalidURIError
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Returns the path relative to the app/views root. Falls back to the basename if the path
|
|
24
|
+
# is not under views.
|
|
25
|
+
def relative_to_views(path)
|
|
26
|
+
index = path.index(VIEWS_MARKER)
|
|
27
|
+
index ? path[(index + VIEWS_MARKER.length)..] : File.basename(path)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Whether call_node is a render call with no receiver (or self).
|
|
31
|
+
def render_call?(call_node)
|
|
32
|
+
return false unless RENDER_METHODS.include?(call_node.message&.to_sym)
|
|
33
|
+
|
|
34
|
+
receiver = call_node.receiver
|
|
35
|
+
receiver.nil? || receiver.is_a?(Prism::SelfNode)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns the string node of the render call's partial argument.
|
|
39
|
+
# The target is (1) the first positional argument, or (2) the value of the `partial:`
|
|
40
|
+
# keyword. Returns nil if neither is present.
|
|
41
|
+
def partial_string_argument(call_node)
|
|
42
|
+
arguments = call_node.arguments&.arguments
|
|
43
|
+
return unless arguments&.any?
|
|
44
|
+
|
|
45
|
+
first = arguments.first
|
|
46
|
+
return first if first.is_a?(Prism::StringNode)
|
|
47
|
+
|
|
48
|
+
found_via_keyword = partial_keyword_value(arguments)
|
|
49
|
+
return found_via_keyword if found_via_keyword.is_a?(Prism::StringNode)
|
|
50
|
+
|
|
51
|
+
partial_hash_value(arguments)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Returns the string node for the `partial:` keyword.
|
|
57
|
+
def partial_keyword_value(arguments)
|
|
58
|
+
keyword_node = arguments.find { |arg| arg.is_a?(Prism::KeywordHashNode) }
|
|
59
|
+
return if keyword_node.nil?
|
|
60
|
+
|
|
61
|
+
assoc = keyword_node.elements.find do |element|
|
|
62
|
+
element.is_a?(Prism::AssocNode) &&
|
|
63
|
+
partial_key?(element.key) &&
|
|
64
|
+
element.value.is_a?(Prism::StringNode)
|
|
65
|
+
end
|
|
66
|
+
assoc&.value
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Returns the string node for the `partial:` keyword.
|
|
70
|
+
def partial_hash_value(arguments)
|
|
71
|
+
hash_node = arguments.find { |arg| arg.is_a?(Prism::HashNode) }
|
|
72
|
+
return if hash_node.nil?
|
|
73
|
+
|
|
74
|
+
assoc = hash_node.elements.find do |element|
|
|
75
|
+
element.is_a?(Prism::AssocNode) &&
|
|
76
|
+
partial_key?(element.key) &&
|
|
77
|
+
element.value.is_a?(Prism::StringNode)
|
|
78
|
+
end
|
|
79
|
+
assoc&.value
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# :nodoc:
|
|
83
|
+
def partial_key?(key)
|
|
84
|
+
key.is_a?(Prism::SymbolNode) && key.unescaped == 'partial'
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'definition'
|
|
4
|
+
require_relative 'util'
|
|
5
|
+
require_relative 'version'
|
|
6
|
+
# NOTE: server_addon.rb is intentionally NOT required here. Its base class
|
|
7
|
+
# RubyLsp::Rails::ServerAddon only exists in the rails runner process, which loads the file via
|
|
8
|
+
# register_server_addon. Requiring it here would fail to load this add-on in the LSP process.
|
|
9
|
+
|
|
10
|
+
module RubyLsp
|
|
11
|
+
module RailsRoutes
|
|
12
|
+
# Ruby LSP add-on that adds go-to-definition from *_path / *_url route helpers to the
|
|
13
|
+
# controller action the named route maps to. Works alongside ruby-lsp-rails, whose own
|
|
14
|
+
# Definition listener contributes the routes.rb declaration; this add-on only adds the action
|
|
15
|
+
# location as an extra candidate.
|
|
16
|
+
class Addon < ::RubyLsp::Addon
|
|
17
|
+
SERVER_ADDON_NAME = 'Rails Routes'
|
|
18
|
+
|
|
19
|
+
# @see super
|
|
20
|
+
def activate(_global_state, _outgoing_queue)
|
|
21
|
+
@server_addon_registered = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @see super
|
|
25
|
+
def deactivate
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @see super
|
|
29
|
+
def name
|
|
30
|
+
'Rails Routes'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @see super
|
|
34
|
+
def version
|
|
35
|
+
VERSION
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Invoked on every definition request. The ruby-lsp-rails client is resolved lazily here
|
|
39
|
+
# (not in #activate) because add-on activation order is not guaranteed, and because the
|
|
40
|
+
# runner client boots asynchronously - it is a NullClient until ready.
|
|
41
|
+
def create_definition_listener(response_builder, _uri, node_context, dispatcher)
|
|
42
|
+
client = rails_runner_client
|
|
43
|
+
return unless client
|
|
44
|
+
|
|
45
|
+
ensure_server_addon_registered(client)
|
|
46
|
+
|
|
47
|
+
Definition.new(client, SERVER_ADDON_NAME, response_builder, node_context, dispatcher)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Returns the ruby-lsp-rails RunnerClient, or nil if the add-on is unavailable/incompatible.
|
|
53
|
+
def rails_runner_client
|
|
54
|
+
::RubyLsp::Addon.get('Ruby LSP Rails', '>= 0.4.0').rails_runner_client
|
|
55
|
+
rescue ::RubyLsp::Addon::AddonNotFoundError, ::RubyLsp::Addon::IncompatibleApiError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Registers our server add-on exactly once. register_server_addon is a no-op on NullClient,
|
|
60
|
+
# so registering before the runner finishes booting would silently be lost. We therefore
|
|
61
|
+
# only register once the client is connected, guarded by an idempotency flag.
|
|
62
|
+
def ensure_server_addon_registered(client)
|
|
63
|
+
return if @server_addon_registered
|
|
64
|
+
|
|
65
|
+
# false for NullClient
|
|
66
|
+
return unless client.connected?
|
|
67
|
+
|
|
68
|
+
client.register_server_addon(File.expand_path('server_addon.rb', __dir__))
|
|
69
|
+
@server_addon_registered = true
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLsp
|
|
4
|
+
module RailsRoutes
|
|
5
|
+
# When the cursor is on a *_path / *_url route helper call, asks the rails runner process to
|
|
6
|
+
# resolve the named route to its controller action, and adds that action's source location as
|
|
7
|
+
# a definition candidate. ruby-lsp-rails' own Definition listener still contributes the
|
|
8
|
+
# routes.rb declaration to the same response builder, so both candidates coexist.
|
|
9
|
+
class Definition
|
|
10
|
+
include ::RubyLsp::Requests::Support::Common
|
|
11
|
+
|
|
12
|
+
def initialize(client, server_addon_name, response_builder, node_context, dispatcher)
|
|
13
|
+
@client = client
|
|
14
|
+
@server_addon_name = server_addon_name
|
|
15
|
+
@response_builder = response_builder
|
|
16
|
+
@node_context = node_context
|
|
17
|
+
|
|
18
|
+
dispatcher.register(self, :on_call_node_enter)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Mirrors ruby-lsp-rails' own detection: self (or implicit) receiver, message ends with
|
|
22
|
+
# _path / _url.
|
|
23
|
+
def on_call_node_enter(node)
|
|
24
|
+
return unless self_receiver?(node)
|
|
25
|
+
|
|
26
|
+
message = node.message
|
|
27
|
+
return unless message
|
|
28
|
+
return unless message.end_with?('_path', '_url')
|
|
29
|
+
|
|
30
|
+
result = @client.delegate_request(
|
|
31
|
+
server_addon_name: @server_addon_name,
|
|
32
|
+
request_name: 'action_location',
|
|
33
|
+
name: message,
|
|
34
|
+
)
|
|
35
|
+
return unless result
|
|
36
|
+
return unless result[:location]
|
|
37
|
+
|
|
38
|
+
@response_builder << Util.location_from_s(result[:location])
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This file is loaded inside the rails runner process via RunnerClient#register_server_addon.
|
|
4
|
+
# The base class RubyLsp::Rails::ServerAddon only exists in that process, so this file must never
|
|
5
|
+
# be required from the LSP process (do not require_relative it from addon.rb).
|
|
6
|
+
module RubyLsp
|
|
7
|
+
module RailsRoutes
|
|
8
|
+
# Runs inside the rails runner process. Resolves a named route to its controller action's
|
|
9
|
+
# source location by inspecting the application's routes, and returns it to the LSP process.
|
|
10
|
+
class ServerAddon < ::RubyLsp::Rails::ServerAddon
|
|
11
|
+
# @see super
|
|
12
|
+
def name
|
|
13
|
+
'Rails Routes'
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @see super
|
|
17
|
+
def execute(request, params)
|
|
18
|
+
case request
|
|
19
|
+
when 'action_location'
|
|
20
|
+
with_request_error_handling(request) do
|
|
21
|
+
send_result(action_location(params[:name]))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# Resolves a route helper name (e.g. 'users_path') to its controller action's source
|
|
29
|
+
# location, returned as { location: 'file:line' }. Returns nil when the route does not map
|
|
30
|
+
# to a resolvable controller action. Mirrors ruby-lsp-rails Server#route_location for the
|
|
31
|
+
# route lookup, then continues on to the action method's source_location.
|
|
32
|
+
def action_location(name)
|
|
33
|
+
# Rails 8 does not populate named_routes until the routes are loaded.
|
|
34
|
+
if ::Rails.application.respond_to?(:reload_routes_unless_loaded)
|
|
35
|
+
::Rails.application.reload_routes_unless_loaded
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
match_data = name.match(/^(.+)(_path|_url)$/)
|
|
39
|
+
return unless match_data
|
|
40
|
+
|
|
41
|
+
route = ::Rails.application.routes.named_routes.get(match_data[1])
|
|
42
|
+
return unless route
|
|
43
|
+
|
|
44
|
+
controller = route.defaults[:controller]
|
|
45
|
+
action = route.defaults[:action]
|
|
46
|
+
return unless controller
|
|
47
|
+
return unless action
|
|
48
|
+
|
|
49
|
+
# camelize also converts a namespaced controller ('members/x') into 'Members::X'.
|
|
50
|
+
controller_class = "#{controller}_controller".camelize.constantize
|
|
51
|
+
# MVP assumes the action has an explicit `def` in the controller. instance_method may
|
|
52
|
+
# resolve to an inherited method; refining this is deferred to a later phase.
|
|
53
|
+
source_location = controller_class.instance_method(action).source_location
|
|
54
|
+
return unless source_location
|
|
55
|
+
|
|
56
|
+
file, line = source_location
|
|
57
|
+
{ location: "#{file}:#{line}" }
|
|
58
|
+
rescue NameError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module RubyLsp
|
|
6
|
+
module RailsRoutes
|
|
7
|
+
# Helpers shared by the LSP-side listeners. Kept dependency-light so they can be unit tested
|
|
8
|
+
# without booting Ruby LSP.
|
|
9
|
+
module Util
|
|
10
|
+
class << self
|
|
11
|
+
# Converts a 'path:line' string (1-based line, as returned by Method#source_location) into
|
|
12
|
+
# an Interface::Location (0-based line). The last segment is treated as the line only when
|
|
13
|
+
# it is numeric, so Windows drive letters ("C:/foo.rb:42") survive the split and a string
|
|
14
|
+
# without a line suffix falls back to line 0.
|
|
15
|
+
def location_from_s(location_string)
|
|
16
|
+
file_path, _, line = location_string.rpartition(':')
|
|
17
|
+
if file_path.empty? || line !~ /\A\d+\z/
|
|
18
|
+
file_path = location_string
|
|
19
|
+
line = nil
|
|
20
|
+
end
|
|
21
|
+
line_as_number = line ? line.to_i - 1 : 0
|
|
22
|
+
|
|
23
|
+
::RubyLsp::Interface::Location.new(
|
|
24
|
+
uri: URI::Generic.from_path(path: file_path).to_s,
|
|
25
|
+
range: ::RubyLsp::Interface::Range.new(
|
|
26
|
+
start: ::RubyLsp::Interface::Position.new(line: line_as_number, character: 0),
|
|
27
|
+
end: ::RubyLsp::Interface::Position.new(line: line_as_number, character: 0),
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|