Skip to content

Commit 787e137

Browse files
committed
feat: ruby_lsp addon (experimental)
1 parent 0c763ea commit 787e137

File tree

1 file changed

+170
-0
lines changed

1 file changed

+170
-0
lines changed
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# frozen_string_literal: true
2+
3+
# From https://gist.github.com/skryukov/35539d57b51f38235faaace2c1a2c1a1
4+
5+
require "active_support/inflector"
6+
7+
module RubyLsp
8+
module ActionPolicy
9+
class Addon < ::RubyLsp::Addon
10+
def name
11+
"ActionPolicy"
12+
end
13+
14+
def activate(global_state, outgoing_queue)
15+
require "action_policy"
16+
warn "[ActionPolicy] Activating Ruby LSP addon v#{::ActionPolicy::VERSION}"
17+
end
18+
19+
def deactivate
20+
end
21+
22+
def create_definition_listener(response_builder, node_context, uri, dispatcher)
23+
Definition.new(response_builder, node_context, uri, dispatcher)
24+
end
25+
end
26+
27+
class Definition
28+
include Requests::Support::Common
29+
include ActiveSupport::Inflector
30+
31+
POLICY_SUPERCLASSES = ["ApplicationPolicy", "ActionPolicy::Base"].freeze
32+
33+
def initialize(response_builder, uri, node_context, dispatcher)
34+
@response_builder = response_builder
35+
@node_context = node_context
36+
@uri = uri
37+
@path = uri.to_standardized_path
38+
@policy_rules_cache = {}
39+
40+
dispatcher.register(self, :on_symbol_node_enter)
41+
end
42+
43+
def on_symbol_node_enter(node)
44+
return unless in_authorize_call?
45+
46+
target = @node_context.call_node
47+
# authorization target is the first argument (if explicit)
48+
policy_class = find_policy_class(target.arguments&.child_node&.first)
49+
return unless policy_class
50+
51+
policy_path = find_policy_file(policy_class)
52+
return unless policy_path
53+
54+
ensure_policy_rules_cached(policy_path)
55+
add_definition(policy_path, node.value)
56+
end
57+
58+
private
59+
60+
def in_authorize_call?
61+
call = @node_context.call_node
62+
call.is_a?(Prism::CallNode) && call.message == "authorize!"
63+
end
64+
65+
def find_policy_class(target)
66+
content = File.read(@path)
67+
document = Prism.parse(content)
68+
class_node = find_containing_class(document.value)
69+
return derive_policy_from_target(target) unless class_node
70+
71+
class_name = class_node.constant_path.slice
72+
return unless class_name.end_with?("Controller", "Channel")
73+
74+
resource_name = class_name
75+
.delete_suffix("Controller")
76+
.delete_suffix("Channel")
77+
.singularize
78+
"#{resource_name}Policy"
79+
end
80+
81+
def find_containing_class(root)
82+
return unless root.respond_to?(:statements)
83+
84+
root.statements.body.find do |node|
85+
node.is_a?(Prism::ClassNode) &&
86+
node.constant_path.slice.end_with?("Controller", "Channel")
87+
end
88+
end
89+
90+
def derive_policy_from_target(target)
91+
target_name = case target
92+
when Prism::InstanceVariableReadNode
93+
target.name.to_s[1..].classify
94+
when Prism::ConstantReadNode
95+
target.name.to_s
96+
when NilClass
97+
return
98+
else
99+
target.slice
100+
end
101+
102+
target_name.end_with?("Policy") ? target_name : "#{target_name}Policy"
103+
end
104+
105+
def find_policy_file(policy_class)
106+
file_path = policy_class.gsub(/([a-z])([A-Z])/, "\\1_\\2").downcase
107+
root_path = Dir.pwd
108+
109+
[
110+
File.join(root_path, "app/policies/#{file_path}.rb"),
111+
File.join(root_path, "app/policies/#{file_path}_policy.rb"),
112+
*Dir.glob(File.join(root_path, "app/policies/**/#{file_path}.rb")),
113+
*Dir.glob(File.join(root_path, "app/policies/**/#{file_path}_policy.rb"))
114+
].find { |path| File.exist?(path) }
115+
end
116+
117+
def ensure_policy_rules_cached(policy_path)
118+
return if @policy_rules_cache[policy_path]
119+
120+
content = File.read(policy_path)
121+
document = Prism.parse(content)
122+
123+
document.value.statements.body.each do |stmt|
124+
if stmt.is_a?(Prism::ClassNode)
125+
@policy_rules_cache[policy_path] = extract_rules(stmt)
126+
break
127+
end
128+
end
129+
end
130+
131+
def extract_rules(node)
132+
return {} unless node.body
133+
134+
rules = {}
135+
private_section = false
136+
137+
node.body.child_nodes.each do |stmt|
138+
case stmt
139+
when Prism::CallNode
140+
if stmt.message == "private"
141+
private_section = true
142+
elsif stmt.message == "alias_rule" && stmt.arguments&.arguments
143+
stmt.arguments.arguments
144+
.select { |arg| arg.is_a?(Prism::SymbolNode) }
145+
.each { |arg| rules[arg.value] ||= stmt.location.start_line }
146+
end
147+
when Prism::DefNode
148+
next if private_section
149+
rules[stmt.name.to_s] = stmt.location.start_line
150+
end
151+
end
152+
rules
153+
end
154+
155+
def add_definition(policy_path, action)
156+
rules = @policy_rules_cache[policy_path]
157+
line_number = rules&.[](action.to_s)
158+
return unless line_number
159+
160+
@response_builder << Interface::Location.new(
161+
uri: URI::Generic.from_path(path: policy_path).to_s,
162+
range: Interface::Range.new(
163+
start: Interface::Position.new(line: line_number - 1, character: 0),
164+
end: Interface::Position.new(line: line_number - 1, character: 0)
165+
)
166+
)
167+
end
168+
end
169+
end
170+
end

0 commit comments

Comments
 (0)