mirror of
				https://gitea.invidious.io/iv-org/shard-ameba.git
				synced 2024-08-15 00:53:29 +00:00 
			
		
		
		
	Merge pull request #248 from FnControlOption/autocorrect
Add autocorrect
This commit is contained in:
		
						commit
						7cb0c15747
					
				
					 29 changed files with 1040 additions and 62 deletions
				
			
		|  | @ -69,24 +69,24 @@ module Ameba::Formatter | |||
| 
 | ||||
|     describe "#affected_code" do | ||||
|       it "returns nil if there is no such a line number" do | ||||
|         source = Source.new %( | ||||
|         code = <<-EOF | ||||
|           a = 1 | ||||
|         ) | ||||
|           EOF | ||||
|         location = Crystal::Location.new("filename", 2, 1) | ||||
|         subject.affected_code(source, location).should be_nil | ||||
|         subject.affected_code(code, location).should be_nil | ||||
|       end | ||||
| 
 | ||||
|       it "returns correct line if it is found" do | ||||
|         source = Source.new %( | ||||
|         code = <<-EOF | ||||
|           a = 1 | ||||
|         ) | ||||
|           EOF | ||||
|         location = Crystal::Location.new("filename", 1, 1) | ||||
|         subject.deansify(subject.affected_code(source, location)) | ||||
|         subject.deansify(subject.affected_code(code, location)) | ||||
|           .should eq "> a = 1\n  ^\n" | ||||
|       end | ||||
| 
 | ||||
|       it "returns correct line if it is found" do | ||||
|         source = Source.new <<-EOF | ||||
|         code = <<-EOF | ||||
|           # pre:1 | ||||
|             # pre:2 | ||||
|               # pre:3 | ||||
|  | @ -101,7 +101,7 @@ module Ameba::Formatter | |||
|           EOF | ||||
| 
 | ||||
|         location = Crystal::Location.new("filename", 6, 1) | ||||
|         subject.deansify(subject.affected_code(source, location, context_lines: 3)) | ||||
|         subject.deansify(subject.affected_code(code, location, context_lines: 3)) | ||||
|           .should eq <<-STR | ||||
|             >     # pre:3 | ||||
|             >       # pre:4 | ||||
|  |  | |||
|  | @ -3,7 +3,8 @@ require "../spec_helper" | |||
| module Ameba | ||||
|   describe Issue do | ||||
|     it "accepts rule and message" do | ||||
|       issue = Issue.new rule: DummyRule.new, | ||||
|       issue = Issue.new code: "", | ||||
|         rule: DummyRule.new, | ||||
|         location: nil, | ||||
|         end_location: nil, | ||||
|         message: "Blah", | ||||
|  | @ -15,7 +16,8 @@ module Ameba | |||
| 
 | ||||
|     it "accepts location" do | ||||
|       location = Crystal::Location.new("path", 3, 2) | ||||
|       issue = Issue.new rule: DummyRule.new, | ||||
|       issue = Issue.new code: "", | ||||
|         rule: DummyRule.new, | ||||
|         location: location, | ||||
|         end_location: nil, | ||||
|         message: "Blah", | ||||
|  | @ -27,7 +29,8 @@ module Ameba | |||
| 
 | ||||
|     it "accepts end_location" do | ||||
|       location = Crystal::Location.new("path", 3, 2) | ||||
|       issue = Issue.new rule: DummyRule.new, | ||||
|       issue = Issue.new code: "", | ||||
|         rule: DummyRule.new, | ||||
|         location: nil, | ||||
|         end_location: location, | ||||
|         message: "Blah", | ||||
|  | @ -38,7 +41,8 @@ module Ameba | |||
|     end | ||||
| 
 | ||||
|     it "accepts status" do | ||||
|       issue = Issue.new rule: DummyRule.new, | ||||
|       issue = Issue.new code: "", | ||||
|         rule: DummyRule.new, | ||||
|         location: nil, | ||||
|         end_location: nil, | ||||
|         message: "", | ||||
|  | @ -50,7 +54,8 @@ module Ameba | |||
|     end | ||||
| 
 | ||||
|     it "sets status to :enabled by default" do | ||||
|       issue = Issue.new rule: DummyRule.new, | ||||
|       issue = Issue.new code: "", | ||||
|         rule: DummyRule.new, | ||||
|         location: nil, | ||||
|         end_location: nil, | ||||
|         message: "" | ||||
|  |  | |||
|  | @ -5,28 +5,26 @@ module Ameba::Rule::Layout | |||
| 
 | ||||
|   describe TrailingBlankLines do | ||||
|     it "passes if there is a blank line at the end of a source" do | ||||
|       source = Source.new "a = 1\n", normalize: false | ||||
|       subject.catch(source).should be_valid | ||||
|       expect_no_issues subject, "a = 1\n", normalize: false | ||||
|     end | ||||
| 
 | ||||
|     it "passes if source is empty" do | ||||
|       source = Source.new "" | ||||
|       subject.catch(source).should be_valid | ||||
|       expect_no_issues subject, "" | ||||
|     end | ||||
| 
 | ||||
|     it "fails if there is no blank lines at the end" do | ||||
|       source = Source.new "no-blankline" | ||||
|       subject.catch(source).should_not be_valid | ||||
|       source = expect_issue subject, "no-blankline # error: Trailing newline missing" | ||||
|       expect_correction source, "no-blankline\n" | ||||
|     end | ||||
| 
 | ||||
|     it "fails if there more then one blank line at the end of a source" do | ||||
|       source = Source.new "a = 1\n \n", normalize: false | ||||
|       subject.catch(source).should_not be_valid | ||||
|       source = expect_issue subject, "a = 1\n \n # error: Excessive trailing newline detected", normalize: false | ||||
|       expect_no_corrections source | ||||
|     end | ||||
| 
 | ||||
|     it "fails if last line is not blank" do | ||||
|       source = Source.new "\n\n\n puts 22", normalize: false | ||||
|       subject.catch(source).should_not be_valid | ||||
|       source = expect_issue subject, "\n\n\n puts 22 # error: Trailing newline missing", normalize: false | ||||
|       expect_correction source, "\n\n\n puts 22\n" | ||||
|     end | ||||
| 
 | ||||
|     context "when unnecessary blank line has been detected" do | ||||
|  | @ -36,7 +34,7 @@ module Ameba::Rule::Layout | |||
| 
 | ||||
|         issue = source.issues.first | ||||
|         issue.rule.should_not be_nil | ||||
|         issue.location.to_s.should eq "source.cr:2:1" | ||||
|         issue.location.to_s.should eq "source.cr:3:1" | ||||
|         issue.end_location.should be_nil | ||||
|         issue.message.should eq "Excessive trailing newline detected" | ||||
|       end | ||||
|  | @ -49,7 +47,7 @@ module Ameba::Rule::Layout | |||
| 
 | ||||
|         issue = source.issues.first | ||||
|         issue.rule.should_not be_nil | ||||
|         issue.location.to_s.should eq "source.cr:0:1" | ||||
|         issue.location.to_s.should eq "source.cr:1:1" | ||||
|         issue.end_location.should be_nil | ||||
|         issue.message.should eq "Trailing newline missing" | ||||
|       end | ||||
|  |  | |||
|  | @ -7,10 +7,14 @@ module Ameba | |||
|     it "transforms large number #{number}" do | ||||
|       rule = Rule::Style::LargeNumbers.new | ||||
| 
 | ||||
|       expect_issue rule, <<-CRYSTAL, number: number | ||||
|       source = expect_issue rule, <<-CRYSTAL, number: number | ||||
|         number = %{number} | ||||
|                # ^{number} error: Large numbers should be written with underscores: #{expected} | ||||
|         CRYSTAL | ||||
| 
 | ||||
|       expect_correction source, <<-CRYSTAL | ||||
|         number = #{expected} | ||||
|         CRYSTAL | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|  |  | |||
|  | @ -8,6 +8,13 @@ module Ameba | |||
| 
 | ||||
|     config.update_rule ErrorRule.rule_name, enabled: false | ||||
|     config.update_rule PerfRule.rule_name, enabled: false | ||||
|     config.update_rule AtoAA.rule_name, enabled: false | ||||
|     config.update_rule AtoB.rule_name, enabled: false | ||||
|     config.update_rule BtoA.rule_name, enabled: false | ||||
|     config.update_rule BtoC.rule_name, enabled: false | ||||
|     config.update_rule CtoA.rule_name, enabled: false | ||||
|     config.update_rule ClassToModule.rule_name, enabled: false | ||||
|     config.update_rule ModuleToClass.rule_name, enabled: false | ||||
| 
 | ||||
|     Runner.new(config) | ||||
|   end | ||||
|  | @ -54,6 +61,15 @@ module Ameba | |||
|         Runner.new(all_rules, [source], formatter, default_severity).run.success?.should be_true | ||||
|       end | ||||
| 
 | ||||
|       it "aborts because of an infinite loop" do | ||||
|         rules = [AtoAA.new] of Rule::Base | ||||
|         source = Source.new "class A; end", "source.cr" | ||||
|         message = "Infinite loop in source.cr caused by Ameba/AtoAA" | ||||
|         expect_raises(Runner::InfiniteCorrectionLoopError, message) do | ||||
|           Runner.new(rules, [source], formatter, default_severity, autocorrect: true).run | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context "exception in rule" do | ||||
|         it "raises an exception raised in fiber while running a rule" do | ||||
|           rule = RaiseRule.new | ||||
|  | @ -180,5 +196,56 @@ module Ameba | |||
|           .run.success?.should be_true | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     describe "#run with rules autocorrecting each other" do | ||||
|       context "with two conflicting rules" do | ||||
|         context "if there is an offense in an inspected file" do | ||||
|           it "aborts because of an infinite loop" do | ||||
|             rules = [AtoB.new, BtoA.new] | ||||
|             source = Source.new "class A; end", "source.cr" | ||||
|             message = "Infinite loop in source.cr caused by Ameba/AtoB -> Ameba/BtoA" | ||||
|             expect_raises(Runner::InfiniteCorrectionLoopError, message) do | ||||
|               Runner.new(rules, [source], formatter, default_severity, autocorrect: true).run | ||||
|             end | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         context "if there are multiple offenses in an inspected file" do | ||||
|           it "aborts because of an infinite loop" do | ||||
|             rules = [AtoB.new, BtoA.new] | ||||
|             source = Source.new %( | ||||
|               class A; end | ||||
|               class A_A; end | ||||
|             ), "source.cr" | ||||
|             message = "Infinite loop in source.cr caused by Ameba/AtoB -> Ameba/BtoA" | ||||
|             expect_raises(Runner::InfiniteCorrectionLoopError, message) do | ||||
|               Runner.new(rules, [source], formatter, default_severity, autocorrect: true).run | ||||
|             end | ||||
|           end | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context "with two pairs of conflicting rules" do | ||||
|         it "aborts because of an infinite loop" do | ||||
|           rules = [ClassToModule.new, ModuleToClass.new, AtoB.new, BtoA.new] | ||||
|           source = Source.new "class A_A; end", "source.cr" | ||||
|           message = "Infinite loop in source.cr caused by Ameba/ClassToModule, Ameba/AtoB -> Ameba/ModuleToClass, Ameba/BtoA" | ||||
|           expect_raises(Runner::InfiniteCorrectionLoopError, message) do | ||||
|             Runner.new(rules, [source], formatter, default_severity, autocorrect: true).run | ||||
|           end | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context "with three rule cycle" do | ||||
|         it "aborts because of an infinite loop" do | ||||
|           rules = [AtoB.new, BtoC.new, CtoA.new] | ||||
|           source = Source.new "class A; end", "source.cr" | ||||
|           message = "Infinite loop in source.cr caused by Ameba/AtoB -> Ameba/BtoC -> Ameba/CtoA" | ||||
|           expect_raises(Runner::InfiniteCorrectionLoopError, message) do | ||||
|             Runner.new(rules, [source], formatter, default_severity, autocorrect: true).run | ||||
|           end | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
							
								
								
									
										110
									
								
								spec/ameba/source/rewriter_spec.cr
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								spec/ameba/source/rewriter_spec.cr
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,110 @@ | |||
| require "../../spec_helper" | ||||
| 
 | ||||
| class Ameba::Source | ||||
|   describe Rewriter do | ||||
|     code = "puts(:hello, :world)" | ||||
|     hello = {5, 11} | ||||
|     comma_space = {11, 13} | ||||
|     world = {13, 19} | ||||
| 
 | ||||
|     it "can remove" do | ||||
|       rewriter = Rewriter.new(code) | ||||
|       rewriter.remove(*hello) | ||||
|       rewriter.process.should eq "puts(, :world)" | ||||
|     end | ||||
| 
 | ||||
|     it "can insert before" do | ||||
|       rewriter = Rewriter.new(code) | ||||
|       rewriter.insert_before(*world, "42, ") | ||||
|       rewriter.process.should eq "puts(:hello, 42, :world)" | ||||
|     end | ||||
| 
 | ||||
|     it "can insert after" do | ||||
|       rewriter = Rewriter.new(code) | ||||
|       rewriter.insert_after(*hello, ", 42") | ||||
|       rewriter.process.should eq "puts(:hello, 42, :world)" | ||||
|     end | ||||
| 
 | ||||
|     it "can wrap" do | ||||
|       rewriter = Rewriter.new(code) | ||||
|       rewriter.wrap(*hello, '[', ']') | ||||
|       rewriter.process.should eq "puts([:hello], :world)" | ||||
|     end | ||||
| 
 | ||||
|     it "can replace" do | ||||
|       rewriter = Rewriter.new(code) | ||||
|       rewriter.replace(*hello, ":hi") | ||||
|       rewriter.process.should eq "puts(:hi, :world)" | ||||
|     end | ||||
| 
 | ||||
|     it "accepts crossing deletions" do | ||||
|       rewriter = Rewriter.new(code) | ||||
|       rewriter.remove(hello[0], comma_space[1]) | ||||
|       rewriter.remove(comma_space[0], world[1]) | ||||
|       rewriter.process.should eq "puts()" | ||||
|     end | ||||
| 
 | ||||
|     it "accepts multiple actions" do | ||||
|       rewriter = Rewriter.new(code) | ||||
|       rewriter.replace(*comma_space, " => ") | ||||
|       rewriter.wrap(hello[0], world[1], '{', '}') | ||||
|       rewriter.replace(*world, ":everybody") | ||||
|       rewriter.wrap(*world, '[', ']') | ||||
|       rewriter.process.should eq "puts({:hello => [:everybody]})" | ||||
|     end | ||||
| 
 | ||||
|     it "can wrap the same range" do | ||||
|       rewriter = Rewriter.new(code) | ||||
|       rewriter.wrap(*hello, '(', ')') | ||||
|       rewriter.wrap(*hello, '[', ']') | ||||
|       rewriter.process.should eq "puts([(:hello)], :world)" | ||||
|     end | ||||
| 
 | ||||
|     it "can insert on empty ranges" do | ||||
|       rewriter = Rewriter.new(code) | ||||
|       rewriter.insert_before(hello[0], '{') | ||||
|       rewriter.replace(hello[0], hello[0], 'x') | ||||
|       rewriter.insert_after(hello[0], '}') | ||||
|       rewriter.insert_before(hello[1], '[') | ||||
|       rewriter.replace(hello[1], hello[1], 'y') | ||||
|       rewriter.insert_after(hello[1], ']') | ||||
|       rewriter.process.should eq "puts({x}:hello[y], :world)" | ||||
|     end | ||||
| 
 | ||||
|     it "can replace the same range" do | ||||
|       rewriter = Rewriter.new(code) | ||||
|       rewriter.replace(*hello, ":hi") | ||||
|       rewriter.replace(*hello, ":hey") | ||||
|       rewriter.process.should eq "puts(:hey, :world)" | ||||
|     end | ||||
| 
 | ||||
|     it "can swallow insertions" do | ||||
|       rewriter = Rewriter.new(code) | ||||
|       rewriter.wrap(hello[0] + 1, hello[1], "__", "__") | ||||
|       rewriter.replace(world[0], world[1] - 2, "xx") | ||||
|       rewriter.replace(hello[0], world[1], ":hi") | ||||
|       rewriter.process.should eq "puts(:hi)" | ||||
|     end | ||||
| 
 | ||||
|     it "rejects out-of-range ranges" do | ||||
|       rewriter = Rewriter.new(code) | ||||
|       expect_raises(IndexError) { rewriter.insert_before(0, 100, "hola") } | ||||
|     end | ||||
| 
 | ||||
|     it "ignores trivial actions" do | ||||
|       rewriter = Rewriter.new(code) | ||||
|       rewriter.empty?.should be_true | ||||
| 
 | ||||
|       # This is a trivial wrap | ||||
|       rewriter.wrap(2, 5, "", "") | ||||
|       rewriter.empty?.should be_true | ||||
| 
 | ||||
|       # This is a trivial deletion | ||||
|       rewriter.remove(2, 2) | ||||
|       rewriter.empty?.should be_true | ||||
| 
 | ||||
|       rewriter.remove(2, 5) | ||||
|       rewriter.empty?.should be_false | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -1,6 +1,7 @@ | |||
| require "../../spec_helper" | ||||
| 
 | ||||
| private def dummy_issue(message, | ||||
| private def dummy_issue(code, | ||||
|                         message, | ||||
|                         position : {Int32, Int32}?, | ||||
|                         end_position : {Int32, Int32}?, | ||||
|                         path = "") | ||||
|  | @ -9,6 +10,7 @@ private def dummy_issue(message, | |||
|   end_location = Crystal::Location.new(path, *end_position) if end_position | ||||
| 
 | ||||
|   Ameba::Issue.new( | ||||
|     code: code, | ||||
|     rule: Ameba::DummyRule.new, | ||||
|     location: location, | ||||
|     end_location: end_location, | ||||
|  | @ -25,7 +27,7 @@ private def expect_invalid_location(code, | |||
|   expect_raises Exception, exception_message, file, line do | ||||
|     Ameba::Spec::AnnotatedSource.new( | ||||
|       lines: code.lines, | ||||
|       issues: [dummy_issue("Message", position, end_position, "path")] | ||||
|       issues: [dummy_issue(code, "Message", position, end_position, "path")] | ||||
|     ) | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -95,6 +95,133 @@ module Ameba | |||
|     end | ||||
|   end | ||||
| 
 | ||||
|   class AtoAA < Rule::Base | ||||
|     include AST::Util | ||||
| 
 | ||||
|     properties do | ||||
|       description "This rule is only used to test infinite loop detection" | ||||
|     end | ||||
| 
 | ||||
|     def test(source, node : Crystal::ClassDef | Crystal::ModuleDef) | ||||
|       return unless (name = node_source(node.name, source.lines)) | ||||
|       return unless name.includes?("A") | ||||
| 
 | ||||
|       issue_for(node.name, message: "A to AA") do |corrector| | ||||
|         corrector.replace(node.name, name.sub("A", "AA")) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   class AtoB < Rule::Base | ||||
|     include AST::Util | ||||
| 
 | ||||
|     properties do | ||||
|       description "This rule is only used to test infinite loop detection" | ||||
|     end | ||||
| 
 | ||||
|     def test(source, node : Crystal::ClassDef | Crystal::ModuleDef) | ||||
|       return unless (name = node_source(node.name, source.lines)) | ||||
|       return unless name.includes?("A") | ||||
| 
 | ||||
|       issue_for(node.name, message: "A to B") do |corrector| | ||||
|         corrector.replace(node.name, name.tr("A", "B")) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   class BtoA < Rule::Base | ||||
|     include AST::Util | ||||
| 
 | ||||
|     properties do | ||||
|       description "This rule is only used to test infinite loop detection" | ||||
|     end | ||||
| 
 | ||||
|     def test(source, node : Crystal::ClassDef | Crystal::ModuleDef) | ||||
|       return unless (name = node_source(node.name, source.lines)) | ||||
|       return unless name.includes?("B") | ||||
| 
 | ||||
|       issue_for(node.name, message: "B to A") do |corrector| | ||||
|         corrector.replace(node.name, name.tr("B", "A")) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   class BtoC < Rule::Base | ||||
|     include AST::Util | ||||
| 
 | ||||
|     properties do | ||||
|       description "This rule is only used to test infinite loop detection" | ||||
|     end | ||||
| 
 | ||||
|     def test(source, node : Crystal::ClassDef | Crystal::ModuleDef) | ||||
|       return unless (name = node_source(node.name, source.lines)) | ||||
|       return unless name.includes?("B") | ||||
| 
 | ||||
|       issue_for(node.name, message: "B to C") do |corrector| | ||||
|         corrector.replace(node.name, name.tr("B", "C")) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   class CtoA < Rule::Base | ||||
|     include AST::Util | ||||
| 
 | ||||
|     properties do | ||||
|       description "This rule is only used to test infinite loop detection" | ||||
|     end | ||||
| 
 | ||||
|     def test(source, node : Crystal::ClassDef | Crystal::ModuleDef) | ||||
|       return unless (name = node_source(node.name, source.lines)) | ||||
|       return unless name.includes?("C") | ||||
| 
 | ||||
|       issue_for(node.name, message: "C to A") do |corrector| | ||||
|         corrector.replace(node.name, name.tr("C", "A")) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   class ClassToModule < Ameba::Rule::Base | ||||
|     include Ameba::AST::Util | ||||
| 
 | ||||
|     properties do | ||||
|       description "This rule is only used to test infinite loop detection" | ||||
|     end | ||||
| 
 | ||||
|     def test(source, node : Crystal::ClassDef) | ||||
|       return unless (location = node.location) | ||||
| 
 | ||||
|       end_location = Crystal::Location.new( | ||||
|         location.filename, | ||||
|         location.line_number, | ||||
|         location.column_number + "class".size - 1 | ||||
|       ) | ||||
|       issue_for(location, end_location, message: "class to module") do |corrector| | ||||
|         corrector.replace(location, end_location, "module") | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   class ModuleToClass < Ameba::Rule::Base | ||||
|     include Ameba::AST::Util | ||||
| 
 | ||||
|     properties do | ||||
|       description "This rule is only used to test infinite loop detection" | ||||
|     end | ||||
| 
 | ||||
|     def test(source, node : Crystal::ModuleDef) | ||||
|       return unless (location = node.location) | ||||
| 
 | ||||
|       end_location = Crystal::Location.new( | ||||
|         location.filename, | ||||
|         location.line_number, | ||||
|         location.column_number + "module".size - 1 | ||||
|       ) | ||||
|       issue_for(location, end_location, message: "module to class") do |corrector| | ||||
|         corrector.replace(location, end_location, "class") | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   class DummyFormatter < Formatter::BaseFormatter | ||||
|     property started_sources : Array(Source)? | ||||
|     property finished_sources : Array(Source)? | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ require "./ameba/*" | |||
| require "./ameba/ast/**" | ||||
| require "./ameba/rule/**" | ||||
| require "./ameba/formatter/*" | ||||
| require "./ameba/source/**" | ||||
| 
 | ||||
| # Ameba's entry module. | ||||
| # | ||||
|  |  | |||
|  | @ -7,7 +7,15 @@ module Ameba::Cli | |||
| 
 | ||||
|   def run(args = ARGV) | ||||
|     opts = parse_args args | ||||
|     location_to_explain = opts.location_to_explain | ||||
|     autocorrect = opts.autocorrect? | ||||
| 
 | ||||
|     if location_to_explain && autocorrect | ||||
|       raise "Invalid usage: Cannot explain an issue and autocorrect at the same time." | ||||
|     end | ||||
| 
 | ||||
|     config = Config.load opts.config, opts.colors? | ||||
|     config.autocorrect = autocorrect | ||||
| 
 | ||||
|     if globs = opts.globs | ||||
|       config.globs = globs | ||||
|  | @ -25,8 +33,8 @@ module Ameba::Cli | |||
| 
 | ||||
|     runner = Ameba.run(config) | ||||
| 
 | ||||
|     if location = opts.location_to_explain | ||||
|       runner.explain(location) | ||||
|     if location_to_explain | ||||
|       runner.explain(location_to_explain) | ||||
|     else | ||||
|       exit 1 unless runner.success? | ||||
|     end | ||||
|  | @ -47,6 +55,7 @@ module Ameba::Cli | |||
|     property? all = false | ||||
|     property? colors = true | ||||
|     property? without_affected_code = false | ||||
|     property? autocorrect = false | ||||
|   end | ||||
| 
 | ||||
|   def parse_args(args, opts = Opts.new) | ||||
|  | @ -89,6 +98,10 @@ module Ameba::Cli | |||
|         opts.all = true | ||||
|       end | ||||
| 
 | ||||
|       parser.on("--fix", "Autocorrect issues") do | ||||
|         opts.autocorrect = true | ||||
|       end | ||||
| 
 | ||||
|       parser.on("--gen-config", | ||||
|         "Generate a configuration file acting as a TODO list") do | ||||
|         opts.formatter = :todo | ||||
|  | @ -133,6 +146,7 @@ module Ameba::Cli | |||
|     if name = opts.formatter | ||||
|       config.formatter = name | ||||
|     end | ||||
|     config.formatter.config[:autocorrect] = opts.autocorrect? | ||||
|     config.formatter.config[:without_affected_code] = | ||||
|       opts.without_affected_code? | ||||
|   end | ||||
|  |  | |||
|  | @ -54,6 +54,9 @@ class Ameba::Config | |||
|   # ``` | ||||
|   property excluded : Array(String) | ||||
| 
 | ||||
|   # Returns true if correctable issues should be autocorrected. | ||||
|   property? autocorrect = false | ||||
| 
 | ||||
|   @rule_groups : Hash(String, Array(Rule::Base)) | ||||
| 
 | ||||
|   # Creates a new instance of `Ameba::Config` based on YAML parameters. | ||||
|  |  | |||
|  | @ -36,13 +36,21 @@ module Ameba::Formatter | |||
|           next if issue.disabled? | ||||
|           next if (location = issue.location).nil? | ||||
| 
 | ||||
|           output.puts location.colorize(:cyan) | ||||
|           output.print location.colorize(:cyan) | ||||
|           if issue.correctable? | ||||
|             if config[:autocorrect]? | ||||
|               output.print " [Corrected]".colorize(:green) | ||||
|             else | ||||
|               output.print " [Correctable]".colorize(:yellow) | ||||
|             end | ||||
|           end | ||||
|           output.puts | ||||
|           output.puts \ | ||||
|             "[#{issue.rule.severity.symbol}] " \ | ||||
|             "#{issue.rule.name}: " \ | ||||
|             "#{issue.message}".colorize(:red) | ||||
| 
 | ||||
|           if show_affected_code && (code = affected_code(source, location, issue.end_location)) | ||||
|           if show_affected_code && (code = affected_code(issue)) | ||||
|             output << code.colorize(:default) | ||||
|           end | ||||
| 
 | ||||
|  |  | |||
|  | @ -38,10 +38,7 @@ module Ameba::Formatter | |||
|     private def explain(source, issue) | ||||
|       rule = issue.rule | ||||
| 
 | ||||
|       location, end_location = | ||||
|         issue.location, issue.end_location | ||||
| 
 | ||||
|       return unless location | ||||
|       return unless (location = issue.location) | ||||
| 
 | ||||
|       output_title "ISSUE INFO" | ||||
|       output_paragraph [ | ||||
|  | @ -49,7 +46,7 @@ module Ameba::Formatter | |||
|         location.to_s.colorize(:cyan).to_s, | ||||
|       ] | ||||
| 
 | ||||
|       if affected_code = affected_code(source, location, end_location, context_lines: 3) | ||||
|       if affected_code = affected_code(issue, context_lines: 3) | ||||
|         output_title "AFFECTED CODE" | ||||
|         output_paragraph affected_code | ||||
|       end | ||||
|  |  | |||
|  | @ -5,6 +5,7 @@ module Ameba::Formatter | |||
|     def source_finished(source : Source) | ||||
|       source.issues.each do |e| | ||||
|         next if e.disabled? | ||||
|         next if e.correctable? && config[:autocorrect]? | ||||
|         if loc = e.location | ||||
|           @mutex.synchronize do | ||||
|             output.printf "%s:%d:%d: %s: [%s] %s\n", | ||||
|  |  | |||
|  | @ -77,6 +77,7 @@ module Ameba::Formatter | |||
| 
 | ||||
|       source.issues.each do |e| | ||||
|         next if e.disabled? | ||||
|         next if e.correctable? && config[:autocorrect]? | ||||
|         json_source.issues << AsJSON::Issue.new(e.rule.name, e.rule.severity.to_s, e.location, e.end_location, e.message) | ||||
|         @result.summary.issues_count += 1 | ||||
|       end | ||||
|  |  | |||
|  | @ -42,6 +42,7 @@ module Ameba::Formatter | |||
|       Hash(Rule::Base, Array(Issue)).new.tap do |h| | ||||
|         issues.each do |issue| | ||||
|           next if issue.disabled? || issue.rule.is_a?(Rule::Lint::Syntax) | ||||
|           next if issue.correctable? && config[:autocorrect]? | ||||
|           (h[issue.rule] ||= Array(Issue).new) << issue | ||||
|         end | ||||
|       end | ||||
|  |  | |||
|  | @ -40,8 +40,14 @@ module Ameba::Formatter | |||
|       {pre_context, post_context} | ||||
|     end | ||||
| 
 | ||||
|     def affected_code(source, location, end_location = nil, context_lines = 0, max_length = 120, ellipsis = " ...", prompt = "> ") | ||||
|       lines = source.lines | ||||
|     def affected_code(issue : Issue, context_lines = 0, max_length = 120, ellipsis = " ...", prompt = "> ") | ||||
|       return unless (location = issue.location) | ||||
| 
 | ||||
|       affected_code(issue.code, location, issue.end_location, context_lines, max_length, ellipsis, prompt) | ||||
|     end | ||||
| 
 | ||||
|     def affected_code(code, location, end_location = nil, context_lines = 0, max_length = 120, ellipsis = " ...", prompt = "> ") | ||||
|       lines = code.split('\n') # must preserve trailing newline | ||||
|       lineno, column = | ||||
|         location.line_number, location.column_number | ||||
| 
 | ||||
|  |  | |||
|  | @ -6,6 +6,9 @@ module Ameba | |||
|       Disabled | ||||
|     end | ||||
| 
 | ||||
|     # The source code that triggered this issue. | ||||
|     getter code : String | ||||
| 
 | ||||
|     # A rule that triggers this issue. | ||||
|     getter rule : Rule::Base | ||||
| 
 | ||||
|  | @ -24,12 +27,20 @@ module Ameba | |||
|     delegate :enabled?, :disabled?, | ||||
|       to: status | ||||
| 
 | ||||
|     def initialize(@rule, @location, @end_location, @message, status : Status? = nil) | ||||
|     def initialize(@code, @rule, @location, @end_location, @message, status : Status? = nil, @block : (Source::Corrector ->)? = nil) | ||||
|       @status = status || Status::Enabled | ||||
|     end | ||||
| 
 | ||||
|     def syntax? | ||||
|       rule.is_a?(Rule::Lint::Syntax) | ||||
|     end | ||||
| 
 | ||||
|     def correctable? | ||||
|       !@block.nil? | ||||
|     end | ||||
| 
 | ||||
|     def correct(corrector) | ||||
|       @block.try &.call(corrector) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -5,41 +5,76 @@ module Ameba | |||
|     getter issues = [] of Issue | ||||
| 
 | ||||
|     # Adds a new issue to the list of issues. | ||||
|     def add_issue(rule, location, end_location, message, status : Issue::Status? = nil) : Issue | ||||
|     def add_issue(rule, location, end_location, message, status : Issue::Status? = nil, block : (Source::Corrector ->)? = nil) : Issue | ||||
|       status ||= | ||||
|         Issue::Status::Disabled if location_disabled?(location, rule) | ||||
| 
 | ||||
|       Issue.new(rule, location, end_location, message, status).tap do |issue| | ||||
|       Issue.new(code, rule, location, end_location, message, status, block).tap do |issue| | ||||
|         issues << issue | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     # :ditto: | ||||
|     def add_issue(rule, location, end_location, message, status : Issue::Status? = nil, &block : Source::Corrector ->) : Issue | ||||
|       add_issue rule, location, end_location, message, status, block | ||||
|     end | ||||
| 
 | ||||
|     # Adds a new issue for Crystal AST *node*. | ||||
|     def add_issue(rule, node : Crystal::ASTNode, message, status : Issue::Status? = nil) : Issue | ||||
|       add_issue rule, node.location, node.end_location, message, status | ||||
|     def add_issue(rule, node : Crystal::ASTNode, message, status : Issue::Status? = nil, block : (Source::Corrector ->)? = nil) : Issue | ||||
|       add_issue rule, node.location, node.end_location, message, status, block | ||||
|     end | ||||
| 
 | ||||
|     # :ditto: | ||||
|     def add_issue(rule, node : Crystal::ASTNode, message, status : Issue::Status? = nil, &block : Source::Corrector ->) : Issue | ||||
|       add_issue rule, node, message, status, block | ||||
|     end | ||||
| 
 | ||||
|     # Adds a new issue for Crystal *token*. | ||||
|     def add_issue(rule, token : Crystal::Token, message, status : Issue::Status? = nil) : Issue | ||||
|       add_issue rule, token.location, nil, message, status | ||||
|     def add_issue(rule, token : Crystal::Token, message, status : Issue::Status? = nil, block : (Source::Corrector ->)? = nil) : Issue | ||||
|       add_issue rule, token.location, nil, message, status, block | ||||
|     end | ||||
| 
 | ||||
|     # :ditto: | ||||
|     def add_issue(rule, token : Crystal::Token, message, status : Issue::Status? = nil, &block : Source::Corrector ->) : Issue | ||||
|       add_issue rule, token, message, status, block | ||||
|     end | ||||
| 
 | ||||
|     # Adds a new issue for *location* defined by line and column numbers. | ||||
|     def add_issue(rule, location : {Int32, Int32}, message, status : Issue::Status? = nil) : Issue | ||||
|     def add_issue(rule, location : {Int32, Int32}, message, status : Issue::Status? = nil, block : (Source::Corrector ->)? = nil) : Issue | ||||
|       location = | ||||
|         Crystal::Location.new(path, *location) | ||||
| 
 | ||||
|       add_issue rule, location, nil, message, status | ||||
|       add_issue rule, location, nil, message, status, block | ||||
|     end | ||||
| 
 | ||||
|     # :ditto: | ||||
|     def add_issue(rule, location : {Int32, Int32}, message, status : Issue::Status? = nil, &block : Source::Corrector ->) : Issue | ||||
|       add_issue rule, location, message, status, block | ||||
|     end | ||||
| 
 | ||||
|     # Adds a new issue for *location* and *end_location* defined by line and column numbers. | ||||
|     def add_issue(rule, location : {Int32, Int32}, end_location : {Int32, Int32}, message, status : Issue::Status? = nil) : Issue | ||||
|     def add_issue(rule, | ||||
|                   location : {Int32, Int32}, | ||||
|                   end_location : {Int32, Int32}, | ||||
|                   message, | ||||
|                   status : Issue::Status? = nil, | ||||
|                   block : (Source::Corrector ->)? = nil) : Issue | ||||
|       location = | ||||
|         Crystal::Location.new(path, *location) | ||||
|       end_location = | ||||
|         Crystal::Location.new(path, *end_location) | ||||
| 
 | ||||
|       add_issue rule, location, end_location, message, status | ||||
|       add_issue rule, location, end_location, message, status, block | ||||
|     end | ||||
| 
 | ||||
|     # :ditto: | ||||
|     def add_issue(rule, | ||||
|                   location : {Int32, Int32}, | ||||
|                   end_location : {Int32, Int32}, | ||||
|                   message, | ||||
|                   status : Issue::Status? = nil, | ||||
|                   &block : Source::Corrector ->) : Issue | ||||
|       add_issue rule, location, end_location, message, status, block | ||||
|     end | ||||
| 
 | ||||
|     # Returns `true` if the list of not disabled issues is empty, `false` otherwise. | ||||
|  |  | |||
|  | @ -112,8 +112,8 @@ module Ameba::Rule | |||
|       name.hash | ||||
|     end | ||||
| 
 | ||||
|     macro issue_for(*args) | ||||
|       source.add_issue self, {{*args}} | ||||
|     macro issue_for(*args, **kwargs, &block) | ||||
|       source.add_issue(self, {{*args}}, {{**kwargs}}) {{block}} | ||||
|     end | ||||
| 
 | ||||
|     protected def self.rule_name | ||||
|  |  | |||
|  | @ -25,7 +25,13 @@ module Ameba::Rule::Layout | |||
| 
 | ||||
|       last_line_empty = last_source_line.empty? | ||||
|       if source_lines_size >= 1 && (source_lines.last(2).join.blank? || !last_line_empty) | ||||
|         issue_for({source_lines_size - 1, 1}, last_line_empty ? MSG : MSG_FINAL_NEWLINE) | ||||
|         if last_line_empty | ||||
|           issue_for({source_lines_size, 1}, MSG) | ||||
|         else | ||||
|           issue_for({source_lines_size, 1}, MSG_FINAL_NEWLINE) do |corrector| | ||||
|             corrector.insert_before({source_lines_size + 1, 1}, '\n') | ||||
|           end | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  |  | |||
|  | @ -28,7 +28,7 @@ module Ameba::Rule::Style | |||
|   # ``` | ||||
|   class LargeNumbers < Base | ||||
|     properties do | ||||
|       enabled false | ||||
|       enabled true | ||||
|       description "Disallows usage of large numbers without underscore" | ||||
|       int_min_digits 5 | ||||
|     end | ||||
|  | @ -48,7 +48,9 @@ module Ameba::Rule::Style | |||
|             location.line_number, | ||||
|             location.column_number + token.raw.size - 1 | ||||
|           ) | ||||
|           issue_for location, end_location, MSG % expected | ||||
|           issue_for location, end_location, MSG % expected do |corrector| | ||||
|             corrector.replace(location, end_location, expected) | ||||
|           end | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|  |  | |||
|  | @ -1,3 +1,5 @@ | |||
| require "digest" | ||||
| 
 | ||||
| module Ameba | ||||
|   # Represents a runner for inspecting sources files. | ||||
|   # Holds a list of rules to do inspection based on, | ||||
|  | @ -11,6 +13,24 @@ module Ameba | |||
|   # ``` | ||||
|   # | ||||
|   class Runner | ||||
|     # An error indicating that the inspection loop got stuck correcting | ||||
|     # issues back and forth. | ||||
|     class InfiniteCorrectionLoopError < RuntimeError | ||||
|       def initialize(path, issues_by_iteration, loop_start = -1) | ||||
|         root_cause = | ||||
|           issues_by_iteration[loop_start..-1] | ||||
|             .join(" -> ", &.map(&.rule.name).uniq!.join(", ")) | ||||
| 
 | ||||
|         message = String.build do |io| | ||||
|           io << "Infinite loop" | ||||
|           io << " in " << path unless path.empty? | ||||
|           io << " caused by " << root_cause | ||||
|         end | ||||
| 
 | ||||
|         super message | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     # A list of rules to do inspection based on. | ||||
|     @rules : Array(Rule::Base) | ||||
| 
 | ||||
|  | @ -29,6 +49,9 @@ module Ameba | |||
|     # Checks for unneeded disable directives. Always inspects a source last | ||||
|     @unneeded_disable_directive_rule : Rule::Base? | ||||
| 
 | ||||
|     # Returns true if correctable issues should be autocorrected. | ||||
|     private getter? autocorrect : Bool | ||||
| 
 | ||||
|     # Instantiates a runner using a `config`. | ||||
|     # | ||||
|     # ``` | ||||
|  | @ -43,13 +66,14 @@ module Ameba | |||
|       @formatter = config.formatter | ||||
|       @severity = config.severity | ||||
|       @rules = config.rules.select(&.enabled).reject!(&.special?) | ||||
|       @autocorrect = config.autocorrect? | ||||
| 
 | ||||
|       @unneeded_disable_directive_rule = | ||||
|         config.rules | ||||
|           .find &.name.==(Rule::Lint::UnneededDisableDirective.rule_name) | ||||
|     end | ||||
| 
 | ||||
|     protected def initialize(@rules, @sources, @formatter, @severity) | ||||
|     protected def initialize(@rules, @sources, @formatter, @severity, @autocorrect = false) | ||||
|     end | ||||
| 
 | ||||
|     # Performs the inspection. Iterates through all sources and test it using | ||||
|  | @ -89,14 +113,40 @@ module Ameba | |||
|     private def run_source(source) | ||||
|       @formatter.source_started source | ||||
| 
 | ||||
|       if @syntax_rule.catch(source).valid? | ||||
|       # This variable is a 2D array used to track corrected issues after each | ||||
|       # inspection iteration. This is used to output meaningful infinite loop | ||||
|       # error message. | ||||
|       corrected_issues = [] of Array(Issue) | ||||
| 
 | ||||
|       # When running with --fix, we need to inspect the source until no more | ||||
|       # corrections are made (because automatic corrections can introduce new | ||||
|       # issues). In the normal case the loop is only executed once. | ||||
|       loop_unless_infinite(source, corrected_issues) do | ||||
|         # We have to reprocess the source to pick up any changes. Since a | ||||
|         # change could (theoretically) introduce syntax errors, we break the | ||||
|         # loop if we find any. | ||||
|         @syntax_rule.test(source) | ||||
|         break unless source.valid? | ||||
| 
 | ||||
|         @rules.each do |rule| | ||||
|           next if rule.excluded?(source) | ||||
|           rule.test(source) | ||||
|         end | ||||
|         check_unneeded_directives(source) | ||||
|         break unless autocorrect? && source.correct | ||||
| 
 | ||||
|         # The issues that couldn't be corrected will be found again so we | ||||
|         # only keep the corrected ones in order to avoid duplicate reporting. | ||||
|         corrected_issues << source.issues.select(&.correctable?) | ||||
|         source.issues.clear | ||||
|       end | ||||
| 
 | ||||
|       corrected_issues.flatten.reverse_each do |issue| | ||||
|         source.issues.unshift(issue) | ||||
|       end | ||||
| 
 | ||||
|       File.write(source.path, source.code) unless corrected_issues.empty? | ||||
|     ensure | ||||
|       @formatter.source_finished source | ||||
|     end | ||||
| 
 | ||||
|  | @ -130,6 +180,46 @@ module Ameba | |||
|       end | ||||
|     end | ||||
| 
 | ||||
|     private MAX_ITERATIONS = 200 | ||||
| 
 | ||||
|     private def loop_unless_infinite(source, corrected_issues) | ||||
|       # Keep track of the state of the source. If a rule modifies the source | ||||
|       # and another rule undoes it producing identical source we have an | ||||
|       # infinite loop. | ||||
|       processed_sources = [] of String | ||||
| 
 | ||||
|       # It is possible for a rule to keep adding indefinitely to a file, | ||||
|       # making it bigger and bigger. If the inspection loop runs for an | ||||
|       # excessively high number of iterations, this is likely happening. | ||||
|       iterations = 0 | ||||
| 
 | ||||
|       loop do | ||||
|         check_for_infinite_loop(source, corrected_issues, processed_sources) | ||||
| 
 | ||||
|         if (iterations += 1) > MAX_ITERATIONS | ||||
|           raise InfiniteCorrectionLoopError.new(source.path, corrected_issues) | ||||
|         end | ||||
| 
 | ||||
|         yield | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     # Check whether a run created source identical to a previous run, which | ||||
|     # means that we definitely have an infinite loop. | ||||
|     private def check_for_infinite_loop(source, corrected_issues, processed_sources) | ||||
|       checksum = Digest::SHA1.hexdigest(source.code) | ||||
| 
 | ||||
|       if loop_start = processed_sources.index(checksum) | ||||
|         raise InfiniteCorrectionLoopError.new( | ||||
|           source.path, | ||||
|           corrected_issues, | ||||
|           loop_start: loop_start | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       processed_sources << checksum | ||||
|     end | ||||
| 
 | ||||
|     private def check_unneeded_directives(source) | ||||
|       if (rule = @unneeded_disable_directive_rule) && rule.enabled | ||||
|         rule.test(source) | ||||
|  |  | |||
|  | @ -22,6 +22,20 @@ module Ameba | |||
|     def initialize(@code, @path = "") | ||||
|     end | ||||
| 
 | ||||
|     # Corrects any correctable issues and updates `code`. | ||||
|     # Returns `false` if no issues were corrected. | ||||
|     def correct | ||||
|       corrector = Corrector.new(code) | ||||
|       issues.each(&.correct(corrector)) | ||||
|       corrected_code = corrector.process | ||||
|       return false if code == corrected_code | ||||
| 
 | ||||
|       @code = corrected_code | ||||
|       @lines = nil | ||||
|       @ast = nil | ||||
|       true | ||||
|     end | ||||
| 
 | ||||
|     # Returns lines of code splitted by new line character. | ||||
|     # Since `code` is immutable and can't be changed, this | ||||
|     # method caches lines in an instance variable, so calling | ||||
|  |  | |||
							
								
								
									
										136
									
								
								src/ameba/source/corrector.cr
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								src/ameba/source/corrector.cr
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,136 @@ | |||
| require "./rewriter" | ||||
| 
 | ||||
| class Ameba::Source | ||||
|   # This class takes source code and rewrites it based | ||||
|   # on the different correction actions supplied. | ||||
|   class Corrector | ||||
|     @line_sizes = [] of Int32 | ||||
| 
 | ||||
|     def initialize(code : String) | ||||
|       code.each_line(chomp: false) do |line| | ||||
|         @line_sizes << line.size | ||||
|       end | ||||
|       @rewriter = Rewriter.new(code) | ||||
|     end | ||||
| 
 | ||||
|     # Replaces the code of the given range with *content*. | ||||
|     def replace(location, end_location, content) | ||||
|       @rewriter.replace(loc_to_pos(location), loc_to_pos(end_location) + 1, content) | ||||
|     end | ||||
| 
 | ||||
|     # Inserts the given strings before and after the given range. | ||||
|     def wrap(location, end_location, insert_before, insert_after) | ||||
|       @rewriter.wrap(loc_to_pos(location), loc_to_pos(end_location) + 1, insert_before, insert_after) | ||||
|     end | ||||
| 
 | ||||
|     # Shortcut for `replace(location, end_location, "")` | ||||
|     def remove(location, end_location) | ||||
|       @rewriter.remove(loc_to_pos(location), loc_to_pos(end_location) + 1) | ||||
|     end | ||||
| 
 | ||||
|     # Shortcut for `wrap(location, end_location, content, nil)` | ||||
|     def insert_before(location, end_location, content) | ||||
|       @rewriter.insert_before(loc_to_pos(location), loc_to_pos(end_location) + 1, content) | ||||
|     end | ||||
| 
 | ||||
|     # Shortcut for `wrap(location, end_location, nil, content)` | ||||
|     def insert_after(location, end_location, content) | ||||
|       @rewriter.insert_after(loc_to_pos(location), loc_to_pos(end_location) + 1, content) | ||||
|     end | ||||
| 
 | ||||
|     # Shortcut for `insert_before(location, location, content)` | ||||
|     def insert_before(location, content) | ||||
|       @rewriter.insert_before(loc_to_pos(location), content) | ||||
|     end | ||||
| 
 | ||||
|     # Shortcut for `insert_after(location, location, content)` | ||||
|     def insert_after(location, content) | ||||
|       @rewriter.insert_after(loc_to_pos(location) + 1, content) | ||||
|     end | ||||
| 
 | ||||
|     # Removes *size* characters prior to the source range. | ||||
|     def remove_preceding(location, end_location, size) | ||||
|       @rewriter.remove(loc_to_pos(location) - size, loc_to_pos(location)) | ||||
|     end | ||||
| 
 | ||||
|     # Removes *size* characters from the beginning of the given range. | ||||
|     # If *size* is greater than the size of the range, the removed region can | ||||
|     # overrun the end of the range. | ||||
|     def remove_leading(location, end_location, size) | ||||
|       remove(loc_to_pos(location), loc_to_pos(location) + size) | ||||
|     end | ||||
| 
 | ||||
|     # Removes *size* characters from the end of the given range. | ||||
|     # If *size* is greater than the size of the range, the removed region can | ||||
|     # overrun the beginning of the range. | ||||
|     def remove_trailing(location, end_location, size) | ||||
|       remove(loc_to_pos(end_location) + 1 - size, loc_to_pos(end_location) + 1) | ||||
|     end | ||||
| 
 | ||||
|     private def loc_to_pos(location : Crystal::Location | {Int32, Int32}) | ||||
|       if location.is_a?(Crystal::Location) | ||||
|         line, column = location.line_number, location.column_number | ||||
|       else | ||||
|         line, column = location | ||||
|       end | ||||
|       @line_sizes[0...line - 1].sum + (column - 1) | ||||
|     end | ||||
| 
 | ||||
|     # Replaces the code of the given node with *content*. | ||||
|     def replace(node : Crystal::ASTNode, content) | ||||
|       replace(location(node), end_location(node), content) | ||||
|     end | ||||
| 
 | ||||
|     # Inserts the given strings before and after the given node. | ||||
|     def wrap(node : Crystal::ASTNode, insert_before, insert_after) | ||||
|       wrap(location(node), end_location(node), insert_before, insert_after) | ||||
|     end | ||||
| 
 | ||||
|     # Shortcut for `replace(node, "")` | ||||
|     def remove(node : Crystal::ASTNode) | ||||
|       remove(location(node), end_location(node)) | ||||
|     end | ||||
| 
 | ||||
|     # Shortcut for `wrap(node, content, nil)` | ||||
|     def insert_before(node : Crystal::ASTNode, content) | ||||
|       insert_before(location(node), content) | ||||
|     end | ||||
| 
 | ||||
|     # Shortcut for `wrap(node, nil, content)` | ||||
|     def insert_after(node : Crystal::ASTNode, content) | ||||
|       insert_after(end_location(node), content) | ||||
|     end | ||||
| 
 | ||||
|     # Removes *size* characters prior to the given node. | ||||
|     def remove_preceding(node : Crystal::ASTNode, size) | ||||
|       remove_preceding(location(node), end_location(node), size) | ||||
|     end | ||||
| 
 | ||||
|     # Removes *size* characters from the beginning of the given node. | ||||
|     # If *size* is greater than the size of the node, the removed region can | ||||
|     # overrun the end of the node. | ||||
|     def remove_leading(node : Crystal::ASTNode, size) | ||||
|       remove_leading(location(node), end_location(node), size) | ||||
|     end | ||||
| 
 | ||||
|     # Removes *size* characters from the end of the given node. | ||||
|     # If *size* is greater than the size of the node, the removed region can | ||||
|     # overrun the beginning of the node. | ||||
|     def remove_trailing(node : Crystal::ASTNode, size) | ||||
|       remove_trailing(location(node), end_location(node), size) | ||||
|     end | ||||
| 
 | ||||
|     private def location(node : Crystal::ASTNode) | ||||
|       node.location || raise "Missing location" | ||||
|     end | ||||
| 
 | ||||
|     private def end_location(node : Crystal::ASTNode) | ||||
|       node.end_location || raise "Missing end location" | ||||
|     end | ||||
| 
 | ||||
|     # Applies all scheduled changes and returns modified source as a new string. | ||||
|     def process | ||||
|       @rewriter.process | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										132
									
								
								src/ameba/source/rewriter.cr
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								src/ameba/source/rewriter.cr
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,132 @@ | |||
| class Ameba::Source | ||||
|   # This class performs the heavy lifting in the source rewriting process. | ||||
|   # It schedules code updates to be performed in the correct order. | ||||
|   # | ||||
|   # For simple cases, the resulting source will be obvious. | ||||
|   # | ||||
|   # Examples for more complex cases follow. Assume these examples are acting on | ||||
|   # the source `puts(:hello, :world)`. The methods `#wrap`, `#remove`, etc. | ||||
|   # receive a range as the first two arguments; for clarity, examples below use | ||||
|   # English sentences and a string of raw code instead. | ||||
|   # | ||||
|   # ## Overlapping deletions: | ||||
|   # | ||||
|   # * remove `:hello, ` | ||||
|   # * remove `, :world` | ||||
|   # | ||||
|   # The overlapping ranges are merged and `:hello, :world` will be removed. | ||||
|   # | ||||
|   # ## Multiple actions at the same end points: | ||||
|   # | ||||
|   # Results will always be independent of the order they were given. | ||||
|   # Exception: rewriting actions done on exactly the same range (covered next). | ||||
|   # | ||||
|   # Example: | ||||
|   # | ||||
|   # * replace `, ` by ` => ` | ||||
|   # * wrap `:hello, :world` with `{` and `}` | ||||
|   # * replace `:world` with `:everybody` | ||||
|   # * wrap `:world` with `[`, `]` | ||||
|   # | ||||
|   # The resulting string will be `puts({:hello => [:everybody]})` | ||||
|   # and this result is independent of the order the instructions were given in. | ||||
|   # | ||||
|   # ## Multiple wraps on same range: | ||||
|   # | ||||
|   # * wrap `:hello` with `(` and `)` | ||||
|   # * wrap `:hello` with `[` and `]` | ||||
|   # | ||||
|   # The wraps are combined in order given and results would be `puts([(:hello)], :world)`. | ||||
|   # | ||||
|   # ## Multiple replacements on same range: | ||||
|   # | ||||
|   # * replace `:hello` by `:hi`, then | ||||
|   # * replace `:hello` by `:hey` | ||||
|   # | ||||
|   # The replacements are made in the order given, so the latter replacement | ||||
|   # supersedes the former and `:hello` will be replaced by `:hey`. | ||||
|   # | ||||
|   # ## Swallowed insertions: | ||||
|   # | ||||
|   # * wrap `world` by `__`, `__` | ||||
|   # * replace `:hello, :world` with `:hi` | ||||
|   # | ||||
|   # A containing replacement will swallow the contained rewriting actions | ||||
|   # and `:hello, :world` will be replaced by `:hi`. | ||||
|   # | ||||
|   # ## Implementation | ||||
|   # | ||||
|   # The updates are organized in a tree, according to the ranges they act on | ||||
|   # (where children are strictly contained by their parent). | ||||
|   class Rewriter | ||||
|     getter code : String | ||||
| 
 | ||||
|     def initialize(@code) | ||||
|       @action_root = Rewriter::Action.new(0, code.size) | ||||
|     end | ||||
| 
 | ||||
|     # Returns true if no (non trivial) update has been recorded | ||||
|     def empty? | ||||
|       @action_root.empty? | ||||
|     end | ||||
| 
 | ||||
|     # Replaces the code of the given range with *content*. | ||||
|     def replace(begin_pos, end_pos, content) | ||||
|       combine(begin_pos, end_pos, replacement: content.to_s) | ||||
|     end | ||||
| 
 | ||||
|     # Inserts the given strings before and after the given range. | ||||
|     def wrap(begin_pos, end_pos, insert_before, insert_after) | ||||
|       combine(begin_pos, end_pos, insert_before: insert_before.to_s, insert_after: insert_after.to_s) | ||||
|     end | ||||
| 
 | ||||
|     # Shortcut for `replace(begin_pos, end_pos, "")` | ||||
|     def remove(begin_pos, end_pos) | ||||
|       replace(begin_pos, end_pos, "") | ||||
|     end | ||||
| 
 | ||||
|     # Shortcut for `wrap(begin_pos, end_pos, content, nil)` | ||||
|     def insert_before(begin_pos, end_pos, content) | ||||
|       wrap(begin_pos, end_pos, content, nil) | ||||
|     end | ||||
| 
 | ||||
|     # Shortcut for `wrap(begin_pos, end_pos, nil, content)` | ||||
|     def insert_after(begin_pos, end_pos, content) | ||||
|       wrap(begin_pos, end_pos, nil, content) | ||||
|     end | ||||
| 
 | ||||
|     # Shortcut for `insert_before(pos, pos, content)` | ||||
|     def insert_before(pos, content) | ||||
|       insert_before(pos, pos, content) | ||||
|     end | ||||
| 
 | ||||
|     # Shortcut for `insert_after(pos, pos, content)` | ||||
|     def insert_after(pos, content) | ||||
|       insert_after(pos, pos, content) | ||||
|     end | ||||
| 
 | ||||
|     # Applies all scheduled changes and returns modified source as a new string. | ||||
|     def process | ||||
|       String.build do |io| | ||||
|         last_end = 0 | ||||
|         @action_root.ordered_replacements.each do |begin_pos, end_pos, replacement| | ||||
|           io << code[last_end...begin_pos] << replacement | ||||
|           last_end = end_pos | ||||
|         end | ||||
|         io << code[last_end...code.size] | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     protected def combine(begin_pos, end_pos, **attributes) | ||||
|       check_range_validity(begin_pos, end_pos) | ||||
|       action = Rewriter::Action.new(begin_pos, end_pos, **attributes) | ||||
|       @action_root = @action_root.combine(action) | ||||
|     end | ||||
| 
 | ||||
|     private def check_range_validity(begin_pos, end_pos) | ||||
|       if begin_pos < 0 || end_pos > code.size | ||||
|         raise IndexError.new("The range #{begin_pos}...#{end_pos} is outside the bounds of the source") | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										179
									
								
								src/ameba/source/rewriter/action.cr
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								src/ameba/source/rewriter/action.cr
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,179 @@ | |||
| class Ameba::Source::Rewriter | ||||
|   # :nodoc: | ||||
|   # Actions are arranged in a tree and get combined so that: | ||||
|   # - children are strictly contained by their parent | ||||
|   # - siblings all disjoint from one another and ordered | ||||
|   # - only actions with `replacement == nil` may have children | ||||
|   class Action | ||||
|     getter begin_pos : Int32 | ||||
|     getter end_pos : Int32 | ||||
|     getter replacement : String? | ||||
|     getter insert_before : String | ||||
|     getter insert_after : String | ||||
|     protected getter children : Array(Action) | ||||
| 
 | ||||
|     def initialize(@begin_pos, | ||||
|                    @end_pos, | ||||
|                    @insert_before = "", | ||||
|                    @replacement = nil, | ||||
|                    @insert_after = "", | ||||
|                    @children = [] of Action) | ||||
|     end | ||||
| 
 | ||||
|     def combine(action) | ||||
|       return self if action.empty? # Ignore empty action | ||||
| 
 | ||||
|       if action.begin_pos == @begin_pos && action.end_pos == @end_pos | ||||
|         merge(action) | ||||
|       else | ||||
|         place_in_hierarchy(action) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     def empty? | ||||
|       replacement = @replacement | ||||
|       @insert_before.empty? && | ||||
|         @insert_after.empty? && | ||||
|         @children.empty? && | ||||
|         (replacement.nil? || (replacement.empty? && @begin_pos == @end_pos)) | ||||
|     end | ||||
| 
 | ||||
|     def ordered_replacements | ||||
|       replacement = @replacement | ||||
|       reps = [] of {Int32, Int32, String} | ||||
|       reps << {@begin_pos, @begin_pos, @insert_before} unless @insert_before.empty? | ||||
|       reps << {@begin_pos, @end_pos, replacement} if replacement | ||||
|       reps.concat(@children.flat_map(&.ordered_replacements)) | ||||
|       reps << {@end_pos, @end_pos, @insert_after} unless @insert_after.empty? | ||||
|       reps | ||||
|     end | ||||
| 
 | ||||
|     def insertion? | ||||
|       replacement = @replacement | ||||
|       !@insert_before.empty? || !@insert_after.empty? || (replacement && !replacement.empty?) | ||||
|     end | ||||
| 
 | ||||
|     protected def with(*, | ||||
|                        begin_pos = @begin_pos, | ||||
|                        end_pos = @end_pos, | ||||
|                        insert_before = @insert_before, | ||||
|                        replacement = @replacement, | ||||
|                        insert_after = @insert_after, | ||||
|                        children = @children) | ||||
|       children = [] of Action if replacement | ||||
|       self.class.new(begin_pos, end_pos, insert_before, replacement, insert_after, children) | ||||
|     end | ||||
| 
 | ||||
|     protected def place_in_hierarchy(action) | ||||
|       family = analyse_hierarchy(action) | ||||
|       sibling_left, sibling_right = family[:sibling_left], family[:sibling_right] | ||||
| 
 | ||||
|       if fusible = family[:fusible] | ||||
|         child = family[:child] | ||||
|         child ||= [] of Action | ||||
|         fuse_deletions(action, fusible, sibling_left + child + sibling_right) | ||||
|       else | ||||
|         extra_sibling = | ||||
|           case | ||||
|           when parent = family[:parent] | ||||
|             # action should be a descendant of one of the children | ||||
|             parent.combine(action) | ||||
|           when child = family[:child] | ||||
|             # or it should become the parent of some of the children, | ||||
|             action.with(children: child).combine_children(action.children) | ||||
|           else | ||||
|             # or else it should become an additional child | ||||
|             action | ||||
|           end | ||||
|         self.with(children: sibling_left + [extra_sibling] + sibling_right) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     # Assumes *more_children* all contained within `@begin_pos...@end_pos` | ||||
|     protected def combine_children(more_children) | ||||
|       more_children.reduce(self) do |parent, new_child| | ||||
|         parent.place_in_hierarchy(new_child) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     protected def fuse_deletions(action, fusible, other_siblings) | ||||
|       without_fusible = self.with(children: other_siblings) | ||||
|       fusible = [action] + fusible | ||||
|       fused_begin_pos = fusible.min_of(&.begin_pos) | ||||
|       fused_end_pos = fusible.max_of(&.end_pos) | ||||
|       fused_deletion = action.with(begin_pos: fused_begin_pos, end_pos: fused_end_pos) | ||||
|       without_fusible.combine(fused_deletion) | ||||
|     end | ||||
| 
 | ||||
|     # Similar to `@children.bsearch_index || size` except allows for a starting point | ||||
|     protected def bsearch_child_index(from = 0) | ||||
|       size = @children.size | ||||
|       (from...size).bsearch { |i| yield @children[i] } || size | ||||
|     end | ||||
| 
 | ||||
|     # Returns the children in a hierarchy with respect to *action*: | ||||
|     # | ||||
|     # - `:sibling_left`, `:sibling_right` (for those that are disjoint from *action*) | ||||
|     # - `:parent` (in case one of our children contains *action*) | ||||
|     # - `:child` (in case *action* strictly contains some of our children) | ||||
|     # - `:fusible` (in case *action* overlaps some children but they can be fused in one deletion) | ||||
|     # | ||||
|     # In case a child has equal range to *action*, it is returned as `:parent` | ||||
|     # | ||||
|     # Reminder: an empty range 1...1 is considered disjoint from 1...10 | ||||
|     protected def analyse_hierarchy(action) # ameba:disable Metrics/CyclomaticComplexity | ||||
|       # left_index is the index of the first child that isn't completely to the left of action | ||||
|       left_index = bsearch_child_index { |child| child.end_pos > action.begin_pos } | ||||
|       # right_index is the index of the first child that is completely on the right of action | ||||
|       start = left_index == 0 ? 0 : left_index - 1 # See "corner case" below for reason of -1 | ||||
|       right_index = bsearch_child_index(start) { |child| child.begin_pos >= action.end_pos } | ||||
|       center = right_index - left_index | ||||
|       case center | ||||
|       when 0 | ||||
|         # All children are disjoint from action, nothing else to do | ||||
|       when -1 | ||||
|         # Corner case: if a child has empty range == action's range | ||||
|         # then it will appear to be both disjoint and to the left of action, | ||||
|         # as well as disjoint and to the right of action. | ||||
|         # Since ranges are equal, we return it as parent | ||||
|         left_index -= 1  # Fix indices, as otherwise this child would be | ||||
|         right_index += 1 # considered as a sibling (both left and right!) | ||||
|         parent = @children[left_index] | ||||
|       else | ||||
|         overlap_left = @children[left_index].begin_pos <=> action.begin_pos | ||||
|         overlap_right = @children[right_index - 1].end_pos <=> action.end_pos | ||||
|         raise "Unable to compare begin pos" if overlap_left.nil? | ||||
|         raise "Unable to compare end pos" if overlap_right.nil? | ||||
| 
 | ||||
|         # For one child to be the parent of action, we must have: | ||||
|         if center == 1 && overlap_left <= 0 && overlap_right >= 0 | ||||
|           parent = @children[left_index] | ||||
|         else | ||||
|           # Otherwise consider all non disjoint elements (center) to be contained... | ||||
|           contained = @children[left_index...right_index] | ||||
|           fusible = [] of Action | ||||
|           fusible << contained.shift if overlap_left < 0 # ... but check first and last one | ||||
|           fusible << contained.pop if overlap_right > 0  # ... for overlaps | ||||
|           fusible = nil if fusible.empty? | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       { | ||||
|         parent:        parent, | ||||
|         sibling_left:  @children[0...left_index], | ||||
|         sibling_right: @children[right_index...@children.size], | ||||
|         fusible:       fusible, | ||||
|         child:         contained, | ||||
|       } | ||||
|     end | ||||
| 
 | ||||
|     # Assumes *action* has the exact same range and has no children | ||||
|     protected def merge(action) | ||||
|       self.with( | ||||
|         insert_before: "#{action.insert_before}#{insert_before}", | ||||
|         replacement: action.replacement || @replacement, | ||||
|         insert_after: "#{insert_after}#{action.insert_after}", | ||||
|       ).combine_children(action.children) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -15,7 +15,8 @@ class Ameba::Spec::AnnotatedSource | |||
|   def self.parse(annotated_code) | ||||
|     lines = [] of String | ||||
|     annotations = [] of {Int32, String, String} | ||||
|     annotated_code.each_line do |code_line| | ||||
|     code_lines = annotated_code.split('\n') # must preserve trailing newline | ||||
|     code_lines.each do |code_line| | ||||
|       if (annotation_match = ANNOTATION_PATTERN_1.match(code_line)) | ||||
|         message_index = annotation_match.end | ||||
|         prefix = code_line[0...message_index] | ||||
|  |  | |||
|  | @ -65,7 +65,7 @@ module Ameba::Spec::ExpectIssue | |||
|       raise "Use `report_no_issues` to assert that no issues are found" | ||||
|     end | ||||
| 
 | ||||
|     actual_annotations = actual_annotations(rules, code, path, lines) | ||||
|     source, actual_annotations = actual_annotations(rules, code, path, lines) | ||||
|     unless actual_annotations == expected_annotations | ||||
|       fail <<-MSG, file, line | ||||
|         Expected: | ||||
|  | @ -77,6 +77,33 @@ module Ameba::Spec::ExpectIssue | |||
|         #{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), | ||||
|  | @ -87,8 +114,8 @@ module Ameba::Spec::ExpectIssue | |||
|                        file = __FILE__, | ||||
|                        line = __LINE__) | ||||
|     code = normalize_code(code) if normalize | ||||
|     lines = code.lines | ||||
|     actual_annotations = actual_annotations(rules, code, path, lines) | ||||
|     lines = code.split('\n') # must preserve trailing newline | ||||
|     _, actual_annotations = actual_annotations(rules, code, path, lines) | ||||
|     unless actual_annotations.to_s == code | ||||
|       fail <<-MSG, file, line | ||||
|         Expected no issues, but got: | ||||
|  | @ -105,7 +132,7 @@ module Ameba::Spec::ExpectIssue | |||
|     else | ||||
|       rules.catch(source) | ||||
|     end | ||||
|     AnnotatedSource.new(lines, source.issues) | ||||
|     {source, AnnotatedSource.new(lines, source.issues)} | ||||
|   end | ||||
| 
 | ||||
|   private def format_issue(code, **replacements) | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue