sorbet_erb 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4076d96d59afeaf2c9f40b7c6cb176482b78ffc0b48e0d262cf71bdc4f49a8ce
4
+ data.tar.gz: c697a0a90d4f9526fdac049f722a802f9f09f5c4a30c3c9447db3e3d0a7503bd
5
+ SHA512:
6
+ metadata.gz: 1cfc74f6346706dee3bec11195b74ed0328f1e304f084d732b74b255eb8f6c8817aa41b10f9df8a0fa9b5c4d1384caab7285ac54b32ea69fed64e5a553abf4a8
7
+ data.tar.gz: f751e2301827f3f4b5a027689eb78e62b6adf821d3930688d6ad28a705bb43905a045d8d9cf971af603f5a93b7a3bf44bdb2c91eebf2a04f2f8a5389210ed762
data/.rubocop.yml ADDED
@@ -0,0 +1,37 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.0.0
3
+
4
+ Style/Documentation:
5
+ Enabled: false
6
+
7
+ Style/StringLiterals:
8
+ Enabled: true
9
+ EnforcedStyle: single_quotes
10
+
11
+ Style/StringLiteralsInInterpolation:
12
+ Enabled: true
13
+ EnforcedStyle: double_quotes
14
+
15
+ Layout/LineLength:
16
+ Max: 120
17
+
18
+ Metrics/AbcSize:
19
+ Enabled: false
20
+
21
+ Metrics/BlockLength:
22
+ Enabled: false
23
+
24
+ Metrics/ClassLength:
25
+ Enabled: false
26
+
27
+ Metrics/CyclomaticComplexity:
28
+ Enabled: false
29
+
30
+ Metrics/MethodLength:
31
+ Enabled: false
32
+
33
+ Metrics/ModuleLength:
34
+ Enabled: false
35
+
36
+ Metrics/PerceivedComplexity:
37
+ Enabled: false
data/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # SorbetErb
2
+
3
+ `sorbet_erb` parses Rails ERB files and extracts the Ruby code so that
4
+ you can run Sorbet typechecking over them. This assumes you're already
5
+ using Sorbet and Tapioca together to generate RBI.
6
+
7
+ Currently this only supports Rails applications since it generates Ruby
8
+ scoped with a Rails `ApplicationController`. Feel free to file an issue
9
+ if you're interested in using this in other contexts.
10
+
11
+ ### Limitations
12
+
13
+ - You must manually specify extra_includes that aren't covered by Tapioca
14
+ - Rails partials (files beginning with `_`) and Turbo streams must use
15
+ strict locals. sorbet_erb will skip them if there are no strict locals
16
+ defined unless you set `skip_missing_locals`.
17
+
18
+ ## Installation
19
+
20
+ This gem isn't published to RubyGems yet, so you need to depend
21
+ directly on the git repository:
22
+
23
+ ```
24
+ gem 'sorbet_erb', git: 'https://github.com/franklinhu/sorbet_erb'
25
+ ```
26
+
27
+ After installing the gem, run `bundle binstubs sorbet_erb` to install
28
+ a helper script under `bin/sorbet_erb`.
29
+
30
+ ## Usage
31
+
32
+ ```
33
+ bin/sorbet_erb input_dir output_dir
34
+ ```
35
+
36
+ You'll most likely want to pass in your Rails app directory as input
37
+ and use `sorbet/erb` as your output directory. Don't forget to add
38
+ `sorbet/erb` to your `.gitignore`.
39
+
40
+ ```
41
+ bin/sorbet_erb ./app ./sorbet/erb
42
+ ```
43
+
44
+ ### Sorbet signatures (experimental)
45
+ Running typechecking in Rails ERB templates is more helpful if there
46
+ are concrete types for the arguments.
47
+
48
+ sorbet_erb supports Sorbet-type signatures as a magic comment.
49
+
50
+ ```
51
+ <%# locals_sig: sig { params(x: Integer).void } %>
52
+ ```
53
+
54
+ ## Development
55
+
56
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
57
+
58
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
59
+
60
+ ## Contributing
61
+
62
+ Bug reports and pull requests are welcome on GitHub at https://github.com/franklinhu/sorbet_erb.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'minitest/test_task'
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require 'rubocop/rake_task'
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
data/exe/sorbet_erb ADDED
@@ -0,0 +1,6 @@
1
+ #! /usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/sorbet_erb'
5
+
6
+ SorbetErb.start(ARGV)
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'better_html'
4
+ require 'better_html/parser'
5
+
6
+ module SorbetErb
7
+ class CodeExtractor
8
+ def initialize; end
9
+
10
+ def extract(input)
11
+ buffer = Parser::Source::Buffer.new('(buffer)')
12
+ buffer.source = input
13
+ parser = BetterHtml::Parser.new(buffer)
14
+
15
+ p = CodeProcessor.new
16
+ p.process(parser.ast)
17
+ [p.output, p.locals, p.locals_sig]
18
+ end
19
+ end
20
+
21
+ class CodeProcessor
22
+ include AST::Processor::Mixin
23
+
24
+ LOCALS_PREFIX = 'locals:'
25
+ LOCALS_SIG_PREFIX = 'locals_sig:'
26
+
27
+ attr_accessor :output, :locals, :locals_sig
28
+
29
+ def initialize
30
+ @output = []
31
+ @locals = nil
32
+ @locals_sig = nil
33
+ end
34
+
35
+ def handler_missing(node)
36
+ # Some children may be strings, so only look for AST nodes
37
+ children = node.children.select { |c| c.is_a?(BetterHtml::AST::Node) }
38
+ process_all(children)
39
+ end
40
+
41
+ def on_erb(node)
42
+ indicator_node = node.children.compact.find { |c| c.type == :indicator }
43
+ code_node = node.children.compact.find { |c| c.type == :code }
44
+
45
+ return process(code_node) if indicator_node.nil?
46
+
47
+ indicator = indicator_node.children.first
48
+ case indicator
49
+ when '#'
50
+ # Ignore comments if it's not strict locals
51
+ code_text = code_node.children.first.strip
52
+ if code_text.start_with?(LOCALS_PREFIX)
53
+ # No need to parse the locals
54
+ @locals = code_text.delete_prefix(LOCALS_PREFIX).strip
55
+ elsif code_text.start_with?(LOCALS_SIG_PREFIX)
56
+ @locals_sig = code_text.delete_prefix(LOCALS_SIG_PREFIX).strip
57
+ end
58
+ else
59
+ process_all(node.children)
60
+ end
61
+ end
62
+
63
+ def on_code(node)
64
+ @output += node.children
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SorbetErb
4
+ VERSION = '0.1.0'
5
+ end
data/lib/sorbet_erb.rb ADDED
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'fileutils'
5
+ require 'pathname'
6
+ require 'psych'
7
+
8
+ require_relative 'sorbet_erb/code_extractor'
9
+ require_relative 'sorbet_erb/version'
10
+
11
+ module SorbetErb
12
+ CONFIG_FILE_NAME = '.sorbet_erb.yml'
13
+
14
+ DEFAULT_CONFIG = {
15
+ 'input_dirs' => ['app'],
16
+ 'exclude_paths' => [],
17
+ 'output_dir' => 'sorbet/erb',
18
+ 'extra_includes' => [],
19
+ 'extra_body' => '',
20
+ 'skip_missing_locals' => true
21
+ }.freeze
22
+
23
+ USAGE = <<~USAGE
24
+ Usage: sorbet_erb input_dir output_dir
25
+ input_dir - where to scan for ERB files
26
+ output_dir - where to write files with Ruby extracted from ERB
27
+ USAGE
28
+
29
+ ERB_TEMPLATE = <<~ERB_TEMPLATE
30
+ # typed: true
31
+ class SorbetErb<%= class_suffix %> < ApplicationController
32
+ extend T::Sig
33
+ include ActionView::Helpers
34
+ include ApplicationController::HelperMethods
35
+ <% extra_includes.each do |i| %>
36
+ include <%= i %>
37
+ <% end %>
38
+
39
+ sig { returns(T::Hash[Symbol, T.untyped]) }
40
+ def local_assigns
41
+ # Shim for typechecking
42
+ {}
43
+ end
44
+
45
+ <%= extra_body %>
46
+
47
+ <%= locals_sig %>
48
+ def body<%= locals %>
49
+ <% lines.each do |line| %>
50
+ <%= line %>
51
+ <% end %>
52
+ end
53
+ end
54
+ ERB_TEMPLATE
55
+
56
+ def self.extract_rb_from_erb(input_dir, output_dir)
57
+ config = read_config
58
+ input_dirs =
59
+ if input_dir
60
+ [input_dir]
61
+ else
62
+ config.fetch('input_dirs')
63
+ end
64
+ exclude_paths = config.fetch('exclude_paths')
65
+ output_dir ||= config.fetch('output_dir')
66
+ skip_missing_locals = config.fetch('skip_missing_locals')
67
+
68
+ puts 'Clearing output directory'
69
+ FileUtils.rm_rf(output_dir)
70
+
71
+ input_dir_to_paths = input_dirs.flat_map do |d|
72
+ Dir.glob(File.join(d, '**', '*.erb')).map do |p|
73
+ [d, p]
74
+ end
75
+ end
76
+ input_dir_to_paths.each do |d, p|
77
+ pathname = Pathname.new(p)
78
+
79
+ next if exclude_paths.any? { |p| p.include?(pathname.to_s) }
80
+
81
+ extractor = CodeExtractor.new
82
+ lines, locals, locals_sig = extractor.extract(File.read(p))
83
+
84
+ # Partials and Turbo streams must use strict locals
85
+ next if requires_defined_locals(pathname.basename.to_s) && locals.nil? && skip_missing_locals
86
+
87
+ locals ||= '()'
88
+ locals_sig ||= ''
89
+
90
+ rel_output_dir = File.join(
91
+ output_dir,
92
+ pathname.dirname.relative_path_from(d)
93
+ )
94
+ FileUtils.mkdir_p(rel_output_dir)
95
+
96
+ output_path = File.join(
97
+ rel_output_dir,
98
+ "#{pathname.basename}.generated.rb"
99
+ )
100
+ erb = ERB.new(ERB_TEMPLATE)
101
+ File.open(output_path, 'w') do |f|
102
+ result = erb.result_with_hash(
103
+ class_suffix: SecureRandom.hex(6),
104
+ locals: locals,
105
+ locals_sig: locals_sig,
106
+ extra_includes: config.fetch('extra_includes'),
107
+ extra_body: config.fetch('extra_body'),
108
+ lines: lines
109
+ )
110
+ f.write(result)
111
+ end
112
+ end
113
+ end
114
+
115
+ def self.read_config
116
+ path = File.join(Dir.pwd, CONFIG_FILE_NAME)
117
+ config =
118
+ if File.exist?(path)
119
+ Psych.safe_load_file(path)
120
+ else
121
+ {}
122
+ end
123
+ DEFAULT_CONFIG.merge(config)
124
+ end
125
+
126
+ def self.requires_defined_locals(file_name)
127
+ file_name.start_with?('_') || file_name.end_with?('.turbo_stream.erb')
128
+ end
129
+
130
+ def self.start(argv)
131
+ input = argv[0]
132
+ output = argv[1]
133
+
134
+ SorbetErb.extract_rb_from_erb(input, output)
135
+ end
136
+ end
@@ -0,0 +1,119 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ return unless defined?(ViewComponent)
5
+
6
+ require 'sorbet-runtime'
7
+ require 'tapioca/dsl'
8
+
9
+ module Tapioca
10
+ module Dsl
11
+ module Compilers
12
+ # Generates RBI for ViewComponent::Slotable
13
+ # See https://github.com/ViewComponent/view_component/blob/main/lib/view_component/slotable.rb
14
+ class ViewComponentSlotables < Tapioca::Dsl::Compiler
15
+ extend T::Sig
16
+
17
+ ConstantType = type_member { { fixed: T.class_of(::ViewComponent::Slotable) } }
18
+
19
+ class << self
20
+ extend T::Sig
21
+
22
+ sig { override.returns(T::Enumerable[Module]) }
23
+ def gather_constants
24
+ all_classes
25
+ .select { |c| c < ViewComponent::Slotable && c.name != 'ViewComponent::Base' }
26
+ end
27
+ end
28
+
29
+ sig { override.void }
30
+ def decorate
31
+ root.create_path(constant) do |klass|
32
+ T.unsafe(constant).registered_slots.each do |name, config|
33
+ renderable_type = config[:renderable]
34
+ renderable = T.let(
35
+ case renderable_type
36
+ when String
37
+ renderable_type
38
+ when Class
39
+ T.must(renderable_type.name)
40
+ else
41
+ 'T.untyped'
42
+ end,
43
+ String
44
+ )
45
+
46
+ is_many = T.let(config[:collection], T::Boolean)
47
+ return_type = renderable
48
+
49
+ module_name = 'ViewComponentSlotablesMethodsModule'
50
+ klass.create_module(module_name) do |mod|
51
+ generate_instance_methods(mod, name.to_s, return_type, is_many)
52
+ end
53
+ klass.create_include(module_name)
54
+ end
55
+ end
56
+ end
57
+
58
+ sig { params(klass: RBI::Scope, name: String, return_type: String, is_many: T::Boolean).void }
59
+ def generate_instance_methods(klass, name, return_type, is_many)
60
+ return_type_maybe_plural =
61
+ if is_many
62
+ "T::Enumerable[#{return_type}]"
63
+ else
64
+ return_type
65
+ end
66
+
67
+ klass.create_method(name, return_type: return_type_maybe_plural)
68
+ klass.create_method("#{name}?", return_type: 'T::Boolean')
69
+
70
+ klass.create_method(
71
+ "with_#{name}",
72
+ parameters: [
73
+ create_rest_param('args', type: 'T.untyped'),
74
+ create_block_param(
75
+ 'block',
76
+ type: "T.nilable(T.proc.params(#{name}: #{return_type_maybe_plural}).returns(T.untyped))"
77
+ )
78
+ ],
79
+ return_type: return_type_maybe_plural
80
+ )
81
+
82
+ if is_many
83
+ # For collection subcomponents, ViewComponent generates methods for the singular version
84
+ # of the name.
85
+ singular_name = ActiveSupport::Inflector.singularize(name)
86
+
87
+ klass.create_method(
88
+ "with_#{singular_name}",
89
+ parameters: [
90
+ create_rest_param('args', type: 'T.untyped'),
91
+ create_block_param(
92
+ 'block',
93
+ type: "T.nilable(T.proc.params(#{singular_name}: #{return_type}).returns(T.untyped))"
94
+ )
95
+ ],
96
+ return_type: return_type
97
+ )
98
+ klass.create_method(
99
+ "with_#{singular_name}_content",
100
+ parameters: [
101
+ create_param('content', type: 'T.untyped')
102
+ ],
103
+ return_type: 'T.untyped'
104
+ )
105
+
106
+ else
107
+ klass.create_method(
108
+ "with_#{name}_content",
109
+ parameters: [
110
+ create_param('content', type: 'T.untyped')
111
+ ],
112
+ return_type: return_type
113
+ )
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,4 @@
1
+ module SorbetErb
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sorbet_erb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Franklin Hu
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-09-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: better_html
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 2.1.1
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 2.1.1
27
+ - !ruby/object:Gem::Dependency
28
+ name: psych
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: tapioca
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 0.16.1
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 0.16.1
55
+ description: Extracts Ruby code from ERB files so you can run Sorbet over them
56
+ email:
57
+ - franklin@thisisfranklin.com
58
+ executables:
59
+ - sorbet_erb
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - ".rubocop.yml"
64
+ - README.md
65
+ - Rakefile
66
+ - exe/sorbet_erb
67
+ - lib/sorbet_erb.rb
68
+ - lib/sorbet_erb/code_extractor.rb
69
+ - lib/sorbet_erb/version.rb
70
+ - lib/tapioca/dsl/compilers/view_component_slotables.rb
71
+ - sig/sorbet_erb.rbs
72
+ homepage: https://github.com/franklinhu/sorbet_erb
73
+ licenses: []
74
+ metadata:
75
+ allowed_push_host: https://rubygems.org
76
+ homepage_uri: https://github.com/franklinhu/sorbet_erb
77
+ source_code_uri: https://github.com/franklinhu/sorbet_erb
78
+ changelog_uri: https://github.com/franklinhu/sorbet_erb
79
+ post_install_message:
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 3.0.0
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.5.16
95
+ signing_key:
96
+ specification_version: 4
97
+ summary: Extracts Ruby code from ERB files for Sorbet
98
+ test_files: []