ð ameba-custom-rules
Use when creating custom Ameba rules for Crystal code analysis including rule development, AST traversal, issue reporting, and rule testing.
Overview
Create custom linting rules for Ameba to enforce project-specific code quality standards and catch domain-specific code smells in Crystal projects.
Understanding Custom Rules
Custom Ameba rules allow you to:
- Enforce project-specific coding standards
- Catch domain-specific anti-patterns
- Validate business logic constraints
- Ensure consistency across large codebases
- Create reusable rule libraries for your organization
- Extend Ameba's built-in capabilities
Rule Anatomy
Basic Rule Structure
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
Key Components
- Module namespace - Custom rules typically use
Ameba::Rule::CustomorAmeba::Rule::<Category> - Base class - All rules inherit from
Ameba::Rule::Base - Properties block - Defines rule metadata and configuration
- Message constant - The error message shown to users
- Test method - Entry point that initializes the AST visitor
- Overloaded test methods - Handle specific AST node types
Creating Your First Custom Rule
Step 1: Project Setup
Create 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.
Step 2: Implement a Simple Rule
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
Step 3: Register and Use the Rule
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
Step 4: Test Your Rule
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
Advanced Rule Examples
Enforcing Naming Conventions
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
Detecting Dangerous Method Calls
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
Enforcing Error Handling
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
Validating Method Complexity
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
Enforcing Documentation Standards
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
Working with AST Nodes
Common AST Node Types
# 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
Traversal Patterns
# 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
Rule Configuration
Configurable Properties
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
Configuration in .ameba.yml
Custom/ConfigurableRule:
Enabled: true
Severity: Warning
StrictMode: true
MaxLength: 120
MinLength: 5
Prefix: "app_"
AllowedNames:
- "primary"
- "secondary"
ExcludedPaths:
- "spec/fixtures/**"
- "db/migrations/**"
Issue Reporting
Basic Issue Reporting
# 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"
)
Issue Severity
# Controlled by configuration
Custom/MyRule:
Severity: Error # Blocks CI
# or
Severity: Warning # Important but not blocking
# or
Severity: Convention # Style preference
Rich Issue Messages
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
Testing Custom Rules
Comprehensive Test Suite
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
Test Helpers
# 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
Packaging and Distribution
Creating a Reusable Rule Package
# 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
Distribution Strategy
# 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
Custom Ameba Binary
# 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
Real-World Rule Examples
Preventing N+1 Queries
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
Enforcing API Versioning
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
When to Use This Skill
Use the ameba-custom-rules skill when:
- Enforcing project-specific coding standards not covered by built-in rules
- Detecting domain-specific anti-patterns or code smells
- Validating business logic constraints in code
- Creating organization-wide linting standards
- Migrating from another language and enforcing new patterns
- Preventing specific bugs that have occurred in production
- Ensuring consistency across microservices
- Teaching team members about code quality through automated feedback
- Enforcing architectural decisions (e.g., layer boundaries)
- Standardizing error handling, logging, or monitoring patterns
Best Practices
- Start simple - Begin with basic rules before tackling complex AST traversals
- Test thoroughly - Write comprehensive specs covering edge cases
- Provide clear messages - Error messages should explain what's wrong and suggest fixes
- Make rules configurable - Use properties for thresholds and options
- Document your rules - Include description and examples in properties block
- Use specific node types - Overload
testfor specific AST nodes, not generic traversal - Consider performance - Avoid complex operations in hot paths; cache results when possible
- Follow naming conventions - Use descriptive rule names that match their purpose
- Provide fix suggestions - When possible, explain how to resolve the issue
- Scope appropriately - Only check relevant files (use source.path checks)
- Handle nil safely - Use try(&.) when accessing potentially nil AST properties
- Avoid false positives - Better to miss some cases than flag correct code
- Version your rules - Track rule versions and breaking changes
- Keep rules focused - One rule should check one thing (Single Responsibility)
- Integrate with CI - Ensure custom rules work in automated environments
Common Pitfalls
- Overly broad matching - Catching too many cases and producing false positives
- Not handling nil - AST nodes may have nil properties causing crashes
- Ignoring visibility - Checking private methods when only public API matters
- Complex visitor logic - Making traversal code hard to understand and maintain
- Missing edge cases - Not testing unusual but valid code patterns
- Poor error messages - Vague messages that don't help developers fix issues
- Hard-coded values - Not making thresholds and options configurable
- Checking generated code - Flagging auto-generated files that shouldn't be changed
- Performance issues - Complex rules that slow down analysis significantly
- Dependency conflicts - Using Ameba as regular dependency instead of development_dependencies
- Not using properties - Hard-coding configuration instead of using properties block
- Incomplete testing - Not testing disabled state, edge cases, or configuration
- Tight coupling - Rules that depend on other rules or specific file structures
- Unclear scope - Rules that apply to wrong files or contexts
- Version incompatibility - Not testing against multiple Ameba versions