diff --git a/USAGE.md b/USAGE.md index 5bf11d86b..94f3c274f 100644 --- a/USAGE.md +++ b/USAGE.md @@ -80,6 +80,28 @@ Packwerk reads from the `packwerk.yml` configuration file in the root directory. | cache | false | when true, caches the results of parsing files | | cache_directory | tmp/cache/packwerk | the directory that will hold the packwerk cache | +### Using custom parsers + +You can specify a custom parser to parse different file formats (e.g. slim or haml) + +```ruby +class SlimParser + include ParserInterface + + REGEX = /\.slim\Z/ + + def call + # Your parsing logic here + end + + def match?(path) + REGEX.match?(path) + end +end + +Packwerk::Parsers::Factory.instance.parsers.append(SlimParser) +``` + ### Using a custom ERB parser You can specify a custom ERB parser if needed. For example, if you're using `<%graphql>` tags from https://github.com/github/graphql-client in your ERBs, you can use a custom parser subclass to comment them out so that Packwerk can parse the rest of the file: @@ -99,7 +121,7 @@ class CustomParser < Packwerk::Parsers::Erb end end -Packwerk::Parsers::Factory.instance.erb_parser_class = CustomParser +Packwerk::Parsers::Factory.instance.parsers = [Packwerk::Parsers::Ruby, CustomParser] ``` ## Using the cache diff --git a/lib/packwerk.rb b/lib/packwerk.rb index 26d9bd127..bcf2addc5 100644 --- a/lib/packwerk.rb +++ b/lib/packwerk.rb @@ -35,6 +35,7 @@ module Packwerk autoload :Package autoload :PackageSet autoload :ParsedConstantDefinitions + autoload :Parser autoload :Parsers autoload :ParseRun autoload :UnresolvedReference diff --git a/lib/packwerk/file_processor.rb b/lib/packwerk/file_processor.rb index 104ecc48c..b0eefb211 100644 --- a/lib/packwerk/file_processor.rb +++ b/lib/packwerk/file_processor.rb @@ -56,7 +56,7 @@ def call(absolute_file) private sig do - params(node: Parser::AST::Node, absolute_file: String).returns(T::Array[UnresolvedReference]) + params(node: ::Parser::AST::Node, absolute_file: String).returns(T::Array[UnresolvedReference]) end def references_from_ast(node, absolute_file) references = [] @@ -68,14 +68,14 @@ def references_from_ast(node, absolute_file) references end - sig { params(absolute_file: String, parser: Parsers::ParserInterface).returns(T.untyped) } + sig { params(absolute_file: String, parser: Packwerk::Parser).returns(T.untyped) } def parse_into_ast(absolute_file, parser) File.open(absolute_file, "r", nil, external_encoding: Encoding::UTF_8) do |file| parser.call(io: file, file_path: absolute_file) end end - sig { params(file_path: String).returns(T.nilable(Parsers::ParserInterface)) } + sig { params(file_path: String).returns(T.nilable(Packwerk::Parser)) } def parser_for(file_path) @parser_factory.for_path(file_path) end diff --git a/lib/packwerk/node.rb b/lib/packwerk/node.rb index 1787ced76..7c657e919 100644 --- a/lib/packwerk/node.rb +++ b/lib/packwerk/node.rb @@ -60,7 +60,7 @@ def constant_name(constant_node) def each_child(node) if block_given? node.children.each do |child| - yield child if child.is_a?(Parser::AST::Node) + yield child if child.is_a?(::Parser::AST::Node) end else enum_for(:each_child, node) diff --git a/lib/packwerk/node_processor.rb b/lib/packwerk/node_processor.rb index c0eb12ddd..2477c87e6 100644 --- a/lib/packwerk/node_processor.rb +++ b/lib/packwerk/node_processor.rb @@ -19,8 +19,8 @@ def initialize(reference_extractor:, absolute_file:) sig do params( - node: Parser::AST::Node, - ancestors: T::Array[Parser::AST::Node] + node: ::Parser::AST::Node, + ancestors: T::Array[::Parser::AST::Node] ).returns(T.nilable(UnresolvedReference)) end def call(node, ancestors) diff --git a/lib/packwerk/parser.rb b/lib/packwerk/parser.rb new file mode 100644 index 000000000..94e3ed8ef --- /dev/null +++ b/lib/packwerk/parser.rb @@ -0,0 +1,33 @@ +# typed: strict +# frozen_string_literal: true + +module Packwerk + module Parser + extend T::Helpers + extend T::Sig + + requires_ancestor { Kernel } + + interface! + + @parsers = T.let([], T::Array[Class]) + + sig { params(base: Class).void } + def self.included(base) + @parsers << base + end + + sig { returns(T::Array[Parser]) } + def self.all + T.unsafe(@parsers).map(&:new) + end + + sig { abstract.params(io: File, file_path: String).returns(T.untyped) } + def call(io:, file_path:) + end + + sig { abstract.params(path: String).returns(T::Boolean) } + def match?(path:) + end + end +end diff --git a/lib/packwerk/parsers/erb.rb b/lib/packwerk/parsers/erb.rb index 98d056d8d..6f8644a3c 100644 --- a/lib/packwerk/parsers/erb.rb +++ b/lib/packwerk/parsers/erb.rb @@ -9,7 +9,10 @@ module Packwerk module Parsers class Erb - include ParserInterface + include Packwerk::Parser + + ERB_REGEX = /\.erb\Z/ + private_constant :ERB_REGEX def initialize(parser_class: BetterHtml::Parser, ruby_parser: Ruby.new) @parser_class = parser_class @@ -17,7 +20,7 @@ def initialize(parser_class: BetterHtml::Parser, ruby_parser: Ruby.new) end def call(io:, file_path: "") - buffer = Parser::Source::Buffer.new(file_path) + buffer = ::Parser::Source::Buffer.new(file_path) buffer.source = io.read parse_buffer(buffer, file_path: file_path) end @@ -28,11 +31,15 @@ def parse_buffer(buffer, file_path:) rescue EncodingError => e result = ParseResult.new(file: file_path, message: e.message) raise Parsers::ParseError, result - rescue Parser::SyntaxError => e + rescue ::Parser::SyntaxError => e result = ParseResult.new(file: file_path, message: "Syntax error: #{e}") raise Parsers::ParseError, result end + def match?(path:) + ERB_REGEX.match?(path) + end + private def to_ruby_ast(erb_ast, file_path) diff --git a/lib/packwerk/parsers/factory.rb b/lib/packwerk/parsers/factory.rb index c38c39413..32a4ff1a6 100644 --- a/lib/packwerk/parsers/factory.rb +++ b/lib/packwerk/parsers/factory.rb @@ -9,34 +9,9 @@ class Factory extend T::Sig include Singleton - RUBY_REGEX = %r{ - # Although not important for regex, these are ordered from most likely to match to least likely. - \.(rb|rake|builder|gemspec|ru)\Z - | - (Gemfile|Rakefile)\Z - }x - private_constant :RUBY_REGEX - - ERB_REGEX = /\.erb\Z/ - private_constant :ERB_REGEX - - sig { params(path: String).returns(T.nilable(ParserInterface)) } + sig { params(path: String).returns(T.nilable(Packwerk::Parser)) } def for_path(path) - case path - when RUBY_REGEX - @ruby_parser ||= Ruby.new - when ERB_REGEX - @erb_parser ||= erb_parser_class.new - end - end - - def erb_parser_class - @erb_parser_class ||= Erb - end - - def erb_parser_class=(klass) - @erb_parser_class = klass - @erb_parser = nil + Packwerk::Parser.all.find { |parser| parser.match?(path: path) } end end end diff --git a/lib/packwerk/parsers/parser_interface.rb b/lib/packwerk/parsers/parser_interface.rb deleted file mode 100644 index 451f009dd..000000000 --- a/lib/packwerk/parsers/parser_interface.rb +++ /dev/null @@ -1,17 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -module Packwerk - module Parsers - module ParserInterface - extend T::Helpers - extend T::Sig - - interface! - - sig { abstract.params(io: File, file_path: String).returns(T.untyped) } - def call(io:, file_path:) - end - end - end -end diff --git a/lib/packwerk/parsers/ruby.rb b/lib/packwerk/parsers/ruby.rb index ed2e8385b..feec45bb8 100644 --- a/lib/packwerk/parsers/ruby.rb +++ b/lib/packwerk/parsers/ruby.rb @@ -7,16 +7,24 @@ module Packwerk module Parsers class Ruby - include ParserInterface + include Packwerk::Parser - class RaiseExceptionsParser < Parser::CurrentRuby + RUBY_REGEX = %r{ + # Although not important for regex, these are ordered from most likely to match to least likely. + \.(rb|rake|builder|gemspec|ru)\Z + | + (Gemfile|Rakefile)\Z + }x + private_constant :RUBY_REGEX + + class RaiseExceptionsParser < ::Parser::CurrentRuby def initialize(builder) super(builder) super.diagnostics.all_errors_are_fatal = true end end - class TolerateInvalidUtf8Builder < Parser::Builders::Default + class TolerateInvalidUtf8Builder < ::Parser::Builders::Default def string_value(token) value(token) end @@ -28,17 +36,21 @@ def initialize(parser_class: RaiseExceptionsParser) end def call(io:, file_path: "") - buffer = Parser::Source::Buffer.new(file_path) + buffer = ::Parser::Source::Buffer.new(file_path) buffer.source = io.read parser = @parser_class.new(@builder) parser.parse(buffer) rescue EncodingError => e result = ParseResult.new(file: file_path, message: e.message) raise Parsers::ParseError, result - rescue Parser::SyntaxError => e + rescue ::Parser::SyntaxError => e result = ParseResult.new(file: file_path, message: "Syntax error: #{e}") raise Parsers::ParseError, result end + + def match?(path:) + RUBY_REGEX.match?(path) + end end end end diff --git a/lib/packwerk/reference_extractor.rb b/lib/packwerk/reference_extractor.rb index 2dfed6fe3..3f633d58c 100644 --- a/lib/packwerk/reference_extractor.rb +++ b/lib/packwerk/reference_extractor.rb @@ -25,8 +25,8 @@ def initialize( sig do params( - node: Parser::AST::Node, - ancestors: T::Array[Parser::AST::Node], + node: ::Parser::AST::Node, + ancestors: T::Array[::Parser::AST::Node], absolute_file: String ).returns(T.nilable(UnresolvedReference)) end @@ -99,8 +99,8 @@ def self.get_fully_qualified_references_and_offenses_from(unresolved_references_ sig do params( constant_name: String, - node: Parser::AST::Node, - ancestors: T::Array[Parser::AST::Node], + node: ::Parser::AST::Node, + ancestors: T::Array[::Parser::AST::Node], absolute_file: String ).returns(T.nilable(UnresolvedReference)) end diff --git a/test/unit/parsers/erb_test.rb b/test/unit/parsers/erb_test.rb index c7cadb799..261b228fa 100644 --- a/test/unit/parsers/erb_test.rb +++ b/test/unit/parsers/erb_test.rb @@ -19,7 +19,7 @@ class ErbTest < Minitest::Test test "#call writes parse error to stdout" do error_message = "stub error" - err = Parser::SyntaxError.new(stub(message: error_message)) + err = ::Parser::SyntaxError.new(stub(message: error_message)) parser = stub parser.stubs(:ast).raises(err) diff --git a/test/unit/parsers/factory_test.rb b/test/unit/parsers/factory_test.rb index b0b3af765..7df4d033d 100644 --- a/test/unit/parsers/factory_test.rb +++ b/test/unit/parsers/factory_test.rb @@ -23,14 +23,20 @@ class FactoryTest < Minitest::Test assert_instance_of(Parsers::Erb, factory.for_path("foo.html.erb")) assert_instance_of(Parsers::Erb, factory.for_path("foo.md.erb")) assert_instance_of(Parsers::Erb, factory.for_path("/sub/directory/foo.erb")) + end + test "#for_path gives custom parser for matching paths" do fake_class = Class.new do - T.unsafe(self).include(ParserInterface) - end + T.unsafe(self).include(Packwerk::Parser) - with_erb_parser_class(fake_class) do - assert_instance_of(fake_class, factory.for_path("foo.html.erb")) + def match?(path:) + /\.slim\Z/.match?(path) + end end + + assert_instance_of(fake_class, factory.for_path("foo.html.slim")) + assert_instance_of(fake_class, factory.for_path("foo.md.slim")) + assert_instance_of(fake_class, factory.for_path("/sub/directory/foo.slim")) end test "#for_path gives nil for unknown path" do @@ -41,13 +47,6 @@ class FactoryTest < Minitest::Test private - def with_erb_parser_class(klass) - factory.erb_parser_class = klass - yield - ensure - factory.erb_parser_class = nil - end - def factory Parsers::Factory.instance end diff --git a/test/unit/parsers/ruby_test.rb b/test/unit/parsers/ruby_test.rb index 790652faa..efa700e77 100644 --- a/test/unit/parsers/ruby_test.rb +++ b/test/unit/parsers/ruby_test.rb @@ -16,7 +16,7 @@ class RubyTest < Minitest::Test test "#call writes parse error to stdout" do error_message = "stub error" - err = Parser::SyntaxError.new(stub(message: error_message)) + err = ::Parser::SyntaxError.new(stub(message: error_message)) parser = stub parser.stubs(:parse).raises(err)