Use when creating custom Ameba rules for Crystal code analysis including rule development, AST traversal, issue reporting, and rule testing.
Limited to specific tools
Additional assets for this skill
This skill is limited to using the following tools:
name: ameba-custom-rules description: Use when creating custom Ameba rules for Crystal code analysis including rule development, AST traversal, issue reporting, and rule testing. allowed-tools:
Create custom linting rules for Ameba to enforce project-specific code quality standards and catch domain-specific code smells in Crystal projects.
Custom Ameba rules allow you to:
Every Ameba rule inherits from Ameba::Rule::Base and follows this structure:
module Ameba::Rule::Custom
# Rule that enforces documentation on public classes
class DocumentedClasses < Base
properties do
description "Enforces public classes to be documented"
end
MSG = "Class must be documented with a comment"
def test(source)
AST::NodeVisitor.new self, source
end
def test(source, node : Crystal::ClassDef)
return unless node.visibility.public?
doc = node.doc
issue_for(node, MSG) if doc.nil? || doc.empty?
end
end
end
Ameba::Rule::Custom or Ameba::Rule::<Category>Ameba::Rule::BaseCreate a Crystal library for your custom rules:
# Initialize a new Crystal library
crystal init lib ameba-custom-rules
cd ameba-custom-rules
Update shard.yml:
name: ameba-custom-rules
version: 0.1.0
authors:
- Your Name <your.email@example.com>
description: Custom Ameba rules for your project
crystal: ">= 1.0.0"
license: MIT
development_dependencies:
ameba:
github: crystal-ameba/ameba
version: ~> 1.6.0
Important: Ameba should be a development dependency to avoid version conflicts.
Create src/ameba-custom-rules/no_sleep_in_production.cr:
require "ameba"
module Ameba::Rule::Custom
# Prevents sleep() calls in production code
class NoSleepInProduction < Base
properties do
description "Prevents sleep calls in production code"
enabled true
end
MSG = "Avoid using sleep() in production code; use proper background jobs"
def test(source)
AST::NodeVisitor.new self, source
end
def test(source, node : Crystal::Call)
return unless node.name == "sleep"
issue_for node, MSG
end
end
end
Create main file src/ameba-custom-rules.cr:
require "ameba"
require "./ameba-custom-rules/*"
# Rules are automatically registered through inheritance
Update your project's Ameba configuration:
# .ameba.yml
Custom/NoSleepInProduction:
Enabled: true
Severity: Warning
Create spec/ameba-custom-rules/no_sleep_in_production_spec.cr:
require "../spec_helper"
module Ameba::Rule::Custom
describe NoSleepInProduction do
it "reports sleep calls" do
rule = NoSleepInProduction.new
source = Source.new %(
def process
sleep 5.seconds
end
)
rule.test(source)
source.issues.size.should eq(1)
end
it "allows code without sleep" do
rule = NoSleepInProduction.new
source = Source.new %(
def process
puts "Processing"
end
)
rule.test(source)
source.issues.should be_empty
end
end
end
module Ameba::Rule::Custom
# Enforces that service classes end with "Service"
class ServiceClassNaming < Base
properties do
description "Service classes must end with 'Service'"
enabled true
end
MSG = "Service class name should end with 'Service'"
def test(source)
AST::NodeVisitor.new self, source
end
def test(source, node : Crystal::ClassDef)
class_name = node.name.to_s
# Check if class is in services directory
return unless source.path.includes?("/services/")
# Check if name ends with Service
unless class_name.ends_with?("Service")
issue_for node.name, MSG
end
end
end
end
module Ameba::Rule::Custom
# Prevents dangerous ActiveRecord-like methods
class NoDangerousDatabaseCalls < Base
properties do
description "Prevents dangerous database operations"
dangerous_methods ["delete_all", "destroy_all", "update_all"]
end
MSG = "Dangerous method %s without conditions"
def test(source)
AST::NodeVisitor.new self, source
end
def test(source, node : Crystal::Call)
return unless dangerous_methods.includes?(node.name)
# Check if call has arguments (conditions)
if node.args.empty?
message = MSG % node.name
issue_for node, message
end
end
end
end
module Ameba::Rule::Custom
# Ensures HTTP client calls have error handling
class HttpErrorHandling < Base
properties do
description "HTTP client calls must handle errors"
enabled true
end
MSG = "HTTP client calls should be wrapped in begin/rescue"
def test(source)
@in_rescue_block = false
AST::NodeVisitor.new self, source
end
def test(source, node : Crystal::ExceptionHandler)
@in_rescue_block = true
true # Continue visiting children
end
def test(source, node : Crystal::Call)
return if @in_rescue_block
# Check for HTTP client calls
if node.obj.try(&.to_s.includes?("HTTP"))
issue_for node, MSG
end
end
end
end
module Ameba::Rule::Custom
# Limits method complexity
class MethodComplexity < Base
properties do
description "Methods should not be too complex"
max_complexity 10
end
MSG = "Method complexity (%d) exceeds maximum (%d)"
def test(source)
AST::NodeVisitor.new self, source
end
def test(source, node : Crystal::Def)
complexity = calculate_complexity(node)
if complexity > max_complexity
message = MSG % [complexity, max_complexity]
issue_for node, message
end
end
private def calculate_complexity(node)
counter = ComplexityCounter.new
node.accept(counter)
counter.complexity
end
private class ComplexityCounter < Crystal::Visitor
getter complexity : Int32 = 1
def visit(node : Crystal::If)
@complexity += 1
true
end
def visit(node : Crystal::Case)
@complexity += 1
true
end
def visit(node : Crystal::While)
@complexity += 1
true
end
def visit(node : Crystal::Call)
# Count logical operators
if node.name.in?("&&", "||")
@complexity += 1
end
true
end
def visit(node : Crystal::ASTNode)
true
end
end
end
end
module Ameba::Rule::Custom
# Requires documentation with specific format
class DocumentationFormat < Base
properties do
description "Public methods must have documentation with examples"
enabled true
require_examples true
end
MSG_NO_DOC = "Public method must have documentation"
MSG_NO_EXAMPLE = "Documentation must include usage examples"
def test(source)
AST::NodeVisitor.new self, source
end
def test(source, node : Crystal::Def)
return unless node.visibility.public?
return if node.name.starts_with?("initialize")
doc = node.doc
if doc.nil? || doc.empty?
issue_for node, MSG_NO_DOC
return
end
if require_examples && !has_example?(doc)
issue_for node, MSG_NO_EXAMPLE
end
end
private def has_example?(doc : String)
doc.includes?("```") || doc.includes?("Example:")
end
end
end
# Class definitions
def test(source, node : Crystal::ClassDef)
node.name # Class name
node.visibility # public?, private?, protected?
node.doc # Documentation comment
node.abstract? # Is abstract class?
node.superclass # Parent class
end
# Method definitions
def test(source, node : Crystal::Def)
node.name # Method name
node.args # Arguments
node.body # Method body
node.return_type # Return type annotation
node.visibility # Visibility modifier
node.doc # Documentation
end
# Method calls
def test(source, node : Crystal::Call)
node.name # Method name
node.obj # Receiver object
node.args # Arguments
node.named_args # Named arguments
node.block # Block argument
end
# Variable assignments
def test(source, node : Crystal::Assign)
node.target # Left side (variable)
node.value # Right side (value)
end
# Conditionals
def test(source, node : Crystal::If)
node.cond # Condition
node.then # Then branch
node.else # Else branch
end
# Loops
def test(source, node : Crystal::While)
node.cond # Loop condition
node.body # Loop body
end
# Pattern 1: Track state during traversal
class MyRule < Base
def test(source)
@inside_block = false
@depth = 0
AST::NodeVisitor.new self, source
end
def test(source, node : Crystal::Block)
@inside_block = true
@depth += 1
true # Continue visiting children
end
end
# Pattern 2: Collect information then analyze
class MyRule < Base
def test(source)
@method_names = [] of String
visitor = AST::NodeVisitor.new self, source
analyze_collected_data(source)
end
def test(source, node : Crystal::Def)
@method_names << node.name
end
private def analyze_collected_data(source)
# Analyze @method_names
end
end
# Pattern 3: Parent-child relationships
class MyRule < Base
def test(source, node : Crystal::ClassDef)
# Visit only methods in this class
node.body.accept(MethodVisitor.new(self, source))
end
private class MethodVisitor < Crystal::Visitor
def initialize(@rule : MyRule, @source : Source)
end
def visit(node : Crystal::Def)
@rule.check_method(node, @source)
true
end
def visit(node : Crystal::ASTNode)
true
end
end
end
module Ameba::Rule::Custom
class ConfigurableRule < Base
properties do
description "A rule with configurable properties"
# Boolean properties
enabled true
strict_mode false
# Numeric properties
max_length 100
min_length 3
# String properties
prefix "test_"
suffix "_spec"
# Array properties
allowed_names ["foo", "bar", "baz"]
excluded_paths ["spec/**/*", "lib/**/*"]
end
def test(source)
# Use properties
return unless enabled
if strict_mode
# Apply strict checks
end
AST::NodeVisitor.new self, source
end
end
end
Custom/ConfigurableRule:
Enabled: true
Severity: Warning
StrictMode: true
MaxLength: 120
MinLength: 5
Prefix: "app_"
AllowedNames:
- "primary"
- "secondary"
ExcludedPaths:
- "spec/fixtures/**"
- "db/migrations/**"
# Simple issue
issue_for node, "Error message"
# Issue with interpolation
issue_for node, "Found #{count} violations"
# Issue at specific location
issue_for node.name, "Method name violates convention"
# Issue with custom location
issue_for(
{node.location.try(&.line_number) || 1, 1},
node.end_location,
"Custom message"
)
# Controlled by configuration
Custom/MyRule:
Severity: Error # Blocks CI
# or
Severity: Warning # Important but not blocking
# or
Severity: Convention # Style preference
module Ameba::Rule::Custom
class RichMessages < Base
MSG_TEMPLATE = <<-MSG
Method '%{method}' is too long (%{actual} lines, max %{max} allowed)
Consider extracting to smaller methods or using composition.
MSG
def test(source, node : Crystal::Def)
line_count = count_lines(node)
if line_count > max_lines
message = MSG_TEMPLATE % {
method: node.name,
actual: line_count,
max: max_lines
}
issue_for node, message
end
end
end
end
require "../spec_helper"
module Ameba::Rule::Custom
describe DocumentedClasses do
context "with documented class" do
it "passes" do
rule = DocumentedClasses.new
source = Source.new %(
# This is a documented class
class MyClass
end
)
rule.test(source)
source.issues.should be_empty
end
end
context "with undocumented public class" do
it "reports an issue" do
rule = DocumentedClasses.new
source = Source.new %(
class MyClass
end
)
rule.test(source)
source.issues.size.should eq(1)
source.issues.first.message.should contain("documented")
end
end
context "with private class" do
it "allows undocumented private classes" do
rule = DocumentedClasses.new
source = Source.new %(
private class InternalClass
end
)
rule.test(source)
source.issues.should be_empty
end
end
context "with empty documentation" do
it "reports an issue" do
rule = DocumentedClasses.new
source = Source.new %(
#
class MyClass
end
)
rule.test(source)
source.issues.size.should eq(1)
end
end
context "configuration" do
it "can be disabled" do
rule = DocumentedClasses.new
rule.enabled = false
source = Source.new %(
class MyClass
end
)
rule.test(source)
source.issues.should be_empty
end
end
end
end
# spec/spec_helper.cr
require "spec"
require "ameba"
require "../src/ameba-custom-rules"
module Ameba
# Helper to create test sources
def self.source(code : String, path = "source.cr")
Source.new(code, path)
end
# Helper to expect issues
def self.expect_issue(rule, code)
source = Source.new(code)
rule.test(source)
source.issues.empty?.should be_false
end
# Helper to expect no issues
def self.expect_no_issue(rule, code)
source = Source.new(code)
rule.test(source)
source.issues.should be_empty
end
end
# Usage in specs
describe MyRule do
it "reports violations" do
rule = MyRule.new
Ameba.expect_issue rule, %(
def bad_code
end
)
end
end
# shard.yml
name: ameba-company-rules
version: 1.0.0
description: |
Company-specific Ameba rules for Crystal projects.
Enforces coding standards and best practices.
authors:
- Company DevTools <devtools@company.com>
crystal: ">= 1.0.0"
license: MIT
development_dependencies:
ameba:
github: crystal-ameba/ameba
version: ~> 1.6.0
# Optional: Add to targets for binary
targets:
ameba-company:
main: src/cli.cr
# Option 1: As a shard dependency
# In user's shard.yml
development_dependencies:
ameba:
github: crystal-ameba/ameba
ameba-company-rules:
github: company/ameba-company-rules
# Option 2: As vendored rules
# Copy rule files to project's lib/ameba-rules/
# Include in custom ameba binary
# Option 3: As a plugin
# Create standalone executable that extends ameba
# bin/ameba-custom.cr
require "ameba/cli"
require "../lib/ameba-company-rules/src/ameba-company-rules"
# Rules are automatically discovered
Ameba::CLI.run
Build and distribute:
crystal build bin/ameba-custom.cr -o bin/ameba-custom
# Distribute binary or build from source
module Ameba::Rule::Custom
class PreventNPlusOne < Base
properties do
description "Detects potential N+1 query patterns"
end
MSG = "Potential N+1 query: accessing association in loop"
def test(source)
@in_loop = false
AST::NodeVisitor.new self, source
end
def test(source, node : Crystal::While | Crystal::Call)
if node.is_a?(Crystal::Call) && node.name.in?("each", "map")
@in_loop = true
node.block.try(&.accept(self))
@in_loop = false
return false # Don't visit block again
end
true
end
def test(source, node : Crystal::Call)
return unless @in_loop
# Detect association access patterns
if looks_like_association?(node)
issue_for node, MSG
end
end
private def looks_like_association?(node)
# Simplified detection
node.name.in?("user", "posts", "comments") &&
node.obj != nil
end
end
end
module Ameba::Rule::Custom
class ApiVersioning < Base
properties do
description "API controllers must be versioned"
end
MSG = "API controller must be in a versioned namespace (e.g., V1::)"
def test(source)
return unless source.path.includes?("/api/")
AST::NodeVisitor.new self, source
end
def test(source, node : Crystal::ClassDef)
return unless node.name.to_s.ends_with?("Controller")
unless has_version_namespace?(node)
issue_for node.name, MSG
end
end
private def has_version_namespace?(node)
# Check if class name includes version (V1::, V2::, etc.)
node.name.to_s.matches?(/V\d+::/)
end
end
end
Use the ameba-custom-rules skill when:
test for specific AST nodes, not generic traversal