Skip to content

Instantly share code, notes, and snippets.

@tk0miya
Created November 28, 2024 15:23
Show Gist options
  • Save tk0miya/fd766d10af26cc3a0fd2bf3997b6498d to your computer and use it in GitHub Desktop.
Save tk0miya/fd766d10af26cc3a0fd2bf3997b6498d to your computer and use it in GitHub Desktop.
# frozen_string_literal: true
# ERB ファイルの型チェックをするための実験コード
#
# 適当な場所に保存し、Steepfile の先頭で `require_relative 'lib/steep_ext'` のようにロードする。
#
# なお、ERB の型チェックを行うには、各 .erb ファイルの self を差し替える必要がある。
# そのために、各 .erb ファイルに proc { ... }.call というブロックを差し込み、
# ブロックの先頭に `# @type self:` コメントでクラス名やモジュール名を指定する。
#
# <% proc { %>
# <%# @type self: Views::Admin::Index %>
# ...
# <% }.call %>
#
# また、上記の self 型に対応するクラスを用意する。
# 手元では、以下のような ActionView::Base のサブクラスを用意し、適切なメソッド、インスタンス変数の型を定義している。
#
# module Views
# module Admin
# class Index < ActionView::Base
# include _RbsRailsPathHelpers
# @instance_var: String
# end
# end
# end
#
#
# なお、VSCode を利用している場合はホバーなどの UI まわりの機能は動作しない。
# steep-vscode 拡張が指定する documentSelector に "erb" が含まれていないため、
# .erb ファイルに対する操作が Steep サーバまで伝搬しないためである。
# https://github.com/soutaro/steep-vscode/blob/master/src/extension.ts#L114
module Steep
class Project
class Pattern
alias initialize_orig initialize
def initialize(patterns:, ext:, ignores: [])
# Steep は .rb ファイルのみを対象としているので .erb ファイルを対象とするように上書きする。
# 本来は Steep::Project::DSL をハックするべきだが、Steepfile 経由では書き換えができない (すでに実行中である) ため、
# Steep::Project::Pattern の初期化をフックする。
#
# なお、この ext オプションは Steep::Project::Pattern の判定や Steep::Services::FileLoader などでも利用されている
ext = '.erb' if ext == '.rb'
initialize_orig(patterns:, ext:, ignores:)
end
end
end
module Services
class TypeCheckService
class SourceFile
alias initialize_orig initialize
def initialize(path:, node:, content:, typing:, ignores:, errors:) # rubocop:disable Metrics/ParameterLists
initialize_orig(path:, node:, content:, typing:, ignores:, errors:)
@converted = ErbExtractor.new.extract(path:, text: content) if path.extname == '.erb' && content
end
def content
@converted || @content
end
end
end
end
end
class ErbExtractor
def extract(path:, text:) #: String
require 'parser'
require 'better_html'
require 'better_html/parser'
buffer = Parser::Source::Buffer.new(path.to_s, source: text)
parser = BetterHtml::Parser.new(buffer, template_language: :html)
traverse(parser.ast).map { |node| whiten(node:, buffer:) }.join
end
#: (untyped?) -> Enumerator[String | untyped]
#: (untyped?) { (String | untyped) -> void } -> void
def traverse(node, &block)
return to_enum(__method__, node) unless block
return if node.nil?
if node.is_a?(String) || node.type == :tag || node.type == :erb
# @type node: String | untyped
yield node
else
node.children.each do |child|
traverse(child, &block)
end
end
end
# @rbs node: String | untyped
# @rbs buffer: untyped
def whiten(node:, buffer:) #: String # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
case node
when String
node.gsub(/\S/, ' ')
when AST::Node, BetterHtml::AST::Node
case node.type
when :erb
prefix, _, code, = node.children
case prefix&.children&.first
when '#'
"# #{code.children.first} "
when '='
" #{code.children.first} "
when '=='
" #{code.children.first} "
else
" #{code.children.first} "
end
when :tag
text = buffer.source[node.location.to_range]
text.gsub(/\S/, ' ')
else
Steep.logger.info("Unknown erb node type: #{node}")
raise
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment