mirror of
				https://gitea.invidious.io/iv-org/shard-ameba.git
				synced 2024-08-15 00:53:29 +00:00 
			
		
		
		
	Add expect_issue and expect_no_issues spec helpers (#245)
This commit is contained in:
		
							parent
							
								
									48b15b9bf8
								
							
						
					
					
						commit
						3d432fdee8
					
				
					 12 changed files with 609 additions and 126 deletions
				
			
		|  | @ -11,7 +11,7 @@ module Ameba | |||
|   end | ||||
| 
 | ||||
|   describe Formatter::TODOFormatter do | ||||
|     Spec.after_each do | ||||
|     ::Spec.after_each do | ||||
|       FileUtils.rm(Ameba::Config::PATH) if File.exists?(Ameba::Config::PATH) | ||||
|     end | ||||
| 
 | ||||
|  |  | |||
|  | @ -5,7 +5,7 @@ module Ameba::Rule::Lint | |||
| 
 | ||||
|   describe DebuggerStatement do | ||||
|     it "passes if there is no debugger statement" do | ||||
|       s = Source.new %( | ||||
|       expect_no_issues subject, %( | ||||
|         "this is not a debugger statement" | ||||
|         s = "debugger" | ||||
| 
 | ||||
|  | @ -19,16 +19,15 @@ module Ameba::Rule::Lint | |||
|         end | ||||
|         A.new.debugger | ||||
|       ) | ||||
|       subject.catch(s).should be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "fails if there is a debugger statement" do | ||||
|       s = Source.new %( | ||||
|       expect_issue subject, %( | ||||
|         a = 2 | ||||
|         debugger | ||||
|         # ^{} error: Possible forgotten debugger statement detected | ||||
|         a = a + 1 | ||||
|       ) | ||||
|       subject.catch(s).should_not be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "reports rule, pos and message" do | ||||
|  |  | |||
|  | @ -5,21 +5,20 @@ module Ameba::Rule::Lint | |||
| 
 | ||||
|   describe DuplicatedRequire do | ||||
|     it "passes if there are no duplicated requires" do | ||||
|       source = Source.new %( | ||||
|       expect_no_issues subject, %( | ||||
|         require "math" | ||||
|         require "big" | ||||
|         require "big/big_decimal" | ||||
|       ) | ||||
|       subject.catch(source).should be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "reports if there are a duplicated requires" do | ||||
|       source = Source.new %( | ||||
|       expect_issue subject, %( | ||||
|         require "big" | ||||
|         require "math" | ||||
|         require "big" | ||||
|         # ^{} error: Duplicated require of `big` | ||||
|       ) | ||||
|       subject.catch(source).should_not be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "reports rule, pos and message" do | ||||
|  |  | |||
|  | @ -5,7 +5,7 @@ module Ameba::Rule::Lint | |||
|     subject = SharedVarInFiber.new | ||||
| 
 | ||||
|     it "doesn't report if there is only local shared var in fiber" do | ||||
|       s = Source.new %( | ||||
|       expect_no_issues subject, %( | ||||
|         spawn do | ||||
|           i = 1 | ||||
|           puts i | ||||
|  | @ -13,11 +13,10 @@ module Ameba::Rule::Lint | |||
| 
 | ||||
|         Fiber.yield | ||||
|       ) | ||||
|       subject.catch(s).should be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "doesn't report if there is only block shared var in fiber" do | ||||
|       s = Source.new %( | ||||
|       expect_no_issues subject, %( | ||||
|         10.times do |i| | ||||
|           spawn do | ||||
|             puts i | ||||
|  | @ -26,11 +25,10 @@ module Ameba::Rule::Lint | |||
| 
 | ||||
|         Fiber.yield | ||||
|       ) | ||||
|       subject.catch(s).should be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "doesn't report if there a spawn macro is used" do | ||||
|       s = Source.new %( | ||||
|       expect_no_issues subject, %( | ||||
|         i = 0 | ||||
|         while i < 10 | ||||
|           spawn puts(i) | ||||
|  | @ -39,26 +37,25 @@ module Ameba::Rule::Lint | |||
| 
 | ||||
|         Fiber.yield | ||||
|       ) | ||||
|       subject.catch(s).should be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "reports if there is a shared var in spawn" do | ||||
|       s = Source.new %( | ||||
|       expect_issue subject, %( | ||||
|         i = 0 | ||||
|         while i < 10 | ||||
|           spawn do | ||||
|             puts(i) | ||||
|                # ^ error: Shared variable `i` is used in fiber | ||||
|           end | ||||
|           i += 1 | ||||
|         end | ||||
| 
 | ||||
|         Fiber.yield | ||||
|       ) | ||||
|       subject.catch(s).should_not be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "reports reassigned reference to shared var in spawn" do | ||||
|       s = Source.new %( | ||||
|       expect_issue subject, %( | ||||
|         channel = Channel(String).new | ||||
|         n = 0 | ||||
| 
 | ||||
|  | @ -66,15 +63,15 @@ module Ameba::Rule::Lint | |||
|           n = n + 1 | ||||
|           spawn do | ||||
|             m = n | ||||
|               # ^ error: Shared variable `n` is used in fiber | ||||
|             channel.send m | ||||
|           end | ||||
|         end | ||||
|       ) | ||||
|       subject.catch(s).should_not be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "doesn't report reassigned reference to shared var in block" do | ||||
|       s = Source.new %( | ||||
|       expect_no_issues subject, %( | ||||
|         channel = Channel(String).new | ||||
|         n = 0 | ||||
| 
 | ||||
|  | @ -86,49 +83,37 @@ module Ameba::Rule::Lint | |||
|           end | ||||
|         end | ||||
|       ) | ||||
|       subject.catch(s).should be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "does not report block is called in a spawn" do | ||||
|       s = Source.new %( | ||||
|       expect_no_issues subject, %( | ||||
|         def method(block) | ||||
|           spawn do | ||||
|             block.call(10) | ||||
|           end | ||||
|         end | ||||
|       ) | ||||
|       subject.catch(s).should be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "reports multiple shared variables in spawn" do | ||||
|       s = Source.new %( | ||||
|       expect_issue subject, %( | ||||
|         foo, bar, baz = 0, 0, 0 | ||||
|         while foo < 10 | ||||
|           baz += 1 | ||||
|           spawn do | ||||
|             puts foo | ||||
|                # ^^^ error: Shared variable `foo` is used in fiber | ||||
|             puts foo + bar + baz | ||||
|                # ^^^ error: Shared variable `foo` is used in fiber | ||||
|                            # ^^^ error: Shared variable `baz` is used in fiber | ||||
|           end | ||||
|           foo += 1 | ||||
|         end | ||||
|       ) | ||||
|       subject.catch(s).should_not be_valid | ||||
|       s.issues.size.should eq 3 | ||||
|       s.issues[0].location.to_s.should eq ":5:10" | ||||
|       s.issues[0].end_location.to_s.should eq ":5:12" | ||||
|       s.issues[0].message.should eq "Shared variable `foo` is used in fiber" | ||||
| 
 | ||||
|       s.issues[1].location.to_s.should eq ":6:10" | ||||
|       s.issues[1].end_location.to_s.should eq ":6:12" | ||||
|       s.issues[1].message.should eq "Shared variable `foo` is used in fiber" | ||||
| 
 | ||||
|       s.issues[2].location.to_s.should eq ":6:22" | ||||
|       s.issues[2].end_location.to_s.should eq ":6:24" | ||||
|       s.issues[2].message.should eq "Shared variable `baz` is used in fiber" | ||||
|     end | ||||
| 
 | ||||
|     it "doesn't report if variable is passed to the proc" do | ||||
|       s = Source.new %( | ||||
|       expect_no_issues subject, %( | ||||
|         i = 0 | ||||
|         while i < 10 | ||||
|           proc = ->(x : Int32) do | ||||
|  | @ -140,20 +125,18 @@ module Ameba::Rule::Lint | |||
|           i += 1 | ||||
|         end | ||||
|       ) | ||||
|       subject.catch(s).should be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "doesn't report if a channel is declared in outer scope" do | ||||
|       s = Source.new %( | ||||
|       expect_no_issues subject, %( | ||||
|         channel = Channel(Nil).new | ||||
|         spawn { channel.send(nil) } | ||||
|         channel.receive | ||||
|       ) | ||||
|       subject.catch(s).should be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "doesn't report if there is a loop in spawn" do | ||||
|       s = Source.new %( | ||||
|       expect_no_issues subject, %( | ||||
|         channel = Channel(String).new | ||||
| 
 | ||||
|         spawn do | ||||
|  | @ -164,34 +147,30 @@ module Ameba::Rule::Lint | |||
|           end | ||||
|         end | ||||
|       ) | ||||
|       subject.catch(s).should be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "doesn't report if a var is mutated in spawn and referenced outside" do | ||||
|       s = Source.new %( | ||||
|       expect_no_issues subject, %( | ||||
|         def method | ||||
|           foo = 1 | ||||
|           spawn { foo = 2 } | ||||
|           foo | ||||
|         end | ||||
|       ) | ||||
|       subject.catch(s).should be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "doesn't report if variable is changed without iterations" do | ||||
|       s = Source.new %( | ||||
|       expect_no_issues subject, %( | ||||
|         def foo | ||||
|           i = 0 | ||||
|           i += 1 | ||||
|           spawn { i } | ||||
|         end | ||||
|       ), "source.cr" | ||||
| 
 | ||||
|       subject.catch(s).should be_valid | ||||
|       ) | ||||
|     end | ||||
| 
 | ||||
|     it "doesn't report if variable is in a loop inside spawn" do | ||||
|       s = Source.new %( | ||||
|       expect_no_issues subject, %( | ||||
|         i = 0 | ||||
|         spawn do | ||||
|           while i < 10 | ||||
|  | @ -199,19 +178,15 @@ module Ameba::Rule::Lint | |||
|           end | ||||
|         end | ||||
|       ) | ||||
| 
 | ||||
|       subject.catch(s).should be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "doesn't report if variable declared inside loop" do | ||||
|       s = Source.new %( | ||||
|       expect_no_issues subject, %( | ||||
|         while true | ||||
|           i = 0 | ||||
|           spawn { i += 1 } | ||||
|         end | ||||
|       ) | ||||
| 
 | ||||
|       subject.catch(s).should be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "reports rule, location and message" do | ||||
|  |  | |||
|  | @ -5,76 +5,66 @@ module Ameba::Rule::Performance | |||
| 
 | ||||
|   describe AnyAfterFilter do | ||||
|     it "passes if there is no potential performance improvements" do | ||||
|       source = Source.new %( | ||||
|       expect_no_issues subject, %( | ||||
|         [1, 2, 3].select { |e| e > 1 }.any?(&.zero?) | ||||
|         [1, 2, 3].reject { |e| e > 1 }.any?(&.zero?) | ||||
|         [1, 2, 3].select { |e| e > 1 } | ||||
|         [1, 2, 3].reject { |e| e > 1 } | ||||
|         [1, 2, 3].any? { |e| e > 1 } | ||||
|       ) | ||||
|       subject.catch(source).should be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "reports if there is select followed by any? without a block" do | ||||
|       source = Source.new %( | ||||
|       expect_issue subject, %( | ||||
|         [1, 2, 3].select { |e| e > 2 }.any? | ||||
|                 # ^^^^^^^^^^^^^^^^^^^^^^^^^^ error: Use `any? {...}` instead of `select {...}.any?` | ||||
|       ) | ||||
|       subject.catch(source).should_not be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "does not report if source is a spec" do | ||||
|       source = Source.new %( | ||||
|       expect_no_issues subject, %( | ||||
|         [1, 2, 3].select { |e| e > 2 }.any? | ||||
|       ), "source_spec.cr" | ||||
|       subject.catch(source).should be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "reports if there is reject followed by any? without a block" do | ||||
|       source = Source.new %( | ||||
|       expect_issue subject, %( | ||||
|         [1, 2, 3].reject { |e| e > 2 }.any? | ||||
|                 # ^^^^^^^^^^^^^^^^^^^^^^^^^^ error: Use `any? {...}` instead of `reject {...}.any?` | ||||
|       ) | ||||
|       subject.catch(source).should_not be_valid | ||||
|     end | ||||
| 
 | ||||
|     it "does not report if any? calls contains a block" do | ||||
|       source = Source.new %( | ||||
|       expect_no_issues subject, %( | ||||
|         [1, 2, 3].select { |e| e > 2 }.any?(&.zero?) | ||||
|         [1, 2, 3].reject { |e| e > 2 }.any?(&.zero?) | ||||
|       ) | ||||
|       subject.catch(source).should be_valid | ||||
|     end | ||||
| 
 | ||||
|     context "properties" do | ||||
|       it "allows to configure object_call_names" do | ||||
|         source = Source.new %( | ||||
|           [1, 2, 3].reject { |e| e > 2 }.any? | ||||
|         ) | ||||
|         rule = Rule::Performance::AnyAfterFilter.new | ||||
|         rule.filter_names = %w(select) | ||||
|         rule.catch(source).should be_valid | ||||
|         expect_no_issues rule, %( | ||||
|           [1, 2, 3].reject { |e| e > 2 }.any? | ||||
|         ) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context "macro" do | ||||
|       it "reports in macro scope" do | ||||
|         source = Source.new %( | ||||
|         expect_issue subject, %( | ||||
|           {{ [1, 2, 3].reject { |e| e > 2  }.any? }} | ||||
|                      # ^^^^^^^^^^^^^^^^^^^^^^^^^^^ error: Use `any? {...}` instead of `reject {...}.any?` | ||||
|         ) | ||||
|         subject.catch(source).should_not be_valid | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     it "reports rule, pos and message" do | ||||
|       s = Source.new %( | ||||
|       expect_issue subject, %( | ||||
|         [1, 2, 3].reject { |e| e > 2 }.any? | ||||
|       ), "source.cr" | ||||
|       subject.catch(s).should_not be_valid | ||||
|       issue = s.issues.first | ||||
| 
 | ||||
|       issue.rule.should_not be_nil | ||||
|       issue.location.to_s.should eq "source.cr:1:11" | ||||
|       issue.end_location.to_s.should eq "source.cr:1:36" | ||||
|       issue.message.should eq "Use `any? {...}` instead of `reject {...}.any?`" | ||||
|                 # ^^^^^^^^^^^^^^^^^^^^^^^^^^ error: Use `any? {...}` instead of `reject {...}.any?` | ||||
|       ) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
							
								
								
									
										223
									
								
								spec/ameba/spec/annotated_source_spec.cr
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								spec/ameba/spec/annotated_source_spec.cr
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,223 @@ | |||
| require "../../spec_helper" | ||||
| 
 | ||||
| private def dummy_issue(message, | ||||
|                         position : {Int32, Int32}?, | ||||
|                         end_position : {Int32, Int32}?, | ||||
|                         path = "") | ||||
|   location, end_location = nil, nil | ||||
|   location = Crystal::Location.new(path, *position) if position | ||||
|   end_location = Crystal::Location.new(path, *end_position) if end_position | ||||
| 
 | ||||
|   Ameba::Issue.new( | ||||
|     rule: Ameba::DummyRule.new, | ||||
|     location: location, | ||||
|     end_location: end_location, | ||||
|     message: message | ||||
|   ) | ||||
| end | ||||
| 
 | ||||
| private def expect_invalid_location(code, | ||||
|                                     position, | ||||
|                                     end_position, | ||||
|                                     message exception_message, | ||||
|                                     file = __FILE__, | ||||
|                                     line = __LINE__) | ||||
|   expect_raises Exception, exception_message, file, line do | ||||
|     Ameba::Spec::AnnotatedSource.new( | ||||
|       lines: code.lines, | ||||
|       issues: [dummy_issue("Message", position, end_position, "path")] | ||||
|     ) | ||||
|   end | ||||
| end | ||||
| 
 | ||||
| module Ameba::Spec | ||||
|   describe AnnotatedSource do | ||||
|     annotated_text = <<-EOS | ||||
|       line 1 | ||||
|         # ^^ error: Message 1 | ||||
|       line 2 # error: Message 2 | ||||
|       EOS | ||||
| 
 | ||||
|     text_without_annotations = <<-EOS | ||||
|       line 1 | ||||
|       line 2 | ||||
|       EOS | ||||
| 
 | ||||
|     text_without_source = <<-EOS | ||||
|       # ^ error: Message 1 | ||||
|       # ^ error: Message 2 | ||||
|       EOS | ||||
| 
 | ||||
|     describe ".parse" do | ||||
|       it "accepts annotated text" do | ||||
|         annotated_source = AnnotatedSource.parse(annotated_text) | ||||
|         annotated_source.lines.should eq ["line 1", "line 2"] | ||||
|         annotated_source.annotations.should eq [ | ||||
|           {1, "  # ^^ error: ", "Message 1"}, | ||||
|           {2, "", "Message 2"}, | ||||
|         ] | ||||
|       end | ||||
| 
 | ||||
|       it "accepts text containing source only and no annotations" do | ||||
|         annotated_source = AnnotatedSource.parse(text_without_annotations) | ||||
|         annotated_source.lines.should eq ["line 1", "line 2"] | ||||
|         annotated_source.annotations.should be_empty | ||||
|       end | ||||
| 
 | ||||
|       it "accepts text containing annotations only and no source" do | ||||
|         annotated_source = AnnotatedSource.parse(text_without_source) | ||||
|         annotated_source.lines.should be_empty | ||||
|         annotated_source.annotations.should eq [ | ||||
|           {1, "# ^ error: ", "Message 1"}, | ||||
|           {1, "# ^ error: ", "Message 2"}, | ||||
|         ] | ||||
|       end | ||||
| 
 | ||||
|       it "accepts RuboCop-style annotations" do | ||||
|         annotated_source = AnnotatedSource.parse <<-EOS | ||||
|           line 1 | ||||
|               ^^ Message | ||||
|           line 2 | ||||
|           EOS | ||||
| 
 | ||||
|         annotated_source.lines.should eq ["line 1", "line 2"] | ||||
| 
 | ||||
|         annotated_source.annotations.should eq [ | ||||
|           {1, "    ^^ ", "Message"}, | ||||
|         ] | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     describe "#==" do | ||||
|       it "accepts source lines ending with annotations" do | ||||
|         expected = AnnotatedSource.parse <<-EOS | ||||
|           line 1 # error: Message | ||||
|           line 2 | ||||
|           EOS | ||||
| 
 | ||||
|         actual = AnnotatedSource.parse <<-EOS | ||||
|           line 1 | ||||
|             # ^^ error: Message | ||||
|           line 2 | ||||
|           EOS | ||||
| 
 | ||||
|         actual.should eq expected | ||||
|       end | ||||
| 
 | ||||
|       it "accepts annotations that are abbreviated using '[...]'" do | ||||
|         expected = AnnotatedSource.parse <<-EOS | ||||
|           line 1 # error: Message [...] | ||||
|           line 2 | ||||
|             # ^^ error: M[...]s[...]g[...] 2 | ||||
|           EOS | ||||
| 
 | ||||
|         actual = AnnotatedSource.parse <<-EOS | ||||
|           line 1 | ||||
|             # ^^ error: Message 1 | ||||
|           line 2 | ||||
|             # ^^ error: Message 2 | ||||
|           EOS | ||||
| 
 | ||||
|         actual.should eq expected | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     describe "#to_s" do | ||||
|       it "accepts annotated text" do | ||||
|         annotated_source = AnnotatedSource.parse(annotated_text) | ||||
|         annotated_source.to_s.should eq annotated_text | ||||
|       end | ||||
| 
 | ||||
|       it "accepts text containing source only and no annotations" do | ||||
|         annotated_source = AnnotatedSource.parse(text_without_annotations) | ||||
|         annotated_source.to_s.should eq text_without_annotations | ||||
|       end | ||||
| 
 | ||||
|       it "accepts text containing annotations only and no source" do | ||||
|         annotated_source = AnnotatedSource.parse(text_without_source) | ||||
|         annotated_source.to_s.should eq text_without_source | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     describe ".new(lines, annotations)" do | ||||
|       it "sorts the annotations" do | ||||
|         annotated_source = AnnotatedSource.new [] of String, [ | ||||
|           {2, "", "Annotation C"}, | ||||
|           {1, "", "Annotation B"}, | ||||
|           {1, "", "Annotation A"}, | ||||
|         ] | ||||
|         annotated_source.annotations.should eq [ | ||||
|           {1, "", "Annotation A"}, | ||||
|           {1, "", "Annotation B"}, | ||||
|           {2, "", "Annotation C"}, | ||||
|         ] | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     describe ".new(lines, issues)" do | ||||
|       it "raises an exception if issue location is nil" do | ||||
|         expect_invalid_location text_without_annotations, | ||||
|           position: nil, | ||||
|           end_position: nil, | ||||
|           message: "Missing location for issue 'Message'" | ||||
|       end | ||||
| 
 | ||||
|       it "raises an exception if issue starts at column 0" do | ||||
|         expect_invalid_location text_without_annotations, | ||||
|           position: {1, 0}, | ||||
|           end_position: nil, | ||||
|           message: "Invalid issue location: path:1:0" | ||||
|       end | ||||
| 
 | ||||
|       it "raises an exception if issue starts at line 0" do | ||||
|         expect_invalid_location text_without_annotations, | ||||
|           position: {0, 1}, | ||||
|           end_position: nil, | ||||
|           message: "Invalid issue location: path:0:1" | ||||
|       end | ||||
| 
 | ||||
|       it "raises an exception if issue starts at a non-existent line" do | ||||
|         expect_invalid_location text_without_annotations, | ||||
|           position: {3, 1}, | ||||
|           end_position: nil, | ||||
|           message: "Invalid issue location: path:3:1" | ||||
|       end | ||||
| 
 | ||||
|       it "raises an exception if issue ends at column 0" do | ||||
|         expect_invalid_location text_without_annotations, | ||||
|           position: {1, 1}, | ||||
|           end_position: {2, 0}, | ||||
|           message: "Invalid issue end location: path:2:0" | ||||
|       end | ||||
| 
 | ||||
|       it "raises an exception if issue ends at a non-existent line" do | ||||
|         expect_invalid_location text_without_annotations, | ||||
|           position: {1, 1}, | ||||
|           end_position: {3, 1}, | ||||
|           message: "Invalid issue end location: path:3:1" | ||||
|       end | ||||
| 
 | ||||
|       it "raises an exception if starting column number is greater than ending column number" do | ||||
|         expect_invalid_location text_without_annotations, | ||||
|           position: {1, 2}, | ||||
|           end_position: {1, 1}, | ||||
|           message: <<-MSG | ||||
|             Invalid issue location | ||||
|               start: path:1:2 | ||||
|               end:   path:1:1 | ||||
|             MSG | ||||
|       end | ||||
| 
 | ||||
|       it "raises an exception if starting line number is greater than ending line number" do | ||||
|         expect_invalid_location text_without_annotations, | ||||
|           position: {2, 1}, | ||||
|           end_position: {1, 1}, | ||||
|           message: <<-MSG | ||||
|             Invalid issue location | ||||
|               start: path:2:1 | ||||
|               end:   path:1:1 | ||||
|             MSG | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -1,5 +1,6 @@ | |||
| require "spec" | ||||
| require "../src/ameba" | ||||
| require "../src/ameba/spec/support" | ||||
| 
 | ||||
| module Ameba | ||||
|   # Dummy Rule which does nothing. | ||||
|  | @ -12,29 +13,6 @@ module Ameba | |||
|     end | ||||
|   end | ||||
| 
 | ||||
|   class Source | ||||
|     def initialize(code : String, @path = "", normalize = true) | ||||
|       @code = normalize ? normalize_source(code) : code | ||||
|     end | ||||
| 
 | ||||
|     private def normalize_source(code, separator = "\n") | ||||
|       lines = code.split(separator) | ||||
| 
 | ||||
|       # remove unneeded first blank lines if any | ||||
|       lines.shift if lines[0].blank? && lines.size > 1 | ||||
| 
 | ||||
|       # find the minimum indentation | ||||
|       min_indent = lines.min_of do |line| | ||||
|         line.blank? ? code.size : line.size - line.lstrip.size | ||||
|       end | ||||
| 
 | ||||
|       # remove the width of minimum indentation in each line | ||||
|       lines | ||||
|         .map! { |line| line.blank? ? line : line[min_indent..-1] } | ||||
|         .join(separator) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   class NamedRule < Rule::Base | ||||
|     properties do | ||||
|       description "A rule with a custom name." | ||||
|  | @ -140,25 +118,6 @@ module Ameba | |||
|     end | ||||
|   end | ||||
| 
 | ||||
|   struct BeValidExpectation | ||||
|     def match(source) | ||||
|       source.valid? | ||||
|     end | ||||
| 
 | ||||
|     def failure_message(source) | ||||
|       String.build do |str| | ||||
|         str << "Source expected to be valid, but there are issues: \n\n" | ||||
|         source.issues.reject(&.disabled?).each do |e| | ||||
|           str << "  * #{e.rule.name}: #{e.message}\n" | ||||
|         end | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     def negative_failure_message(source) | ||||
|       "Source expected to be invalid, but it is valid." | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   class TestNodeVisitor < Crystal::Visitor | ||||
|     NODES = [ | ||||
|       Crystal::NilLiteral, | ||||
|  | @ -197,10 +156,6 @@ module Ameba | |||
|   end | ||||
| end | ||||
| 
 | ||||
| def be_valid | ||||
|   Ameba::BeValidExpectation.new | ||||
| end | ||||
| 
 | ||||
| def as_node(source) | ||||
|   Crystal::Parser.new(source).parse | ||||
| end | ||||
|  |  | |||
							
								
								
									
										172
									
								
								src/ameba/spec/annotated_source.cr
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								src/ameba/spec/annotated_source.cr
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,172 @@ | |||
| # Parsed representation of code annotated with the `# ^^^ error: Message` style | ||||
| class Ameba::Spec::AnnotatedSource | ||||
|   ANNOTATION_PATTERN_1 = /\A\s*(# )?(\^+|\^{})( error:)? / | ||||
|   ANNOTATION_PATTERN_2 = " # error: " | ||||
|   ABBREV               = "[...]" | ||||
| 
 | ||||
|   getter lines : Array(String) | ||||
| 
 | ||||
|   # Each entry is the line number, annotation prefix, and message. | ||||
|   # The prefix is empty if the annotation is at the end of a code line. | ||||
|   getter annotations : Array({Int32, String, String}) | ||||
| 
 | ||||
|   # Separates annotation lines from code lines. Tracks the real | ||||
|   # code line number that each annotation corresponds to. | ||||
|   def self.parse(annotated_code) | ||||
|     lines = [] of String | ||||
|     annotations = [] of {Int32, String, String} | ||||
|     annotated_code.each_line do |code_line| | ||||
|       if (annotation_match = ANNOTATION_PATTERN_1.match(code_line)) | ||||
|         message_index = annotation_match.end | ||||
|         prefix = code_line[0...message_index] | ||||
|         message = code_line[message_index...] | ||||
|         annotations << {lines.size, prefix, message} | ||||
|       elsif (annotation_index = code_line.index(ANNOTATION_PATTERN_2)) | ||||
|         lines << code_line[...annotation_index] | ||||
|         message_index = annotation_index + ANNOTATION_PATTERN_2.size | ||||
|         message = code_line[message_index...] | ||||
|         annotations << {lines.size, "", message} | ||||
|       else | ||||
|         lines << code_line | ||||
|       end | ||||
|     end | ||||
|     annotations.map! { |_, prefix, message| {1, prefix, message} } if lines.empty? | ||||
|     new(lines, annotations) | ||||
|   end | ||||
| 
 | ||||
|   # NOTE: Annotations are sorted so that reconstructing the annotation | ||||
|   #       text via `#to_s` is deterministic. | ||||
|   def initialize(@lines, annotations : Enumerable({Int32, String, String})) | ||||
|     @annotations = annotations.to_a.sort_by { |line, _, message| {line, message} } | ||||
|   end | ||||
| 
 | ||||
|   # Annotates the source code with the Ameba issues provided. | ||||
|   # | ||||
|   # NOTE: Annotations are sorted so that reconstructing the annotation | ||||
|   #       text via `#to_s` is deterministic. | ||||
|   def initialize(@lines, issues : Enumerable(Issue)) | ||||
|     @annotations = issues_to_annotations(issues).sort_by { |line, _, message| {line, message} } | ||||
|   end | ||||
| 
 | ||||
|   def ==(other) | ||||
|     other.is_a?(AnnotatedSource) && other.lines == lines && match_annotations?(other) | ||||
|   end | ||||
| 
 | ||||
|   private def match_annotations?(other) | ||||
|     return false unless annotations.size == other.annotations.size | ||||
| 
 | ||||
|     annotations.zip(other.annotations) do |(actual_line, actual_prefix, actual_message), (expected_line, expected_prefix, expected_message)| | ||||
|       return false unless actual_line == expected_line | ||||
|       return false unless expected_prefix.empty? || actual_prefix == expected_prefix | ||||
|       next if actual_message == expected_message | ||||
|       return false unless expected_message.includes?(ABBREV) | ||||
| 
 | ||||
|       regex = /\A#{message_to_regex(expected_message)}\Z/ | ||||
|       return false unless actual_message.matches?(regex) | ||||
|     end | ||||
| 
 | ||||
|     true | ||||
|   end | ||||
| 
 | ||||
|   private def message_to_regex(expected_annotation) | ||||
|     String.build do |io| | ||||
|       offset = 0 | ||||
|       while (index = expected_annotation.index(ABBREV, offset)) | ||||
|         io << Regex.escape(expected_annotation[offset...index]) | ||||
|         io << ".*?" | ||||
|         offset = index + ABBREV.size | ||||
|       end | ||||
|       io << Regex.escape(expected_annotation[offset..]) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   # Constructs an annotated source string (like what we parse). | ||||
|   # | ||||
|   # Reconstructs a deterministic annotated source string. This is | ||||
|   # useful for eliminating semantically irrelevant annotation | ||||
|   # ordering differences. | ||||
|   # | ||||
|   #     source1 = AnnotatedSource.parse(<<-CRYSTAL) | ||||
|   #     line1 | ||||
|   #     ^ Annotation 1 | ||||
|   #      ^^ Annotation 2 | ||||
|   #     CRYSTAL | ||||
|   # | ||||
|   #     source2 = AnnotatedSource.parse(<<-CRYSTAL) | ||||
|   #     line1 | ||||
|   #      ^^ Annotation 2 | ||||
|   #     ^ Annotation 1 | ||||
|   #     CRYSTAL | ||||
|   # | ||||
|   #     source1.to_s == source2.to_s # => true | ||||
|   def to_s(io) | ||||
|     reconstructed = lines.dup | ||||
|     annotations.reverse_each do |line_number, prefix, message| | ||||
|       if prefix.empty? | ||||
|         reconstructed[line_number - 1] += "#{ANNOTATION_PATTERN_2}#{message}" | ||||
|       else | ||||
|         line_number = 0 if lines.empty? | ||||
|         reconstructed.insert(line_number, "#{prefix}#{message}") | ||||
|       end | ||||
|     end | ||||
|     io << reconstructed.join('\n') | ||||
|   end | ||||
| 
 | ||||
|   private def issues_to_annotations(issues) | ||||
|     issues.map do |issue| | ||||
|       line, column, end_line, end_column = validate_location(issue) | ||||
|       indent_count = column - 3 | ||||
|       indent = if indent_count < 0 | ||||
|                  "" | ||||
|                else | ||||
|                  " " * indent_count | ||||
|                end | ||||
|       caret_count = column_length(line, column, end_line, end_column) | ||||
|       carets = if indent_count < 0 || caret_count <= 0 | ||||
|                  "^{}" | ||||
|                else | ||||
|                  "^" * caret_count | ||||
|                end | ||||
|       {line, "#{indent}# #{carets} error: ", issue.message} | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   private def validate_location(issue) | ||||
|     loc, end_loc = issue.location, issue.end_location | ||||
|     raise "Missing location for issue '#{issue.message}'" unless loc | ||||
| 
 | ||||
|     line, column = loc.line_number, loc.column_number | ||||
|     if line > lines.size || line < 1 || column < 1 | ||||
|       raise "Invalid issue location: #{loc}" | ||||
|     end | ||||
| 
 | ||||
|     if end_loc | ||||
|       if end_loc < loc | ||||
|         raise <<-MSG | ||||
|           Invalid issue location | ||||
|             start: #{loc} | ||||
|             end:   #{end_loc} | ||||
|           MSG | ||||
|       end | ||||
| 
 | ||||
|       end_line, end_column = end_loc.line_number, end_loc.column_number | ||||
| 
 | ||||
|       if end_line > lines.size || end_line < 1 || end_column < 1 | ||||
|         raise "Invalid issue end location: #{end_loc}" | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     {line, column, end_line, end_column} | ||||
|   end | ||||
| 
 | ||||
|   private def column_length(line, column, end_line, end_column) | ||||
|     return 1 unless end_line && end_column | ||||
| 
 | ||||
|     if line < end_line | ||||
|       code_line = lines[line - 1] | ||||
|       end_column = code_line.size | ||||
|     end | ||||
| 
 | ||||
|     end_column - column + 1 | ||||
|   end | ||||
| end | ||||
							
								
								
									
										26
									
								
								src/ameba/spec/be_valid.cr
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/ameba/spec/be_valid.cr
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,26 @@ | |||
| module Ameba::Spec | ||||
|   module BeValid | ||||
|     def be_valid | ||||
|       BeValidExpectation.new | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   struct BeValidExpectation | ||||
|     def match(source) | ||||
|       source.valid? | ||||
|     end | ||||
| 
 | ||||
|     def failure_message(source) | ||||
|       String.build do |str| | ||||
|         str << "Source expected to be valid, but there are issues: \n\n" | ||||
|         source.issues.reject(&.disabled?).each do |e| | ||||
|           str << "  * #{e.rule.name}: #{e.message}\n" | ||||
|         end | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     def negative_failure_message(source) | ||||
|       "Source expected to be invalid, but it is valid." | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										108
									
								
								src/ameba/spec/expect_issue.cr
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								src/ameba/spec/expect_issue.cr
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,108 @@ | |||
| require "./annotated_source" | ||||
| require "./util" | ||||
| 
 | ||||
| # 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, %( | ||||
| #       def foo | ||||
| #         a do | ||||
| #           b | ||||
| #         end.c | ||||
| #       # ^^^^^ error: Avoid chaining a method call on a do...end block. | ||||
| #       end | ||||
| #     ) | ||||
| # | ||||
| # Equivalent assertion without `expect_issue`: | ||||
| # | ||||
| #     source = Source.new %( | ||||
| #       def foo | ||||
| #         a do | ||||
| #           b | ||||
| #         end.c | ||||
| #       end | ||||
| #     ), "source.cr" | ||||
| #     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:4:3" | ||||
| #     issue.end_location.to_s.should eq "source.cr:4:7" | ||||
| #     issue.message.should eq( | ||||
| #       "Avoid chaining a method call on a do...end block." | ||||
| #     ) | ||||
| # | ||||
| # 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. | ||||
| module Ameba::Spec::ExpectIssue | ||||
|   include Spec::Util | ||||
| 
 | ||||
|   def expect_issue(rules : Rule::Base | Enumerable(Rule::Base), | ||||
|                    annotated_code : String, | ||||
|                    path = "", | ||||
|                    normalize = true, | ||||
|                    *, | ||||
|                    file = __FILE__, | ||||
|                    line = __LINE__) | ||||
|     annotated_code = normalize_code(annotated_code) if normalize | ||||
|     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 | ||||
| 
 | ||||
|     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 | ||||
|   end | ||||
| 
 | ||||
|   def expect_no_issues(rules : Rule::Base | Enumerable(Rule::Base), | ||||
|                        code : String, | ||||
|                        path = "", | ||||
|                        normalize = true, | ||||
|                        *, | ||||
|                        file = __FILE__, | ||||
|                        line = __LINE__) | ||||
|     code = normalize_code(code) if normalize | ||||
|     lines = code.lines | ||||
|     actual_annotations = actual_annotations(rules, code, path, lines) | ||||
|     unless actual_annotations.to_s == code | ||||
|       fail <<-MSG, file, line | ||||
|         Expected no issues, but got: | ||||
| 
 | ||||
|         #{actual_annotations} | ||||
|         MSG | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   private def actual_annotations(rules, code, path, lines) | ||||
|     source = Source.new(code, path, normalize: false) # already normalized | ||||
|     if rules.is_a?(Enumerable) | ||||
|       rules.each(&.catch(source)) | ||||
|     else | ||||
|       rules.catch(source) | ||||
|     end | ||||
|     AnnotatedSource.new(lines, source.issues) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										18
									
								
								src/ameba/spec/support.cr
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/ameba/spec/support.cr
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,18 @@ | |||
| # Require this file to load code that supports testing Ameba rules. | ||||
| 
 | ||||
| require "./be_valid" | ||||
| require "./expect_issue" | ||||
| require "./util" | ||||
| 
 | ||||
| module Ameba | ||||
|   class Source | ||||
|     include Spec::Util | ||||
| 
 | ||||
|     def initialize(code : String, @path = "", normalize = true) | ||||
|       @code = normalize ? normalize_code(code) : code | ||||
|     end | ||||
|   end | ||||
| end | ||||
| 
 | ||||
| include Ameba::Spec::BeValid | ||||
| include Ameba::Spec::ExpectIssue | ||||
							
								
								
									
										18
									
								
								src/ameba/spec/util.cr
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/ameba/spec/util.cr
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,18 @@ | |||
| module Ameba::Spec::Util | ||||
|   def normalize_code(code, separator = '\n') | ||||
|     lines = code.split(separator) | ||||
| 
 | ||||
|     # remove unneeded first blank lines if any | ||||
|     lines.shift if lines[0].blank? && lines.size > 1 | ||||
| 
 | ||||
|     # find the minimum indentation | ||||
|     min_indent = lines.min_of do |line| | ||||
|       line.blank? ? code.size : line.size - line.lstrip.size | ||||
|     end | ||||
| 
 | ||||
|     # remove the width of minimum indentation in each line | ||||
|     lines.join(separator) do |line| | ||||
|       line.blank? ? line : line[min_indent..] | ||||
|     end | ||||
|   end | ||||
| end | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue