Merge pull request #248 from FnControlOption/autocorrect

Add autocorrect
This commit is contained in:
Sijawusz Pur Rahnama 2021-11-01 20:24:43 +01:00 committed by GitHub
commit 7cb0c15747
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1040 additions and 62 deletions

View file

@ -69,24 +69,24 @@ module Ameba::Formatter
describe "#affected_code" do describe "#affected_code" do
it "returns nil if there is no such a line number" do it "returns nil if there is no such a line number" do
source = Source.new %( code = <<-EOF
a = 1 a = 1
) EOF
location = Crystal::Location.new("filename", 2, 1) location = Crystal::Location.new("filename", 2, 1)
subject.affected_code(source, location).should be_nil subject.affected_code(code, location).should be_nil
end end
it "returns correct line if it is found" do it "returns correct line if it is found" do
source = Source.new %( code = <<-EOF
a = 1 a = 1
) EOF
location = Crystal::Location.new("filename", 1, 1) 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" .should eq "> a = 1\n ^\n"
end end
it "returns correct line if it is found" do it "returns correct line if it is found" do
source = Source.new <<-EOF code = <<-EOF
# pre:1 # pre:1
# pre:2 # pre:2
# pre:3 # pre:3
@ -101,7 +101,7 @@ module Ameba::Formatter
EOF EOF
location = Crystal::Location.new("filename", 6, 1) 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 .should eq <<-STR
> # pre:3 > # pre:3
> # pre:4 > # pre:4

View file

@ -3,7 +3,8 @@ require "../spec_helper"
module Ameba module Ameba
describe Issue do describe Issue do
it "accepts rule and message" do it "accepts rule and message" do
issue = Issue.new rule: DummyRule.new, issue = Issue.new code: "",
rule: DummyRule.new,
location: nil, location: nil,
end_location: nil, end_location: nil,
message: "Blah", message: "Blah",
@ -15,7 +16,8 @@ module Ameba
it "accepts location" do it "accepts location" do
location = Crystal::Location.new("path", 3, 2) location = Crystal::Location.new("path", 3, 2)
issue = Issue.new rule: DummyRule.new, issue = Issue.new code: "",
rule: DummyRule.new,
location: location, location: location,
end_location: nil, end_location: nil,
message: "Blah", message: "Blah",
@ -27,7 +29,8 @@ module Ameba
it "accepts end_location" do it "accepts end_location" do
location = Crystal::Location.new("path", 3, 2) location = Crystal::Location.new("path", 3, 2)
issue = Issue.new rule: DummyRule.new, issue = Issue.new code: "",
rule: DummyRule.new,
location: nil, location: nil,
end_location: location, end_location: location,
message: "Blah", message: "Blah",
@ -38,7 +41,8 @@ module Ameba
end end
it "accepts status" do it "accepts status" do
issue = Issue.new rule: DummyRule.new, issue = Issue.new code: "",
rule: DummyRule.new,
location: nil, location: nil,
end_location: nil, end_location: nil,
message: "", message: "",
@ -50,7 +54,8 @@ module Ameba
end end
it "sets status to :enabled by default" do it "sets status to :enabled by default" do
issue = Issue.new rule: DummyRule.new, issue = Issue.new code: "",
rule: DummyRule.new,
location: nil, location: nil,
end_location: nil, end_location: nil,
message: "" message: ""

View file

@ -5,28 +5,26 @@ module Ameba::Rule::Layout
describe TrailingBlankLines do describe TrailingBlankLines do
it "passes if there is a blank line at the end of a source" do it "passes if there is a blank line at the end of a source" do
source = Source.new "a = 1\n", normalize: false expect_no_issues subject, "a = 1\n", normalize: false
subject.catch(source).should be_valid
end end
it "passes if source is empty" do it "passes if source is empty" do
source = Source.new "" expect_no_issues subject, ""
subject.catch(source).should be_valid
end end
it "fails if there is no blank lines at the end" do it "fails if there is no blank lines at the end" do
source = Source.new "no-blankline" source = expect_issue subject, "no-blankline # error: Trailing newline missing"
subject.catch(source).should_not be_valid expect_correction source, "no-blankline\n"
end end
it "fails if there more then one blank line at the end of a source" do 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 source = expect_issue subject, "a = 1\n \n # error: Excessive trailing newline detected", normalize: false
subject.catch(source).should_not be_valid expect_no_corrections source
end end
it "fails if last line is not blank" do it "fails if last line is not blank" do
source = Source.new "\n\n\n puts 22", normalize: false source = expect_issue subject, "\n\n\n puts 22 # error: Trailing newline missing", normalize: false
subject.catch(source).should_not be_valid expect_correction source, "\n\n\n puts 22\n"
end end
context "when unnecessary blank line has been detected" do context "when unnecessary blank line has been detected" do
@ -36,7 +34,7 @@ module Ameba::Rule::Layout
issue = source.issues.first issue = source.issues.first
issue.rule.should_not be_nil 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.end_location.should be_nil
issue.message.should eq "Excessive trailing newline detected" issue.message.should eq "Excessive trailing newline detected"
end end
@ -49,7 +47,7 @@ module Ameba::Rule::Layout
issue = source.issues.first issue = source.issues.first
issue.rule.should_not be_nil 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.end_location.should be_nil
issue.message.should eq "Trailing newline missing" issue.message.should eq "Trailing newline missing"
end end

View file

@ -7,10 +7,14 @@ module Ameba
it "transforms large number #{number}" do it "transforms large number #{number}" do
rule = Rule::Style::LargeNumbers.new rule = Rule::Style::LargeNumbers.new
expect_issue rule, <<-CRYSTAL, number: number source = expect_issue rule, <<-CRYSTAL, number: number
number = %{number} number = %{number}
# ^{number} error: Large numbers should be written with underscores: #{expected} # ^{number} error: Large numbers should be written with underscores: #{expected}
CRYSTAL CRYSTAL
expect_correction source, <<-CRYSTAL
number = #{expected}
CRYSTAL
end end
end end

View file

@ -8,6 +8,13 @@ module Ameba
config.update_rule ErrorRule.rule_name, enabled: false config.update_rule ErrorRule.rule_name, enabled: false
config.update_rule PerfRule.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) Runner.new(config)
end end
@ -54,6 +61,15 @@ module Ameba
Runner.new(all_rules, [source], formatter, default_severity).run.success?.should be_true Runner.new(all_rules, [source], formatter, default_severity).run.success?.should be_true
end 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 context "exception in rule" do
it "raises an exception raised in fiber while running a rule" do it "raises an exception raised in fiber while running a rule" do
rule = RaiseRule.new rule = RaiseRule.new
@ -180,5 +196,56 @@ module Ameba
.run.success?.should be_true .run.success?.should be_true
end end
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
end end

View 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

View file

@ -1,6 +1,7 @@
require "../../spec_helper" require "../../spec_helper"
private def dummy_issue(message, private def dummy_issue(code,
message,
position : {Int32, Int32}?, position : {Int32, Int32}?,
end_position : {Int32, Int32}?, end_position : {Int32, Int32}?,
path = "") path = "")
@ -9,6 +10,7 @@ private def dummy_issue(message,
end_location = Crystal::Location.new(path, *end_position) if end_position end_location = Crystal::Location.new(path, *end_position) if end_position
Ameba::Issue.new( Ameba::Issue.new(
code: code,
rule: Ameba::DummyRule.new, rule: Ameba::DummyRule.new,
location: location, location: location,
end_location: end_location, end_location: end_location,
@ -25,7 +27,7 @@ private def expect_invalid_location(code,
expect_raises Exception, exception_message, file, line do expect_raises Exception, exception_message, file, line do
Ameba::Spec::AnnotatedSource.new( Ameba::Spec::AnnotatedSource.new(
lines: code.lines, lines: code.lines,
issues: [dummy_issue("Message", position, end_position, "path")] issues: [dummy_issue(code, "Message", position, end_position, "path")]
) )
end end
end end

View file

@ -95,6 +95,133 @@ module Ameba
end end
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 class DummyFormatter < Formatter::BaseFormatter
property started_sources : Array(Source)? property started_sources : Array(Source)?
property finished_sources : Array(Source)? property finished_sources : Array(Source)?

View file

@ -2,6 +2,7 @@ require "./ameba/*"
require "./ameba/ast/**" require "./ameba/ast/**"
require "./ameba/rule/**" require "./ameba/rule/**"
require "./ameba/formatter/*" require "./ameba/formatter/*"
require "./ameba/source/**"
# Ameba's entry module. # Ameba's entry module.
# #

View file

@ -7,7 +7,15 @@ module Ameba::Cli
def run(args = ARGV) def run(args = ARGV)
opts = parse_args args 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 = Config.load opts.config, opts.colors?
config.autocorrect = autocorrect
if globs = opts.globs if globs = opts.globs
config.globs = globs config.globs = globs
@ -25,8 +33,8 @@ module Ameba::Cli
runner = Ameba.run(config) runner = Ameba.run(config)
if location = opts.location_to_explain if location_to_explain
runner.explain(location) runner.explain(location_to_explain)
else else
exit 1 unless runner.success? exit 1 unless runner.success?
end end
@ -47,6 +55,7 @@ module Ameba::Cli
property? all = false property? all = false
property? colors = true property? colors = true
property? without_affected_code = false property? without_affected_code = false
property? autocorrect = false
end end
def parse_args(args, opts = Opts.new) def parse_args(args, opts = Opts.new)
@ -89,6 +98,10 @@ module Ameba::Cli
opts.all = true opts.all = true
end end
parser.on("--fix", "Autocorrect issues") do
opts.autocorrect = true
end
parser.on("--gen-config", parser.on("--gen-config",
"Generate a configuration file acting as a TODO list") do "Generate a configuration file acting as a TODO list") do
opts.formatter = :todo opts.formatter = :todo
@ -133,6 +146,7 @@ module Ameba::Cli
if name = opts.formatter if name = opts.formatter
config.formatter = name config.formatter = name
end end
config.formatter.config[:autocorrect] = opts.autocorrect?
config.formatter.config[:without_affected_code] = config.formatter.config[:without_affected_code] =
opts.without_affected_code? opts.without_affected_code?
end end

View file

@ -54,6 +54,9 @@ class Ameba::Config
# ``` # ```
property excluded : Array(String) property excluded : Array(String)
# Returns true if correctable issues should be autocorrected.
property? autocorrect = false
@rule_groups : Hash(String, Array(Rule::Base)) @rule_groups : Hash(String, Array(Rule::Base))
# Creates a new instance of `Ameba::Config` based on YAML parameters. # Creates a new instance of `Ameba::Config` based on YAML parameters.

View file

@ -36,13 +36,21 @@ module Ameba::Formatter
next if issue.disabled? next if issue.disabled?
next if (location = issue.location).nil? 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 \ output.puts \
"[#{issue.rule.severity.symbol}] " \ "[#{issue.rule.severity.symbol}] " \
"#{issue.rule.name}: " \ "#{issue.rule.name}: " \
"#{issue.message}".colorize(:red) "#{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) output << code.colorize(:default)
end end

View file

@ -38,10 +38,7 @@ module Ameba::Formatter
private def explain(source, issue) private def explain(source, issue)
rule = issue.rule rule = issue.rule
location, end_location = return unless (location = issue.location)
issue.location, issue.end_location
return unless location
output_title "ISSUE INFO" output_title "ISSUE INFO"
output_paragraph [ output_paragraph [
@ -49,7 +46,7 @@ module Ameba::Formatter
location.to_s.colorize(:cyan).to_s, 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_title "AFFECTED CODE"
output_paragraph affected_code output_paragraph affected_code
end end

View file

@ -5,6 +5,7 @@ module Ameba::Formatter
def source_finished(source : Source) def source_finished(source : Source)
source.issues.each do |e| source.issues.each do |e|
next if e.disabled? next if e.disabled?
next if e.correctable? && config[:autocorrect]?
if loc = e.location if loc = e.location
@mutex.synchronize do @mutex.synchronize do
output.printf "%s:%d:%d: %s: [%s] %s\n", output.printf "%s:%d:%d: %s: [%s] %s\n",

View file

@ -77,6 +77,7 @@ module Ameba::Formatter
source.issues.each do |e| source.issues.each do |e|
next if e.disabled? 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) 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 @result.summary.issues_count += 1
end end

View file

@ -42,6 +42,7 @@ module Ameba::Formatter
Hash(Rule::Base, Array(Issue)).new.tap do |h| Hash(Rule::Base, Array(Issue)).new.tap do |h|
issues.each do |issue| issues.each do |issue|
next if issue.disabled? || issue.rule.is_a?(Rule::Lint::Syntax) next if issue.disabled? || issue.rule.is_a?(Rule::Lint::Syntax)
next if issue.correctable? && config[:autocorrect]?
(h[issue.rule] ||= Array(Issue).new) << issue (h[issue.rule] ||= Array(Issue).new) << issue
end end
end end

View file

@ -40,8 +40,14 @@ module Ameba::Formatter
{pre_context, post_context} {pre_context, post_context}
end end
def affected_code(source, location, end_location = nil, context_lines = 0, max_length = 120, ellipsis = " ...", prompt = "> ") def affected_code(issue : Issue, context_lines = 0, max_length = 120, ellipsis = " ...", prompt = "> ")
lines = source.lines 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 = lineno, column =
location.line_number, location.column_number location.line_number, location.column_number

View file

@ -6,6 +6,9 @@ module Ameba
Disabled Disabled
end end
# The source code that triggered this issue.
getter code : String
# A rule that triggers this issue. # A rule that triggers this issue.
getter rule : Rule::Base getter rule : Rule::Base
@ -24,12 +27,20 @@ module Ameba
delegate :enabled?, :disabled?, delegate :enabled?, :disabled?,
to: status 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 @status = status || Status::Enabled
end end
def syntax? def syntax?
rule.is_a?(Rule::Lint::Syntax) rule.is_a?(Rule::Lint::Syntax)
end end
def correctable?
!@block.nil?
end
def correct(corrector)
@block.try &.call(corrector)
end
end end
end end

View file

@ -5,41 +5,76 @@ module Ameba
getter issues = [] of Issue getter issues = [] of Issue
# Adds a new issue to the list of issues. # 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 ||= status ||=
Issue::Status::Disabled if location_disabled?(location, rule) 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 issues << issue
end end
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*. # Adds a new issue for Crystal AST *node*.
def add_issue(rule, node : Crystal::ASTNode, message, status : Issue::Status? = nil) : Issue 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 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 end
# Adds a new issue for Crystal *token*. # Adds a new issue for Crystal *token*.
def add_issue(rule, token : Crystal::Token, message, status : Issue::Status? = nil) : Issue 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 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 end
# Adds a new issue for *location* defined by line and column numbers. # 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 = location =
Crystal::Location.new(path, *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 end
# Adds a new issue for *location* and *end_location* defined by line and column numbers. # 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 = location =
Crystal::Location.new(path, *location) Crystal::Location.new(path, *location)
end_location = end_location =
Crystal::Location.new(path, *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 end
# Returns `true` if the list of not disabled issues is empty, `false` otherwise. # Returns `true` if the list of not disabled issues is empty, `false` otherwise.

View file

@ -112,8 +112,8 @@ module Ameba::Rule
name.hash name.hash
end end
macro issue_for(*args) macro issue_for(*args, **kwargs, &block)
source.add_issue self, {{*args}} source.add_issue(self, {{*args}}, {{**kwargs}}) {{block}}
end end
protected def self.rule_name protected def self.rule_name

View file

@ -25,7 +25,13 @@ module Ameba::Rule::Layout
last_line_empty = last_source_line.empty? last_line_empty = last_source_line.empty?
if source_lines_size >= 1 && (source_lines.last(2).join.blank? || !last_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 end
end end

View file

@ -28,7 +28,7 @@ module Ameba::Rule::Style
# ``` # ```
class LargeNumbers < Base class LargeNumbers < Base
properties do properties do
enabled false enabled true
description "Disallows usage of large numbers without underscore" description "Disallows usage of large numbers without underscore"
int_min_digits 5 int_min_digits 5
end end
@ -48,7 +48,9 @@ module Ameba::Rule::Style
location.line_number, location.line_number,
location.column_number + token.raw.size - 1 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 end
end end

View file

@ -1,3 +1,5 @@
require "digest"
module Ameba module Ameba
# Represents a runner for inspecting sources files. # Represents a runner for inspecting sources files.
# Holds a list of rules to do inspection based on, # Holds a list of rules to do inspection based on,
@ -11,6 +13,24 @@ module Ameba
# ``` # ```
# #
class Runner 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. # A list of rules to do inspection based on.
@rules : Array(Rule::Base) @rules : Array(Rule::Base)
@ -29,6 +49,9 @@ module Ameba
# Checks for unneeded disable directives. Always inspects a source last # Checks for unneeded disable directives. Always inspects a source last
@unneeded_disable_directive_rule : Rule::Base? @unneeded_disable_directive_rule : Rule::Base?
# Returns true if correctable issues should be autocorrected.
private getter? autocorrect : Bool
# Instantiates a runner using a `config`. # Instantiates a runner using a `config`.
# #
# ``` # ```
@ -43,13 +66,14 @@ module Ameba
@formatter = config.formatter @formatter = config.formatter
@severity = config.severity @severity = config.severity
@rules = config.rules.select(&.enabled).reject!(&.special?) @rules = config.rules.select(&.enabled).reject!(&.special?)
@autocorrect = config.autocorrect?
@unneeded_disable_directive_rule = @unneeded_disable_directive_rule =
config.rules config.rules
.find &.name.==(Rule::Lint::UnneededDisableDirective.rule_name) .find &.name.==(Rule::Lint::UnneededDisableDirective.rule_name)
end end
protected def initialize(@rules, @sources, @formatter, @severity) protected def initialize(@rules, @sources, @formatter, @severity, @autocorrect = false)
end end
# Performs the inspection. Iterates through all sources and test it using # Performs the inspection. Iterates through all sources and test it using
@ -89,14 +113,40 @@ module Ameba
private def run_source(source) private def run_source(source)
@formatter.source_started 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| @rules.each do |rule|
next if rule.excluded?(source) next if rule.excluded?(source)
rule.test(source) rule.test(source)
end end
check_unneeded_directives(source) 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 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 @formatter.source_finished source
end end
@ -130,6 +180,46 @@ module Ameba
end end
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) private def check_unneeded_directives(source)
if (rule = @unneeded_disable_directive_rule) && rule.enabled if (rule = @unneeded_disable_directive_rule) && rule.enabled
rule.test(source) rule.test(source)

View file

@ -22,6 +22,20 @@ module Ameba
def initialize(@code, @path = "") def initialize(@code, @path = "")
end 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. # Returns lines of code splitted by new line character.
# Since `code` is immutable and can't be changed, this # Since `code` is immutable and can't be changed, this
# method caches lines in an instance variable, so calling # method caches lines in an instance variable, so calling

View 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

View 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

View 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

View file

@ -15,7 +15,8 @@ class Ameba::Spec::AnnotatedSource
def self.parse(annotated_code) def self.parse(annotated_code)
lines = [] of String lines = [] of String
annotations = [] of {Int32, String, 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)) if (annotation_match = ANNOTATION_PATTERN_1.match(code_line))
message_index = annotation_match.end message_index = annotation_match.end
prefix = code_line[0...message_index] prefix = code_line[0...message_index]

View file

@ -65,7 +65,7 @@ module Ameba::Spec::ExpectIssue
raise "Use `report_no_issues` to assert that no issues are found" raise "Use `report_no_issues` to assert that no issues are found"
end end
actual_annotations = actual_annotations(rules, code, path, lines) source, actual_annotations = actual_annotations(rules, code, path, lines)
unless actual_annotations == expected_annotations unless actual_annotations == expected_annotations
fail <<-MSG, file, line fail <<-MSG, file, line
Expected: Expected:
@ -77,6 +77,33 @@ module Ameba::Spec::ExpectIssue
#{actual_annotations} #{actual_annotations}
MSG MSG
end 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 end
def expect_no_issues(rules : Rule::Base | Enumerable(Rule::Base), def expect_no_issues(rules : Rule::Base | Enumerable(Rule::Base),
@ -87,8 +114,8 @@ module Ameba::Spec::ExpectIssue
file = __FILE__, file = __FILE__,
line = __LINE__) line = __LINE__)
code = normalize_code(code) if normalize code = normalize_code(code) if normalize
lines = code.lines lines = code.split('\n') # must preserve trailing newline
actual_annotations = actual_annotations(rules, code, path, lines) _, actual_annotations = actual_annotations(rules, code, path, lines)
unless actual_annotations.to_s == code unless actual_annotations.to_s == code
fail <<-MSG, file, line fail <<-MSG, file, line
Expected no issues, but got: Expected no issues, but got:
@ -105,7 +132,7 @@ module Ameba::Spec::ExpectIssue
else else
rules.catch(source) rules.catch(source)
end end
AnnotatedSource.new(lines, source.issues) {source, AnnotatedSource.new(lines, source.issues)}
end end
private def format_issue(code, **replacements) private def format_issue(code, **replacements)