Merge pull request #374 from crystal-ameba/lint-documentation-rule

Add `Lint/Documentation` rule
This commit is contained in:
Sijawusz Pur Rahnama 2023-06-08 14:10:21 +02:00 committed by GitHub
commit aceb054aa0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 321 additions and 47 deletions

View file

@ -36,4 +36,4 @@ jobs:
run: shards build -Dpreview_mt run: shards build -Dpreview_mt
- name: Run ameba linter - name: Run ameba linter
run: bin/ameba --all run: bin/ameba

View file

@ -10,7 +10,7 @@ build:
.PHONY: lint .PHONY: lint
lint: build lint: build
./bin/ameba --all ./bin/ameba
.PHONY: spec .PHONY: spec
spec: spec:

View file

@ -99,15 +99,6 @@ $ ameba --explain crystal/command/format.cr:26:83 # same thing
### Run in parallel ### Run in parallel
Starting from 0.31.0 Crystal [supports parallelism](https://crystal-lang.org/2019/09/06/parallelism-in-crystal.html).
It allows to run linting in parallel too.
In order to take advantage of this feature you need to build ameba with preview_mt support:
```sh
$ crystal build src/cli.cr -Dpreview_mt -o bin/ameba
$ make install
```
Some quick benchmark results measured while running Ameba on Crystal repo: Some quick benchmark results measured while running Ameba on Crystal repo:
```sh ```sh

View file

@ -2,6 +2,17 @@ require "../../../spec_helper"
module Ameba::AST module Ameba::AST
describe ScopeVisitor do describe ScopeVisitor do
{% for type in %w[class module enum].map(&.id) %}
it "creates a scope for the {{ type }} def" do
rule = ScopeRule.new
ScopeVisitor.new rule, Source.new <<-CRYSTAL
{{ type }} Foo
end
CRYSTAL
rule.scopes.size.should eq 1
end
{% end %}
it "creates a scope for the def" do it "creates a scope for the def" do
rule = ScopeRule.new rule = ScopeRule.new
ScopeVisitor.new rule, Source.new <<-CRYSTAL ScopeVisitor.new rule, Source.new <<-CRYSTAL
@ -54,5 +65,33 @@ module Ameba::AST
outer_block.outer_scope.should be_nil outer_block.outer_scope.should be_nil
end end
end end
context "#visibility" do
it "is being properly set" do
rule = ScopeRule.new
ScopeVisitor.new rule, Source.new <<-CRYSTAL
private class Foo
end
CRYSTAL
rule.scopes.size.should eq 1
rule.scopes.first.visibility.should eq Crystal::Visibility::Private
end
it "is being inherited from the outer scope(s)" do
rule = ScopeRule.new
ScopeVisitor.new rule, Source.new <<-CRYSTAL
private class Foo
class Bar
def baz
end
end
end
CRYSTAL
rule.scopes.size.should eq 3
rule.scopes.each &.visibility.should eq Crystal::Visibility::Private
rule.scopes.last.node.visibility.should eq Crystal::Visibility::Private
rule.scopes[0...-1].each &.node.visibility.should eq Crystal::Visibility::Public
end
end
end end
end end

View file

@ -0,0 +1,151 @@
require "../../../spec_helper"
module Ameba::Rule::Lint
subject = Documentation.new
.tap(&.ignore_classes = false)
.tap(&.ignore_modules = false)
.tap(&.ignore_enums = false)
.tap(&.ignore_defs = false)
.tap(&.ignore_macros = false)
describe Documentation do
it "passes for undocumented private types" do
expect_no_issues subject, <<-CRYSTAL
private class Foo
def foo
end
end
private module Bar
def bar
end
end
private enum Baz
end
private def bat
end
private macro bag
end
CRYSTAL
end
it "passes for documented public types" do
expect_no_issues subject, <<-CRYSTAL
# Foo
class Foo
# foo
def foo
end
end
# Bar
module Bar
# bar
def bar
end
end
# Baz
enum Baz
end
# bat
def bat
end
# bag
macro bag
end
CRYSTAL
end
it "fails if there is an undocumented public type" do
expect_issue subject, <<-CRYSTAL
class Foo
# ^^^^^^^^^ error: Missing documentation
end
module Bar
# ^^^^^^^^^^ error: Missing documentation
end
enum Baz
# ^^^^^^^^ error: Missing documentation
end
def bat
# ^^^^^^^ error: Missing documentation
end
macro bag
# ^^^^^^^^^ error: Missing documentation
end
CRYSTAL
end
context "properties" do
describe "#ignore_classes" do
it "lets the rule to ignore method definitions if true" do
rule = Documentation.new
rule.ignore_classes = true
expect_no_issues rule, <<-CRYSTAL
class Foo
end
CRYSTAL
end
end
describe "#ignore_modules" do
it "lets the rule to ignore method definitions if true" do
rule = Documentation.new
rule.ignore_modules = true
expect_no_issues rule, <<-CRYSTAL
module Bar
end
CRYSTAL
end
end
describe "#ignore_enums" do
it "lets the rule to ignore method definitions if true" do
rule = Documentation.new
rule.ignore_enums = true
expect_no_issues rule, <<-CRYSTAL
enum Baz
end
CRYSTAL
end
end
describe "#ignore_defs" do
it "lets the rule to ignore method definitions if true" do
rule = Documentation.new
rule.ignore_defs = true
expect_no_issues rule, <<-CRYSTAL
def bat
end
CRYSTAL
end
end
describe "#ignore_macros" do
it "lets the rule to ignore macros if true" do
rule = Documentation.new
rule.ignore_macros = true
expect_no_issues rule, <<-CRYSTAL
macro bag
end
CRYSTAL
end
end
end
end
end

View file

@ -43,6 +43,9 @@ module Ameba
description "Internal rule to test scopes" description "Internal rule to test scopes"
end end
def test(source, node : Crystal::VisibilityModifier, scope : AST::Scope)
end
def test(source, node : Crystal::ASTNode, scope : AST::Scope) def test(source, node : Crystal::ASTNode, scope : AST::Scope)
@scopes << scope @scopes << scope
end end

View file

@ -7,6 +7,9 @@ module Ameba::AST
# Whether the scope yields. # Whether the scope yields.
setter yields = false setter yields = false
# Scope visibility level
setter visibility : Crystal::Visibility?
# Link to local variables # Link to local variables
getter variables = [] of Variable getter variables = [] of Variable
@ -121,10 +124,7 @@ module Ameba::AST
# end # end
# ``` # ```
def spawn_block? def spawn_block?
return false unless node.is_a?(Crystal::Block) node.as?(Crystal::Block).try(&.call).try(&.name) == "spawn"
call = node.as(Crystal::Block).call
call.try(&.name) == "spawn"
end end
# Returns `true` if current scope sits inside a macro. # Returns `true` if current scope sits inside a macro.
@ -167,9 +167,12 @@ module Ameba::AST
# Returns `true` if current scope (or any of inner scopes) yields, # Returns `true` if current scope (or any of inner scopes) yields,
# `false` otherwise. # `false` otherwise.
def yields?(check_inner_scopes = true) def yields?(check_inner_scopes = true)
return true if @yields @yields || (check_inner_scopes && inner_scopes.any?(&.yields?))
return inner_scopes.any?(&.yields?) if check_inner_scopes end
false
# Returns visibility of the current scope (could be inherited from the outer scope).
def visibility
@visibility || outer_scope.try(&.visibility)
end end
# Returns `true` if current scope is a def, `false` otherwise. # Returns `true` if current scope is a def, `false` otherwise.

View file

@ -1,6 +1,13 @@
require "./base_visitor" require "./base_visitor"
module Ameba::AST module Ameba::AST
# An AST Visitor that traverses the source and allows all nodes
# to be inspected by rules.
#
# ```
# visitor = Ameba::AST::NodeVisitor.new(rule, source)
# ```
class NodeVisitor < BaseVisitor
# List of nodes to be visited by Ameba's rules. # List of nodes to be visited by Ameba's rules.
NODES = { NODES = {
Alias, Alias,
@ -29,13 +36,6 @@ module Ameba::AST
Until, Until,
} }
# An AST Visitor that traverses the source and allows all nodes
# to be inspected by rules.
#
# ```
# visitor = Ameba::AST::NodeVisitor.new(rule, source)
# ```
class NodeVisitor < BaseVisitor
@skip : Array(Crystal::ASTNode.class)? @skip : Array(Crystal::ASTNode.class)?
def initialize(@rule, @source, skip = nil) def initialize(@rule, @source, skip = nil)
@ -43,6 +43,11 @@ module Ameba::AST
super @rule, @source super @rule, @source
end end
def visit(node : Crystal::VisibilityModifier)
node.exp.visibility = node.modifier
true
end
{% for name in NODES %} {% for name in NODES %}
# A visit callback for `Crystal::{{ name }}` node. # A visit callback for `Crystal::{{ name }}` node.
# #

View file

@ -25,6 +25,7 @@ module Ameba::AST
@scope_queue = [] of Scope @scope_queue = [] of Scope
@current_scope : Scope @current_scope : Scope
@current_assign : Crystal::ASTNode? @current_assign : Crystal::ASTNode?
@visibility_modifier : Crystal::Visibility?
@skip : Array(Crystal::ASTNode.class)? @skip : Array(Crystal::ASTNode.class)?
def initialize(@rule, @source, skip = nil) def initialize(@rule, @source, skip = nil)
@ -36,12 +37,18 @@ module Ameba::AST
private def on_scope_enter(node) private def on_scope_enter(node)
return if skip?(node) return if skip?(node)
@current_scope = Scope.new(node, @current_scope)
scope = Scope.new(node, @current_scope)
scope.visibility = @visibility_modifier
@current_scope = scope
end end
private def on_scope_end(node) private def on_scope_end(node)
@scope_queue << @current_scope @scope_queue << @current_scope
@visibility_modifier = nil
# go up if this is not a top level scope # go up if this is not a top level scope
return unless outer_scope = @current_scope.outer_scope return unless outer_scope = @current_scope.outer_scope
@current_scope = outer_scope @current_scope = outer_scope
@ -64,6 +71,12 @@ module Ameba::AST
end end
{% end %} {% end %}
# :nodoc:
def visit(node : Crystal::VisibilityModifier)
@visibility_modifier = node.exp.visibility = node.modifier
true
end
# :nodoc: # :nodoc:
def visit(node : Crystal::Yield) def visit(node : Crystal::Yield)
@current_scope.yields = true @current_scope.yields = true
@ -109,6 +122,7 @@ module Ameba::AST
@current_assign = node.value unless node.value.nil? @current_assign = node.value unless node.value.nil?
end end
# :nodoc:
def end_visit(node : Crystal::TypeDeclaration) def end_visit(node : Crystal::TypeDeclaration)
return unless (var = node.var).is_a?(Crystal::Var) return unless (var = node.var).is_a?(Crystal::Var)

View file

@ -240,6 +240,7 @@ class Ameba::Config
# :nodoc: # :nodoc:
module RuleConfig module RuleConfig
# Define rule properties
macro properties(&block) macro properties(&block)
{% definitions = [] of NamedTuple %} {% definitions = [] of NamedTuple %}
{% if block.body.is_a? Assign %} {% if block.body.is_a? Assign %}

View file

@ -112,6 +112,7 @@ module Ameba::Rule
name.hash name.hash
end end
# Adds an issue to the *source*
macro issue_for(*args, **kwargs, &block) macro issue_for(*args, **kwargs, &block)
source.add_issue(self, {{ *args }}, {{ **kwargs }}) {{ block }} source.add_issue(self, {{ *args }}, {{ **kwargs }}) {{ block }}
end end

View file

@ -0,0 +1,66 @@
module Ameba::Rule::Lint
# A rule that enforces documentation for public types:
# modules, classes, enums, methods and macros.
#
# YAML configuration example:
#
# ```
# Lint/Documentation:
# Enabled: true
# ```
class Documentation < Base
properties do
enabled false
description "Enforces public types to be documented"
ignore_classes false
ignore_modules true
ignore_enums false
ignore_defs true
ignore_macros false
end
MSG = "Missing documentation"
MACRO_HOOK_NAMES = %w[
inherited
included extended
method_missing method_added
finished
]
def test(source)
AST::ScopeVisitor.new self, source
end
def test(source, node : Crystal::ClassDef, scope : AST::Scope)
ignore_classes? || check_missing_doc(source, node, scope)
end
def test(source, node : Crystal::ModuleDef, scope : AST::Scope)
ignore_modules? || check_missing_doc(source, node, scope)
end
def test(source, node : Crystal::EnumDef, scope : AST::Scope)
ignore_enums? || check_missing_doc(source, node, scope)
end
def test(source, node : Crystal::Def, scope : AST::Scope)
ignore_defs? || check_missing_doc(source, node, scope)
end
def test(source, node : Crystal::Macro, scope : AST::Scope)
node.name.in?(MACRO_HOOK_NAMES) ||
ignore_macros? || check_missing_doc(source, node, scope)
end
private def check_missing_doc(source, node, scope)
visibility = scope.visibility
return if visibility && !visibility.public?
return if node.doc.presence
issue_for(node, MSG)
end
end
end