|
| 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