Module: SorbetErb

Defined in:
lib/sorbet_erb.rb,
lib/sorbet_erb/version.rb,
lib/sorbet_erb/code_extractor.rb

Defined Under Namespace

Classes: CodeExtractor, CodeProcessor

Constant Summary collapse

CONFIG_FILE_NAME =
'.sorbet_erb.yml'
DEFAULT_CONFIG =
{
  'input_dirs' => ['app'],
  'exclude_paths' => [],
  'output_dir' => 'sorbet/erb',
  'extra_includes' => [],
  'extra_body' => '',
  'skip_missing_locals' => true
}.freeze
USAGE =
<<~USAGE
  Usage: sorbet_erb input_dir output_dir
    input_dir - where to scan for ERB files
    output_dir - where to write files with Ruby extracted from ERB
USAGE
ERB_TEMPLATE =
<<~ERB_TEMPLATE
  # typed: true
  class SorbetErb<%= class_suffix %> < ApplicationController
    extend T::Sig
    include ActionView::Helpers
    include ApplicationController::HelperMethods
    <% extra_includes.each do |i| %>
      include <%= i %>
    <% end %>

    sig { returns(T::Hash[Symbol, T.untyped]) }
    def local_assigns
      # Shim for typechecking
      {}
    end

    <%= extra_body %>

    <%= locals_sig %>
    def body<%= locals %>
      <% lines.each do |line| %>
        <%= line %>
      <% end %>
    end
  end
ERB_TEMPLATE
VERSION =
'0.2.0'

Class Method Summary collapse

Class Method Details

.extract_rb_from_erb(input_dir, output_dir) ⇒ Object



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/sorbet_erb.rb', line 56

def self.extract_rb_from_erb(input_dir, output_dir)
  config = read_config
  input_dirs =
    if input_dir
      [input_dir]
    else
      config.fetch('input_dirs')
    end
  exclude_paths = config.fetch('exclude_paths')
  output_dir ||= config.fetch('output_dir')
  skip_missing_locals = config.fetch('skip_missing_locals')

  puts 'Clearing output directory'
  FileUtils.rm_rf(output_dir)

  input_dir_to_paths = input_dirs.flat_map do |d|
    Dir.glob(File.join(d, '**', '*.erb')).map do |p|
      [d, p]
    end
  end
  input_dir_to_paths.each do |d, p|
    pathname = Pathname.new(p)

    next if exclude_paths.any? { |p| p.include?(pathname.to_s) }

    extractor = CodeExtractor.new
    lines, locals, locals_sig = extractor.extract(File.read(p))

    # Partials and Turbo streams must use strict locals
    next if requires_defined_locals(pathname.basename.to_s) && locals.nil? && skip_missing_locals

    locals ||= '()'
    locals_sig ||= ''

    rel_output_dir = File.join(
      output_dir,
      pathname.dirname.relative_path_from(d)
    )
    FileUtils.mkdir_p(rel_output_dir)

    output_path = File.join(
      rel_output_dir,
      "#{pathname.basename}.generated.rb"
    )
    erb = ERB.new(ERB_TEMPLATE)
    File.open(output_path, 'w') do |f|
      result = erb.result_with_hash(
        class_suffix: SecureRandom.hex(6),
        locals: locals,
        locals_sig: locals_sig,
        extra_includes: config.fetch('extra_includes'),
        extra_body: config.fetch('extra_body'),
        lines: lines
      )
      f.write(result)
    end
  end
end

.read_configObject



115
116
117
118
119
120
121
122
123
124
# File 'lib/sorbet_erb.rb', line 115

def self.read_config
  path = File.join(Dir.pwd, CONFIG_FILE_NAME)
  config =
    if File.exist?(path)
      Psych.safe_load_file(path)
    else
      {}
    end
  DEFAULT_CONFIG.merge(config)
end

.requires_defined_locals(file_name) ⇒ Object



126
127
128
# File 'lib/sorbet_erb.rb', line 126

def self.requires_defined_locals(file_name)
  file_name.start_with?('_') || file_name.end_with?('.turbo_stream.erb')
end

.start(argv) ⇒ Object



130
131
132
133
134
135
# File 'lib/sorbet_erb.rb', line 130

def self.start(argv)
  input = argv[0]
  output = argv[1]

  SorbetErb.extract_rb_from_erb(input, output)
end