sorbet_view 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 19cbb3af668f9a79a3cad67007559a4e7cb5957c855bf1576c76469d9de7de3a
4
- data.tar.gz: 0d192ba1ad47f3e1ca3ab092307ad87de92f633bbddaae74bcfc7cf9ac4f1eed
3
+ metadata.gz: b85b20a942dbfaf73a6f5a89f36cfd6853e904ebbda437a3327b0fd31acb7d2e
4
+ data.tar.gz: 3aa9e2a536f1ddb491ff2fbc10994115434c6d27713a7f4d122ce130b9f695f2
5
5
  SHA512:
6
- metadata.gz: ba54aa8d1a88981103c875dcea2926bc1c67cff801682abce54b0bd00bac70dd3d1937b5f6a156cd432d93f05674b2baf60d61d6001c04df991d5ced5118ab04
7
- data.tar.gz: 6b79b5dd47f1b22ffd338dca0c273d294915a1bbd0dc1d5669f3f6db1f114020ebf47ca1b7b786160d648f538c178f0618f2cc563d754c231be97694b2f49062
6
+ metadata.gz: 2fc47b3223e1d8b148fabff24d1dda049a2f02de381e3b5a23af5df095a18793f85b9a69203f3122e85866303ca3840a6785429eeeb4ecb7f1abbf62079439ce
7
+ data.tar.gz: 04c45489cb6fa27ae97644169f31c556a8a31fc01fe5b3282269e5fe0b747f830f6472a338886ae88faad5ba644386978fad832a6f7a19efbd7f2ce10f9fac08
@@ -141,9 +141,10 @@ module SorbetView
141
141
 
142
142
  sig { params(args: T::Array[String]).void }
143
143
  def run_lsp(args)
144
+ _, sorbet_args = split_args(args)
144
145
  $stdin.binmode
145
146
  $stdout.binmode
146
- server = Lsp::Server.new
147
+ server = Lsp::Server.new(sorbet_args: sorbet_args)
147
148
  server.start
148
149
  end
149
150
 
@@ -192,7 +193,7 @@ module SorbetView
192
193
  puts ' [paths...] Input directories (overrides config)'
193
194
  puts ' -o, --output DIR Output directory (overrides config)'
194
195
  puts ' --no-config Ignore .sorbet_view.yml'
195
- puts ' -- [args...] Pass remaining args to srb tc (tc only)'
196
+ puts ' -- [args...] Pass remaining args to srb tc (tc/lsp)'
196
197
  end
197
198
 
198
199
  sig { params(path: String).returns(T::Boolean) }
@@ -89,10 +89,14 @@ module SorbetView
89
89
  method_args = component_mode ? '' : (locals || '()')
90
90
  lines << " def __sorbet_view_render#{method_args}"
91
91
 
92
- # Declare undefined instance variables as NilClass
93
- undefined_ivars = find_undefined_ivars(code_segments, context, config, component_mode)
94
- undefined_ivars.each do |ivar|
95
- lines << " #{ivar} = T.let(nil, NilClass)"
92
+ # Declare instance variables with types from srb-lens (or NilClass for unknown)
93
+ resolved_ivars = resolve_ivar_types(code_segments, context, config, component_mode)
94
+ resolved_ivars.each do |ivar, type|
95
+ if type == 'NilClass'
96
+ lines << " #{ivar} = T.let(nil, NilClass)"
97
+ else
98
+ lines << " #{ivar} = T.let(T.unsafe(nil), #{type})"
99
+ end
96
100
  end
97
101
 
98
102
  # Body: extracted Ruby code
@@ -153,26 +157,32 @@ module SorbetView
153
157
  segments.flat_map { |seg| seg.code.scan(/(?<!@)@[a-zA-Z_]\w*/) }.uniq.sort
154
158
  end
155
159
 
156
- # Find instance variables used in the template but not defined
160
+ # Resolve types for all instance variables used in the template
161
+ # Returns [["@var", "Type"], ...] — defined ivars get their srb-lens type, undefined get NilClass
157
162
  sig do
158
163
  params(
159
164
  segments: T::Array[RubySegment],
160
165
  context: TemplateContext,
161
166
  config: Configuration,
162
167
  component_mode: T::Boolean
163
- ).returns(T::Array[String])
168
+ ).returns(T::Array[[String, String]])
164
169
  end
165
- def find_undefined_ivars(segments, context, config, component_mode)
170
+ def resolve_ivar_types(segments, context, config, component_mode)
166
171
  all_ivars = collect_ivars(segments)
167
172
  return [] if all_ivars.empty?
168
173
 
169
- defined_ivars = if component_mode
170
- load_component_defined_ivars(context.template_path)
174
+ if component_mode
175
+ # Component mode: ivars defined in source are omitted, undefined get NilClass
176
+ defined_ivars = load_component_defined_ivars(context.template_path)
177
+ (all_ivars - defined_ivars).map { |ivar| [ivar, 'NilClass'] }
171
178
  else
172
- load_view_defined_ivars(context.template_path, config)
179
+ # View mode: use typed ivar mapping from srb-lens
180
+ ivar_types = load_view_ivar_types(context.template_path, config)
181
+ all_ivars.map do |ivar|
182
+ type = ivar_types[ivar]
183
+ [ivar, type || 'NilClass']
184
+ end
173
185
  end
174
-
175
- all_ivars - defined_ivars
176
186
  end
177
187
 
178
188
  # Scan the component .rb source for @var = assignments
@@ -184,27 +194,24 @@ module SorbetView
184
194
  source.scan(/(?<!@)@([a-zA-Z_]\w*)\s*(?:=|\|\|=)/).map { |m| "@#{m[0]}" }.uniq
185
195
  end
186
196
 
187
- # Load defined ivars from the Tapioca-generated mapping for views
188
- sig { params(template_path: String, config: Configuration).returns(T::Array[String]) }
189
- def load_view_defined_ivars(template_path, config)
197
+ # Load ivar type mapping from the Tapioca-generated JSON
198
+ # Returns { "@var" => "Type" } for the matching template
199
+ sig { params(template_path: String, config: Configuration).returns(T::Hash[String, String]) }
200
+ def load_view_ivar_types(template_path, config)
190
201
  mapping_path = File.join(config.output_dir, '.defined_ivars.json')
191
- return [] unless File.exist?(mapping_path)
202
+ return {} unless File.exist?(mapping_path)
192
203
 
193
- @ivar_mapping = T.let(@ivar_mapping, T.nilable(T::Hash[String, T::Array[String]]))
204
+ @ivar_mapping = T.let(@ivar_mapping, T.nilable(T::Hash[String, T::Hash[String, String]]))
194
205
  @ivar_mapping ||= begin
195
206
  JSON.parse(File.read(mapping_path))
196
207
  rescue JSON::ParserError
197
208
  {}
198
209
  end
199
210
 
200
- # Derive controller_path from template_path
201
- # app/views/posts/show.html.erb → posts
202
- # app/views/admin_area/v21/booths/show.html.erb → admin_area/v21/booths
203
- controller_path = template_path
204
- .sub(%r{^app/views/}, '')
205
- .sub(%r{/[^/]+$}, '') # strip filename
206
-
207
- @ivar_mapping[controller_path] || []
211
+ # Lookup by template path (without extensions)
212
+ # "app/views/posts/show.html.erb""app/views/posts/show"
213
+ template_key = template_path.sub(/\..*\z/, '')
214
+ @ivar_mapping[template_key] || {}
208
215
  end
209
216
  end
210
217
  end
@@ -32,7 +32,7 @@ module SorbetView
32
32
  sig { params(template_path: String, config: Configuration).returns(TemplateContext) }
33
33
  def self.resolve(template_path, config)
34
34
  ruby_path = File.join(config.output_dir, "#{template_path}.rb")
35
- classification = classify(template_path)
35
+ classification = classify(template_path, config)
36
36
 
37
37
  case classification
38
38
  when :mailer_view
@@ -53,29 +53,46 @@ module SorbetView
53
53
 
54
54
  private
55
55
 
56
- sig { params(path: String).returns(Symbol) }
57
- def classify(path)
56
+ # Strip the matching input_dir prefix from a template path
57
+ # "app/views/users/show.html.erb" → "users/show.html.erb"
58
+ # "app/users/show.html.erb" (input_dirs: ['app/']) → "users/show.html.erb"
59
+ sig { params(path: String, config: Configuration).returns(String) }
60
+ def strip_input_dir(path, config)
61
+ config.input_dirs.each do |dir|
62
+ prefix = dir.end_with?('/') ? dir : "#{dir}/"
63
+ if path.start_with?(prefix)
64
+ relative = path.delete_prefix(prefix)
65
+ # Also strip "views/" if the input_dir didn't include it
66
+ # e.g. input_dirs: ['app/'] with path 'app/views/users/show.html.erb'
67
+ relative = relative.delete_prefix('views/') if relative.start_with?('views/')
68
+ return relative
69
+ end
70
+ end
71
+ path
72
+ end
73
+
74
+ sig { params(path: String, config: Configuration).returns(Symbol) }
75
+ def classify(path, config)
58
76
  basename = File.basename(path)
77
+ relative = strip_input_dir(path, config)
59
78
 
60
79
  if path.include?('_mailer/') || path.include?('mailers/')
61
80
  :mailer_view
62
- elsif path.include?('app/views/layouts/')
81
+ elsif relative.start_with?('layouts/')
63
82
  :layout
64
83
  elsif basename.start_with?('_')
65
84
  :partial
66
- elsif path.include?('app/views/')
85
+ elsif relative != path
86
+ # input_dir prefix was stripped → this is a view under input_dirs
67
87
  :controller_view
68
88
  else
69
89
  :generic
70
90
  end
71
91
  end
72
92
 
73
- sig { params(path: String).returns(String) }
74
- def path_to_class_name(path)
75
- # /abs/path/app/views/users/show.html.erb -> Users::Show
76
- relative = path
77
- .sub(%r{.*app/views/}, '')
78
- .sub(%r{.*app/}, '')
93
+ sig { params(path: String, config: Configuration).returns(String) }
94
+ def path_to_class_name(path, config)
95
+ relative = strip_input_dir(path, config)
79
96
  basename = File.basename(relative).sub(/\..*$/, '') # strip all extensions
80
97
  basename = basename.delete_prefix('_') # strip partial prefix
81
98
  dir = File.dirname(relative)
@@ -97,7 +114,7 @@ module SorbetView
97
114
  sig { params(path: String, ruby_path: String, config: Configuration).returns(TemplateContext) }
98
115
  def resolve_controller_view(path, ruby_path, config)
99
116
  new(
100
- class_name: "SorbetView::Generated::#{path_to_class_name(path)}",
117
+ class_name: "SorbetView::Generated::#{path_to_class_name(path, config)}",
101
118
  superclass: nil,
102
119
  includes: [
103
120
  '::ActionView::Helpers',
@@ -112,7 +129,7 @@ module SorbetView
112
129
  sig { params(path: String, ruby_path: String, config: Configuration).returns(TemplateContext) }
113
130
  def resolve_mailer_view(path, ruby_path, config)
114
131
  new(
115
- class_name: "SorbetView::Generated::#{path_to_class_name(path)}",
132
+ class_name: "SorbetView::Generated::#{path_to_class_name(path, config)}",
116
133
  superclass: nil,
117
134
  includes: [
118
135
  '::ActionView::Helpers',
@@ -127,7 +144,7 @@ module SorbetView
127
144
  sig { params(path: String, ruby_path: String, config: Configuration).returns(TemplateContext) }
128
145
  def resolve_layout(path, ruby_path, config)
129
146
  new(
130
- class_name: "SorbetView::Generated::#{path_to_class_name(path)}",
147
+ class_name: "SorbetView::Generated::#{path_to_class_name(path, config)}",
131
148
  superclass: nil,
132
149
  includes: [
133
150
  '::ActionView::Helpers',
@@ -142,7 +159,7 @@ module SorbetView
142
159
  sig { params(path: String, ruby_path: String, config: Configuration).returns(TemplateContext) }
143
160
  def resolve_partial(path, ruby_path, config)
144
161
  new(
145
- class_name: "SorbetView::Generated::#{path_to_class_name(path)}",
162
+ class_name: "SorbetView::Generated::#{path_to_class_name(path, config)}",
146
163
  superclass: nil,
147
164
  includes: [
148
165
  '::ActionView::Helpers',
@@ -157,7 +174,7 @@ module SorbetView
157
174
  sig { params(path: String, ruby_path: String, config: Configuration).returns(TemplateContext) }
158
175
  def resolve_generic(path, ruby_path, config)
159
176
  new(
160
- class_name: "SorbetView::Generated::#{path_to_class_name(path)}",
177
+ class_name: "SorbetView::Generated::#{path_to_class_name(path, config)}",
161
178
  superclass: nil,
162
179
  includes: config.extra_includes,
163
180
  template_path: path,
@@ -15,6 +15,7 @@ module SorbetView
15
15
  const :typed_level, String, default: 'true'
16
16
  const :path_mapping, T::Hash[String, String], default: {}
17
17
  const :component_dirs, T::Array[String], default: []
18
+ const :sorbet_options, T::Array[String], default: []
18
19
 
19
20
  class << self
20
21
  extend T::Sig
@@ -9,9 +9,10 @@ module SorbetView
9
9
  class Server
10
10
  extend T::Sig
11
11
 
12
- sig { params(input: IO, output: IO).void }
13
- def initialize(input: $stdin, output: $stdout)
12
+ sig { params(input: IO, output: IO, sorbet_args: T::Array[String]).void }
13
+ def initialize(input: $stdin, output: $stdout, sorbet_args: [])
14
14
  @config = T.let(Configuration.load, Configuration)
15
+ @sorbet_args = T.let(sorbet_args, T::Array[String])
15
16
  @logger = T.let(Logger.new(File.open('sorbet_view_lsp.log', 'a')), Logger)
16
17
  @transport = T.let(Transport.new(input: input, output: output), Transport)
17
18
  @document_store = T.let(DocumentStore.new, DocumentStore)
@@ -100,7 +101,7 @@ module SorbetView
100
101
  compile_all_templates
101
102
 
102
103
  # Start Sorbet LSP
103
- @sorbet.start
104
+ @sorbet.start(extra_args: @sorbet_args)
104
105
 
105
106
  # Register handler for diagnostics from Sorbet
106
107
  @sorbet.on_notification('textDocument/publishDiagnostics') do |msg|
@@ -24,13 +24,15 @@ module SorbetView
24
24
  @next_id = T.let(1, Integer)
25
25
  end
26
26
 
27
- sig { void }
28
- def start
27
+ sig { params(extra_args: T::Array[String]).void }
28
+ def start(extra_args: [])
29
29
  cmd = [
30
30
  @config.sorbet_path,
31
31
  'tc',
32
32
  '--lsp',
33
- '--enable-all-experimental-lsp-features'
33
+ '--enable-all-experimental-lsp-features',
34
+ *@config.sorbet_options,
35
+ *extra_args
34
36
  ]
35
37
 
36
38
  @logger.info("Starting Sorbet: #{cmd.join(' ')}")
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module SorbetView
5
- VERSION = '0.3.0'
5
+ VERSION = '0.4.0'
6
6
  end
@@ -44,17 +44,37 @@ module Tapioca
44
44
 
45
45
  private
46
46
 
47
- # Generate a mapping of controller_path -> defined instance variables
48
- # Used by the compiler to declare undefined ivars as NilClass
47
+ # Generate a mapping of template_path -> { ivar => type }
48
+ # Used by the compiler to declare ivars with proper types
49
49
  sig { params(controllers: T::Array[T.untyped]).void }
50
50
  def generate_ivar_mapping(controllers)
51
51
  config = ::SorbetView::Configuration.load
52
- mapping = T.let({}, T::Hash[String, T::Array[String]])
52
+ mapping = T.let({}, T::Hash[String, T::Hash[String, String]])
53
+
54
+ # Get view directories from Rails (e.g. ["app/views"])
55
+ view_dirs = resolve_view_dirs
53
56
 
54
57
  controllers.each do |controller|
55
58
  path = controller.controller_path
56
- ivars = extract_defined_ivars(controller)
57
- mapping[path] = ivars unless ivars.empty?
59
+
60
+ controller.action_methods.each do |action_name|
61
+ next unless action_name.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
62
+
63
+ action_ivars = extract_ivars_from_srb_lens(controller, action_name.to_s)
64
+ next if action_ivars.empty?
65
+
66
+ # Template-path keys: "app/views/posts/show" => { "@post" => "Post" }
67
+ view_dirs.each do |vd|
68
+ template_key = File.join(vd, path, action_name.to_s)
69
+ existing = mapping[template_key]
70
+ if existing
71
+ # Multiple actions render the same template: narrow to intersection
72
+ mapping[template_key] = intersect_ivars(existing, action_ivars)
73
+ else
74
+ mapping[template_key] = action_ivars
75
+ end
76
+ end
77
+ end
58
78
  end
59
79
 
60
80
  mapping_path = File.join(config.output_dir, '.defined_ivars.json')
@@ -64,14 +84,59 @@ module Tapioca
64
84
  # Non-critical: if mapping fails, all ivars default to NilClass
65
85
  end
66
86
 
67
- # Extract instance variables assigned in the controller source
68
- sig { params(controller: T.untyped).returns(T::Array[String]) }
69
- def extract_defined_ivars(controller)
70
- source_file = "app/controllers/#{controller.controller_path}_controller.rb"
71
- return [] unless File.exist?(source_file)
87
+ # Resolve view directories from Rails, relative to project root
88
+ sig { returns(T::Array[String]) }
89
+ def resolve_view_dirs
90
+ if defined?(::ActionController::Base) && ::ActionController::Base.respond_to?(:view_paths)
91
+ ::ActionController::Base.view_paths.paths.filter_map do |p|
92
+ path_str = p.to_s
93
+ relative = path_str.sub(%r{^#{Regexp.escape(Dir.pwd)}/?}, '')
94
+ relative unless relative.empty?
95
+ end
96
+ else
97
+ ['app/views']
98
+ end
99
+ rescue StandardError
100
+ ['app/views']
101
+ end
102
+
103
+ # Intersect two ivar mappings: keep only ivars present in both, narrow types with T.all
104
+ sig { params(a: T::Hash[String, String], b: T::Hash[String, String]).returns(T::Hash[String, String]) }
105
+ def intersect_ivars(a, b)
106
+ common_keys = a.keys & b.keys
107
+ result = T.let({}, T::Hash[String, String])
108
+ common_keys.each do |key|
109
+ type_a = T.must(a[key])
110
+ type_b = T.must(b[key])
111
+ result[key] = if type_a == type_b
112
+ type_a
113
+ else
114
+ "T.all(#{type_a}, #{type_b})"
115
+ end
116
+ end
117
+ result
118
+ end
72
119
 
73
- source = File.read(source_file)
74
- source.scan(/(?<!@)@([a-zA-Z_]\w*)\s*(?:=|\|\|=)/).map { |m| "@#{m[0]}" }.uniq.sort
120
+ # Extract instance variables and their types from srb-lens for a controller action
121
+ sig { params(controller: T.untyped, action_name: String).returns(T::Hash[String, String]) }
122
+ def extract_ivars_from_srb_lens(controller, action_name)
123
+ methods = @project.find_methods("#{controller.name}##{action_name}")
124
+ method_info = methods&.first
125
+ return {} unless method_info
126
+
127
+ ivars = method_info.ivars
128
+ return {} if ivars.nil? || ivars.empty?
129
+
130
+ result = T.let({}, T::Hash[String, String])
131
+ ivars.each do |ivar|
132
+ name = ivar.name
133
+ type = ivar.type
134
+ next if name.nil? || name.empty?
135
+ next if type.nil? || type.empty?
136
+
137
+ result[name] = type
138
+ end
139
+ result
75
140
  end
76
141
 
77
142
  sig { void }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sorbet_view
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kazuma