Open Source Adventures: Episode 76: Ameba linter for Crystal
Ameba is a linter for Crystal.
I wrote a bit of Crystal code recently, so let's see how it goes.
I'll run it in both default settings and with --all
. Hopefully there will be few false positives with default settings.
Shebang issue
ameba
was ignoring Crystal scripts starting with #!/usr/bin/env crystal
- it was only looking at files with .cr
extension. This is not Crystal's main use case, but it's a valid use case.
Crystal's VSCode extension has the same issue.
The issue can be solved with .ameba.yml
config file.
crystal-z3
and Lint/UselessAssign
In default settings it finds one issue:
$ ameba
Inspecting 25 files
......................F..
src/z3/api.cr:89:7
[W] Lint/UselessAssign: Useless assignment to variable `var`
> var = LibZ3.mk_const(Context, name_sym, sort)
^-^
Finished in 223.71 milliseconds
25 inspected, 1 failure
It doesn't show any context, but it's from this method:
def mk_const(name, sort)
name_sym = LibZ3.mk_string_symbol(Context, name)
var = LibZ3.mk_const(Context, name_sym, sort)
end
That's definitely real.
crystal-z3
with --all
and Lint/ComparisonToBoolean
This generates a huge number of Lint/ComparisonToBoolean
issues, some examples here:
spec/bool_spec.cr:10:6 [Correctable]
[W] Lint/ComparisonToBoolean: Comparison to a boolean is pointless
> [a == true, b == true, c == (a & b)].should have_solution({c => true})
^--------^
spec/model_spec.cr:15:19 [Correctable]
[W] Lint/ComparisonToBoolean: Comparison to a boolean is pointless
> solver.assert a == true
^-------^
[W] Lint/ComparisonToBoolean: Comparison to a boolean is pointless
> raise Z3::Exception.new("Cannot evaluate") unless result == true
^------------^
Well, crystal-z3
overrides Z3::BoolExpr#==
so semantically this lint rule is a miss, but I cannot blame ameba
for not supporting such unusual code.
I'm not sure this is a good rule in general. As long as x
can be anything else than a boolean, x == true
is absolutely not the same thing as x
.
Thue Interptetter and Performance/CompactAfterMap
There are two issues it found with Thue interpretter from the series:
./episode-65-crystal-thue-randomized-finite-automaton/thue_rfa.cr:162:27
[C] Performance/CompactAfterMap: Use `compact_map {...}` instead of `map {...}.compact`
> next_tries = active.map{|t| t[char]}.compact
^-----------------------^
compact_map
is a decent suggestion, as these merged common operations tend to be more performant, but methods like that keep getting added every minor version of every language, it's hard to remember which language has which combinations.
./episode-65-crystal-thue-randomized-finite-automaton/thue_rfa.cr:191:13
[W] Lint/UselessAssign: Useless assignment to variable `line_no`
> line, line_no = lines.shift
^-----^
I don't like this one. Useless single variable assignment can be removed, so that makes sense, but in array destructuring, the alternatives are ugly code like one of:
line, _ = lines.shift
line, _line_no = lines.shift
line = lines.shift[0]
I'd rather stick with the original.
But that's not specific to ameba
, most linters' "Useless assignment to variable" check don't have exception for multiple assignment.
For a note, if it was a named tuple, we could do some equivalent of (JavaScript here):
let {line} = lines.shift()
For destructuring a hash / named tuple / object, there's no reason to include these extras.
Thue Interpretter and --all
As we're not using --all
, false positives are expected. Here's one example, there's more cases like that:
./episode-65-crystal-thue-randomized-finite-automaton/thue_rfa.cr:221:5 [Correctable]
[C] Style/GuardClause: Use a guard clause (`return unless debug`) instead of wrapping the code inside a conditional expression.
> if debug
^^
So linter wants us to replace this:
def run(debug)
@state = @initial
if debug
@rules.each do |rule|
STDERR.puts rule
end
end
while match = @rfa.not_nil!.random_match(@state)
rule = match[:rule]
idx = match[:idx]
if debug
STDERR.puts "Applying rule #{rule} at #{idx} to #{@state.inspect}"
end
@state = rule.apply(@state, idx)
end
if debug
STDERR.puts "No more matches. Final state: #{@state.inspect}"
end
end
With this:
def run(debug)
@state = @initial
if debug
@rules.each do |rule|
STDERR.puts rule
end
end
while match = @rfa.not_nil!.random_match(@state)
rule = match[:rule]
idx = match[:idx]
if debug
STDERR.puts "Applying rule #{rule} at #{idx} to #{@state.inspect}"
end
@state = rule.apply(@state, idx)
end
return unless debug
STDERR.puts "No more matches. Final state: #{@state.inspect}"
end
That would not be an improvement. Guard clauses at the beginning of a method or loop body are common pattern, but putting them after the main body is weird.
Open Source Adventures and Style/VerboseBlock
To get ameba
to see all the files I need to run it with weird:
$ ameba `git grep -l '#!/usr/bin/env crystal'` `git ls "*.cr"`
Here are some of the suggestions:
episode-65/minesweeper.cr:40:25 [Correctable]
[C] Style/VerboseBlock: Use short block notation instead: `map(&.ite(1, 0))`
> neighbourhood(x, y).map{|v| v.ite(1, 0)}.reduce{|a,b| a+b}
^------------------^
That's Crystal code not directly doable in Ruby. It's a reasonable suggestion.
episode-70/aquarium:17:25 [Correctable]
[C] Style/VerboseBlock: Use short block notation instead: `map(&.[2..].chars)`
> @board = lines[2..].map{|l| l[2..].chars}
^-------------------^
This feels like maybe a bit much, but maybe I could get used to it.
episode-72/dominosa:65:23
[W] Lint/UnusedArgument: Unused argument `type`. If it's necessary, use `_` as an argument name to indicate that it won't be used.
> @dominos.each do |type, vars|
^
This one can use .each_value
, but just like with multiple assignment, I don't like the suggestion of @dominos.each do |_type, vars|
or @dominos.each do |_, vars|
.
episode-68/switches:50:67 [Correctable]
[C] Performance/ChainedCallWithNoBang: Use bang method variant `sort!` after chained `select` call
> puts @nodes.select{|n| model[@switches[n]].to_s == "true" }.sort.join(" ")
^--^
I see why it makes sense for performance, but .sort!
in the middle of a chain is so weird, I'd rather not.
There weren't any new types of suggestions with --all
.
Should you use ameba
?
Overall it seems like a decent linter, and defaults don't generate too many false positives.
It doesn't do any kind of deep analysis. For example I know I had some if
checks that are always true
due to type issue (Z3 results LBool
enum, not Bool
, so result is always true
), but ameba
never picked that up, as that doesn't show in a purely syntactic check.
Coming next
OK, that's really enough Crystal for now. In the next episode we'll try another technology as promised.