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 +4 -4
- data/lib/sorbet_view/cli/runner.rb +3 -2
- data/lib/sorbet_view/compiler/ruby_generator.rb +32 -25
- data/lib/sorbet_view/compiler/template_context.rb +33 -16
- data/lib/sorbet_view/configuration.rb +1 -0
- data/lib/sorbet_view/lsp/server.rb +4 -3
- data/lib/sorbet_view/lsp/sorbet_process.rb +5 -3
- data/lib/sorbet_view/version.rb +1 -1
- data/lib/tapioca/dsl/compilers/sorbet_view.rb +77 -12
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b85b20a942dbfaf73a6f5a89f36cfd6853e904ebbda437a3327b0fd31acb7d2e
|
|
4
|
+
data.tar.gz: 3aa9e2a536f1ddb491ff2fbc10994115434c6d27713a7f4d122ce130b9f695f2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
202
|
+
return {} unless File.exist?(mapping_path)
|
|
192
203
|
|
|
193
|
-
@ivar_mapping = T.let(@ivar_mapping, T.nilable(T::Hash[String, T::
|
|
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
|
-
#
|
|
201
|
-
# app/views/posts/show.html.erb → posts
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
81
|
+
elsif relative.start_with?('layouts/')
|
|
63
82
|
:layout
|
|
64
83
|
elsif basename.start_with?('_')
|
|
65
84
|
:partial
|
|
66
|
-
elsif path
|
|
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
|
-
|
|
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(' ')}")
|
data/lib/sorbet_view/version.rb
CHANGED
|
@@ -44,17 +44,37 @@ module Tapioca
|
|
|
44
44
|
|
|
45
45
|
private
|
|
46
46
|
|
|
47
|
-
# Generate a mapping of
|
|
48
|
-
# Used by the compiler to declare
|
|
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::
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
#
|
|
68
|
-
sig {
|
|
69
|
-
def
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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 }
|