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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/Rakefile +8 -0
  4. data/exe/sv +6 -0
  5. data/lib/sorbet_view/cli/runner.rb +206 -0
  6. data/lib/sorbet_view/compiler/adapters/erb_adapter.rb +170 -0
  7. data/lib/sorbet_view/compiler/component_compiler.rb +48 -0
  8. data/lib/sorbet_view/compiler/heredoc_extractor.rb +112 -0
  9. data/lib/sorbet_view/compiler/parser_adapter.rb +16 -0
  10. data/lib/sorbet_view/compiler/ruby_generator.rb +211 -0
  11. data/lib/sorbet_view/compiler/ruby_segment.rb +13 -0
  12. data/lib/sorbet_view/compiler/template_compiler.rb +41 -0
  13. data/lib/sorbet_view/compiler/template_context.rb +170 -0
  14. data/lib/sorbet_view/configuration.rb +34 -0
  15. data/lib/sorbet_view/file_system/file_watcher.rb +61 -0
  16. data/lib/sorbet_view/file_system/output_manager.rb +40 -0
  17. data/lib/sorbet_view/file_system/project_scanner.rb +34 -0
  18. data/lib/sorbet_view/lsp/document_store.rb +56 -0
  19. data/lib/sorbet_view/lsp/position_translator.rb +95 -0
  20. data/lib/sorbet_view/lsp/server.rb +607 -0
  21. data/lib/sorbet_view/lsp/sorbet_process.rb +164 -0
  22. data/lib/sorbet_view/lsp/transport.rb +89 -0
  23. data/lib/sorbet_view/lsp/uri_mapper.rb +105 -0
  24. data/lib/sorbet_view/perf.rb +92 -0
  25. data/lib/sorbet_view/source_map/mapping_entry.rb +12 -0
  26. data/lib/sorbet_view/source_map/position.rb +23 -0
  27. data/lib/sorbet_view/source_map/range.rb +35 -0
  28. data/lib/sorbet_view/source_map/source_map.rb +103 -0
  29. data/lib/sorbet_view/version.rb +6 -0
  30. data/lib/sorbet_view.rb +36 -0
  31. data/lib/tapioca/dsl/compilers/sorbet_view.rb +265 -0
  32. data/sorbet_view.gemspec +34 -0
  33. data/vscode/.gitignore +3 -0
  34. data/vscode/.vscode/launch.json +12 -0
  35. data/vscode/.vscodeignore +4 -0
  36. data/vscode/package-lock.json +140 -0
  37. data/vscode/package.json +61 -0
  38. data/vscode/src/extension.ts +88 -0
  39. data/vscode/tsconfig.json +17 -0
  40. 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