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.
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLsp
4
+ module RailsPartial
5
+ VERSION = '0.1.0'
6
+ end
7
+ 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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLsp
4
+ module RailsRoutes
5
+ VERSION = '0.1.0'
6
+ end
7
+ end