Compare commits

...

62 Commits

Author SHA1 Message Date
Sijawusz Pur Rahnama 891cad2610 Install `typos-cli` on macOS CI 2023-11-08 02:24:35 +01:00
Sijawusz Pur Rahnama 0140fd3573 Add `Lint/Typos` rule 2023-11-08 02:24:35 +01:00
Sijawusz Pur Rahnama 9f6615bdfd
Merge pull request #380 from crystal-ameba/add-documentation-admonition-rule
Add `Lint/DocumentationAdmonition` rule
2023-11-06 17:03:50 +01:00
Sijawusz Pur Rahnama 1fccbfc8b8 Set timezone to `UTC` in CI 2023-11-06 16:59:09 +01:00
Sijawusz Pur Rahnama c2b5e9449c Do not report `TODO` admonitions 2023-11-06 16:59:09 +01:00
Sijawusz Pur Rahnama d5ac394d19 Switch only `FIXME` comment to `TODO` 2023-11-06 16:59:09 +01:00
Sijawusz Pur Rahnama bdbb79f1fa Fix nonexistent method name used in error message 2023-11-06 16:59:09 +01:00
Sijawusz Pur Rahnama 1b342e8257 Make `TODOFormatter`'s configuration file path configurable
Fixes the case where formatter specs were deleting project's `.ameba.yml` file
2023-11-06 16:59:09 +01:00
Sijawusz Pur Rahnama 23c61e04c0 Add `Lint/DocumentationAdmonition` rule 2023-11-06 16:59:09 +01:00
Sijawusz Pur Rahnama ddb6e3c38f
Merge pull request #390 from crystal-ameba/refactor-rules-cli-switch
Refactor `--rules` CLI switch output + add `--describe <rule-name>` CLI switch
2023-11-05 06:44:55 +01:00
Sijawusz Pur Rahnama ef16ad6471 Add presenter specs 2023-11-05 06:39:24 +01:00
Sijawusz Pur Rahnama 1b57e2cad5 Make `Formatter::Util` extend itself for easier access 2023-11-05 06:08:40 +01:00
Sijawusz Pur Rahnama 3d3626accc Introduced new presenter abstraction 2023-11-04 01:44:59 +01:00
Sijawusz Pur Rahnama bede3f97a1 Colorize code in rule descriptions too 2023-11-04 00:49:11 +01:00
Sijawusz Pur Rahnama 8ff621ba66 Add `--describe` CLI switch 2023-11-04 00:49:11 +01:00
Sijawusz Pur Rahnama f1f21ac94d Refactor `--rules` CLI switch output 2023-11-04 00:49:11 +01:00
Sijawusz Pur Rahnama 1718945523 Refactor `ExplainFormatter` a bit 2023-11-04 00:49:11 +01:00
Sijawusz Pur Rahnama c9538220c6
Merge pull request #407 from crystal-ameba/crystal-next-compatibility
fix: crystal next compatibility
2023-10-09 23:47:32 +02:00
Vitalii Elenhaupt 789e1b77e8
fix: crystal next compatibility
refs https://github.com/crystal-lang/crystal/pull/11597
fixes https://github.com/crystal-ameba/ameba/issues/406
2023-10-06 18:57:39 +03:00
Sijawusz Pur Rahnama 7174e81a13
Merge pull request #401 from crystal-ameba/dependabot/github_actions/docker/login-action-3
Bump docker/login-action from 2 to 3
2023-09-12 23:41:13 +02:00
Sijawusz Pur Rahnama 29f84921b5
Merge pull request #402 from crystal-ameba/dependabot/github_actions/docker/build-push-action-5
Bump docker/build-push-action from 4 to 5
2023-09-12 23:41:02 +02:00
Sijawusz Pur Rahnama c7f3fe78aa
Merge pull request #403 from crystal-ameba/dependabot/github_actions/docker/metadata-action-5
Bump docker/metadata-action from 4 to 5
2023-09-12 23:40:53 +02:00
Sijawusz Pur Rahnama 2d9db35ec4
Merge pull request #404 from crystal-ameba/dependabot/github_actions/docker/setup-qemu-action-3
Bump docker/setup-qemu-action from 2 to 3
2023-09-12 23:40:44 +02:00
Sijawusz Pur Rahnama dfda3d7677
Merge pull request #405 from crystal-ameba/dependabot/github_actions/docker/setup-buildx-action-3
Bump docker/setup-buildx-action from 2 to 3
2023-09-12 23:40:34 +02:00
dependabot[bot] 0829f70256
Bump docker/setup-buildx-action from 2 to 3
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-12 21:20:47 +00:00
dependabot[bot] 53b311c5eb
Bump docker/setup-qemu-action from 2 to 3
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-12 21:20:44 +00:00
dependabot[bot] 867ddb4fbd
Bump docker/metadata-action from 4 to 5
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 4 to 5.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Upgrade guide](https://github.com/docker/metadata-action/blob/master/UPGRADE.md)
- [Commits](https://github.com/docker/metadata-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-12 21:20:41 +00:00
dependabot[bot] 6724f9a0e0
Bump docker/build-push-action from 4 to 5
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 4 to 5.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-12 21:20:37 +00:00
dependabot[bot] 6389edc5fa
Bump docker/login-action from 2 to 3
Bumps [docker/login-action](https://github.com/docker/login-action) from 2 to 3.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-12 21:20:34 +00:00
Vitalii Elenhaupt 0ab39a025b
Merge pull request #399 from crystal-ameba/dependabot/github_actions/actions/checkout-4
Bump actions/checkout from 3 to 4
2023-09-05 08:15:00 +03:00
dependabot[bot] 135ff87c7e
Bump actions/checkout from 3 to 4
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-04 21:50:31 +00:00
Vitalii Elenhaupt 18d193bd08
Merge pull request #394 from stufro/388-raise-on-invalid-file-path
Raise error when passed invalid file paths
2023-08-10 13:08:00 +03:00
Stuart Frost f96cb01015 Ensure test cleanup occurs 2023-08-05 19:28:58 +01:00
Stuart Frost 1b85ba6f22 Refactor: use unless instead of if not 2023-08-05 19:28:47 +01:00
Stuart Frost eb60b25c4e Refactor building default globs 2023-08-05 16:15:50 +01:00
Stuart Frost 7690074cab Conditionally add !lib to default globs 2023-08-04 21:48:35 +01:00
Vitalii Elenhaupt 7b8316f061
Bump v1.5.0 2023-07-28 22:40:22 +03:00
Stuart Frost b2069ea4ff Add extra test cases 2023-07-27 09:29:28 +01:00
Stuart Frost e85531df6c Rename test fixture to source.cr 2023-07-27 09:24:26 +01:00
Stuart Frost 07aebfc84a
Merge branch 'master' into 388-raise-on-invalid-file-path 2023-07-26 15:22:04 +01:00
Vitalii Elenhaupt 8ef588dc6d
Merge pull request #393 from stufro/362-raise-on-invalid-config-file-path
Raise error when passed invalid config file path
2023-07-26 17:19:44 +03:00
Stuart Frost 3b9c442e09 Raise error when passed invalid file paths 2023-07-26 15:01:59 +01:00
Stuart Frost 88e0437902 Move fixture file to spec/fixtures directory 2023-07-25 10:00:16 +01:00
Stuart Frost 4741c9f4c4 Reword generic error message on config load 2023-07-25 08:46:13 +01:00
Stuart Frost d9b2d69055 Reword error when file doesn't exist
Applied suggestion from PR

Co-authored-by: Vitalii Elenhaupt <3624712+veelenga@users.noreply.github.com>
2023-07-25 08:43:49 +01:00
Stuart Frost 5f878fb40f Move missing config file check into Ameba::Config 2023-07-24 19:10:52 +01:00
Stuart Frost 01a943d0d6 Raise error when passed invalid config file path 2023-07-24 15:30:38 +01:00
Sijawusz Pur Rahnama 8c9d234d0b
Merge pull request #391 from straight-shoota/feat/portability
Make postinstall portable
2023-07-16 20:42:37 +02:00
Johannes Müller efa9c9dba0
Makefile: Remove `run_file` target 2023-07-15 22:47:14 +02:00
Johannes Müller 15ce5437d1
Make `postinstall` portable
Using `shards build` directly instead of `make build` improves portability a lot.
This should basically "just work" almost anywhere (including Windows). No need for `make` to be available and makefile compatibility doesn't matter either.
2023-07-15 10:13:53 +02:00
Johannes Müller eacb9308a7
Utilize shards' `executables`
There's no need for copying the executables manually (which happens in both makefile targets `bin` and `run_file`).
Shards' `executables` takes care of that.

The makefile targets could potentially be dropped as well, I don't think there would be other uses case for those.
2023-07-15 10:13:53 +02:00
Sijawusz Pur Rahnama a33f98624a
Merge pull request #376 from crystal-ameba/update-to-work-with-crystal-nightly 2023-07-11 23:50:14 +02:00
Sijawusz Pur Rahnama 33c8273866
Merge pull request #387 from crystal-ameba/feature/minmax-after-map-rule
Add `Performance/MinMaxAfterMap` rule
2023-07-10 16:17:53 +02:00
Sijawusz Pur Rahnama 327ed546b9
Apply suggestions from code review
Co-authored-by: Vitalii Elenhaupt <3624712+veelenga@users.noreply.github.com>
2023-07-10 16:09:01 +02:00
Sijawusz Pur Rahnama ddff8d226b Add `Performance/MinMaxAfterMap` rule 2023-07-10 15:46:17 +02:00
Sijawusz Pur Rahnama 5cff76071a No need for such micro-optimizations, LLVM takes care of those 2023-07-02 14:34:25 +02:00
Sijawusz Pur Rahnama 29e29b8e1d Fix `Performance/ExcessiveAllocations` to exclude `each` calls without a block 2023-06-30 21:44:47 +02:00
Sijawusz Pur Rahnama 21051acfff
Merge pull request #386 from crystal-ameba/fix-issue-385 2023-06-30 20:58:56 +02:00
Sijawusz Pur Rahnama abe5237802 Add `Performance/ExcessiveAllocations` rule 2023-06-30 15:17:40 +02:00
Sijawusz Pur Rahnama b7b21ffeb0
Merge pull request #384 from crystal-ameba/fix-issue-383
Fix `Style/VerboseBlock` rule to work with binary operations
2023-06-29 10:20:13 +02:00
Sijawusz Pur Rahnama 4d0125a0f3 Fix `Style/VerboseBlock` rule to work with binary operations 2023-06-29 08:15:39 +02:00
Sijawusz Pur Rahnama db59b23f9b Fix specs against Crystal nightly 2023-06-10 01:11:21 +02:00
50 changed files with 907 additions and 120 deletions

7
.ameba.yml Normal file
View File

@ -0,0 +1,7 @@
Lint/DocumentationAdmonition:
Timezone: UTC
Admonitions: [FIXME, BUG]
Lint/Typos:
Excluded:
- spec/ameba/rule/lint/typos_spec.cr

View File

@ -24,18 +24,18 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3
- name: Setup Docker Buildx - name: Setup Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
# Login against a Docker registry except on PR # Login against a Docker registry except on PR
# https://github.com/docker/login-action # https://github.com/docker/login-action
- name: Log into ${{ env.REGISTRY }} registry - name: Log into ${{ env.REGISTRY }} registry
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@ -45,7 +45,7 @@ jobs:
# https://github.com/docker/metadata-action # https://github.com/docker/metadata-action
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@v5
with: with:
images: | images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@ -61,7 +61,7 @@ jobs:
# https://github.com/docker/build-push-action # https://github.com/docker/build-push-action
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: docker/build-push-action@v4 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: true push: true

View File

@ -18,17 +18,24 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Set timezone to UTC
uses: szenius/set-timezone@v1.2
- name: Install Crystal - name: Install Crystal
uses: crystal-lang/install-crystal@v1 uses: crystal-lang/install-crystal@v1
with: with:
crystal: ${{ matrix.crystal }} crystal: ${{ matrix.crystal }}
- name: Download source - name: Download source
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Install dependencies - name: Install dependencies
run: shards install run: shards install
- name: Install typos-cli
if: matrix.os == 'macos-latest'
run: brew install typos-cli
- name: Run specs - name: Run specs
run: crystal spec run: crystal spec

View File

@ -19,7 +19,7 @@ jobs:
uses: crystal-lang/install-crystal@v1 uses: crystal-lang/install-crystal@v1
- name: Download source - name: Download source
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Install dependencies - name: Install dependencies
run: shards install run: shards install

View File

@ -30,9 +30,5 @@ bin: build
mkdir -p $(SHARD_BIN) mkdir -p $(SHARD_BIN)
cp ./bin/ameba $(SHARD_BIN) cp ./bin/ameba $(SHARD_BIN)
.PHONY: run_file
run_file:
cp -n ./bin/ameba.cr $(SHARD_BIN) || true
.PHONY: test .PHONY: test
test: spec lint test: spec lint

View File

@ -1,5 +1,5 @@
name: ameba name: ameba
version: 1.4.3 version: 1.5.0
authors: authors:
- Vitalii Elenhaupt <velenhaupt@gmail.com> - Vitalii Elenhaupt <velenhaupt@gmail.com>
@ -10,11 +10,12 @@ targets:
scripts: scripts:
# TODO: remove pre-compiled executable in future releases # TODO: remove pre-compiled executable in future releases
postinstall: make bin && make run_file postinstall: shards build -Dpreview_mt
executables: executables:
- ameba - ameba
- ameba.cr
crystal: "~> 1.7.0" crystal: "~> 1.9.0"
license: MIT license: MIT

View File

@ -85,30 +85,5 @@ module Ameba::AST
assignment.branch.should be_nil assignment.branch.should be_nil
end end
end end
describe "#transformed?" do
it "returns false if the assignment is not transformed by the compiler" do
nodes = as_nodes <<-CRYSTAL
def method(a)
a = 2
end
CRYSTAL
scope = Scope.new nodes.def_nodes.first
variable = Variable.new(nodes.var_nodes.first, scope)
assignment = Assignment.new(nodes.assign_nodes.first, variable, scope)
assignment.transformed?.should be_false
end
it "returns true if the assignment is transformed by the compiler" do
nodes = as_nodes <<-CRYSTAL
array.each do |(a, b)|
end
CRYSTAL
scope = Scope.new nodes.block_nodes.first
variable = Variable.new(nodes.var_nodes.first, scope)
assignment = Assignment.new(nodes.assign_nodes.first, variable, scope)
assignment.transformed?.should be_true
end
end
end end
end end

View File

@ -5,7 +5,7 @@ module Ameba::Cli
describe "Cmd" do describe "Cmd" do
describe ".run" do describe ".run" do
it "runs ameba" do it "runs ameba" do
r = Cli.run %w(-f silent file.cr) r = Cli.run %w(-f silent -c spec/fixtures/config.yml spec/fixtures/source.cr)
r.should be_nil r.should be_nil
end end
end end
@ -43,12 +43,12 @@ module Ameba::Cli
end end
it "defaults rules? flag to false" do it "defaults rules? flag to false" do
c = Cli.parse_args %w(file.cr) c = Cli.parse_args %w(spec/fixtures/source.cr)
c.rules?.should be_false c.rules?.should be_false
end end
it "defaults skip_reading_config? flag to false" do it "defaults skip_reading_config? flag to false" do
c = Cli.parse_args %w(file.cr) c = Cli.parse_args %w(spec/fixtures/source.cr)
c.skip_reading_config?.should be_false c.skip_reading_config?.should be_false
end end
@ -58,7 +58,7 @@ module Ameba::Cli
end end
it "defaults all? flag to false" do it "defaults all? flag to false" do
c = Cli.parse_args %w(file.cr) c = Cli.parse_args %w(spec/fixtures/source.cr)
c.all?.should be_false c.all?.should be_false
end end
@ -95,35 +95,35 @@ module Ameba::Cli
describe "-e/--explain" do describe "-e/--explain" do
it "configures file/line/column" do it "configures file/line/column" do
c = Cli.parse_args %w(--explain src/file.cr:3:5) c = Cli.parse_args %w(--explain spec/fixtures/source.cr:3:5)
location_to_explain = c.location_to_explain.should_not be_nil location_to_explain = c.location_to_explain.should_not be_nil
location_to_explain[:file].should eq "src/file.cr" location_to_explain[:file].should eq "spec/fixtures/source.cr"
location_to_explain[:line].should eq 3 location_to_explain[:line].should eq 3
location_to_explain[:column].should eq 5 location_to_explain[:column].should eq 5
end end
it "raises an error if location is not valid" do it "raises an error if location is not valid" do
expect_raises(Exception, "location should have PATH:line:column") do expect_raises(Exception, "location should have PATH:line:column") do
Cli.parse_args %w(--explain src/file.cr:3) Cli.parse_args %w(--explain spec/fixtures/source.cr:3)
end end
end end
it "raises an error if line number is not valid" do it "raises an error if line number is not valid" do
expect_raises(Exception, "location should have PATH:line:column") do expect_raises(Exception, "location should have PATH:line:column") do
Cli.parse_args %w(--explain src/file.cr:a:3) Cli.parse_args %w(--explain spec/fixtures/source.cr:a:3)
end end
end end
it "raises an error if column number is not valid" do it "raises an error if column number is not valid" do
expect_raises(Exception, "location should have PATH:line:column") do expect_raises(Exception, "location should have PATH:line:column") do
Cli.parse_args %w(--explain src/file.cr:3:&) Cli.parse_args %w(--explain spec/fixtures/source.cr:3:&)
end end
end end
it "raises an error if line/column are missing" do it "raises an error if line/column are missing" do
expect_raises(Exception, "location should have PATH:line:column") do expect_raises(Exception, "location should have PATH:line:column") do
Cli.parse_args %w(--explain src/file.cr) Cli.parse_args %w(--explain spec/fixtures/source.cr)
end end
end end
end end

View File

@ -2,17 +2,28 @@ require "../spec_helper"
module Ameba module Ameba
describe Config do describe Config do
config_sample = "config/ameba.yml" config_sample = "spec/fixtures/config.yml"
it "should have a list of available formatters" do it "should have a list of available formatters" do
Config::AVAILABLE_FORMATTERS.should_not be_nil Config::AVAILABLE_FORMATTERS.should_not be_nil
end end
describe ".new" do describe ".new" do
it "loads default globs when config is empty" do context "when config is empty" do
yml = YAML.parse "{}" it "loads default globs" do
config = Config.new(yml) yml = YAML.parse "{}"
config.globs.should eq Config::DEFAULT_GLOBS config = Config.new(yml)
config.globs.should eq ["**/*.cr"]
end
it "sets !lib as a default glob when there are .cr files in lib" do
File.touch "lib/shard.cr"
yml = YAML.parse "{}"
config = Config.new(yml)
config.globs.should eq ["**/*.cr", "!lib"]
ensure
File.delete "lib/shard.cr"
end
end end
it "initializes globs as string" do it "initializes globs as string" do
@ -84,6 +95,12 @@ module Ameba
config.formatter.should_not be_nil config.formatter.should_not be_nil
end end
it "raises when custom config file doesn't exist" do
expect_raises(Exception, "Unable to load config file: Config file does not exist foo.yml") do
Config.load "foo.yml"
end
end
it "loads default config" do it "loads default config" do
config = Config.load config = Config.load
config.should_not be_nil config.should_not be_nil
@ -96,7 +113,7 @@ module Ameba
config = Config.load config_sample config = Config.load config_sample
it "holds source globs" do it "holds source globs" do
config.globs.should eq Config::DEFAULT_GLOBS config.globs.should eq ["**/*.cr"]
end end
it "allows to set globs" do it "allows to set globs" do

View File

@ -1,10 +1,12 @@
require "../../spec_helper" require "../../spec_helper"
require "file_utils" require "file_utils"
CONFIG_PATH = Path[Dir.tempdir] / Ameba::Config::FILENAME
module Ameba module Ameba
private def with_formatter(&) private def with_formatter(&)
io = IO::Memory.new io = IO::Memory.new
formatter = Formatter::TODOFormatter.new(io) formatter = Formatter::TODOFormatter.new(io, CONFIG_PATH)
yield formatter, io yield formatter, io
end end
@ -20,7 +22,7 @@ module Ameba
describe Formatter::TODOFormatter do describe Formatter::TODOFormatter do
::Spec.after_each do ::Spec.after_each do
FileUtils.rm_rf(Ameba::Config::DEFAULT_PATH) FileUtils.rm_rf(CONFIG_PATH)
end end
context "problems not found" do context "problems not found" do
@ -45,7 +47,7 @@ module Ameba
s = Source.new "a = 1", "source.cr" s = Source.new "a = 1", "source.cr"
s.add_issue DummyRule.new, {1, 2}, "message" s.add_issue DummyRule.new, {1, 2}, "message"
formatter.finished([s]) formatter.finished([s])
io.to_s.should contain "Created #{Config::DEFAULT_PATH}" io.to_s.should contain "Created #{CONFIG_PATH}"
end end
end end

View File

@ -45,6 +45,30 @@ module Ameba
subject.expand(["**/#{current_file_basename}", "**/#{current_file_basename}"]) subject.expand(["**/#{current_file_basename}", "**/#{current_file_basename}"])
.should eq [current_file_path] .should eq [current_file_path]
end end
it "raises an ArgumentError when the glob doesn't match any files" do
expect_raises(ArgumentError, "No files found matching foo/*") do
subject.expand(["foo/*"])
end
end
it "raises an ArgumentError when given a missing file" do
expect_raises(ArgumentError, "No files found matching foo.cr") do
subject.expand(["foo.cr"])
end
end
it "raises an ArgumentError when given a missing directory" do
expect_raises(ArgumentError, "No files found matching foo/") do
subject.expand(["foo/"])
end
end
it "raises an ArgumentError when given multiple arguments, one of which is missing" do
expect_raises(ArgumentError, "No files found matching foo.cr") do
subject.expand(["**/#{current_file_basename}", "foo.cr"])
end
end
end end
end end
end end

View File

@ -0,0 +1,32 @@
require "../../spec_helper"
module Ameba
private def with_rule_collection_presenter(&)
with_presenter(Presenter::RuleCollectionPresenter) do |presenter, io|
rules = Config.load.rules
presenter.run(rules)
output = io.to_s
output = Formatter::Util.deansify(output).to_s
yield rules, output, presenter
end
end
describe Presenter::RuleCollectionPresenter do
it "outputs rule collection details" do
with_rule_collection_presenter do |rules, output|
rules.each do |rule|
output.should contain rule.name
output.should contain rule.severity.symbol
if description = rule.description
output.should contain description
end
end
output.should contain "Total rules: #{rules.size}"
output.should match /\d+ enabled/
end
end
end
end

View File

@ -0,0 +1,30 @@
require "../../spec_helper"
module Ameba
private def rule_presenter_each_rule(&)
with_presenter(Presenter::RulePresenter) do |presenter, io|
rules = Config.load.rules
rules.each do |rule|
presenter.run(rule)
output = io.to_s
output = Formatter::Util.deansify(output).to_s
yield rule, output, presenter
end
end
end
describe Presenter::RulePresenter do
it "outputs rule details" do
rule_presenter_each_rule do |rule, output|
output.should contain rule.name
output.should contain rule.severity.to_s
if description = rule.description
output.should contain description
end
end
end
end
end

View File

@ -0,0 +1,113 @@
require "../../../spec_helper"
module Ameba::Rule::Lint
subject = DocumentationAdmonition.new
describe DocumentationAdmonition do
it "passes for comments with admonition mid-word/sentence" do
subject.admonitions.each do |admonition|
expect_no_issues subject, <<-CRYSTAL
# Mentioning #{admonition} mid-sentence
# x#{admonition}x
# x#{admonition}
# #{admonition}x
CRYSTAL
end
end
it "fails for comments with admonition" do
subject.admonitions.each do |admonition|
expect_issue subject, <<-CRYSTAL
# #{admonition}: Single-line comment
# ^{} error: Found a #{admonition} admonition in a comment
CRYSTAL
expect_issue subject, <<-CRYSTAL
# Text before ...
# #{admonition}(some context): Part of multi-line comment
# ^{} error: Found a #{admonition} admonition in a comment
# Text after ...
CRYSTAL
expect_issue subject, <<-CRYSTAL
# #{admonition}
# ^{} error: Found a #{admonition} admonition in a comment
if rand > 0.5
end
CRYSTAL
end
end
context "with date" do
it "passes for admonitions with future date" do
subject.admonitions.each do |admonition|
future_date = (Time.utc + 21.days).to_s(format: "%F")
expect_no_issues subject, <<-CRYSTAL
# #{admonition}(#{future_date}): sth in the future
CRYSTAL
end
end
it "fails for admonitions with past date" do
subject.admonitions.each do |admonition|
past_date = (Time.utc - 21.days).to_s(format: "%F")
expect_issue subject, <<-CRYSTAL
# #{admonition}(#{past_date}): sth in the past
# ^{} error: Found a #{admonition} admonition in a comment (21 days past)
CRYSTAL
end
end
it "fails for admonitions with yesterday's date" do
subject.admonitions.each do |admonition|
yesterday_date = (Time.utc - 1.day).to_s(format: "%F")
expect_issue subject, <<-CRYSTAL
# #{admonition}(#{yesterday_date}): sth in the past
# ^{} error: Found a #{admonition} admonition in a comment (1 day past)
CRYSTAL
end
end
it "fails for admonitions with today's date" do
subject.admonitions.each do |admonition|
today_date = Time.utc.to_s(format: "%F")
expect_issue subject, <<-CRYSTAL
# #{admonition}(#{today_date}): sth in the past
# ^{} error: Found a #{admonition} admonition in a comment (today is the day!)
CRYSTAL
end
end
it "fails for admonitions with invalid date" do
subject.admonitions.each do |admonition|
expect_issue subject, <<-CRYSTAL
# #{admonition}(0000-00-00): sth wrong
# ^{} error: #{admonition} admonition error: Invalid time: "0000-00-00"
CRYSTAL
end
end
end
context "properties" do
describe "#admonitions" do
it "lets setting custom admonitions" do
rule = DocumentationAdmonition.new
rule.admonitions = %w[FOO BAR]
rule.admonitions.each do |admonition|
expect_issue rule, <<-CRYSTAL
# #{admonition}
# ^{} error: Found a #{admonition} admonition in a comment
CRYSTAL
end
subject.admonitions.each do |admonition|
expect_no_issues rule, <<-CRYSTAL
# #{admonition}
CRYSTAL
end
end
end
end
end
end

View File

@ -44,7 +44,7 @@ module Ameba::Rule::Lint
foo = 1 foo = 1
-> (foo : Int32) {} -> (foo : Int32) {}
# ^ error: Shadowing outer local variable `foo` # ^^^^^^^^^^^ error: Shadowing outer local variable `foo`
end end
CRYSTAL CRYSTAL
end end
@ -69,7 +69,7 @@ module Ameba::Rule::Lint
3.times do |foo| 3.times do |foo|
# ^ error: Shadowing outer local variable `foo` # ^ error: Shadowing outer local variable `foo`
-> (foo : Int32) { foo + 1 } -> (foo : Int32) { foo + 1 }
# ^ error: Shadowing outer local variable `foo` # ^^^^^^^^^^^ error: Shadowing outer local variable `foo`
end end
CRYSTAL CRYSTAL
end end

View File

@ -115,12 +115,12 @@ module Ameba::Rule::Lint
first.rule.should_not be_nil first.rule.should_not be_nil
first.location.to_s.should eq "source_spec.cr:1:11" first.location.to_s.should eq "source_spec.cr:1:11"
first.end_location.to_s.should eq "" first.end_location.to_s.should eq "source_spec.cr:1:21"
first.message.should eq "Focused spec item detected" first.message.should eq "Focused spec item detected"
second.rule.should_not be_nil second.rule.should_not be_nil
second.location.to_s.should eq "source_spec.cr:2:13" second.location.to_s.should eq "source_spec.cr:2:13"
second.end_location.to_s.should eq "" second.end_location.to_s.should eq "source_spec.cr:2:23"
second.message.should eq "Focused spec item detected" second.message.should eq "Focused spec item detected"
end end
end end

View File

@ -0,0 +1,32 @@
require "../../../spec_helper"
private def check_typos_bin!
unless Ameba::Rule::Lint::Typos::BIN_PATH
pending! "`typos` executable is not available"
end
end
module Ameba::Rule::Lint
subject = Typos.new
.tap(&.fail_on_error = true)
describe Typos do
it "reports typos" do
check_typos_bin!
source = expect_issue subject, <<-CRYSTAL
# method with no arugments
# ^^^^^^^^^ error: Typo found: arugments -> arguments
def tpos
# ^^^^ error: Typo found: tpos -> typos
end
CRYSTAL
expect_correction source, <<-CRYSTAL
# method with no arguments
def typos
end
CRYSTAL
end
end
end

View File

@ -52,7 +52,7 @@ module Ameba::Rule::Lint
it "reports if proc argument is unused" do it "reports if proc argument is unused" do
source = expect_issue subject, <<-CRYSTAL source = expect_issue subject, <<-CRYSTAL
-> (a : Int32, b : String) do -> (a : Int32, b : String) do
# ^ error: Unused argument `b`. If it's necessary, use `_b` as an argument name to indicate that it won't be used. # ^^^^^^^^^^ error: Unused argument `b`. If it's necessary, use `_b` as an argument name to indicate that it won't be used.
a = a + 1 a = a + 1
end end
CRYSTAL CRYSTAL
@ -306,7 +306,7 @@ module Ameba::Rule::Lint
expect_issue rule, <<-CRYSTAL expect_issue rule, <<-CRYSTAL
->(a : Int32) {} ->(a : Int32) {}
# ^ error: Unused argument `a`. If it's necessary, use `_a` as an argument name to indicate that it won't be used. # ^^^^^^^^^ error: Unused argument `a`. If it's necessary, use `_a` as an argument name to indicate that it won't be used.
CRYSTAL CRYSTAL
end end
end end

View File

@ -233,7 +233,7 @@ module Ameba::Rule::Lint
subject.catch(s).should be_valid subject.catch(s).should be_valid
end end
context "when transformed" do context "block unpacking" do
it "does not report if the first arg is transformed and not used" do it "does not report if the first arg is transformed and not used" do
s = Source.new %( s = Source.new %(
collection.each do |(a, b)| collection.each do |(a, b)|

View File

@ -0,0 +1,58 @@
require "../../../spec_helper"
module Ameba::Rule::Performance
subject = ExcessiveAllocations.new
describe ExcessiveAllocations do
it "passes if there is no potential performance improvements" do
expect_no_issues subject, <<-CRYSTAL
"Alice".chars.each
"Alice".chars.each(arg) { |c| puts c }
"Alice".chars(arg).each { |c| puts c }
"Alice\nBob".lines.each(arg) { |l| puts l }
"Alice\nBob".lines(arg).each { |l| puts l }
CRYSTAL
end
it "reports if there is a collection method followed by each" do
source = expect_issue subject, <<-CRYSTAL
"Alice".chars.each { |c| puts c }
# ^^^^^^^^^^ error: Use `each_char {...}` instead of `chars.each {...}` to avoid excessive allocation
"Alice\nBob".lines.each { |l| puts l }
# ^^^^^^^^^^ error: Use `each_line {...}` instead of `lines.each {...}` to avoid excessive allocation
CRYSTAL
expect_correction source, <<-CRYSTAL
"Alice".each_char { |c| puts c }
"Alice\nBob".each_line { |l| puts l }
CRYSTAL
end
it "does not report if source is a spec" do
expect_no_issues subject, <<-CRYSTAL, "source_spec.cr"
"Alice".chars.each { |c| puts c }
CRYSTAL
end
context "properties" do
it "#call_names" do
rule = ExcessiveAllocations.new
rule.call_names = {
"children" => "each_child",
}
expect_no_issues rule, <<-CRYSTAL
"Alice".chars.each { |c| puts c }
CRYSTAL
end
end
context "macro" do
it "doesn't report in macro scope" do
expect_no_issues subject, <<-CRYSTAL
{{ "Alice".chars.each { |c| puts c } }}
CRYSTAL
end
end
end
end

View File

@ -0,0 +1,45 @@
require "../../../spec_helper"
module Ameba::Rule::Performance
subject = MinMaxAfterMap.new
describe MinMaxAfterMap do
it "passes if there are no potential performance improvements" do
expect_no_issues subject, <<-CRYSTAL
%w[Alice Bob].map { |name| name.size }.min(2)
%w[Alice Bob].map { |name| name.size }.max(2)
CRYSTAL
end
it "reports if there is a `min/max/minmax` call followed by `map`" do
source = expect_issue subject, <<-CRYSTAL
%w[Alice Bob].map { |name| name.size }.min
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ error: Use `min_of {...}` instead of `map {...}.min`.
%w[Alice Bob].map(&.size).max.zero?
# ^^^^^^^^^^^^^^^ error: Use `max_of {...}` instead of `map {...}.max`.
%w[Alice Bob].map(&.size).minmax?
# ^^^^^^^^^^^^^^^^^^^ error: Use `minmax_of? {...}` instead of `map {...}.minmax?`.
CRYSTAL
expect_correction source, <<-CRYSTAL
%w[Alice Bob].min_of { |name| name.size }
%w[Alice Bob].max_of(&.size).zero?
%w[Alice Bob].minmax_of?(&.size)
CRYSTAL
end
it "does not report if source is a spec" do
expect_no_issues subject, path: "source_spec.cr", code: <<-CRYSTAL
%w[Alice Bob].map(&.size).min
CRYSTAL
end
context "macro" do
it "doesn't report in macro scope" do
expect_no_issues subject, <<-CRYSTAL
{{ %w[Alice Bob].map(&.size).min }}
CRYSTAL
end
end
end
end

View File

@ -25,6 +25,7 @@ module Ameba::Rule::Style
(1..3).map { |l| l.to_i64 * l.to_i64 } (1..3).map { |l| l.to_i64 * l.to_i64 }
(1..3).map { |m| m.to_s[start: m.to_i64, count: 3]? } (1..3).map { |m| m.to_s[start: m.to_i64, count: 3]? }
(1..3).map { |n| n.to_s.split.map { |z| n.to_i * z.to_i }.join } (1..3).map { |n| n.to_s.split.map { |z| n.to_i * z.to_i }.join }
(1..3).map { |o| o.foo = foos[o.abs]? || 0 }
CRYSTAL CRYSTAL
end end

4
spec/fixtures/config.yml vendored Normal file
View File

@ -0,0 +1,4 @@
Ameba/PerfRule:
Enabled: false
Ameba/ErrorRule:
Enabled: false

0
spec/fixtures/source.cr vendored Normal file
View File

View File

@ -282,6 +282,13 @@ module Ameba
end end
end end
def with_presenter(klass, &)
io = IO::Memory.new
presenter = klass.new(io)
yield presenter, io
end
def as_node(source) def as_node(source)
Crystal::Parser.new(source).parse Crystal::Parser.new(source).parse
end end

View File

@ -3,6 +3,7 @@ require "./ameba/ast/**"
require "./ameba/ext/**" require "./ameba/ext/**"
require "./ameba/rule/**" require "./ameba/rule/**"
require "./ameba/formatter/*" require "./ameba/formatter/*"
require "./ameba/presenter/*"
require "./ameba/source/**" require "./ameba/source/**"
# Ameba's entry module. # Ameba's entry module.

View File

@ -76,6 +76,9 @@ module Ameba::AST
end end
end end
# TODO: Remove in a next release. BC for crystal <= 1.9.
# refs https://github.com/crystal-ameba/ameba/pull/407
#
# Indicates whether the node is a transformed assignment by the compiler. # Indicates whether the node is a transformed assignment by the compiler.
# i.e. # i.e.
# #

View File

@ -21,7 +21,6 @@ module Ameba::AST
} }
SPECIAL_NODE_NAMES = %w[super previous_def] SPECIAL_NODE_NAMES = %w[super previous_def]
RECORD_NODE_NAME = "record"
@scope_queue = [] of Scope @scope_queue = [] of Scope
@current_scope : Scope @current_scope : Scope
@ -190,7 +189,7 @@ module Ameba::AST
end end
private def record_macro?(node) private def record_macro?(node)
node.name == RECORD_NODE_NAME && node.args.first?.is_a?(Crystal::Path) node.name == "record" && node.args.first?.is_a?(Crystal::Path)
end end
private def skip?(node) private def skip?(node)

View File

@ -28,7 +28,14 @@ module Ameba::Cli
configure_rules(config, opts) configure_rules(config, opts)
if opts.rules? if opts.rules?
print_rules(config) print_rules(config.rules)
end
if describe_rule_name = opts.describe_rule
unless rule = config.rules.find(&.name.== describe_rule_name)
raise "Unknown rule"
end
describe_rule(rule)
end end
runner = Ameba.run(config) runner = Ameba.run(config)
@ -49,6 +56,7 @@ module Ameba::Cli
property globs : Array(String)? property globs : Array(String)?
property only : Array(String)? property only : Array(String)?
property except : Array(String)? property except : Array(String)?
property describe_rule : String?
property location_to_explain : NamedTuple(file: String, line: Int32, column: Int32)? property location_to_explain : NamedTuple(file: String, line: Int32, column: Int32)?
property fail_level : Severity? property fail_level : Severity?
property? skip_reading_config = false property? skip_reading_config = false
@ -119,6 +127,11 @@ module Ameba::Cli
configure_explain_opts(loc, opts) configure_explain_opts(loc, opts)
end end
parser.on("-d", "--describe Category/Rule",
"Describe a rule with specified name") do |rule_name|
configure_describe_opts(rule_name, opts)
end
parser.on("--without-affected-code", parser.on("--without-affected-code",
"Stop showing affected code while using a default formatter") do "Stop showing affected code while using a default formatter") do
opts.without_affected_code = true opts.without_affected_code = true
@ -152,6 +165,11 @@ module Ameba::Cli
opts.without_affected_code? opts.without_affected_code?
end end
private def configure_describe_opts(rule_name, opts)
opts.describe_rule = rule_name.presence
opts.formatter = :silent
end
private def configure_explain_opts(loc, opts) private def configure_explain_opts(loc, opts)
location_to_explain = parse_explain_location(loc) location_to_explain = parse_explain_location(loc)
opts.location_to_explain = location_to_explain opts.location_to_explain = location_to_explain
@ -183,14 +201,13 @@ module Ameba::Cli
exit 0 exit 0
end end
private def print_rules(config) private def describe_rule(rule)
config.rules.each do |rule| Presenter::RulePresenter.new.run(rule)
puts "%s [%s] - %s" % { exit 0
rule.name.colorize(:white), end
rule.severity.symbol.to_s.colorize(:green),
rule.description.colorize(:dark_gray), private def print_rules(rules)
} Presenter::RuleCollectionPresenter.new.run(rules)
end
exit 0 exit 0
end end
end end

View File

@ -55,10 +55,7 @@ class Ameba::Config
Path[XDG_CONFIG_HOME] / "ameba/config.yml", Path[XDG_CONFIG_HOME] / "ameba/config.yml",
} }
DEFAULT_GLOBS = %w( SOURCES_GLOB = "**/*.cr"
**/*.cr
!lib
)
getter rules : Array(Rule::Base) getter rules : Array(Rule::Base)
property severity = Severity::Convention property severity = Severity::Convention
@ -95,7 +92,7 @@ class Ameba::Config
@rules = Rule.rules.map &.new(config).as(Rule::Base) @rules = Rule.rules.map &.new(config).as(Rule::Base)
@rule_groups = @rules.group_by &.group @rule_groups = @rules.group_by &.group
@excluded = load_array_section(config, "Excluded") @excluded = load_array_section(config, "Excluded")
@globs = load_array_section(config, "Globs", DEFAULT_GLOBS) @globs = load_array_section(config, "Globs", default_globs)
return unless formatter_name = load_formatter_name(config) return unless formatter_name = load_formatter_name(config)
self.formatter = formatter_name self.formatter = formatter_name
@ -115,12 +112,13 @@ class Ameba::Config
end end
Config.new YAML.parse(content) Config.new YAML.parse(content)
rescue e rescue e
raise "Config file is invalid: #{e.message}" raise "Unable to load config file: #{e.message}"
end end
protected def self.read_config(path = nil) protected def self.read_config(path = nil)
if path if path
return File.exists?(path) ? File.read(path) : nil raise ArgumentError.new("Config file does not exist #{path}") unless File.exists?(path)
return File.read(path)
end end
each_config_path do |config_path| each_config_path do |config_path|
return File.read(config_path) if File.exists?(config_path) return File.read(config_path) if File.exists?(config_path)
@ -238,6 +236,12 @@ class Ameba::Config
end end
end end
private def default_globs
[SOURCES_GLOB].tap do |globs|
globs.push("!lib") unless Dir["lib/**/*.cr"].empty?
end
end
# :nodoc: # :nodoc:
module RuleConfig module RuleConfig
# Define rule properties # Define rule properties

View File

@ -4,8 +4,6 @@ module Ameba::Formatter
# A formatter that shows the detailed explanation of the issue at # A formatter that shows the detailed explanation of the issue at
# a specific location. # a specific location.
class ExplainFormatter class ExplainFormatter
HEADING_MARKER = "## "
include Util include Util
getter output : IO::FileDescriptor | IO::Memory getter output : IO::FileDescriptor | IO::Memory
@ -64,9 +62,8 @@ module Ameba::Formatter
rule.name.colorize(:magenta), rule.name.colorize(:magenta),
rule.severity.to_s.colorize(rule.severity.color), rule.severity.to_s.colorize(rule.severity.color),
} }
if rule_description = colorize_code_fences(rule.description)
if rule.responds_to?(:description) output_paragraph rule_description
output_paragraph rule.description
end end
rule_doc = colorize_code_fences(rule.class.parsed_doc) rule_doc = colorize_code_fences(rule.class.parsed_doc)
@ -84,7 +81,7 @@ module Ameba::Formatter
end end
private def output_title(title) private def output_title(title)
output << HEADING_MARKER.colorize(:yellow) output << "### ".colorize(:yellow)
output << title.upcase.colorize(:yellow) output << title.upcase.colorize(:yellow)
output << "\n\n" output << "\n\n"
end end
@ -95,7 +92,7 @@ module Ameba::Formatter
private def output_paragraph(paragraph : Array) private def output_paragraph(paragraph : Array)
paragraph.each do |line| paragraph.each do |line|
output << ' ' << line << '\n' output << " " << line << '\n'
end end
output << '\n' output << '\n'
end end

View File

@ -3,7 +3,7 @@ module Ameba::Formatter
# Basically, it takes all issues reported and disables corresponding rules # Basically, it takes all issues reported and disables corresponding rules
# or excludes failed sources from these rules. # or excludes failed sources from these rules.
class TODOFormatter < DotFormatter class TODOFormatter < DotFormatter
def initialize(@output = STDOUT) def initialize(@output = STDOUT, @config_path : Path = Config::DEFAULT_PATH)
end end
def finished(sources) def finished(sources)
@ -26,7 +26,7 @@ module Ameba::Formatter
end end
private def generate_todo_config(issues) private def generate_todo_config(issues)
file = File.new(Config::DEFAULT_PATH, mode: "w") file = File.new(@config_path, mode: "w")
file << header file << header
rule_issues_map(issues).each do |rule, rule_issues| rule_issues_map(issues).each do |rule, rule_issues|
file << "\n# Problems found: #{rule_issues.size}" file << "\n# Problems found: #{rule_issues.size}"

View File

@ -1,5 +1,7 @@
module Ameba::Formatter module Ameba::Formatter
module Util module Util
extend self
def deansify(message : String?) : String? def deansify(message : String?) : String?
message.try &.gsub(/\x1b[^m]*m/, "").presence message.try &.gsub(/\x1b[^m]*m/, "").presence
end end

View File

@ -22,6 +22,7 @@ module Ameba
def expand(globs) def expand(globs)
globs.flat_map do |glob| globs.flat_map do |glob|
glob += "/**/*.cr" if File.directory?(glob) glob += "/**/*.cr" if File.directory?(glob)
raise ArgumentError.new("No files found matching #{glob}") if Dir[glob].empty?
Dir[glob] Dir[glob]
end.uniq! end.uniq!
end end

View File

@ -0,0 +1,12 @@
module Ameba::Presenter
private ENABLED_MARK = "".colorize(:green)
private DISABLED_MARK = "x".colorize(:red)
class BasePresenter
# TODO: allow other IOs
getter output : IO::FileDescriptor | IO::Memory
def initialize(@output = STDOUT)
end
end
end

View File

@ -0,0 +1,34 @@
module Ameba::Presenter
class RuleCollectionPresenter < BasePresenter
def run(rules)
rules = rules.to_h do |rule|
name = rule.name.split('/')
name = "%s/%s" % {
name[0...-1].join('/').colorize(:light_gray),
name.last.colorize(:white),
}
{name, rule}
end
longest_name = rules.max_of(&.first.size)
rules.group_by(&.last.group).each do |group, group_rules|
output.puts "— %s" % group.colorize(:light_blue).underline
output.puts
group_rules.each do |name, rule|
output.puts " %s [%s] %s %s" % {
rule.enabled? ? ENABLED_MARK : DISABLED_MARK,
rule.severity.symbol.to_s.colorize(:green),
name.ljust(longest_name),
rule.description.colorize(:dark_gray),
}
end
output.puts
end
output.puts "Total rules: %s / %s enabled" % {
rules.size.to_s.colorize(:light_blue),
rules.count(&.last.enabled?).to_s.colorize(:light_blue),
}
end
end
end

View File

@ -0,0 +1,43 @@
module Ameba::Presenter
class RulePresenter < BasePresenter
def run(rule)
output.puts
output_title "Rule info"
output_paragraph "%s of a %s severity [enabled: %s]" % {
rule.name.colorize(:magenta),
rule.severity.to_s.colorize(rule.severity.color),
rule.enabled? ? ENABLED_MARK : DISABLED_MARK,
}
if rule_description = colorize_code_fences(rule.description)
output_paragraph rule_description
end
if rule_doc = colorize_code_fences(rule.class.parsed_doc)
output_title "Detailed description"
output_paragraph rule_doc
end
end
private def output_title(title)
output.print "### %s\n\n" % title.upcase.colorize(:yellow)
end
private def output_paragraph(paragraph : String)
output_paragraph(paragraph.lines)
end
private def output_paragraph(paragraph : Array)
paragraph.each do |line|
output.puts " #{line}"
end
output.puts
end
private def colorize_code_fences(string)
return unless string
string
.gsub(/```(.+?)```/m, &.colorize(:dark_gray))
.gsub(/`(?!`)(.+?)`/, &.colorize(:dark_gray))
end
end
end

View File

@ -0,0 +1,96 @@
module Ameba::Rule::Lint
# A rule that reports documentation admonitions.
#
# Optionally, these can fail at an appropriate time.
#
# ```
# def get_user(id)
# # TODO(2024-04-24) Fix this hack when the database migration is complete
# if id < 1_000_000
# v1_api_call(id)
# else
# v2_api_call(id)
# end
# end
# ```
#
# `TODO` comments are used to remind yourself of source code related things.
#
# The premise here is that `TODO` should be dealt with in the near future
# and are therefore reported by Ameba.
#
# `FIXME` comments are used to indicate places where source code needs fixing.
#
# The premise here is that `FIXME` should indeed be fixed as soon as possible
# and are therefore reported by Ameba.
#
# YAML configuration example:
#
# ```
# Lint/DocumentationAdmonition:
# Enabled: true
# Admonitions: [TODO, FIXME, BUG]
# Timezone: UTC
# ```
class DocumentationAdmonition < Base
properties do
description "Reports documentation admonitions"
admonitions %w[TODO FIXME BUG]
timezone "UTC"
end
MSG = "Found a %s admonition in a comment"
MSG_LATE = "Found a %s admonition in a comment (%s)"
MSG_ERR = "%s admonition error: %s"
@[YAML::Field(ignore: true)]
private getter location : Time::Location {
Time::Location.load(self.timezone)
}
def test(source)
Tokenizer.new(source).run do |token|
next unless token.type.comment?
next unless doc = token.value.to_s
pattern =
/^#\s*(?<admonition>#{Regex.union(admonitions)})(?:\((?<context>.+?)\))?(?:\W+|$)/m
matches = doc.scan(pattern)
matches.each do |match|
admonition = match["admonition"]
begin
case expr = match["context"]?.presence
when /\A\d{4}-\d{2}-\d{2}\Z/ # date
# ameba:disable Lint/NotNil
date = Time.parse(expr.not_nil!, "%F", location)
issue_for_date source, token, admonition, date
when /\A\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?\Z/ # date + time (no tz)
# ameba:disable Lint/NotNil
date = Time.parse(expr.not_nil!, "%F #{$1?.presence ? "%T" : "%R"}", location)
issue_for_date source, token, admonition, date
else
issue_for token, MSG % admonition
end
rescue ex
issue_for token, MSG_ERR % {admonition, "#{ex}: #{expr.inspect}"}
end
end
end
end
private def issue_for_date(source, node, admonition, date)
diff = Time.utc - date.to_utc
return if diff.negative?
past = case diff
when 0.seconds..1.day then "today is the day!"
when 1.day..2.days then "1 day past"
else "#{diff.total_days.to_i} days past"
end
issue_for node, MSG_LATE % {admonition, past}
end
end
end

View File

@ -32,15 +32,14 @@ module Ameba::Rule::Lint
description "Identifies usage of `not_nil!` calls" description "Identifies usage of `not_nil!` calls"
end end
NOT_NIL_NAME = "not_nil!" MSG = "Avoid using `not_nil!`"
MSG = "Avoid using `not_nil!`"
def test(source) def test(source)
AST::NodeVisitor.new self, source, skip: :macro AST::NodeVisitor.new self, source, skip: :macro
end end
def test(source, node : Crystal::Call) def test(source, node : Crystal::Call)
return unless node.name == NOT_NIL_NAME return unless node.name == "not_nil!"
return unless node.obj && node.args.empty? return unless node.obj && node.args.empty?
return unless name_location = node.name_location return unless name_location = node.name_location

View File

@ -30,15 +30,14 @@ module Ameba::Rule::Lint
BLOCK_CALL_NAMES = %w(index rindex find) BLOCK_CALL_NAMES = %w(index rindex find)
CALL_NAMES = %w(index rindex) CALL_NAMES = %w(index rindex)
NOT_NIL_NAME = "not_nil!" MSG = "Use `%s! {...}` instead of `%s {...}.not_nil!`"
MSG = "Use `%s! {...}` instead of `%s {...}.not_nil!`"
def test(source) def test(source)
AST::NodeVisitor.new self, source, skip: :macro AST::NodeVisitor.new self, source, skip: :macro
end end
def test(source, node : Crystal::Call) def test(source, node : Crystal::Call)
return unless node.name == NOT_NIL_NAME && node.args.empty? return unless node.name == "not_nil!" && node.args.empty?
return unless (obj = node.obj).is_a?(Crystal::Call) return unless (obj = node.obj).is_a?(Crystal::Call)
return unless obj.name.in?(obj.block ? BLOCK_CALL_NAMES : CALL_NAMES) return unless obj.name.in?(obj.block ? BLOCK_CALL_NAMES : CALL_NAMES)

View File

@ -0,0 +1,96 @@
module Ameba::Rule::Lint
# A rule that reports typos found in source files.
#
# NOTE: Needs [typos](https://github.com/crate-ci/typos) CLI tool.
# NOTE: See the chapter on [false positives](https://github.com/crate-ci/typos#false-positives).
#
# YAML configuration example:
#
# ```
# Lint/Typos:
# Enabled: true
# BinPath: ~
# FailOnError: false
# ```
class Typos < Base
properties do
description "Reports typos found in source files"
bin_path nil.as(String?)
fail_on_error false
end
MSG = "Typo found: %s -> %s"
BIN_PATH = Process.find_executable("typos")
def bin_path : String?
@bin_path || BIN_PATH
end
def test(source : Source)
typos = typos_from(source)
typos.try &.each do |typo|
corrections = typo.corrections
message = MSG % {
typo.typo, corrections.join(" | "),
}
if corrections.size == 1
issue_for typo.location, typo.end_location, message do |corrector|
corrector.replace(typo.location, typo.end_location, corrections.first)
end
else
issue_for typo.location, typo.end_location, message
end
end
rescue ex
raise ex if fail_on_error?
end
private record Typo,
path : String,
typo : String,
corrections : Array(String),
location : {Int32, Int32},
end_location : {Int32, Int32} do
def self.parse(str) : self?
issue = JSON.parse(str)
return unless issue["type"] == "typo"
typo = issue["typo"].as_s
corrections = issue["corrections"].as_a.map(&.as_s)
return if typo.empty? || corrections.empty?
path = issue["path"].as_s
line_no = issue["line_num"].as_i
col_no = issue["byte_offset"].as_i + 1
end_col_no = col_no + typo.size - 1
new(path, typo, corrections,
{line_no, col_no}, {line_no, end_col_no})
end
end
protected def typos_from(source : Source) : Array(Typo)?
unless bin_path = self.bin_path
if fail_on_error?
raise RuntimeError.new "Could not find `typos` executable"
end
return
end
status = Process.run(bin_path, args: %w[--format json -],
input: IO::Memory.new(source.code),
output: output = IO::Memory.new,
)
return if status.success?
([] of Typo).tap do |typos|
# NOTE: `--format json` is actually JSON Lines (`jsonl`)
output.to_s.each_line do |line|
Typo.parse(line).try { |typo| typos << typo }
end
end
end
end
end

View File

@ -32,11 +32,10 @@ module Ameba::Rule::Performance
filter_names %w(select reject) filter_names %w(select reject)
end end
ANY_NAME = "any?" MSG = "Use `any? {...}` instead of `%s {...}.any?`"
MSG = "Use `any? {...}` instead of `%s {...}.any?`"
def test(source, node : Crystal::Call) def test(source, node : Crystal::Call)
return unless node.name == ANY_NAME && (obj = node.obj) return unless node.name == "any?" && (obj = node.obj)
return unless obj.is_a?(Crystal::Call) && obj.block && node.block.nil? return unless obj.is_a?(Crystal::Call) && obj.block && node.block.nil?
return unless obj.name.in?(filter_names) return unless obj.name.in?(filter_names)

View File

@ -34,11 +34,10 @@ module Ameba::Rule::Performance
description "Identifies usage of arg-less `any?` calls" description "Identifies usage of arg-less `any?` calls"
end end
ANY_NAME = "any?" MSG = "Use `!{...}.empty?` instead of `{...}.any?`"
MSG = "Use `!{...}.empty?` instead of `{...}.any?`"
def test(source, node : Crystal::Call) def test(source, node : Crystal::Call)
return unless node.name == ANY_NAME return unless node.name == "any?"
return unless node.block.nil? && node.args.empty? return unless node.block.nil? && node.args.empty?
return unless node.obj return unless node.obj

View File

@ -26,18 +26,16 @@ module Ameba::Rule::Performance
description "Identifies usage of `compact` calls that follow `map`" description "Identifies usage of `compact` calls that follow `map`"
end end
COMPACT_NAME = "compact" MSG = "Use `compact_map {...}` instead of `map {...}.compact`"
MAP_NAME = "map"
MSG = "Use `compact_map {...}` instead of `map {...}.compact`"
def test(source) def test(source)
AST::NodeVisitor.new self, source, skip: :macro AST::NodeVisitor.new self, source, skip: :macro
end end
def test(source, node : Crystal::Call) def test(source, node : Crystal::Call)
return unless node.name == COMPACT_NAME && (obj = node.obj) return unless node.name == "compact" && (obj = node.obj)
return unless obj.is_a?(Crystal::Call) && obj.block return unless obj.is_a?(Crystal::Call) && obj.block
return unless obj.name == MAP_NAME return unless obj.name == "map"
issue_for obj.name_location, node.name_end_location, MSG issue_for obj.name_location, node.name_end_location, MSG
end end

View File

@ -0,0 +1,70 @@
require "./base"
module Ameba::Rule::Performance
# This rule is used to identify excessive collection allocations,
# that can be avoided by using `each_<member>` instead of `<collection>.each`.
#
# For example, this is considered inefficient:
#
# ```
# "Alice".chars.each { |c| puts c }
# "Alice\nBob".lines.each { |l| puts l }
# ```
#
# And can be written as this:
#
# ```
# "Alice".each_char { |c| puts c }
# "Alice\nBob".each_line { |l| puts l }
# ```
#
# YAML configuration example:
#
# ```
# Performance/ExcessiveAllocations:
# Enabled: true
# CallNames:
# codepoints: each_codepoint
# graphemes: each_grapheme
# chars: each_char
# lines: each_line
# ```
class ExcessiveAllocations < Base
include AST::Util
properties do
description "Identifies usage of excessive collection allocations"
call_names({
"codepoints" => "each_codepoint",
"graphemes" => "each_grapheme",
"chars" => "each_char",
"lines" => "each_line",
# "keys" => "each_key",
# "values" => "each_value",
# "children" => "each_child",
})
end
MSG = "Use `%s {...}` instead of `%s.each {...}` to avoid excessive allocation"
def test(source)
AST::NodeVisitor.new self, source, skip: :macro
end
def test(source, node : Crystal::Call)
return unless node.name == "each" && node.args.empty? && node.block
return unless (obj = node.obj).is_a?(Crystal::Call)
return unless obj.args.empty? && obj.block.nil?
return unless method = call_names[obj.name]?
return unless name_location = obj.name_location
return unless end_location = name_end_location(node)
msg = MSG % {method, obj.name}
issue_for name_location, end_location, msg do |corrector|
corrector.replace(name_location, end_location, method)
end
end
end
end

View File

@ -26,18 +26,16 @@ module Ameba::Rule::Performance
description "Identifies usage of `flatten` calls that follow `map`" description "Identifies usage of `flatten` calls that follow `map`"
end end
FLATTEN_NAME = "flatten" MSG = "Use `flat_map {...}` instead of `map {...}.flatten`"
MAP_NAME = "map"
MSG = "Use `flat_map {...}` instead of `map {...}.flatten`"
def test(source) def test(source)
AST::NodeVisitor.new self, source, skip: :macro AST::NodeVisitor.new self, source, skip: :macro
end end
def test(source, node : Crystal::Call) def test(source, node : Crystal::Call)
return unless node.name == FLATTEN_NAME && (obj = node.obj) return unless node.name == "flatten" && (obj = node.obj)
return unless obj.is_a?(Crystal::Call) && obj.block return unless obj.is_a?(Crystal::Call) && obj.block
return unless obj.name == MAP_NAME return unless obj.name == "map"
issue_for obj.name_location, node.name_end_location, MSG issue_for obj.name_location, node.name_end_location, MSG
end end

View File

@ -0,0 +1,67 @@
require "./base"
module Ameba::Rule::Performance
# This rule is used to identify usage of `min/max/minmax` calls that follow `map`.
#
# For example, this is considered invalid:
#
# ```
# %w[Alice Bob].map(&.size).min
# %w[Alice Bob].map(&.size).max
# %w[Alice Bob].map(&.size).minmax
# ```
#
# And it should be written as this:
#
# ```
# %w[Alice Bob].min_of(&.size)
# %w[Alice Bob].max_of(&.size)
# %w[Alice Bob].minmax_of(&.size)
# ```
#
# YAML configuration example:
#
# ```
# Performance/MinMaxAfterMap:
# Enabled: true
# ```
class MinMaxAfterMap < Base
properties do
description "Identifies usage of `min/max/minmax` calls that follow `map`"
end
MSG = "Use `%s {...}` instead of `map {...}.%s`."
CALL_NAMES = %w[min min? max max? minmax minmax?]
def test(source)
AST::NodeVisitor.new self, source, skip: :macro
end
def test(source, node : Crystal::Call)
return unless node.name.in?(CALL_NAMES) && node.block.nil? && node.args.empty?
return unless (obj = node.obj) && obj.is_a?(Crystal::Call)
return unless obj.name == "map" && obj.block && obj.args.empty?
return unless name_location = obj.name_location
return unless end_location = node.name_end_location
of_name = node.name.sub(/(.+?)(\?)?$/, "\\1_of\\2")
message = MSG % {of_name, node.name}
issue_for name_location, end_location, message do |corrector|
next unless node_name_location = node.name_location
# TODO: switching the order of the below calls breaks the corrector
corrector.replace(
name_location,
name_location.adjust(column_number: {{ "map".size - 1 }}),
of_name
)
corrector.remove(
node_name_location.adjust(column_number: -1),
end_location
)
end
end
end
end

View File

@ -38,15 +38,14 @@ module Ameba::Rule::Performance
filter_names %w(select reject) filter_names %w(select reject)
end end
SIZE_NAME = "size" MSG = "Use `count {...}` instead of `%s {...}.size`."
MSG = "Use `count {...}` instead of `%s {...}.size`."
def test(source) def test(source)
AST::NodeVisitor.new self, source, skip: :macro AST::NodeVisitor.new self, source, skip: :macro
end end
def test(source, node : Crystal::Call) def test(source, node : Crystal::Call)
return unless node.name == SIZE_NAME && (obj = node.obj) return unless node.name == "size" && (obj = node.obj)
return unless obj.is_a?(Crystal::Call) && obj.block return unless obj.is_a?(Crystal::Call) && obj.block
return unless obj.name.in?(filter_names) return unless obj.name.in?(filter_names)

View File

@ -99,6 +99,9 @@ module Ameba::Rule::Style
node.named_args.try &.each do |arg| node.named_args.try &.each do |arg|
i += reference_count(arg.value, obj) i += reference_count(arg.value, obj)
end end
when Crystal::BinaryOp
i += reference_count(node.left, obj)
i += reference_count(node.right, obj)
when Crystal::Block when Crystal::Block
i += reference_count(node.body, obj) i += reference_count(node.body, obj)
when Crystal::Var when Crystal::Var
@ -230,7 +233,7 @@ module Ameba::Rule::Style
# we filter out the blocks that are of call type - `i.to_i64.odd?` # we filter out the blocks that are of call type - `i.to_i64.odd?`
return unless (body = block.body).is_a?(Crystal::Call) return unless (body = block.body).is_a?(Crystal::Call)
# we need to "unwind" the chain challs, so the final receiver object # we need to "unwind" the chain calls, so the final receiver object
# ends up being a variable - `i` # ends up being a variable - `i`
obj = body.obj obj = body.obj
while obj.is_a?(Crystal::Call) while obj.is_a?(Crystal::Call)

View File

@ -109,7 +109,7 @@ module Ameba::Spec::ExpectIssue
code = lines.join('\n') code = lines.join('\n')
if code == annotated_code if code == annotated_code
raise "Use `report_no_issues` to assert that no issues are found" raise "Use `expect_no_issues` to assert that no issues are found"
end end
source, actual_annotations = actual_annotations(rules, code, path, lines) source, actual_annotations = actual_annotations(rules, code, path, lines)