sorbet_view 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/Rakefile +8 -0
- data/exe/sv +6 -0
- data/lib/sorbet_view/cli/runner.rb +206 -0
- data/lib/sorbet_view/compiler/adapters/erb_adapter.rb +170 -0
- data/lib/sorbet_view/compiler/component_compiler.rb +48 -0
- data/lib/sorbet_view/compiler/heredoc_extractor.rb +112 -0
- data/lib/sorbet_view/compiler/parser_adapter.rb +16 -0
- data/lib/sorbet_view/compiler/ruby_generator.rb +211 -0
- data/lib/sorbet_view/compiler/ruby_segment.rb +13 -0
- data/lib/sorbet_view/compiler/template_compiler.rb +41 -0
- data/lib/sorbet_view/compiler/template_context.rb +170 -0
- data/lib/sorbet_view/configuration.rb +34 -0
- data/lib/sorbet_view/file_system/file_watcher.rb +61 -0
- data/lib/sorbet_view/file_system/output_manager.rb +40 -0
- data/lib/sorbet_view/file_system/project_scanner.rb +34 -0
- data/lib/sorbet_view/lsp/document_store.rb +56 -0
- data/lib/sorbet_view/lsp/position_translator.rb +95 -0
- data/lib/sorbet_view/lsp/server.rb +607 -0
- data/lib/sorbet_view/lsp/sorbet_process.rb +164 -0
- data/lib/sorbet_view/lsp/transport.rb +89 -0
- data/lib/sorbet_view/lsp/uri_mapper.rb +105 -0
- data/lib/sorbet_view/perf.rb +92 -0
- data/lib/sorbet_view/source_map/mapping_entry.rb +12 -0
- data/lib/sorbet_view/source_map/position.rb +23 -0
- data/lib/sorbet_view/source_map/range.rb +35 -0
- data/lib/sorbet_view/source_map/source_map.rb +103 -0
- data/lib/sorbet_view/version.rb +6 -0
- data/lib/sorbet_view.rb +36 -0
- data/lib/tapioca/dsl/compilers/sorbet_view.rb +265 -0
- data/sorbet_view.gemspec +34 -0
- data/vscode/.gitignore +3 -0
- data/vscode/.vscode/launch.json +12 -0
- data/vscode/.vscodeignore +4 -0
- data/vscode/package-lock.json +140 -0
- data/vscode/package.json +61 -0
- data/vscode/src/extension.ts +88 -0
- data/vscode/tsconfig.json +17 -0
- metadata +151 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module SorbetView
|
|
7
|
+
module Compiler
|
|
8
|
+
class CompileResult < T::Struct
|
|
9
|
+
const :ruby_source, String
|
|
10
|
+
const :source_map, SourceMap::SourceMap
|
|
11
|
+
const :locals, T.nilable(String)
|
|
12
|
+
const :locals_sig, T.nilable(String)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class RubyGenerator
|
|
16
|
+
extend T::Sig
|
|
17
|
+
|
|
18
|
+
LOCALS_PREFIX = 'locals:'
|
|
19
|
+
LOCALS_SIG_PREFIX = 'locals_sig:'
|
|
20
|
+
|
|
21
|
+
sig do
|
|
22
|
+
params(
|
|
23
|
+
segments: T::Array[RubySegment],
|
|
24
|
+
context: TemplateContext,
|
|
25
|
+
config: Configuration,
|
|
26
|
+
component_mode: T::Boolean
|
|
27
|
+
).returns(CompileResult)
|
|
28
|
+
end
|
|
29
|
+
def generate(segments:, context:, config:, component_mode: false)
|
|
30
|
+
return Perf.measure('generator.generate') { _generate(segments: segments, context: context, config: config, component_mode: component_mode) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
sig do
|
|
34
|
+
params(
|
|
35
|
+
segments: T::Array[RubySegment],
|
|
36
|
+
context: TemplateContext,
|
|
37
|
+
config: Configuration,
|
|
38
|
+
component_mode: T::Boolean
|
|
39
|
+
).returns(CompileResult)
|
|
40
|
+
end
|
|
41
|
+
def _generate(segments:, context:, config:, component_mode: false)
|
|
42
|
+
locals = T.let(nil, T.nilable(String))
|
|
43
|
+
locals_sig = T.let(nil, T.nilable(String))
|
|
44
|
+
code_segments = T.let([], T::Array[RubySegment])
|
|
45
|
+
|
|
46
|
+
# Extract locals declarations from comments, collect code segments
|
|
47
|
+
segments.each do |seg|
|
|
48
|
+
if seg.type == :comment
|
|
49
|
+
text = seg.code.strip
|
|
50
|
+
if text.start_with?(LOCALS_PREFIX)
|
|
51
|
+
locals = text.delete_prefix(LOCALS_PREFIX).strip
|
|
52
|
+
elsif text.start_with?(LOCALS_SIG_PREFIX)
|
|
53
|
+
locals_sig = text.delete_prefix(LOCALS_SIG_PREFIX).strip
|
|
54
|
+
end
|
|
55
|
+
else
|
|
56
|
+
code_segments << seg
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
lines = T.let([], T::Array[String])
|
|
61
|
+
mapping_entries = T.let([], T::Array[SourceMap::MappingEntry])
|
|
62
|
+
|
|
63
|
+
# Boilerplate header
|
|
64
|
+
lines << "# typed: #{config.typed_level}"
|
|
65
|
+
lines << '# generated by sorbet_view - do not edit'
|
|
66
|
+
lines << "# source: #{context.template_path}"
|
|
67
|
+
lines << ''
|
|
68
|
+
lines << "class #{context.class_name}#{context.superclass_clause}"
|
|
69
|
+
|
|
70
|
+
unless component_mode
|
|
71
|
+
lines << ' extend T::Sig'
|
|
72
|
+
context.includes.each { |inc| lines << " include #{inc}" }
|
|
73
|
+
lines << ''
|
|
74
|
+
|
|
75
|
+
if config.extra_body.length > 0
|
|
76
|
+
config.extra_body.each_line { |l| lines << " #{l.chomp}" }
|
|
77
|
+
lines << ''
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
lines << ' sig { returns(T::Hash[Symbol, T.untyped]) }'
|
|
81
|
+
lines << ' def local_assigns = {}'
|
|
82
|
+
lines << ''
|
|
83
|
+
|
|
84
|
+
if locals_sig
|
|
85
|
+
lines << " #{locals_sig}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
method_args = component_mode ? '' : (locals || '()')
|
|
90
|
+
lines << " def __sorbet_view_render#{method_args}"
|
|
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)"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Body: extracted Ruby code
|
|
99
|
+
code_segments.each do |seg|
|
|
100
|
+
seg_lines = seg.code.split("\n", -1)
|
|
101
|
+
seg_lines = [seg.code] if seg_lines.empty?
|
|
102
|
+
|
|
103
|
+
seg_lines.each_with_index do |code_line, i|
|
|
104
|
+
ruby_line = lines.length
|
|
105
|
+
stripped = code_line.strip
|
|
106
|
+
|
|
107
|
+
next if stripped.empty? && i > 0 # skip trailing empty from split
|
|
108
|
+
|
|
109
|
+
lines << " #{stripped}"
|
|
110
|
+
|
|
111
|
+
# Build mapping entry
|
|
112
|
+
template_start_col = if i == 0
|
|
113
|
+
seg.column
|
|
114
|
+
else
|
|
115
|
+
0
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
mapping_entries << SourceMap::MappingEntry.new(
|
|
119
|
+
template_range: SourceMap::Range.new(
|
|
120
|
+
start: SourceMap::Position.new(line: seg.line + i, column: template_start_col),
|
|
121
|
+
end_: SourceMap::Position.new(line: seg.line + i, column: template_start_col + code_line.strip.length)
|
|
122
|
+
),
|
|
123
|
+
ruby_range: SourceMap::Range.new(
|
|
124
|
+
start: SourceMap::Position.new(line: ruby_line, column: 4),
|
|
125
|
+
end_: SourceMap::Position.new(line: ruby_line, column: 4 + stripped.length)
|
|
126
|
+
),
|
|
127
|
+
type: seg.type == :expression ? :expression : :code
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
lines << ' end'
|
|
133
|
+
lines << 'end'
|
|
134
|
+
lines << '' # trailing newline
|
|
135
|
+
|
|
136
|
+
CompileResult.new(
|
|
137
|
+
ruby_source: lines.join("\n"),
|
|
138
|
+
source_map: SourceMap::SourceMap.new(
|
|
139
|
+
template_path: context.template_path,
|
|
140
|
+
ruby_path: context.ruby_path,
|
|
141
|
+
entries: mapping_entries
|
|
142
|
+
),
|
|
143
|
+
locals: locals,
|
|
144
|
+
locals_sig: locals_sig
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
# Collect @variable references from code segments
|
|
151
|
+
sig { params(segments: T::Array[RubySegment]).returns(T::Array[String]) }
|
|
152
|
+
def collect_ivars(segments)
|
|
153
|
+
segments.flat_map { |seg| seg.code.scan(/(?<!@)@[a-zA-Z_]\w*/) }.uniq.sort
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Find instance variables used in the template but not defined
|
|
157
|
+
sig do
|
|
158
|
+
params(
|
|
159
|
+
segments: T::Array[RubySegment],
|
|
160
|
+
context: TemplateContext,
|
|
161
|
+
config: Configuration,
|
|
162
|
+
component_mode: T::Boolean
|
|
163
|
+
).returns(T::Array[String])
|
|
164
|
+
end
|
|
165
|
+
def find_undefined_ivars(segments, context, config, component_mode)
|
|
166
|
+
all_ivars = collect_ivars(segments)
|
|
167
|
+
return [] if all_ivars.empty?
|
|
168
|
+
|
|
169
|
+
defined_ivars = if component_mode
|
|
170
|
+
load_component_defined_ivars(context.template_path)
|
|
171
|
+
else
|
|
172
|
+
load_view_defined_ivars(context.template_path, config)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
all_ivars - defined_ivars
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Scan the component .rb source for @var = assignments
|
|
179
|
+
sig { params(component_path: String).returns(T::Array[String]) }
|
|
180
|
+
def load_component_defined_ivars(component_path)
|
|
181
|
+
return [] unless File.exist?(component_path)
|
|
182
|
+
|
|
183
|
+
source = File.read(component_path)
|
|
184
|
+
source.scan(/(?<!@)@([a-zA-Z_]\w*)\s*(?:=|\|\|=)/).map { |m| "@#{m[0]}" }.uniq
|
|
185
|
+
end
|
|
186
|
+
|
|
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)
|
|
190
|
+
mapping_path = File.join(config.output_dir, '.defined_ivars.json')
|
|
191
|
+
return [] unless File.exist?(mapping_path)
|
|
192
|
+
|
|
193
|
+
@ivar_mapping = T.let(@ivar_mapping, T.nilable(T::Hash[String, T::Array[String]]))
|
|
194
|
+
@ivar_mapping ||= begin
|
|
195
|
+
JSON.parse(File.read(mapping_path))
|
|
196
|
+
rescue JSON::ParserError
|
|
197
|
+
{}
|
|
198
|
+
end
|
|
199
|
+
|
|
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] || []
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module SorbetView
|
|
5
|
+
module Compiler
|
|
6
|
+
class RubySegment < T::Struct
|
|
7
|
+
const :code, String
|
|
8
|
+
const :line, Integer # 0-based line in the template
|
|
9
|
+
const :column, Integer # 0-based column in the template
|
|
10
|
+
const :type, Symbol # :statement (<% %>), :expression (<%= %>), :comment (<%# %>)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module SorbetView
|
|
5
|
+
module Compiler
|
|
6
|
+
class TemplateCompiler
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
sig { params(adapter: ParserAdapter, config: Configuration).void }
|
|
10
|
+
def initialize(adapter: Adapters::ErbAdapter.new, config: Configuration.load)
|
|
11
|
+
@adapter = adapter
|
|
12
|
+
@generator = T.let(RubyGenerator.new, RubyGenerator)
|
|
13
|
+
@config = config
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
sig { params(template_path: String, source: String).returns(CompileResult) }
|
|
17
|
+
def compile(template_path, source)
|
|
18
|
+
Perf.measure('compiler.compile') do
|
|
19
|
+
segments = @adapter.extract_segments(source)
|
|
20
|
+
context = TemplateContext.resolve(template_path, @config)
|
|
21
|
+
@generator.generate(segments: segments, context: context, config: @config)
|
|
22
|
+
end
|
|
23
|
+
rescue => e
|
|
24
|
+
# On parse failure, generate a minimal file so Sorbet doesn't complain about missing files
|
|
25
|
+
context = TemplateContext.resolve(template_path, @config)
|
|
26
|
+
CompileResult.new(
|
|
27
|
+
ruby_source: "# typed: ignore\n# sorbet_view: failed to parse #{template_path}: #{e.message}\n",
|
|
28
|
+
source_map: SourceMap::SourceMap.empty(template_path, context.ruby_path),
|
|
29
|
+
locals: nil,
|
|
30
|
+
locals_sig: nil
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sig { params(template_path: String).returns(CompileResult) }
|
|
35
|
+
def compile_file(template_path)
|
|
36
|
+
source = File.read(template_path)
|
|
37
|
+
compile(template_path, source)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module SorbetView
|
|
5
|
+
module Compiler
|
|
6
|
+
class TemplateContext < T::Struct
|
|
7
|
+
const :class_name, String
|
|
8
|
+
const :superclass, T.nilable(String)
|
|
9
|
+
const :includes, T::Array[String]
|
|
10
|
+
const :template_path, String
|
|
11
|
+
const :ruby_path, String
|
|
12
|
+
|
|
13
|
+
extend T::Sig
|
|
14
|
+
|
|
15
|
+
sig { returns(String) }
|
|
16
|
+
def superclass_clause
|
|
17
|
+
superclass ? " < #{superclass}" : ''
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
sig { params(component_path: String, class_name: String, config: Configuration).returns(TemplateContext) }
|
|
21
|
+
def self.resolve_component(component_path, class_name, config)
|
|
22
|
+
ruby_path = File.join(config.output_dir, "#{component_path}__erb_template.rb")
|
|
23
|
+
new(
|
|
24
|
+
class_name: class_name,
|
|
25
|
+
superclass: nil,
|
|
26
|
+
includes: [],
|
|
27
|
+
template_path: component_path,
|
|
28
|
+
ruby_path: ruby_path
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
sig { params(template_path: String, config: Configuration).returns(TemplateContext) }
|
|
33
|
+
def self.resolve(template_path, config)
|
|
34
|
+
ruby_path = File.join(config.output_dir, "#{template_path}.rb")
|
|
35
|
+
classification = classify(template_path)
|
|
36
|
+
|
|
37
|
+
case classification
|
|
38
|
+
when :mailer_view
|
|
39
|
+
resolve_mailer_view(template_path, ruby_path, config)
|
|
40
|
+
when :layout
|
|
41
|
+
resolve_layout(template_path, ruby_path, config)
|
|
42
|
+
when :partial
|
|
43
|
+
resolve_partial(template_path, ruby_path, config)
|
|
44
|
+
when :controller_view
|
|
45
|
+
resolve_controller_view(template_path, ruby_path, config)
|
|
46
|
+
else
|
|
47
|
+
resolve_generic(template_path, ruby_path, config)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
extend T::Sig
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
sig { params(path: String).returns(Symbol) }
|
|
57
|
+
def classify(path)
|
|
58
|
+
basename = File.basename(path)
|
|
59
|
+
|
|
60
|
+
if path.include?('_mailer/') || path.include?('mailers/')
|
|
61
|
+
:mailer_view
|
|
62
|
+
elsif path.include?('app/views/layouts/')
|
|
63
|
+
:layout
|
|
64
|
+
elsif basename.start_with?('_')
|
|
65
|
+
:partial
|
|
66
|
+
elsif path.include?('app/views/')
|
|
67
|
+
:controller_view
|
|
68
|
+
else
|
|
69
|
+
:generic
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
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/}, '')
|
|
79
|
+
basename = File.basename(relative).sub(/\..*$/, '') # strip all extensions
|
|
80
|
+
basename = basename.delete_prefix('_') # strip partial prefix
|
|
81
|
+
dir = File.dirname(relative)
|
|
82
|
+
|
|
83
|
+
parts = if dir == '.'
|
|
84
|
+
[basename]
|
|
85
|
+
else
|
|
86
|
+
dir.split('/') + [basename]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
parts.map { |p| camelize(p) }.join('::')
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
sig { params(str: String).returns(String) }
|
|
93
|
+
def camelize(str)
|
|
94
|
+
str.split('_').map(&:capitalize).join
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
sig { params(path: String, ruby_path: String, config: Configuration).returns(TemplateContext) }
|
|
98
|
+
def resolve_controller_view(path, ruby_path, config)
|
|
99
|
+
new(
|
|
100
|
+
class_name: "SorbetView::Generated::#{path_to_class_name(path)}",
|
|
101
|
+
superclass: nil,
|
|
102
|
+
includes: [
|
|
103
|
+
'::ActionView::Helpers',
|
|
104
|
+
'::ApplicationController::HelperMethods',
|
|
105
|
+
*config.extra_includes
|
|
106
|
+
],
|
|
107
|
+
template_path: path,
|
|
108
|
+
ruby_path: ruby_path
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
sig { params(path: String, ruby_path: String, config: Configuration).returns(TemplateContext) }
|
|
113
|
+
def resolve_mailer_view(path, ruby_path, config)
|
|
114
|
+
new(
|
|
115
|
+
class_name: "SorbetView::Generated::#{path_to_class_name(path)}",
|
|
116
|
+
superclass: nil,
|
|
117
|
+
includes: [
|
|
118
|
+
'::ActionView::Helpers',
|
|
119
|
+
'::ActionMailer::Base',
|
|
120
|
+
*config.extra_includes
|
|
121
|
+
],
|
|
122
|
+
template_path: path,
|
|
123
|
+
ruby_path: ruby_path
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
sig { params(path: String, ruby_path: String, config: Configuration).returns(TemplateContext) }
|
|
128
|
+
def resolve_layout(path, ruby_path, config)
|
|
129
|
+
new(
|
|
130
|
+
class_name: "SorbetView::Generated::#{path_to_class_name(path)}",
|
|
131
|
+
superclass: nil,
|
|
132
|
+
includes: [
|
|
133
|
+
'::ActionView::Helpers',
|
|
134
|
+
'::ApplicationController::HelperMethods',
|
|
135
|
+
*config.extra_includes
|
|
136
|
+
],
|
|
137
|
+
template_path: path,
|
|
138
|
+
ruby_path: ruby_path
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
sig { params(path: String, ruby_path: String, config: Configuration).returns(TemplateContext) }
|
|
143
|
+
def resolve_partial(path, ruby_path, config)
|
|
144
|
+
new(
|
|
145
|
+
class_name: "SorbetView::Generated::#{path_to_class_name(path)}",
|
|
146
|
+
superclass: nil,
|
|
147
|
+
includes: [
|
|
148
|
+
'::ActionView::Helpers',
|
|
149
|
+
'::ApplicationController::HelperMethods',
|
|
150
|
+
*config.extra_includes
|
|
151
|
+
],
|
|
152
|
+
template_path: path,
|
|
153
|
+
ruby_path: ruby_path
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
sig { params(path: String, ruby_path: String, config: Configuration).returns(TemplateContext) }
|
|
158
|
+
def resolve_generic(path, ruby_path, config)
|
|
159
|
+
new(
|
|
160
|
+
class_name: "SorbetView::Generated::#{path_to_class_name(path)}",
|
|
161
|
+
superclass: nil,
|
|
162
|
+
includes: config.extra_includes,
|
|
163
|
+
template_path: path,
|
|
164
|
+
ruby_path: ruby_path
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'psych'
|
|
5
|
+
|
|
6
|
+
module SorbetView
|
|
7
|
+
class Configuration < T::Struct
|
|
8
|
+
const :input_dirs, T::Array[String], default: ['app/views']
|
|
9
|
+
const :exclude_paths, T::Array[String], default: []
|
|
10
|
+
const :output_dir, String, default: 'sorbet/templates'
|
|
11
|
+
const :extra_includes, T::Array[String], default: []
|
|
12
|
+
const :extra_body, String, default: ''
|
|
13
|
+
const :skip_missing_locals, T::Boolean, default: true
|
|
14
|
+
const :sorbet_path, String, default: 'srb'
|
|
15
|
+
const :typed_level, String, default: 'true'
|
|
16
|
+
const :path_mapping, T::Hash[String, String], default: {}
|
|
17
|
+
const :component_dirs, T::Array[String], default: []
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
extend T::Sig
|
|
21
|
+
|
|
22
|
+
sig { returns(Configuration) }
|
|
23
|
+
def load
|
|
24
|
+
path = File.join(Dir.pwd, SorbetView::CONFIG_FILE_NAME)
|
|
25
|
+
hash = if File.exist?(path)
|
|
26
|
+
Psych.safe_load_file(path) || {}
|
|
27
|
+
else
|
|
28
|
+
{}
|
|
29
|
+
end
|
|
30
|
+
from_hash(hash)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'listen'
|
|
5
|
+
|
|
6
|
+
module SorbetView
|
|
7
|
+
module FileSystem
|
|
8
|
+
class FileWatcher
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig do
|
|
12
|
+
params(
|
|
13
|
+
config: Configuration,
|
|
14
|
+
on_change: T.proc.params(modified: T::Array[String], added: T::Array[String], removed: T::Array[String]).void
|
|
15
|
+
).void
|
|
16
|
+
end
|
|
17
|
+
def initialize(config:, &on_change)
|
|
18
|
+
@config = config
|
|
19
|
+
@on_change = on_change
|
|
20
|
+
@listener = T.let(nil, T.untyped)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
sig { void }
|
|
24
|
+
def start
|
|
25
|
+
dirs = (@config.input_dirs + @config.component_dirs).select { |d| Dir.exist?(d) }
|
|
26
|
+
return if dirs.empty?
|
|
27
|
+
|
|
28
|
+
@listener = Listen.to(
|
|
29
|
+
*dirs,
|
|
30
|
+
only: /\.(erb|rb)$/,
|
|
31
|
+
wait_for_delay: 0.1
|
|
32
|
+
) do |modified, added, removed|
|
|
33
|
+
# Filter out excluded paths; for .rb files, only pass those containing erb_template
|
|
34
|
+
modified = filter_paths(modified)
|
|
35
|
+
added = filter_paths(added)
|
|
36
|
+
removed = filter_paths(removed)
|
|
37
|
+
|
|
38
|
+
next if modified.empty? && added.empty? && removed.empty?
|
|
39
|
+
|
|
40
|
+
@on_change.call(modified, added, removed)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
@listener.start
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig { void }
|
|
47
|
+
def stop
|
|
48
|
+
@listener&.stop
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
sig { params(paths: T::Array[String]).returns(T::Array[String]) }
|
|
54
|
+
def filter_paths(paths)
|
|
55
|
+
paths.reject do |path|
|
|
56
|
+
@config.exclude_paths.any? { |ex| path.include?(ex) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'digest'
|
|
6
|
+
|
|
7
|
+
module SorbetView
|
|
8
|
+
module FileSystem
|
|
9
|
+
class OutputManager
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { params(output_dir: String).void }
|
|
13
|
+
def initialize(output_dir)
|
|
14
|
+
@output_dir = output_dir
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
sig { params(result: Compiler::CompileResult).void }
|
|
18
|
+
def write(result)
|
|
19
|
+
Perf.measure('output.write') do
|
|
20
|
+
path = result.source_map.ruby_path
|
|
21
|
+
dir = File.dirname(path)
|
|
22
|
+
FileUtils.mkdir_p(dir)
|
|
23
|
+
|
|
24
|
+
# Only write if content changed (avoid unnecessary Watchman triggers)
|
|
25
|
+
if File.exist?(path)
|
|
26
|
+
existing = File.read(path)
|
|
27
|
+
next if existing == result.ruby_source
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
File.write(path, result.ruby_source)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sig { void }
|
|
35
|
+
def clean
|
|
36
|
+
FileUtils.rm_rf(@output_dir)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module SorbetView
|
|
5
|
+
module FileSystem
|
|
6
|
+
class ProjectScanner
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
sig { params(config: Configuration).returns(T::Array[String]) }
|
|
10
|
+
def self.scan(config)
|
|
11
|
+
Perf.measure('scanner.scan_templates') do
|
|
12
|
+
config.input_dirs.flat_map do |dir|
|
|
13
|
+
Dir.glob(File.join(dir, '**', '*.erb'))
|
|
14
|
+
end.reject do |path|
|
|
15
|
+
config.exclude_paths.any? { |ex| path.start_with?(ex) }
|
|
16
|
+
end.sort
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
sig { params(config: Configuration).returns(T::Array[String]) }
|
|
21
|
+
def self.scan_components(config)
|
|
22
|
+
Perf.measure('scanner.scan_components') do
|
|
23
|
+
config.component_dirs.flat_map do |dir|
|
|
24
|
+
Dir.glob(File.join(dir, '**', '*.rb'))
|
|
25
|
+
end.reject do |path|
|
|
26
|
+
config.exclude_paths.any? { |ex| path.start_with?(ex) }
|
|
27
|
+
end.select do |path|
|
|
28
|
+
File.read(path).include?('erb_template')
|
|
29
|
+
end.sort
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module SorbetView
|
|
5
|
+
module Lsp
|
|
6
|
+
class Document < T::Struct
|
|
7
|
+
const :uri, String
|
|
8
|
+
prop :content, String
|
|
9
|
+
prop :version, Integer
|
|
10
|
+
prop :compile_result, T.nilable(Compiler::CompileResult)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# In-memory store of open template documents
|
|
14
|
+
class DocumentStore
|
|
15
|
+
extend T::Sig
|
|
16
|
+
|
|
17
|
+
sig { void }
|
|
18
|
+
def initialize
|
|
19
|
+
@documents = T.let({}, T::Hash[String, Document])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
sig { params(uri: String, content: String, version: Integer).returns(Document) }
|
|
23
|
+
def open(uri, content, version)
|
|
24
|
+
doc = Document.new(uri: uri, content: content, version: version, compile_result: nil)
|
|
25
|
+
@documents[uri] = doc
|
|
26
|
+
doc
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { params(uri: String, content: String, version: Integer).returns(T.nilable(Document)) }
|
|
30
|
+
def change(uri, content, version)
|
|
31
|
+
doc = @documents[uri]
|
|
32
|
+
return nil unless doc
|
|
33
|
+
|
|
34
|
+
doc.content = content
|
|
35
|
+
doc.version = version
|
|
36
|
+
doc.compile_result = nil # Invalidate
|
|
37
|
+
doc
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
sig { params(uri: String).void }
|
|
41
|
+
def close(uri)
|
|
42
|
+
@documents.delete(uri)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
sig { params(uri: String).returns(T.nilable(Document)) }
|
|
46
|
+
def get(uri)
|
|
47
|
+
@documents[uri]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
sig { returns(T::Array[Document]) }
|
|
51
|
+
def all
|
|
52
|
+
@documents.values
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|