ruby-lsp-rails 0.3.5 → 0.3.6

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 175aa77410d6d479169ab7c5c212d12d56ec2c6624d7766bd8e1575da706d7a9
4
- data.tar.gz: 76799c42df0346b1628479e61c86914a7f52b96c656ddf39975bed904e13e459
3
+ metadata.gz: 65c6258c3267f6d1d080e056f4561d61cf8f9bb93059dec1c0b70086fa0ce540
4
+ data.tar.gz: 536aac52752c432fc3c18d76c66151c099533849abc8968aafe832524e25303b
5
5
  SHA512:
6
- metadata.gz: 59a4059ffdeb381dd3b8c4858e91b64a624df5e424807540b99aa6182f908f944cc6ea0ddf677776eb2d4680f8bc7c7944883fea2eb346da8252125eedd2c788
7
- data.tar.gz: 8bdc777be1f0663cc6d7e2a450b42ceef091f5e75769cf74570ce536bc914de2c2b559cbdcd05989b55d3fc6a58a843f637b2caaf1ec165a97b4c3d0b2c7db11
6
+ metadata.gz: 3b527faec645912d5c49aef3d4d2bb094a789410283ddcebcc9be168f703807831a5a5f2561a88dcecf5c4e3d894c0d2ccf9fe617688f09462e88ab7ae1a2ee8
7
+ data.tar.gz: b080811bd5697ae68573df2fd6d922dec47e2e16b5264cd86b707f73cab9689fffa858851da2ebed7a819930fbc148234db4de5c4a15dc2e4fffbec3e4fdfbcb
data/README.md CHANGED
@@ -6,6 +6,7 @@ Ruby LSP Rails is a [Ruby LSP](https://github.com/Shopify/ruby-lsp) addon for ex
6
6
  * Run or debug a test by clicking on the code lens which appears above the test class, or an individual test.
7
7
  * Navigate to associations, validations, callbacks and test cases using your editor's "Go to Symbol" feature, or outline view.
8
8
  * Jump to the definition of callbacks using your editor's "Go to Definition" feature.
9
+ * Jump to the declaration of a route.
9
10
 
10
11
  ## Installation
11
12
 
@@ -36,9 +37,7 @@ When extension is stopped (e.g. by quitting the editor), the server instance is
36
37
 
37
38
  ## Contributing
38
39
 
39
- Bug reports and pull requests are welcome on GitHub at https://github.com/Shopify/ruby-lsp-rails. This project is
40
- intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the
41
- [Contributor Covenant](https://github.com/Shopify/ruby-lsp-rails/blob/main/CODE_OF_CONDUCT.md) code of conduct.
40
+ See [CONTRIBUTING.md](https://github.com/Shopify/ruby-lsp-rails/blob/main/CONTRIBUTING.md)
42
41
 
43
42
  ## License
44
43
 
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "ruby_lsp/addon"
5
5
 
6
+ require_relative "../../ruby_lsp_rails/version"
6
7
  require_relative "support/active_support_test_case_helper"
7
8
  require_relative "support/callbacks"
8
9
  require_relative "runner_client"
@@ -31,6 +32,7 @@ module RubyLsp
31
32
  $stderr.puts("Activating Ruby LSP Rails addon v#{VERSION}")
32
33
  # Start booting the real client in a background thread. Until this completes, the client will be a NullClient
33
34
  Thread.new { @client = RunnerClient.create_client }
35
+ register_additional_file_watchers(global_state: global_state, message_queue: message_queue)
34
36
  end
35
37
 
36
38
  sig { override.void }
@@ -83,7 +85,7 @@ module RubyLsp
83
85
  end
84
86
  def create_definition_listener(response_builder, uri, nesting, dispatcher)
85
87
  index = T.must(@global_state).index
86
- Definition.new(response_builder, nesting, index, dispatcher)
88
+ Definition.new(@client, response_builder, nesting, index, dispatcher)
87
89
  end
88
90
 
89
91
  sig { params(changes: T::Array[{ uri: String, type: Integer }]).void }
@@ -95,6 +97,32 @@ module RubyLsp
95
97
  end
96
98
  end
97
99
 
100
+ sig { params(global_state: GlobalState, message_queue: Thread::Queue).void }
101
+ def register_additional_file_watchers(global_state:, message_queue:)
102
+ return unless global_state.supports_watching_files
103
+
104
+ message_queue << Request.new(
105
+ id: "ruby-lsp-rails-file-watcher",
106
+ method: "client/registerCapability",
107
+ params: Interface::RegistrationParams.new(
108
+ registrations: [
109
+ Interface::Registration.new(
110
+ id: "workspace/didChangeWatchedFilesRails",
111
+ method: "workspace/didChangeWatchedFiles",
112
+ register_options: Interface::DidChangeWatchedFilesRegistrationOptions.new(
113
+ watchers: [
114
+ Interface::FileSystemWatcher.new(
115
+ glob_pattern: "**/*structure.sql",
116
+ kind: Constant::WatchKind::CREATE | Constant::WatchKind::CHANGE | Constant::WatchKind::DELETE,
117
+ ),
118
+ ],
119
+ ),
120
+ ),
121
+ ],
122
+ ),
123
+ )
124
+ end
125
+
98
126
  sig { override.returns(String) }
99
127
  def name
100
128
  "Ruby LSP Rails"
@@ -42,8 +42,6 @@ module RubyLsp
42
42
  include Requests::Support::Common
43
43
  include ActiveSupportTestCaseHelper
44
44
 
45
- BASE_COMMAND = "bin/rails test"
46
-
47
45
  sig do
48
46
  params(
49
47
  response_builder: ResponseBuilders::CollectionResponseBuilder[Interface::CodeLens],
@@ -67,7 +65,7 @@ module RubyLsp
67
65
  return unless content
68
66
 
69
67
  line_number = node.location.start_line
70
- command = "#{BASE_COMMAND} #{@path}:#{line_number}"
68
+ command = "#{test_command} #{@path}:#{line_number}"
71
69
  add_test_code_lens(node, name: content, command: command, kind: :example)
72
70
  end
73
71
 
@@ -77,7 +75,7 @@ module RubyLsp
77
75
  method_name = node.name.to_s
78
76
  if method_name.start_with?("test_")
79
77
  line_number = node.location.start_line
80
- command = "#{BASE_COMMAND} #{@path}:#{line_number}"
78
+ command = "#{test_command} #{@path}:#{line_number}"
81
79
  add_test_code_lens(node, name: method_name, command: command, kind: :example)
82
80
  end
83
81
  end
@@ -86,7 +84,7 @@ module RubyLsp
86
84
  def on_class_node_enter(node)
87
85
  class_name = node.constant_path.slice
88
86
  if class_name.end_with?("Test")
89
- command = "#{BASE_COMMAND} #{@path}"
87
+ command = "#{test_command} #{@path}"
90
88
  add_test_code_lens(node, name: class_name, command: command, kind: :group)
91
89
  @group_id_stack.push(@group_id)
92
90
  @group_id += 1
@@ -103,6 +101,15 @@ module RubyLsp
103
101
 
104
102
  private
105
103
 
104
+ sig { returns(String) }
105
+ def test_command
106
+ if Gem.win_platform?
107
+ "ruby bin/rails test"
108
+ else
109
+ "bin/rails test"
110
+ end
111
+ end
112
+
106
113
  sig { params(node: Prism::Node, name: String, command: String, kind: Symbol).void }
107
114
  def add_test_code_lens(node, name:, command:, kind:)
108
115
  return unless @path
@@ -11,25 +11,35 @@ module RubyLsp
11
11
  #
12
12
  # Currently supported targets:
13
13
  # - Callbacks
14
+ # - Named routes (e.g. `users_path`)
14
15
  #
15
16
  # # Example
16
17
  #
17
18
  # ```ruby
18
19
  # before_action :foo # <- Go to definition on this symbol will jump to the method if it is defined in the same class
19
20
  # ```
21
+ #
22
+ # Notes for named routes:
23
+ # - It is available only in Rails 7.1 or newer.
24
+ # - Route may be defined across multiple files, e.g. using `draw`, rather than in `routes.rb`.
25
+ # - Routes won't be found if not defined for the Rails development environment.
26
+ # - If using `constraints`, the route can only be found if the constraints are met.
27
+ # - Changes to routes won't be picked up until the server is restarted.
20
28
  class Definition
21
29
  extend T::Sig
22
30
  include Requests::Support::Common
23
31
 
24
32
  sig do
25
33
  params(
34
+ client: RunnerClient,
26
35
  response_builder: ResponseBuilders::CollectionResponseBuilder[Interface::Location],
27
36
  nesting: T::Array[String],
28
37
  index: RubyIndexer::Index,
29
38
  dispatcher: Prism::Dispatcher,
30
39
  ).void
31
40
  end
32
- def initialize(response_builder, nesting, index, dispatcher)
41
+ def initialize(client, response_builder, nesting, index, dispatcher)
42
+ @client = client
33
43
  @response_builder = response_builder
34
44
  @nesting = nesting
35
45
  @index = index
@@ -43,8 +53,19 @@ module RubyLsp
43
53
 
44
54
  message = node.message
45
55
 
46
- return unless message && Support::Callbacks::ALL.include?(message)
56
+ return unless message
57
+
58
+ if Support::Callbacks::ALL.include?(message)
59
+ handle_callback(node)
60
+ elsif message.end_with?("_path") || message.end_with?("_url")
61
+ handle_route(node)
62
+ end
63
+ end
47
64
 
65
+ private
66
+
67
+ sig { params(node: Prism::CallNode).void }
68
+ def handle_callback(node)
48
69
  arguments = node.arguments&.arguments
49
70
  return unless arguments&.any?
50
71
 
@@ -62,7 +83,25 @@ module RubyLsp
62
83
  end
63
84
  end
64
85
 
65
- private
86
+ sig { params(node: Prism::CallNode).void }
87
+ def handle_route(node)
88
+ result = @client.route_location(T.must(node.message))
89
+ return unless result
90
+
91
+ *file_parts, line = result.fetch(:location).split(":")
92
+
93
+ # On Windows, file paths will look something like `C:/path/to/file.rb:123`. Only the last colon is the line
94
+ # number and all other parts compose the file path
95
+ file_path = file_parts.join(":")
96
+
97
+ @response_builder << Interface::Location.new(
98
+ uri: URI::Generic.from_path(path: file_path).to_s,
99
+ range: Interface::Range.new(
100
+ start: Interface::Position.new(line: Integer(line) - 1, character: 0),
101
+ end: Interface::Position.new(line: Integer(line) - 1, character: 0),
102
+ ),
103
+ )
104
+ end
66
105
 
67
106
  sig { params(name: String).void }
68
107
  def collect_definitions(name)
@@ -21,12 +21,22 @@ module RubyLsp
21
21
  end
22
22
  def initialize(response_builder, dispatcher)
23
23
  @response_builder = response_builder
24
-
25
- dispatcher.register(self, :on_call_node_enter)
24
+ @namespace_stack = T.let([], T::Array[String])
25
+
26
+ dispatcher.register(
27
+ self,
28
+ :on_call_node_enter,
29
+ :on_class_node_enter,
30
+ :on_class_node_leave,
31
+ :on_module_node_enter,
32
+ :on_module_node_leave,
33
+ )
26
34
  end
27
35
 
28
36
  sig { params(node: Prism::CallNode).void }
29
37
  def on_call_node_enter(node)
38
+ return if @namespace_stack.empty?
39
+
30
40
  content = extract_test_case_name(node)
31
41
 
32
42
  if content
@@ -44,15 +54,46 @@ module RubyLsp
44
54
  case message
45
55
  when *Support::Callbacks::ALL, "validate"
46
56
  handle_all_arg_types(node, T.must(message))
47
- when "validates", "validates!", "validates_each", "belongs_to", "has_one", "has_many", "has_and_belongs_to_many"
57
+ when "validates", "validates!", "validates_each", "belongs_to", "has_one", "has_many",
58
+ "has_and_belongs_to_many", "attr_readonly", "scope"
48
59
  handle_symbol_and_string_arg_types(node, T.must(message))
49
60
  when "validates_with"
50
61
  handle_class_arg_types(node, T.must(message))
51
62
  end
52
63
  end
53
64
 
65
+ sig { params(node: Prism::ClassNode).void }
66
+ def on_class_node_enter(node)
67
+ add_to_namespace_stack(node)
68
+ end
69
+
70
+ sig { params(node: Prism::ClassNode).void }
71
+ def on_class_node_leave(node)
72
+ remove_from_namespace_stack(node)
73
+ end
74
+
75
+ sig { params(node: Prism::ModuleNode).void }
76
+ def on_module_node_enter(node)
77
+ add_to_namespace_stack(node)
78
+ end
79
+
80
+ sig { params(node: Prism::ModuleNode).void }
81
+ def on_module_node_leave(node)
82
+ remove_from_namespace_stack(node)
83
+ end
84
+
54
85
  private
55
86
 
87
+ sig { params(node: T.any(Prism::ClassNode, Prism::ModuleNode)).void }
88
+ def add_to_namespace_stack(node)
89
+ @namespace_stack << node.constant_path.slice
90
+ end
91
+
92
+ sig { params(node: T.any(Prism::ClassNode, Prism::ModuleNode)).void }
93
+ def remove_from_namespace_stack(node)
94
+ @namespace_stack.delete(node.constant_path.slice)
95
+ end
96
+
56
97
  sig { params(node: Prism::CallNode, message: String).void }
57
98
  def handle_all_arg_types(node, message)
58
99
  block = node.block
@@ -77,13 +77,14 @@ module RubyLsp
77
77
  schema_file = model[:schema_file]
78
78
 
79
79
  @response_builder.push(
80
- "[Schema](#{URI::Generic.build(scheme: "file", path: schema_file)})",
80
+ "[Schema](#{URI::Generic.from_path(path: schema_file)})",
81
81
  category: :links,
82
82
  ) if schema_file
83
83
 
84
84
  @response_builder.push(
85
85
  model[:columns].map do |name, type|
86
- "**#{name}**: #{type}\n"
86
+ primary_key_suffix = " (PK)" if model[:primary_keys].include?(name)
87
+ "**#{name}**: #{type}#{primary_key_suffix}\n"
87
88
  end.join("\n"),
88
89
  category: :documentation,
89
90
  )
@@ -46,14 +46,15 @@ module RubyLsp
46
46
  Process.setsid
47
47
  rescue Errno::EPERM
48
48
  # If we can't set the session ID, continue
49
+ rescue NotImplementedError
50
+ # setpgrp() may be unimplemented on some platform
51
+ # https://github.com/Shopify/ruby-lsp-rails/issues/348
52
+ end
53
+
54
+ stdin, stdout, stderr, wait_thread = Bundler.with_original_env do
55
+ Open3.popen3("bundle", "exec", "rails", "runner", "#{__dir__}/server.rb", "start")
49
56
  end
50
57
 
51
- stdin, stdout, stderr, wait_thread = Open3.popen3(
52
- "bin/rails",
53
- "runner",
54
- "#{__dir__}/server.rb",
55
- "start",
56
- )
57
58
  @stdin = T.let(stdin, IO)
58
59
  @stdout = T.let(stdout, IO)
59
60
  @stderr = T.let(stderr, IO)
@@ -79,7 +80,9 @@ module RubyLsp
79
80
  if @wait_thread.alive?
80
81
  $stderr.puts("Ruby LSP Rails is force killing the server")
81
82
  sleep(0.5) # give the server a bit of time if we already issued a shutdown notification
82
- Process.kill(T.must(Signal.list["TERM"]), @wait_thread.pid)
83
+
84
+ # Windows does not support the `TERM` signal, so we're forced to use `KILL` here
85
+ Process.kill(T.must(Signal.list["KILL"]), @wait_thread.pid)
83
86
  end
84
87
  end
85
88
  end
@@ -95,6 +98,14 @@ module RubyLsp
95
98
  nil
96
99
  end
97
100
 
101
+ sig { params(name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
102
+ def route_location(name)
103
+ make_request("route_location", name: name)
104
+ rescue IncompleteMessageError
105
+ $stderr.puts("Ruby LSP Rails failed to get route location: #{@stderr.read}")
106
+ nil
107
+ end
108
+
98
109
  sig { void }
99
110
  def trigger_reload
100
111
  $stderr.puts("Reloading Rails application")
@@ -1,24 +1,8 @@
1
- # typed: strict
1
+ # typed: false
2
2
  # frozen_string_literal: true
3
3
 
4
- require "sorbet-runtime"
5
4
  require "json"
6
5
 
7
- begin
8
- T::Configuration.default_checked_level = :never
9
- # Suppresses call validation errors
10
- T::Configuration.call_validation_error_handler = ->(*) {}
11
- # Suppresses errors caused by T.cast, T.let, T.must, etc.
12
- T::Configuration.inline_type_error_handler = ->(*) {}
13
- # Suppresses errors caused by incorrect parameter ordering
14
- T::Configuration.sig_validation_error_handler = ->(*) {}
15
- rescue
16
- # Need this rescue so that if another gem has
17
- # already set the checked level by the time we
18
- # get to it, we don't fail outright.
19
- nil
20
- end
21
-
22
6
  # NOTE: We should avoid printing to stderr since it causes problems. We never read the standard error pipe from the
23
7
  # client, so it will become full and eventually hang or crash. Instead, return a response with an `error` key.
24
8
 
@@ -27,16 +11,14 @@ module RubyLsp
27
11
  class Server
28
12
  VOID = Object.new
29
13
 
30
- extend T::Sig
31
-
32
- sig { void }
33
14
  def initialize
34
15
  $stdin.sync = true
35
16
  $stdout.sync = true
36
- @running = T.let(true, T::Boolean)
17
+ $stdin.binmode
18
+ $stdout.binmode
19
+ @running = true
37
20
  end
38
21
 
39
- sig { void }
40
22
  def start
41
23
  initialize_result = { result: { message: "ok" } }.to_json
42
24
  $stdout.write("Content-Length: #{initialize_result.length}\r\n\r\n#{initialize_result}")
@@ -54,22 +36,18 @@ module RubyLsp
54
36
  end
55
37
  end
56
38
 
57
- sig do
58
- params(
59
- request: String,
60
- params: T.nilable(T::Hash[Symbol, T.untyped]),
61
- ).returns(T.any(Object, T::Hash[Symbol, T.untyped]))
62
- end
63
39
  def execute(request, params)
64
40
  case request
65
41
  when "shutdown"
66
42
  @running = false
67
43
  VOID
68
44
  when "model"
69
- resolve_database_info_from_model(T.must(params).fetch(:name))
45
+ resolve_database_info_from_model(params.fetch(:name))
70
46
  when "reload"
71
47
  ::Rails.application.reloader.reload!
72
48
  VOID
49
+ when "route_location"
50
+ route_location(params.fetch(:name))
73
51
  else
74
52
  VOID
75
53
  end
@@ -79,10 +57,37 @@ module RubyLsp
79
57
 
80
58
  private
81
59
 
82
- sig { params(model_name: String).returns(T::Hash[Symbol, T.untyped]) }
60
+ # Older versions of Rails don't support `route_source_locations`.
61
+ # We also check that it's enabled.
62
+ if ActionDispatch::Routing::Mapper.respond_to?(:route_source_locations) &&
63
+ ActionDispatch::Routing::Mapper.route_source_locations
64
+ def route_location(name)
65
+ match_data = name.match(/^(.+)(_path|_url)$/)
66
+ return { result: nil } unless match_data
67
+
68
+ key = match_data[1]
69
+
70
+ # A token could match the _path or _url pattern, but not be an actual route.
71
+ route = ::Rails.application.routes.named_routes.get(key)
72
+ return { result: nil } unless route&.source_location
73
+
74
+ {
75
+ result: {
76
+ location: ::Rails.root.join(route.source_location).to_s,
77
+ },
78
+ }
79
+ rescue => e
80
+ { error: e.full_message(highlight: false) }
81
+ end
82
+ else
83
+ def route_location(name)
84
+ { result: nil }
85
+ end
86
+ end
87
+
83
88
  def resolve_database_info_from_model(model_name)
84
89
  const = ActiveSupport::Inflector.safe_constantize(model_name)
85
- unless const && defined?(ActiveRecord) && const < ActiveRecord::Base && !const.abstract_class?
90
+ unless active_record_model?(const)
86
91
  return {
87
92
  result: nil,
88
93
  }
@@ -91,6 +96,7 @@ module RubyLsp
91
96
  info = {
92
97
  result: {
93
98
  columns: const.columns.map { |column| [column.name, column.type] },
99
+ primary_keys: Array(const.primary_key),
94
100
  },
95
101
  }
96
102
 
@@ -103,6 +109,15 @@ module RubyLsp
103
109
  rescue => e
104
110
  { error: e.full_message(highlight: false) }
105
111
  end
112
+
113
+ def active_record_model?(const)
114
+ !!(
115
+ const &&
116
+ defined?(ActiveRecord) &&
117
+ ActiveRecord::Base > const && # We do this 'backwards' in case the class overwrites `<`
118
+ !const.abstract_class?
119
+ )
120
+ end
106
121
  end
107
122
  end
108
123
  end
@@ -66,18 +66,25 @@ module RubyLsp
66
66
  private def build_search_index
67
67
  return unless RAILTIES_VERSION
68
68
 
69
- $stderr.puts("Fetching Rails Documents...")
69
+ $stderr.puts("Fetching search index for Rails documentation")
70
70
 
71
- response = Net::HTTP.get_response(URI("#{RAILS_DOC_HOST}/v#{RAILTIES_VERSION}/js/search_index.js"))
71
+ response = Net::HTTP.get_response(
72
+ URI("#{RAILS_DOC_HOST}/v#{RAILTIES_VERSION}/js/search_index.js"),
73
+ { "User-Agent" => "ruby-lsp-rails/#{RubyLsp::Rails::VERSION}" },
74
+ )
72
75
 
73
76
  body = case response
74
77
  when Net::HTTPSuccess
78
+ $stderr.puts("Finished fetching search index for Rails documentation")
75
79
  response.body
76
80
  when Net::HTTPRedirection
77
81
  # If the version's doc is not found, e.g. Rails main, it'll be redirected
78
82
  # In this case, we just fetch the latest doc
79
83
  response = Net::HTTP.get_response(URI("#{RAILS_DOC_HOST}/js/search_index.js"))
80
- response.body if response.is_a?(Net::HTTPSuccess)
84
+ if response.is_a?(Net::HTTPSuccess)
85
+ $stderr.puts("Finished fetching search index for Rails documentation")
86
+ response.body
87
+ end
81
88
  else
82
89
  $stderr.puts("Response failed: #{response.inspect}")
83
90
  nil
@@ -3,6 +3,6 @@
3
3
 
4
4
  module RubyLsp
5
5
  module Rails
6
- VERSION = "0.3.5"
6
+ VERSION = "0.3.6"
7
7
  end
8
8
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-lsp-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.5
4
+ version: 0.3.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-04-05 00:00:00.000000000 Z
11
+ date: 2024-05-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby-lsp
@@ -16,7 +16,7 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 0.16.0
19
+ version: 0.16.5
20
20
  - - "<"
21
21
  - !ruby/object:Gem::Version
22
22
  version: 0.17.0
@@ -26,7 +26,7 @@ dependencies:
26
26
  requirements:
27
27
  - - ">="
28
28
  - !ruby/object:Gem::Version
29
- version: 0.16.0
29
+ version: 0.16.5
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
32
  version: 0.17.0
@@ -91,7 +91,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
91
91
  - !ruby/object:Gem::Version
92
92
  version: '0'
93
93
  requirements: []
94
- rubygems_version: 3.5.7
94
+ rubygems_version: 3.5.9
95
95
  signing_key:
96
96
  specification_version: 4
97
97
  summary: A Ruby LSP addon for Rails