require "./annotated_source" require "./util" # Mixin for `expect_issue` and `expect_no_issues` # # This mixin makes it easier to specify strict issue expectations # in a declarative and visual fashion. Just type out the code that # should generate an issue, annotate code by writing '^'s # underneath each character that should be highlighted, and follow # the carets with a string (separated by a space) that is the # message of the issue. You can include multiple issues in # one code snippet. # # Usage: # # expect_issue subject, <<-CRYSTAL # a do # b # end.c # # ^^^ error: Avoid chaining a method call on a do...end block. # CRYSTAL # # Equivalent assertion without `expect_issue`: # # source = Source.new <<-CRYSTAL, "source.cr" # a do # b # end.c # CRYSTAL # subject.catch(source).should_not be_valid # source.issues.size.should be(1) # # issue = source.issues.first # issue.location.to_s.should eq "source.cr:3:1" # issue.end_location.to_s.should eq "source.cr:3:5" # issue.message.should eq( # "Avoid chaining a method call on a do...end block." # ) # # Autocorrection can be tested using `expect_correction` after # `expect_issue`. # # source = expect_issue subject, <<-CRYSTAL # x % 2 == 0 # # ^^^^^^^^ error: Replace with `Int#even?`. # CRYSTAL # # expect_correction source, <<-CRYSTAL # x.even? # CRYSTAL # # If you do not want to specify an issue then use the # companion method `expect_no_issues`. This method is a much # simpler assertion since it just inspects the code and checks # that there were no issues. The `expect_issue` method has # to do more work by parsing out lines that contain carets. # # If the code produces an issue that could not be auto-corrected, you can # use `expect_no_corrections` after `expect_issue`. # # source = expect_issue subject, <<-CRYSTAL # a do # b # end.c # # ^^^ error: Avoid chaining a method call on a do...end block. # CRYSTAL # # expect_no_corrections source # # If your code has variables of different lengths, you can use `%{foo}`, # `^{foo}`, and `_{foo}` to format your template; you can also abbreviate # issue messages with `[...]`: # # %w[raise fail].each do |keyword| # expect_issue subject, <<-CRYSTAL, keyword: keyword # %{keyword} Exception.new(msg) # # ^{keyword}^^^^^^^^^^^^^^^^^ error: Redundant `Exception.new` [...] # CRYSTAL # # %w[has_one has_many].each do |type| # expect_issue subject, <<-CRYSTAL, type: type # class Book # %{type} :chapter, foreign_key: "book_id" # _{type} # ^^^^^^^^^^^^^^^^^^^^^^ error: Specifying the default [...] # end # CRYSTAL # end # # If you need to specify an issue on a blank line, use the empty `^{}` marker: # # expect_issue subject, <<-CRYSTAL # # # ^{} error: Missing frozen string literal comment. # puts 1 # CRYSTAL module Ameba::Spec::ExpectIssue include Spec::Util def expect_issue(rules : Rule::Base | Enumerable(Rule::Base), annotated_code : String, path = "", *, file = __FILE__, line = __LINE__, **replacements) annotated_code = format_issue(annotated_code, **replacements) expected_annotations = AnnotatedSource.parse(annotated_code) lines = expected_annotations.lines code = lines.join('\n') if code == annotated_code raise "Use `report_no_issues` to assert that no issues are found" end source, actual_annotations = actual_annotations(rules, code, path, lines) unless actual_annotations == expected_annotations fail <<-MSG, file, line Expected: #{expected_annotations} Got: #{actual_annotations} MSG end source end def expect_correction(source, correction, *, file = __FILE__, line = __LINE__) raise "Use `expect_no_corrections` if the code will not change" unless source.correct? return if correction == source.code fail <<-MSG, file, line Expected correction: #{correction} Got: #{source.code} MSG end def expect_no_corrections(source, *, file = __FILE__, line = __LINE__) return unless source.correct? fail <<-MSG, file, line Expected no corrections, but got: #{source.code} MSG end def expect_no_issues(rules : Rule::Base | Enumerable(Rule::Base), code : String, path = "", *, file = __FILE__, line = __LINE__) lines = code.split('\n') # must preserve trailing newline _, actual_annotations = actual_annotations(rules, code, path, lines) return if actual_annotations.to_s == code fail <<-MSG, file, line Expected no issues, but got: #{actual_annotations} MSG end private def actual_annotations(rules, code, path, lines) source = Source.new(code, path, normalize: false) if rules.is_a?(Enumerable) rules.each(&.catch(source)) else rules.catch(source) end {source, AnnotatedSource.new(lines, source.issues)} end private def format_issue(code, **replacements) replacements.each do |keyword, value| value = value.to_s code = code .gsub("%{#{keyword}}", value) .gsub("^{#{keyword}}", "^" * value.size) .gsub("_{#{keyword}}", " " * value.size) end code end end