mirror of
				https://gitea.invidious.io/iv-org/shard-ameba.git
				synced 2024-08-15 00:53:29 +00:00 
			
		
		
		
	Merge pull request #374 from crystal-ameba/lint-documentation-rule
Add `Lint/Documentation` rule
This commit is contained in:
		
						commit
						aceb054aa0
					
				
					 12 changed files with 321 additions and 47 deletions
				
			
		
							
								
								
									
										2
									
								
								.github/workflows/ci.yml
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci.yml
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -36,4 +36,4 @@ jobs:
 | 
			
		|||
        run: shards build -Dpreview_mt
 | 
			
		||||
 | 
			
		||||
      - name: Run ameba linter
 | 
			
		||||
        run: bin/ameba --all
 | 
			
		||||
        run: bin/ameba
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										2
									
								
								Makefile
									
										
									
									
									
								
							
							
						
						
									
										2
									
								
								Makefile
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -10,7 +10,7 @@ build:
 | 
			
		|||
 | 
			
		||||
.PHONY: lint
 | 
			
		||||
lint: build
 | 
			
		||||
	./bin/ameba --all
 | 
			
		||||
	./bin/ameba
 | 
			
		||||
 | 
			
		||||
.PHONY: spec
 | 
			
		||||
spec:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -99,15 +99,6 @@ $ ameba --explain crystal/command/format.cr:26:83 # same thing
 | 
			
		|||
 | 
			
		||||
### 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:
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,6 +2,17 @@ require "../../../spec_helper"
 | 
			
		|||
 | 
			
		||||
module Ameba::AST
 | 
			
		||||
  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
 | 
			
		||||
      rule = ScopeRule.new
 | 
			
		||||
      ScopeVisitor.new rule, Source.new <<-CRYSTAL
 | 
			
		||||
| 
						 | 
				
			
			@ -54,5 +65,33 @@ module Ameba::AST
 | 
			
		|||
        outer_block.outer_scope.should be_nil
 | 
			
		||||
      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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										151
									
								
								spec/ameba/rule/lint/documentation_spec.cr
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								spec/ameba/rule/lint/documentation_spec.cr
									
										
									
									
									
										Normal 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
 | 
			
		||||
| 
						 | 
				
			
			@ -43,6 +43,9 @@ module Ameba
 | 
			
		|||
      description "Internal rule to test scopes"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def test(source, node : Crystal::VisibilityModifier, scope : AST::Scope)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def test(source, node : Crystal::ASTNode, scope : AST::Scope)
 | 
			
		||||
      @scopes << scope
 | 
			
		||||
    end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,6 +7,9 @@ module Ameba::AST
 | 
			
		|||
    # Whether the scope yields.
 | 
			
		||||
    setter yields = false
 | 
			
		||||
 | 
			
		||||
    # Scope visibility level
 | 
			
		||||
    setter visibility : Crystal::Visibility?
 | 
			
		||||
 | 
			
		||||
    # Link to local variables
 | 
			
		||||
    getter variables = [] of Variable
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -121,10 +124,7 @@ module Ameba::AST
 | 
			
		|||
    # end
 | 
			
		||||
    # ```
 | 
			
		||||
    def spawn_block?
 | 
			
		||||
      return false unless node.is_a?(Crystal::Block)
 | 
			
		||||
 | 
			
		||||
      call = node.as(Crystal::Block).call
 | 
			
		||||
      call.try(&.name) == "spawn"
 | 
			
		||||
      node.as?(Crystal::Block).try(&.call).try(&.name) == "spawn"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    # 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,
 | 
			
		||||
    # `false` otherwise.
 | 
			
		||||
    def yields?(check_inner_scopes = true)
 | 
			
		||||
      return true if @yields
 | 
			
		||||
      return inner_scopes.any?(&.yields?) if check_inner_scopes
 | 
			
		||||
      false
 | 
			
		||||
      @yields || (check_inner_scopes && inner_scopes.any?(&.yields?))
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    # Returns visibility of the current scope (could be inherited from the outer scope).
 | 
			
		||||
    def visibility
 | 
			
		||||
      @visibility || outer_scope.try(&.visibility)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    # Returns `true` if current scope is a def, `false` otherwise.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,13 @@
 | 
			
		|||
require "./base_visitor"
 | 
			
		||||
 | 
			
		||||
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.
 | 
			
		||||
    NODES = {
 | 
			
		||||
      Alias,
 | 
			
		||||
| 
						 | 
				
			
			@ -29,13 +36,6 @@ module Ameba::AST
 | 
			
		|||
      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)?
 | 
			
		||||
 | 
			
		||||
    def initialize(@rule, @source, skip = nil)
 | 
			
		||||
| 
						 | 
				
			
			@ -43,6 +43,11 @@ module Ameba::AST
 | 
			
		|||
      super @rule, @source
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def visit(node : Crystal::VisibilityModifier)
 | 
			
		||||
      node.exp.visibility = node.modifier
 | 
			
		||||
      true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    {% for name in NODES %}
 | 
			
		||||
      # A visit callback for `Crystal::{{ name }}` node.
 | 
			
		||||
      #
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -25,6 +25,7 @@ module Ameba::AST
 | 
			
		|||
    @scope_queue = [] of Scope
 | 
			
		||||
    @current_scope : Scope
 | 
			
		||||
    @current_assign : Crystal::ASTNode?
 | 
			
		||||
    @visibility_modifier : Crystal::Visibility?
 | 
			
		||||
    @skip : Array(Crystal::ASTNode.class)?
 | 
			
		||||
 | 
			
		||||
    def initialize(@rule, @source, skip = nil)
 | 
			
		||||
| 
						 | 
				
			
			@ -36,12 +37,18 @@ module Ameba::AST
 | 
			
		|||
 | 
			
		||||
    private def on_scope_enter(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
 | 
			
		||||
 | 
			
		||||
    private def on_scope_end(node)
 | 
			
		||||
      @scope_queue << @current_scope
 | 
			
		||||
 | 
			
		||||
      @visibility_modifier = nil
 | 
			
		||||
 | 
			
		||||
      # go up if this is not a top level scope
 | 
			
		||||
      return unless outer_scope = @current_scope.outer_scope
 | 
			
		||||
      @current_scope = outer_scope
 | 
			
		||||
| 
						 | 
				
			
			@ -64,6 +71,12 @@ module Ameba::AST
 | 
			
		|||
      end
 | 
			
		||||
    {% end %}
 | 
			
		||||
 | 
			
		||||
    # :nodoc:
 | 
			
		||||
    def visit(node : Crystal::VisibilityModifier)
 | 
			
		||||
      @visibility_modifier = node.exp.visibility = node.modifier
 | 
			
		||||
      true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    # :nodoc:
 | 
			
		||||
    def visit(node : Crystal::Yield)
 | 
			
		||||
      @current_scope.yields = true
 | 
			
		||||
| 
						 | 
				
			
			@ -109,6 +122,7 @@ module Ameba::AST
 | 
			
		|||
      @current_assign = node.value unless node.value.nil?
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    # :nodoc:
 | 
			
		||||
    def end_visit(node : Crystal::TypeDeclaration)
 | 
			
		||||
      return unless (var = node.var).is_a?(Crystal::Var)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -240,6 +240,7 @@ class Ameba::Config
 | 
			
		|||
 | 
			
		||||
  # :nodoc:
 | 
			
		||||
  module RuleConfig
 | 
			
		||||
    # Define rule properties
 | 
			
		||||
    macro properties(&block)
 | 
			
		||||
      {% definitions = [] of NamedTuple %}
 | 
			
		||||
      {% if block.body.is_a? Assign %}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -112,6 +112,7 @@ module Ameba::Rule
 | 
			
		|||
      name.hash
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    # Adds an issue to the *source*
 | 
			
		||||
    macro issue_for(*args, **kwargs, &block)
 | 
			
		||||
      source.add_issue(self, {{ *args }}, {{ **kwargs }}) {{ block }}
 | 
			
		||||
    end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										66
									
								
								src/ameba/rule/lint/documentation.cr
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								src/ameba/rule/lint/documentation.cr
									
										
									
									
									
										Normal 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
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue