diff --git a/.ameba.yml b/.ameba.yml index d8720e76..411ae591 100644 --- a/.ameba.yml +++ b/.ameba.yml @@ -22,7 +22,9 @@ Style/NegatedConditionsInUnless: Naming/BlockParameterName: Enabled: false +# exclude all files in vendor/cache Globs: - "**/*.cr" - - "!lib" - "!spec/acceptance/acceptance.cr" + - "!vendor" + - "!tmp" diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index f9e93ebc..0a1e25cf 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -28,7 +28,7 @@ jobs: crystal: ${{ steps.crystal-version.outputs.crystal }} - name: bootstrap - run: script/bootstrap + run: script/bootstrap --ci - name: acceptance run: script/acceptance diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3c5cf1bc..dc3d378c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -28,7 +28,7 @@ jobs: crystal: ${{ steps.crystal-version.outputs.crystal }} - name: bootstrap - run: script/bootstrap + run: script/bootstrap --ci - name: lint run: script/lint diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18c7bcd1..c7ba3472 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,5 +27,8 @@ jobs: with: crystal: ${{ steps.crystal-version.outputs.crystal }} + - name: bootstrap + run: script/bootstrap --ci + - name: test run: script/test diff --git a/.gitignore b/.gitignore index 38efd238..29f3f0c7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,16 @@ .cache/shards/* tmp/ bin/ +!vendor/bin/ +!vendor/darwin_arm64/bin/ +!vendor/darwin_x86_64/bin/ +!vendor/linux_x86_64/bin/ .shards/ *.dwarf dist/ +vendor/shards/install +vendor/.cache + # non-critical vendored crystal files lib/**/.github/ diff --git a/lib/.shards.info b/lib/.shards.info deleted file mode 100644 index d014c948..00000000 --- a/lib/.shards.info +++ /dev/null @@ -1,15 +0,0 @@ ---- -version: 1.0 -shards: - json_mapping: - git: https://github.com/crystal-lang/json_mapping.cr.git - version: 0.1.1 - halite: - git: https://github.com/icyleaf/halite.git - version: 0.12.0 - webmock: - git: https://github.com/manastech/webmock.cr.git - version: 0.14.0 - ameba: - git: https://github.com/crystal-ameba/ameba.git - version: 1.6.1 diff --git a/lib/ameba/.ameba.yml b/lib/ameba/.ameba.yml deleted file mode 100644 index f679ef08..00000000 --- a/lib/ameba/.ameba.yml +++ /dev/null @@ -1,7 +0,0 @@ -Documentation/DocumentationAdmonition: - Timezone: UTC - Admonitions: [FIXME, BUG] - -Lint/Typos: - Excluded: - - spec/ameba/rule/lint/typos_spec.cr diff --git a/lib/ameba/.dockerignore b/lib/ameba/.dockerignore deleted file mode 100644 index 69e9d6b5..00000000 --- a/lib/ameba/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -.* -!Makefile -!shard.yml -!src \ No newline at end of file diff --git a/lib/ameba/.editorconfig b/lib/ameba/.editorconfig deleted file mode 100644 index 8f0c87a1..00000000 --- a/lib/ameba/.editorconfig +++ /dev/null @@ -1,7 +0,0 @@ -[*.cr] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -indent_style = space -indent_size = 2 -trim_trailing_whitespace = true diff --git a/lib/ameba/.gitignore b/lib/ameba/.gitignore deleted file mode 100644 index 2e28b915..00000000 --- a/lib/ameba/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -/doc/ -/lib/ -/bin/ameba -/bin/ameba.dwarf -/.shards/ - -# Libraries don't need dependency lock -# Dependencies will be locked in application that uses them -/shard.lock diff --git a/lib/ameba/Dockerfile b/lib/ameba/Dockerfile deleted file mode 100644 index 9d836036..00000000 --- a/lib/ameba/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM alpine:edge as builder -RUN apk add --update crystal shards yaml-dev musl-dev make -RUN mkdir /ameba -WORKDIR /ameba -COPY . /ameba/ -RUN make clean && make - -FROM alpine:latest -RUN apk add --update yaml pcre2 gc libevent libgcc -RUN mkdir /src -WORKDIR /src -COPY --from=builder /ameba/bin/ameba /usr/bin/ -RUN ameba -v -ENTRYPOINT [ "/usr/bin/ameba" ] diff --git a/lib/ameba/LICENSE b/lib/ameba/LICENSE deleted file mode 100644 index 3970ca6f..00000000 --- a/lib/ameba/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2018-2020 Vitalii Elenhaupt - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/lib/ameba/Makefile b/lib/ameba/Makefile deleted file mode 100644 index a34f0be6..00000000 --- a/lib/ameba/Makefile +++ /dev/null @@ -1,34 +0,0 @@ -CRYSTAL_BIN ?= crystal -SHARDS_BIN ?= shards -PREFIX ?= /usr/local -SHARD_BIN ?= ../../bin -CRFLAGS ?= -Dpreview_mt - -.PHONY: build -build: - $(SHARDS_BIN) build $(CRFLAGS) - -.PHONY: lint -lint: build - ./bin/ameba - -.PHONY: spec -spec: - $(CRYSTAL_BIN) spec - -.PHONY: clean -clean: - rm -f ./bin/ameba ./bin/ameba.dwarf - -.PHONY: install -install: build - mkdir -p $(PREFIX)/bin - cp ./bin/ameba $(PREFIX)/bin - -.PHONY: bin -bin: build - mkdir -p $(SHARD_BIN) - cp ./bin/ameba $(SHARD_BIN) - -.PHONY: test -test: spec lint diff --git a/lib/ameba/README.md b/lib/ameba/README.md deleted file mode 100644 index ecddc395..00000000 --- a/lib/ameba/README.md +++ /dev/null @@ -1,242 +0,0 @@ -

- -

Ameba

-

Code style linter for Crystal

-

- - (a single-celled animal that catches food and moves about by extending fingerlike projections of protoplasm) - -

-

- - - -

-

- -- [About](#about) -- [Usage](#usage) - - [Watch a tutorial](#watch-a-tutorial) - - [Autocorrection](#autocorrection) - - [Explain issues](#explain-issues) - - [Run in parallel](#run-in-parallel) -- [Installation](#installation) - - [As a project dependency:](#as-a-project-dependency) - - [OS X](#os-x) - - [Docker](#docker) - - [From sources](#from-sources) -- [Configuration](#configuration) - - [Sources](#sources) - - [Rules](#rules) - - [Inline disabling](#inline-disabling) -- [Editors \& integrations](#editors--integrations) -- [Credits \& inspirations](#credits--inspirations) -- [Contributors](#contributors) - -## About - -Ameba is a static code analysis tool for the Crystal language. -It enforces a consistent [Crystal code style](https://crystal-lang.org/reference/conventions/coding_style.html), -also catches code smells and wrong code constructions. - -See also [Roadmap](https://github.com/crystal-ameba/ameba/wiki). - -## Usage - -Run `ameba` binary within your project directory to catch code issues: - -```sh -$ ameba -Inspecting 107 files - -...............F.....................FF.................................................................... - -src/ameba/formatter/flycheck_formatter.cr:6:37 -[W] Lint/UnusedArgument: Unused argument `location`. If it's necessary, use `_` as an argument name to indicate that it won't be used. -> source.issues.each do |issue, location| - ^ - -src/ameba/formatter/base_formatter.cr:16:14 -[W] Lint/UselessAssign: Useless assignment to variable `s` -> return s += issues.size - ^ - -src/ameba/formatter/base_formatter.cr:16:7 [Correctable] -[C] Style/RedundantReturn: Redundant `return` detected -> return s += issues.size - ^---------------------^ - -Finished in 389.45 milliseconds -107 inspected, 3 failures -``` - -### Watch a tutorial - - - -[๐ŸŽฌ Watch the LuckyCast showing how to use Ameba](https://luckycasts.com/videos/ameba) - -### Autocorrection - -Rules that are marked as `[Correctable]` in the output can be automatically corrected using `--fix` flag: - -```sh -$ ameba --fix -``` - -### Explain issues - -Ameba allows you to dig deeper into an issue, by showing you details about the issue -and the reasoning by it being reported. - -To be convenient, you can just copy-paste the `PATH:line:column` string from the -report and paste behind the `ameba` command to check it out. - -```sh -$ ameba crystal/command/format.cr:26:83 # show explanation for the issue -$ ameba --explain crystal/command/format.cr:26:83 # same thing -``` - -### Run in parallel - -Some quick benchmark results measured while running Ameba on Crystal repo: - -```sh -$ CRYSTAL_WORKERS=1 ameba #=> 29.11 seconds -$ CRYSTAL_WORKERS=2 ameba #=> 19.49 seconds -$ CRYSTAL_WORKERS=4 ameba #=> 13.48 seconds -$ CRYSTAL_WORKERS=8 ameba #=> 10.14 seconds -``` - -## Installation - -### As a project dependency: - -Add this to your application's `shard.yml`: - -```yaml -development_dependencies: - ameba: - github: crystal-ameba/ameba -``` - -Build `bin/ameba` binary within your project directory while running `shards install`. - -### OS X - -```sh -$ brew tap crystal-ameba/ameba -$ brew install ameba -``` - -### Docker - -Build the image: - -```sh -$ docker build -t ghcr.io/crystal-ameba/ameba . -``` - -To use the resulting image on a local source folder, mount the current (or target) directory into `/src`: - -```sh -$ docker run -v $(pwd):/src ghcr.io/crystal-ameba/ameba -``` - -Also available on GitHub: https://github.com/crystal-ameba/ameba/pkgs/container/ameba - -### From sources - -```sh -$ git clone https://github.com/crystal-ameba/ameba && cd ameba -$ make install -``` - -## Configuration - -Default configuration file is `.ameba.yml`. -It allows to configure rule properties, disable specific rules and exclude sources from the rules. - -Generate new file by running `ameba --gen-config`. - -### Sources - -**List of sources to run Ameba on can be configured globally via:** - -- `Globs` section - an array of wildcards (or paths) to include to the - inspection. Defaults to `%w[**/*.cr !lib]`, meaning it includes all project - files with `*.cr` extension except those which exist in `lib` folder. -- `Excluded` section - an array of wildcards (or paths) to exclude from the - source list defined by `Globs`. Defaults to an empty array. - -In this example we define default globs and exclude `src/compiler` folder: - -``` yaml -Globs: - - "**/*.cr" - - "!lib" - -Excluded: - - src/compiler -``` - -**Specific sources can be excluded at rule level**: - -``` yaml -Style/RedundantBegin: - Excluded: - - src/server/processor.cr - - src/server/api.cr -``` - -### Rules - -One or more rules, or a one or more group of rules can be included or excluded -via command line arguments: - -```sh -$ ameba --only Lint/Syntax # runs only Lint/Syntax rule -$ ameba --only Style,Lint # runs only rules from Style and Lint groups -$ ameba --except Lint/Syntax # runs all rules except Lint/Syntax -$ ameba --except Style,Lint # runs all rules except rules in Style and Lint groups -``` - -Or through the configuration file: - -``` yaml -Style/RedundantBegin: - Enabled: false -``` - -### Inline disabling - -One or more rules or one or more group of rules can be disabled using inline directives: - -```crystal -# ameba:disable Style/LargeNumbers -time = Time.epoch(1483859302) - -time = Time.epoch(1483859302) # ameba:disable Style/LargeNumbers, Lint/UselessAssign -time = Time.epoch(1483859302) # ameba:disable Style, Lint -``` - -## Editors & integrations - -- Vim: [vim-crystal](https://github.com/rhysd/vim-crystal), [Ale](https://github.com/w0rp/ale) -- Emacs: [ameba.el](https://github.com/crystal-ameba/ameba.el) -- Sublime Text: [Sublime Linter Ameba](https://github.com/epergo/SublimeLinter-contrib-ameba) -- VSCode: [vscode-crystal-ameba](https://github.com/crystal-ameba/vscode-crystal-ameba) -- Codacy: [codacy-ameba](https://github.com/codacy/codacy-ameba) -- GitHub Actions: [github-action](https://github.com/crystal-ameba/github-action) - -## Credits & inspirations - -- [Crystal Language](https://crystal-lang.org) -- [Rubocop](https://rubocop.readthedocs.io/en/latest/) -- [Credo](http://credo-ci.org/) -- [Dogma](https://github.com/lpil/dogma) - -## Contributors - -- [veelenga](https://github.com/veelenga) Vitalii Elenhaupt - creator, maintainer -- [Sija](https://github.com/Sija) Sijawusz Pur Rahnama - contributor, maintainer diff --git a/lib/ameba/bench/check_sources.cr b/lib/ameba/bench/check_sources.cr deleted file mode 100644 index f5fbc2e3..00000000 --- a/lib/ameba/bench/check_sources.cr +++ /dev/null @@ -1,30 +0,0 @@ -require "../src/ameba" -require "benchmark" - -private def get_files(n) - Dir["src/**/*.cr"].first(n) -end - -puts "== Compare:" -Benchmark.ips do |x| - [ - 1, - 3, - 5, - 10, - 20, - 30, - 40, - ].each do |n| # ameba:disable Naming/BlockParameterName - config = Ameba::Config.load - config.formatter = Ameba::Formatter::BaseFormatter.new - config.globs = get_files(n) - s = n == 1 ? "" : "s" - x.report("#{n} source#{s}") { Ameba.run config } - end -end - -puts "== Measure:" -config = Ameba::Config.load -config.formatter = Ameba::Formatter::BaseFormatter.new -puts Benchmark.measure { Ameba.run config } diff --git a/lib/ameba/lib b/lib/ameba/lib deleted file mode 120000 index a96aa0ea..00000000 --- a/lib/ameba/lib +++ /dev/null @@ -1 +0,0 @@ -.. \ No newline at end of file diff --git a/lib/ameba/shard.yml b/lib/ameba/shard.yml deleted file mode 100644 index 74ce7c67..00000000 --- a/lib/ameba/shard.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: ameba -version: 1.6.1 - -authors: - - Vitalii Elenhaupt - - Sijawusz Pur Rahnama - -targets: - ameba: - main: src/cli.cr - -scripts: - postinstall: shards build -Dpreview_mt - -# TODO: remove pre-compiled executable in future releases -executables: - - ameba - - ameba.cr - -crystal: ~> 1.10 - -license: MIT diff --git a/lib/ameba/src/ameba.cr b/lib/ameba/src/ameba.cr deleted file mode 100644 index e675939a..00000000 --- a/lib/ameba/src/ameba.cr +++ /dev/null @@ -1,43 +0,0 @@ -require "./ameba/*" -require "./ameba/ast/**" -require "./ameba/ext/**" -require "./ameba/rule/**" -require "./ameba/formatter/*" -require "./ameba/presenter/*" -require "./ameba/source/**" - -# Ameba's entry module. -# -# To run the linter with default parameters: -# -# ``` -# Ameba.run -# ``` -# -# To configure and run it: -# -# ``` -# config = Ameba::Config.load -# config.formatter = formatter -# config.files = file_paths -# -# Ameba.run config -# ``` -module Ameba - extend self - - VERSION = {{ `shards version "#{__DIR__}"`.chomp.stringify }} - - # Initializes `Ameba::Runner` and runs it. - # Can be configured via `config` parameter. - # - # Examples: - # - # ``` - # Ameba.run - # Ameba.run config - # ``` - def run(config = Config.load) - Runner.new(config).run - end -end diff --git a/lib/ameba/src/ameba/ast/branch.cr b/lib/ameba/src/ameba/ast/branch.cr deleted file mode 100644 index 30929880..00000000 --- a/lib/ameba/src/ameba/ast/branch.cr +++ /dev/null @@ -1,193 +0,0 @@ -require "./util" - -module Ameba::AST - # Represents the branch in Crystal code. - # Branch is a part of a branchable statement. - # For example, the branchable if statement contains 3 branches: - # - # ``` - # if a = something # --> Branch A - # a = 1 # --> Branch B - # put a if out # --> Branch C - # else - # do_something a # --> Branch D - # end - # ``` - class Branch - # The actual branch node. - getter node : Crystal::ASTNode - - # The parent branchable. - getter parent : Branchable - - delegate to_s, to: @node - delegate location, to: @node - delegate end_location, to: @node - - def_equals_and_hash node, location - - # Creates a new branch. - # - # ``` - # Branch.new(if_node) - # ``` - def initialize(@node, @parent) - end - - # Returns `true` if current branch is in a loop, `false` - otherwise. - # For example, this branch is in a loop: - # - # ``` - # while true - # handle_input # this branch is in a loop - # if wrong_input - # show_message # this branch is also in a loop. - # end - # end - # ``` - def in_loop? - @parent.loop? - end - - # Constructs a new branch based on the node in scope. - # - # ``` - # Branch.of(assign_node, scope) - # ``` - def self.of(node : Crystal::ASTNode, scope : Scope) - of(node, scope.node) - end - - # Constructs a new branch based on the node some parent scope. - # - # ``` - # Branch.of(assign_node, def_node) - # ``` - def self.of(node : Crystal::ASTNode, parent_node : Crystal::ASTNode) - BranchVisitor.new(node).tap(&.accept(parent_node)).branch - end - - # :nodoc: - private class BranchVisitor < Crystal::Visitor - include Util - - @current_branch : Crystal::ASTNode? - - property branchable : Branchable? - property branch : Branch? - - def initialize(@node : Crystal::ASTNode) - end - - private def on_branchable_start(node, *branches) - on_branchable_start(node, branches) - end - - private def on_branchable_start(node, branches : Enumerable) - @branchable = Branchable.new(node, @branchable) - - branches.each do |branch_node| - break if branch # branch found - - @current_branch = branch_node if branch_node && !branch_node.nop? - branch_node.try &.accept(self) - end - - false - end - - private def on_branchable_end(node) - @branchable = @branchable.try &.parent - end - - def visit(node : Crystal::ASTNode) - return false if branch - - if node.class == @node.class && - node.location == @node.location && - (branchable = @branchable) && - (branch = @current_branch) - @branch = Branch.new(branch, branchable) - end - - true - end - - def visit(node : Crystal::If | Crystal::Unless) - on_branchable_start node, node.cond, node.then, node.else - end - - def end_visit(node : Crystal::If | Crystal::Unless) - on_branchable_end node - end - - def visit(node : Crystal::BinaryOp) - on_branchable_start node, node.left, node.right - end - - def end_visit(node : Crystal::BinaryOp) - on_branchable_end node - end - - def visit(node : Crystal::Case) - on_branchable_start node, [node.cond, node.whens, node.else].flatten - end - - def end_visit(node : Crystal::Case) - on_branchable_end node - end - - def visit(node : Crystal::While | Crystal::Until) - on_branchable_start node, node.cond, node.body - end - - def end_visit(node : Crystal::While | Crystal::Until) - on_branchable_end node - end - - def visit(node : Crystal::ExceptionHandler) - on_branchable_start node, [node.body, node.rescues, node.else, node.ensure].flatten - end - - def end_visit(node : Crystal::ExceptionHandler) - on_branchable_end node - end - - def visit(node : Crystal::Rescue) - on_branchable_start node, node.body - end - - def end_visit(node : Crystal::Rescue) - on_branchable_end node - end - - def visit(node : Crystal::MacroIf) - on_branchable_start node, node.cond, node.then, node.else - end - - def end_visit(node : Crystal::MacroIf) - on_branchable_end node - end - - def visit(node : Crystal::MacroFor) - on_branchable_start node, node.exp, node.body - end - - def end_visit(node : Crystal::MacroFor) - on_branchable_end node - end - - def visit(node : Crystal::Call) - if loop?(node) && (block = node.block) - on_branchable_start node, block.body - end - end - - def end_visit(node : Crystal::Call) - if loop?(node) && node.block - on_branchable_end node - end - end - end - end -end diff --git a/lib/ameba/src/ameba/ast/branchable.cr b/lib/ameba/src/ameba/ast/branchable.cr deleted file mode 100644 index faeea67c..00000000 --- a/lib/ameba/src/ameba/ast/branchable.cr +++ /dev/null @@ -1,45 +0,0 @@ -require "./util" - -module Ameba::AST - # A generic entity to represent a branchable Crystal node. - # For example, `Crystal::If`, `Crystal::Unless`, `Crystal::While` - # are branchables. - # - # ``` - # while a > 100 # Branchable A - # if b > 2 # Branchable B - # a += 1 - # end - # end - # ``` - class Branchable - include Util - - # Parent branchable (if any) - getter parent : Branchable? - - # Array of branches - getter branches = [] of Crystal::ASTNode - - # The actual Crystal node - getter node : Crystal::ASTNode - - delegate to_s, to: @node - delegate location, to: @node - delegate end_location, to: @node - - # Creates a new branchable - # - # ``` - # Branchable.new(node, parent_branchable) - # ``` - def initialize(@node, @parent = nil) - end - - # Returns `true` if this node or one of the parent branchables is a loop, - # `false` otherwise. - def loop? - loop?(node) || !!parent.try(&.loop?) - end - end -end diff --git a/lib/ameba/src/ameba/ast/flow_expression.cr b/lib/ameba/src/ameba/ast/flow_expression.cr deleted file mode 100644 index fe71c699..00000000 --- a/lib/ameba/src/ameba/ast/flow_expression.cr +++ /dev/null @@ -1,72 +0,0 @@ -require "./util" - -module Ameba::AST - # Represents a flow expression in Crystal code. - # For example, - # - # ``` - # def foobar - # a = 3 - # return 42 # => flow expression - # a + 1 - # end - # ``` - # - # Flow expression contains an actual node of a control expression and - # a parent node, which allows easily search through the related statement - # (i.e. find unreachable code) - class FlowExpression - include Util - - # Is true only if some of the nodes parents is a loop. - getter? in_loop : Bool - - # The actual node of the flow expression. - getter node : Crystal::ASTNode - - delegate to_s, to: @node - delegate location, to: @node - delegate end_location, to: @node - - # Creates a new flow expression. - # - # ``` - # FlowExpression.new(node, parent_node) - # ``` - def initialize(@node, @in_loop) - end - - # Returns nodes which can't be reached because of a flow command inside. - # For example: - # - # ``` - # def foobar - # a = 1 - # return 42 - # - # a + 2 # => unreachable assign node - # end - # ``` - def unreachable_nodes - unreachable_nodes = [] of Crystal::ASTNode - - case current_node = node - when Crystal::Expressions - control_flow_found = false - - current_node.expressions.each do |exp| - if control_flow_found - unreachable_nodes << exp - end - control_flow_found ||= !loop?(exp) && flow_expression?(exp, in_loop?) - end - when Crystal::BinaryOp - if flow_expression?(current_node.left, in_loop?) - unreachable_nodes << current_node.right - end - end - - unreachable_nodes - end - end -end diff --git a/lib/ameba/src/ameba/ast/scope.cr b/lib/ameba/src/ameba/ast/scope.cr deleted file mode 100644 index 3e0b4573..00000000 --- a/lib/ameba/src/ameba/ast/scope.cr +++ /dev/null @@ -1,229 +0,0 @@ -require "./variabling/*" - -module Ameba::AST - # Represents a context of the local variable visibility. - # This is where the local variables belong to. - class Scope - # Whether the scope yields. - setter yields = false - - # Scope visibility level - setter visibility : Crystal::Visibility? - - # Link to local variables - getter variables = [] of Variable - - # Link to all variable references in currency scope - getter references = [] of Reference - - # Link to the arguments in current scope - getter arguments = [] of Argument - - # Link to the instance variables used in current scope - getter ivariables = [] of InstanceVariable - - # Link to the type declaration variables used in current scope - getter type_dec_variables = [] of TypeDecVariable - - # Link to the outer scope - getter outer_scope : Scope? - - # List of inner scopes - getter inner_scopes = [] of Scope - - # The actual AST node that represents a current scope. - getter node : Crystal::ASTNode - - delegate location, end_location, to_s, - to: @node - - def_equals_and_hash node, location - - # Creates a new scope. Accepts the AST node and the outer scope. - # - # ``` - # scope = Scope.new(class_node, nil) - # ``` - def initialize(@node, @outer_scope = nil) - @outer_scope.try &.inner_scopes.<< self - end - - # Creates a new variable in the current scope. - # - # ``` - # scope = Scope.new(class_node, nil) - # scope.add_variable(var_node) - # ``` - def add_variable(node) - variables << Variable.new(node, self) - end - - # Creates a new argument in the current scope. - # - # ``` - # scope = Scope.new(class_node, nil) - # scope.add_argument(arg_node) - # ``` - def add_argument(node) - add_variable Crystal::Var.new(node.name).at(node) - arguments << Argument.new(node, variables.last) - end - - # Adds a new instance variable to the current scope. - # - # ``` - # scope = Scope.new(class_node, nil) - # scope.add_ivariable(ivar_node) - # ``` - def add_ivariable(node) - ivariables << InstanceVariable.new(node) - end - - # Adds a new type declaration variable to the current scope. - # - # ``` - # scope = Scope.new(class_node, nil) - # scope.add_type_dec_variable(node) - # ``` - def add_type_dec_variable(node) - type_dec_variables << TypeDecVariable.new(node) - end - - # Returns variable by its name or `nil` if it does not exist. - # - # ``` - # scope = Scope.new(class_node, nil) - # scope.find_variable("foo") - # ``` - def find_variable(name : String) - variables.find(&.name.==(name)) || - outer_scope.try &.find_variable(name) - end - - # Creates a new assignment for the variable. - # - # ``` - # scope = Scope.new(class_node, nil) - # scope.assign_variable(var_name, assign_node) - # ``` - def assign_variable(name, node) - find_variable(name).try &.assign(node, self) - end - - # Returns `true` if current scope represents a block (or proc), - # `false` otherwise. - def block? - node.is_a?(Crystal::Block) || - node.is_a?(Crystal::ProcLiteral) - end - - # Returns `true` if current scope represents a spawn block, e. g. - # - # ``` - # spawn do - # # ... - # end - # ``` - def spawn_block? - node.as?(Crystal::Block).try(&.call).try(&.name) == "spawn" - end - - # Returns `true` if current scope sits inside a macro. - def in_macro? - (node.is_a?(Crystal::Macro) || - node.is_a?(Crystal::MacroIf) || - node.is_a?(Crystal::MacroFor)) || - !!outer_scope.try(&.in_macro?) - end - - # Returns `true` if instance variable is assigned in this scope. - def assigns_ivar?(name) - arguments.any?(&.name.== name) && - ivariables.any?(&.name.== "@#{name}") - end - - # Returns `true` if type declaration variable is assigned in this scope. - def assigns_type_dec?(name) - type_dec_variables.any?(&.name.== name) || - !!outer_scope.try(&.assigns_type_dec?(name)) - end - - # Returns `true` if and only if current scope represents some - # type definition, for example a class. - def type_definition? - node.is_a?(Crystal::ClassDef) || - node.is_a?(Crystal::ModuleDef) || - node.is_a?(Crystal::EnumDef) || - node.is_a?(Crystal::LibDef) || - node.is_a?(Crystal::FunDef) || - node.is_a?(Crystal::TypeDef) || - node.is_a?(Crystal::CStructOrUnionDef) - end - - # Returns `true` if current scope (or any of inner scopes) references variable, - # `false` otherwise. - def references?(variable : Variable, check_inner_scopes = true) - variable.references.any? do |reference| - (reference.scope == self) || - (check_inner_scopes && inner_scopes.any?(&.references?(variable))) - end || variable.used_in_macro? - end - - # Returns `true` if current scope (or any of inner scopes) yields, - # `false` otherwise. - def yields?(check_inner_scopes = true) - @yields || (check_inner_scopes && inner_scopes.any?(&.yields?)) - end - - # Returns visibility of the current scope (could be inherited from the outer scope). - def visibility - @visibility || outer_scope.try(&.visibility) - end - - # Returns `true` if current scope is a def, `false` otherwise. - def def? - node.is_a?(Crystal::Def) - end - - # Returns `true` if current scope is a class, `false` otherwise. - def class_def? - node.is_a?(Crystal::ClassDef) - end - - # Returns `true` if current scope is a module, `false` otherwise. - def module_def? - node.is_a?(Crystal::ModuleDef) - end - - # Returns `true` if this scope is a top level scope, `false` otherwise. - def top_level? - outer_scope.nil? - end - - # Returns `true` if var is an argument in current scope, `false` otherwise. - def arg?(var) - case current_node = node - when Crystal::Def - var.is_a?(Crystal::Arg) && any_arg?(current_node.args, var) - when Crystal::Block - var.is_a?(Crystal::Var) && any_arg?(current_node.args, var) - when Crystal::ProcLiteral - var.is_a?(Crystal::Var) && any_arg?(current_node.def.args, var) - else - false - end - end - - private def any_arg?(args, var) - args.any? { |arg| arg.name == var.name && arg.location == var.location } - end - - # Returns `true` if the *node* represents exactly - # the same Crystal node as `@node`. - def eql?(node) - node == @node && - node.location && - node.location == @node.location - end - end -end diff --git a/lib/ameba/src/ameba/ast/util.cr b/lib/ameba/src/ameba/ast/util.cr deleted file mode 100644 index ce71fc9c..00000000 --- a/lib/ameba/src/ameba/ast/util.cr +++ /dev/null @@ -1,248 +0,0 @@ -# Utility module for Ameba's rules. -module Ameba::AST::Util - # Returns tuple with two bool flags: - # - # 1. is *node* a literal? - # 2. can *node* be proven static? - protected def literal_kind?(node) : {Bool, Bool} - case node - when Crystal::NilLiteral, - Crystal::BoolLiteral, - Crystal::NumberLiteral, - Crystal::CharLiteral, - Crystal::StringLiteral, - Crystal::SymbolLiteral, - Crystal::RegexLiteral, - Crystal::ProcLiteral, - Crystal::MacroLiteral - {true, true} - when Crystal::RangeLiteral - {true, static_literal?(node.from) && - static_literal?(node.to)} - when Crystal::ArrayLiteral, - Crystal::TupleLiteral - {true, node.elements.all? do |element| - static_literal?(element) - end} - when Crystal::HashLiteral - {true, node.entries.all? do |entry| - static_literal?(entry.key) && - static_literal?(entry.value) - end} - when Crystal::NamedTupleLiteral - {true, node.entries.all? do |entry| - static_literal?(entry.value) - end} - else - {false, false} - end - end - - # Returns `true` if current `node` is a static literal, `false` otherwise. - def static_literal?(node) : Bool - is_literal, is_static = literal_kind?(node) - is_literal && is_static - end - - # Returns `true` if current `node` is a dynamic literal, `false` otherwise. - def dynamic_literal?(node) : Bool - is_literal, is_static = literal_kind?(node) - is_literal && !is_static - end - - # Returns `true` if current `node` is a literal, `false` otherwise. - def literal?(node) : Bool - is_literal, _ = literal_kind?(node) - is_literal - end - - # Returns `true` if current `node` is a `Crystal::Path` - # matching given *name*, `false` otherwise. - def path_named?(node, name) : Bool - node.is_a?(Crystal::Path) && - name == node.names.join("::") - end - - # Returns a source code for the current node. - # This method uses `node.location` and `node.end_location` - # to determine and cut a piece of source of the node. - def node_source(node, code_lines) - loc, end_loc = node.location, node.end_location - return unless loc && end_loc - - source_between(loc, end_loc, code_lines) - end - - # Returns the source code from *loc* to *end_loc* (inclusive). - def source_between(loc, end_loc, code_lines) : String? - line, column = loc.line_number - 1, loc.column_number - 1 - end_line, end_column = end_loc.line_number - 1, end_loc.column_number - 1 - node_lines = code_lines[line..end_line] - first_line, last_line = node_lines[0]?, node_lines[-1]? - - return if first_line.nil? || last_line.nil? - return if first_line.size < column # compiler reports incorrect location - - node_lines[0] = first_line.sub(0...column, "") - - if line == end_line # one line - end_column = end_column - column - last_line = node_lines[0] - end - - return if last_line.size < end_column + 1 - - node_lines[-1] = last_line.sub(end_column + 1...last_line.size, "") - node_lines.join('\n') - end - - # Returns `true` if node is a flow command, `false` otherwise. - # Node represents a flow command if it is a control expression, - # or special call node that interrupts execution (i.e. raise, exit, abort). - def flow_command?(node, in_loop) - case node - when Crystal::Return - true - when Crystal::Break, Crystal::Next - in_loop - when Crystal::Call - raise?(node) || exit?(node) || abort?(node) - else - false - end - end - - # Returns `true` if node is a flow expression, `false` if not. - # Node represents a flow expression if it is full-filled by a flow command. - # - # For example, this node is a flow expression, because each branch contains - # a flow command `return`: - # - # ``` - # if a > 0 - # return :positive - # elsif a < 0 - # return :negative - # else - # return :zero - # end - # ``` - # - # This node is a not a flow expression: - # - # ``` - # if a > 0 - # return :positive - # end - # ``` - # - # That's because not all branches return(i.e. `else` is missing). - def flow_expression?(node, in_loop = false) - return true if flow_command? node, in_loop - - case node - when Crystal::If, Crystal::Unless - flow_expressions? [node.then, node.else], in_loop - when Crystal::BinaryOp - flow_expression? node.left, in_loop - when Crystal::Case - flow_expressions? [node.whens, node.else].flatten, in_loop - when Crystal::ExceptionHandler - flow_expressions? [node.else || node.body, node.rescues].flatten, in_loop - when Crystal::While, Crystal::Until - flow_expression? node.body, in_loop - when Crystal::Rescue, Crystal::When - flow_expression? node.body, in_loop - when Crystal::Expressions - node.expressions.any? { |exp| flow_expression? exp, in_loop } - else - false - end - end - - private def flow_expressions?(nodes, in_loop) - nodes.all? { |exp| flow_expression? exp, in_loop } - end - - # Returns `true` if node represents `raise` method call. - def raise?(node) - node.is_a?(Crystal::Call) && - node.name == "raise" && node.args.size == 1 && node.obj.nil? - end - - # Returns `true` if node represents `exit` method call. - def exit?(node) - node.is_a?(Crystal::Call) && - node.name == "exit" && node.args.size <= 1 && node.obj.nil? - end - - # Returns `true` if node represents `abort` method call. - def abort?(node) - node.is_a?(Crystal::Call) && - node.name == "abort" && node.args.size <= 2 && node.obj.nil? - end - - # Returns `true` if node represents a loop. - def loop?(node) - case node - when Crystal::While, Crystal::Until - true - when Crystal::Call - node.name == "loop" && node.args.size == 0 && node.obj.nil? - else - false - end - end - - # Returns the exp code of a control expression. - # Wraps implicit tuple literal with curly brackets (e.g. multi-return). - def control_exp_code(node : Crystal::ControlExpression, code_lines) - return unless exp = node.exp - return unless exp_code = node_source(exp, code_lines) - return exp_code unless exp.is_a?(Crystal::TupleLiteral) && exp_code[0] != '{' - return unless exp_start = exp.elements.first.location - return unless exp_end = exp.end_location - - "{#{source_between(exp_start, exp_end, code_lines)}}" - end - - # Returns `nil` if *node* does not contain a name. - def name_location(node) - if loc = node.name_location - return loc - end - - return node.var.location if node.is_a?(Crystal::TypeDeclaration) || - node.is_a?(Crystal::UninitializedVar) - return unless node.responds_to?(:name) && (name = node.name) - return unless name.is_a?(Crystal::ASTNode) - - name.location - end - - # Returns zero if *node* does not contain a name. - def name_size(node) - unless (size = node.name_size).zero? - return size - end - - return 0 unless node.responds_to?(:name) && (name = node.name) - - case name - when Crystal::ASTNode then name.name_size - when Crystal::Token::Kind then name.to_s.size # Crystal::MagicConstant - else name.size - end - end - - # Returns `nil` if *node* does not contain a name. - # - # NOTE: Use this instead of `Crystal::Call#name_end_location` to avoid an - # off-by-one error. - def name_end_location(node) - return unless loc = name_location(node) - return if (size = name_size(node)).zero? - - loc.adjust(column_number: size - 1) - end -end diff --git a/lib/ameba/src/ameba/ast/variabling/argument.cr b/lib/ameba/src/ameba/ast/variabling/argument.cr deleted file mode 100644 index d080e9a1..00000000 --- a/lib/ameba/src/ameba/ast/variabling/argument.cr +++ /dev/null @@ -1,53 +0,0 @@ -module Ameba::AST - # Represents the argument of some node. - # Holds the reference to the variable, thus to scope. - # - # For example, all these vars are arguments: - # - # ``` - # def method(a, b, c = 10, &block) - # 3.times do |i| - # end - # - # ->(x : Int32) {} - # end - # ``` - class Argument - # The actual node. - getter node : Crystal::Var | Crystal::Arg - - # Variable of this argument (may be the same node) - getter variable : Variable - - delegate location, end_location, to_s, - to: @node - - # Creates a new argument. - # - # ``` - # Argument.new(node, variable) - # ``` - def initialize(@node, @variable) - end - - # Returns `true` if the `name` is empty, `false` otherwise. - def anonymous? - name.blank? - end - - # Returns `true` if the `name` starts with '_', `false` otherwise. - def ignored? - name.starts_with? '_' - end - - # Name of the argument. - def name - case current_node = node - when Crystal::Var, Crystal::Arg - current_node.name - else - raise ArgumentError.new "Invalid node" - end - end - end -end diff --git a/lib/ameba/src/ameba/ast/variabling/assignment.cr b/lib/ameba/src/ameba/ast/variabling/assignment.cr deleted file mode 100644 index ece19b80..00000000 --- a/lib/ameba/src/ameba/ast/variabling/assignment.cr +++ /dev/null @@ -1,76 +0,0 @@ -require "./reference" -require "./variable" - -module Ameba::AST - # Represents the assignment to the variable. - # Holds the assign node and the variable. - class Assignment - property? referenced = false - - # The actual assignment node. - getter node : Crystal::ASTNode - - # Variable of this assignment. - getter variable : Variable - - # Branch of this assignment. - getter branch : Branch? - - # A scope assignment belongs to - getter scope : Scope - - delegate location, end_location, to_s, - to: @node - - # Creates a new assignment. - # - # ``` - # Assignment.new(node, variable, scope) - # ``` - def initialize(@node, @variable, @scope) - return unless scope = @variable.scope - - @branch = Branch.of(@node, scope) - @referenced = true if @variable.special? || referenced_in_loop? - end - - def referenced_in_loop? - @variable.referenced? && !!@branch.try(&.in_loop?) - end - - # Returns `true` if this assignment is an op assign, `false` if not. - # For example, this is an op assign: - # - # ``` - # a ||= 1 - # ``` - def op_assign? - node.is_a?(Crystal::OpAssign) - end - - # Returns `true` if this assignment is in a branch, `false` if not. - # For example, this assignment is in a branch: - # - # ``` - # a = 1 if a.nil? - # ``` - def in_branch? - !branch.nil? - end - - # Returns the target node of the variable in this assignment. - def target_node - case assign = node - when Crystal::Assign then assign.target - when Crystal::OpAssign then assign.target - when Crystal::UninitializedVar then assign.var - when Crystal::MultiAssign - assign.targets.find(node) do |target| - target.is_a?(Crystal::Var) && target.name == variable.name - end - else - node - end - end - end -end diff --git a/lib/ameba/src/ameba/ast/variabling/ivariable.cr b/lib/ameba/src/ameba/ast/variabling/ivariable.cr deleted file mode 100644 index db6f3bd3..00000000 --- a/lib/ameba/src/ameba/ast/variabling/ivariable.cr +++ /dev/null @@ -1,11 +0,0 @@ -module Ameba::AST - class InstanceVariable - getter node : Crystal::InstanceVar - - delegate location, end_location, name, to_s, - to: @node - - def initialize(@node) - end - end -end diff --git a/lib/ameba/src/ameba/ast/variabling/reference.cr b/lib/ameba/src/ameba/ast/variabling/reference.cr deleted file mode 100644 index 885234d9..00000000 --- a/lib/ameba/src/ameba/ast/variabling/reference.cr +++ /dev/null @@ -1,10 +0,0 @@ -require "./variable" - -module Ameba::AST - # Represents a reference to the variable. - # It behaves like a variable is used to distinguish a - # the variable from its reference. - class Reference < Variable - property? explicit = true - end -end diff --git a/lib/ameba/src/ameba/ast/variabling/type_dec_variable.cr b/lib/ameba/src/ameba/ast/variabling/type_dec_variable.cr deleted file mode 100644 index 74cc3fbb..00000000 --- a/lib/ameba/src/ameba/ast/variabling/type_dec_variable.cr +++ /dev/null @@ -1,20 +0,0 @@ -module Ameba::AST - class TypeDecVariable - getter node : Crystal::TypeDeclaration - - delegate location, end_location, to_s, - to: @node - - def initialize(@node) - end - - def name - case var = @node.var - when Crystal::Var, Crystal::InstanceVar, Crystal::ClassVar, Crystal::Global - var.name - else - raise "Unsupported var node type: #{var.class}" - end - end - end -end diff --git a/lib/ameba/src/ameba/ast/variabling/variable.cr b/lib/ameba/src/ameba/ast/variabling/variable.cr deleted file mode 100644 index 8b513948..00000000 --- a/lib/ameba/src/ameba/ast/variabling/variable.cr +++ /dev/null @@ -1,219 +0,0 @@ -module Ameba::AST - # Represents the existence of the local variable. - # Holds the var node and variable assignments. - class Variable - # List of the assignments of this variable. - getter assignments = [] of Assignment - - # List of the references of this variable. - getter references = [] of Reference - - # The actual var node. - getter node : Crystal::Var - - # Scope of this variable. - getter scope : Scope - - # Node of the first assignment which can be available before any reference. - getter assign_before_reference : Crystal::ASTNode? - - delegate location, end_location, name, to_s, - to: @node - - # Creates a new variable(in the scope). - # - # ``` - # Variable.new(node, scope) - # ``` - def initialize(@node, @scope) - end - - # Returns `true` if it is a special variable, i.e `$?`. - def special? - @node.special_var? - end - - # Assigns the variable (creates a new assignment). - # Variable may have multiple assignments. - # - # ``` - # variable = Variable.new(node, scope) - # variable.assign(node1) - # variable.assign(node2) - # variable.assignment.size # => 2 - # ``` - def assign(node, scope) - assignments << Assignment.new(node, self, scope) - - update_assign_reference! - end - - # Returns `true` if variable has any reference. - # - # ``` - # variable = Variable.new(node, scope) - # variable.reference(var_node, some_scope) - # variable.referenced? # => true - # ``` - def referenced? - !references.empty? - end - - # Creates a reference to this variable in some scope. - # - # ``` - # variable = Variable.new(node, scope) - # variable.reference(var_node, some_scope) - # ``` - def reference(node : Crystal::Var, scope : Scope) - Reference.new(node, scope).tap do |reference| - references << reference - scope.references << reference - end - end - - # :ditto: - def reference(scope : Scope) - reference(node, scope) - end - - # Reference variable's assignments. - # - # ``` - # variable = Variable.new(node, scope) - # variable.assign(assign_node) - # variable.reference_assignments! - # ``` - def reference_assignments! - consumed_branches = Set(Branch).new - - assignments.reverse_each do |assignment| - next if assignment.branch.in?(consumed_branches) - assignment.referenced = true - - break unless branch = assignment.branch - consumed_branches << branch - end - end - - # Returns `true` if the current var is referenced in - # in the block. For example this variable is captured - # by block: - # - # ``` - # a = 1 - # 3.times { |i| a = a + i } - # ``` - # - # And this variable is not captured by block. - # - # ``` - # i = 1 - # 3.times { |i| i + 1 } - # ``` - def captured_by_block?(scope = @scope) - scope.inner_scopes.each do |inner_scope| - return true if inner_scope.block? && - inner_scope.references?(self, check_inner_scopes: false) - return true if captured_by_block?(inner_scope) - end - - false - end - - # Returns `true` if current variable potentially referenced in a macro, - # `false` if not. - def used_in_macro?(scope = @scope) - scope.inner_scopes.each do |inner_scope| - return true if MacroReferenceFinder.new(inner_scope.node, node.name).references? - end - return true if MacroReferenceFinder.new(scope.node, node.name).references? - return true if (outer_scope = scope.outer_scope) && used_in_macro?(outer_scope) - - false - end - - # Returns `true` if the variable is a target (on the left) of the assignment, - # `false` otherwise. - def target_of?(assign) - case assign - when Crystal::Assign then eql?(assign.target) - when Crystal::OpAssign then eql?(assign.target) - when Crystal::MultiAssign then assign.targets.any? { |target| eql?(target) } - when Crystal::UninitializedVar then eql?(assign.var) - else - false - end - end - - # Returns `true` if the name starts with '_', `false` if not. - def ignored? - name.starts_with? '_' - end - - # Returns `true` if the `node` represents exactly - # the same Crystal node as `@node`. - def eql?(node) - node.is_a?(Crystal::Var) && - node.name == @node.name && - node.location == @node.location - end - - # Returns `true` if the variable is declared before the `node`. - def declared_before?(node) - var_location, node_location = location, node.location - - return unless var_location && node_location - - (var_location.line_number < node_location.line_number) || - (var_location.line_number == node_location.line_number && - var_location.column_number < node_location.column_number) - end - - private class MacroReferenceFinder < Crystal::Visitor - property? references = false - - def initialize(node, @reference : String) - node.accept self - end - - @[AlwaysInline] - private def includes_reference?(val) - val.to_s.includes?(@reference) - end - - def visit(node : Crystal::MacroLiteral) - !(@references ||= includes_reference?(node.value)) - end - - def visit(node : Crystal::MacroExpression) - !(@references ||= includes_reference?(node.exp)) - end - - def visit(node : Crystal::MacroFor) - !(@references ||= includes_reference?(node.exp) || - includes_reference?(node.body)) - end - - def visit(node : Crystal::MacroIf) - !(@references ||= includes_reference?(node.cond) || - includes_reference?(node.then) || - includes_reference?(node.else)) - end - - def visit(node : Crystal::ASTNode) - true - end - end - - private def update_assign_reference! - return if @assign_before_reference - return if references.size > assignments.size - return if assignments.any?(&.op_assign?) - - @assign_before_reference = assignments - .find(&.in_branch?.!) - .try(&.node) - end - end -end diff --git a/lib/ameba/src/ameba/ast/visitors/base_visitor.cr b/lib/ameba/src/ameba/ast/visitors/base_visitor.cr deleted file mode 100644 index 0b2efc66..00000000 --- a/lib/ameba/src/ameba/ast/visitors/base_visitor.cr +++ /dev/null @@ -1,28 +0,0 @@ -require "compiler/crystal/syntax/*" - -# A module that helps to traverse Crystal AST using `Crystal::Visitor`. -module Ameba::AST - # An abstract base visitor that utilizes general logic for all visitors. - abstract class BaseVisitor < Crystal::Visitor - # A corresponding rule that uses this visitor. - @rule : Rule::Base - - # A source that needs to be traversed. - @source : Source - - # Creates instance of this visitor. - # - # ``` - # visitor = Ameba::AST::NodeVisitor.new(rule, source) - # ``` - def initialize(@rule, @source) - @source.ast.accept self - end - - # A main visit method that accepts `Crystal::ASTNode`. - # Returns `true`, meaning all child nodes will be traversed. - def visit(node : Crystal::ASTNode) - true - end - end -end diff --git a/lib/ameba/src/ameba/ast/visitors/counting_visitor.cr b/lib/ameba/src/ameba/ast/visitors/counting_visitor.cr deleted file mode 100644 index 347e5ccd..00000000 --- a/lib/ameba/src/ameba/ast/visitors/counting_visitor.cr +++ /dev/null @@ -1,52 +0,0 @@ -module Ameba::AST - # AST Visitor that counts occurrences of certain keywords - class CountingVisitor < Crystal::Visitor - DEFAULT_COMPLEXITY = 1 - - getter? macro_condition = false - - # Creates a new counting visitor - def initialize(@scope : Crystal::ASTNode) - @complexity = DEFAULT_COMPLEXITY - end - - # :nodoc: - def visit(node : Crystal::ASTNode) - true - end - - # Returns the number of keywords that were found in the node - def count - @scope.accept(self) - @complexity - end - - # Uses the same logic than rubocop. See - # https://github.com/rubocop-hq/rubocop/blob/master/lib/rubocop/cop/metrics/cyclomatic_complexity.rb#L21 - # Except "for", because crystal doesn't have a "for" loop. - {% for node in %i[if while until rescue or and] %} - # :nodoc: - def visit(node : Crystal::{{ node.id.capitalize }}) - @complexity += 1 unless macro_condition? - end - {% end %} - - # :nodoc: - def visit(node : Crystal::Case) - return true if macro_condition? - - # Count the complexity of an exhaustive `Case` as 1 - # Otherwise count the number of `When`s - @complexity += node.exhaustive? ? 1 : node.whens.size - - true - end - - def visit(node : Crystal::MacroIf | Crystal::MacroFor) - @macro_condition = true - @complexity = DEFAULT_COMPLEXITY - - false - end - end -end diff --git a/lib/ameba/src/ameba/ast/visitors/flow_expression_visitor.cr b/lib/ameba/src/ameba/ast/visitors/flow_expression_visitor.cr deleted file mode 100644 index f23d8de7..00000000 --- a/lib/ameba/src/ameba/ast/visitors/flow_expression_visitor.cr +++ /dev/null @@ -1,51 +0,0 @@ -require "../util" -require "./base_visitor" - -module Ameba::AST - # AST Visitor that traverses all the flow expressions. - class FlowExpressionVisitor < BaseVisitor - include Util - - @loop_stack = [] of Crystal::ASTNode - - # :nodoc: - def visit(node) - if flow_expression?(node, in_loop?) - @rule.test @source, node, FlowExpression.new(node, in_loop?) - end - true - end - - # :nodoc: - def visit(node : Crystal::While | Crystal::Until) - on_loop_started(node) - end - - # :nodoc: - def visit(node : Crystal::Call) - on_loop_started(node) if loop?(node) - end - - # :nodoc: - def end_visit(node : Crystal::While | Crystal::Until) - on_loop_ended(node) - end - - # :nodoc: - def end_visit(node : Crystal::Call) - on_loop_ended(node) if loop?(node) - end - - private def on_loop_started(node) - @loop_stack.push(node) - end - - private def on_loop_ended(node) - @loop_stack.pop? - end - - private def in_loop? - !@loop_stack.empty? - end - end -end diff --git a/lib/ameba/src/ameba/ast/visitors/node_visitor.cr b/lib/ameba/src/ameba/ast/visitors/node_visitor.cr deleted file mode 100644 index 60617b55..00000000 --- a/lib/ameba/src/ameba/ast/visitors/node_visitor.cr +++ /dev/null @@ -1,94 +0,0 @@ -require "./base_visitor" - -module Ameba::AST - # An AST Visitor that traverses the source and allows all nodes - # to be inspected by rules. - # - # ``` - # visitor = Ameba::AST::NodeVisitor.new(rule, source) - # ``` - class NodeVisitor < BaseVisitor - @[Flags] - enum Category - Macro - end - - # List of nodes to be visited by Ameba's rules. - NODES = { - Alias, - Assign, - Block, - Call, - Case, - ClassDef, - ClassVar, - Def, - EnumDef, - ExceptionHandler, - Expressions, - HashLiteral, - If, - InstanceVar, - IsA, - LibDef, - ModuleDef, - MultiAssign, - NilLiteral, - StringInterpolation, - Unless, - Until, - Var, - When, - While, - } - - @skip : Array(Crystal::ASTNode.class)? - - def self.category_to_node_classes(category : Category) - ([] of Crystal::ASTNode.class).tap do |classes| - classes.push( - Crystal::Macro, - Crystal::MacroExpression, - Crystal::MacroIf, - Crystal::MacroFor, - ) if category.macro? - end - end - - def initialize(@rule, @source, *, skip : Category) - initialize @rule, @source, - skip: NodeVisitor.category_to_node_classes(skip) - end - - def initialize(@rule, @source, *, skip : Array? = nil) - @skip = skip.try &.map(&.as(Crystal::ASTNode.class)) - super @rule, @source - end - - def visit(node : Crystal::VisibilityModifier) - node.exp.visibility = node.modifier - true - end - - {% for name in NODES %} - # A visit callback for `Crystal::{{ name }}` node. - # - # Returns `true` if the child nodes should be traversed as well, - # `false` otherwise. - def visit(node : Crystal::{{ name }}) - return false if skip?(node) - - @rule.test @source, node - true - end - {% end %} - - def visit(node) - !skip?(node) - end - - private def skip?(node) - !!@skip.try(&.includes?(node.class)) - end - end -end diff --git a/lib/ameba/src/ameba/ast/visitors/redundant_control_expression_visitor.cr b/lib/ameba/src/ameba/ast/visitors/redundant_control_expression_visitor.cr deleted file mode 100644 index 43f11fa3..00000000 --- a/lib/ameba/src/ameba/ast/visitors/redundant_control_expression_visitor.cr +++ /dev/null @@ -1,60 +0,0 @@ -module Ameba::AST - # A class that utilizes a logic to traverse AST nodes and - # fire a source test callback if a redundant `Crystal::ControlExpression` - # is reached. - class RedundantControlExpressionVisitor - # A corresponding rule that uses this visitor. - getter rule : Rule::Base - - # A source that needs to be traversed. - getter source : Source - - # A node to run traversal on. - getter node : Crystal::ASTNode - - def initialize(@rule, @source, @node) - traverse_node node - end - - private def traverse_control_expression(node) - @rule.test(@source, node, self) - end - - private def traverse_node(node) - case node - when Crystal::ControlExpression then traverse_control_expression node - when Crystal::Expressions then traverse_expressions node - when Crystal::If, Crystal::Unless then traverse_condition node - when Crystal::Case then traverse_case node - when Crystal::BinaryOp then traverse_binary_op node - when Crystal::ExceptionHandler then traverse_exception_handler node - end - end - - private def traverse_expressions(node) - traverse_node node.expressions.last? - end - - private def traverse_condition(node) - return if node.else.nil? || node.else.nop? - - traverse_node(node.then) - traverse_node(node.else) - end - - private def traverse_case(node) - node.whens.each { |when_node| traverse_node when_node.body } - traverse_node(node.else) - end - - private def traverse_binary_op(node) - traverse_node(node.right) - end - - private def traverse_exception_handler(node) - traverse_node node.body - traverse_node node.else - node.rescues.try &.each { |rescue_node| traverse_node rescue_node.body } - end - end -end diff --git a/lib/ameba/src/ameba/ast/visitors/scope_visitor.cr b/lib/ameba/src/ameba/ast/visitors/scope_visitor.cr deleted file mode 100644 index 53ea39eb..00000000 --- a/lib/ameba/src/ameba/ast/visitors/scope_visitor.cr +++ /dev/null @@ -1,204 +0,0 @@ -require "./base_visitor" - -module Ameba::AST - # AST Visitor that traverses the source and constructs scopes. - class ScopeVisitor < BaseVisitor - # Non-exhaustive list of nodes to be visited by Ameba's rules. - NODES = { - ClassDef, - ModuleDef, - EnumDef, - LibDef, - FunDef, - TypeDef, - TypeOf, - CStructOrUnionDef, - ProcLiteral, - Block, - Macro, - MacroIf, - MacroFor, - } - - SPECIAL_NODE_NAMES = %w[super previous_def] - - @scope_queue = [] of Scope - @current_scope : Scope - @current_assign : Crystal::ASTNode? - @current_visibility : Crystal::Visibility? - @skip : Array(Crystal::ASTNode.class)? - - def initialize(@rule, @source, skip = nil) - @current_scope = Scope.new(@source.ast) # top level scope - @skip = skip.try &.map(&.as(Crystal::ASTNode.class)) - - super @rule, @source - - @scope_queue.each do |scope| - @rule.test @source, scope.node, scope - end - end - - private def on_scope_enter(node) - return if skip?(node) - - scope = Scope.new(node, @current_scope) - scope.visibility = @current_visibility - - @current_scope = scope - end - - private def on_scope_end(node) - @scope_queue << @current_scope - - @current_visibility = nil - - # go up if this is not a top level scope - if outer_scope = @current_scope.outer_scope - @current_scope = outer_scope - end - end - - private def on_assign_end(target, node) - target.is_a?(Crystal::Var) && - @current_scope.assign_variable(target.name, node) - end - - # :nodoc: - def end_visit(node : Crystal::ASTNode) - on_scope_end(node) if @current_scope.eql?(node) - end - - {% for name in NODES %} - # :nodoc: - def visit(node : Crystal::{{ name }}) - on_scope_enter(node) - end - {% end %} - - # :nodoc: - def visit(node : Crystal::VisibilityModifier) - @current_visibility = node.exp.visibility = node.modifier - true - end - - # :nodoc: - def visit(node : Crystal::Yield) - @current_scope.yields = true - end - - # :nodoc: - def visit(node : Crystal::Def) - node.name == "->" || on_scope_enter(node) - end - - # :nodoc: - def visit(node : Crystal::Assign | Crystal::OpAssign | Crystal::MultiAssign | Crystal::UninitializedVar) - @current_assign = node - end - - # :nodoc: - def end_visit(node : Crystal::Assign | Crystal::OpAssign) - on_assign_end(node.target, node) - @current_assign = nil - - on_scope_end(node) if @current_scope.eql?(node) - end - - # :nodoc: - def end_visit(node : Crystal::MultiAssign) - node.targets.each { |target| on_assign_end(target, node) } - @current_assign = nil - - on_scope_end(node) if @current_scope.eql?(node) - end - - # :nodoc: - def end_visit(node : Crystal::UninitializedVar) - on_assign_end(node.var, node) - @current_assign = nil - - on_scope_end(node) if @current_scope.eql?(node) - end - - # :nodoc: - def visit(node : Crystal::TypeDeclaration) - return unless (var = node.var).is_a?(Crystal::Var) - - @current_scope.add_variable(var) - @current_scope.add_type_dec_variable(node) - - @current_assign = node.value if node.value - end - - # :nodoc: - def end_visit(node : Crystal::TypeDeclaration) - return unless (var = node.var).is_a?(Crystal::Var) - - on_assign_end(var, node) - @current_assign = nil - - on_scope_end(node) if @current_scope.eql?(node) - end - - # :nodoc: - def visit(node : Crystal::Arg) - @current_scope.add_argument(node) - end - - # :nodoc: - def visit(node : Crystal::InstanceVar) - @current_scope.add_ivariable(node) - end - - # :nodoc: - def visit(node : Crystal::Var) - variable = @current_scope.find_variable(node.name) - - case - when @current_scope.arg?(node) # node is an argument - @current_scope.add_argument(node) - when variable.nil? && @current_assign # node is a variable - @current_scope.add_variable(node) - when variable # node is a reference - reference = variable.reference(node, @current_scope) - if @current_assign.is_a?(Crystal::OpAssign) || !reference.target_of?(@current_assign) - variable.reference_assignments! - end - end - end - - # :nodoc: - def visit(node : Crystal::Call) - scope = @current_scope - - case - when scope.top_level? && record_macro?(node) then return false - when scope.type_definition? && record_macro?(node) then return false - when scope.type_definition? && accessor_macro?(node) then return false - when scope.def? && special_node?(node) - scope.arguments.each do |arg| - ref = arg.variable.reference(scope) - ref.explicit = false - end - end - true - end - - private def special_node?(node) - node.name.in?(SPECIAL_NODE_NAMES) && node.args.empty? - end - - private def accessor_macro?(node) - node.name.matches? /^(class_)?(getter[?!]?|setter|property[?!]?)$/ - end - - private def record_macro?(node) - node.name == "record" && node.args.first?.is_a?(Crystal::Path) - end - - private def skip?(node) - !!@skip.try(&.includes?(node.class)) - end - end -end diff --git a/lib/ameba/src/ameba/ast/visitors/top_level_nodes_visitor.cr b/lib/ameba/src/ameba/ast/visitors/top_level_nodes_visitor.cr deleted file mode 100644 index 5e5ad913..00000000 --- a/lib/ameba/src/ameba/ast/visitors/top_level_nodes_visitor.cr +++ /dev/null @@ -1,30 +0,0 @@ -module Ameba::AST - # AST Visitor that visits certain nodes at a top level, which - # can characterize the source (i.e. require statements, modules etc.) - class TopLevelNodesVisitor < Crystal::Visitor - getter require_nodes = [] of Crystal::Require - - # Creates a new instance of visitor - def initialize(@scope : Crystal::ASTNode) - @scope.accept(self) - end - - # :nodoc: - def visit(node : Crystal::Require) - require_nodes << node - true - end - - # If a top level node is `Crystal::Expressions`, - # then always traverse the children. - def visit(node : Crystal::Expressions) - true - end - - # A general visit method for rest of the nodes. - # Returns `false`, meaning all child nodes will not be traversed. - def visit(node : Crystal::ASTNode) - false - end - end -end diff --git a/lib/ameba/src/ameba/cli/cmd.cr b/lib/ameba/src/ameba/cli/cmd.cr deleted file mode 100644 index 5cb9bc0b..00000000 --- a/lib/ameba/src/ameba/cli/cmd.cr +++ /dev/null @@ -1,213 +0,0 @@ -require "../../ameba" -require "option_parser" - -# :nodoc: -module Ameba::Cli - extend self - - def run(args = ARGV) - opts = parse_args args - location_to_explain = opts.location_to_explain - autocorrect = opts.autocorrect? - - if location_to_explain && autocorrect - raise "Invalid usage: Cannot explain an issue and autocorrect at the same time." - end - - config = Config.load opts.config, opts.colors?, opts.skip_reading_config? - config.autocorrect = autocorrect - - if globs = opts.globs - config.globs = globs - end - if fail_level = opts.fail_level - config.severity = fail_level - end - - configure_formatter(config, opts) - configure_rules(config, opts) - - if opts.rules? - 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 - - runner = Ameba.run(config) - - if location_to_explain - runner.explain(location_to_explain) - else - exit 1 unless runner.success? - end - rescue e - puts "Error: #{e.message}" - exit 255 - end - - private class Opts - property config : Path? - property formatter : Symbol | String | Nil - property globs : Array(String)? - property only : Array(String)? - property except : Array(String)? - property describe_rule : String? - property location_to_explain : NamedTuple(file: String, line: Int32, column: Int32)? - property fail_level : Severity? - property? skip_reading_config = false - property? rules = false - property? all = false - property? colors = true - property? without_affected_code = false - property? autocorrect = false - end - - def parse_args(args, opts = Opts.new) - OptionParser.parse(args) do |parser| - parser.banner = "Usage: ameba [options] [file1 file2 ...]" - - parser.on("-v", "--version", "Print version") { print_version } - parser.on("-h", "--help", "Show this help") { print_help(parser) } - parser.on("-r", "--rules", "Show all available rules") { opts.rules = true } - parser.on("-s", "--silent", "Disable output") { opts.formatter = :silent } - parser.unknown_args do |arr| - if arr.size == 1 && arr.first.matches?(/.+:\d+:\d+/) - configure_explain_opts(arr.first, opts) - else - opts.globs = arr unless arr.empty? - end - end - - parser.on("-c", "--config PATH", - "Specify a configuration file") do |path| - opts.config = Path[path] unless path.empty? - end - - parser.on("-f", "--format FORMATTER", - "Choose an output formatter: #{Config.formatter_names}") do |formatter| - opts.formatter = formatter - end - - parser.on("--only RULE1,RULE2,...", - "Run only given rules (or groups)") do |rules| - opts.only = rules.split(',') - end - - parser.on("--except RULE1,RULE2,...", - "Disable the given rules (or groups)") do |rules| - opts.except = rules.split(',') - end - - parser.on("--all", "Enable all available rules") do - opts.all = true - end - - parser.on("--fix", "Autocorrect issues") do - opts.autocorrect = true - end - - parser.on("--gen-config", - "Generate a configuration file acting as a TODO list") do - opts.formatter = :todo - opts.skip_reading_config = true - end - - parser.on("--fail-level SEVERITY", - "Change the level of failure to exit. Defaults to Convention") do |level| - opts.fail_level = Severity.parse(level) - end - - parser.on("-e", "--explain PATH:line:column", - "Explain an issue at a specified location") do |loc| - configure_explain_opts(loc, opts) - 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", - "Stop showing affected code while using a default formatter") do - opts.without_affected_code = true - end - - parser.on("--no-color", "Disable colors") do - opts.colors = false - end - end - - opts - end - - private def configure_rules(config, opts) - case - when only = opts.only - config.rules.each(&.enabled = false) - config.update_rules(only, enabled: true) - when opts.all? - config.rules.each(&.enabled = true) - end - config.update_rules(opts.except, enabled: false) - end - - private def configure_formatter(config, opts) - if name = opts.formatter - config.formatter = name - end - config.formatter.config[:autocorrect] = opts.autocorrect? - config.formatter.config[:without_affected_code] = - opts.without_affected_code? - 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) - location_to_explain = parse_explain_location(loc) - opts.location_to_explain = location_to_explain - opts.globs = [location_to_explain[:file]] - opts.formatter = :silent - end - - private def parse_explain_location(arg) - location = arg.split(':', remove_empty: true).map! &.strip - raise ArgumentError.new unless location.size === 3 - - file, line, column = location - { - file: file, - line: line.to_i, - column: column.to_i, - } - rescue - raise "location should have PATH:line:column format" - end - - private def print_version - puts VERSION - exit 0 - end - - private def print_help(parser) - puts parser - exit 0 - end - - private def describe_rule(rule) - Presenter::RulePresenter.new.run(rule) - exit 0 - end - - private def print_rules(rules) - Presenter::RuleCollectionPresenter.new.run(rules) - exit 0 - end -end diff --git a/lib/ameba/src/ameba/config.cr b/lib/ameba/src/ameba/config.cr deleted file mode 100644 index 195c7058..00000000 --- a/lib/ameba/src/ameba/config.cr +++ /dev/null @@ -1,350 +0,0 @@ -require "yaml" -require "./glob_utils" - -# A configuration entry for `Ameba::Runner`. -# -# Config can be loaded from configuration YAML file and adjusted. -# -# ``` -# config = Config.load -# config.formatter = my_formatter -# ``` -# -# By default config loads `.ameba.yml` file located in a current -# working directory. -# -# If it cannot be found until reaching the root directory, then it will be -# searched for in the userโ€™s global config locations, which consists of a -# dotfile or a config file inside the XDG Base Directory specification. -# -# - `~/.ameba.yml` -# - `$XDG_CONFIG_HOME/ameba/config.yml` (expands to `~/.config/ameba/config.yml` -# if `$XDG_CONFIG_HOME` is not set) -# -# If both files exist, the dotfile will be selected. -# -# As an example, if Ameba is invoked from inside `/path/to/project/lib/utils`, -# then it will use the config as specified inside the first of the following files: -# -# - `/path/to/project/lib/utils/.ameba.yml` -# - `/path/to/project/lib/.ameba.yml` -# - `/path/to/project/.ameba.yml` -# - `/path/to/.ameba.yml` -# - `/path/.ameba.yml` -# - `/.ameba.yml` -# - `~/.ameba.yml` -# - `~/.config/ameba/config.yml` -class Ameba::Config - include GlobUtils - - AVAILABLE_FORMATTERS = { - progress: Formatter::DotFormatter, - todo: Formatter::TODOFormatter, - flycheck: Formatter::FlycheckFormatter, - silent: Formatter::BaseFormatter, - disabled: Formatter::DisabledFormatter, - json: Formatter::JSONFormatter, - } - - XDG_CONFIG_HOME = ENV.fetch("XDG_CONFIG_HOME", "~/.config") - - FILENAME = ".ameba.yml" - DEFAULT_PATH = Path[Dir.current] / FILENAME - DEFAULT_PATHS = { - Path["~"] / FILENAME, - Path[XDG_CONFIG_HOME] / "ameba/config.yml", - } - - DEFAULT_GLOBS = %w( - **/*.cr - !lib - ) - - getter rules : Array(Rule::Base) - property severity = Severity::Convention - - # Returns a list of paths (with wildcards) to files. - # Represents a list of sources to be inspected. - # If globs are not set, it will return default list of files. - # - # ``` - # config = Ameba::Config.load - # config.globs = ["**/*.cr"] - # config.globs - # ``` - property globs : Array(String) - - # Represents a list of paths to exclude from globs. - # Can have wildcards. - # - # ``` - # config = Ameba::Config.load - # config.excluded = ["spec", "src/server/*.cr"] - # ``` - property excluded : Array(String) - - # Returns `true` if correctable issues should be autocorrected. - property? autocorrect = false - - @rule_groups : Hash(String, Array(Rule::Base)) - - # Creates a new instance of `Ameba::Config` based on YAML parameters. - # - # `Config.load` uses this constructor to instantiate new config by YAML file. - protected def initialize(config : YAML::Any) - @rules = Rule.rules.map &.new(config).as(Rule::Base) - @rule_groups = @rules.group_by &.group - @excluded = load_array_section(config, "Excluded") - @globs = load_array_section(config, "Globs", DEFAULT_GLOBS) - - if formatter_name = load_formatter_name(config) - self.formatter = formatter_name - end - end - - # Loads YAML configuration file by `path`. - # - # ``` - # config = Ameba::Config.load - # ``` - def self.load(path = nil, colors = true, skip_reading_config = false) - Colorize.enabled = colors - content = if skip_reading_config - "{}" - else - read_config(path) || "{}" - end - Config.new YAML.parse(content) - rescue e - raise "Unable to load config file: #{e.message}" - end - - protected def self.read_config(path = nil) - if path - return File.read(path) if File.exists?(path) - raise "Config file does not exist" - end - each_config_path do |config_path| - return File.read(config_path) if File.exists?(config_path) - end - end - - protected def self.each_config_path(&) - path = Path[DEFAULT_PATH].expand(home: true) - - search_paths = path.parents - search_paths.reverse_each do |search_path| - yield search_path / FILENAME - end - - DEFAULT_PATHS.each do |default_path| - yield default_path - end - end - - def self.formatter_names - AVAILABLE_FORMATTERS.keys.join('|') - end - - # Returns a list of sources matching globs and excluded sections. - # - # ``` - # config = Ameba::Config.load - # config.sources # => list of default sources - # config.globs = ["**/*.cr"] - # config.excluded = ["spec"] - # config.sources # => list of sources pointing to files found by the wildcards - # ``` - def sources - (find_files_by_globs(globs) - find_files_by_globs(excluded)) - .map { |path| Source.new File.read(path), path } - end - - # Returns a formatter to be used while inspecting files. - # If formatter is not set, it will return default formatter. - # - # ``` - # config = Ameba::Config.load - # config.formatter = custom_formatter - # config.formatter - # ``` - property formatter : Formatter::BaseFormatter do - Formatter::DotFormatter.new - end - - # Sets formatter by name. - # - # ``` - # config = Ameba::Config.load - # config.formatter = :progress - # ``` - def formatter=(name : String | Symbol) - unless formatter = AVAILABLE_FORMATTERS[name]? - raise "Unknown formatter `#{name}`. Use one of #{Config.formatter_names}." - end - @formatter = formatter.new - end - - # Updates rule properties. - # - # ``` - # config = Ameba::Config.load - # config.update_rule "MyRuleName", enabled: false - # ``` - def update_rule(name, enabled = true, excluded = nil) - rule = @rules.find(&.name.==(name)) - raise ArgumentError.new("Rule `#{name}` does not exist") unless rule - - rule - .tap(&.enabled = enabled) - .tap(&.excluded = excluded) - end - - # Updates rules properties. - # - # ``` - # config = Ameba::Config.load - # config.update_rules %w[Rule1 Rule2], enabled: true - # ``` - # - # also it allows to update groups of rules: - # - # ``` - # config.update_rules %w[Group1 Group2], enabled: true - # ``` - def update_rules(names, enabled = true, excluded = nil) - names.try &.each do |name| - if rules = @rule_groups[name]? - rules.each do |rule| - rule.enabled = enabled - rule.excluded = excluded - end - else - update_rule name, enabled, excluded - end - end - end - - private def load_formatter_name(config) - name = config["Formatter"]?.try &.["Name"]? - name.try(&.to_s) - end - - private def load_array_section(config, section_name, default = [] of String) - case value = config[section_name]? - when .nil? then default - when .as_s? then [value.to_s] - when .as_a? then value.as_a.map(&.as_s) - else - raise "Incorrect '#{section_name}' section in a config files" - end - end - - # :nodoc: - module RuleConfig - # Define rule properties - macro properties(&block) - {% definitions = [] of NamedTuple %} - {% if (prop = block.body).is_a? Call %} - {% if (named_args = prop.named_args) && (type = named_args.select(&.name.== "as".id).first) %} - {% definitions << {var: prop.name, value: prop.args.first, type: type.value} %} - {% else %} - {% definitions << {var: prop.name, value: prop.args.first} %} - {% end %} - {% elsif block.body.is_a? Expressions %} - {% for prop in block.body.expressions %} - {% if prop.is_a? Call %} - {% if (named_args = prop.named_args) && (type = named_args.select(&.name.== "as".id).first) %} - {% definitions << {var: prop.name, value: prop.args.first, type: type.value} %} - {% else %} - {% definitions << {var: prop.name, value: prop.args.first} %} - {% end %} - {% end %} - {% end %} - {% end %} - - {% properties = {} of MacroId => NamedTuple %} - {% for df in definitions %} - {% name = df[:var].id %} - {% key = name.camelcase.stringify %} - {% value = df[:value] %} - {% type = df[:type] %} - {% converter = nil %} - - {% if key == "Severity" %} - {% type = Severity %} - {% converter = SeverityYamlConverter %} - {% end %} - - {% unless type %} - {% if value.is_a? BoolLiteral %} - {% type = Bool %} - {% elsif value.is_a? StringLiteral %} - {% type = String %} - {% elsif value.is_a? NumberLiteral %} - {% if value.kind == :i32 %} - {% type = Int32 %} - {% elsif value.kind == :i64 %} - {% type = Int64 %} - {% elsif value.kind == :i128 %} - {% type = Int128 %} - {% elsif value.kind == :f32 %} - {% type = Float32 %} - {% elsif value.kind == :f64 %} - {% type = Float64 %} - {% end %} - {% end %} - {% end %} - - {% properties[name] = {key: key, default: value, type: type, converter: converter} %} - - @[YAML::Field(key: {{ key }}, converter: {{ converter }})] - {% if type == Bool %} - property? {{ name }}{{ " : #{type}".id if type }} = {{ value }} - {% else %} - property {{ name }}{{ " : #{type}".id if type }} = {{ value }} - {% end %} - {% end %} - - {% unless properties["enabled".id] %} - @[YAML::Field(key: "Enabled")] - property? enabled = true - {% end %} - - {% unless properties["severity".id] %} - @[YAML::Field(key: "Severity", converter: Ameba::SeverityYamlConverter)] - property severity = {{ @type }}.default_severity - {% end %} - - {% unless properties["excluded".id] %} - @[YAML::Field(key: "Excluded")] - property excluded : Array(String)? - {% end %} - end - - macro included - GROUP_SEVERITY = { - Documentation: Ameba::Severity::Warning, - Lint: Ameba::Severity::Warning, - Metrics: Ameba::Severity::Warning, - Performance: Ameba::Severity::Warning, - } - - class_getter default_severity : Ameba::Severity do - GROUP_SEVERITY[group_name]? || Ameba::Severity::Convention - end - - macro inherited - include YAML::Serializable - include YAML::Serializable::Strict - - def self.new(config = nil) - if (raw = config.try &.raw).is_a?(Hash) - yaml = raw[rule_name]?.try &.to_yaml - end - from_yaml yaml || "{}" - end - end - end - end -end diff --git a/lib/ameba/src/ameba/ext/location.cr b/lib/ameba/src/ameba/ext/location.cr deleted file mode 100644 index 88404b8d..00000000 --- a/lib/ameba/src/ameba/ext/location.cr +++ /dev/null @@ -1,35 +0,0 @@ -# Extensions to Crystal::Location -module Ameba::Ext::Location - # Returns the same location as this location but with the line and/or column number(s) changed - # to the given value(s). - def with(line_number = @line_number, column_number = @column_number) : self - self.class.new(@filename, line_number, column_number) - end - - # Returns the same location as this location but with the line and/or column number(s) adjusted - # by the given amount(s). - def adjust(line_number = 0, column_number = 0) : self - self.class.new(@filename, @line_number + line_number, @column_number + column_number) - end - - # Seeks to a given *offset* relative to `self`. - def seek(offset : self) : self - if offset.filename.as?(String).presence && @filename != offset.filename - raise ArgumentError.new <<-MSG - Mismatching filenames: - #{@filename} - #{offset.filename} - MSG - end - - if offset.line_number == 1 - self.class.new(@filename, @line_number, @column_number + offset.column_number - 1) - else - self.class.new(@filename, @line_number + offset.line_number - 1, offset.column_number) - end - end -end - -class Crystal::Location - include Ameba::Ext::Location -end diff --git a/lib/ameba/src/ameba/formatter/base_formatter.cr b/lib/ameba/src/ameba/formatter/base_formatter.cr deleted file mode 100644 index a6ceafdf..00000000 --- a/lib/ameba/src/ameba/formatter/base_formatter.cr +++ /dev/null @@ -1,32 +0,0 @@ -require "./util" - -# A module that utilizes Ameba's formatters. -module Ameba::Formatter - # A base formatter for all formatters. It uses `output` IO - # to report results and also implements stub methods for - # callbacks in `Ameba::Runner#run` method. - class BaseFormatter - # TODO: allow other IOs - getter output : IO::FileDescriptor | IO::Memory - getter config = {} of Symbol => String | Bool - - def initialize(@output = STDOUT) - end - - # Callback that indicates when inspecting is started. - # A list of sources to inspect is passed as an argument. - def started(sources); end - - # Callback that indicates when source inspection is started. - # A corresponding source is passed as an argument. - def source_started(source : Source); end - - # Callback that indicates when source inspection is finished. - # A corresponding source is passed as an argument. - def source_finished(source : Source); end - - # Callback that indicates when inspection is finished. - # A list of inspected sources is passed as an argument. - def finished(sources); end - end -end diff --git a/lib/ameba/src/ameba/formatter/disabled_formatter.cr b/lib/ameba/src/ameba/formatter/disabled_formatter.cr deleted file mode 100644 index cf777ee3..00000000 --- a/lib/ameba/src/ameba/formatter/disabled_formatter.cr +++ /dev/null @@ -1,17 +0,0 @@ -module Ameba::Formatter - # A formatter that shows all disabled lines by inline directives. - class DisabledFormatter < BaseFormatter - def finished(sources) - output << "Disabled rules using inline directives:\n\n" - - sources.each do |source| - source.issues.select(&.disabled?).each do |issue| - next unless loc = issue.location - - output << "#{source.path}:#{loc.line_number}".colorize(:cyan) - output << " #{issue.rule.name}\n" - end - end - end - end -end diff --git a/lib/ameba/src/ameba/formatter/dot_formatter.cr b/lib/ameba/src/ameba/formatter/dot_formatter.cr deleted file mode 100644 index 97982622..00000000 --- a/lib/ameba/src/ameba/formatter/dot_formatter.cr +++ /dev/null @@ -1,110 +0,0 @@ -require "./util" - -module Ameba::Formatter - # A formatter that shows a progress of inspection in a terminal using dots. - # It is similar to Crystal's dot formatter for specs. - class DotFormatter < BaseFormatter - include Util - - @started_at : Time::Span? - @mutex = Thread::Mutex.new - - # Reports a message when inspection is started. - def started(sources) - @started_at = Time.monotonic - - output.puts started_message(sources.size) - output.puts - end - - # Reports a result of the inspection of a corresponding source. - def source_finished(source : Source) - sym = source.valid? ? ".".colorize(:green) : "F".colorize(:red) - @mutex.synchronize { output << sym } - end - - # Reports a message when inspection is finished. - def finished(sources) - output.flush - output << "\n\n" - - show_affected_code = !config[:without_affected_code]? - failed_sources = sources.reject &.valid? - - failed_sources.each do |source| - source.issues.each do |issue| - next if issue.disabled? - next if (location = issue.location).nil? - - output.print location.colorize(:cyan) - if issue.correctable? - if config[:autocorrect]? - output.print " [Corrected]".colorize(:green) - else - output.print " [Correctable]".colorize(:yellow) - end - end - output.puts - output.puts ("[%s] %s: %s" % { - issue.rule.severity.symbol, - issue.rule.name, - issue.message, - }).colorize(issue.rule.severity.color) - - if show_affected_code && (code = affected_code(issue)) - output << code.colorize(:default) - end - - output.puts - end - end - - output.puts finished_in_message(@started_at, Time.monotonic) - output.puts final_message(sources, failed_sources) - end - - private def started_message(size) - if size == 1 - "Inspecting 1 file".colorize(:default) - else - "Inspecting #{size} files".colorize(:default) - end - end - - private def finished_in_message(started, finished) - return unless started && finished - - "Finished in #{to_human(finished - started)}".colorize(:default) - end - - private def to_human(span : Time::Span) - total_milliseconds = span.total_milliseconds - if total_milliseconds < 1 - return "#{(span.total_milliseconds * 1_000).round.to_i} microseconds" - end - - total_seconds = span.total_seconds - if total_seconds < 1 - return "#{span.total_milliseconds.round(2)} milliseconds" - end - - if total_seconds < 60 - return "#{total_seconds.round(2)} seconds" - end - - minutes = span.minutes - seconds = span.seconds - - "#{minutes}:#{seconds < 10 ? "0" : ""}#{seconds} minutes" - end - - private def final_message(sources, failed_sources) - total = sources.size - failures = failed_sources.sum(&.issues.size) - color = failures == 0 ? :green : :red - s = failures != 1 ? "s" : "" - - "#{total} inspected, #{failures} failure#{s}".colorize(color) - end - end -end diff --git a/lib/ameba/src/ameba/formatter/explain_formatter.cr b/lib/ameba/src/ameba/formatter/explain_formatter.cr deleted file mode 100644 index e963d98f..00000000 --- a/lib/ameba/src/ameba/formatter/explain_formatter.cr +++ /dev/null @@ -1,100 +0,0 @@ -require "./util" - -module Ameba::Formatter - # A formatter that shows the detailed explanation of the issue at - # a specific location. - class ExplainFormatter - include Util - - getter output : IO::FileDescriptor | IO::Memory - getter location : Crystal::Location - - # Creates a new instance of `ExplainFormatter`. - # - # Accepts *output* which indicates the io where the explanation will be written to. - # Second argument is *location* which indicates the location to explain. - # - # ``` - # ExplainFormatter.new output, { - # file: path, - # line: line_number, - # column: column_number, - # } - # ``` - def initialize(@output, location) - @location = Crystal::Location.new( - location[:file], - location[:line], - location[:column] - ) - end - - # Reports the explanations at the *@location*. - def finished(sources) - source = sources.find(&.path.==(@location.filename)) - return unless source - - issue = source.issues.find(&.location.==(@location)) - return unless issue - - explain(source, issue) - end - - private def explain(source, issue) - return unless location = issue.location - - output << '\n' - output_title "Issue info" - output_paragraph [ - issue.message.colorize(:red), - location.to_s.colorize(:cyan), - ] - - if affected_code = affected_code(issue, context_lines: 3) - output_title "Affected code" - output_paragraph affected_code - end - - rule = issue.rule - - output_title "Rule info" - output_paragraph "%s of a %s severity" % { - rule.name.colorize(:magenta), - rule.severity.to_s.colorize(rule.severity.color), - } - if rule_description = colorize_code_fences(rule.description) - output_paragraph rule_description - end - - rule_doc = colorize_code_fences(rule.class.parsed_doc) - return unless rule_doc - - output_title "Detailed description" - output_paragraph rule_doc - end - - private def colorize_code_fences(string) - return unless string - string - .gsub(/```(.+?)```/m, &.colorize(:dark_gray)) - .gsub(/`(?!`)(.+?)`/, &.colorize(:dark_gray)) - end - - private def output_title(title) - output << "### ".colorize(:yellow) - output << title.upcase.colorize(:yellow) - output << "\n\n" - end - - private def output_paragraph(paragraph : String) - output_paragraph(paragraph.lines) - end - - private def output_paragraph(paragraph : Array) - paragraph.each do |line| - output << " " << line << '\n' - end - output << '\n' - end - end -end diff --git a/lib/ameba/src/ameba/formatter/flycheck_formatter.cr b/lib/ameba/src/ameba/formatter/flycheck_formatter.cr deleted file mode 100644 index 491567e4..00000000 --- a/lib/ameba/src/ameba/formatter/flycheck_formatter.cr +++ /dev/null @@ -1,20 +0,0 @@ -module Ameba::Formatter - class FlycheckFormatter < BaseFormatter - @mutex = Mutex.new - - def source_finished(source : Source) - source.issues.each do |issue| - next if issue.disabled? - next if issue.correctable? && config[:autocorrect]? - - next unless loc = issue.location - - @mutex.synchronize do - output.printf "%s:%d:%d: %s: [%s] %s\n", - source.path, loc.line_number, loc.column_number, issue.rule.severity.symbol, - issue.rule.name, issue.message.gsub('\n', " ") - end - end - end - end -end diff --git a/lib/ameba/src/ameba/formatter/json_formatter.cr b/lib/ameba/src/ameba/formatter/json_formatter.cr deleted file mode 100644 index 6cd3a328..00000000 --- a/lib/ameba/src/ameba/formatter/json_formatter.cr +++ /dev/null @@ -1,170 +0,0 @@ -require "json" - -module Ameba::Formatter - # A formatter that produces the result in a json format. - # - # Example: - # - # ``` - # { - # "metadata": { - # "ameba_version": "x.x.x", - # "crystal_version": "x.x.x", - # }, - # "sources": [ - # { - # "issues": [ - # { - # "location": { - # "column": 7, - # "line": 17, - # }, - # "end_location": { - # "column": 20, - # "line": 17, - # }, - # "message": "Useless assignment to variable `a`", - # "rule_name": "UselessAssign", - # "severity": "Convention", - # }, - # { - # "location": { - # "column": 7, - # "line": 18, - # }, - # "end_location": { - # "column": 8, - # "line": 18, - # }, - # "message": "Useless assignment to variable `a`", - # "rule_name": "UselessAssign", - # }, - # { - # "location": { - # "column": 7, - # "line": 19, - # }, - # "end_location": { - # "column": 9, - # "line": 19, - # }, - # "message": "Useless assignment to variable `a`", - # "rule_name": "UselessAssign", - # "severity": "Convention", - # }, - # ], - # "path": "src/ameba/formatter/json_formatter.cr", - # }, - # ], - # "summary": { - # "issues_count": 3, - # "target_sources_count": 1, - # }, - # } - # ``` - class JSONFormatter < BaseFormatter - def initialize(@output = STDOUT) - @result = AsJSON::Result.new - end - - def started(sources) - @result.summary.target_sources_count = sources.size - end - - def source_finished(source : Source) - json_source = AsJSON::Source.new source.path - - source.issues.each do |issue| - next if issue.disabled? - next if issue.correctable? && config[:autocorrect]? - - json_source.issues << AsJSON::Issue.new( - issue.rule.name, - issue.rule.severity.to_s, - issue.location, - issue.end_location, - issue.message - ) - @result.summary.issues_count += 1 - end - - @result.sources << json_source - end - - def finished(sources) - @result.to_json @output - end - end - - private module AsJSON - record Result, - sources = [] of Source, - metadata = Metadata.new, - summary = Summary.new do - def to_json(json) - { - sources: sources, - metadata: metadata, - summary: summary, - }.to_json(json) - end - end - - record Source, - path : String, - issues = [] of Issue do - def to_json(json) - { - path: path, - issues: issues, - }.to_json(json) - end - end - - record Issue, - rule_name : String, - severity : String, - location : Crystal::Location?, - end_location : Crystal::Location?, - message : String do - def to_json(json) - { - rule_name: rule_name, - severity: severity, - message: message, - location: { - line: location.try &.line_number, - column: location.try &.column_number, - }, - end_location: { - line: end_location.try &.line_number, - column: end_location.try &.column_number, - }, - }.to_json(json) - end - end - - record Metadata, - ameba_version : String = Ameba::VERSION, - crystal_version : String = Crystal::VERSION do - def to_json(json) - { - ameba_version: ameba_version, - crystal_version: crystal_version, - }.to_json(json) - end - end - - class Summary - property target_sources_count = 0 - property issues_count = 0 - - def to_json(json) - { - target_sources_count: target_sources_count, - issues_count: issues_count, - }.to_json(json) - end - end - end -end diff --git a/lib/ameba/src/ameba/formatter/todo_formatter.cr b/lib/ameba/src/ameba/formatter/todo_formatter.cr deleted file mode 100644 index 00a2d28f..00000000 --- a/lib/ameba/src/ameba/formatter/todo_formatter.cr +++ /dev/null @@ -1,75 +0,0 @@ -module Ameba::Formatter - # A formatter that creates a todo config. - # Basically, it takes all issues reported and disables corresponding rules - # or excludes failed sources from these rules. - class TODOFormatter < DotFormatter - def initialize(@output = STDOUT, @config_path : Path = Config::DEFAULT_PATH) - end - - def finished(sources) - super - - issues = sources.flat_map(&.issues) - unless issues.any? { |issue| !issue.disabled? } - @output.puts "No issues found. File is not generated." - return - end - - if issues.any?(&.syntax?) - @output.puts "Unable to generate TODO file. Please fix syntax issues." - return - end - - generate_todo_config(issues).tap do |file| - @output.puts "Created #{file.path}" - end - end - - private def generate_todo_config(issues) - File.open(@config_path, mode: "w") do |file| - file << header - - rule_issues_map(issues).each do |rule, rule_issues| - rule_todo = rule_todo(rule, rule_issues) - rule_todo = - {rule_todo.name => rule_todo} - .to_yaml.gsub("---", "") - - file << "\n# Problems found: #{rule_issues.size}" - file << "\n# Run `ameba --only #{rule.name}` for details" - file << rule_todo - end - file - end - end - - private def rule_issues_map(issues) - Hash(Rule::Base, Array(Issue)).new.tap do |hash| - issues.each do |issue| - next if issue.disabled? || issue.rule.is_a?(Rule::Lint::Syntax) - next if issue.correctable? && config[:autocorrect]? - - (hash[issue.rule] ||= Array(Issue).new) << issue - end - end - end - - private def header - <<-HEADER - # This configuration file was generated by `ameba --gen-config` - # on #{Time.utc} using Ameba version #{VERSION}. - # The point is for the user to remove these configuration records - # one by one as the reported problems are removed from the code base. - - HEADER - end - - private def rule_todo(rule, issues) - rule.dup.tap do |rule_todo| - rule_todo.excluded = issues - .compact_map(&.location.try &.filename.try &.to_s) - .uniq! - end - end - end -end diff --git a/lib/ameba/src/ameba/formatter/util.cr b/lib/ameba/src/ameba/formatter/util.cr deleted file mode 100644 index da8554a0..00000000 --- a/lib/ameba/src/ameba/formatter/util.cr +++ /dev/null @@ -1,118 +0,0 @@ -module Ameba::Formatter - module Util - extend self - - def deansify(message : String?) : String? - message.try &.gsub(/\x1b[^m]*m/, "").presence - end - - def trim(str, max_length = 120, ellipsis = " ...") - if (str.size - ellipsis.size) > max_length - str = str[0, max_length] - if str.size > ellipsis.size - str = str[0...-ellipsis.size] + ellipsis - end - end - str - end - - def context(lines, lineno, context_lines = 3, remove_empty = true) - pre_context, post_context = %w[], %w[] - - lines.each_with_index do |line, i| - case i + 1 - when lineno - context_lines...lineno - pre_context << line - when lineno + 1..lineno + context_lines - post_context << line - end - end - - if remove_empty - # remove empty lines at the beginning ... - while pre_context.first?.try(&.blank?) - pre_context.shift - end - # ... and the end - while post_context.last?.try(&.blank?) - post_context.pop - end - end - - {pre_context, post_context} - end - - def affected_code(issue : Issue, context_lines = 0, max_length = 120, ellipsis = " ...", prompt = "> ") - return unless location = issue.location - - affected_code(issue.code, location, issue.end_location, context_lines, max_length, ellipsis, prompt) - end - - def affected_code(code, location, end_location = nil, context_lines = 0, max_length = 120, ellipsis = " ...", prompt = "> ") - lines = code.split('\n') # must preserve trailing newline - lineno, column = - location.line_number, location.column_number - - return unless affected_line = lines[lineno - 1]?.presence - - if column < max_length - affected_line = trim(affected_line, max_length, ellipsis) - end - - show_context = context_lines > 0 - - if show_context - pre_context, post_context = - context(lines, lineno, context_lines) - - position = prompt.size + column - position -= 1 - else - affected_line_size, affected_line = - affected_line.size, affected_line.lstrip - - position = column - (affected_line_size - affected_line.size) + prompt.size - position -= 1 - end - - String.build do |str| - if show_context - pre_context.try &.each do |line| - line = trim(line, max_length, ellipsis) - str << prompt - str.puts(line.colorize(:dark_gray)) - end - end - - str << prompt - str.puts(affected_line.colorize(:white)) - - str << (" " * position) - str << "^".colorize(:yellow) - - if end_location - end_lineno = end_location.line_number - end_column = end_location.column_number - - if end_lineno == lineno && end_column > column - end_position = end_column - column - end_position -= 1 - - str << ("-" * end_position).colorize(:dark_gray) - str << "^".colorize(:yellow) - end - end - - str.puts - - if show_context - post_context.try &.each do |line| - line = trim(line, max_length, ellipsis) - str << prompt - str.puts(line.colorize(:dark_gray)) - end - end - end - end - end -end diff --git a/lib/ameba/src/ameba/glob_utils.cr b/lib/ameba/src/ameba/glob_utils.cr deleted file mode 100644 index 71cd0cb5..00000000 --- a/lib/ameba/src/ameba/glob_utils.cr +++ /dev/null @@ -1,35 +0,0 @@ -module Ameba - # Helper module that is utilizes helpers for working with globs. - module GlobUtils - # Returns all files that match specified globs. - # Globs can have wildcards or be rejected: - # - # ``` - # find_files_by_globs(["**/*.cr", "!lib"]) - # ``` - def find_files_by_globs(globs) - rejected = rejected_globs(globs) - selected = globs - rejected - - expand(selected) - expand(rejected.map!(&.[1..-1])) - end - - # Expands globs. Globs can point to files or even directories. - # - # ``` - # expand(["spec/*.cr", "src"]) # => all files in src folder + first level specs - # ``` - def expand(globs) - globs.flat_map do |glob| - glob += "/**/*.cr" if File.directory?(glob) - Dir[glob] - end.uniq! - end - - private def rejected_globs(globs) - globs.select do |glob| - glob.starts_with?('!') && !File.exists?(glob) - end - end - end -end diff --git a/lib/ameba/src/ameba/inline_comments.cr b/lib/ameba/src/ameba/inline_comments.cr deleted file mode 100644 index 3dce0890..00000000 --- a/lib/ameba/src/ameba/inline_comments.cr +++ /dev/null @@ -1,105 +0,0 @@ -module Ameba - # A module that utilizes inline comments parsing and processing logic. - module InlineComments - COMMENT_DIRECTIVE_REGEX = - /# ameba:(?\w+) (?\w+(?:\/\w+)?(?:,? \w+(?:\/\w+)?)*)/ - - # Available actions in the inline comments - enum Action - Disable - Enable - end - - # Returns `true` if current location is disabled for a particular rule, - # `false` otherwise. - # - # Location is disabled in two cases: - # 1. The line of the location ends with a comment directive. - # 2. The line above the location is a comment directive. - # - # For example, here are two examples of disabled location: - # - # ``` - # # ameba:disable Style/LargeNumbers - # Time.epoch(1483859302) - # - # Time.epoch(1483859302) # ameba:disable Style/LargeNumbers - # ``` - # - # But here are examples which are not considered as disabled location: - # - # ``` - # # ameba:disable Style/LargeNumbers - # # - # Time.epoch(1483859302) - # - # if use_epoch? # ameba:disable Style/LargeNumbers - # Time.epoch(1483859302) - # end - # ``` - def location_disabled?(location : Crystal::Location?, rule) - return false if rule.name.in?(Rule::SPECIAL) - return false unless line_number = location.try &.line_number.try &.- 1 - return false unless line = lines[line_number]? - - line_disabled?(line, rule) || - (line_number > 0 && - (prev_line = lines[line_number - 1]) && - comment?(prev_line) && - line_disabled?(prev_line, rule)) - end - - # Parses inline comment directive. Returns a tuple that consists of - # an action and parsed rules if directive found, nil otherwise. - # - # ``` - # line = "# ameba:disable Rule1, Rule2" - # directive = parse_inline_directive(line) - # directive[:action] # => "disable" - # directive[:rules] # => ["Rule1", "Rule2"] - # ``` - # - # It ignores the directive if it is commented out. - # - # ``` - # line = "# # ameba:disable Rule1, Rule2" - # parse_inline_directive(line) # => nil - # ``` - def parse_inline_directive(line) - return unless directive = COMMENT_DIRECTIVE_REGEX.match(line) - return if commented_out?(line.gsub(directive[0], "")) - { - action: directive["action"], - rules: directive["rules"].split(/[\s,]/, remove_empty: true), - } - end - - # Returns `true` if the line at the given `line_number` is a comment. - def comment?(line_number : Int32) - return unless line = lines[line_number]? - comment?(line) - end - - private def comment?(line : String) - line.lstrip.starts_with? '#' - end - - private def line_disabled?(line, rule) - return false unless directive = parse_inline_directive(line) - return false unless Action.parse?(directive[:action]).try(&.disable?) - - rules = directive[:rules] - rules.includes?(rule.name) || rules.includes?(rule.group) - end - - private def commented_out?(line) - commented = false - - lexer = Crystal::Lexer.new(line).tap(&.comments_enabled = true) - Tokenizer.new(lexer).run do |token| - commented = true if token.type.comment? - end - commented - end - end -end diff --git a/lib/ameba/src/ameba/issue.cr b/lib/ameba/src/ameba/issue.cr deleted file mode 100644 index b6fa55e8..00000000 --- a/lib/ameba/src/ameba/issue.cr +++ /dev/null @@ -1,46 +0,0 @@ -module Ameba - # Represents an issue reported by Ameba. - struct Issue - enum Status - Enabled - Disabled - end - - # The source code that triggered this issue. - getter code : String - - # A rule that triggers this issue. - getter rule : Rule::Base - - # Location of the issue. - getter location : Crystal::Location? - - # End location of the issue. - getter end_location : Crystal::Location? - - # Issue message. - getter message : String - - # Issue status. - getter status : Status - - delegate :enabled?, :disabled?, - to: status - - def initialize(@code, @rule, @location, @end_location, @message, status : Status? = nil, @block : (Source::Corrector ->)? = nil) - @status = status || Status::Enabled - end - - def syntax? - rule.is_a?(Rule::Lint::Syntax) - end - - def correctable? - !@block.nil? - end - - def correct(corrector) - @block.try &.call(corrector) - end - end -end diff --git a/lib/ameba/src/ameba/presenter/base_presenter.cr b/lib/ameba/src/ameba/presenter/base_presenter.cr deleted file mode 100644 index 73f9ad00..00000000 --- a/lib/ameba/src/ameba/presenter/base_presenter.cr +++ /dev/null @@ -1,12 +0,0 @@ -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 diff --git a/lib/ameba/src/ameba/presenter/rule_collection_presenter.cr b/lib/ameba/src/ameba/presenter/rule_collection_presenter.cr deleted file mode 100644 index e833df11..00000000 --- a/lib/ameba/src/ameba/presenter/rule_collection_presenter.cr +++ /dev/null @@ -1,34 +0,0 @@ -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 diff --git a/lib/ameba/src/ameba/presenter/rule_presenter.cr b/lib/ameba/src/ameba/presenter/rule_presenter.cr deleted file mode 100644 index a790ac4b..00000000 --- a/lib/ameba/src/ameba/presenter/rule_presenter.cr +++ /dev/null @@ -1,43 +0,0 @@ -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 diff --git a/lib/ameba/src/ameba/reportable.cr b/lib/ameba/src/ameba/reportable.cr deleted file mode 100644 index b60f9709..00000000 --- a/lib/ameba/src/ameba/reportable.cr +++ /dev/null @@ -1,105 +0,0 @@ -require "./ast/util" - -module Ameba - # Represents a module used to report issues. - module Reportable - include AST::Util - - # List of reported issues. - getter issues = [] of Issue - - # Adds a new issue to the list of issues. - def add_issue(rule, - location : Crystal::Location?, - end_location : Crystal::Location?, - message : String, - status : Issue::Status? = nil, - block : (Source::Corrector ->)? = nil) : Issue - status ||= - Issue::Status::Disabled if location_disabled?(location, rule) - - Issue.new(code, rule, location, end_location, message, status, block).tap do |issue| - issues << issue - end - end - - # :ditto: - def add_issue(rule, - location : Crystal::Location?, - end_location : Crystal::Location?, - message : String, - status : Issue::Status? = nil, - &block : Source::Corrector ->) : Issue - add_issue rule, location, end_location, message, status, block - end - - # Adds a new issue for Crystal AST *node*. - def add_issue(rule, node : Crystal::ASTNode, message, status : Issue::Status? = nil, block : (Source::Corrector ->)? = nil, *, prefer_name_location = false) : Issue - location = name_location(node) if prefer_name_location - location ||= node.location - - end_location = name_end_location(node) if prefer_name_location - end_location ||= node.end_location - - add_issue rule, location, end_location, message, status, block - end - - # :ditto: - def add_issue(rule, node : Crystal::ASTNode, message, status : Issue::Status? = nil, *, prefer_name_location = false, &block : Source::Corrector ->) : Issue - add_issue rule, node, message, status, block, prefer_name_location: prefer_name_location - end - - # Adds a new issue for Crystal *token*. - def add_issue(rule, token : Crystal::Token, message, status : Issue::Status? = nil, block : (Source::Corrector ->)? = nil) : Issue - add_issue rule, token.location, nil, message, status, block - end - - # :ditto: - def add_issue(rule, token : Crystal::Token, message, status : Issue::Status? = nil, &block : Source::Corrector ->) : Issue - add_issue rule, token, message, status, block - end - - # Adds a new issue for *location* defined by line and column numbers. - def add_issue(rule, location : {Int32, Int32}, message, status : Issue::Status? = nil, block : (Source::Corrector ->)? = nil) : Issue - location = - Crystal::Location.new(path, *location) - - add_issue rule, location, nil, message, status, block - end - - # :ditto: - def add_issue(rule, location : {Int32, Int32}, message, status : Issue::Status? = nil, &block : Source::Corrector ->) : Issue - add_issue rule, location, message, status, block - end - - # Adds a new issue for *location* and *end_location* defined by line and column numbers. - def add_issue(rule, - location : {Int32, Int32}, - end_location : {Int32, Int32}, - message, - status : Issue::Status? = nil, - block : (Source::Corrector ->)? = nil) : Issue - location = - Crystal::Location.new(path, *location) - end_location = - Crystal::Location.new(path, *end_location) - - add_issue rule, location, end_location, message, status, block - end - - # :ditto: - def add_issue(rule, - location : {Int32, Int32}, - end_location : {Int32, Int32}, - message, - status : Issue::Status? = nil, - &block : Source::Corrector ->) : Issue - add_issue rule, location, end_location, message, status, block - end - - # Returns `true` if the list of not disabled issues is empty, `false` otherwise. - def valid? - issues.none?(&.enabled?) - end - end -end diff --git a/lib/ameba/src/ameba/rule/base.cr b/lib/ameba/src/ameba/rule/base.cr deleted file mode 100644 index 242c86f0..00000000 --- a/lib/ameba/src/ameba/rule/base.cr +++ /dev/null @@ -1,177 +0,0 @@ -module Ameba::Rule - # List of names of the special rules, which - # behave differently than usual rules. - SPECIAL = { - Lint::Syntax.rule_name, - Lint::UnneededDisableDirective.rule_name, - } - - # Represents a base of all rules. In other words, all rules - # inherits from this struct: - # - # ``` - # class MyRule < Ameba::Rule::Base - # def test(source) - # if invalid?(source) - # issue_for line, column, "Something wrong." - # end - # end - # - # private def invalid?(source) - # # ... - # end - # end - # ``` - # - # Enforces rules to implement an abstract `#test` method which - # is designed to test the source passed in. If source has issues - # that are tested by this rule, it should add an issue. - abstract class Base - include Config::RuleConfig - - # This method is designed to test the source passed in. If source has issues - # that are tested by this rule, it should add an issue. - # - # By default it uses a node visitor to traverse all the nodes in the source. - # - # NOTE: Must be overridden for other type of rules. - def test(source : Source) - AST::NodeVisitor.new self, source - end - - # NOTE: Can't be abstract - def test(source : Source, node : Crystal::ASTNode, *opts) - end - - # A convenient addition to `#test` method that does the same - # but returns a passed in `source` as an addition. - # - # ``` - # source = MyRule.new.catch(source) - # source.valid? - # ``` - def catch(source : Source) - source.tap { test source } - end - - # Returns a name of this rule, which is basically a class name. - # - # ``` - # class MyRule < Ameba::Rule::Base - # def test(source) - # end - # end - # - # MyRule.new.name # => "MyRule" - # ``` - def name - {{ @type }}.rule_name - end - - # Returns a group this rule belong to. - # - # ``` - # class MyGroup::MyRule < Ameba::Rule::Base - # # ... - # end - # - # MyGroup::MyRule.new.group # => "MyGroup" - # ``` - def group - {{ @type }}.group_name - end - - # Checks whether the source is excluded from this rule. - # It searches for a path in `excluded` property which matches - # the one of the given source. - # - # ``` - # my_rule.excluded?(source) # => true or false - # ``` - def excluded?(source) - !!excluded.try &.any? do |path| - source.matches_path?(path) || - Dir.glob(path).any? { |glob| source.matches_path?(glob) } - end - end - - # Returns `true` if this rule is special and behaves differently than - # usual rules. - # - # ``` - # my_rule.special? # => true or false - # ``` - def special? - name.in?(SPECIAL) - end - - def ==(other) - name == other.try(&.name) - end - - def hash - name.hash - end - - # Adds an issue to the *source* - macro issue_for(*args, **kwargs, &block) - source.add_issue(self, {{ args.splat }}, {{ kwargs.double_splat }}) {{ block }} - end - - protected def self.rule_name - name.gsub("Ameba::Rule::", "").gsub("::", '/') - end - - protected def self.group_name - rule_name.split('/')[0...-1].join('/') - end - - protected def self.subclasses - {{ @type.subclasses }} - end - - protected def self.abstract? - {{ @type.abstract? }} - end - - protected def self.inherited_rules - subclasses.each_with_object([] of Base.class) do |klass, obj| - klass.abstract? ? obj.concat(klass.inherited_rules) : (obj << klass) - end - end - - private macro read_type_doc(filepath = __FILE__) - {{ run("../../contrib/read_type_doc", - @type.name.split("::").last, - filepath - ).chomp.stringify }}.presence - end - - macro inherited - # Returns documentation for this rule, if there is any. - # - # ``` - # module Ameba - # # This is a test rule. - # # Does nothing. - # class MyRule < Ameba::Rule::Base - # def test(source) - # end - # end - # end - # - # MyRule.parsed_doc # => "This is a test rule.\nDoes nothing." - # ``` - class_getter parsed_doc : String? = read_type_doc - end - end - - # Returns a list of all available rules. - # - # ``` - # Ameba::Rule.rules # => [Rule1, Rule2, ....] - # ``` - def self.rules - Base.inherited_rules - end -end diff --git a/lib/ameba/src/ameba/rule/documentation/documentation.cr b/lib/ameba/src/ameba/rule/documentation/documentation.cr deleted file mode 100644 index 45cbfc36..00000000 --- a/lib/ameba/src/ameba/rule/documentation/documentation.cr +++ /dev/null @@ -1,80 +0,0 @@ -module Ameba::Rule::Documentation - # A rule that enforces documentation for public types: - # modules, classes, enums, methods and macros. - # - # YAML configuration example: - # - # ``` - # Documentation/Documentation: - # Enabled: true - # IgnoreClasses: false - # IgnoreModules: true - # IgnoreEnums: false - # IgnoreDefs: true - # IgnoreMacros: false - # IgnoreMacroHooks: true - # ``` - class Documentation < Base - properties do - enabled false - description "Enforces public types to be documented" - - ignore_classes false - ignore_modules true - ignore_enums false - ignore_defs true - ignore_macros false - ignore_macro_hooks true - end - - MSG = "Missing documentation" - - MACRO_HOOK_NAMES = %w[ - inherited - included extended - method_missing method_added - finished - ] - - def test(source) - AST::ScopeVisitor.new self, source - end - - def test(source, node : Crystal::ClassDef, scope : AST::Scope) - ignore_classes? || check_missing_doc(source, node, scope) - end - - def test(source, node : Crystal::ModuleDef, scope : AST::Scope) - ignore_modules? || check_missing_doc(source, node, scope) - end - - def test(source, node : Crystal::EnumDef, scope : AST::Scope) - ignore_enums? || check_missing_doc(source, node, scope) - end - - def test(source, node : Crystal::Def, scope : AST::Scope) - ignore_defs? || check_missing_doc(source, node, scope) - end - - def test(source, node : Crystal::Macro, scope : AST::Scope) - return if ignore_macro_hooks? && node.name.in?(MACRO_HOOK_NAMES) - - ignore_macros? || check_missing_doc(source, node, scope) - end - - private def check_missing_doc(source, node, scope) - # bail out if the node is not public, - # i.e. `private def foo` - return if !node.visibility.public? - - # bail out if the scope is not public, - # i.e. `def bar` inside `private class Foo` - return if (visibility = scope.visibility) && !visibility.public? - - # bail out if the node has the documentation present - return if node.doc.presence - - issue_for(node, MSG) - end - end -end diff --git a/lib/ameba/src/ameba/rule/documentation/documentation_admonition.cr b/lib/ameba/src/ameba/rule/documentation/documentation_admonition.cr deleted file mode 100644 index 75547750..00000000 --- a/lib/ameba/src/ameba/rule/documentation/documentation_admonition.cr +++ /dev/null @@ -1,96 +0,0 @@ -module Ameba::Rule::Documentation - # 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: - # - # ``` - # Documentation/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*(?#{Regex.union(admonitions)})(?:\((?.+?)\))?(?:\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 diff --git a/lib/ameba/src/ameba/rule/layout/line_length.cr b/lib/ameba/src/ameba/rule/layout/line_length.cr deleted file mode 100644 index 41eb76fa..00000000 --- a/lib/ameba/src/ameba/rule/layout/line_length.cr +++ /dev/null @@ -1,26 +0,0 @@ -module Ameba::Rule::Layout - # A rule that disallows lines longer than `max_length` number of symbols. - # - # YAML configuration example: - # - # ``` - # Layout/LineLength: - # Enabled: true - # MaxLength: 100 - # ``` - class LineLength < Base - properties do - enabled false - description "Disallows lines longer than `MaxLength` number of symbols" - max_length 140 - end - - MSG = "Line too long" - - def test(source) - source.lines.each_with_index do |line, index| - issue_for({index + 1, max_length + 1}, MSG) if line.size > max_length - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/layout/trailing_blank_lines.cr b/lib/ameba/src/ameba/rule/layout/trailing_blank_lines.cr deleted file mode 100644 index dbfa3aa6..00000000 --- a/lib/ameba/src/ameba/rule/layout/trailing_blank_lines.cr +++ /dev/null @@ -1,39 +0,0 @@ -module Ameba::Rule::Layout - # A rule that disallows trailing blank lines at the end of the source file. - # - # YAML configuration example: - # - # ``` - # Layout/TrailingBlankLines: - # Enabled: true - # ``` - class TrailingBlankLines < Base - properties do - description "Disallows trailing blank lines" - end - - MSG = "Excessive trailing newline detected" - MSG_FINAL_NEWLINE = "Trailing newline missing" - - def test(source) - source_lines = source.lines - return if source_lines.empty? - - last_source_line = source_lines.last - source_lines_size = source_lines.size - return if source_lines_size == 1 && last_source_line.empty? - - last_line_empty = last_source_line.empty? - return if source_lines_size.zero? || - (source_lines.last(2).join.presence && last_line_empty) - - if last_line_empty - issue_for({source_lines_size, 1}, MSG) - else - issue_for({source_lines_size, 1}, MSG_FINAL_NEWLINE) do |corrector| - corrector.insert_before({source_lines_size + 1, 1}, '\n') - end - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/layout/trailing_whitespace.cr b/lib/ameba/src/ameba/rule/layout/trailing_whitespace.cr deleted file mode 100644 index 48561a96..00000000 --- a/lib/ameba/src/ameba/rule/layout/trailing_whitespace.cr +++ /dev/null @@ -1,30 +0,0 @@ -module Ameba::Rule::Layout - # A rule that disallows trailing whitespace. - # - # YAML configuration example: - # - # ``` - # Layout/TrailingWhitespace: - # Enabled: true - # ``` - class TrailingWhitespace < Base - properties do - description "Disallows trailing whitespace" - end - - MSG = "Trailing whitespace detected" - - def test(source) - source.lines.each_with_index do |line, index| - next unless ws_index = line =~ /\s+$/ - - location = {index + 1, ws_index + 1} - end_location = {index + 1, line.size} - - issue_for location, end_location, MSG do |corrector| - corrector.remove(location, end_location) - end - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/ambiguous_assignment.cr b/lib/ameba/src/ameba/rule/lint/ambiguous_assignment.cr deleted file mode 100644 index f0f1bd7a..00000000 --- a/lib/ameba/src/ameba/rule/lint/ambiguous_assignment.cr +++ /dev/null @@ -1,57 +0,0 @@ -module Ameba::Rule::Lint - # This rule checks for mistyped shorthand assignments. - # - # This is considered invalid: - # - # ``` - # x = -y - # x = +y - # x = !y - # ``` - # - # And this is valid: - # - # ``` - # x -= y # or x = -y - # x += y # or x = +y - # x != y # or x = !y - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/AmbiguousAssignment: - # Enabled: true - # ``` - class AmbiguousAssignment < Base - include AST::Util - - properties do - description "Disallows ambiguous `=-/=+/=!`" - end - - MSG = "Suspicious assignment detected. Did you mean `%s`?" - - MISTAKES = { - "=-": "-=", - "=+": "+=", - "=!": "!=", - } - - def test(source, node : Crystal::Assign) - return unless op_end_location = node.value.location - - op_location = Crystal::Location.new( - op_end_location.filename, - op_end_location.line_number, - op_end_location.column_number - 1 - ) - op_text = source_between(op_location, op_end_location, source.lines) - - return unless op_text - return unless suggestion = MISTAKES[op_text]? - - issue_for op_location, op_end_location, MSG % suggestion - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/bad_directive.cr b/lib/ameba/src/ameba/rule/lint/bad_directive.cr deleted file mode 100644 index e0ff30f4..00000000 --- a/lib/ameba/src/ameba/rule/lint/bad_directive.cr +++ /dev/null @@ -1,55 +0,0 @@ -module Ameba::Rule::Lint - # A rule that reports incorrect comment directives for Ameba. - # - # For example, the user can mistakenly add a directive - # to disable a rule that even doesn't exist: - # - # ``` - # # ameba:disable BadRuleName - # def foo - # :bar - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/BadDirective: - # Enabled: true - # ``` - class BadDirective < Base - properties do - description "Reports bad comment directives" - end - - AVAILABLE_ACTIONS = InlineComments::Action.names.map(&.downcase) - ALL_RULE_NAMES = Rule.rules.map(&.rule_name) - ALL_GROUP_NAMES = Rule.rules.map(&.group_name).uniq! - - def test(source) - Tokenizer.new(source).run do |token| - next unless token.type.comment? - next unless directive = source.parse_inline_directive(token.value.to_s) - - check_action source, token, directive[:action] - check_rules source, token, directive[:rules] - end - end - - private def check_action(source, token, action) - return if InlineComments::Action.parse?(action) - - issue_for token, - "Bad action in comment directive: '%s'. Possible values: %s" % { - action, AVAILABLE_ACTIONS.join(", "), - } - end - - private def check_rules(source, token, rules) - bad_names = rules - ALL_RULE_NAMES - ALL_GROUP_NAMES - return if bad_names.empty? - - issue_for token, "Such rules do not exist: %s" % bad_names.join(", ") - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/comparison_to_boolean.cr b/lib/ameba/src/ameba/rule/lint/comparison_to_boolean.cr deleted file mode 100644 index abd4efc7..00000000 --- a/lib/ameba/src/ameba/rule/lint/comparison_to_boolean.cr +++ /dev/null @@ -1,61 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows comparison to booleans. - # - # For example, these are considered invalid: - # - # ``` - # foo == true - # bar != false - # false === baz - # ``` - # - # This is because these expressions evaluate to `true` or `false`, so you - # could get the same result by using either the variable directly, - # or negating the variable. - # - # YAML configuration example: - # - # ``` - # Lint/ComparisonToBoolean: - # Enabled: true - # ``` - class ComparisonToBoolean < Base - include AST::Util - - properties do - enabled false - description "Disallows comparison to booleans" - end - - MSG = "Comparison to a boolean is pointless" - OP_NAMES = %w[== != ===] - - def test(source, node : Crystal::Call) - return unless node.name.in?(OP_NAMES) - return unless node.args.size == 1 - - arg, obj = node.args.first, node.obj - case - when arg.is_a?(Crystal::BoolLiteral) - bool, exp = arg, obj - when obj.is_a?(Crystal::BoolLiteral) - bool, exp = obj, arg - end - - return unless bool && exp - return unless exp_code = node_source(exp, source.lines) - - not = - case node.name - when "==", "===" then !bool.value # foo == false - when "!=" then bool.value # foo != true - end - - exp_code = "!#{exp_code}" if not - - issue_for node, MSG do |corrector| - corrector.replace(node, exp_code) - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/debug_calls.cr b/lib/ameba/src/ameba/rule/lint/debug_calls.cr deleted file mode 100644 index b2958295..00000000 --- a/lib/ameba/src/ameba/rule/lint/debug_calls.cr +++ /dev/null @@ -1,32 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows calls to debug-related methods. - # - # This is because we don't want debug calls accidentally being - # committed into our codebase. - # - # YAML configuration example: - # - # ``` - # Lint/DebugCalls: - # Enabled: true - # MethodNames: - # - p - # - p! - # - pp - # - pp! - # ``` - class DebugCalls < Base - properties do - description "Disallows debug-related calls" - method_names %w[p p! pp pp!] - end - - MSG = "Possibly forgotten debug-related `%s` call detected" - - def test(source, node : Crystal::Call) - return unless node.name.in?(method_names) && node.obj.nil? - - issue_for node, MSG % node.name - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/debugger_statement.cr b/lib/ameba/src/ameba/rule/lint/debugger_statement.cr deleted file mode 100644 index da478cbb..00000000 --- a/lib/ameba/src/ameba/rule/lint/debugger_statement.cr +++ /dev/null @@ -1,28 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows calls to debugger. - # - # This is because we don't want debugger breakpoints accidentally being - # committed into our codebase. - # - # YAML configuration example: - # - # ``` - # Lint/DebuggerStatement: - # Enabled: true - # ``` - class DebuggerStatement < Base - properties do - description "Disallows calls to debugger" - end - - MSG = "Possible forgotten debugger statement detected" - - def test(source, node : Crystal::Call) - return unless node.name == "debugger" && - node.args.empty? && - node.obj.nil? - - issue_for node, MSG - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/duplicated_require.cr b/lib/ameba/src/ameba/rule/lint/duplicated_require.cr deleted file mode 100644 index d1fe1295..00000000 --- a/lib/ameba/src/ameba/rule/lint/duplicated_require.cr +++ /dev/null @@ -1,31 +0,0 @@ -module Ameba::Rule::Lint - # A rule that reports duplicated require statements. - # - # ``` - # require "./thing" - # require "./stuff" - # require "./thing" # duplicated require - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/DuplicatedRequire: - # Enabled: true - # ``` - class DuplicatedRequire < Base - properties do - description "Reports duplicated require statements" - end - - MSG = "Duplicated require of `%s`" - - def test(source) - nodes = AST::TopLevelNodesVisitor.new(source.ast).require_nodes - nodes.each_with_object([] of String) do |node, processed_require_strings| - issue_for(node, MSG % node.string) if node.string.in?(processed_require_strings) - processed_require_strings << node.string - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/empty_ensure.cr b/lib/ameba/src/ameba/rule/lint/empty_ensure.cr deleted file mode 100644 index dce5dfae..00000000 --- a/lib/ameba/src/ameba/rule/lint/empty_ensure.cr +++ /dev/null @@ -1,54 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows empty ensure statement. - # - # For example, this is considered invalid: - # - # ``` - # def some_method - # do_some_stuff - # ensure - # end - # - # begin - # do_some_stuff - # ensure - # end - # ``` - # - # And it should be written as this: - # - # ``` - # def some_method - # do_some_stuff - # ensure - # do_something_else - # end - # - # begin - # do_some_stuff - # ensure - # do_something_else - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/EmptyEnsure - # Enabled: true - # ``` - class EmptyEnsure < Base - properties do - description "Disallows empty ensure statement" - end - - MSG = "Empty `ensure` block detected" - - def test(source, node : Crystal::ExceptionHandler) - node_ensure = node.ensure - return if node_ensure.nil? || !node_ensure.nop? - - issue_for node.ensure_location, node.end_location, MSG - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/empty_expression.cr b/lib/ameba/src/ameba/rule/lint/empty_expression.cr deleted file mode 100644 index bac570c9..00000000 --- a/lib/ameba/src/ameba/rule/lint/empty_expression.cr +++ /dev/null @@ -1,42 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows empty expressions. - # - # This is considered invalid: - # - # ``` - # foo = () - # - # if () - # bar - # end - # ``` - # - # And this is valid: - # - # ``` - # foo = (some_expression) - # - # if (some_expression) - # bar - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/EmptyExpression: - # Enabled: true - # ``` - class EmptyExpression < Base - properties do - description "Disallows empty expressions" - end - - MSG = "Avoid empty expressions" - - def test(source, node : Crystal::Expressions) - return unless node.expressions.size == 1 && node.expressions.first.nop? - issue_for node, MSG - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/empty_loop.cr b/lib/ameba/src/ameba/rule/lint/empty_loop.cr deleted file mode 100644 index 32d9b5a4..00000000 --- a/lib/ameba/src/ameba/rule/lint/empty_loop.cr +++ /dev/null @@ -1,64 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows empty loops. - # - # This is considered invalid: - # - # ``` - # while false - # end - # - # until 10 - # end - # - # loop do - # # nothing here - # end - # ``` - # - # And this is valid: - # - # ``` - # a = 1 - # while a < 10 - # a += 1 - # end - # - # until socket_opened? - # end - # - # loop do - # do_something_here - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/EmptyLoop: - # Enabled: true - # ``` - class EmptyLoop < Base - include AST::Util - - properties do - description "Disallows empty loops" - end - - MSG = "Empty loop detected" - - def test(source, node : Crystal::Call) - check_node(source, node, node.block) if loop?(node) - end - - def test(source, node : Crystal::While | Crystal::Until) - check_node(source, node, node.body) if literal?(node.cond) - end - - private def check_node(source, node, loop_body) - body = loop_body.is_a?(Crystal::Block) ? loop_body.body : loop_body - return unless body.nil? || body.nop? - - issue_for node, MSG - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/formatting.cr b/lib/ameba/src/ameba/rule/lint/formatting.cr deleted file mode 100644 index ea848ad2..00000000 --- a/lib/ameba/src/ameba/rule/lint/formatting.cr +++ /dev/null @@ -1,65 +0,0 @@ -require "compiler/crystal/formatter" - -module Ameba::Rule::Lint - # A rule that verifies syntax formatting according to the - # Crystal's built-in formatter. - # - # For example, this syntax is invalid: - # - # def foo(a,b,c=0) - # #foobar - # a+b+c - # end - # - # And should be properly written: - # - # def foo(a, b, c = 0) - # # foobar - # a + b + c - # end - # - # YAML configuration example: - # - # ``` - # Lint/Formatting: - # Enabled: true - # FailOnError: false - # ``` - class Formatting < Base - properties do - description "Reports not formatted sources" - fail_on_error false - end - - MSG = "Use built-in formatter to format this source" - MSG_ERROR = "Error while formatting: %s" - - private LOCATION = {1, 1} - - def test(source) - source_code = source.code - result = Crystal.format(source_code, source.path) - return if result == source_code - - source_lines = source_code.lines - return if source_lines.empty? - - end_location = { - source_lines.size, - source_lines.last.size + 1, - } - - issue_for LOCATION, MSG do |corrector| - corrector.replace(LOCATION, end_location, result) - end - rescue ex : Crystal::SyntaxException - if fail_on_error? - issue_for({ex.line_number, ex.column_number}, MSG_ERROR % ex.message) - end - rescue ex - if fail_on_error? - issue_for(LOCATION, MSG_ERROR % ex.message) - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/hash_duplicated_key.cr b/lib/ameba/src/ameba/rule/lint/hash_duplicated_key.cr deleted file mode 100644 index 5b335edc..00000000 --- a/lib/ameba/src/ameba/rule/lint/hash_duplicated_key.cr +++ /dev/null @@ -1,42 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows duplicated keys in hash literals. - # - # This is considered invalid: - # - # ``` - # h = {"foo" => 1, "bar" => 2, "foo" => 3} - # ``` - # - # And it has to written as this instead: - # - # ``` - # h = {"foo" => 1, "bar" => 2} - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/HashDuplicatedKey: - # Enabled: true - # ``` - class HashDuplicatedKey < Base - properties do - description "Disallows duplicated keys in hash literals" - end - - MSG = "Duplicated keys in hash literal: %s" - - def test(source, node : Crystal::HashLiteral) - return if (keys = duplicated_keys(node.entries)).empty? - - issue_for node, MSG % keys.join(", ") - end - - private def duplicated_keys(entries) - entries.map(&.key) - .group_by(&.itself) - .select! { |_, v| v.size > 1 } - .map { |k, _| k } - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/literal_assignments_in_expressions.cr b/lib/ameba/src/ameba/rule/lint/literal_assignments_in_expressions.cr deleted file mode 100644 index 1851bb0f..00000000 --- a/lib/ameba/src/ameba/rule/lint/literal_assignments_in_expressions.cr +++ /dev/null @@ -1,43 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows assignments with literal values - # in control expressions. - # - # For example, this is considered invalid: - # - # ``` - # if foo = 42 - # do_something - # end - # ``` - # - # And most likely should be replaced by the following: - # - # ``` - # if foo == 42 - # do_something - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/LiteralAssignmentsInExpressions: - # Enabled: true - # ``` - class LiteralAssignmentsInExpressions < Base - include AST::Util - - properties do - description "Disallows assignments with literal values in control expressions" - end - - MSG = "Detected assignment with a literal value in control expression" - - def test(source, node : Crystal::If | Crystal::Unless | Crystal::Case | Crystal::While | Crystal::Until) - return unless (cond = node.cond).is_a?(Crystal::Assign) - return unless literal?(cond.value) - - issue_for cond, MSG - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/literal_in_condition.cr b/lib/ameba/src/ameba/rule/lint/literal_in_condition.cr deleted file mode 100644 index 3be1c152..00000000 --- a/lib/ameba/src/ameba/rule/lint/literal_in_condition.cr +++ /dev/null @@ -1,37 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows useless conditional statements that contain a literal - # in place of a variable or predicate function. - # - # This is because a conditional construct with a literal predicate will - # always result in the same behaviour at run time, meaning it can be - # replaced with either the body of the construct, or deleted entirely. - # - # This is considered invalid: - # - # ``` - # if "something" - # :ok - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/LiteralInCondition: - # Enabled: true - # ``` - class LiteralInCondition < Base - include AST::Util - - properties do - description "Disallows useless conditional statements that contain \ - a literal in place of a variable or predicate function" - end - - MSG = "Literal value found in conditional" - - def test(source, node : Crystal::If | Crystal::Unless | Crystal::Case) - issue_for node, MSG if static_literal?(node.cond) - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/literal_in_interpolation.cr b/lib/ameba/src/ameba/rule/lint/literal_in_interpolation.cr deleted file mode 100644 index fce21501..00000000 --- a/lib/ameba/src/ameba/rule/lint/literal_in_interpolation.cr +++ /dev/null @@ -1,33 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows useless string interpolations - # that contain a literal value instead of a variable or function. - # - # For example: - # - # ``` - # "Hello, #{:Ary}" - # "There are #{4} cats" - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/LiteralInInterpolation - # Enabled: true - # ``` - class LiteralInInterpolation < Base - include AST::Util - - properties do - description "Disallows useless string interpolations" - end - - MSG = "Literal value found in interpolation" - - def test(source, node : Crystal::StringInterpolation) - node.expressions - .select { |exp| !exp.is_a?(Crystal::StringLiteral) && literal?(exp) } - .each { |exp| issue_for exp, MSG } - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/literals_comparison.cr b/lib/ameba/src/ameba/rule/lint/literals_comparison.cr deleted file mode 100644 index 9f9a7fee..00000000 --- a/lib/ameba/src/ameba/rule/lint/literals_comparison.cr +++ /dev/null @@ -1,53 +0,0 @@ -module Ameba::Rule::Lint - # This rule is used to identify comparisons between two literals. - # - # They usually have the same result - except for non-primitive - # types like containers, range or regex. - # - # For example, this will be always false: - # - # ``` - # "foo" == 42 - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/LiteralsComparison: - # Enabled: true - # ``` - class LiteralsComparison < Base - include AST::Util - - properties do - description "Identifies comparisons between literals" - end - - OP_NAMES = %w[=== == !=] - - MSG = "Comparison always evaluates to %s" - MSG_LIKELY = "Comparison most likely evaluates to %s" - - def test(source, node : Crystal::Call) - return unless node.name.in?(OP_NAMES) - return unless (obj = node.obj) && (arg = node.args.first?) - - obj_is_literal, obj_is_static = literal_kind?(obj) - arg_is_literal, arg_is_static = literal_kind?(arg) - - return unless obj_is_literal && arg_is_literal - return unless obj.to_s == arg.to_s - - is_dynamic = !obj_is_static || !arg_is_static - - what = - case node.name - when "===" then "the same" - when "==" then "true" - when "!=" then "false" - end - - issue_for node, (is_dynamic ? MSG_LIKELY : MSG) % what - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/missing_block_argument.cr b/lib/ameba/src/ameba/rule/lint/missing_block_argument.cr deleted file mode 100644 index 5fd6176a..00000000 --- a/lib/ameba/src/ameba/rule/lint/missing_block_argument.cr +++ /dev/null @@ -1,40 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows yielding method definitions without block argument. - # - # For example, this is considered invalid: - # - # def foo - # yield 42 - # end - # - # And has to be written as the following: - # - # def foo(&) - # yield 42 - # end - # - # YAML configuration example: - # - # ``` - # Lint/MissingBlockArgument: - # Enabled: true - # ``` - class MissingBlockArgument < Base - properties do - description "Disallows yielding method definitions without block argument" - end - - MSG = "Missing anonymous block argument. Use `&` as an argument " \ - "name to indicate yielding method." - - def test(source) - AST::ScopeVisitor.new self, source - end - - def test(source, node : Crystal::Def, scope : AST::Scope) - return if !scope.yields? || node.block_arg - - issue_for node, MSG, prefer_name_location: true - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/not_nil.cr b/lib/ameba/src/ameba/rule/lint/not_nil.cr deleted file mode 100644 index 1ab405e2..00000000 --- a/lib/ameba/src/ameba/rule/lint/not_nil.cr +++ /dev/null @@ -1,46 +0,0 @@ -module Ameba::Rule::Lint - # This rule is used to identify usages of `not_nil!` calls. - # - # For example, this is considered a code smell: - # - # ``` - # names = %w[Alice Bob] - # alice = names.find { |name| name == "Alice" }.not_nil! - # ``` - # - # And can be written as this: - # - # ``` - # names = %w[Alice Bob] - # alice = names.find { |name| name == "Alice" } - # - # if alice - # # ... - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/NotNil: - # Enabled: true - # ``` - class NotNil < Base - properties do - description "Identifies usage of `not_nil!` calls" - end - - MSG = "Avoid using `not_nil!`" - - def test(source) - AST::NodeVisitor.new self, source, skip: :macro - end - - def test(source, node : Crystal::Call) - return unless node.name == "not_nil!" - return unless node.obj && node.args.empty? - - issue_for node, MSG, prefer_name_location: true - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/not_nil_after_no_bang.cr b/lib/ameba/src/ameba/rule/lint/not_nil_after_no_bang.cr deleted file mode 100644 index 1f9f3394..00000000 --- a/lib/ameba/src/ameba/rule/lint/not_nil_after_no_bang.cr +++ /dev/null @@ -1,56 +0,0 @@ -module Ameba::Rule::Lint - # This rule is used to identify usage of `index/rindex/find/match` calls - # followed by a call to `not_nil!`. - # - # For example, this is considered a code smell: - # - # ``` - # %w[Alice Bob].find(&.chars.any?(&.in?('o', 'b'))).not_nil! - # ``` - # - # And can be written as this: - # - # ``` - # %w[Alice Bob].find!(&.chars.any?(&.in?('o', 'b'))) - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/NotNilAfterNoBang: - # Enabled: true - # ``` - class NotNilAfterNoBang < Base - include AST::Util - - properties do - description "Identifies usage of `index/rindex/find/match` calls followed by `not_nil!`" - end - - MSG = "Use `%s! {...}` instead of `%s {...}.not_nil!`" - - BLOCK_CALL_NAMES = %w[index rindex find] - CALL_NAMES = %w[index rindex match] - - def test(source) - AST::NodeVisitor.new self, source, skip: :macro - end - - def test(source, node : Crystal::Call) - return unless node.name == "not_nil!" && node.args.empty? - return unless (obj = node.obj).is_a?(Crystal::Call) - return unless obj.name.in?(obj.block ? BLOCK_CALL_NAMES : CALL_NAMES) - - return unless name_location = name_location(obj) - return unless name_location_end = name_end_location(obj) - return unless end_location = name_end_location(node) - - msg = MSG % {obj.name, obj.name} - - issue_for name_location, end_location, msg do |corrector| - corrector.insert_after(name_location_end, '!') - corrector.remove_trailing(node, {{ ".not_nil!".size }}) - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/percent_array.cr b/lib/ameba/src/ameba/rule/lint/percent_array.cr deleted file mode 100644 index 987d87d8..00000000 --- a/lib/ameba/src/ameba/rule/lint/percent_array.cr +++ /dev/null @@ -1,69 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows some unwanted symbols in percent array literals. - # - # For example, this is usually written by mistake: - # - # ``` - # %i[:one, :two] - # %w["one", "two"] - # ``` - # - # And the expected example is: - # - # ``` - # %i[one two] - # %w[one two] - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/PercentArrays: - # Enabled: true - # StringArrayUnwantedSymbols: ',"' - # SymbolArrayUnwantedSymbols: ',:' - # ``` - class PercentArrays < Base - properties do - description "Disallows some unwanted symbols in percent array literals" - - string_array_unwanted_symbols %(,") - symbol_array_unwanted_symbols %(,:) - end - - MSG = "Symbols `%s` may be unwanted in %s array literals" - - def test(source) - issue = start_token = nil - - Tokenizer.new(source).run do |token| - case token.type - when .string_array_start?, .symbol_array_start? - start_token = token.dup - when .string? - if (_start = start_token) && !issue - issue = array_entry_invalid?(token.value.to_s, _start.raw) - end - when .string_array_end? - if (_start = start_token) && (_issue = issue) - issue_for _start, _issue - end - issue = start_token = nil - end - end - end - - private def array_entry_invalid?(entry, array_type) - case array_type - when .starts_with? "%w" - check_array_entry entry, string_array_unwanted_symbols, "%w" - when .starts_with? "%i" - check_array_entry entry, symbol_array_unwanted_symbols, "%i" - end - end - - private def check_array_entry(entry, symbols, literal) - MSG % {symbols, literal} if entry.matches?(/[#{Regex.escape(symbols)}]/) - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/rand_zero.cr b/lib/ameba/src/ameba/rule/lint/rand_zero.cr deleted file mode 100644 index 5293704f..00000000 --- a/lib/ameba/src/ameba/rule/lint/rand_zero.cr +++ /dev/null @@ -1,41 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows `rand(0)` and `rand(1)` calls. - # Such calls always return `0`. - # - # For example: - # - # ``` - # rand(1) - # ``` - # - # Should be written as: - # - # ``` - # rand - # # or - # rand(2) - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/RandZero: - # Enabled: true - # ``` - class RandZero < Base - properties do - description "Disallows rand zero calls" - end - - MSG = "%s always returns 0" - - def test(source, node : Crystal::Call) - return unless node.name == "rand" && - node.args.size == 1 && - (arg = node.args.first).is_a?(Crystal::NumberLiteral) && - arg.value.in?("0", "1") - - issue_for node, MSG % node - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/redundant_string_coercion.cr b/lib/ameba/src/ameba/rule/lint/redundant_string_coercion.cr deleted file mode 100644 index 0a486084..00000000 --- a/lib/ameba/src/ameba/rule/lint/redundant_string_coercion.cr +++ /dev/null @@ -1,48 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows string conversion in string interpolation, - # which is redundant. - # - # For example, this is considered invalid: - # - # ``` - # "Hello, #{name.to_s}" - # ``` - # - # And this is valid: - # - # ``` - # "Hello, #{name}" - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/RedundantStringCoercion - # Enabled: true - # ``` - class RedundantStringCoercion < Base - include AST::Util - - properties do - description "Disallows redundant string conversions in interpolation" - end - - MSG = "Redundant use of `Object#to_s` in interpolation" - - def test(source, node : Crystal::StringInterpolation) - string_coercion_nodes(node).each do |expr| - issue_for name_location(expr), expr.end_location, MSG - end - end - - private def string_coercion_nodes(node) - node.expressions.select do |exp| - exp.is_a?(Crystal::Call) && - exp.name == "to_s" && - exp.args.size.zero? && - exp.named_args.nil? && - exp.obj - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/redundant_with_index.cr b/lib/ameba/src/ameba/rule/lint/redundant_with_index.cr deleted file mode 100644 index ac42e4f8..00000000 --- a/lib/ameba/src/ameba/rule/lint/redundant_with_index.cr +++ /dev/null @@ -1,57 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows redundant `with_index` calls. - # - # For example, this is considered invalid: - # - # ``` - # collection.each.with_index do |e| - # # ... - # end - # - # collection.each_with_index do |e, _| - # # ... - # end - # ``` - # - # and it should be written as follows: - # - # ``` - # collection.each do |e| - # # ... - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/RedundantWithIndex: - # Enabled: true - # ``` - class RedundantWithIndex < Base - properties do - description "Disallows redundant `with_index` calls" - end - - def test(source, node : Crystal::Call) - args, block = node.args, node.block - - return if block.nil? || args.size > 1 - return if with_index_arg?(block) - - case node.name - when "with_index" - report source, node, "Remove redundant with_index" - when "each_with_index" - report source, node, "Use each instead of each_with_index" - end - end - - private def with_index_arg?(block : Crystal::Block) - block.args.size >= 2 && block.args.last.name != "_" - end - - private def report(source, node, msg) - issue_for node, msg, prefer_name_location: true - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/redundant_with_object.cr b/lib/ameba/src/ameba/rule/lint/redundant_with_object.cr deleted file mode 100644 index 7dfbb968..00000000 --- a/lib/ameba/src/ameba/rule/lint/redundant_with_object.cr +++ /dev/null @@ -1,50 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows redundant `each_with_object` calls. - # - # For example, this is considered invalid: - # - # ``` - # collection.each_with_object(0) do |e| - # # ... - # end - # - # collection.each_with_object(0) do |e, _| - # # ... - # end - # ``` - # - # and it should be written as follows: - # - # ``` - # collection.each do |e| - # # ... - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/RedundantWithObject: - # Enabled: true - # ``` - class RedundantWithObject < Base - properties do - description "Disallows redundant `with_object` calls" - end - - MSG = "Use `each` instead of `each_with_object`" - - def test(source, node : Crystal::Call) - return if node.name != "each_with_object" || - node.args.size != 1 || - !(block = node.block) || - with_index_arg?(block) - - issue_for node, MSG, prefer_name_location: true - end - - private def with_index_arg?(block : Crystal::Block) - block.args.size >= 2 && block.args.last.name != "_" - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/shadowed_argument.cr b/lib/ameba/src/ameba/rule/lint/shadowed_argument.cr deleted file mode 100644 index c21c1dae..00000000 --- a/lib/ameba/src/ameba/rule/lint/shadowed_argument.cr +++ /dev/null @@ -1,57 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows shadowed arguments. - # - # For example, this is considered invalid: - # - # ``` - # do_something do |foo| - # foo = 1 # shadows block argument - # foo - # end - # - # def do_something(foo) - # foo = 1 # shadows method argument - # foo - # end - # ``` - # - # and it should be written as follows: - # - # ``` - # do_something do |foo| - # foo = foo + 42 - # foo - # end - # - # def do_something(foo) - # foo = foo + 42 - # foo - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/ShadowedArgument: - # Enabled: true - # ``` - class ShadowedArgument < Base - properties do - description "Disallows shadowed arguments" - end - - MSG = "Argument `%s` is assigned before it is used" - - def test(source) - AST::ScopeVisitor.new self, source - end - - def test(source, node, scope : AST::Scope) - scope.arguments.each do |arg| - next unless assign = arg.variable.assign_before_reference - - issue_for assign, MSG % arg.name - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/shadowed_exception.cr b/lib/ameba/src/ameba/rule/lint/shadowed_exception.cr deleted file mode 100644 index b6708a24..00000000 --- a/lib/ameba/src/ameba/rule/lint/shadowed_exception.cr +++ /dev/null @@ -1,83 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows a rescued exception that get shadowed by a - # less specific exception being rescued before a more specific - # exception is rescued. - # - # For example, this is invalid: - # - # ``` - # begin - # do_something - # rescue Exception - # handle_exception - # rescue ArgumentError - # handle_argument_error_exception - # end - # ``` - # - # And it has to be written as follows: - # - # ``` - # begin - # do_something - # rescue ArgumentError - # handle_argument_error_exception - # rescue Exception - # handle_exception - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/ShadowedException: - # Enabled: true - # ``` - class ShadowedException < Base - properties do - description "Disallows rescued exception that get shadowed" - end - - MSG = "Shadowed exception found: %s" - - def test(source, node : Crystal::ExceptionHandler) - rescues = node.rescues - return if rescues.nil? - - shadowed(rescues).each do |path| - issue_for path, MSG % path.names.join("::") - end - end - - private def shadowed(rescues, catch_all = false) - traversed_types = Set(String).new - - rescues = filter_rescues(rescues) - rescues.each_with_object([] of Crystal::Path) do |types, shadowed| - case - when catch_all - shadowed.concat(types) - next - when types.any?(&.single?("Exception")) - nodes = types.reject(&.single?("Exception")) - shadowed.concat(nodes) unless nodes.empty? - catch_all = true - next - else - nodes = types.select { |path| traverse(path.to_s, traversed_types) } - shadowed.concat(nodes) unless nodes.empty? - end - end - end - - private def filter_rescues(rescues) - rescues.compact_map(&.types.try &.select(Crystal::Path)) - end - - private def traverse(path, traversed_types) - dup = traversed_types.includes?(path) - dup || (traversed_types << path) - dup - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/shadowing_outer_local_var.cr b/lib/ameba/src/ameba/rule/lint/shadowing_outer_local_var.cr deleted file mode 100644 index 1ac1c239..00000000 --- a/lib/ameba/src/ameba/rule/lint/shadowing_outer_local_var.cr +++ /dev/null @@ -1,69 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows the usage of the same name as outer local variables - # for block or proc arguments. - # - # For example, this is considered incorrect: - # - # ``` - # def some_method - # foo = 1 - # - # 3.times do |foo| # shadowing outer `foo` - # end - # end - # ``` - # - # and should be written as: - # - # ``` - # def some_method - # foo = 1 - # - # 3.times do |bar| - # end - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/ShadowingOuterLocalVar: - # Enabled: true - # ``` - class ShadowingOuterLocalVar < Base - properties do - description "Disallows the usage of the same name as outer local variables " \ - "for block or proc arguments" - end - - MSG = "Shadowing outer local variable `%s`" - - def test(source) - AST::ScopeVisitor.new self, source, skip: [ - Crystal::Macro, - Crystal::MacroFor, - ] - end - - def test(source, node : Crystal::ProcLiteral | Crystal::Block, scope : AST::Scope) - find_shadowing source, scope - end - - private def find_shadowing(source, scope) - return unless outer_scope = scope.outer_scope - - scope.arguments.reject(&.ignored?).each do |arg| - # TODO: handle unpacked variables from `Block#unpacks` - next unless name = arg.name.presence - - variable = outer_scope.find_variable(name) - - next if variable.nil? || !variable.declared_before?(arg) - next if outer_scope.assigns_ivar?(name) - next if outer_scope.assigns_type_dec?(name) - - issue_for arg.node, MSG % name - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/shared_var_in_fiber.cr b/lib/ameba/src/ameba/rule/lint/shared_var_in_fiber.cr deleted file mode 100644 index 2a2bd16e..00000000 --- a/lib/ameba/src/ameba/rule/lint/shared_var_in_fiber.cr +++ /dev/null @@ -1,85 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows using shared variables in fibers, - # which are mutated during iterations. - # - # In most cases it leads to unexpected behaviour and is undesired. - # - # For example, having this example: - # - # ``` - # n = 0 - # channel = Channel(Int32).new - # - # while n < 3 - # n = n + 1 - # spawn { channel.send n } - # end - # - # 3.times { puts channel.receive } # => # 3, 3, 3 - # ``` - # - # The problem is there is only one shared between fibers variable `n` - # and when `channel.receive` is executed its value is `3`. - # - # To solve this, the code above needs to be rewritten to the following: - # - # ``` - # n = 0 - # channel = Channel(Int32).new - # - # while n < 3 - # n = n + 1 - # m = n - # spawn do { channel.send m } - # end - # - # 3.times { puts channel.receive } # => # 1, 2, 3 - # ``` - # - # This rule is able to find the shared variables between fibers, which are mutated - # during iterations. So it reports the issue on the first sample and passes on - # the second one. - # - # There are also other techniques to solve the problem above which are - # [officially documented](https://crystal-lang.org/reference/guides/concurrency.html) - # - # YAML configuration example: - # - # ``` - # Lint/SharedVarInFiber: - # Enabled: true - # ``` - class SharedVarInFiber < Base - properties do - description "Disallows shared variables in fibers" - end - - MSG = "Shared variable `%s` is used in fiber" - - def test(source) - AST::ScopeVisitor.new self, source - end - - def test(source, node, scope : AST::Scope) - return unless scope.spawn_block? - - scope.references.each do |ref| - next if (variable = scope.find_variable(ref.name)).nil? - next if variable.scope == scope || !mutated_in_loop?(variable) - - issue_for ref.node, MSG % variable.name - end - end - - # Variable is mutated in loop if it was declared above the loop and assigned inside. - private def mutated_in_loop?(variable) - declared_in = variable.assignments.first?.try &.branch - - variable.assignments - .reject(&.scope.spawn_block?) - .any? do |assign| - assign.branch.try(&.in_loop?) && assign.branch != declared_in - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/spec_filename.cr b/lib/ameba/src/ameba/rule/lint/spec_filename.cr deleted file mode 100644 index 9711806f..00000000 --- a/lib/ameba/src/ameba/rule/lint/spec_filename.cr +++ /dev/null @@ -1,50 +0,0 @@ -require "file_utils" - -module Ameba::Rule::Lint - # A rule that enforces spec filenames to have `_spec` suffix. - # - # YAML configuration example: - # - # ``` - # Lint/SpecFilename: - # Enabled: true - # ``` - class SpecFilename < Base - properties do - description "Enforces spec filenames to have `_spec` suffix" - ignored_dirs %w[spec/support spec/fixtures spec/data] - ignored_filenames %w[spec_helper] - end - - MSG = "Spec filename should have `_spec` suffix: %s.cr, not %s.cr" - - private LOCATION = {1, 1} - - # TODO: fix the assumption that *source.path* contains relative path - def test(source : Source) - path_ = Path[source.path].to_posix - name = path_.stem - path = path_.to_s - - # check files only within spec/ directory - return unless path.starts_with?("spec/") - # ignore files having `_spec` suffix - return if name.ends_with?("_spec") - - # ignore known false-positives - ignored_dirs.each do |substr| - return if path.starts_with?("#{substr}/") - end - return if name.in?(ignored_filenames) - - expected = "#{name}_spec" - - issue_for LOCATION, MSG % {expected, name} do - new_path = - path_.sibling(expected + path_.extension) - - FileUtils.mv(path, new_path) - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/spec_focus.cr b/lib/ameba/src/ameba/rule/lint/spec_focus.cr deleted file mode 100644 index b165145a..00000000 --- a/lib/ameba/src/ameba/rule/lint/spec_focus.cr +++ /dev/null @@ -1,72 +0,0 @@ -module Ameba::Rule::Lint - # Checks if specs are focused. - # - # In specs `focus: true` is mainly used to focus on a spec - # item locally during development. However, if such change - # is committed, it silently runs only focused spec on all - # other environment, which is undesired. - # - # This is considered bad: - # - # ``` - # describe MyClass, focus: true do - # end - # - # describe ".new", focus: true do - # end - # - # context "my context", focus: true do - # end - # - # it "works", focus: true do - # end - # ``` - # - # And it should be written as the following: - # - # ``` - # describe MyClass do - # end - # - # describe ".new" do - # end - # - # context "my context" do - # end - # - # it "works" do - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/SpecFocus: - # Enabled: true - # ``` - class SpecFocus < Base - properties do - description "Reports focused spec items" - end - - MSG = "Focused spec item detected" - - SPEC_ITEM_NAMES = %w[describe context it pending] - - def test(source) - return unless source.spec? - - AST::NodeVisitor.new self, source - end - - def test(source, node : Crystal::Call) - return unless node.name.in?(SPEC_ITEM_NAMES) - return unless node.block - - arg = node.named_args.try &.find(&.name.== "focus") - return unless arg - - issue_for arg, MSG - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/syntax.cr b/lib/ameba/src/ameba/rule/lint/syntax.cr deleted file mode 100644 index 14e7ba4b..00000000 --- a/lib/ameba/src/ameba/rule/lint/syntax.cr +++ /dev/null @@ -1,33 +0,0 @@ -module Ameba::Rule::Lint - # A rule that reports invalid Crystal syntax. - # - # For example, this syntax is invalid: - # - # ``` - # def hello - # do_something - # rescue Exception => e - # end - # ``` - # - # And should be properly written: - # - # ``` - # def hello - # do_something - # rescue e : Exception - # end - # ``` - class Syntax < Base - properties do - description "Reports invalid Crystal syntax" - severity :error - end - - def test(source) - source.ast - rescue e : Crystal::SyntaxException - issue_for({e.line_number, e.column_number}, e.message.to_s) - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/typos.cr b/lib/ameba/src/ameba/rule/lint/typos.cr deleted file mode 100644 index 2371aff1..00000000 --- a/lib/ameba/src/ameba/rule/lint/typos.cr +++ /dev/null @@ -1,97 +0,0 @@ -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 diff --git a/lib/ameba/src/ameba/rule/lint/unneeded_disable_directive.cr b/lib/ameba/src/ameba/rule/lint/unneeded_disable_directive.cr deleted file mode 100644 index a32215a8..00000000 --- a/lib/ameba/src/ameba/rule/lint/unneeded_disable_directive.cr +++ /dev/null @@ -1,67 +0,0 @@ -module Ameba::Rule::Lint - # A rule that reports unneeded disable directives. - # For example, this is considered invalid: - # - # ``` - # # ameba:disable Style/PredicateName - # def comment? - # do_something - # end - # ``` - # - # as the predicate name is correct and the comment directive does not - # have any effect, the snippet should be written as the following: - # - # ``` - # def comment? - # do_something - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/UnneededDisableDirective - # Enabled: true - # ``` - class UnneededDisableDirective < Base - properties do - description "Reports unneeded disable directives in comments" - end - - MSG = "Unnecessary disabling of %s" - - def test(source) - Tokenizer.new(source).run do |token| - next unless token.type.comment? - next unless directive = source.parse_inline_directive(token.value.to_s) - next unless names = unneeded_disables(source, directive, token.location) - next if names.empty? - - issue_for token, MSG % names.join(", ") - end - end - - private def unneeded_disables(source, directive, location) - return unless directive[:action] == "disable" - - directive[:rules].reject do |rule_name| - next if rule_name == self.name - source.issues.any? do |issue| - issue.rule.name == rule_name && - issue.disabled? && - issue_at_location?(source, issue, location) - end - end - end - - private def issue_at_location?(source, issue, location) - return false unless issue_line_number = issue.location.try(&.line_number) - - issue_line_number == location.line_number || - ((prev_line_number = issue_line_number - 1) && - prev_line_number == location.line_number && - source.comment?(prev_line_number - 1)) - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/unreachable_code.cr b/lib/ameba/src/ameba/rule/lint/unreachable_code.cr deleted file mode 100644 index f3c28439..00000000 --- a/lib/ameba/src/ameba/rule/lint/unreachable_code.cr +++ /dev/null @@ -1,60 +0,0 @@ -module Ameba::Rule::Lint - # A rule that reports unreachable code. - # - # For example, this is considered invalid: - # - # ``` - # def method(a) - # return 42 - # a + 1 - # end - # ``` - # - # ``` - # a = 1 - # loop do - # break - # a += 1 - # end - # ``` - # - # And has to be written as the following: - # - # ``` - # def method(a) - # return 42 if a == 0 - # a + 1 - # end - # ``` - # - # ``` - # a = 1 - # loop do - # break a > 3 - # a += 1 - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/UnreachableCode: - # Enabled: true - # ``` - class UnreachableCode < Base - properties do - description "Reports unreachable code" - end - - MSG = "Unreachable code detected" - - def test(source) - AST::FlowExpressionVisitor.new self, source - end - - def test(source, node, flow_expression : AST::FlowExpression) - return unless unreachable_node = flow_expression.unreachable_nodes.first? - issue_for unreachable_node, MSG - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/unused_argument.cr b/lib/ameba/src/ameba/rule/lint/unused_argument.cr deleted file mode 100644 index d001a1aa..00000000 --- a/lib/ameba/src/ameba/rule/lint/unused_argument.cr +++ /dev/null @@ -1,84 +0,0 @@ -module Ameba::Rule::Lint - # A rule that reports unused arguments. - # For example, this is considered invalid: - # - # ``` - # def method(a, b, c) - # a + b - # end - # ``` - # - # and should be written as: - # - # ``` - # def method(a, b) - # a + b - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/UnusedArgument: - # Enabled: true - # IgnoreDefs: true - # IgnoreBlocks: false - # IgnoreProcs: false - # ``` - class UnusedArgument < Base - properties do - description "Disallows unused arguments" - - ignore_defs true - ignore_blocks false - ignore_procs false - end - - MSG = "Unused argument `%s`. If it's necessary, use `%s` " \ - "as an argument name to indicate that it won't be used." - - def test(source) - AST::ScopeVisitor.new self, source - end - - def test(source, node : Crystal::ProcLiteral, scope : AST::Scope) - ignore_procs? || find_unused_arguments(source, scope) - end - - def test(source, node : Crystal::Block, scope : AST::Scope) - ignore_blocks? || find_unused_arguments(source, scope) - end - - def test(source, node : Crystal::Def, scope : AST::Scope) - arguments = scope.arguments.dup - - # `Lint/UnusedBlockArgument` rule covers this case explicitly - if block_arg = node.block_arg - arguments.reject!(&.node.== block_arg) - end - - ignore_defs? || find_unused_arguments(source, scope, arguments) - end - - private def find_unused_arguments(source, scope, arguments = scope.arguments) - arguments.each do |argument| - next if argument.anonymous? || argument.ignored? - next if scope.references?(argument.variable) - - name_suggestion = scope.node.is_a?(Crystal::Block) ? '_' : "_#{argument.name}" - message = MSG % {argument.name, name_suggestion} - - location = argument.node.location - end_location = location.try &.adjust(column_number: argument.name.size - 1) - - if location && end_location - issue_for argument.node, message do |corrector| - corrector.replace(location, end_location, name_suggestion) - end - else - issue_for argument.node, message - end - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/unused_block_argument.cr b/lib/ameba/src/ameba/rule/lint/unused_block_argument.cr deleted file mode 100644 index 65a7255d..00000000 --- a/lib/ameba/src/ameba/rule/lint/unused_block_argument.cr +++ /dev/null @@ -1,79 +0,0 @@ -module Ameba::Rule::Lint - # A rule that reports unused block arguments. - # For example, this is considered invalid: - # - # ``` - # def foo(a, b, &block) - # a + b - # end - # - # def bar(&block) - # yield 42 - # end - # ``` - # - # and should be written as: - # - # ``` - # def foo(a, b, &_block) - # a + b - # end - # - # def bar(&) - # yield 42 - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/UnusedBlockArgument: - # Enabled: true - # ``` - class UnusedBlockArgument < Base - properties do - description "Disallows unused block arguments" - end - - MSG_UNUSED = "Unused block argument `%1$s`. If it's necessary, use `_%1$s` " \ - "as an argument name to indicate that it won't be used." - - MSG_YIELDED = "Use `&` as an argument name to indicate that it won't be referenced." - - def test(source) - AST::ScopeVisitor.new self, source - end - - def test(source, node : Crystal::Def, scope : AST::Scope) - return if node.abstract? - - return unless block_arg = node.block_arg - return unless block_arg = scope.arguments.find(&.node.== block_arg) - - return if block_arg.anonymous? - return if scope.references?(block_arg.variable) - - location = block_arg.node.location - end_location = location.try &.adjust(column_number: block_arg.name.size - 1) - - case - when scope.yields? - if location && end_location - issue_for location, end_location, MSG_YIELDED do |corrector| - corrector.remove(location, end_location) - end - else - issue_for block_arg.node, MSG_YIELDED - end - when !block_arg.ignored? - if location && end_location - issue_for location, end_location, MSG_UNUSED % block_arg.name do |corrector| - corrector.insert_before(location, '_') - end - else - issue_for block_arg.node, MSG_UNUSED % block_arg.name - end - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/useless_assign.cr b/lib/ameba/src/ameba/rule/lint/useless_assign.cr deleted file mode 100644 index ed7d536d..00000000 --- a/lib/ameba/src/ameba/rule/lint/useless_assign.cr +++ /dev/null @@ -1,53 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows useless assignments. - # - # For example, this is considered invalid: - # - # ``` - # def method - # var = 1 - # do_something - # end - # ``` - # - # And has to be written as the following: - # - # ``` - # def method - # var = 1 - # do_something(var) - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/UselessAssign: - # Enabled: true - # ExcludeTypeDeclarations: false - # ``` - class UselessAssign < Base - properties do - description "Disallows useless variable assignments" - exclude_type_declarations false - end - - MSG = "Useless assignment to variable `%s`" - - def test(source) - AST::ScopeVisitor.new self, source - end - - def test(source, node, scope : AST::Scope) - scope.variables.each do |var| - next if var.ignored? || var.used_in_macro? || var.captured_by_block? - next if exclude_type_declarations? && scope.assigns_type_dec?(var.name) - - var.assignments.each do |assign| - next if assign.referenced? - issue_for assign.target_node, MSG % var.name - end - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/lint/useless_condition_in_when.cr b/lib/ameba/src/ameba/rule/lint/useless_condition_in_when.cr deleted file mode 100644 index da3f47ab..00000000 --- a/lib/ameba/src/ameba/rule/lint/useless_condition_in_when.cr +++ /dev/null @@ -1,74 +0,0 @@ -module Ameba::Rule::Lint - # A rule that disallows useless conditions in when clause - # where it is guaranteed to always return the same result. - # - # For example, this is considered invalid: - # - # ``` - # case - # when utc? - # io << " UTC" - # when local? - # Format.new(" %:z").format(self, io) if local? - # end - # ``` - # - # And has to be written as the following: - # - # ``` - # case - # when utc? - # io << " UTC" - # when local? - # Format.new(" %:z").format(self, io) - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Lint/UselessConditionInWhen: - # Enabled: true - # ``` - class UselessConditionInWhen < Base - properties do - description "Disallows useless conditions in when" - end - - MSG = "Useless condition in when detected" - - # TODO: condition *cond* may be a complex ASTNode with - # useless inner conditions. We might need to improve this - # simple implementation in future. - protected def check_node(source, when_node, cond) - return unless cond_s = cond.to_s.presence - return if when_node.conds.none?(&.to_s.==(cond_s)) - - issue_for cond, MSG - end - - def test(source, node : Crystal::When) - ConditionInWhenVisitor.new self, source, node - end - - # :nodoc: - private class ConditionInWhenVisitor < Crystal::Visitor - @source : Source - @rule : UselessConditionInWhen - @parent : Crystal::When - - def initialize(@rule, @source, @parent) - @parent.accept self - end - - def visit(node : Crystal::If | Crystal::Unless) - @rule.check_node(@source, @parent, node.cond) - true - end - - def visit(node : Crystal::ASTNode) - true - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/metrics/cyclomatic_complexity.cr b/lib/ameba/src/ameba/rule/metrics/cyclomatic_complexity.cr deleted file mode 100644 index 51d6abce..00000000 --- a/lib/ameba/src/ameba/rule/metrics/cyclomatic_complexity.cr +++ /dev/null @@ -1,26 +0,0 @@ -module Ameba::Rule::Metrics - # A rule that disallows methods with a cyclomatic complexity higher than `MaxComplexity` - # - # YAML configuration example: - # - # ``` - # Metrics/CyclomaticComplexity: - # Enabled: true - # MaxComplexity: 10 - # ``` - class CyclomaticComplexity < Base - properties do - description "Disallows methods with a cyclomatic complexity higher than `MaxComplexity`" - max_complexity 10 - end - - MSG = "Cyclomatic complexity too high [%d/%d]" - - def test(source, node : Crystal::Def) - complexity = AST::CountingVisitor.new(node).count - return unless complexity > max_complexity - - issue_for node, MSG % {complexity, max_complexity}, prefer_name_location: true - end - end -end diff --git a/lib/ameba/src/ameba/rule/naming/accessor_method_name.cr b/lib/ameba/src/ameba/rule/naming/accessor_method_name.cr deleted file mode 100644 index 55b9cbd9..00000000 --- a/lib/ameba/src/ameba/rule/naming/accessor_method_name.cr +++ /dev/null @@ -1,77 +0,0 @@ -module Ameba::Rule::Naming - # A rule that makes sure that accessor methods are named properly. - # - # Favour this: - # - # ``` - # class Foo - # def user - # @user - # end - # - # def user=(value) - # @user = value - # end - # end - # ``` - # - # Over this: - # - # ``` - # class Foo - # def get_user - # @user - # end - # - # def set_user(value) - # @user = value - # end - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Naming/AccessorMethodName: - # Enabled: true - # ``` - class AccessorMethodName < Base - properties do - description "Makes sure that accessor methods are named properly" - end - - MSG = "Favour method name '%s' over '%s'" - - def test(source, node : Crystal::ClassDef | Crystal::ModuleDef) - defs = - case body = node.body - when Crystal::Def - [body] - when Crystal::Expressions - body.expressions.select(Crystal::Def) - end - - defs.try &.each do |def_node| - # skip defs with explicit receiver, as they'll be handled - # by the `test(source, node : Crystal::Def)` overload - check_issue(source, def_node) unless def_node.receiver - end - end - - def test(source, node : Crystal::Def) - # check only defs with explicit receiver (`def self.foo`) - check_issue(source, node) if node.receiver - end - - private def check_issue(source, node : Crystal::Def) - case node.name - when /^get_([a-z]\w*)$/ - return unless node.args.empty? - issue_for node, MSG % {$1, node.name}, prefer_name_location: true - when /^set_([a-z]\w*)$/ - return unless node.args.size == 1 - issue_for node, MSG % {"#{$1}=", node.name}, prefer_name_location: true - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/naming/ascii_identifiers.cr b/lib/ameba/src/ameba/rule/naming/ascii_identifiers.cr deleted file mode 100644 index f2739b47..00000000 --- a/lib/ameba/src/ameba/rule/naming/ascii_identifiers.cr +++ /dev/null @@ -1,87 +0,0 @@ -module Ameba::Rule::Naming - # A rule that reports non-ascii characters in identifiers. - # - # Favour this: - # - # ``` - # class BigAwesomeWolf - # end - # ``` - # - # Over this: - # - # ``` - # class BigAwesome๐Ÿบ - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Naming/AsciiIdentifiers: - # Enabled: true - # IgnoreSymbols: false - # ``` - class AsciiIdentifiers < Base - properties do - description "Disallows non-ascii characters in identifiers" - ignore_symbols false - end - - MSG = "Identifier contains non-ascii characters" - - def test(source, node : Crystal::Assign) - if (target = node.target).is_a?(Crystal::Path) - check_issue(source, target, target) - end - check_symbol_literal(source, node.value) - end - - def test(source, node : Crystal::MultiAssign) - node.values.each do |value| - check_symbol_literal(source, value) - end - end - - def test(source, node : Crystal::Call) - node.args.each do |arg| - check_symbol_literal(source, arg) - end - node.named_args.try &.each do |arg| - check_symbol_literal(source, arg.value) - end - end - - def test(source, node : Crystal::Def) - check_issue(source, node, prefer_name_location: true) - - node.args.each do |arg| - check_issue(source, arg, prefer_name_location: true) - check_symbol_literal(source, arg.default_value) - end - end - - def test(source, node : Crystal::ClassVar | Crystal::InstanceVar | Crystal::Var | Crystal::Alias) - check_issue(source, node, prefer_name_location: true) - end - - def test(source, node : Crystal::ClassDef | Crystal::ModuleDef | Crystal::EnumDef | Crystal::LibDef) - check_issue(source, node.name, node.name) - end - - private def check_symbol_literal(source, node) - return if ignore_symbols? - return unless node.is_a?(Crystal::SymbolLiteral) - - check_issue(source, node, node.value) - end - - private def check_issue(source, location, end_location, name) - issue_for location, end_location, MSG unless name.to_s.ascii_only? - end - - private def check_issue(source, node, name = node.name, *, prefer_name_location = false) - issue_for node, MSG, prefer_name_location: prefer_name_location unless name.to_s.ascii_only? - end - end -end diff --git a/lib/ameba/src/ameba/rule/naming/binary_operator_parameter_name.cr b/lib/ameba/src/ameba/rule/naming/binary_operator_parameter_name.cr deleted file mode 100644 index 16cc1ed6..00000000 --- a/lib/ameba/src/ameba/rule/naming/binary_operator_parameter_name.cr +++ /dev/null @@ -1,50 +0,0 @@ -module Ameba::Rule::Naming - # A rule that enforces that certain binary operator methods have - # their sole parameter named `other`. - # - # For example, this is considered valid: - # - # ``` - # class Money - # def +(other) - # end - # end - # ``` - # - # And this is invalid parameter name: - # - # ``` - # class Money - # def +(amount) - # end - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Naming/BinaryOperatorParameterName: - # Enabled: true - # ExcludedOperators: ["[]", "[]?", "[]=", "<<", ">>", "=~", "!~"] - # ``` - class BinaryOperatorParameterName < Base - properties do - description "Enforces that certain binary operator methods have " \ - "their sole parameter named `other`" - excluded_operators %w[[] []? []= << >> ` =~ !~] - end - - MSG = "When defining the `%s` operator, name its argument `other`" - - def test(source, node : Crystal::Def) - name = node.name - - return if name == "->" || name.in?(excluded_operators) - return if name.chars.any?(&.alphanumeric?) - return unless node.args.size == 1 - return if (arg = node.args.first).name == "other" - - issue_for arg, MSG % name - end - end -end diff --git a/lib/ameba/src/ameba/rule/naming/block_parameter_name.cr b/lib/ameba/src/ameba/rule/naming/block_parameter_name.cr deleted file mode 100644 index 71e6a6a8..00000000 --- a/lib/ameba/src/ameba/rule/naming/block_parameter_name.cr +++ /dev/null @@ -1,54 +0,0 @@ -module Ameba::Rule::Naming - # A rule that reports non-descriptive block parameter names. - # - # Favour this: - # - # ``` - # tokens.each { |token| token.last_accessed_at = Time.utc } - # ``` - # - # Over this: - # - # ``` - # tokens.each { |t| t.last_accessed_at = Time.utc } - # ``` - # - # YAML configuration example: - # - # ``` - # Naming/BlockParameterName: - # Enabled: true - # MinNameLength: 3 - # AllowNamesEndingInNumbers: true - # AllowedNames: [_, e, i, j, k, v, x, y, ex, io, ws, op, tx, id, ip, k1, k2, v1, v2] - # ForbiddenNames: [] - # ``` - class BlockParameterName < Base - properties do - description "Disallows non-descriptive block parameter names" - min_name_length 3 - allow_names_ending_in_numbers true - allowed_names %w[_ e i j k v x y ex io ws op tx id ip k1 k2 v1 v2] - forbidden_names %w[] - end - - MSG = "Disallowed block parameter name found" - - def test(source, node : Crystal::Call) - node.try(&.block).try(&.args).try &.each do |arg| - issue_for arg, MSG unless valid_name?(arg.name) - end - end - - private def valid_name?(name) - return true if name.blank? # TODO: handle unpacked variables - return true if name.in?(allowed_names) - - return false if name.in?(forbidden_names) - return false if name.size < min_name_length - return false if name[-1].ascii_number? && !allow_names_ending_in_numbers? - - true - end - end -end diff --git a/lib/ameba/src/ameba/rule/naming/constant_names.cr b/lib/ameba/src/ameba/rule/naming/constant_names.cr deleted file mode 100644 index 88f815fb..00000000 --- a/lib/ameba/src/ameba/rule/naming/constant_names.cr +++ /dev/null @@ -1,42 +0,0 @@ -module Ameba::Rule::Naming - # A rule that enforces constant names to be in screaming case. - # - # For example, these constant names are considered valid: - # - # ``` - # LUCKY_NUMBERS = [3, 7, 11] - # DOCUMENTATION_URL = "http://crystal-lang.org/docs" - # ``` - # - # And these are invalid names: - # - # ``` - # myBadConstant = 1 - # Wrong_NAME = 2 - # ``` - # - # YAML configuration example: - # - # ``` - # Naming/ConstantNames: - # Enabled: true - # ``` - class ConstantNames < Base - properties do - description "Enforces constant names to be in screaming case" - end - - MSG = "Constant name should be screaming-cased: %s, not %s" - - def test(source, node : Crystal::Assign) - return unless (target = node.target).is_a?(Crystal::Path) - - name = target.to_s - expected = name.upcase - - return if name.in?(expected, name.camelcase) - - issue_for target, MSG % {expected, name} - end - end -end diff --git a/lib/ameba/src/ameba/rule/naming/filename.cr b/lib/ameba/src/ameba/rule/naming/filename.cr deleted file mode 100644 index 1c73b767..00000000 --- a/lib/ameba/src/ameba/rule/naming/filename.cr +++ /dev/null @@ -1,28 +0,0 @@ -module Ameba::Rule::Naming - # A rule that enforces file names to be in underscored case. - # - # YAML configuration example: - # - # ``` - # Naming/Filename: - # Enabled: true - # ``` - class Filename < Base - properties do - description "Enforces file names to be in underscored case" - end - - MSG = "Filename should be underscore-cased: %s, not %s" - - private LOCATION = {1, 1} - - def test(source : Source) - path = Path[source.path] - name = path.basename - - return if (expected = name.underscore) == name - - issue_for LOCATION, MSG % {expected, name} - end - end -end diff --git a/lib/ameba/src/ameba/rule/naming/method_names.cr b/lib/ameba/src/ameba/rule/naming/method_names.cr deleted file mode 100644 index d434d9a7..00000000 --- a/lib/ameba/src/ameba/rule/naming/method_names.cr +++ /dev/null @@ -1,55 +0,0 @@ -module Ameba::Rule::Naming - # A rule that enforces method names to be in underscored case. - # - # For example, these are considered valid: - # - # ``` - # class Person - # def first_name - # end - # - # def date_of_birth - # end - # - # def homepage_url - # end - # end - # ``` - # - # And these are invalid method names: - # - # ``` - # class Person - # def firstName - # end - # - # def date_of_Birth - # end - # - # def homepageURL - # end - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Naming/MethodNames: - # Enabled: true - # ``` - class MethodNames < Base - properties do - description "Enforces method names to be in underscored case" - end - - MSG = "Method name should be underscore-cased: %s, not %s" - - def test(source, node : Crystal::Def) - name = node.name.to_s - - return if (expected = name.underscore) == name - - issue_for node, MSG % {expected, name}, prefer_name_location: true - end - end -end diff --git a/lib/ameba/src/ameba/rule/naming/predicate_name.cr b/lib/ameba/src/ameba/rule/naming/predicate_name.cr deleted file mode 100644 index b3935f2b..00000000 --- a/lib/ameba/src/ameba/rule/naming/predicate_name.cr +++ /dev/null @@ -1,40 +0,0 @@ -module Ameba::Rule::Naming - # A rule that disallows tautological predicate names - - # meaning those that start with the prefix `is_`, except for - # the ones that are not valid Crystal code (e.g. `is_404?`). - # - # Favour this: - # - # ``` - # def valid?(x) - # end - # ``` - # - # Over this: - # - # ``` - # def is_valid?(x) - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Naming/PredicateName: - # Enabled: true - # ``` - class PredicateName < Base - properties do - description "Disallows tautological predicate names" - end - - MSG = "Favour method name '%s?' over '%s'" - - def test(source, node : Crystal::Def) - return unless node.name =~ /^is_([a-z]\w*)\??$/ - alternative = $1 - - issue_for node, MSG % {alternative, node.name}, prefer_name_location: true - end - end -end diff --git a/lib/ameba/src/ameba/rule/naming/query_bool_methods.cr b/lib/ameba/src/ameba/rule/naming/query_bool_methods.cr deleted file mode 100644 index a0ecaebb..00000000 --- a/lib/ameba/src/ameba/rule/naming/query_bool_methods.cr +++ /dev/null @@ -1,70 +0,0 @@ -module Ameba::Rule::Naming - # A rule that disallows boolean properties without the `?` suffix - defined - # using `Object#(class_)property` or `Object#(class_)getter` macros. - # - # Favour this: - # - # ``` - # class Person - # property? deceased = false - # getter? witty = true - # end - # ``` - # - # Over this: - # - # ``` - # class Person - # property deceased = false - # getter witty = true - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Naming/QueryBoolMethods: - # Enabled: true - # ``` - class QueryBoolMethods < Base - include AST::Util - - properties do - description "Reports boolean properties without the `?` suffix" - end - - MSG = "Consider using '%s?' for '%s'" - - CALL_NAMES = %w[getter class_getter property class_property] - - def test(source, node : Crystal::ClassDef | Crystal::ModuleDef) - calls = - case body = node.body - when Crystal::Call - [body] if body.name.in?(CALL_NAMES) - when Crystal::Expressions - body.expressions - .select(Crystal::Call) - .select!(&.name.in?(CALL_NAMES)) - end - - calls.try &.each do |exp| - exp.args.each do |arg| - name_node, is_bool = - case arg - when Crystal::Assign - {arg.target, arg.value.is_a?(Crystal::BoolLiteral)} - when Crystal::TypeDeclaration - {arg.var, path_named?(arg.declared_type, "Bool")} - else - {nil, false} - end - - if name_node && is_bool - issue_for name_node, MSG % {exp.name, name_node} - end - end - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/naming/rescued_exceptions_variable_name.cr b/lib/ameba/src/ameba/rule/naming/rescued_exceptions_variable_name.cr deleted file mode 100644 index f1230c51..00000000 --- a/lib/ameba/src/ameba/rule/naming/rescued_exceptions_variable_name.cr +++ /dev/null @@ -1,51 +0,0 @@ -module Ameba::Rule::Naming - # A rule that makes sure that rescued exceptions variables are named as expected. - # - # For example, these are considered valid: - # - # def foo - # # potentially raising computations - # rescue e - # Log.error(exception: e) { "Error" } - # end - # - # And these are invalid variable names: - # - # def foo - # # potentially raising computations - # rescue wtf - # Log.error(exception: wtf) { "Error" } - # end - # - # YAML configuration example: - # - # ``` - # Naming/RescuedExceptionsVariableName: - # Enabled: true - # AllowedNames: [e, ex, exception, error] - # ``` - class RescuedExceptionsVariableName < Base - properties do - description "Makes sure that rescued exceptions variables are named as expected" - allowed_names %w[e ex exception error] - end - - MSG = "Disallowed variable name, use one of these instead: '%s'" - MSG_SINGULAR = "Disallowed variable name, use '%s' instead" - - def test(source, node : Crystal::ExceptionHandler) - node.rescues.try &.each do |rescue_node| - next if valid_name?(rescue_node.name) - - message = - allowed_names.size == 1 ? MSG_SINGULAR : MSG - - issue_for rescue_node, message % allowed_names.join("', '") - end - end - - private def valid_name?(name) - !name || name.in?(allowed_names) - end - end -end diff --git a/lib/ameba/src/ameba/rule/naming/type_names.cr b/lib/ameba/src/ameba/rule/naming/type_names.cr deleted file mode 100644 index fb472471..00000000 --- a/lib/ameba/src/ameba/rule/naming/type_names.cr +++ /dev/null @@ -1,69 +0,0 @@ -module Ameba::Rule::Naming - # A rule that enforces type names in camelcase manner. - # - # For example, these are considered valid: - # - # ``` - # class ParseError < Exception - # end - # - # module HTTP - # class RequestHandler - # end - # end - # - # alias NumericValue = Float32 | Float64 | Int32 | Int64 - # - # lib LibYAML - # end - # - # struct TagDirective - # end - # - # enum Time::DayOfWeek - # end - # ``` - # - # And these are invalid type names - # - # ``` - # class My_class - # end - # - # module HTT_p - # end - # - # alias Numeric_value = Int32 - # - # lib Lib_YAML - # end - # - # struct Tag_directive - # end - # - # enum Time_enum::Day_of_week - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Naming/TypeNames: - # Enabled: true - # ``` - class TypeNames < Base - properties do - description "Enforces type names in camelcase manner" - end - - MSG = "Type name should be camelcased: %s, but it was %s" - - def test(source, node : Crystal::Alias | Crystal::ClassDef | Crystal::ModuleDef | Crystal::LibDef | Crystal::EnumDef) - name = node.name.to_s - - return if (expected = name.camelcase) == name - - issue_for node.name, MSG % {expected, name} - end - end -end diff --git a/lib/ameba/src/ameba/rule/naming/variable_names.cr b/lib/ameba/src/ameba/rule/naming/variable_names.cr deleted file mode 100644 index fe28b3eb..00000000 --- a/lib/ameba/src/ameba/rule/naming/variable_names.cr +++ /dev/null @@ -1,59 +0,0 @@ -module Ameba::Rule::Naming - # A rule that enforces variable names to be in underscored case. - # - # For example, these variable names are considered valid: - # - # ``` - # var_name = 1 - # name = 2 - # _another_good_name = 3 - # ``` - # - # And these are invalid variable names: - # - # ``` - # myBadNamedVar = 1 - # wrong_Name = 2 - # ``` - # - # YAML configuration example: - # - # ``` - # Naming/VariableNames: - # Enabled: true - # ``` - class VariableNames < Base - properties do - description "Enforces variable names to be in underscored case" - end - - MSG = "Var name should be underscore-cased: %s, not %s" - - def test(source : Source) - VarVisitor.new self, source - end - - def test(source, node : Crystal::Var | Crystal::InstanceVar | Crystal::ClassVar) - name = node.name.to_s - - return if (expected = name.underscore) == name - - issue_for node, MSG % {expected, name} - end - - private class VarVisitor < AST::NodeVisitor - private getter var_locations = [] of Crystal::Location - - def visit(node : Crystal::Var) - !node.location.in?(var_locations) && super - end - - def visit(node : Crystal::InstanceVar | Crystal::ClassVar) - if location = node.location - var_locations << location - end - super - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/performance/any_after_filter.cr b/lib/ameba/src/ameba/rule/performance/any_after_filter.cr deleted file mode 100644 index c51f6b28..00000000 --- a/lib/ameba/src/ameba/rule/performance/any_after_filter.cr +++ /dev/null @@ -1,47 +0,0 @@ -require "./base" - -module Ameba::Rule::Performance - # This rule is used to identify usage of `any?` calls that follow filters. - # - # For example, this is considered invalid: - # - # ``` - # [1, 2, 3].select { |e| e > 2 }.any? - # [1, 2, 3].reject { |e| e >= 2 }.any? - # ``` - # - # And it should be written as this: - # - # ``` - # [1, 2, 3].any? { |e| e > 2 } - # [1, 2, 3].any? { |e| e < 2 } - # ``` - # - # YAML configuration example: - # - # ``` - # Performance/AnyAfterFilter: - # Enabled: true - # FilterNames: - # - select - # - reject - # ``` - class AnyAfterFilter < Base - include AST::Util - - properties do - description "Identifies usage of `any?` calls that follow filters" - filter_names %w[select reject] - end - - MSG = "Use `any? {...}` instead of `%s {...}.any?`" - - def test(source, node : Crystal::Call) - return unless node.name == "any?" && (obj = node.obj) - return unless obj.is_a?(Crystal::Call) && obj.block && node.block.nil? - return unless obj.name.in?(filter_names) - - issue_for name_location(obj), name_end_location(node), MSG % obj.name - end - end -end diff --git a/lib/ameba/src/ameba/rule/performance/any_instead_of_empty.cr b/lib/ameba/src/ameba/rule/performance/any_instead_of_empty.cr deleted file mode 100644 index 88ecc106..00000000 --- a/lib/ameba/src/ameba/rule/performance/any_instead_of_empty.cr +++ /dev/null @@ -1,45 +0,0 @@ -require "./base" - -module Ameba::Rule::Performance - # This rule is used to identify usage of arg-less `Enumerable#any?` calls. - # - # Using `Enumerable#any?` instead of `Enumerable#empty?` might lead to an - # unexpected results (like `[nil, false].any? # => false`). In some cases - # it also might be less efficient, since it iterates until the block will - # return a _truthy_ value, instead of just checking if there's at least - # one value present. - # - # For example, this is considered invalid: - # - # ``` - # [1, 2, 3].any? - # ``` - # - # And it should be written as this: - # - # ``` - # ![1, 2, 3].empty? - # ``` - # - # YAML configuration example: - # - # ``` - # Performance/AnyInsteadOfEmpty: - # Enabled: true - # ``` - class AnyInsteadOfEmpty < Base - properties do - description "Identifies usage of arg-less `any?` calls" - end - - MSG = "Use `!{...}.empty?` instead of `{...}.any?`" - - def test(source, node : Crystal::Call) - return unless node.name == "any?" - return unless node.block.nil? && node.args.empty? - return unless node.obj - - issue_for node, MSG, prefer_name_location: true - end - end -end diff --git a/lib/ameba/src/ameba/rule/performance/base.cr b/lib/ameba/src/ameba/rule/performance/base.cr deleted file mode 100644 index 405cf97a..00000000 --- a/lib/ameba/src/ameba/rule/performance/base.cr +++ /dev/null @@ -1,10 +0,0 @@ -require "../base" - -module Ameba::Rule::Performance - # A general base class for performance rules. - abstract class Base < Ameba::Rule::Base - def catch(source : Source) - source.spec? ? source : super - end - end -end diff --git a/lib/ameba/src/ameba/rule/performance/chained_call_with_no_bang.cr b/lib/ameba/src/ameba/rule/performance/chained_call_with_no_bang.cr deleted file mode 100644 index a5d2d0e7..00000000 --- a/lib/ameba/src/ameba/rule/performance/chained_call_with_no_bang.cr +++ /dev/null @@ -1,79 +0,0 @@ -require "./base" - -module Ameba::Rule::Performance - # This rule is used to identify usage of chained calls not utilizing - # the bang method variants. - # - # For example, this is considered inefficient: - # - # ``` - # names = %w[Alice Bob] - # chars = names - # .flat_map(&.chars) - # .uniq - # .sort - # ``` - # - # And can be written as this: - # - # ``` - # names = %w[Alice Bob] - # chars = names - # .flat_map(&.chars) - # .uniq! - # .sort! - # ``` - # - # YAML configuration example: - # - # ``` - # Performance/ChainedCallWithNoBang: - # Enabled: true - # CallNames: - # - uniq - # - sort - # - sort_by - # - shuffle - # - reverse - # ``` - class ChainedCallWithNoBang < Base - include AST::Util - - properties do - description "Identifies usage of chained calls not utilizing the bang method variants" - - # All of those have bang method variants returning `self` - # and are not modifying the receiver type (like `compact` does), - # thus are safe to switch to the bang variant. - call_names %w[uniq sort sort_by shuffle reverse] - end - - MSG = "Use bang method variant `%s!` after chained `%s` call" - - # All these methods allocate a new object - ALLOCATING_METHOD_NAMES = %w[ - keys values values_at map map_with_index flat_map compact_map - flatten compact select reject sample group_by chunks tally merge - combinations repeated_combinations permutations repeated_permutations - transpose invert chars captures named_captures clone - ] - - def test(source) - AST::NodeVisitor.new self, source, skip: :macro - end - - def test(source, node : Crystal::Call) - return unless (obj = node.obj).is_a?(Crystal::Call) - return unless node.name.in?(call_names) - return unless obj.name.in?(call_names) || obj.name.in?(ALLOCATING_METHOD_NAMES) - - if end_location = name_end_location(node) - issue_for node, MSG % {node.name, obj.name}, prefer_name_location: true do |corrector| - corrector.insert_after(end_location, '!') - end - else - issue_for node, MSG % {node.name, obj.name}, prefer_name_location: true - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/performance/compact_after_map.cr b/lib/ameba/src/ameba/rule/performance/compact_after_map.cr deleted file mode 100644 index 7ec44a1d..00000000 --- a/lib/ameba/src/ameba/rule/performance/compact_after_map.cr +++ /dev/null @@ -1,45 +0,0 @@ -require "./base" - -module Ameba::Rule::Performance - # This rule is used to identify usage of `compact` calls that follow `map`. - # - # For example, this is considered inefficient: - # - # ``` - # %w[Alice Bob].map(&.match(/^A./)).compact - # ``` - # - # And can be written as this: - # - # ``` - # %w[Alice Bob].compact_map(&.match(/^A./)) - # ``` - # - # YAML configuration example: - # - # ``` - # Performance/CompactAfterMap: - # Enabled: true - # ``` - class CompactAfterMap < Base - include AST::Util - - properties do - description "Identifies usage of `compact` calls that follow `map`" - end - - MSG = "Use `compact_map {...}` instead of `map {...}.compact`" - - def test(source) - AST::NodeVisitor.new self, source, skip: :macro - end - - def test(source, node : Crystal::Call) - return unless node.name == "compact" && (obj = node.obj) - return unless obj.is_a?(Crystal::Call) && obj.block - return unless obj.name == "map" - - issue_for name_location(obj), name_end_location(node), MSG - end - end -end diff --git a/lib/ameba/src/ameba/rule/performance/excessive_allocations.cr b/lib/ameba/src/ameba/rule/performance/excessive_allocations.cr deleted file mode 100644 index e8677d74..00000000 --- a/lib/ameba/src/ameba/rule/performance/excessive_allocations.cr +++ /dev/null @@ -1,70 +0,0 @@ -require "./base" - -module Ameba::Rule::Performance - # This rule is used to identify excessive collection allocations, - # that can be avoided by using `each_` instead of `.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? - 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 = name_location(obj) - 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 diff --git a/lib/ameba/src/ameba/rule/performance/first_last_after_filter.cr b/lib/ameba/src/ameba/rule/performance/first_last_after_filter.cr deleted file mode 100644 index 4290ba4c..00000000 --- a/lib/ameba/src/ameba/rule/performance/first_last_after_filter.cr +++ /dev/null @@ -1,57 +0,0 @@ -require "./base" - -module Ameba::Rule::Performance - # This rule is used to identify usage of `first/last/first?/last?` calls that follow filters. - # - # For example, this is considered inefficient: - # - # ``` - # [-1, 0, 1, 2].select { |e| e > 0 }.first? - # [-1, 0, 1, 2].select { |e| e > 0 }.last? - # ``` - # - # And can be written as this: - # - # ``` - # [-1, 0, 1, 2].find { |e| e > 0 } - # [-1, 0, 1, 2].reverse_each.find { |e| e > 0 } - # ``` - # - # YAML configuration example: - # - # ``` - # Performance/FirstLastAfterFilter - # Enabled: true - # FilterNames: - # - select - # ``` - class FirstLastAfterFilter < Base - include AST::Util - - properties do - description "Identifies usage of `first/last/first?/last?` calls that follow filters" - filter_names %w[select] - end - - MSG = "Use `find {...}` instead of `%s {...}.%s`" - MSG_REVERSE = "Use `reverse_each.find {...}` instead of `%s {...}.%s`" - - CALL_NAMES = %w[first last first? last?] - - def test(source) - AST::NodeVisitor.new self, source, skip: :macro - end - - def test(source, node : Crystal::Call) - return unless node.name.in?(CALL_NAMES) && (obj = node.obj) - return unless obj.is_a?(Crystal::Call) && obj.block - return unless node.block.nil? && node.args.empty? - return unless obj.name.in?(filter_names) - - message = node.name.includes?(CALL_NAMES.first) ? MSG : MSG_REVERSE - - issue_for name_location(obj), name_end_location(node), - message % {obj.name, node.name} - end - end -end diff --git a/lib/ameba/src/ameba/rule/performance/flatten_after_map.cr b/lib/ameba/src/ameba/rule/performance/flatten_after_map.cr deleted file mode 100644 index cbdd4f1f..00000000 --- a/lib/ameba/src/ameba/rule/performance/flatten_after_map.cr +++ /dev/null @@ -1,45 +0,0 @@ -require "./base" - -module Ameba::Rule::Performance - # This rule is used to identify usage of `flatten` calls that follow `map`. - # - # For example, this is considered inefficient: - # - # ``` - # %w[Alice Bob].map(&.chars).flatten - # ``` - # - # And can be written as this: - # - # ``` - # %w[Alice Bob].flat_map(&.chars) - # ``` - # - # YAML configuration example: - # - # ``` - # Performance/FlattenAfterMap: - # Enabled: true - # ``` - class FlattenAfterMap < Base - include AST::Util - - properties do - description "Identifies usage of `flatten` calls that follow `map`" - end - - MSG = "Use `flat_map {...}` instead of `map {...}.flatten`" - - def test(source) - AST::NodeVisitor.new self, source, skip: :macro - end - - def test(source, node : Crystal::Call) - return unless node.name == "flatten" && (obj = node.obj) - return unless obj.is_a?(Crystal::Call) && obj.block - return unless obj.name == "map" - - issue_for name_location(obj), name_end_location(node), MSG - end - end -end diff --git a/lib/ameba/src/ameba/rule/performance/map_instead_of_block.cr b/lib/ameba/src/ameba/rule/performance/map_instead_of_block.cr deleted file mode 100644 index cb4f6fd6..00000000 --- a/lib/ameba/src/ameba/rule/performance/map_instead_of_block.cr +++ /dev/null @@ -1,49 +0,0 @@ -require "./base" - -module Ameba::Rule::Performance - # This rule is used to identify usage of `sum/product` calls - # that follow `map`. - # - # For example, this is considered inefficient: - # - # ``` - # (1..3).map(&.*(2)).sum - # ``` - # - # And can be written as this: - # - # ``` - # (1..3).sum(&.*(2)) - # ``` - # - # YAML configuration example: - # - # ``` - # Performance/MapInsteadOfBlock: - # Enabled: true - # ``` - class MapInsteadOfBlock < Base - include AST::Util - - properties do - description "Identifies usage of `sum/product` calls that follow `map`" - end - - MSG = "Use `%s {...}` instead of `map {...}.%s`" - - CALL_NAMES = %w[sum product] - - def test(source) - AST::NodeVisitor.new self, source, skip: :macro - end - - def test(source, node : Crystal::Call) - return unless node.name.in?(CALL_NAMES) && (obj = node.obj) - return unless obj.is_a?(Crystal::Call) && obj.block - return unless obj.name == "map" - - issue_for name_location(obj), name_end_location(node), - MSG % {node.name, node.name} - end - end -end diff --git a/lib/ameba/src/ameba/rule/performance/minmax_after_map.cr b/lib/ameba/src/ameba/rule/performance/minmax_after_map.cr deleted file mode 100644 index 803c84e1..00000000 --- a/lib/ameba/src/ameba/rule/performance/minmax_after_map.cr +++ /dev/null @@ -1,69 +0,0 @@ -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 - include AST::Util - - 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 = name_location(obj) - return unless end_location = name_end_location(node) - - 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 = name_location(node) - - # 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 diff --git a/lib/ameba/src/ameba/rule/performance/size_after_filter.cr b/lib/ameba/src/ameba/rule/performance/size_after_filter.cr deleted file mode 100644 index 254576de..00000000 --- a/lib/ameba/src/ameba/rule/performance/size_after_filter.cr +++ /dev/null @@ -1,57 +0,0 @@ -require "./base" - -module Ameba::Rule::Performance - # This rule is used to identify usage of `size` calls that follow filter. - # - # For example, this is considered invalid: - # - # ``` - # [1, 2, 3].select { |e| e > 2 }.size - # [1, 2, 3].reject { |e| e < 2 }.size - # [1, 2, 3].select(&.< 2).size - # [0, 1, 2].select(&.zero?).size - # [0, 1, 2].reject(&.zero?).size - # ``` - # - # And it should be written as this: - # - # ``` - # [1, 2, 3].count { |e| e > 2 } - # [1, 2, 3].count { |e| e >= 2 } - # [1, 2, 3].count(&.< 2) - # [0, 1, 2].count(&.zero?) - # [0, 1, 2].count(&.!= 0) - # ``` - # - # YAML configuration example: - # - # ``` - # Performance/SizeAfterFilter: - # Enabled: true - # FilterNames: - # - select - # - reject - # ``` - class SizeAfterFilter < Base - include AST::Util - - properties do - description "Identifies usage of `size` calls that follow filter" - filter_names %w[select reject] - end - - MSG = "Use `count {...}` instead of `%s {...}.size`." - - def test(source) - AST::NodeVisitor.new self, source, skip: :macro - end - - def test(source, node : Crystal::Call) - return unless node.name == "size" && (obj = node.obj) - return unless obj.is_a?(Crystal::Call) && obj.block - return unless obj.name.in?(filter_names) - - issue_for name_location(obj), name_end_location(node), MSG % obj.name - end - end -end diff --git a/lib/ameba/src/ameba/rule/style/guard_clause.cr b/lib/ameba/src/ameba/rule/style/guard_clause.cr deleted file mode 100644 index 348fd85c..00000000 --- a/lib/ameba/src/ameba/rule/style/guard_clause.cr +++ /dev/null @@ -1,186 +0,0 @@ -module Ameba::Rule::Style - # Use a guard clause instead of wrapping the code inside a conditional - # expression - # - # ``` - # # bad - # def test - # if something - # work - # end - # end - # - # # good - # def test - # return unless something - # - # work - # end - # - # # also good - # def test - # work if something - # end - # - # # bad - # if something - # raise "exception" - # else - # ok - # end - # - # # good - # raise "exception" if something - # ok - # - # # bad - # if something - # foo || raise("exception") - # else - # ok - # end - # - # # good - # foo || raise("exception") if something - # ok - # ``` - # - # YAML configuration example: - # - # ``` - # Style/GuardClause: - # Enabled: true - # ``` - class GuardClause < Base - include AST::Util - - properties do - enabled false - description "Check for conditionals that can be replaced with guard clauses" - end - - MSG = "Use a guard clause (`%s`) instead of wrapping the " \ - "code inside a conditional expression." - - def test(source) - AST::NodeVisitor.new self, source, skip: [ - Crystal::Assign, - ] - end - - def test(source, node : Crystal::Def) - final_expression = - if (body = node.body).is_a?(Crystal::Expressions) - body.last - else - body - end - - case final_expression - when Crystal::If, Crystal::Unless - check_ending_if(source, final_expression) - end - end - - def test(source, node : Crystal::If | Crystal::Unless) - return if accepted_form?(source, node, ending: false) - - case - when guard_clause = guard_clause(node.then) - parent, conditional_keyword = node.then, keyword(node) - when guard_clause = guard_clause(node.else) - parent, conditional_keyword = node.else, opposite_keyword(node) - end - - return unless guard_clause && parent && conditional_keyword - - guard_clause_source = guard_clause_source(source, guard_clause, parent) - report_issue(source, node, guard_clause_source, conditional_keyword) - end - - private def check_ending_if(source, node) - return if accepted_form?(source, node, ending: true) - - report_issue(source, node, "return", opposite_keyword(node)) - end - - private def report_issue(source, node, scope_exiting_keyword, conditional_keyword) - return unless keyword_loc = node.location - return unless cond_code = node_source(node.cond, source.lines) - - keyword_end_loc = keyword_loc.adjust(column_number: keyword(node).size - 1) - - example = "#{scope_exiting_keyword} #{conditional_keyword} #{cond_code}" - # TODO: check if example is too long for single line - - if node.else.is_a?(Crystal::Nop) - return unless end_end_loc = node.end_location - - end_loc = end_end_loc.adjust(column_number: {{ 1 - "end".size }}) - - issue_for keyword_loc, keyword_end_loc, MSG % example do |corrector| - replacement = "#{scope_exiting_keyword} #{conditional_keyword}" - - corrector.replace(keyword_loc, keyword_end_loc, replacement) - corrector.remove(end_loc, end_end_loc) - end - else - issue_for keyword_loc, keyword_end_loc, MSG % example - end - end - - private def keyword(node : Crystal::If) - "if" - end - - private def keyword(node : Crystal::Unless) - "unless" - end - - private def opposite_keyword(node : Crystal::If) - "unless" - end - - private def opposite_keyword(node : Crystal::Unless) - "if" - end - - private def accepted_form?(source, node, ending) - return true if node.is_a?(Crystal::If) && node.ternary? - return true unless cond_loc = node.cond.location - return true unless cond_end_loc = node.cond.end_location - return true unless cond_loc.line_number == cond_end_loc.line_number - return true unless (then_loc = node.then.location).nil? || cond_loc < then_loc - - if ending - !node.else.is_a?(Crystal::Nop) - else - return true if node.else.is_a?(Crystal::Nop) - return true unless code = node_source(node, source.lines) - - code.starts_with?("elsif") - end - end - - private def guard_clause(node) - node = node.right if node.is_a?(Crystal::BinaryOp) - - return unless location = node.location - return unless end_location = node.end_location - return unless location.line_number == end_location.line_number - - case node - when Crystal::Call - node if node.obj.nil? && node.name == "raise" - when Crystal::Return, Crystal::Break, Crystal::Next - node - end - end - - def guard_clause_source(source, guard_clause, parent) - node = parent.is_a?(Crystal::BinaryOp) ? parent : guard_clause - - node_source(node, source.lines) - end - end -end diff --git a/lib/ameba/src/ameba/rule/style/is_a_filter.cr b/lib/ameba/src/ameba/rule/style/is_a_filter.cr deleted file mode 100644 index 1a252fc0..00000000 --- a/lib/ameba/src/ameba/rule/style/is_a_filter.cr +++ /dev/null @@ -1,90 +0,0 @@ -module Ameba::Rule::Style - # This rule is used to identify usage of `is_a?/nil?` calls within filters. - # - # For example, this is considered invalid: - # - # ``` - # matches = %w[Alice Bob].map(&.match(/^A./)) - # - # matches.any?(&.is_a?(Regex::MatchData)) # => true - # matches.one?(&.nil?) # => true - # - # typeof(matches.reject(&.nil?)) # => Array(Regex::MatchData | Nil) - # typeof(matches.select(&.is_a?(Regex::MatchData))) # => Array(Regex::MatchData | Nil) - # ``` - # - # And it should be written as this: - # - # ``` - # matches = %w[Alice Bob].map(&.match(/^A./)) - # - # matches.any?(Regex::MatchData) # => true - # matches.one?(Nil) # => true - # - # typeof(matches.reject(Nil)) # => Array(Regex::MatchData) - # typeof(matches.select(Regex::MatchData)) # => Array(Regex::MatchData) - # ``` - # - # YAML configuration example: - # - # ``` - # Style/IsAFilter: - # Enabled: true - # FilterNames: - # - select - # - reject - # - any? - # - all? - # - none? - # - one? - # ``` - class IsAFilter < Base - include AST::Util - - properties do - description "Identifies usage of `is_a?/nil?` calls within filters" - filter_names %w[select reject any? all? none? one?] - end - - MSG = "Use `%s` instead of `%s`" - - OLD = "%s {...}" - NEW = "%s(%s)" - - def test(source) - AST::NodeVisitor.new self, source, skip: :macro - end - - def test(source, node : Crystal::Call) - return unless node.name.in?(filter_names) - return unless filter_location = name_location(node) - return unless block = node.block - return unless (body = block.body).is_a?(Crystal::IsA) - return unless (path = body.const).is_a?(Crystal::Path) - return unless body.obj.is_a?(Crystal::Var) - return if block.args.size > 1 - - name = path.names.join("::") - name = "::#{name}" if path.global? && !body.nil_check? - - end_location = node.end_location - if !end_location || end_location.try(&.column_number.zero?) - if end_location = path.end_location - end_location = end_location.adjust(column_number: 1) - end - end - - old = OLD % node.name - new = NEW % {node.name, name} - msg = MSG % {new, old} - - if end_location - issue_for(filter_location, end_location, msg) do |corrector| - corrector.replace(filter_location, end_location, new) - end - else - issue_for(filter_location, nil, msg) - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/style/is_a_nil.cr b/lib/ameba/src/ameba/rule/style/is_a_nil.cr deleted file mode 100644 index 06681261..00000000 --- a/lib/ameba/src/ameba/rule/style/is_a_nil.cr +++ /dev/null @@ -1,42 +0,0 @@ -module Ameba::Rule::Style - # A rule that disallows calls to `is_a?(Nil)` in favor of `nil?`. - # - # This is considered bad: - # - # ``` - # var.is_a?(Nil) - # ``` - # - # And needs to be written as: - # - # ``` - # var.nil? - # ``` - # - # YAML configuration example: - # - # ``` - # Style/IsANil: - # Enabled: true - # ``` - class IsANil < Base - include AST::Util - - properties do - description "Disallows calls to `is_a?(Nil)` in favor of `nil?`" - end - - MSG = "Use `nil?` instead of `is_a?(Nil)`" - - def test(source, node : Crystal::IsA) - return if node.nil_check? - - const = node.const - return unless path_named?(const, "Nil") - - issue_for const, MSG do |corrector| - corrector.replace(node, "#{node.obj}.nil?") - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/style/large_numbers.cr b/lib/ameba/src/ameba/rule/style/large_numbers.cr deleted file mode 100644 index b439ba61..00000000 --- a/lib/ameba/src/ameba/rule/style/large_numbers.cr +++ /dev/null @@ -1,113 +0,0 @@ -module Ameba::Rule::Style - # A rule that disallows usage of large numbers without underscore. - # These do not affect the value of the number, but can help read - # large numbers more easily. - # - # For example, these are considered invalid: - # - # ``` - # 100000 - # 141592654 - # 5.123456 - # ``` - # - # And has to be rewritten as the following: - # - # ``` - # 100_000 - # 141_592_654 - # 5.123_456 - # ``` - # - # YAML configuration example: - # - # ``` - # Style/LargeNumbers: - # Enabled: true - # IntMinDigits: 6 # i.e. integers higher than 99999 - # ``` - class LargeNumbers < Base - properties do - enabled false - description "Disallows usage of large numbers without underscore" - int_min_digits 6 - end - - MSG = "Large numbers should be written with underscores: %s" - - def test(source) - Tokenizer.new(source).run do |token| - next unless token.type.number? && decimal?(token.raw) - - parsed = parse_number(token.raw) - - if allowed?(*parsed) && (expected = underscored *parsed) != token.raw - location = token.location - end_location = location.adjust(column_number: token.raw.size - 1) - - issue_for location, end_location, MSG % expected do |corrector| - corrector.replace(location, end_location, expected) - end - end - end - end - - private def decimal?(value) - value !~ /^0(x|b|o)/ - end - - private def allowed?(_sign, value, fraction, _suffix) - return true if fraction && fraction.size > 3 - - digits = value.chars.select!(&.number?) - digits.size >= int_min_digits - end - - private def underscored(sign, value, fraction, suffix) - value = slice_digits(value.reverse).reverse - fraction = ".#{slice_digits(fraction)}" if fraction - - "#{sign}#{value}#{fraction}#{suffix}" - end - - private def slice_digits(value, by = 3) - %w[].tap do |slices| - value.chars.reject!(&.== '_').each_slice(by) do |slice| - slices << slice.join - end - end.join('_') - end - - private def parse_number(value) - value, sign = parse_sign(value) - value, suffix = parse_suffix(value) - value, fraction = parse_fraction(value) - - {sign, value, fraction, suffix} - end - - private def parse_sign(value) - if value[0].in?('+', '-') - sign = value[0] - value = value[1..-1] - end - {value, sign} - end - - private def parse_suffix(value) - if pos = (value =~ /(e|_?(i|u|f))/) - suffix = value[pos..-1] - value = value[0..pos - 1] - end - {value, suffix} - end - - private def parse_fraction(value) - if comma = value.index('.') - fraction = value[comma + 1..-1] - value = value[0..comma - 1] - end - {value, fraction} - end - end -end diff --git a/lib/ameba/src/ameba/rule/style/negated_conditions_in_unless.cr b/lib/ameba/src/ameba/rule/style/negated_conditions_in_unless.cr deleted file mode 100644 index 129ba274..00000000 --- a/lib/ameba/src/ameba/rule/style/negated_conditions_in_unless.cr +++ /dev/null @@ -1,53 +0,0 @@ -module Ameba::Rule::Style - # A rule that disallows negated conditions in unless. - # - # For example, this is considered invalid: - # - # ``` - # unless !s.empty? - # :ok - # end - # ``` - # - # And should be rewritten to the following: - # - # ``` - # if s.empty? - # :ok - # end - # ``` - # - # It is pretty difficult to wrap your head around a block of code - # that is executed if a negated condition is NOT met. - # - # YAML configuration example: - # - # ``` - # Style/NegatedConditionsInUnless: - # Enabled: true - # ``` - class NegatedConditionsInUnless < Base - properties do - description "Disallows negated conditions in unless" - end - - MSG = "Avoid negated conditions in unless blocks" - - def test(source, node : Crystal::Unless) - issue_for node, MSG if negated_condition?(node.cond) - end - - private def negated_condition?(node) - case node - when Crystal::BinaryOp - negated_condition?(node.left) || negated_condition?(node.right) - when Crystal::Expressions - node.expressions.any? { |exp| negated_condition?(exp) } - when Crystal::Not - true - else - false - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/style/parentheses_around_condition.cr b/lib/ameba/src/ameba/rule/style/parentheses_around_condition.cr deleted file mode 100644 index 112d3a64..00000000 --- a/lib/ameba/src/ameba/rule/style/parentheses_around_condition.cr +++ /dev/null @@ -1,81 +0,0 @@ -module Ameba::Rule::Style - # A rule that checks for the presence of superfluous parentheses - # around the condition of `if`, `unless`, `case`, `while` and `until`. - # - # For example, this is considered invalid: - # - # ``` - # if (foo == 42) - # do_something - # end - # ``` - # - # And should be replaced by the following: - # - # ``` - # if foo == 42 - # do_something - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Style/ParenthesesAroundCondition: - # Enabled: true - # ExcludeTernary: false - # AllowSafeAssignment: false - # ``` - class ParenthesesAroundCondition < Base - properties do - description "Disallows redundant parentheses around control expressions" - - exclude_ternary false - allow_safe_assignment false - end - - MSG_REDUNDANT = "Redundant parentheses" - MSG_MISSING = "Missing parentheses" - - protected def strip_parentheses?(node, in_ternary) : Bool - case node - when Crystal::BinaryOp, Crystal::ExceptionHandler - !in_ternary - when Crystal::Call - !in_ternary || node.has_parentheses? || node.args.empty? - when Crystal::Yield - !in_ternary || node.has_parentheses? || node.exps.empty? - when Crystal::Assign, Crystal::OpAssign, Crystal::MultiAssign - !in_ternary && !allow_safe_assignment? - else - true - end - end - - def test(source, node : Crystal::If | Crystal::Unless | Crystal::Case | Crystal::While | Crystal::Until) - cond = node.cond - - if cond.is_a?(Crystal::Assign) && allow_safe_assignment? - issue_for cond, MSG_MISSING do |corrector| - corrector.wrap(cond, '(', ')') - end - return - end - - is_ternary = node.is_a?(Crystal::If) && node.ternary? - - return if is_ternary && exclude_ternary? - - return unless cond.is_a?(Crystal::Expressions) - return unless cond.keyword.paren? - - return unless exp = cond.single_expression? - return unless strip_parentheses?(exp, is_ternary) - - issue_for cond, MSG_REDUNDANT do |corrector| - corrector.remove_trailing(cond, 1) - corrector.remove_leading(cond, 1) - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/style/redundant_begin.cr b/lib/ameba/src/ameba/rule/style/redundant_begin.cr deleted file mode 100644 index be21d73d..00000000 --- a/lib/ameba/src/ameba/rule/style/redundant_begin.cr +++ /dev/null @@ -1,154 +0,0 @@ -module Ameba::Rule::Style - # A rule that disallows redundant begin blocks. - # - # Currently it is able to detect: - # - # 1. Exception handler block that can be used as a part of the method. - # - # For example, this: - # - # ``` - # def method - # begin - # read_content - # rescue - # close_file - # end - # end - # ``` - # - # should be rewritten as: - # - # ``` - # def method - # read_content - # rescue - # close_file - # end - # ``` - # - # 2. begin..end block as a top level block in a method. - # - # For example this is considered invalid: - # - # ``` - # def method - # begin - # a = 1 - # b = 2 - # end - # end - # ``` - # - # and has to be written as the following: - # - # ``` - # def method - # a = 1 - # b = 2 - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Style/RedundantBegin: - # Enabled: true - # ``` - class RedundantBegin < Base - include AST::Util - - properties do - description "Disallows redundant begin blocks" - end - - MSG = "Redundant `begin` block detected" - - def test(source, node : Crystal::Def) - return unless def_loc = node.location - - case body = node.body - when Crystal::ExceptionHandler - return if begin_exprs_in_handler?(body) || inner_handler?(body) - when Crystal::Expressions - return unless redundant_begin_in_expressions?(body) - else - return - end - - return unless begin_range = def_redundant_begin_range(source, node) - - begin_loc, end_loc = begin_range - begin_loc, end_loc = def_loc.seek(begin_loc), def_loc.seek(end_loc) - begin_end_loc = begin_loc.adjust(column_number: {{ "begin".size - 1 }}) - end_end_loc = end_loc.adjust(column_number: {{ "end".size - 1 }}) - - issue_for begin_loc, begin_end_loc, MSG do |corrector| - corrector.remove(begin_loc, begin_end_loc) - corrector.remove(end_loc, end_end_loc) - end - end - - private def redundant_begin_in_expressions?(node) - !!node.keyword.try(&.begin?) - end - - private def inner_handler?(handler) - handler.body.is_a?(Crystal::ExceptionHandler) - end - - private def begin_exprs_in_handler?(handler) - return unless (body = handler.body).is_a?(Crystal::Expressions) - body.expressions.first?.is_a?(Crystal::ExceptionHandler) - end - - private def def_redundant_begin_range(source, node) - return unless code = node_source(node, source.lines) - - lexer = Crystal::Lexer.new code - return unless begin_loc = def_redundant_begin_loc(lexer) - return unless end_loc = def_redundant_end_loc(lexer) - - {begin_loc, end_loc} - end - - private def def_redundant_begin_loc(lexer) - in_body = in_argument_list = false - - loop do - token = lexer.next_token - - case token.type - when .eof?, .op_minus_gt? - break - when .ident? - next unless in_body - return unless token.value == Crystal::Keyword::BEGIN - return token.location - when .op_lparen? - in_argument_list = true - when .op_rparen? - in_argument_list = false - when .newline? - in_body = true unless in_argument_list - when .space? - # ignore - else - return if in_body - end - end - end - - private def def_redundant_end_loc(lexer) - end_loc = def_end_loc = nil - - Tokenizer.new(lexer).run do |token| - next unless token.value == Crystal::Keyword::END - - end_loc, def_end_loc = def_end_loc, token.location - end - - end_loc - end - end -end diff --git a/lib/ameba/src/ameba/rule/style/redundant_next.cr b/lib/ameba/src/ameba/rule/style/redundant_next.cr deleted file mode 100644 index ce81d080..00000000 --- a/lib/ameba/src/ameba/rule/style/redundant_next.cr +++ /dev/null @@ -1,128 +0,0 @@ -module Ameba::Rule::Style - # A rule that disallows redundant next expressions. A `next` keyword allows - # a block to skip to the next iteration early, however, it is considered - # redundant in cases where it is the last expression in a block or combines - # into the node which is the last in a block. - # - # For example, this is considered invalid: - # - # ``` - # block do |v| - # next v + 1 - # end - # ``` - # - # ``` - # block do |v| - # case v - # when .nil? - # next "nil" - # when .blank? - # next "blank" - # else - # next "empty" - # end - # end - # ``` - # - # And has to be written as the following: - # - # ``` - # block do |v| - # v + 1 - # end - # ``` - # - # ``` - # block do |v| - # case arg - # when .nil? - # "nil" - # when .blank? - # "blank" - # else - # "empty" - # end - # end - # ``` - # - # ### Configuration params - # - # 1. *allow_multi_next*, default: true - # - # Allows end-user to configure whether to report or not the next statements - # which yield tuple literals i.e. - # - # ``` - # block do - # next a, b - # end - # ``` - # - # If this param equals to `false`, the block above will be forced to be written as: - # - # ``` - # block do - # {a, b} - # end - # ``` - # - # 2. *allow_empty_next*, default: true - # - # Allows end-user to configure whether to report or not the next statements - # without arguments. Sometimes such statements are used to yild the `nil` value explicitly. - # - # ``` - # block do - # @foo = :empty - # next - # end - # ``` - # - # If this param equals to `false`, the block above will be forced to be written as: - # - # ``` - # block do - # @foo = :empty - # nil - # end - # ``` - # - # ### YAML config example - # - # ``` - # Style/RedundantNext: - # Enabled: true - # AllowMultiNext: true - # AllowEmptyNext: true - # ``` - class RedundantNext < Base - include AST::Util - - properties do - description "Reports redundant next expressions" - - allow_multi_next true - allow_empty_next true - end - - MSG = "Redundant `next` detected" - - def test(source, node : Crystal::Block) - AST::RedundantControlExpressionVisitor.new(self, source, node.body) - end - - def test(source, node : Crystal::Next, visitor : AST::RedundantControlExpressionVisitor) - return if allow_multi_next? && node.exp.is_a?(Crystal::TupleLiteral) - return if allow_empty_next? && (node.exp.nil? || node.exp.try(&.nop?)) - - if exp_code = control_exp_code(node, source.lines) - issue_for node, MSG do |corrector| - corrector.replace(node, exp_code) - end - else - issue_for node, MSG - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/style/redundant_return.cr b/lib/ameba/src/ameba/rule/style/redundant_return.cr deleted file mode 100644 index 234fd097..00000000 --- a/lib/ameba/src/ameba/rule/style/redundant_return.cr +++ /dev/null @@ -1,125 +0,0 @@ -module Ameba::Rule::Style - # A rule that disallows redundant return expressions. - # - # For example, this is considered invalid: - # - # ``` - # def foo - # return :bar - # end - # ``` - # - # ``` - # def bar(arg) - # case arg - # when .nil? - # return "nil" - # when .blank? - # return "blank" - # else - # return "empty" - # end - # end - # ``` - # - # And has to be written as the following: - # - # ``` - # def foo - # :bar - # end - # ``` - # - # ``` - # def bar(arg) - # case arg - # when .nil? - # "nil" - # when .blank? - # "blank" - # else - # "empty" - # end - # end - # ``` - # - # ### Configuration params - # - # 1. *allow_multi_return*, default: true - # - # Allows end-user to configure whether to report or not the return statements - # which return tuple literals i.e. - # - # ``` - # def method(a, b) - # return a, b - # end - # ``` - # - # If this param equals to `false`, the method above has to be written as: - # - # ``` - # def method(a, b) - # {a, b} - # end - # ``` - # - # 2. *allow_empty_return*, default: true - # - # Allows end-user to configure whether to report or not the return statements - # without arguments. Sometimes such returns are used to return the `nil` value explicitly. - # - # ``` - # def method - # @foo = :empty - # return - # end - # ``` - # - # If this param equals to `false`, the method above has to be written as: - # - # ``` - # def method - # @foo = :empty - # nil - # end - # ``` - # - # ### YAML config example - # - # ``` - # Style/RedundantReturn: - # Enabled: true - # AllowMultiReturn: true - # AllowEmptyReturn: true - # ``` - class RedundantReturn < Base - include AST::Util - - properties do - description "Reports redundant return expressions" - - allow_multi_return true - allow_empty_return true - end - - MSG = "Redundant `return` detected" - - def test(source, node : Crystal::Def) - AST::RedundantControlExpressionVisitor.new(self, source, node.body) - end - - def test(source, node : Crystal::Return, visitor : AST::RedundantControlExpressionVisitor) - return if allow_multi_return? && node.exp.is_a?(Crystal::TupleLiteral) - return if allow_empty_return? && (node.exp.nil? || node.exp.try(&.nop?)) - - if exp_code = control_exp_code(node, source.lines) - issue_for node, MSG do |corrector| - corrector.replace(node, exp_code) - end - else - issue_for node, MSG - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/style/unless_else.cr b/lib/ameba/src/ameba/rule/style/unless_else.cr deleted file mode 100644 index ba9c380b..00000000 --- a/lib/ameba/src/ameba/rule/style/unless_else.cr +++ /dev/null @@ -1,85 +0,0 @@ -module Ameba::Rule::Style - # A rule that disallows the use of an `else` block with the `unless`. - # - # For example, the rule considers these valid: - # - # ``` - # unless something - # :ok - # end - # - # if something - # :one - # else - # :two - # end - # ``` - # - # But it considers this one invalid as it is an `unless` with an `else`: - # - # ``` - # unless something - # :one - # else - # :two - # end - # ``` - # - # The solution is to swap the order of the blocks, and change the `unless` to - # an `if`, so the previous invalid example would become this: - # - # ``` - # if something - # :two - # else - # :one - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Style/UnlessElse: - # Enabled: true - # ``` - class UnlessElse < Base - properties do - description "Disallows the use of an `else` block with the `unless`" - end - - MSG = "Favour if over unless with else" - - def test(source, node : Crystal::Unless) - return if node.else.nop? - - location = node.location - cond_end_location = node.cond.end_location - else_location = node.else_location - end_location = node.end_location - - unless location && cond_end_location && else_location && end_location - issue_for node, MSG - return - end - - issue_for location, cond_end_location, MSG do |corrector| - keyword_begin_pos = source.pos(location) - keyword_end_pos = keyword_begin_pos + {{ "unless".size }} - keyword_range = keyword_begin_pos...keyword_end_pos - - cond_end_pos = source.pos(cond_end_location, end: true) - else_begin_pos = source.pos(else_location) - body_range = cond_end_pos...else_begin_pos - - else_end_pos = else_begin_pos + {{ "else".size }} - end_end_pos = source.pos(end_location, end: true) - end_begin_pos = end_end_pos - {{ "end".size }} - else_range = else_end_pos...end_begin_pos - - corrector.replace(keyword_range, "if") - corrector.replace(body_range, source.code[else_range]) - corrector.replace(else_range, source.code[body_range]) - end - end - end -end diff --git a/lib/ameba/src/ameba/rule/style/verbose_block.cr b/lib/ameba/src/ameba/rule/style/verbose_block.cr deleted file mode 100644 index fa4ae961..00000000 --- a/lib/ameba/src/ameba/rule/style/verbose_block.cr +++ /dev/null @@ -1,247 +0,0 @@ -module Ameba::Rule::Style - # This rule is used to identify usage of single expression blocks with - # argument as a receiver, that can be collapsed into a short form. - # - # For example, this is considered invalid: - # - # ``` - # (1..3).any? { |i| i.odd? } - # ``` - # - # And it should be written as this: - # - # ``` - # (1..3).any?(&.odd?) - # ``` - # - # YAML configuration example: - # - # ``` - # Style/VerboseBlock: - # Enabled: true - # ExcludeMultipleLineBlocks: true - # ExcludeCallsWithBlock: true - # ExcludePrefixOperators: true - # ExcludeOperators: true - # ExcludeSetters: false - # MaxLineLength: ~ - # MaxLength: 50 # use ~ to disable - # ``` - class VerboseBlock < Base - include AST::Util - - properties do - description "Identifies usage of collapsible single expression blocks" - - exclude_multiple_line_blocks true - exclude_calls_with_block true - exclude_prefix_operators true - exclude_operators true - exclude_setters false - - max_line_length nil, as: Int32? - max_length 50, as: Int32? - end - - MSG = "Use short block notation instead: `%s`" - CALL_PATTERN = "%s(%s&.%s)" - - protected def same_location_lines?(a, b) - return unless a_location = name_location(a) - return unless b_location = b.location - - a_location.line_number == b_location.line_number - end - - private PREFIX_OPERATORS = {"+", "-", "~"} - private OPERATOR_CHARS = - {'[', ']', '!', '=', '>', '<', '~', '+', '-', '*', '/', '%', '^', '|', '&'} - - protected def prefix_operator?(node) - node.name.in?(PREFIX_OPERATORS) && node.args.empty? - end - - protected def operator?(name) - !name.empty? && name[0].in?(OPERATOR_CHARS) - end - - protected def setter?(name) - !name.empty? && name[0].letter? && name.ends_with?('=') - end - - protected def valid_length?(code) - if max_length = self.max_length - return code.size <= max_length - end - true - end - - protected def valid_line_length?(node, code) - if max_line_length = self.max_line_length - if location = name_location(node) - final_line_length = location.column_number + code.size - return final_line_length <= max_line_length - end - end - true - end - - protected def reference_count(node, obj : Crystal::Var) - i = 0 - case node - when Crystal::Call - i += reference_count(node.obj, obj) - i += reference_count(node.block, obj) - - node.args.each do |arg| - i += reference_count(arg, obj) - end - node.named_args.try &.each do |arg| - i += reference_count(arg.value, obj) - end - when Crystal::BinaryOp - i += reference_count(node.left, obj) - i += reference_count(node.right, obj) - when Crystal::Block - i += reference_count(node.body, obj) - when Crystal::Var - i += 1 if node == obj - end - i - end - - protected def args_to_s(io : IO, node : Crystal::Call, short_block = nil, skip_last_arg = false) : Nil - args = node.args.dup - args.pop? if skip_last_arg - args.join io, ", " - - named_args = node.named_args - if named_args - io << ", " unless args.empty? || named_args.empty? - named_args.join io, ", " do |arg, inner_io| - inner_io << arg.name << ": " << arg.value - end - end - - if short_block - io << ", " unless args.empty? && (named_args.nil? || named_args.empty?) - io << short_block - end - end - - protected def node_to_s(source, node : Crystal::Call) - String.build do |str| - case name = node.name - when "[]" - str << '[' - args_to_s(str, node) - str << ']' - when "[]?" - str << '[' - args_to_s(str, node) - str << "]?" - when "[]=" - str << '[' - args_to_s(str, node, skip_last_arg: true) - str << "]=(" << node.args.last? << ')' - else - short_block = short_block_code(source, node) - str << name - if !node.args.empty? || (node.named_args && !node.named_args.try(&.empty?)) || short_block - str << '(' - args_to_s(str, node, short_block) - str << ')' - end - str << " {...}" if node.block && short_block.nil? - end - end - end - - protected def short_block_code(source, node : Crystal::Call) - return unless block = node.block - return unless block_location = block.location - return unless block_end_location = block.body.end_location - - block_code = source_between(block_location, block_end_location, source.lines) - block_code if block_code.try(&.starts_with?("&.")) - end - - protected def call_code(source, call, body) - args = String.build { |io| args_to_s(io, call) }.presence - args += ", " if args - - call_chain = %w[].tap do |arr| - obj = body.obj - while obj.is_a?(Crystal::Call) - arr << node_to_s(source, obj) - obj = obj.obj - end - arr.reverse! - arr << node_to_s(source, body) - end - - name = - call_chain.join('.') - - CALL_PATTERN % {call.name, args, name} - end - - # ameba:disable Metrics/CyclomaticComplexity - protected def issue_for_valid(source, call : Crystal::Call, block : Crystal::Block, body : Crystal::Call) - return if exclude_calls_with_block? && body.block - return if exclude_multiple_line_blocks? && !same_location_lines?(call, body) - return if exclude_prefix_operators? && prefix_operator?(body) - return if exclude_operators? && operator?(body.name) - return if exclude_setters? && setter?(body.name) - - call_code = - call_code(source, call, body) - - return unless valid_line_length?(call, call_code) - return unless valid_length?(call_code) - - return unless location = name_location(call) - return unless end_location = block.end_location - - if call_code.includes?("{...}") - issue_for location, end_location, MSG % call_code - else - issue_for location, end_location, MSG % call_code do |corrector| - corrector.replace(location, end_location, call_code) - end - end - end - - def test(source, node : Crystal::Call) - # we are interested only in calls with block taking a single argument - # - # ``` - # (1..3).any? { |i| i.to_i64.odd? } - # ^--- ^ ^------------ - # block arg body - # ``` - return unless (block = node.block) && block.args.size == 1 - - arg = block.args.first - - # we filter out the blocks that are of call type - `i.to_i64.odd?` - return unless (body = block.body).is_a?(Crystal::Call) - - # we need to "unwind" the call chain, so the final receiver object - # ends up being a variable - `i` - obj = body.obj - while obj.is_a?(Crystal::Call) - obj = obj.obj - end - - # only calls with a first argument used as a receiver are the valid game - return unless obj == arg - - # we bail out if the block node include the block argument - return if reference_count(body, arg) > 1 - - # add issue if the given nodes pass all of the checks - issue_for_valid source, node, block, body - end - end -end diff --git a/lib/ameba/src/ameba/rule/style/while_true.cr b/lib/ameba/src/ameba/rule/style/while_true.cr deleted file mode 100644 index 70036402..00000000 --- a/lib/ameba/src/ameba/rule/style/while_true.cr +++ /dev/null @@ -1,46 +0,0 @@ -module Ameba::Rule::Style - # A rule that disallows the use of `while true` instead of using the idiomatic `loop` - # - # For example, this is considered invalid: - # - # ``` - # while true - # do_something - # break if some_condition - # end - # ``` - # - # And should be replaced by the following: - # - # ``` - # loop do - # do_something - # break if some_condition - # end - # ``` - # - # YAML configuration example: - # - # ``` - # Style/WhileTrue: - # Enabled: true - # ``` - class WhileTrue < Base - properties do - description "Disallows while statements with a true literal as condition" - end - - MSG = "While statement using true literal as condition" - - def test(source, node : Crystal::While) - return unless node.cond.true_literal? - - return unless location = node.location - return unless end_location = node.cond.end_location - - issue_for node, MSG do |corrector| - corrector.replace(location, end_location, "loop do") - end - end - end -end diff --git a/lib/ameba/src/ameba/runner.cr b/lib/ameba/src/ameba/runner.cr deleted file mode 100644 index 5a526fac..00000000 --- a/lib/ameba/src/ameba/runner.cr +++ /dev/null @@ -1,227 +0,0 @@ -module Ameba - # Represents a runner for inspecting sources files. - # Holds a list of rules to do inspection based on, - # list of sources to run inspection on and a formatter - # to prepare a report. - # - # ``` - # config = Ameba::Config.load - # runner = Ameba::Runner.new config - # runner.run.success? # => true or false - # ``` - class Runner - # An error indicating that the inspection loop got stuck correcting - # issues back and forth. - class InfiniteCorrectionLoopError < RuntimeError - def initialize(path, issues_by_iteration, loop_start = -1) - root_cause = - issues_by_iteration[loop_start..-1] - .join(" -> ", &.map(&.rule.name).uniq!.join(", ")) - - message = String.build do |io| - io << "Infinite loop" - io << " in " << path unless path.empty? - io << " caused by " << root_cause - end - - super message - end - end - - # A list of rules to do inspection based on. - @rules : Array(Rule::Base) - - # A list of sources to run inspection on. - getter sources : Array(Source) - - # A level of severity to be reported. - @severity : Severity - - # A formatter to prepare report. - @formatter : Formatter::BaseFormatter - - # A syntax rule which always inspects a source first - @syntax_rule = Rule::Lint::Syntax.new - - # Checks for unneeded disable directives. Always inspects a source last - @unneeded_disable_directive_rule : Rule::Base? - - # Returns `true` if correctable issues should be autocorrected. - private getter? autocorrect : Bool - - # Instantiates a runner using a `config`. - # - # ``` - # config = Ameba::Config.load - # config.files = files - # config.formatter = formatter - # - # Ameba::Runner.new config - # ``` - def initialize(config : Config) - @sources = config.sources - @formatter = config.formatter - @severity = config.severity - @rules = config.rules.select(&.enabled?).reject!(&.special?) - @autocorrect = config.autocorrect? - - @unneeded_disable_directive_rule = - config.rules - .find &.class.==(Rule::Lint::UnneededDisableDirective) - end - - protected def initialize(@rules, @sources, @formatter, @severity, @autocorrect = false) - end - - # Performs the inspection. Iterates through all sources and test it using - # list of rules. If a specific rule fails on a specific source, it adds - # an issue to that source. - # - # This action also notifies formatter when inspection is started/finished, - # and when a specific source started/finished to be inspected. - # - # ``` - # runner = Ameba::Runner.new config - # runner.run # => returns runner again - # ``` - def run - @formatter.started @sources - - channels = @sources.map { Channel(Exception?).new } - @sources.zip(channels).each do |source, channel| - spawn do - run_source(source) - rescue e - channel.send(e) - else - channel.send(nil) - end - end - - channels.each do |chan| - chan.receive.try { |e| raise e } - end - - self - ensure - @formatter.finished @sources - end - - private def run_source(source) - @formatter.source_started source - - # This variable is a 2D array used to track corrected issues after each - # inspection iteration. This is used to output meaningful infinite loop - # error message. - corrected_issues = [] of Array(Issue) - - # When running with --fix, we need to inspect the source until no more - # corrections are made (because automatic corrections can introduce new - # issues). In the normal case the loop is only executed once. - loop_unless_infinite(source, corrected_issues) do - # We have to reprocess the source to pick up any changes. Since a - # change could (theoretically) introduce syntax errors, we break the - # loop if we find any. - @syntax_rule.test(source) - break unless source.valid? - - @rules.each do |rule| - next if rule.excluded?(source) - rule.test(source) - end - check_unneeded_directives(source) - break unless autocorrect? && source.correct? - - # The issues that couldn't be corrected will be found again so we - # only keep the corrected ones in order to avoid duplicate reporting. - corrected_issues << source.issues.select(&.correctable?) - source.issues.clear - end - - corrected_issues.flatten.reverse_each do |issue| - source.issues.unshift(issue) - end - - File.write(source.path, source.code) unless corrected_issues.empty? - ensure - @formatter.source_finished source - end - - # Explains an issue at a specified *location*. - # - # Runner should perform inspection before doing the explain. - # This is necessary to be able to find the issue at a specified location. - # - # ``` - # runner = Ameba::Runner.new config - # runner.run - # runner.explain({file: file, line: l, column: c}) - # ``` - def explain(location, output = STDOUT) - Formatter::ExplainFormatter.new(output, location).finished @sources - end - - # Indicates whether the last inspection successful or not. - # It returns `true` if no issues matching severity in sources found, `false` otherwise. - # - # ``` - # runner = Ameba::Runner.new config - # runner.run - # runner.success? # => true or false - # ``` - def success? - @sources.all? do |source| - source.issues - .reject(&.disabled?) - .none?(&.rule.severity.<=(@severity)) - end - end - - private MAX_ITERATIONS = 200 - - private def loop_unless_infinite(source, corrected_issues, &) - # Keep track of the state of the source. If a rule modifies the source - # and another rule undoes it producing identical source we have an - # infinite loop. - processed_sources = [] of UInt64 - - # It is possible for a rule to keep adding indefinitely to a file, - # making it bigger and bigger. If the inspection loop runs for an - # excessively high number of iterations, this is likely happening. - iterations = 0 - - loop do - check_for_infinite_loop(source, corrected_issues, processed_sources) - - if (iterations += 1) > MAX_ITERATIONS - raise InfiniteCorrectionLoopError.new(source.path, corrected_issues) - end - - yield - end - end - - # Check whether a run created source identical to a previous run, which - # means that we definitely have an infinite loop. - private def check_for_infinite_loop(source, corrected_issues, processed_sources) - checksum = source.code.hash - - if loop_start = processed_sources.index(checksum) - raise InfiniteCorrectionLoopError.new( - source.path, - corrected_issues, - loop_start: loop_start - ) - end - - processed_sources << checksum - end - - private def check_unneeded_directives(source) - return unless rule = @unneeded_disable_directive_rule - return unless rule.enabled? - - rule.test(source) - end - end -end diff --git a/lib/ameba/src/ameba/severity.cr b/lib/ameba/src/ameba/severity.cr deleted file mode 100644 index 52dd7455..00000000 --- a/lib/ameba/src/ameba/severity.cr +++ /dev/null @@ -1,67 +0,0 @@ -require "colorize" - -module Ameba - enum Severity - Error - Warning - Convention - - # Returns a symbol uniquely indicating severity. - # - # ``` - # Severity::Warning.symbol # => 'W' - # ``` - def symbol : Char - case self - in Error then 'E' - in Warning then 'W' - in Convention then 'C' - end - end - - # Returns a color uniquely indicating severity. - # - # ``` - # Severity::Warning.color # => Colorize::ColorANSI::Red - # ``` - def color : Colorize::Color - case self - in Error then Colorize::ColorANSI::Red - in Warning then Colorize::ColorANSI::Red - in Convention then Colorize::ColorANSI::Blue - end - end - - # Creates Severity by the name. - # - # ``` - # Severity.parse("convention") # => Severity::Convention - # Severity.parse("foo-bar") # => Exception: Incorrect severity name - # ``` - def self.parse(name : String) - super name - rescue ArgumentError - raise "Incorrect severity name #{name}. Try one of: #{values.map(&.to_s).join(", ")}" - end - end - - # Converter for `YAML.mapping` which converts severity enum to and from YAML. - class SeverityYamlConverter - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) - unless node.is_a?(YAML::Nodes::Scalar) - raise "Severity must be a scalar, not #{node.class}" - end - - case value = node.value - when String then Severity.parse(value) - when Nil then raise "Missing severity" - else - raise "Incorrect severity: #{value}" - end - end - - def self.to_yaml(value : Severity, yaml : YAML::Nodes::Builder) - yaml.scalar value - end - end -end diff --git a/lib/ameba/src/ameba/source.cr b/lib/ameba/src/ameba/source.cr deleted file mode 100644 index 29d9d199..00000000 --- a/lib/ameba/src/ameba/source.cr +++ /dev/null @@ -1,88 +0,0 @@ -module Ameba - # An entity that represents a Crystal source file. - # Has path, lines of code and issues reported by rules. - class Source - include InlineComments - include Reportable - - # Path to the source file. - getter path : String - - # Crystal code (content of a source file). - getter code : String - - # Creates a new source by `code` and `path`. - # - # For example: - # - # ``` - # path = "./src/source.cr" - # Ameba::Source.new File.read(path), path - # ``` - def initialize(@code, @path = "") - end - - # Corrects any correctable issues and updates `code`. - # Returns `false` if no issues were corrected. - def correct? - corrector = Corrector.new(code) - issues.each(&.correct(corrector)) - - corrected_code = corrector.process - return false if code == corrected_code - - @code = corrected_code - @lines = nil - @ast = nil - - true - end - - # Returns lines of code split by new line character. - # Since `code` is immutable and can't be changed, this - # method caches lines in an instance variable, so calling - # it second time will not perform a split, but will return - # lines instantly. - # - # ``` - # source = Ameba::Source.new "a = 1\nb = 2", path - # source.lines # => ["a = 1", "b = 2"] - # ``` - getter lines : Array(String) { code.split('\n') } - - # Returns AST nodes constructed by `Crystal::Parser`. - # - # ``` - # source = Ameba::Source.new code, path - # source.ast - # ``` - getter ast : Crystal::ASTNode do - Crystal::Parser.new(code) - .tap(&.wants_doc = true) - .tap(&.filename = path) - .parse - end - - getter fullpath : String do - File.expand_path(path) - end - - # Returns `true` if the source is a spec file, `false` otherwise. - def spec? - path.ends_with?("_spec.cr") - end - - # Returns `true` if *filepath* matches the source's path, `false` otherwise. - def matches_path?(filepath) - fullpath == File.expand_path(filepath) - end - - # Converts an AST location to a string position. - def pos(location : Crystal::Location, end end_pos = false) : Int32 - line, column = location.line_number, location.column_number - pos = lines[0...line - 1].sum(&.size) + line + column - 2 - pos += 1 if end_pos - pos - end - end -end diff --git a/lib/ameba/src/ameba/source/corrector.cr b/lib/ameba/src/ameba/source/corrector.cr deleted file mode 100644 index 33e722d2..00000000 --- a/lib/ameba/src/ameba/source/corrector.cr +++ /dev/null @@ -1,200 +0,0 @@ -require "./rewriter" - -class Ameba::Source - # This class takes source code and rewrites it based - # on the different correction actions supplied. - class Corrector - @line_sizes = [] of Int32 - - def initialize(code : String) - code.each_line(chomp: false) do |line| - @line_sizes << line.size - end - @rewriter = Rewriter.new(code) - end - - # Replaces the code of the given range with *content*. - def replace(location, end_location, content) - @rewriter.replace(loc_to_pos(location), loc_to_pos(end_location) + 1, content) - end - - # :ditto: - def replace(range : Range(Int32, Int32), content) - begin_pos, end_pos = range.begin, range.end - end_pos -= 1 unless range.excludes_end? - @rewriter.replace(begin_pos, end_pos, content) - end - - # Inserts the given strings before and after the given range. - def wrap(location, end_location, insert_before, insert_after) - @rewriter.wrap(loc_to_pos(location), loc_to_pos(end_location) + 1, insert_before, insert_after) - end - - # :ditto: - def wrap(range : Range(Int32, Int32), insert_before, insert_after) - begin_pos, end_pos = range.begin, range.end - end_pos -= 1 unless range.excludes_end? - @rewriter.wrap(begin_pos, end_pos, insert_before, insert_after) - end - - # Shortcut for `replace(location, end_location, "")` - def remove(location, end_location) - @rewriter.remove(loc_to_pos(location), loc_to_pos(end_location) + 1) - end - - # Shortcut for `replace(range, "")` - def remove(range : Range(Int32, Int32)) - begin_pos, end_pos = range.begin, range.end - end_pos -= 1 unless range.excludes_end? - @rewriter.remove(begin_pos, end_pos) - end - - # Shortcut for `wrap(location, end_location, content, nil)` - def insert_before(location, end_location, content) - @rewriter.insert_before(loc_to_pos(location), loc_to_pos(end_location) + 1, content) - end - - # Shortcut for `wrap(range, content, nil)` - def insert_before(range : Range(Int32, Int32), content) - begin_pos, end_pos = range.begin, range.end - end_pos -= 1 unless range.excludes_end? - @rewriter.insert_before(begin_pos, end_pos, content) - end - - # Shortcut for `wrap(location, end_location, nil, content)` - def insert_after(location, end_location, content) - @rewriter.insert_after(loc_to_pos(location), loc_to_pos(end_location) + 1, content) - end - - # Shortcut for `wrap(range, nil, content)` - def insert_after(range : Range(Int32, Int32), content) - begin_pos, end_pos = range.begin, range.end - end_pos -= 1 unless range.excludes_end? - @rewriter.insert_after(begin_pos, end_pos, content) - end - - # Shortcut for `insert_before(location, location, content)` - def insert_before(location, content) - @rewriter.insert_before(loc_to_pos(location), content) - end - - # Shortcut for `insert_before(pos.., content)` - def insert_before(pos : Int32, content) - @rewriter.insert_before(pos, content) - end - - # Shortcut for `insert_after(location, location, content)` - def insert_after(location, content) - @rewriter.insert_after(loc_to_pos(location) + 1, content) - end - - # Shortcut for `insert_after(...pos, content)` - def insert_after(pos : Int32, content) - @rewriter.insert_after(pos, content) - end - - # Removes *size* characters prior to the source range. - def remove_preceding(location, end_location, size) - @rewriter.remove(loc_to_pos(location) - size, loc_to_pos(location)) - end - - # :ditto: - def remove_preceding(range : Range(Int32, Int32), size) - begin_pos = range.begin - @rewriter.remove(begin_pos - size, begin_pos) - end - - # Removes *size* characters from the beginning of the given range. - # If *size* is greater than the size of the range, the removed region can - # overrun the end of the range. - def remove_leading(location, end_location, size) - @rewriter.remove(loc_to_pos(location), loc_to_pos(location) + size) - end - - # :ditto: - def remove_leading(range : Range(Int32, Int32), size) - begin_pos = range.begin - @rewriter.remove(begin_pos, begin_pos + size) - end - - # Removes *size* characters from the end of the given range. - # If *size* is greater than the size of the range, the removed region can - # overrun the beginning of the range. - def remove_trailing(location, end_location, size) - @rewriter.remove(loc_to_pos(end_location) + 1 - size, loc_to_pos(end_location) + 1) - end - - # :ditto: - def remove_trailing(range : Range(Int32, Int32), size) - end_pos = range.end - end_pos -= 1 unless range.excludes_end? - @rewriter.remove(end_pos - size, end_pos) - end - - private def loc_to_pos(location : Crystal::Location | {Int32, Int32}) - if location.is_a?(Crystal::Location) - line, column = location.line_number, location.column_number - else - line, column = location - end - @line_sizes[0...line - 1].sum + (column - 1) - end - - # Replaces the code of the given node with *content*. - def replace(node : Crystal::ASTNode, content) - replace(location(node), end_location(node), content) - end - - # Inserts the given strings before and after the given node. - def wrap(node : Crystal::ASTNode, insert_before, insert_after) - wrap(location(node), end_location(node), insert_before, insert_after) - end - - # Shortcut for `replace(node, "")` - def remove(node : Crystal::ASTNode) - remove(location(node), end_location(node)) - end - - # Shortcut for `wrap(node, content, nil)` - def insert_before(node : Crystal::ASTNode, content) - insert_before(location(node), content) - end - - # Shortcut for `wrap(node, nil, content)` - def insert_after(node : Crystal::ASTNode, content) - insert_after(end_location(node), content) - end - - # Removes *size* characters prior to the given node. - def remove_preceding(node : Crystal::ASTNode, size) - remove_preceding(location(node), end_location(node), size) - end - - # Removes *size* characters from the beginning of the given node. - # If *size* is greater than the size of the node, the removed region can - # overrun the end of the node. - def remove_leading(node : Crystal::ASTNode, size) - remove_leading(location(node), end_location(node), size) - end - - # Removes *size* characters from the end of the given node. - # If *size* is greater than the size of the node, the removed region can - # overrun the beginning of the node. - def remove_trailing(node : Crystal::ASTNode, size) - remove_trailing(location(node), end_location(node), size) - end - - private def location(node : Crystal::ASTNode) - node.location || raise "Missing location" - end - - private def end_location(node : Crystal::ASTNode) - node.end_location || raise "Missing end location" - end - - # Applies all scheduled changes and returns modified source as a new string. - def process - @rewriter.process - end - end -end diff --git a/lib/ameba/src/ameba/source/rewriter.cr b/lib/ameba/src/ameba/source/rewriter.cr deleted file mode 100644 index ec71bd13..00000000 --- a/lib/ameba/src/ameba/source/rewriter.cr +++ /dev/null @@ -1,136 +0,0 @@ -class Ameba::Source - # This class performs the heavy lifting in the source rewriting process. - # It schedules code updates to be performed in the correct order. - # - # For simple cases, the resulting source will be obvious. - # - # Examples for more complex cases follow. Assume these examples are acting on - # the source `puts(:hello, :world)`. The methods `#wrap`, `#remove`, etc. - # receive a range as the first two arguments; for clarity, examples below use - # English sentences and a string of raw code instead. - # - # ## Overlapping deletions: - # - # * remove `:hello, ` - # * remove `, :world` - # - # The overlapping ranges are merged and `:hello, :world` will be removed. - # - # ## Multiple actions at the same end points: - # - # Results will always be independent of the order they were given. - # Exception: rewriting actions done on exactly the same range (covered next). - # - # Example: - # - # * replace `, ` by ` => ` - # * wrap `:hello, :world` with `{` and `}` - # * replace `:world` with `:everybody` - # * wrap `:world` with `[`, `]` - # - # The resulting string will be `puts({:hello => [:everybody]})` - # and this result is independent of the order the instructions were given in. - # - # ## Multiple wraps on same range: - # - # * wrap `:hello` with `(` and `)` - # * wrap `:hello` with `[` and `]` - # - # The wraps are combined in order given and results would be `puts([(:hello)], :world)`. - # - # ## Multiple replacements on same range: - # - # * replace `:hello` by `:hi`, then - # * replace `:hello` by `:hey` - # - # The replacements are made in the order given, so the latter replacement - # supersedes the former and `:hello` will be replaced by `:hey`. - # - # ## Swallowed insertions: - # - # * wrap `world` by `__`, `__` - # * replace `:hello, :world` with `:hi` - # - # A containing replacement will swallow the contained rewriting actions - # and `:hello, :world` will be replaced by `:hi`. - # - # ## Implementation - # - # The updates are organized in a tree, according to the ranges they act on - # (where children are strictly contained by their parent). - class Rewriter - getter code : String - - def initialize(@code) - @action_root = Rewriter::Action.new(0, code.size) - end - - # Returns `true` if no (non trivial) update has been recorded - def empty? - @action_root.empty? - end - - # Replaces the code of the given range with *content*. - def replace(begin_pos, end_pos, content) - combine begin_pos, end_pos, - replacement: content.to_s - end - - # Inserts the given strings before and after the given range. - def wrap(begin_pos, end_pos, insert_before, insert_after) - combine begin_pos, end_pos, - insert_before: insert_before.to_s, - insert_after: insert_after.to_s - end - - # Shortcut for `replace(begin_pos, end_pos, "")` - def remove(begin_pos, end_pos) - replace(begin_pos, end_pos, "") - end - - # Shortcut for `wrap(begin_pos, end_pos, content, nil)` - def insert_before(begin_pos, end_pos, content) - wrap(begin_pos, end_pos, content, nil) - end - - # Shortcut for `wrap(begin_pos, end_pos, nil, content)` - def insert_after(begin_pos, end_pos, content) - wrap(begin_pos, end_pos, nil, content) - end - - # Shortcut for `insert_before(pos, pos, content)` - def insert_before(pos, content) - insert_before(pos, pos, content) - end - - # Shortcut for `insert_after(pos, pos, content)` - def insert_after(pos, content) - insert_after(pos, pos, content) - end - - # Applies all scheduled changes and returns modified source as a new string. - def process - String.build do |io| - last_end = 0 - @action_root.ordered_replacements.each do |begin_pos, end_pos, replacement| - io << code[last_end...begin_pos] << replacement - last_end = end_pos - end - io << code[last_end...code.size] - end - end - - protected def combine(begin_pos, end_pos, **attributes) - check_range_validity(begin_pos, end_pos) - action = Rewriter::Action.new(begin_pos, end_pos, **attributes) - @action_root = @action_root.combine(action) - end - - private def check_range_validity(begin_pos, end_pos) - return unless begin_pos < 0 || end_pos > code.size - raise IndexError.new( - "The range #{begin_pos}...#{end_pos} is outside the bounds of the source" - ) - end - end -end diff --git a/lib/ameba/src/ameba/source/rewriter/action.cr b/lib/ameba/src/ameba/source/rewriter/action.cr deleted file mode 100644 index a13013c2..00000000 --- a/lib/ameba/src/ameba/source/rewriter/action.cr +++ /dev/null @@ -1,185 +0,0 @@ -class Ameba::Source::Rewriter - # :nodoc: - # Actions are arranged in a tree and get combined so that: - # - children are strictly contained by their parent - # - siblings all disjoint from one another and ordered - # - only actions with `replacement == nil` may have children - class Action - getter begin_pos : Int32 - getter end_pos : Int32 - getter replacement : String? - getter insert_before : String - getter insert_after : String - protected getter children : Array(Action) - - def initialize(@begin_pos, - @end_pos, - @insert_before = "", - @replacement = nil, - @insert_after = "", - @children = [] of Action) - end - - def combine(action) - return self if action.empty? # Ignore empty action - - if action.begin_pos == @begin_pos && action.end_pos == @end_pos - merge(action) - else - place_in_hierarchy(action) - end - end - - def empty? - replacement = @replacement - - @insert_before.empty? && - @insert_after.empty? && - @children.empty? && - (replacement.nil? || - (replacement.empty? && @begin_pos == @end_pos)) - end - - def ordered_replacements - replacement = @replacement - reps = [] of {Int32, Int32, String} - reps << {@begin_pos, @begin_pos, @insert_before} unless @insert_before.empty? - reps << {@begin_pos, @end_pos, replacement} if replacement - reps.concat(@children.flat_map(&.ordered_replacements)) - reps << {@end_pos, @end_pos, @insert_after} unless @insert_after.empty? - reps - end - - def insertion? - replacement = @replacement - - !@insert_before.empty? || - !@insert_after.empty? || - (replacement && !replacement.empty?) - end - - protected def with(*, - begin_pos = @begin_pos, - end_pos = @end_pos, - insert_before = @insert_before, - replacement = @replacement, - insert_after = @insert_after, - children = @children) - children = [] of Action if replacement - self.class.new(begin_pos, end_pos, insert_before, replacement, insert_after, children) - end - - protected def place_in_hierarchy(action) - family = analyze_hierarchy(action) - sibling_left, sibling_right = family[:sibling_left], family[:sibling_right] - - if fusible = family[:fusible] - child = family[:child] - child ||= [] of Action - fuse_deletions(action, fusible, sibling_left + child + sibling_right) - else - extra_sibling = - case - when parent = family[:parent] - # action should be a descendant of one of the children - parent.combine(action) - when child = family[:child] - # or it should become the parent of some of the children, - action.with(children: child).combine_children(action.children) - else - # or else it should become an additional child - action - end - self.with(children: sibling_left + [extra_sibling] + sibling_right) - end - end - - # Assumes *more_children* all contained within `@begin_pos...@end_pos` - protected def combine_children(more_children) - more_children.reduce(self) do |parent, new_child| - parent.place_in_hierarchy(new_child) - end - end - - protected def fuse_deletions(action, fusible, other_siblings) - without_fusible = self.with(children: other_siblings) - fusible = [action] + fusible - fused_begin_pos = fusible.min_of(&.begin_pos) - fused_end_pos = fusible.max_of(&.end_pos) - fused_deletion = action.with(begin_pos: fused_begin_pos, end_pos: fused_end_pos) - without_fusible.combine(fused_deletion) - end - - # Similar to `@children.bsearch_index || size` except allows for a starting point - protected def bsearch_child_index(from = 0, &) - size = @children.size - (from...size).bsearch { |i| yield @children[i] } || size - end - - # Returns the children in a hierarchy with respect to *action*: - # - # - `:sibling_left`, `:sibling_right` (for those that are disjoint from *action*) - # - `:parent` (in case one of our children contains *action*) - # - `:child` (in case *action* strictly contains some of our children) - # - `:fusible` (in case *action* overlaps some children but they can be fused in one deletion) - # - # In case a child has equal range to *action*, it is returned as `:parent` - # - # Reminder: an empty range 1...1 is considered disjoint from 1...10 - protected def analyze_hierarchy(action) # ameba:disable Metrics/CyclomaticComplexity - # left_index is the index of the first child that isn't completely to the left of action - left_index = bsearch_child_index { |child| child.end_pos > action.begin_pos } - # right_index is the index of the first child that is completely on the right of action - start = left_index == 0 ? 0 : left_index - 1 # See "corner case" below for reason of -1 - right_index = bsearch_child_index(start) { |child| child.begin_pos >= action.end_pos } - center = right_index - left_index - case center - when 0 - # All children are disjoint from action, nothing else to do - when -1 - # Corner case: if a child has empty range == action's range - # then it will appear to be both disjoint and to the left of action, - # as well as disjoint and to the right of action. - # Since ranges are equal, we return it as parent - left_index -= 1 # Fix indices, as otherwise this child would be - right_index += 1 # considered as a sibling (both left and right!) - parent = @children[left_index] - else - overlap_left = @children[left_index].begin_pos <=> action.begin_pos - overlap_right = @children[right_index - 1].end_pos <=> action.end_pos - - raise "Unable to compare begin pos" if overlap_left.nil? - raise "Unable to compare end pos" if overlap_right.nil? - - # For one child to be the parent of action, we must have: - if center == 1 && overlap_left <= 0 && overlap_right >= 0 - parent = @children[left_index] - else - # Otherwise consider all non disjoint elements (center) to be contained... - contained = @children[left_index...right_index] - fusible = [] of Action - fusible << contained.shift if overlap_left < 0 # ... but check first and last one - fusible << contained.pop if overlap_right > 0 # ... for overlaps - fusible = nil if fusible.empty? - end - end - - { - parent: parent, - sibling_left: @children[0...left_index], - sibling_right: @children[right_index...@children.size], - fusible: fusible, - child: contained, - } - end - - # Assumes *action* has the exact same range and has no children - protected def merge(action) - self.with( - insert_before: "#{action.insert_before}#{insert_before}", - replacement: action.replacement || @replacement, - insert_after: "#{insert_after}#{action.insert_after}", - ).combine_children(action.children) - end - end -end diff --git a/lib/ameba/src/ameba/tokenizer.cr b/lib/ameba/src/ameba/tokenizer.cr deleted file mode 100644 index 86f6aa3b..00000000 --- a/lib/ameba/src/ameba/tokenizer.cr +++ /dev/null @@ -1,100 +0,0 @@ -require "compiler/crystal/syntax/*" - -module Ameba - # Represents Crystal syntax tokenizer based on `Crystal::Lexer`. - # - # ``` - # source = Ameba::Source.new code, path - # tokenizer = Ameba::Tokenizer.new(source) - # tokenizer.run do |token| - # puts token - # end - # ``` - class Tokenizer - # Instantiates Tokenizer using a `source`. - # - # ``` - # source = Ameba::Source.new code, path - # Ameba::Tokenizer.new(source) - # ``` - def initialize(source) - @lexer = Crystal::Lexer.new source.code - @lexer.count_whitespace = true - @lexer.comments_enabled = true - @lexer.wants_raw = true - @lexer.filename = source.path - end - - # Instantiates Tokenizer using a `lexer`. - # - # ``` - # lexer = Crystal::Lexer.new(code) - # Ameba::Tokenizer.new(lexer) - # ``` - def initialize(@lexer : Crystal::Lexer) - end - - # Runs the tokenizer and yields each token as a block argument. - # - # ``` - # Ameba::Tokenizer.new(source).run do |token| - # puts token - # end - # ``` - def run(&block : Crystal::Token -> _) - run_normal_state @lexer, &block - true - rescue e : Crystal::SyntaxException - # puts e - false - end - - private def run_normal_state(lexer, break_on_rcurly = false, &block : Crystal::Token -> _) - loop do - token = @lexer.next_token - block.call token - - case token.type - when .delimiter_start? - run_delimiter_state lexer, token, &block - when .string_array_start?, .symbol_array_start? - run_array_state lexer, token, &block - when .eof? - break - when .op_rcurly? - break if break_on_rcurly - end - end - end - - private def run_delimiter_state(lexer, token, &block : Crystal::Token -> _) - loop do - token = @lexer.next_string_token(token.delimiter_state) - block.call token - - case token.type - when .delimiter_end? - break - when .interpolation_start? - run_normal_state lexer, break_on_rcurly: true, &block - when .eof? - break - end - end - end - - private def run_array_state(lexer, token, &block : Crystal::Token -> _) - loop do - lexer.next_string_array_token - block.call token - - case token.type - when .string_array_end? - break - when .eof? - break - end - end - end - end -end diff --git a/lib/ameba/src/cli.cr b/lib/ameba/src/cli.cr deleted file mode 100644 index 4bb38cf9..00000000 --- a/lib/ameba/src/cli.cr +++ /dev/null @@ -1,3 +0,0 @@ -require "./ameba/cli/cmd" - -Ameba::Cli.run diff --git a/lib/ameba/src/contrib/read_type_doc.cr b/lib/ameba/src/contrib/read_type_doc.cr deleted file mode 100644 index 53ec0e2d..00000000 --- a/lib/ameba/src/contrib/read_type_doc.cr +++ /dev/null @@ -1,29 +0,0 @@ -require "compiler/crystal/syntax/*" - -private class DocFinder < Crystal::Visitor - getter type_name : String - getter doc : String? - - def initialize(nodes, @type_name) - self.accept(nodes) - end - - def visit(node : Crystal::ASTNode) - return false if @doc - - if node.responds_to?(:name) && (name = node.name).is_a?(Crystal::Path) - @doc = node.doc if name.names.last? == @type_name - end - - true - end -end - -type_name, path_to_source_file = ARGV - -source = File.read(path_to_source_file) -nodes = Crystal::Parser.new(source) - .tap(&.wants_doc = true) - .parse - -puts DocFinder.new(nodes, type_name).doc diff --git a/lib/halite/.editorconfig b/lib/halite/.editorconfig deleted file mode 100644 index 8f0c87a1..00000000 --- a/lib/halite/.editorconfig +++ /dev/null @@ -1,7 +0,0 @@ -[*.cr] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -indent_style = space -indent_size = 2 -trim_trailing_whitespace = true diff --git a/lib/halite/.gitignore b/lib/halite/.gitignore deleted file mode 100644 index 4d705e82..00000000 --- a/lib/halite/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -docs/ -lib/ -bin/ -logs/ -.shards/ - -# Local test file -main.cr - -# Libraries don't need dependency lock -# Dependencies will be locked in application that uses them -shard.lock -.history/ -cache/ diff --git a/lib/halite/CHANGELOG.md b/lib/halite/CHANGELOG.md deleted file mode 100644 index b68709c8..00000000 --- a/lib/halite/CHANGELOG.md +++ /dev/null @@ -1,397 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) -and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -> List all changes before release a new version. - -### Todo - -- [ ] Rewrite HTTP Connection - - [ ] New Connection for Halite - - [x] Proxy support - - [ ] Reuse connection - -## [0.12.0] (2021-03-24) - -- Compatibility with Crystal 1.0. - -## [0.11.0] (2021-02-18) - -> Finally, the major version was out! Happy new year! - -### Changed - -- **[breaking changing]** Drop file logging in favor of Crystal's [Log](https://crystal-lang.org/api/0.36.1/Log.html). (removed `.logging(file: )`, use `.logging(for: )` instead) [#101](https://github.com/icyleaf/halite/pull/101) (thanks @[oprypin](https://github.com/oprypin)) -- Pre-read `TZ` environment value to convert timestamp's timezone during logging output. [#102](https://github.com/icyleaf/halite/pull/102) -- Crystal 0.34.x support. - -## [0.10.9] (2021-02-01) - -### Fixed - -- `timeout` fail to match argument type. [#97](https://github.com/icyleaf/halite/issues/97) (thanks @[oprypin](https://github.com/oprypin)) -- Compatibility with Crystal 0.36.0. - -## [0.10.8] (2020-12-22) - -### Fixed - -- Resolve path of endpoint ending without slash. [#94](https://github.com/icyleaf/halite/issues/94) (thanks @[mipmip](https://github.com/mipmip)) - -## [0.10.7] (2020-12-08) -### Fixed - -- Fix initial status_message. [#91](https://github.com/icyleaf/halite/issues/91) (thanks @[oprypin](https://github.com/oprypin)) - -## [0.10.6] (2020-11-24) -### Fixed - -- Improve resolve of URI. [#88](https://github.com/icyleaf/halite/issues/88) (thanks @[oprypin](https://github.com/oprypin)) - -## [0.10.5] (2020-04-15) - -### Fixed - -- Compatibility with Crystal 0.34.0. - -## [0.10.4] (2019-09-26) - -### Fixed - -- Compatibility with Crystal 0.31.0. - -## [0.10.3] (2019-08-12) - -### Fixed - -- Compatibility with Crystal 0.30.0. - -## [0.10.2] (2019-06-24) - -### Fixed - -- Fixed Basic Auth creates bad headers in crystal 0.29.0. [#73](https://github.com/icyleaf/halite/pull/73) (thanks @[kalinon](https://github.com/kalinon)) -- Fixed use one shared options in multiple instanced `Halite::Client`. [#72](https://github.com/icyleaf/halite/issues/72) (thanks @[qszhu](https://github.com/qszhu)) - -## [0.10.1] (2019-05-28) - -### Fixed - -- Fixed duplica query and backslash when reuse client. [#67](https://github.com/icyleaf/halite/pull/67), [#68](https://github.com/icyleaf/halite/issues/68) (thanks @[watzon](https://github.com/watzon)) -- Fixed no effect to call `logging(true)` method in Crystal 0.28. [#69](https://github.com/icyleaf/halite/issues/69) - -## [0.10.0] (2019-05-20) - -### Added - -- Add `endpoint` chainable method, also add it as configuration option to reuse client. [#66](https://github.com/icyleaf/halite/pull/66) - -## [0.9.2] (2019-05-20) - -### Fixed - -- Compatibility with Crystal 0.28.0 - -### Changed - -- Drop Crystal 0.25.x, 0.26.x, 0.27.x support. - -## [0.9.1] (2019-01-14) - -> Minor typo fix (same as v0.9.0) - -### Fixed - -- Correct version both in `shard.yml` and `version.cr`. (thanks @[matthewmcgarvey](https://github.com/matthewmcgarvey)) -- Update basic auth example in `README.md`. (thanks @[matthewmcgarvey](https://github.com/matthewmcgarvey)) - -## [0.9.0] (2018-12-21) - -> New features with performance improved. - -### Added - -- Add streaming requests (feature to store binary data chunk by chunk) [#53](https://github.com/icyleaf/halite/pull/53) -- Add `user_agent` to Chainable methods. [#55](https://github.com/icyleaf/halite/pull/55) - -### Fixed - -- Fix overwrite the value with default headers when use `merge` or `merge!` method in `Halite::Options`. [#54](https://github.com/icyleaf/halite/pull/54) - -### Changed - -- Remove default headers in `Halite::Options`. -- Move header `User-Agent` to `Halite::Request`. -- Change header `Connection` from "keep-alive" to "close" to `Halite::Request`. -- Remove header `Accept`. - -## [0.8.0] (2018-11-30) - -> Compatibility with Crystal 0.27 and serious bugfix. - -### Changed - -- **[breaking changing]** Rename `logger` to `logging`, `with_logger` to `with_logging`. [#52](https://github.com/icyleaf/halite/pull/52) -- **[breaking changing]** Remove `logging` argument in `Halite::Options.new` and `Halite::Client.new`. [#51](https://github.com/icyleaf/halite/pull/51) -- **[breaking changing]** Remove `logging?` method in `Halite::Options`, use `logging` method instead. [#51](https://github.com/icyleaf/halite/pull/51) -- Change `logging` behavior check if features is exists any class of superclasses is `Halite::Logging` instead of given a Bool type. -- Rename prefix `X-Cache` to `X-Halite-Cache` in cache feature. - -### Added - -- Allow `timeout` method passed single `read` or `connect` method. -- Add `merge!` and `dup` methods in `Halite::Options`. [#51](https://github.com/icyleaf/halite/pull/51) - -### Fixed - -- Fix duplice add "Content-Type" into header during request. [#50](https://github.com/icyleaf/halite/pull/50) -- Fix non overwrite value of headers use `Halite::Options.merge` method. [#50](https://github.com/icyleaf/halite/pull/50) -- Fix always overwrite and return merged option in a instanced class(session mode), see updated note in [Session](https://github.com/icyleaf/halite#sessions). - -### Tested - -- Compatibility with Crystal 0.27 -- Add specs with Crystal 0.25, 0.26 and 0.27 in Circle CI. - -## [0.7.5] (2018-10-31) - -### Changed - -- **[breaking changing]** Rename argument name `ssl` to `tls` in `Halite::Client`/`Halite::Options`/`Halite::Chainable`. - -### Fixed - -- Fix new a `Halite::Client` instance with empty block return `Nil`. [#44](https://github.com/icyleaf/halite/issues/44) - -## [0.7.4] (2018-10-30) - -### Fixed - -- Fix typos in document and comments. [#43](https://github.com/icyleaf/halite/issues/43) (thanks @[GloverDonovan](https://github.com/GloverDonovan)) - -## [0.7.3] (2018-10-18) - -### Fixed - -- Fix json payloads with sub hash/array/namedtupled. [#41](https://github.com/icyleaf/halite/issues/41) (thanks @[fusillicode](https://github.com/fusillicode)) - -## [0.7.2] (2018-09-14) - -> Minor bugfix :bug: - -### Changed - -- **[breaking changing]** Renamed `#to_h` to `#to_flat_h` to avoid confict in `HTTP::Params` extension. [#39](https://github.com/icyleaf/halite/issues/39) - -### Fixed - -- Fix cast from NamedTuple(work: String) to Halite::Options::Type failed with params/json/form. [#38](https://github.com/icyleaf/halite/issues/38) - -## [0.7.1] (2018-09-04) - -### Changed - -- Return empty hash for an empty named tuple. - -### Fixed - -- Fix send cookie during requesting in session mode. (thanks @[megatux](https://github.com/megatux)) -- Fix pass current options instead of instance variable. -- Fix move named tuple extension to src path. - -## [0.7.0] (2018-09-03) - -> Features support :tada: - -### Changed - -- **[breaking changing]** Change instance `Halite::Client` with block behavior. [#33](https://github.com/icyleaf/halite/issues/33) -- **[breaking changing]** Renamed argument name `adapter` to `format` in `#logger` chainable method. -- Move logger into features. - -### Added - -- Add features (aka middleware) support, you can create monitor or interceptor. [#29](https://github.com/icyleaf/halite/issues/29) -- Add cache feature. [#24](https://github.com/icyleaf/halite/issues/24) -- Add `#logging` in chainable method. - -### Fixed - -- Add misisng `#request` method with headers, params, form, json, raw, ssl arguments. -- Fix do not overwrite default headers with exists one by using `Halite::Options.merge`. -- Fix append response to history only with redirect uri. (thanks @[j8r](https://github.com/j8r)) -- Typo and correct words in README. (thanks @[megatux](https://github.com/megatux)) - -## [0.6.0] (2018-08-24) - -> Improve performance with :see_no_evil: - -### Changed - -- **[breaking changing]** Set `logger` to nil when instance a `Halite::Options`, it throws a `Halite::Error` exception if enable `logging`. -- Change `Halite::Options` accepts argument inside. no effect for users. [#27](https://github.com/icyleaf/halite/pull/27) -- Wrap all exception class into a module, better for reading document. - -### Fixed - -- Fix always return `#` with `#full_path` if fragment not exists in `Halite::Request`. -- Fix always overwrite with default headers with `#merge` in `Halite::Options` - -### Tested - -- Compatibility with Crystal 0.26 - -## [0.5.0] (2018-07-03) - -### Changed - -- New logger system and json logger support, see [#19](https://github.com/icyleaf/halite/pull/19). -- **[breaking changing]** Change verb request behavior: - - `get`, `head` only accepts `#params` argument. - - `post`, `put`, `delete`, `patch`, `options` accepts `#params`, `#form`, `#json` and `#raw` arguments. - -### Added - -- Add request [#raw](https://github.com/icyleaf/halite/#raw-string) string support. [#20](https://github.com/icyleaf/halite/issues/20) (thanks @[wirrareka](https://github.com/wirrareka)) - -## [0.4.0] (2018-06-27) - -### Changed - -- **[breaking changing]** Remove `#mime_type` duplicate with `#content_type` in `Halite::Response`. -- Change write log file use append mode by default, it could be change by param. -- Change logger formatter to easy identify category(request/response). - -### Added - -- Add [#links](https://github.com/icyleaf/halite/#link-headers) to `Halite::Response` to fetch link headers. -- Add [#raise_for_status](https://github.com/icyleaf/halite/#raise-for-status-code) to `Halite::Response`. -- Support multiple files upload. [#14](https://github.com/icyleaf/halite/issues/14) (thanks @[BenDietze](https://github.com/BenDietze)) -- Add `#to_raw` to `Halite::Response` to dump a raw of response. [#15](https://github.com/icyleaf/halite/issues/15) (thanks @[BenDietze](https://github.com/BenDietze)) -- Support `OPTIONS` method (crystal 0.25.0+) -- Append write log to a file section to README. - -### Fixed - -- Stripped the filename in a `multipart/form-data` body. [#16](https://github.com/icyleaf/halite/issues/16) (thanks @[BenDietze](https://github.com/BenDietze)) -- Fix `#domain` in `Halite::Request` with subdomain. [#17](https://github.com/icyleaf/halite/pull/17) (thanks @[007lva](https://github.com/007lva)) -- Create missing directories when use path to write log to a file. - -## [0.3.2] (2018-06-19) - -### Fixed - -Compatibility with Crystal 0.25 - -## [0.3.1] (2017-12-13) - -### Added - -- Set `Options.default_headers` to be public method. -- Accept tuples options in `Options.new`. -- Accept `follow`/`follow_strict` in `Options.new`. -- Accept options block in `Options.new`. -- Add logger during request and response (see [usage](README.md#logging)). -- Alias method `Options.read_timeout` to `Options::Timeout.read`. -- Alias method `Options.read_timeout=` to `Options::Timeout.read=`. -- Alias method `Options.connect_timeout` to `Options::Timeout.connect`. -- Alias method `Options.connect_timeout` to `Options::Timeout.connect=`. -- Alias method `Options.follow=` to `Options::Timeout.follow.hops=`. -- Alias method `Options.follow_strict` to `Options::Timeout.follow.strict`. -- Alias method `Options.follow_strict=` to `Options::Timeout.follow.strict=`. - -### Fixed - -- Fix store **Set-Cookies** in response and set **Cookies** in request in better way. -- Fix cant not set connect/read timeout in `Options.new`. -- Fix cant not overwrite default headers in `Options.new`. -- Fix `Options.clear!` was not clear everything and restore default headers. - -## [0.2.0] (2017-11-28) - -### Changed - -- `HTTP::Headers#to_h` return string with each key if it contains one in array. ([commit#e057c47c](https://github.com/icyleaf/halite/commit/e057c47c4b587b27b2bae6871a1968299ce348f5)) - -### Added - -- Add `Response#mime_type` method. -- Add `Response#history` method to support full history of redirections. ([#8](https://github.com/icyleaf/halite/issues/8)) -- Add `Response#parse` method that it better body parser of response with json and write custom adapter for MIME type. ([#9](https://github.com/icyleaf/halite/issues/9)) - -### Fixed - -- Fix issue to first char of redirect uri is not slash(/). ([#11](https://github.com/icyleaf/halite/issues/11)) -- Fix raise unsafe verbs in strict mode. - -## [0.1.5] (2017-10-11) - -### Changed - -- Only store cookies in Sessions shards. ([#7](https://github.com/icyleaf/halite/issues/7)) - -### Added - -- Add `TLS/SSL` support (based on [HTTP::Client.new(uri : URI, tls = nil)](https://crystal-lang.org/api/0.23.1/HTTP/Client.html#new%28uri%3AURI%2Ctls%3Dnil%29-class-method)). -- Add `UnsupportedMethodError/UnsupportedSchemeError` exceptions. - -### Fixed - -- Timeout with redirection. ([#7](https://github.com/icyleaf/halite/issues/7)) -- Compatibility with Crystal 0.24.0 (unreleased) - -## [0.1.3] (2017-10-09) - -### Changed - -- Always instance a new Options with each request in chainable methods. - -### Added - -- Add `accept` method. - -### Fixed - -- Fix `follow`(redirect uri) with full uri and relative path. -- Fix always overwrite request headers with default values. -- Fix always shard same options in any new call. (it only valid in chainable methods) - -## 0.1.2 (2017-09-18) - -- First beta version. - -[Unreleased]: https://github.com/icyleaf/halite/compare/v0.11.0...HEAD -[0.11.0]: https://github.com/icyleaf/halite/compare/v0.10.9...v0.11.0 -[0.10.9]: https://github.com/icyleaf/halite/compare/v0.10.8...v0.10.9 -[0.10.8]: https://github.com/icyleaf/halite/compare/v0.10.7...v0.10.8 -[0.10.7]: https://github.com/icyleaf/halite/compare/v0.10.6...v0.10.7 -[0.10.6]: https://github.com/icyleaf/halite/compare/v0.10.5...v0.10.6 -[0.10.5]: https://github.com/icyleaf/halite/compare/v0.10.4...v0.10.5 -[0.10.4]: https://github.com/icyleaf/halite/compare/v0.10.3...v0.10.4 -[0.10.3]: https://github.com/icyleaf/halite/compare/v0.10.2...v0.10.3 -[0.10.2]: https://github.com/icyleaf/halite/compare/v0.10.1...v0.10.2 -[0.10.1]: https://github.com/icyleaf/halite/compare/v0.10.0...v0.10.1 -[0.10.0]: https://github.com/icyleaf/halite/compare/v0.9.2...v0.10.0 -[0.9.2]: https://github.com/icyleaf/halite/compare/v0.9.1...v0.9.2 -[0.9.1]: https://github.com/icyleaf/halite/compare/v0.9.0...v0.9.1 -[0.9.0]: https://github.com/icyleaf/halite/compare/v0.8.0...v0.9.0 -[0.8.0]: https://github.com/icyleaf/halite/compare/v0.7.5...v0.8.0 -[0.7.5]: https://github.com/icyleaf/halite/compare/v0.7.4...v0.7.5 -[0.7.4]: https://github.com/icyleaf/halite/compare/v0.7.3...v0.7.4 -[0.7.3]: https://github.com/icyleaf/halite/compare/v0.7.2...v0.7.3 -[0.7.2]: https://github.com/icyleaf/halite/compare/v0.7.1...v0.7.2 -[0.7.1]: https://github.com/icyleaf/halite/compare/v0.7.0...v0.7.1 -[0.7.0]: https://github.com/icyleaf/halite/compare/v0.6.0...v0.7.0 -[0.6.0]: https://github.com/icyleaf/halite/compare/v0.5.0...v0.6.0 -[0.5.0]: https://github.com/icyleaf/halite/compare/v0.4.0...v0.5.0 -[0.4.0]: https://github.com/icyleaf/halite/compare/v0.3.2...v0.4.0 -[0.3.2]: https://github.com/icyleaf/halite/compare/v0.3.1...v0.3.2 -[0.3.1]: https://github.com/icyleaf/halite/compare/v0.2.0...v0.3.1 -[0.2.0]: https://github.com/icyleaf/halite/compare/v0.1.5...v0.2.0 -[0.1.5]: https://github.com/icyleaf/halite/compare/v0.1.3...v0.1.5 -[0.1.3]: https://github.com/icyleaf/halite/compare/v0.1.2...v0.1.3 diff --git a/lib/halite/CODE_OF_CONDUCT.md b/lib/halite/CODE_OF_CONDUCT.md deleted file mode 100644 index a95658ae..00000000 --- a/lib/halite/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,46 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. - -## Our Standards - -Examples of behavior that contributes to creating a positive environment include: - -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members - -Examples of unacceptable behavior by participants include: - -* The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a professional setting - -## Our Responsibilities - -Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. - -Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. - -## Scope - -This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at icyleaf.cn@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. - -Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] - -[homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/4/ diff --git a/lib/halite/LICENSE b/lib/halite/LICENSE deleted file mode 100644 index 317898da..00000000 --- a/lib/halite/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2017-present icyleaf - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/lib/halite/README.md b/lib/halite/README.md deleted file mode 100644 index 09135322..00000000 --- a/lib/halite/README.md +++ /dev/null @@ -1,882 +0,0 @@ -![halite-logo](https://github.com/icyleaf/halite/raw/master/halite-logo-small.png) - -# Halite - -[![Language](https://img.shields.io/badge/language-crystal-776791.svg)](https://github.com/crystal-lang/crystal) -[![Tag](https://img.shields.io/github/tag/icyleaf/halite.svg)](https://github.com/icyleaf/halite/blob/master/CHANGELOG.md) -[![Source](https://img.shields.io/badge/source-github-brightgreen.svg)](https://github.com/icyleaf/halite/) -[![Document](https://img.shields.io/badge/document-api-brightgreen.svg)](https://icyleaf.github.io/halite/) -[![Build Status](https://github.com/icyleaf/halite/workflows/Linux%20CI/badge.svg)](https://github.com/icyleaf/halite/actions?query=workflow%3A%22Linux+CI%22) - -HTTP Requests with a chainable REST API, built-in sessions and middleware written by [Crystal](https://crystal-lang.org/). -Inspired from the **awesome** Ruby's [HTTP](https://github.com/httprb/http)/[RESTClient](https://github.com/rest-client/rest-client) -and Python's [requests](https://github.com/requests/requests). - -Build in Crystal version `>= 1.0.0`, this document valid with latest commit. - -## Index - - - -- [Installation](#installation) -- [Usage](#usage) - - [Making Requests](#making-requests) - - [Passing Parameters](#passing-parameters) - - [Query string parameters](#query-string-parameters) - - [Form data](#form-data) - - [File uploads (via form data)](#file-uploads-via-form-data) - - [JSON data](#json-data) - - [Raw String](#raw-string) - - [Passing advanced options](#passing-advanced-options) - - [Auth](#auth) - - [User Agent](#user-agent) - - [Headers](#headers) - - [Cookies](#cookies) - - [Redirects and History](#redirects-and-history) - - [Timeout](#timeout) - - [HTTPS](#https) - - [Response Handling](#response-handling) - - [Response Content](#response-content) - - [JSON Content](#json-content) - - [Parsing Content](#parsing-content) - - [Binary Data](#binary-data) - - [Error Handling](#error-handling) - - [Raise for status code](#raise-for-status-code) -- [Middleware](#middleware) - - [Write a simple feature](#write-a-simple-feature) - - [Write a interceptor](#write-a-interceptor) -- [Advanced Usage](#advanced-usage) - - [Configuring](#configuring) - - [Endpoint](#endpoint) - - [Sessions](#sessions) - - [Streaming Requests](#streaming-requests) - - [Logging](#logging) - - [Local Cache](#local-cache) - - [Link Headers](#link-headers) - - -## Installation - -Add this to your application's `shard.yml`: - -```yaml -dependencies: - halite: - github: icyleaf/halite -``` - -## Usage - -```crystal -require "halite" -``` - -### Making Requests - -Make a GET request: - -```crystal -# Direct get url -Halite.get("http://httpbin.org/get") - -# Support NamedTuple as query params -Halite.get("http://httpbin.org/get", params: { - language: "crystal", - shard: "halite" -}) - -# Also support Hash as query params -Halite.get("http://httpbin.org/get", headers: { - "Private-Token" => "T0k3n" - }, params: { - "language" => "crystal", - "shard" => "halite" - }) - -# And support chainable -Halite.header(private_token: "T0k3n") - .get("http://httpbin.org/get", params: { - "language" => "crystal", - "shard" => "halite" - }) -``` - -See also all [chainable methods](https://icyleaf.github.io/halite/Halite/Chainable.html). - -Many other HTTP methods are available as well: - -- `get` -- `head` -- `post` -- `put` -- `delete` -- `patch` -- `options` - -### Passing Parameters - -#### Query string parameters - -Use the `params` argument to add query string parameters to requests: - -```crystal -Halite.get("http://httpbin.org/get", params: { "firstname" => "Olen", "lastname" => "Rosenbaum" }) -``` - -#### Form data - -Use the `form` argument to pass data serialized as form encoded: - -```crystal -Halite.post("http://httpbin.org/post", form: { "firstname" => "Olen", "lastname" => "Rosenbaum" }) -``` - -#### File uploads (via form data) - -To upload files as if form data, construct the form as follows: - -```crystal -Halite.post("http://httpbin.org/post", form: { - "username" => "Quincy", - "avatar" => File.open("/Users/icyleaf/quincy_avatar.png") -}) -``` - -It is possible to upload multiple files: - -```crystal -Halite.post("http://httpbin.org/post", form: { - photos: [ - File.open("/Users/icyleaf/photo1.png"), - File.open("/Users/icyleaf/photo2.png") - ], - album_name: "samples" -}) -``` - -Or pass the name with `[]`: - -```crystal -Halite.post("http://httpbin.org/post", form: { - "photos[]" => [ - File.open("/Users/icyleaf/photo1.png"), - File.open("/Users/icyleaf/photo2.png") - ], - "album_name" => "samples" -}) -``` - -Multiple files can also be uploaded using both ways above, it depend on web server. - -#### JSON data - -Use the `json` argument to pass data serialized as body encoded: - -```crystal -Halite.post("http://httpbin.org/post", json: { "firstname" => "Olen", "lastname" => "Rosenbaum" }) -``` - -#### Raw String - -Use the `raw` argument to pass raw string as body and set the `Content-Type` manually: - -```crystal -# Set content-type to "text/plain" by default -Halite.post("http://httpbin.org/post", raw: "name=Peter+Lee&address=%23123+Happy+Ave&language=C%2B%2B") - -# Set content-type manually -Halite.post("http://httpbin.org/post", - headers: { - "content-type" => "application/json" - }, - raw: %Q{{"name":"Peter Lee","address":"23123 Happy Ave","language":"C++"}} -) -``` - -### Passing advanced options - -#### Auth - -Use the `#basic_auth` method to perform [HTTP Basic Authentication](http://tools.ietf.org/html/rfc2617) using a username and password: - -```crystal -Halite.basic_auth(user: "user", pass: "p@ss").get("http://httpbin.org/get") - -# We can pass a raw authorization header using the auth method: -Halite.auth("Bearer dXNlcjpwQHNz").get("http://httpbin.org/get") -``` - -#### User Agent - -Use the `#user_agent` method to overwrite default one: - -```crystal -Halite.user_agent("Crystal Client").get("http://httpbin.org/user-agent") -``` - -#### Headers - -Here are two way to passing headers data: - -##### 1. Use the `#headers` method - -```crystal -Halite.headers(private_token: "T0k3n").get("http://httpbin.org/get") - -# Also support Hash or NamedTuple -Halite.headers({ "private_token" => "T0k3n" }).get("http://httpbin.org/get") - -# Or -Halite.headers({ private_token: "T0k3n" }).get("http://httpbin.org/get") -``` - -##### 2. Use the `headers` argument in the available request method: - -```crystal -Halite.get("http://httpbin.org/anything" , headers: { private_token: "T0k3n" }) - -Halite.post("http://httpbin.org/anything" , headers: { private_token: "T0k3n" }) -``` - -#### Cookies - -##### Passing cookies in requests - -The `Halite.cookies` option can be used to configure cookies for a given request: - -```crystal -Halite.cookies(session_cookie: "6abaef100b77808ceb7fe26a3bcff1d0") - .get("http://httpbin.org/headers") -``` - -##### Get cookies in requests - -To obtain the cookies(cookie jar) for a given response, call the `#cookies` method: - -```crystal -r = Halite.get("http://httpbin.org/cookies?set?session_cookie=6abaef100b77808ceb7fe26a3bcff1d0") -pp r.cookies -# => ##}> -``` - -#### Redirects and History - -##### Automatically following redirects - -The `Halite.follow` method can be used for automatically following redirects(Max up to 5 times): - -```crystal -# Set the cookie and redirect to http://httpbin.org/cookies -Halite.follow - .get("http://httpbin.org/cookies/set/name/foo") -``` - -##### Limiting number of redirects - -As above, set over 5 times, it will raise a `Halite::TooManyRedirectsError`, but you can change less if you can: - -```crystal -Halite.follow(2) - .get("http://httpbin.org/relative-redirect/5") -``` - -##### Disabling unsafe redirects - -It only redirects with `GET`, `HEAD` request and returns a `300`, `301`, `302` by default, otherwise it will raise a `Halite::StateError`. -We can disable it to set `:strict` to `false` if we want any method(verb) requests, in which case the `GET` method(verb) will be used for -that redirect: - -```crystal -Halite.follow(strict: false) - .post("http://httpbin.org/relative-redirect/5") -``` - -##### History - -`Response#history` property list contains the `Response` objects that were created in order to complete the request. -The list is ordered from the oldest to most recent response. - -```crystal -r = Halite.follow - .get("http://httpbin.org/redirect/3") - -r.uri -# => http://httpbin.org/get - -r.status_code -# => 200 - -r.history -# => [ -# # "/relative-redirect/2" ...>, -# # "/relative-redirect/1" ...>, -# # "/get" ...>, -# # "application/json" ...> -# ] -``` - -**NOTE**: It contains the `Response` object if you use `history` and HTTP was not a `30x`, For example: - -```crystal -r = Halite.get("http://httpbin.org/get") -r.history.size # => 0 - -r = Halite.follow - .get("http://httpbin.org/get") -r.history.size # => 1 -``` - -#### Timeout - -By default, the Halite does not enforce timeout on a request. -We can enable per operation timeouts by configuring them through the chaining API. - -The `connect` timeout is the number of seconds Halite will wait for our client to establish a connection to a remote server call on the socket. - -Once our client has connected to the server and sent the HTTP request, -the `read` timeout is the number of seconds the client will wait for the server to send a response. - -```crystal -# Separate set connect and read timeout -Halite.timeout(connect: 3.0, read: 2.minutes) - .get("http://httpbin.org/anything") - -# Boath set connect and read timeout -# The timeout value will be applied to both the connect and the read timeouts. -Halite.timeout(5) - .get("http://httpbin.org/anything") -``` - -### HTTPS - -The Halite supports HTTPS via Crystal's built-in OpenSSL module. All you have to do in order to use HTTPS is pass in an https://-prefixed URL. - -To use client certificates, you can pass in a custom `OpenSSL::SSL::Context::Client` object containing the certificates you wish to use: - -```crystal -tls = OpenSSL::SSL::Context::Client.new -tls.ca_certificates = File.expand_path("~/client.crt") -tls.private_key = File.expand_path("~/client.key") - -Halite.get("https://httpbin.org/anything", tls: tls) -``` - -### Response Handling - -After an HTTP request, `Halite::Response` object have several useful methods. (Also see the [API documentation](https://icyleaf.github.io/halite/Halite/Response.html)). - -- **#body**: The response body. -- **#body_io**: The response body io only available in streaming requests. -- **#status_code**: The HTTP status code. -- **#content_type**: The content type of the response. -- **#content_length**: The content length of the response. -- **#cookies**: A `HTTP::Cookies` set by server. -- **#headers**: A `HTTP::Headers` of the response. -- **#links**: A list of `Halite::HeaderLink` set from headers. -- **#parse**: (return value depends on MIME type) parse the body using a parser defined for the `#content_type`. -- **#to_a**: Return a `Hash` of status code, response headers and body as a string. -- **#to_raw**: Return a raw of response as a string. -- **#to_s**: Return response body as a string. -- **#version**: The HTTP version. - -#### Response Content - -We can read the content of the server's response by call `#body`: - -```crystal -r = Halite.get("http://httpbin.org/user-agent") -r.body -# => {"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.87 Safari/537.36"} -``` - -The `gzip` and `deflate` transfer-encodings are automatically decoded for you. -And requests will automatically decode content from the server. Most unicode charsets are seamlessly decoded. - -#### JSON Content - -Thereโ€™s also a built-in a JSON adapter, in case youโ€™re dealing with JSON data: - -```crystal -r = Halite.get("http://httpbin.org/user-agent") -r.parse("json") -r.parse # simplily by default -# => { -# => "user-agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.87 Safari/537.36" -# => } -``` - -#### Parsing Content - -`Halite::Response` has a MIME type adapter system that you can use a decoder to parse the content, -we can inherit `Halite::MimeTypes::Adapter` to make our adapter: - -```crystal -# Define a MIME type adapter -class YAMLAdapter < Halite::MimeType::Adapter - def decode(string) - YAML.parse(string) - end - - def encode(obj) - obj.to_yaml - end -end - -# Register to Halite to invoke -Halite::MimeType.register YAMLAdapter.new, "application/x-yaml", "yaml", "yml" - -# Test it! -r = Halite.get "https://raw.githubusercontent.com/icyleaf/halite/master/shard.yml" -r.parse("yaml") # or "yml" -# => {"name" => "halite", "version" => "0.4.0", "authors" => ["icyleaf "], "crystal" => "0.25.0", "license" => "MIT"} -``` - -#### Binary Data - -Store binary data (eg, `application/octet-stream`) to file, you can use [streaming requests](#streaming-requests): - -```crystal -Halite.get("https://github.com/icyleaf/halite/archive/master.zip") do |response| - filename = response.filename || "halite-master.zip" - File.open(filename, "w") do |file| - IO.copy(response.body_io, file) - end -end -``` - -### Error Handling - -- For any status code, a `Halite::Response` will be returned. -- If request timeout, a `Halite::TimeoutError` will be raised. -- If a request exceeds the configured number of maximum redirections, a `Halite::TooManyRedirectsError` will raised. -- If request uri is http and configured tls context, a `Halite::RequestError` will raised. -- If request uri is invalid, a `Halite::ConnectionError`/`Halite::UnsupportedMethodError`/`Halite::UnsupportedSchemeError` will raised. - -#### Raise for status code - -If we made a bad request(a 4xx client error or a 5xx server error response), we can raise with `Halite::Response.raise_for_status`. - -But, since our `status_code` was not `4xx` or `5xx`, it returns `nil` when we call it: - -```crystal -urls = [ - "https://httpbin.org/status/404", - "https://httpbin.org/status/500?foo=bar", - "https://httpbin.org/status/200", -] - -urls.each do |url| - r = Halite.get url - begin - r.raise_for_status - p r.body - rescue ex : Halite::ClientError | Halite::ServerError - p "[#{ex.status_code}] #{ex.status_message} (#{ex.class})" - end -end - -# => "[404] not found error with url: https://httpbin.org/status/404 (Halite::Exception::ClientError)" -# => "[500] internal server error error with url: https://httpbin.org/status/500?foo=bar (Halite::Exception::ServerError)" -# => "" -``` - -## Middleware - -Halite now has middleware (a.k.a features) support providing a simple way to plug in intermediate custom logic -in your HTTP client, allowing you to monitor outgoing requests, incoming responses, and use it as an interceptor. - -Available features: - -- [Logging](#logging) (Yes, logging is based on feature, cool, aha!) -- [Local Cache](#local-cache) (local storage, speed up in development) - -### Write a simple feature - -Let's implement simple middleware that prints each request: - -```crystal -class RequestMonister < Halite::Feature - @label : String - def initialize(**options) - @label = options.fetch(:label, "") - end - - def request(request) : Halite::Request - puts @label - puts request.verb - puts request.uri - puts request.body - - request - end - - Halite.register_feature "request_monster", self -end -``` - -Then use it in Halite: - -```crystal -Halite.use("request_monster", label: "testing") - .post("http://httpbin.org/post", form: {name: "foo"}) - -# Or configure to client -client = Halite::Client.new do - use "request_monster", label: "testing" -end - -client.post("http://httpbin.org/post", form: {name: "foo"}) - -# => testing -# => POST -# => http://httpbin.org/post -# => name=foo -``` - -### Write a interceptor - -Halite's killer feature is the **interceptor**, Use `Halite::Feature::Chain` to process with two result: - -- `next`: perform and run next interceptor -- `return`: perform and return - -So, you can intercept and turn to the following registered features. - -```crystal -class AlwaysNotFound < Halite::Feature - def intercept(chain) - response = chain.perform - response = Halite::Response.new(chain.request.uri, 404, response.body, response.headers) - chain.next(response) - end - - Halite.register_feature "404", self -end - -class PoweredBy < Halite::Feature - def intercept(chain) - if response = chain.response - response.headers["X-Powered-By"] = "Halite" - chain.return(response) - else - chain - end - end - - Halite.register_feature "powered_by", self -end - -r = Halite.use("404").use("powered_by").get("http://httpbin.org/user-agent") -r.status_code # => 404 -r.headers["X-Powered-By"] # => Halite -r.body # => {"user-agent":"Halite/0.6.0"} -``` - -For more implementation details about the feature layer, see the [Feature](https://github.com/icyleaf/halite/blob/master/src/halite/feature.cr#L2) class and [examples](https://github.com/icyleaf/halite/tree/master/src/halite/features) and [specs](https://github.com/icyleaf/halite/blob/master/spec/spec_helper.cr#L23). - -## Advanced Usage - -### Configuring - -Halite provides a traditional way to instance client, and you can configure any chainable methods with block: - -```crystal -client = Halite::Client.new do - # Set basic auth - basic_auth "username", "password" - - # Enable logging - logging true - - # Set timeout - timeout 10.seconds - - # Set user agent - headers user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36" -end - -# You also can configure in this way -client.accept("application/json") - -r = client.get("http://httpbin.org/get") -``` - -### Endpoint - -No more given endpoint per request, use `endpoint` will make the request URI shorter, you can set it in flexible way: - -```crystal -client = Halite::Client.new do - endpoint "https://gitlab.org/api/v4" - user_agent "Halite" -end - -client.get("users") # GET https://gitlab.org/api/v4/users - -# You can override the path by using an absolute path -client.get("/users") # GET https://gitlab.org/users -``` - -### Sessions - -As like [requests.Session()](http://docs.python-requests.org/en/master/user/advanced/#session-objects), Halite built-in session by default. - -Let's persist some cookies across requests: - -```crystal -client = Halite::Client.new -client.get("http://httpbin.org/cookies/set?private_token=6abaef100b77808ceb7fe26a3bcff1d0") -client.get("http://httpbin.org/cookies") -# => 2018-06-25 18:41:05 +08:00 | request | GET | http://httpbin.org/cookies/set?private_token=6abaef100b77808ceb7fe26a3bcff1d0 -# => 2018-06-25 18:41:06 +08:00 | response | 302 | http://httpbin.org/cookies/set?private_token=6abaef100b77808ceb7fe26a3bcff1d0 | text/html -# => -# => Redirecting... -# =>

Redirecting...

-# =>

You should be redirected automatically to target URL: /cookies. If not click the link. -# => 2018-06-25 18:41:06 +08:00 | request | GET | http://httpbin.org/cookies -# => 2018-06-25 18:41:07 +08:00 | response | 200 | http://httpbin.org/cookies | application/json -# => {"cookies":{"private_token":"6abaef100b77808ceb7fe26a3bcff1d0"}} -``` - -All it support with [chainable methods](https://icyleaf.github.io/halite/Halite/Chainable.html) in the other examples list in [requests.Session](http://docs.python-requests.org/en/master/user/advanced/#session-objects). - -Note, however, that chainable methods will not be persisted across requests, even if using a session. This example will only send the cookies or headers with the first request, but not the second: - -```crystal -client = Halite::Client.new -r = client.cookies("username": "foobar").get("http://httpbin.org/cookies") -r.body # => {"cookies":{"username":"foobar"}} - -r = client.get("http://httpbin.org/cookies") -r.body # => {"cookies":{}} -``` - -If you want to manually add cookies, headers (even features etc) to your session, use the methods start with `with_` in `Halite::Options` -to manipulate them: - -```crystal -r = client.get("http://httpbin.org/cookies") -r.body # => {"cookies":{}} - -client.options.with_cookie("username": "foobar") -r = client.get("http://httpbin.org/cookies") -r.body # => {"cookies":{"username":"foobar"}} -``` - -### Streaming Requests - -Similar to [HTTP::Client](https://crystal-lang.org/api/0.36.1/HTTP/Client.html#streaming) usage with a block, -you can easily use same way, but Halite returns a `Halite::Response` object: - -```crystal -r = Halite.get("http://httpbin.org/stream/5") do |response| - response.status_code # => 200 - response.body_io.each_line do |line| - puts JSON.parse(line) # => {"url" => "http://httpbin.org/stream/5", "args" => {}, "headers" => {"Host" => "httpbin.org", "Connection" => "close", "User-Agent" => "Halite/0.8.0", "Accept" => "*/*", "Accept-Encoding" => "gzip, deflate"}, "id" => 0_i64} - end -end -``` - -> **Warning**: -> -> `body_io` is avaiabled as an `IO` and not reentrant safe. Might throws a "Nil assertion failed" exception if there is no data in the `IO` -(such like `head` requests). Calling this method multiple times causes some of the received data being lost. -> -> One more thing, use streaming requests the response will always [enable redirect](#redirects-and-history) automatically. - -### Logging - -Halite does not enable logging on each request and response too. -We can enable per operation logging by configuring them through the chaining API. - -By default, Halite will logging all outgoing HTTP requests and their responses(without binary stream) to `STDOUT` on DEBUG level. -You can configuring the following options: - -- `logging`: Instance your `Halite::Logging::Abstract`, check [Use the custom logging](#use-the-custom-logging). -- `format`: Output format, built-in `common` and `json`, you can write your own. -- `file`: Write to file with path, works with `format`. -- `filemode`: Write file mode, works with `format`, by default is `a`. (append to bottom, create it if file is not exist) -- `skip_request_body`: By default is `false`. -- `skip_response_body`: By default is `false`. -- `skip_benchmark`: Display elapsed time, by default is `false`. -- `colorize`: Enable colorize in terminal, only apply in `common` format, by default is `true`. - -> **NOTE**: `format` (`file` and `filemode`) and `logging` are conflict, you can not use both. - -Let's try with it: - -```crystal -# Logging json request -Halite.logging - .get("http://httpbin.org/get", params: {name: "foobar"}) - -# => 2018-06-25 18:33:14 +08:00 | request | GET | http://httpbin.org/get?name=foobar -# => 2018-06-25 18:33:15 +08:00 | response | 200 | http://httpbin.org/get?name=foobar | 381.32ms | application/json -# => {"args":{"name":"foobar"},"headers":{"Accept":"*/*","Accept-Encoding":"gzip, deflate","Connection":"close","Host":"httpbin.org","User-Agent":"Halite/0.3.2"},"origin":"60.206.194.34","url":"http://httpbin.org/get?name=foobar"} - -# Logging image request -Halite.logging - .get("http://httpbin.org/image/png") - -# => 2018-06-25 18:34:15 +08:00 | request | GET | http://httpbin.org/image/png -# => 2018-06-25 18:34:15 +08:00 | response | 200 | http://httpbin.org/image/png | image/png - -# Logging with options -Halite.logging(skip_request_body: true, skip_response_body: true) - .post("http://httpbin.org/get", form: {image: File.open("halite-logo.png")}) - -# => 2018-08-28 14:33:19 +08:00 | request | POST | http://httpbin.org/post -# => 2018-08-28 14:33:21 +08:00 | response | 200 | http://httpbin.org/post | 1.61s | application/json -``` - -#### JSON-formatted logging - -It has JSON formatted for developer friendly logging. - -``` -Halite.logging(format: "json") - .get("http://httpbin.org/get", params: {name: "foobar"}) -``` - -#### Write to a log file - -```crystal -# Write plain text to a log file -Log.setup("halite.file", backend: Log::IOBackend.new(File.open("/tmp/halite.log", "a"))) -Halite.logging(for: "halite.file", skip_benchmark: true, colorize: false) - .get("http://httpbin.org/get", params: {name: "foobar"}) - -# Write json data to a log file -Log.setup("halite.file", backend: Log::IOBackend.new(File.open("/tmp/halite.log", "a"))) -Halite.logging(format: "json", for: "halite.file") - .get("http://httpbin.org/get", params: {name: "foobar"}) - -# Redirect *all* logging from Halite to a file: -Log.setup("halite", backend: Log::IOBackend.new(File.open("/tmp/halite.log", "a"))) -``` - -#### Use the custom logging - -Creating the custom logging by integration `Halite::Logging::Abstract` abstract class. -Here has two methods must be implement: `#request` and `#response`. - -```crystal -class CustomLogging < Halite::Logging::Abstract - def request(request) - @logger.info { "| >> | %s | %s %s" % [request.verb, request.uri, request.body] } - end - - def response(response) - @logger.info { "| << | %s | %s %s" % [response.status_code, response.uri, response.content_type] } - end -end - -# Add to adapter list (optional) -Halite::Logging.register "custom", CustomLogging.new - -Halite.logging(logging: CustomLogging.new) - .get("http://httpbin.org/get", params: {name: "foobar"}) - -# We can also call it use format name if you added it. -Halite.logging(format: "custom") - .get("http://httpbin.org/get", params: {name: "foobar"}) - -# => 2017-12-13 16:40:13 +08:00 | >> | GET | http://httpbin.org/get?name=foobar -# => 2017-12-13 16:40:15 +08:00 | << | 200 | http://httpbin.org/get?name=foobar application/json -``` - -### Local Cache - -Local cache feature is caching responses easily with Halite through an chainable method that is simple and elegant -yet powerful. Its aim is to focus on the HTTP part of caching and do not worrying about how stuff stored, api rate limiting -even works without network(offline). - -It has the following options: - -- `file`: Load cache from file. it conflict with `path` and `expires`. -- `path`: The path of cache, default is "/tmp/halite/cache/" -- `expires`: The expires time of cache, default is never expires. -- `debug`: The debug mode of cache, default is `true` - -With debug mode, cached response it always included some headers information: - -- `X-Halite-Cached-From`: Cache source (cache or file) -- `X-Halite-Cached-Key`: Cache key with verb, uri and body (return with cache, not `file` passed) -- `X-Halite-Cached-At`: Cache created time -- `X-Halite-Cached-Expires-At`: Cache expired time (return with cache, not `file` passed) - -```crystal -Halite.use("cache").get "http://httpbin.org/anything" # request a HTTP -r = Halite.use("cache").get "http://httpbin.org/anything" # request from local storage -r.headers # => {..., "X-Halite-Cached-At" => "2018-08-30 10:41:14 UTC", "X-Halite-Cached-By" => "Halite", "X-Halite-Cached-Expires-At" => "2018-08-30 10:41:19 UTC", "X-Halite-Cached-Key" => "2bb155e6c8c47627da3d91834eb4249a"}} -``` - -### Link Headers - -Many HTTP APIs feature [Link headers](https://tools.ietf.org/html/rfc5988). GitHub uses -these for [pagination](https://developer.github.com/v3/#pagination) in their API, for example: - -```crystal -r = Halite.get "https://api.github.com/users/icyleaf/repos?page=1&per_page=2" -r.links -# => {"next" => -# => Halite::HeaderLink( -# => @params={}, -# => @rel="next", -# => @target="https://api.github.com/user/17814/repos?page=2&per_page=2"), -# => "last" => -# => Halite::HeaderLink( -# => @params={}, -# => @rel="last", -# => @target="https://api.github.com/user/17814/repos?page=41&per_page=2")} - -r.links["next"] -# => "https://api.github.com/user/17814/repos?page=2&per_page=2" - -r.links["next"].params -# => {} -``` - -## Help and Discussion - -You can browse the API documents: - -https://icyleaf.github.io/halite/ - -You can browse the all chainable methods: - -https://icyleaf.github.io/halite/Halite/Chainable.html - -You can browse the Changelog: - -https://github.com/icyleaf/halite/blob/master/CHANGELOG.md - -If you have found a bug, please create a issue here: - -https://github.com/icyleaf/halite/issues/new - -## Donate - -Halite is a open source, collaboratively funded project. If you run a business and are using Halite in a revenue-generating product, -it would make business sense to sponsor Halite development. Individual users are also welcome to make a one time donation -if Halite has helped you in your work or personal projects. - -You can donate via [Paypal](https://www.paypal.me/icyleaf/5). - -## How to Contribute - -Your contributions are always welcome! Please submit a pull request or create an issue to add a new question, bug or feature to the list. - -All [Contributors](https://github.com/icyleaf/halite/graphs/contributors) are on the wall. - -## You may also like - -- [totem](https://github.com/icyleaf/totem) - Load and parse a configuration file or string in JSON, YAML, dotenv formats. -- [markd](https://github.com/icyleaf/markd) - Yet another markdown parser built for speed, Compliant to CommonMark specification. -- [poncho](https://github.com/icyleaf/poncho) - A .env parser/loader improved for performance. -- [popcorn](https://github.com/icyleaf/popcorn) - Easy and Safe casting from one type to another. -- [fast-crystal](https://github.com/icyleaf/fast-crystal) - ๐Ÿ’จ Writing Fast Crystal ๐Ÿ˜ -- Collect Common Crystal idioms. - -## License - -[MIT License](https://github.com/icyleaf/halite/blob/master/LICENSE) ยฉ icyleaf diff --git a/lib/halite/benchmarks/.gitignore b/lib/halite/benchmarks/.gitignore deleted file mode 100644 index fa22f9c8..00000000 --- a/lib/halite/benchmarks/.gitignore +++ /dev/null @@ -1,17 +0,0 @@ -doc/ -docs/ -lib/ -bin/ -logs/ -.shards/ - -# Local test file -main.cr - -# Temporay ignore -benchmarks - -# Libraries don't need dependency lock -# Dependencies will be locked in application that uses them -shard.lock -.history/ diff --git a/lib/halite/benchmarks/README.md b/lib/halite/benchmarks/README.md deleted file mode 100644 index b79406e3..00000000 --- a/lib/halite/benchmarks/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# About this benchmarks - -Benchmarks performed inspired from excon's benchmarking tool. - -## Environments - -- MacBook Pro (Retina, 15-inch, Mid 2015), 2.2 GHz Intel Core i7, 16 GB 1600 MHz DDR3. -- Crystal 0.35.1 (2020-06-19) LLVM: 10.0.0 -- Clients - - buit-in HTTP::Client - - create v0.26.1 - - halite v0.10.8 - -## Result - -``` -Tach times: 10000 - Tach Total - crest 8.0365ms - halite 7.9538ms (fastest) - halite (persistent) 8.0205ms - built-in HTTP::Client 8.0256ms -``` - -## Test yourself - -```crystal -$ shards build --release --no-debug -$ ./bin/run_benchmark -``` diff --git a/lib/halite/benchmarks/shard.yml b/lib/halite/benchmarks/shard.yml deleted file mode 100644 index aac4f20d..00000000 --- a/lib/halite/benchmarks/shard.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: benchmarks -version: 0.1.0 - -authors: - - icyleaf - -targets: - run_benchmark: - main: src/run_benchmark.cr - -dependencies: - crest: - github: mamantoha/crest - version: ~> 0.26.1 - halite: - path: ../ - -crystal: 0.35.1 - -license: MIT diff --git a/lib/halite/benchmarks/src/clients/crest.cr b/lib/halite/benchmarks/src/clients/crest.cr deleted file mode 100644 index daeab1de..00000000 --- a/lib/halite/benchmarks/src/clients/crest.cr +++ /dev/null @@ -1,10 +0,0 @@ -require "crest" - -module Client - MEMBERS << { - name: "crest", - proc: ->(url : String) { - Crest.get(url).body - }, - } -end diff --git a/lib/halite/benchmarks/src/clients/halite.cr b/lib/halite/benchmarks/src/clients/halite.cr deleted file mode 100644 index 733989c3..00000000 --- a/lib/halite/benchmarks/src/clients/halite.cr +++ /dev/null @@ -1,17 +0,0 @@ -require "halite" - -module Client - MEMBERS << { - name: "halite", - proc: ->(url : String) { - Halite::Client.new.request("get", url).body - }, - } - - MEMBERS << { - name: "halite (persistent)", - proc: ->(url : String) { - Halite.get(url).body - }, - } -end diff --git a/lib/halite/benchmarks/src/clients/http_client.cr b/lib/halite/benchmarks/src/clients/http_client.cr deleted file mode 100644 index aab267db..00000000 --- a/lib/halite/benchmarks/src/clients/http_client.cr +++ /dev/null @@ -1,10 +0,0 @@ -require "http/server" - -module Client - MEMBERS << { - name: "built-in HTTP::Client", - proc: ->(url : String) { - HTTP::Client.get(url).body - }, - } -end diff --git a/lib/halite/benchmarks/src/run_benchmark.cr b/lib/halite/benchmarks/src/run_benchmark.cr deleted file mode 100644 index 8e37635a..00000000 --- a/lib/halite/benchmarks/src/run_benchmark.cr +++ /dev/null @@ -1,18 +0,0 @@ -require "./support/**" -require "./clients/**" - -module Client - MEMBERS = [] of NamedTuple(name: String, proc: Proc(String, String)) -end - -url = run_server - -sleep 1 - -Benchmark.tach(10_000) do |x| - Client::MEMBERS.each do |client| - x.report(client["name"]) do - client["proc"].call(url) - end - end -end diff --git a/lib/halite/benchmarks/src/support/http_server.cr b/lib/halite/benchmarks/src/support/http_server.cr deleted file mode 100644 index 46030a97..00000000 --- a/lib/halite/benchmarks/src/support/http_server.cr +++ /dev/null @@ -1,16 +0,0 @@ -require "http/server" - -def run_server - port = 12381 - server = HTTP::Server.new do |context| - context.response.content_type = "text/plain" - text = "x" * 10000 - context.response.print text - end - - spawn do - server.listen(port) - end - - "http://localhost:#{port}" -end diff --git a/lib/halite/benchmarks/src/support/tach_benchmark.cr b/lib/halite/benchmarks/src/support/tach_benchmark.cr deleted file mode 100644 index 1aad9d1f..00000000 --- a/lib/halite/benchmarks/src/support/tach_benchmark.cr +++ /dev/null @@ -1,77 +0,0 @@ -module Benchmark - def self.tach(times : Int32) - {% if !flag?(:release) %} - puts "Warning: benchmarking without the `--release` flag won't yield useful results" - {% end %} - - job = Tach::Job.new(times) - yield job - job.execute - job.report - job - end - - module Tach - class Job - def initialize(@times : Int32 = 1) - @benchmarks = [] of {String, ->} - @results = {} of String => Float64 - end - - def report(label : String, &block) - @benchmarks << {label, block} - end - - def execute - @benchmarks.each do |benchmark| - GC.collect - - label, block = benchmark - durations = [] of Float64 - @times.times do - before = Time.utc - block.call - after = Time.utc - - durations << (after - before).total_seconds - end - - average = durations.sum.to_f / @times.to_f - - @results[label] = average - end - end - - def report - fastest = @results.min_by { |_, value| value } - - puts "Tach times: #{@times}" - printf "%30s %20s\n", "Tach", "Total" - @results.each do |label, result| - mark = label == fastest.first ? " (fastest)" : "" - - printf "%30s %20s%s\n", label, human_mean(result), mark - end - end - - private def human_mean(iteration_time) - case Math.log10(iteration_time) - when 0..Float64::MAX - digits = iteration_time - suffix = "s" - when -3..0 - digits = iteration_time * 1000 - suffix = "ms" - when -6..-3 - digits = iteration_time * 1_000_000 - suffix = "ยตs" - else - digits = iteration_time * 1_000_000_000 - suffix = "ns" - end - - "#{digits.round(4).to_s.rjust(6)}#{suffix}" - end - end - end -end diff --git a/lib/halite/halite-logo-small.png b/lib/halite/halite-logo-small.png deleted file mode 100644 index 3e6a65e4..00000000 Binary files a/lib/halite/halite-logo-small.png and /dev/null differ diff --git a/lib/halite/halite-logo.png b/lib/halite/halite-logo.png deleted file mode 100644 index 214813fd..00000000 Binary files a/lib/halite/halite-logo.png and /dev/null differ diff --git a/lib/halite/lib b/lib/halite/lib deleted file mode 120000 index a96aa0ea..00000000 --- a/lib/halite/lib +++ /dev/null @@ -1 +0,0 @@ -.. \ No newline at end of file diff --git a/lib/halite/shard.yml b/lib/halite/shard.yml deleted file mode 100644 index 6174cf2b..00000000 --- a/lib/halite/shard.yml +++ /dev/null @@ -1,9 +0,0 @@ -name: halite -version: 0.12.0 - -authors: - - icyleaf - -crystal: ">= 0.36.1, < 2.0.0" - -license: MIT diff --git a/lib/halite/src/halite.cr b/lib/halite/src/halite.cr deleted file mode 100644 index 512e1149..00000000 --- a/lib/halite/src/halite.cr +++ /dev/null @@ -1,53 +0,0 @@ -require "./halite/*" -require "./halite/ext/*" - -module Halite - extend Chainable - - VERSION = "0.12.0" - - module Helper - # Parses a `Time` into a [RFC 3339](https://tools.ietf.org/html/rfc3339) datetime format string - # ([ISO 8601](http://xml.coverpages.org/ISO-FDIS-8601.pdf) profile). - # - # > Load Enviroment named "TZ" as high priority - def self.to_rfc3339(time : Time, *, timezone = ENV["TZ"]?, fraction_digits : Int = 0) - Time::Format::RFC_3339.format(time.in(configure_location(timezone)), fraction_digits: fraction_digits) - end - - # Parses a `Time` into a [RFC 3339](https://tools.ietf.org/html/rfc3339) datetime format string to `IO` - # ([ISO 8601](http://xml.coverpages.org/ISO-FDIS-8601.pdf) profile). - # - # > Load Enviroment named "TZ" as high priority - def self.to_rfc3339(time : Time, io : IO, *, timezone = ENV["TZ"]?, fraction_digits : Int = 0) - Time::Format::RFC_3339.format(time.in(configure_location(timezone)), io, fraction_digits) - end - - # :nodoc: - private def self.configure_location(timezone = ENV["TZ"]?) - timezone ? Time::Location.load(timezone.not_nil!) : Time::Location::UTC - end - end - - @@features = {} of String => Feature.class - - module FeatureRegister - def register_feature(name : String, klass : Feature.class) - @@features[name] = klass - end - - def feature(name : String) - @@features[name] - end - - def feature?(name : String) - @@features[name]? - end - - def has_feature?(name) - @@features.keys.includes?(name) - end - end - - extend FeatureRegister -end diff --git a/lib/halite/src/halite/chainable.cr b/lib/halite/src/halite/chainable.cr deleted file mode 100644 index 4f35d8ed..00000000 --- a/lib/halite/src/halite/chainable.cr +++ /dev/null @@ -1,537 +0,0 @@ -require "base64" - -module Halite - module Chainable - {% for verb in %w(get head) %} - # {{ verb.id.capitalize }} a resource - # - # ``` - # Halite.{{ verb.id }}("http://httpbin.org/anything", params: { - # first_name: "foo", - # last_name: "bar" - # }) - # ``` - def {{ verb.id }}(uri : String, *, - headers : (Hash(String, _) | NamedTuple)? = nil, - params : (Hash(String, _) | NamedTuple)? = nil, - raw : String? = nil, - tls : OpenSSL::SSL::Context::Client? = nil) : Halite::Response - request({{ verb }}, uri, headers: headers, params: params, raw: raw, tls: tls) - end - - # {{ verb.id.capitalize }} a streaming resource - # - # ``` - # Halite.{{ verb.id }}("http://httpbin.org/anything") do |response| - # puts response.status_code - # while line = response.body_io.gets - # puts line - # end - # end - # ``` - def {{ verb.id }}(uri : String, *, - headers : (Hash(String, _) | NamedTuple)? = nil, - params : (Hash(String, _) | NamedTuple)? = nil, - raw : String? = nil, - tls : OpenSSL::SSL::Context::Client? = nil, - &block : Halite::Response ->) - request({{ verb }}, uri, headers: headers, params: params, raw: raw, tls: tls, &block) - end - {% end %} - - {% for verb in %w(put post patch delete options) %} - # {{ verb.id.capitalize }} a resource - # - # ### Request with form data - # - # ``` - # Halite.{{ verb.id }}("http://httpbin.org/anything", form: { - # first_name: "foo", - # last_name: "bar" - # }) - # ``` - # - # ### Request with json data - # - # ``` - # Halite.{{ verb.id }}("http://httpbin.org/anything", json: { - # first_name: "foo", - # last_name: "bar" - # }) - # ``` - # - # ### Request with raw string - # - # ``` - # Halite.{{ verb.id }}("http://httpbin.org/anything", raw: "name=Peter+Lee&address=%23123+Happy+Ave&Language=C%2B%2B") - # ``` - def {{ verb.id }}(uri : String, *, - headers : (Hash(String, _) | NamedTuple)? = nil, - params : (Hash(String, _) | NamedTuple)? = nil, - form : (Hash(String, _) | NamedTuple)? = nil, - json : (Hash(String, _) | NamedTuple)? = nil, - raw : String? = nil, - tls : OpenSSL::SSL::Context::Client? = nil) : Halite::Response - request({{ verb }}, uri, headers: headers, params: params, form: form, json: json, raw: raw, tls: tls) - end - - # {{ verb.id.capitalize }} a streaming resource - # - # ``` - # Halite.{{ verb.id }}("http://httpbin.org/anything") do |response| - # puts response.status_code - # while line = response.body_io.gets - # puts line - # end - # end - # ``` - def {{ verb.id }}(uri : String, *, - headers : (Hash(String, _) | NamedTuple)? = nil, - params : (Hash(String, _) | NamedTuple)? = nil, - form : (Hash(String, _) | NamedTuple)? = nil, - json : (Hash(String, _) | NamedTuple)? = nil, - raw : String? = nil, - tls : OpenSSL::SSL::Context::Client? = nil, - &block : Halite::Response ->) - request({{ verb }}, uri, headers: headers, params: params, form: form, json: json, raw: raw, tls: tls, &block) - end - {% end %} - - # Adds a endpoint to the request. - # - # - # ``` - # Halite.endpoint("https://httpbin.org") - # .get("/get") - # ``` - def endpoint(endpoint : String | URI) : Halite::Client - branch(default_options.with_endpoint(endpoint)) - end - - # Make a request with the given Basic authorization header - # - # ``` - # Halite.basic_auth("icyleaf", "p@ssw0rd") - # .get("http://httpbin.org/get") - # ``` - # - # See Also: [http://tools.ietf.org/html/rfc2617](http://tools.ietf.org/html/rfc2617) - def basic_auth(user : String, pass : String) : Halite::Client - auth("Basic " + Base64.strict_encode(user + ":" + pass)) - end - - # Make a request with the given Authorization header - # - # ``` - # Halite.auth("private-token", "6abaef100b77808ceb7fe26a3bcff1d0") - # .get("http://httpbin.org/get") - # ``` - def auth(value : String) : Halite::Client - headers({"Authorization" => value}) - end - - # Accept the given MIME type - # - # ``` - # Halite.accept("application/json") - # .get("http://httpbin.org/get") - # ``` - def accept(value : String) : Halite::Client - headers({"Accept" => value}) - end - - # Set requests user agent - # - # ``` - # Halite.user_agent("Custom User Agent") - # .get("http://httpbin.org/get") - # ``` - def user_agent(value : String) : Halite::Client - headers({"User-Agent" => value}) - end - - # Make a request with the given headers - # - # ``` - # Halite.headers({"Content-Type", "application/json", "Connection": "keep-alive"}) - # .get("http://httpbin.org/get") - # # Or - # Halite.headers({content_type: "application/json", connection: "keep-alive"}) - # .get("http://httpbin.org/get") - # ``` - def headers(headers : Hash(String, _) | NamedTuple) : Halite::Client - branch(default_options.with_headers(headers)) - end - - # Make a request with the given headers - # - # ``` - # Halite.headers(content_type: "application/json", connection: "keep-alive") - # .get("http://httpbin.org/get") - # ``` - def headers(**kargs) : Halite::Client - branch(default_options.with_headers(kargs)) - end - - # Make a request with the given cookies - # - # ``` - # Halite.cookies({"private-token", "6abaef100b77808ceb7fe26a3bcff1d0"}) - # .get("http://httpbin.org/get") - # # Or - # Halite.cookies({private-token: "6abaef100b77808ceb7fe26a3bcff1d0"}) - # .get("http://httpbin.org/get") - # ``` - def cookies(cookies : Hash(String, _) | NamedTuple) : Halite::Client - branch(default_options.with_cookies(cookies)) - end - - # Make a request with the given cookies - # - # ``` - # Halite.cookies(name: "icyleaf", "gender": "male") - # .get("http://httpbin.org/get") - # ``` - def cookies(**kargs) : Halite::Client - branch(default_options.with_cookies(kargs)) - end - - # Make a request with the given cookies - # - # ``` - # cookies = HTTP::Cookies.from_client_headers(headers) - # Halite.cookies(cookies) - # .get("http://httpbin.org/get") - # ``` - def cookies(cookies : HTTP::Cookies) : Halite::Client - branch(default_options.with_cookies(cookies)) - end - - # Adds a timeout to the request. - # - # How long to wait for the server to send data before giving up, as a int, float or time span. - # The timeout value will be applied to both the connect and the read timeouts. - # - # Set `nil` to timeout to ignore timeout. - # - # ``` - # Halite.timeout(5.5).get("http://httpbin.org/get") - # # Or - # Halite.timeout(2.minutes) - # .post("http://httpbin.org/post", form: {file: "file.txt"}) - # ``` - def timeout(timeout : (Int32 | Float64 | Time::Span)?) - timeout ? timeout(timeout, timeout, timeout) : branch - end - - # Adds a timeout to the request. - # - # How long to wait for the server to send data before giving up, as a int, float or time span. - # The timeout value will be applied to both the connect and the read timeouts. - # - # ``` - # Halite.timeout(3, 3.minutes, 5) - # .post("http://httpbin.org/post", form: {file: "file.txt"}) - # # Or - # Halite.timeout(3.04, 64, 10.0) - # .get("http://httpbin.org/get") - # ``` - def timeout(connect : (Int32 | Float64 | Time::Span)? = nil, - read : (Int32 | Float64 | Time::Span)? = nil, - write : (Int32 | Float64 | Time::Span)? = nil) - branch(default_options.with_timeout(connect, read, write)) - end - - # Returns `Options` self with automatically following redirects. - # - # ``` - # # Automatically following redirects. - # Halite.follow - # .get("http://httpbin.org/relative-redirect/5") - # - # # Always redirect with any request methods - # Halite.follow(strict: false) - # .get("http://httpbin.org/get") - # ``` - def follow(strict = Halite::Options::Follow::STRICT) : Halite::Client - branch(default_options.with_follow(strict: strict)) - end - - # Returns `Options` self with given max hops of redirect times. - # - # ``` - # # Max hops 3 times - # Halite.follow(3) - # .get("http://httpbin.org/relative-redirect/3") - # - # # Always redirect with any request methods - # Halite.follow(4, strict: false) - # .get("http://httpbin.org/relative-redirect/4") - # ``` - def follow(hops : Int32, strict = Halite::Options::Follow::STRICT) : Halite::Client - branch(default_options.with_follow(hops, strict)) - end - - # Returns `Options` self with enable or disable logging. - # - # #### Enable logging - # - # Same as call `logging` method without any argument. - # - # ``` - # Halite.logging.get("http://httpbin.org/get") - # ``` - # - # #### Disable logging - # - # ``` - # Halite.logging(false).get("http://httpbin.org/get") - # ``` - def logging(enable : Bool = true) - options = default_options - options.logging = enable - branch(options) - end - - # Returns `Options` self with given the logging which it integration from `Halite::Logging`. - # - # #### Simple logging - # - # ``` - # Halite.logging - # .get("http://httpbin.org/get", params: {name: "foobar"}) - # - # => 2018-08-28 14:33:19 +08:00 | request | POST | http://httpbin.org/post - # => 2018-08-28 14:33:21 +08:00 | response | 200 | http://httpbin.org/post | 1.61s | application/json - # { ... } - # ``` - # - # #### Logger configuration - # - # By default, Halite will logging all outgoing HTTP requests and their responses(without binary stream) to `STDOUT` on DEBUG level. - # You can configuring the following options: - # - # - `skip_request_body`: By default is `false`. - # - `skip_response_body`: By default is `false`. - # - `skip_benchmark`: Display elapsed time, by default is `false`. - # - `colorize`: Enable colorize in terminal, only apply in `common` format, by default is `true`. - # - # ``` - # Halite.logging(skip_request_body: true, skip_response_body: true) - # .post("http://httpbin.org/get", form: {image: File.open("halite-logo.png")}) - # - # # => 2018-08-28 14:33:19 +08:00 | request | POST | http://httpbin.org/post - # # => 2018-08-28 14:33:21 +08:00 | response | 200 | http://httpbin.org/post | 1.61s | application/json - # ``` - # - # #### Use custom logging - # - # Creating the custom logging by integration `Halite::Logging::Abstract` abstract class. - # Here has two methods must be implement: `#request` and `#response`. - # - # ``` - # class CustomLogger < Halite::Logging::Abstract - # def request(request) - # @logger.info "| >> | %s | %s %s" % [request.verb, request.uri, request.body] - # end - # - # def response(response) - # @logger.info "| << | %s | %s %s" % [response.status_code, response.uri, response.content_type] - # end - # end - # - # # Add to adapter list (optional) - # Halite::Logging.register_adapter "custom", CustomLogger.new - # - # Halite.logging(logging: CustomLogger.new) - # .get("http://httpbin.org/get", params: {name: "foobar"}) - # - # # We can also call it use format name if you added it. - # Halite.logging(format: "custom") - # .get("http://httpbin.org/get", params: {name: "foobar"}) - # - # # => 2017-12-13 16:40:13 +08:00 | >> | GET | http://httpbin.org/get?name=foobar - # # => 2017-12-13 16:40:15 +08:00 | << | 200 | http://httpbin.org/get?name=foobar application/json - # ``` - def logging(logging : Halite::Logging::Abstract = Halite::Logging::Common.new) - branch(default_options.with_logging(logging)) - end - - # Returns `Options` self with given the file with the path. - # - # #### JSON-formatted logging - # - # ``` - # Halite.logging(format: "json") - # .get("http://httpbin.org/get", params: {name: "foobar"}) - # ``` - # - # #### create a http request and log to file - # - # ``` - # Log.setup("halite.file", backend: Log::IOBackend.new(File.open("/tmp/halite.log", "a"))) - # Halite.logging(for: "halite.file") - # .get("http://httpbin.org/get", params: {name: "foobar"}) - # ``` - # - # #### Always create new log file and store data to JSON formatted - # - # ``` - # Log.setup("halite.file", backend: Log::IOBackend.new(File.open("/tmp/halite.log", "w")) - # Halite.logging(for: "halite.file", format: "json") - # .get("http://httpbin.org/get", params: {name: "foobar"}) - # ``` - # - # Check the log file content: **/tmp/halite.log** - def logging(format : String = "common", *, for : String = "halite", - skip_request_body = false, skip_response_body = false, - skip_benchmark = false, colorize = true) - opts = { - for: for, - skip_request_body: skip_request_body, - skip_response_body: skip_response_body, - skip_benchmark: skip_benchmark, - colorize: colorize, - } - branch(default_options.with_logging(format, **opts)) - end - - # Turn on given features and its options. - # - # Available features to review all subclasses of `Halite::Feature`. - # - # #### Use JSON logging - # - # ``` - # Halite.use("logging", format: "json") - # .get("http://httpbin.org/get", params: {name: "foobar"}) - # - # # => { ... } - # ``` - # - # #### Use common format logging and skip response body - # ``` - # Halite.use("logging", format: "common", skip_response_body: true) - # .get("http://httpbin.org/get", params: {name: "foobar"}) - # - # # => 2018-08-28 14:58:26 +08:00 | request | GET | http://httpbin.org/get - # # => 2018-08-28 14:58:27 +08:00 | response | 200 | http://httpbin.org/get | 615.8ms | application/json - # ``` - def use(feature : String, **opts) - branch(default_options.with_features(feature, **opts)) - end - - # Turn on given the name of features. - # - # Available features to review all subclasses of `Halite::Feature`. - # - # ``` - # Halite.use("logging", "your-custom-feature-name") - # .get("http://httpbin.org/get", params: {name: "foobar"}) - # ``` - def use(*features) - branch(default_options.with_features(*features)) - end - - # Make an HTTP request with the given verb - # - # ``` - # Halite.request("get", "http://httpbin.org/get", { - # "headers" = { "user_agent" => "halite" }, - # "params" => { "nickname" => "foo" }, - # "form" => { "username" => "bar" }, - # }) - # ``` - def request(verb : String, uri : String, *, - headers : (Hash(String, _) | NamedTuple)? = nil, - params : (Hash(String, _) | NamedTuple)? = nil, - form : (Hash(String, _) | NamedTuple)? = nil, - json : (Hash(String, _) | NamedTuple)? = nil, - raw : String? = nil, - tls : OpenSSL::SSL::Context::Client? = nil) : Halite::Response - request(verb, uri, options_with(headers, params, form, json, raw, tls)) - end - - # Make an HTTP request with the given verb and options - # - # > This method will be executed with oneshot request. - # - # ``` - # Halite.request("get", "http://httpbin.org/stream/3", headers: {"user-agent" => "halite"}) do |response| - # puts response.status_code - # while line = response.body_io.gets - # puts line - # end - # end - # ``` - def request(verb : String, uri : String, *, - headers : (Hash(String, _) | NamedTuple)? = nil, - params : (Hash(String, _) | NamedTuple)? = nil, - form : (Hash(String, _) | NamedTuple)? = nil, - json : (Hash(String, _) | NamedTuple)? = nil, - raw : String? = nil, - tls : OpenSSL::SSL::Context::Client? = nil, - &block : Halite::Response ->) - request(verb, uri, options_with(headers, params, form, json, raw, tls), &block) - end - - # Make an HTTP request with the given verb and options - # - # > This method will be executed with oneshot request. - # - # ``` - # Halite.request("get", "http://httpbin.org/get", Halite::Options.new( - # "headers" = { "user_agent" => "halite" }, - # "params" => { "nickname" => "foo" }, - # "form" => { "username" => "bar" }, - # ) - # ``` - def request(verb : String, uri : String, options : Halite::Options? = nil) : Halite::Response - branch(options).request(verb, uri) - end - - # Make an HTTP request with the given verb and options - # - # > This method will be executed with oneshot request. - # - # ``` - # Halite.request("get", "http://httpbin.org/stream/3") do |response| - # puts response.status_code - # while line = response.body_io.gets - # puts line - # end - # end - # ``` - def request(verb : String, uri : String, options : Halite::Options? = nil, &block : Halite::Response ->) - branch(options).request(verb, uri, &block) - end - - private def branch(options : Halite::Options? = nil) : Halite::Client - options ||= default_options - Halite::Client.new(options) - end - - # Use with new instance of Halite::Client to load unique options - # - # Note: append options in Halite::Client#initialize and revoke at #finalize - DEFAULT_OPTIONS = {} of UInt64 => Halite::Options - - private def default_options - {% if @type.superclass %} - DEFAULT_OPTIONS[object_id] - {% else %} - Halite::Options.new - {% end %} - end - - private def options_with(headers : (Hash(String, _) | NamedTuple)? = nil, - params : (Hash(String, _) | NamedTuple)? = nil, - form : (Hash(String, _) | NamedTuple)? = nil, - json : (Hash(String, _) | NamedTuple)? = nil, - raw : String? = nil, - tls : OpenSSL::SSL::Context::Client? = nil) - options = Halite::Options.new(headers: headers, params: params, form: form, json: json, raw: raw, tls: tls) - default_options.merge!(options) - end - end -end diff --git a/lib/halite/src/halite/client.cr b/lib/halite/src/halite/client.cr deleted file mode 100644 index aa588cd7..00000000 --- a/lib/halite/src/halite/client.cr +++ /dev/null @@ -1,304 +0,0 @@ -require "./request" -require "./response" -require "./redirector" - -require "http/client" -require "json" - -module Halite - # Clients make requests and receive responses - # - # Support all `Chainable` methods. - # - # ### Simple setup - # - # ``` - # client = Halite::Client.new(headers: { - # "private-token" => "bdf39d82661358f80b31b67e6f89fee4" - # }) - # - # client.auth(private_token: "bdf39d82661358f80b31b67e6f89fee4"). - # .get("http://httpbin.org/get", params: { - # name: "icyleaf" - # }) - # ``` - # - # ### Setup with block - # - # ``` - # client = Halite::Client.new do - # basic_auth "name", "foo" - # headers content_type: "application/jsong" - # read_timeout 3.minutes - # logging true - # end - # ``` - class Client - include Chainable - - # Instance a new client - # - # ``` - # Halite::Client.new(headers: {"private-token" => "bdf39d82661358f80b31b67e6f89fee4"}) - # ``` - def self.new(*, - endpoint : (String | URI)? = nil, - headers : (Hash(String, _) | NamedTuple)? = nil, - cookies : (Hash(String, _) | NamedTuple)? = nil, - params : (Hash(String, _) | NamedTuple)? = nil, - form : (Hash(String, _) | NamedTuple)? = nil, - json : (Hash(String, _) | NamedTuple)? = nil, - raw : String? = nil, - timeout = Timeout.new, - follow = Follow.new, - tls : OpenSSL::SSL::Context::Client? = nil) - new(Options.new( - endpoint: endpoint, - headers: headers, - cookies: cookies, - params: params, - form: form, - json: json, - raw: raw, - tls: tls, - timeout: timeout, - follow: follow - )) - end - - property options - - # Instance a new client with block - # - # ``` - # client = Halite::Client.new do - # basic_auth "name", "foo" - # logging true - # end - # ``` - def self.new(&block) - instance = new - yield_instance = with instance yield - if yield_instance - yield_instance.options.merge!(yield_instance.oneshot_options) - yield_instance.oneshot_options.clear! - instance = yield_instance - end - - instance - end - - # Instance a new client - # - # ``` - # options = Halite::Options.new(headers: { - # "private-token" => "bdf39d82661358f80b31b67e6f89fee4", - # }) - # - # client = Halite::Client.new(options) - # ``` - def initialize(@options = Halite::Options.new) - @history = [] of Response - - DEFAULT_OPTIONS[object_id] = Halite::Options.new - end - - def finalize - DEFAULT_OPTIONS.delete(object_id) - end - - # Make an HTTP request - def request(verb : String, uri : String, options : Halite::Options? = nil) : Halite::Response - opts = options ? @options.merge(options.not_nil!) : @options - request = build_request(verb, uri, opts) - response = perform_chain(request, opts) do - perform(request, opts) - end - - return response if opts.follow.hops.zero? - - Redirector.new(request, response, opts).perform do |req| - perform(req, opts) - end - end - - # Make an HTTP request - def request(verb : String, uri : String, options : Halite::Options? = nil, &block : Halite::Response ->) - opts = options ? @options.merge(options.not_nil!) : @options - request = build_request(verb, uri, opts) - perform(request, opts, &block) - end - - # Find interceptor and return `Response` else perform HTTP request. - private def perform_chain(request : Halite::Request, options : Halite::Options, &block : -> Response) - chain = Feature::Chain.new(request, nil, options, &block) - options.features.each do |_, feature| - current_chain = feature.intercept(chain) - if current_chain.result == Feature::Chain::Result::Next - chain = current_chain - elsif current_chain.result == Feature::Chain::Result::Return && (response = current_chain.response) - return handle_response(response, options) - end - end - - # Make sure return if has response with each interceptor - if response = chain.response - return handle_response(response, options) - end - - # Perform original HTTP request if not found any response in interceptors - block.call - end - - # Perform a single (no follow) HTTP request - private def perform(request : Halite::Request, options : Halite::Options) : Halite::Response - raise RequestError.new("SSL context given for HTTP URI = #{request.uri}") if request.scheme == "http" && options.tls - - conn = make_connection(request, options) - conn_response = conn.exec(request.verb, request.full_path, request.headers, request.body) - handle_response(request, conn_response, options) - rescue ex : IO::TimeoutError - raise TimeoutError.new(ex.message) - rescue ex : Socket::Error - raise ConnectionError.new(ex.message) - end - - # Perform a single (no follow) streaming HTTP request and redirect automatically - private def perform(request : Halite::Request, options : Halite::Options, &block : Halite::Response ->) - raise RequestError.new("SSL context given for HTTP URI = #{request.uri}") if request.scheme == "http" && options.tls - - conn = make_connection(request, options) - conn.exec(request.verb, request.full_path, request.headers, request.body) do |conn_response| - response = handle_response(request, conn_response, options) - redirector = Redirector.new(request, response, options) - if redirector.avaiable? - redirector.each_redirect do |req| - perform(req, options, &block) - end - else - block.call(response) - end - - return response - end - end - - # Prepare a HTTP request - private def build_request(verb : String, uri : String, options : Halite::Options) : Halite::Request - uri = make_request_uri(uri, options) - body_data = make_request_body(options) - headers = make_request_headers(options, body_data.content_type) - request = Request.new(verb, uri, headers, body_data.body) - - # reset options during onshot request, see `default_options` method at the bottom of file. - default_options.clear! - - options.features.reduce(request) do |req, (_, feature)| - feature.request(req) - end - end - - # Merges query params if needed - private def make_request_uri(url : String, options : Halite::Options) : URI - uri = resolve_uri(url, options) - if params = options.params - query = HTTP::Params.encode(params) - uri.query = [uri.query, query].compact.join('&') unless query.empty? - end - - uri - end - - # Merges request headers - private def make_request_headers(options : Halite::Options, content_type : String?) : HTTP::Headers - headers = options.headers - if (value = content_type) && !value.empty? && !headers.has_key?("Content-Type") - headers.add("Content-Type", value) - end - - # Cookie shards - options.cookies.add_request_headers(headers) - end - - # Create the request body object to send - private def make_request_body(options : Halite::Options) : Halite::Request::Data - if (form = options.form) && !form.empty? - FormData.create(form) - elsif (hash = options.json) && !hash.empty? - body = JSON.build do |builder| - hash.to_json(builder) - end - - Halite::Request::Data.new(body, "application/json") - elsif (raw = options.raw) && !raw.empty? - Halite::Request::Data.new(raw, "text/plain") - else - Halite::Request::Data.new("") - end - end - - # Create the http connection - private def make_connection(request, options) - conn = HTTP::Client.new(request.domain, options.tls) - conn.connect_timeout = options.timeout.connect.not_nil! if options.timeout.connect - conn.read_timeout = options.timeout.read.not_nil! if options.timeout.read - conn.write_timeout = options.timeout.write.not_nil! if options.timeout.write - conn - end - - # Convert HTTP::Client::Response to response and handles response (see below) - private def handle_response(request, conn_response : HTTP::Client::Response, options) : Halite::Response - response = Response.new(uri: request.uri, conn: conn_response, history: @history) - handle_response(response, options) - end - - # Handles response by reduce the response of feature, add history and update options - private def handle_response(response, options) : Halite::Response - response = options.features.reduce(response) do |res, (_, feature)| - feature.response(res) - end - - # Append history of response if enable follow - @history << response unless options.follow.hops.zero? - store_cookies_from_response(response) - end - - # Store cookies for sessions use from response - private def store_cookies_from_response(response : Halite::Response) : Halite::Response - return response unless response.headers - - @options.with_cookies(HTTP::Cookies.from_server_headers(response.headers)) - response - end - - # Use in instance/session mode, it will replace same method in `Halite::Chainable`. - private def branch(options : Halite::Options? = nil) : Halite::Client - oneshot_options.merge!(options) - self - end - - private def resolve_uri(url : String, options : Halite::Options) : URI - return URI.parse(url) unless endpoint = options.endpoint - return endpoint if url.empty? - - endpoint.path += '/' unless endpoint.path.ends_with?('/') - endpoint.resolve(url) - end - - # :nodoc: - @oneshot_options : Halite::Options? - - # :nodoc: - # - # Store options on each request, then it will reset after finish response. - # - # > It will called in this class method, so it mark be public but not recommend to users. - # - # It make sure never store any gived headers, cookies, query, form, raw and tls - # during each request in instance/session mode. - def oneshot_options - @oneshot_options ||= Halite::Options.new - @oneshot_options.not_nil! - end - end -end diff --git a/lib/halite/src/halite/error.cr b/lib/halite/src/halite/error.cr deleted file mode 100644 index 3e489c69..00000000 --- a/lib/halite/src/halite/error.cr +++ /dev/null @@ -1,87 +0,0 @@ -module Halite - module Exception - # Generic error - class Error < ::Exception; end - - # Generic Connection error - class ConnectionError < Error; end - - # Generic Request error - class RequestError < Error; end - - # Generic Response error - class ResponseError < Error; end - - # Generic Feature error - class FeatureError < Error; end - - # The method given was not understood - class UnsupportedMethodError < RequestError; end - - # The scheme given was not understood - class UnsupportedSchemeError < RequestError; end - - # The head method can not streaming without empty response - class UnsupportedStreamMethodError < RequestError; end - - # Requested to do something when we're in the wrong state - class StateError < RequestError; end - - # Generic Timeout error - class TimeoutError < RequestError; end - - # The feature given was not understood - class UnRegisterFeatureError < FeatureError; end - - # The format given was not understood - class UnRegisterLoggerFormatError < FeatureError; end - - # Notifies that we reached max allowed redirect hops - class TooManyRedirectsError < ResponseError; end - - # Notifies that following redirects got into an endless loop - class EndlessRedirectError < TooManyRedirectsError; end - - # The MIME type(adapter) given was not understood - class UnRegisterMimeTypeError < ResponseError; end - - # Generic API error - class APIError < ResponseError - getter uri - getter status_code - getter status_message : String? = nil - - def initialize(@message : String? = nil, @status_code : Int32? = nil, @uri : URI? = nil) - @status_message = build_status_message - if status_code = @status_code - @message ||= "#{status_code} #{@status_message}" - end - - super(@message) - end - - private def build_status_message : String - String::Builder.build do |io| - if status_code = @status_code - io << "#{HTTP::Status.new(status_code).description.to_s.downcase} error" - else - io << "#{@message || "unknown"} error" - end - - io << " with url: #{@uri}" if uri = @uri - end.to_s - end - end - - # 4XX client error - class ClientError < APIError; end - - # 5XX server error - class ServerError < APIError; end - end - - {% for cls in Exception.constants %} - # :nodoc: - alias {{ cls.id }} = Exception::{{ cls.id }} - {% end %} -end diff --git a/lib/halite/src/halite/ext/file_to_json.cr b/lib/halite/src/halite/ext/file_to_json.cr deleted file mode 100644 index 7e1e54a6..00000000 --- a/lib/halite/src/halite/ext/file_to_json.cr +++ /dev/null @@ -1,6 +0,0 @@ -# :nodoc: -class File - def to_json(json : JSON::Builder) - json.string(to_s) - end -end diff --git a/lib/halite/src/halite/ext/http_headers_encode.cr b/lib/halite/src/halite/ext/http_headers_encode.cr deleted file mode 100644 index fdccd745..00000000 --- a/lib/halite/src/halite/ext/http_headers_encode.cr +++ /dev/null @@ -1,57 +0,0 @@ -module HTTP - # This is **extension** apply in Halite. - struct Headers - # Returns the given key value pairs as HTTP Headers - # - # Every parameter added is directly written to an IO, where keys are properly escaped. - # - # ``` - # HTTP::Headers.encode({ - # content_type: "application/json", - # }) - # # => "HTTP::Headers{"Content-Type" => "application/json"}" - # - # HTTP::Headers.encode({ - # "conTENT-type": "application/json", - # }) - # # => "HTTP::Headers{"Content-Type" => "application/json"}" - # ``` - def self.encode(data : Hash(String, _) | NamedTuple) : HTTP::Headers - ::HTTP::Headers.new.tap do |builder| - data = data.is_a?(NamedTuple) ? data.to_h : data - data.each do |key, value| - key = key.to_s.gsub("_", "-").split("-").map { |v| v.capitalize }.join("-") - # skip invalid value of content length - next if key == "Content-Length" && !(value =~ /^\d+$/) - - builder.add key, value.is_a?(Array(String)) ? value : value.to_s - end - end - end - - # Same as `#encode` - def self.encode(**data) - encode(data) - end - - # Similar as `Hahs#to_h` but return `String` if it has one value of the key. - # - # ``` - # headers = HTTP::Headers{"Accepts" => ["application/json", "text/html"], "Content-Type" => ["text/html"]} - # headers["Accepts"] # => ["application/json", "text/html"] - # headers["Content-Type"] # => "text/html" - # ``` - def to_flat_h - @hash.each_with_object({} of String => String | Array(String)) do |(key, values), obj| - obj[key.name] = case values - when String - values.as(String) - when Array - values.size == 1 ? values[0].as(String) : values.as(Array(String)) - else - raise Halite::Error.new("Not support type `#{values.class} with value: #{values}") - end - end - end - end -end diff --git a/lib/halite/src/halite/ext/http_params_encode.cr b/lib/halite/src/halite/ext/http_params_encode.cr deleted file mode 100644 index 5dcde8cd..00000000 --- a/lib/halite/src/halite/ext/http_params_encode.cr +++ /dev/null @@ -1,79 +0,0 @@ -module HTTP - # This is **extension** apply in Halite. - struct Params - # Returns the given key value pairs as a url-encoded query. - # - # Every parameter added is directly written to an IO, where keys and values are properly escaped. - # - # ``` - # HTTP::Params.encode({ - # "name" => "Lizeth Gusikowski", - # "skill" => ["ruby", "crystal"], - # "company" => { - # "name" => "Keeling Inc", - # }, - # "avatar" => File.open("avatar_big.png"), - # }) - # # => "name=Lizeth+Gusikowski&skill=ruby&skill=crystal&company=%7B%22name%22+%3D%3E+%22Keeling+Inc%22%7D&avatar=avatar_big.png" - # ``` - def self.encode(hash : Hash) : String - ::HTTP::Params.build do |form| - hash.each do |key, value| - key = key.to_s - case value - when Array - value.each do |item| - form.add("#{key}", item.to_s) - end - when File - form.add(key, value.as(File).path) - when Hash - value.each do |hkey, hvalue| - form.add("#{key}[#{hkey}]", hvalue.to_s) - end - else - form.add(key, value.to_s) - end - end - end - end - - # Returns the given key value pairs as a url-encoded query. - # - # Every parameter added is directly written to an IO, where keys and values are properly escaped. - # - # ``` - # HTTP::Params.encode({ - # name: "Lizeth Gusikowski", - # skill: ["ruby", "crystal"], - # company: { - # name: "Keeling Inc", - # }, - # avatar: File.open("avatar_big.png" - # }) - # # => "name=Lizeth+Gusikowski&skill=ruby&skill=crystal&company=%7B%22name%22+%3D%3E+%22Keeling+Inc%22%7D&avatar=avatar_big.png" - # ``` - def self.encode(named_tuple : NamedTuple) : String - encode(named_tuple.to_h) - end - - # Returns the given key value pairs as a url-encoded query. - # - # Every parameter added is directly written to an IO, where keys and values are properly escaped. - # - # ``` - # HTTP::Params.encode( - # name: "Lizeth Gusikowski", - # skill: ["ruby", "crystal"], - # company: { - # name: "Keeling Inc", - # }, - # avatar: File.open("avatar_big.png" - # ) - # # => "name=Lizeth+Gusikowski&skill=ruby&skill=crystal&company=%7B%22name%22+%3D%3E+%22Keeling+Inc%22%7D&avatar=avatar_big.png" - # ``` - def self.encode(**named_tuple) : String - encode(named_tuple) - end - end -end diff --git a/lib/halite/src/halite/feature.cr b/lib/halite/src/halite/feature.cr deleted file mode 100644 index fcfc6297..00000000 --- a/lib/halite/src/halite/feature.cr +++ /dev/null @@ -1,71 +0,0 @@ -module Halite - abstract class Feature - def initialize(**options) - end - - # Cooks with request - def request(request : Halite::Request) : Halite::Request - request - end - - # Cooking with response - def response(response : Halite::Response) : Halite::Response - response - end - - # Intercept and cooking request and response - def intercept(chain : Halite::Feature::Chain) : Halite::Feature::Chain - chain - end - - # Feature chain - # - # Chain has two result: - # - # next: perform and run next interceptor - # return: perform and return - class Chain - enum Result - Next - Return - end - - property request - getter response - getter result - - @performed_response : Halite::Response? - - def initialize(@request : Halite::Request, @response : Halite::Response?, @options : Halite::Options, &block : -> Halite::Response) - @result = Result::Next - @performed_response = nil - @perform_request_block = block - end - - def next(response) - @result = Result::Next - @response = response - - self - end - - def return(response) - @result = Result::Return - @response = response - - self - end - - def performed? - !@performed_response.nil? - end - - def perform - @performed_response ||= @perform_request_block.call - @performed_response.not_nil! - end - end - end -end - -require "./features/*" diff --git a/lib/halite/src/halite/features/cache.cr b/lib/halite/src/halite/features/cache.cr deleted file mode 100644 index 703732b4..00000000 --- a/lib/halite/src/halite/features/cache.cr +++ /dev/null @@ -1,184 +0,0 @@ -require "json" -require "digest" -require "file_utils" - -module Halite - # Cache feature use for caching HTTP response to local storage to speed up in developing stage. - # - # It has the following options: - # - # - `file`: Load cache from file. it conflict with `path` and `expires`. - # - `path`: The path of cache, default is "/tmp/halite/cache/" - # - `expires`: The expires time of cache, default is never expires. - # - `debug`: The debug mode of cache, default is `true` - # - # With debug mode, cached response it always included some headers information: - # - # - `X-Halite-Cached-From`: Cache source (cache or file) - # - `X-Halite-Cached-Key`: Cache key with verb, uri and body (return with cache, not `file` passed) - # - `X-Halite-Cached-At`: Cache created time - # - `X-Halite-Cached-Expires-At`: Cache expired time (return with cache, not `file` passed) - # - # ``` - # Halite.use("cache").get "http://httpbin.org/anything" # request a HTTP - # r = Halite.use("cache").get "http://httpbin.org/anything" # request from local storage - # r.headers # => {..., "X-Halite-Cached-At" => "2018-08-30 10:41:14 UTC", "X-Halite-Cached-By" => "Halite", "X-Halite-Cached-Expires-At" => "2018-08-30 10:41:19 UTC", "X-Halite-Cached-Key" => "2bb155e6c8c47627da3d91834eb4249a"}} - # ``` - class Cache < Feature - DEFAULT_PATH = "/tmp/halite/cache/" - - getter file : String? - getter path : String - getter expires : Time::Span? - getter debug : Bool - - # return a new Cache instance - # - # Accepts argument: - # - # - **debug**: `Bool` - # - **path**: `String` - # - **expires**: `(Int32 | Time::Span)?` - def initialize(**options) - @debug = options.fetch(:debug, true).as(Bool) - if file = options[:file]? - @file = file - @path = DEFAULT_PATH - @expires = nil - else - @file = nil - @path = options.fetch(:path, DEFAULT_PATH).as(String) - @expires = case expires = options[:expires]? - when Time::Span - expires.as(Time::Span) - when Int32 - Time::Span.new(seconds: expires.as(Int32), nanoseconds: 0) - when Nil - nil - else - raise "Only accept Int32 and Time::Span type." - end - end - end - - def intercept(chain) - response = cache(chain) do - chain.perform - end - - chain.return(response) - end - - private def cache(chain, &block : -> Halite::Response) - if response = find_cache(chain.request) - return response - end - - response = yield - write_cache(chain.request, response) - response - end - - private def find_cache(request : Halite::Request) : Halite::Response? - if file = @file - build_response(request, file) - elsif response = build_response(request) - response - end - end - - private def find_file(file) : Halite::Response - raise Error.new("Not find cache file: #{file}") if File.file?(file) - build_response(file) - end - - private def build_response(request : Halite::Request, file : String? = nil) : Halite::Response? - status_code = 200 - headers = HTTP::Headers.new - cache_from = "file" - - unless file - # Cache in path - key = generate_cache_key(request) - path = File.join(@path, key) - - return unless Dir.exists?(path) - - cache_from = "cache" - cache_file = File.join(path, "#{key}.cache") - if File.file?(cache_file) && !cache_expired?(cache_file) - file = cache_file - - if metadata = find_metadata(path) - status_code = metadata["status_code"].as_i - metadata["headers"].as_h.each do |name, value| - headers[name] = value.as_s - end - end - - if @debug - headers["X-Halite-Cached-Key"] = key - headers["X-Halite-Cached-Expires-At"] = @expires ? (cache_created_time(file) + @expires.not_nil!).to_s : "None" - end - end - end - - return unless file - - if @debug - headers["X-Halite-Cached-From"] = cache_from - headers["X-Halite-Cached-At"] = cache_created_time(file).to_s - end - - body = File.read_lines(file).join("\n") - Response.new(request.uri, status_code, body, headers) - end - - private def find_metadata(path) - file = File.join(path, "metadata.json") - if File.exists?(file) - JSON.parse(File.open(file)).as_h - end - end - - private def cache_expired?(file) - return false unless expires = @expires - file_modified_time = cache_created_time(file) - Time.utc >= (file_modified_time + expires) - end - - private def cache_created_time(file) - File.info(file).modification_time - end - - private def generate_cache_key(request) : String - Digest::MD5.hexdigest("#{request.verb}-#{request.uri}-#{request.body}") - end - - private def write_cache(request, response) - key = generate_cache_key(request) - path = File.join(@path, key) - FileUtils.mkdir_p(path) unless Dir.exists?(path) - - write_metadata(path, response) - write_body(path, key, response) - end - - private def write_metadata(path, response) - File.open(File.join(path, "metadata.json"), "w") do |f| - f.puts({ - "status_code" => response.status_code, - "headers" => response.headers.to_flat_h, - }.to_json) - end - end - - private def write_body(path, key, response) - File.open(File.join(path, "#{key}.cache"), "w") do |f| - f.puts response.body - end - end - - Halite.register_feature "cache", self - end -end diff --git a/lib/halite/src/halite/features/logging.cr b/lib/halite/src/halite/features/logging.cr deleted file mode 100644 index d4500868..00000000 --- a/lib/halite/src/halite/features/logging.cr +++ /dev/null @@ -1,120 +0,0 @@ -require "log" -require "colorize" -require "file_utils" - -Log.setup do |c| - backend = Log::IOBackend.new(formatter: Halite::Logging::ShortFormat) - c.bind("halite", :info, backend) -end - -module Halite - # Logging feature - class Logging < Feature - DEFAULT_LOGGER = Logging::Common.new - - getter writer : Logging::Abstract - - # return a new Cache instance - # - # Accepts argument: - # - # - **logging**: `Logging::Abstract` - def initialize(**options) - @writer = (logging = options[:logging]?) ? logging.as(Logging::Abstract) : DEFAULT_LOGGER - end - - def request(request) - @writer.request(request) - request - end - - def response(response) - @writer.response(response) - response - end - - # Logging format Abstract - abstract class Abstract - setter logger : Log - getter skip_request_body : Bool - getter skip_response_body : Bool - getter skip_benchmark : Bool - getter colorize : Bool - - @request_time : Time? - - def initialize(*, for : String = "halite", - @skip_request_body = false, @skip_response_body = false, - @skip_benchmark = false, @colorize = true) - @logger = Log.for(for) - Colorize.enabled = @colorize - end - - abstract def request(request) - abstract def response(response) - - protected def human_time(elapsed : Time::Span) - elapsed = elapsed.to_f - case Math.log10(elapsed) - when 0..Float64::MAX - digits = elapsed - suffix = "s" - when -3..0 - digits = elapsed * 1000 - suffix = "ms" - when -6..-3 - digits = elapsed * 1_000_000 - suffix = "ยตs" - else - digits = elapsed * 1_000_000_000 - suffix = "ns" - end - - "#{digits.round(2).to_s}#{suffix}" - end - end - - @@formats = {} of String => Abstract.class - - # Logging format register - module Register - def register(name : String, format : Abstract.class) - @@formats[name] = format - end - - def [](name : String) - @@formats[name] - end - - def []?(name : String) - @@formats[name]? - end - - def availables - @@formats.keys - end - end - - # Similar to `Log::ShortFormat` - # - # **NOTE**: It invalid by calling `Log.setup` or `Log.setup_from_env` outside of Halite. - # - # Copy from https://github.com/crystal-lang/crystal/blob/3c48f311f/src/log/format.cr#L197 - struct ShortFormat < Log::StaticFormatter - def run - "#{timestamp} - #{source(before: " ", after: ": ")}#{message}" \ - "#{data(before: " -- ")}#{context(before: " -- ")}#{exception}" - end - - def timestamp - Helper.to_rfc3339(@entry.timestamp, @io) - end - end - - extend Register - - Halite.register_feature "logging", self - end -end - -require "./logging/*" diff --git a/lib/halite/src/halite/features/logging/common.cr b/lib/halite/src/halite/features/logging/common.cr deleted file mode 100644 index 892f520f..00000000 --- a/lib/halite/src/halite/features/logging/common.cr +++ /dev/null @@ -1,112 +0,0 @@ -class Halite::Logging - # Common logging format - # - # Instance variables to check `Halite::Logging::Abstract` - # - # ``` - # Halite.use("logging", logging: Halite::Logging::Common.new(skip_request_body: true)) - # .get("http://httpbin.org/get") - # - # # Or - # Halite.logging(format: "common", skip_request_body: true) - # .get("http://httpbin.org/get") - # - # # => 2018-08-31 16:56:12 +08:00 | request | GET | http://httpbin.org/get - # # => 2018-08-31 16:56:13 +08:00 | response | 200 | http://httpbin.org/get | 1.08s | application/json - # ``` - class Common < Abstract - def request(request) - message = String.build do |io| - io << "> | request | " << colorful_method(request.verb) - io << "| " << request.uri - unless request.body.empty? || @skip_request_body - io << "\n" << request.body - end - end - - @logger.info { message } - @request_time = Time.utc unless @skip_benchmark - end - - def response(response) - message = String.build do |io| - content_type = response.content_type || "Unknown MIME" - io << "< | response | " << colorful_status_code(response.status_code) - io << "| " << response.uri - if !@skip_benchmark && (request_time = @request_time) - elapsed = Time.utc - request_time - io << " | " << human_time(elapsed) - end - - io << " | " << content_type - unless response.body.empty? || binary_type?(content_type) || @skip_response_body - io << "\n" << response.body - end - end - - @logger.info { message } - end - - protected def colorful_method(method, is_request = true) - fore, back = case method.upcase - when "GET" - [:white, :blue] - when "POST" - [:white, :cyan] - when "PUT" - [:white, :yellow] - when "DELETE" - [:white, :red] - when "PATCH" - [:white, :green] - when "HEAD" - [:white, :magenta] - else - [:dark_gray, :white] - end - - colorful((" %-7s" % method), fore, back) - end - - protected def colorful_status_code(status_code : Int32) - fore, back = case status_code - when 300..399 - [:dark_gray, :white] - when 400..499 - [:white, :yellow] - when 500..999 - [:white, :red] - else - [:white, :green] - end - - colorful((" %-7s" % status_code), fore, back) - end - - protected def colorful(message, fore, back) - Colorize.enabled = !!(@colorize && (backend = @logger.backend.as?(Log::IOBackend)) && backend.io.tty?) - - message.colorize.fore(fore).back(back) - end - - # return `true` if is binary types with MIME type - # - # MIME types list: https://developer.mozilla.org/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Complete_list_of_MIME_types - private def binary_type?(content_type) - binary_types = %w(image audio video) - application_types = %w(pdf octet-stream ogg 3gpp ebook archive rar zip tar 7z word powerpoint excel flash font) - - binary_types.each do |name| - return true if content_type.starts_with?(name) - end - - application_types.each do |name| - return true if content_type.starts_with?("application") && content_type.includes?(name) - end - - false - end - - Logging.register "common", self - end -end diff --git a/lib/halite/src/halite/features/logging/json.cr b/lib/halite/src/halite/features/logging/json.cr deleted file mode 100644 index da980bc9..00000000 --- a/lib/halite/src/halite/features/logging/json.cr +++ /dev/null @@ -1,106 +0,0 @@ -require "json" - -class Halite::Logging - # JSON logging format - # - # Instance variables to check `Halite::Logging::Abstract`. - # - # In JSON format, if you set skip some key, it will return `false`. - # - # ``` - # Halite.use("logging", logging: Halite::Logging::JSON.new(skip_request_body: true)) - # .get("http://httpbin.org/get") - # - # # Or - # Halite.logging(format: "json", skip_request_body: true) - # .get("http://httpbin.org/get") - # ``` - # - # Log will look like: - # - # ``` - # { - # "created_at": "2018-08-31T16:53:57+08:00:00", - # "entry": { - # "request": { - # "body": "", - # "headers": {...}, - # "method": "GET", - # "url": "http://httpbin.org/anything", - # "timestamp": "2018-08-31T16:53:59+08:00:00", - # }, - # "response": { - # "body": false, - # "header": {...}, - # "status_code": 200, - # "http_version": "HTTP/1.1", - # "timestamp": "2018-08-31T16:53:59+08:00:00", - # }, - # }, - # } - # ``` - class JSON < Abstract - @request : Request? = nil - @response : Response? = nil - - def request(request) - @request_time = Time.utc - @request = Request.new(request, @skip_request_body) - end - - def response(response) - @response = Response.new(response, @skip_response_body) - @logger.info { raw } - end - - private def raw - elapsed : String? = nil - if !@skip_benchmark && (request_time = @request_time) - elapsed = human_time(Time.utc - request_time) - end - - { - "created_at" => Helper.to_rfc3339(@request_time.not_nil!), - "elapsed" => elapsed, - "entry" => { - "request" => @request.not_nil!.to_h, - "response" => @response.not_nil!.to_h, - }, - }.to_pretty_json - end - - # :nodoc: - private struct Request - def initialize(@request : Halite::Request, @skip_body = false) - end - - def to_h - { - "body" => @skip_body ? false : @request.body, - "headers" => @request.headers.to_flat_h, - "method" => @request.verb, - "url" => @request.uri.to_s, - "timestamp" => Helper.to_rfc3339(Time.utc), - } - end - end - - # :nodoc: - private struct Response - def initialize(@response : Halite::Response, @skip_body = false) - end - - def to_h - { - "body" => @skip_body ? false : @response.body, - "header" => @response.headers.to_flat_h, - "status_code" => @response.status_code, - "http_version" => @response.version, - "timestamp" => Helper.to_rfc3339(Time.utc), - } - end - end - - Logging.register "json", self - end -end diff --git a/lib/halite/src/halite/form_data.cr b/lib/halite/src/halite/form_data.cr deleted file mode 100644 index 707d53b3..00000000 --- a/lib/halite/src/halite/form_data.cr +++ /dev/null @@ -1,68 +0,0 @@ -require "http/formdata" -require "mime/multipart" - -module Halite - # Utility-belt to build form data request bodies. - # - # Provides support for `application/x-www-form-urlencoded` and - # `multipart/form-data` types. - # - # ``` - # form = FormData.create({ - # "name" => "Lizeth Gusikowski", - # "skill" => ["ruby", "crystal"], - # "avatar" => File.open("avatar.png"), # => "image binary data" - # }) - # - # form.body # => "----------------------------_ytTht-0D5oif0cAGXSPjPSN\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\nLizeth Gusikowski\r\n----------------------------_ytTht-0D5oif0cAGXSPjPSN\r\nContent-Disposition: form-data; name=\"skill\"\r\n\r\nruby\r\n----------------------------_ytTht-0D5oif0cAGXSPjPSN\r\nContent-Disposition: form-data; name=\"skill\"\r\n\r\ncrystal\r\n----------------------------_ytTht-0D5oif0cAGXSPjPSN\r\nContent-Disposition: form-data; name=\"avatar\"; filename=\"avatar.png\"\r\n\r\nimage binary data\n\r\n----------------------------_ytTht-0D5oif0cAGXSPjPSN--" - # form.headers # => HTTP::Headers{"Content-Type" => "multipart/form-data; boundary=\"--------------------------SS0a9QKeM_6fcj2CE5D4d0LQ\""} - # ``` - module FormData - # FormData factory. Automatically selects best type depending on given `data` Hash - def self.create(data : Hash(String, Halite::Options::Type) = {} of String => Halite::Options::Type) : Halite::Request::Data - if multipart?(data) - io = IO::Memory.new - builder = HTTP::FormData::Builder.new(io) - data.each do |k, v| - case v - when File - builder.file(k, v.as(IO), HTTP::FormData::FileMetadata.new(filename: File.basename(v.path))) - when Array - v.each do |e| - case e - when File - builder.file(k, e.as(IO), HTTP::FormData::FileMetadata.new(filename: File.basename(e.path))) - else - builder.field(k, e.to_s) - end - end - else - builder.field(k, v.to_s) - end - end - builder.finish - - Halite::Request::Data.new(io.to_s, builder.content_type) - else - body = HTTP::Params.encode(data) - Halite::Request::Data.new(body, "application/x-www-form-urlencoded") - end - end - - # Tells whenever data contains multipart data or not. - private def self.multipart?(data : Hash(String, Halite::Options::Type)) : Bool - data.any? do |_, v| - case v - when File - next true - when Array - v.any? do |vv| - next true if vv.is_a?(File) - end - else - false - end - end - end - end -end diff --git a/lib/halite/src/halite/header_link.cr b/lib/halite/src/halite/header_link.cr deleted file mode 100644 index 25b6bbf9..00000000 --- a/lib/halite/src/halite/header_link.cr +++ /dev/null @@ -1,59 +0,0 @@ -module Halite - # HeaderLink - # - # ref: [https://tools.ietf.org/html/rfc5988](https://tools.ietf.org/html/rfc5988) - struct HeaderLink - # Header link parser - def self.parse(raw : String, uri : URI? = nil) : Hash(String, Halite::HeaderLink) - links = {} of String => HeaderLink - raw.split(/,\s* String - if raw.includes?(";") - target, attrs = raw.split(";", 2) - rel = target = target.gsub(/[<> '\"]/, "").strip - unless attrs.strip.empty? - attrs.split(";").each do |attr| - next if attr.strip.empty? - key, value = attr.split("=") - key = key.gsub(/['\"]/, "").strip - next if params.has_key?(key) - - value = value.gsub(/['\"]/, "").strip - params[key] = value - end - - if name = params.delete("rel") - rel = name - if target == "/" - target = rel - elsif target.starts_with?("/") && (uri_local = uri) - full_target = uri_local.dup - full_target.path = target - target = full_target.to_s - end - end - end - else - rel = target = raw.gsub(/[<> '\"]/, "").strip - end - - new(rel, target, params) - end - - getter rel, target, params - - def initialize(@rel : String, @target : String, @params : Hash(String, String)) - end - - def to_s(io) - io << target - end - end -end diff --git a/lib/halite/src/halite/mime_type.cr b/lib/halite/src/halite/mime_type.cr deleted file mode 100644 index ca871e6f..00000000 --- a/lib/halite/src/halite/mime_type.cr +++ /dev/null @@ -1,33 +0,0 @@ -module Halite - module MimeType - @@adapters = {} of String => MimeType::Adapter - @@aliases = {} of String => String - - def self.register(adapter : MimeType::Adapter, name : String, *shortcuts) - @@adapters[name] = adapter - shortcuts.each do |shortcut| - next unless shortcut.is_a?(String) - @@aliases[shortcut] = name - end unless shortcuts.empty? - end - - def self.[](name : String) - @@adapters[normalize name] - end - - def self.[]?(name : String) - @@adapters[normalize name]? - end - - private def self.normalize(name : String) - @@aliases.fetch name, name - end - - abstract class Adapter - abstract def encode(obj) - abstract def decode(string) - end - end -end - -require "./mime_types/*" diff --git a/lib/halite/src/halite/mime_types/json.cr b/lib/halite/src/halite/mime_types/json.cr deleted file mode 100644 index 65191909..00000000 --- a/lib/halite/src/halite/mime_types/json.cr +++ /dev/null @@ -1,15 +0,0 @@ -require "json" - -module Halite::MimeType - class JSON < Adapter - def encode(obj) - obj.to_json - end - - def decode(str) - ::JSON.parse str - end - end -end - -Halite::MimeType.register Halite::MimeType::JSON.new, "application/json", "json" diff --git a/lib/halite/src/halite/options.cr b/lib/halite/src/halite/options.cr deleted file mode 100644 index b70f12df..00000000 --- a/lib/halite/src/halite/options.cr +++ /dev/null @@ -1,495 +0,0 @@ -require "openssl" -require "./options/*" - -module Halite - # Options class - # - # ### Init with splats options - # - # ``` - # o = Options.new( - # headers: { - # user_agent: "foobar" - # } - # } - # o.headers.class # => HTTP::Headers - # o.cookies.class # => HTTP::Cookies - # ``` - # - # ### Set/Get timeout - # - # Set it with `connect_timeout`/`read_timeout`/`write_timeout` keys, - # but get it call `Timeout` class. - # - # ``` - # o = Options.new(connect_timeout: 30, read_timeout: 30) - # o.timeout.connect # => 30.0 - # o.timeout.read # => 30.0 - # o.timeout.write # => nil - # ``` - # - # ### Set/Get follow - # - # Set it with `follow`/`follow_strict` keys, but get it call `Follow` class. - # - # ``` - # o = Options.new(follow: 3, follow_strict: false) - # o.follow.hops # => 3 - # o.follow.strict # => false - # ``` - class Options - def self.new(endpoint : (String | URI)? = nil, - headers : (Hash(String, _) | NamedTuple)? = nil, - cookies : (Hash(String, _) | NamedTuple)? = nil, - params : (Hash(String, _) | NamedTuple)? = nil, - form : (Hash(String, _) | NamedTuple)? = nil, - json : (Hash(String, _) | NamedTuple)? = nil, - raw : String? = nil, - connect_timeout : (Int32 | Float64 | Time::Span)? = nil, - read_timeout : (Int32 | Float64 | Time::Span)? = nil, - write_timeout : (Int32 | Float64 | Time::Span)? = nil, - follow : Int32? = nil, - follow_strict : Bool? = nil, - tls : OpenSSL::SSL::Context::Client? = nil, - features = {} of String => Feature) - new( - endpoint: endpoint, - headers: headers, - cookies: cookies, - params: params, - form: form, - json: json, - raw: raw, - timeout: Timeout.new(connect: connect_timeout, read: read_timeout, write: write_timeout), - follow: Follow.new(hops: follow, strict: follow_strict), - tls: tls, - features: features - ) - end - - # Types of options in a Hash - alias Type = Nil | Symbol | String | Int32 | Int64 | Float64 | Bool | File | Array(Type) | Hash(String, Type) - - property endpoint : URI? - property headers : HTTP::Headers - property cookies : HTTP::Cookies - property timeout : Timeout - property follow : Follow - property tls : OpenSSL::SSL::Context::Client? - - property params : Hash(String, Type) - property form : Hash(String, Type) - property json : Hash(String, Type) - property raw : String? - - property features : Hash(String, Feature) - - def initialize(*, - endpoint : (String | URI)? = nil, - headers : (Hash(String, _) | NamedTuple)? = nil, - cookies : (Hash(String, _) | NamedTuple)? = nil, - params : (Hash(String, _) | NamedTuple)? = nil, - form : (Hash(String, _) | NamedTuple)? = nil, - json : (Hash(String, _) | NamedTuple)? = nil, - @raw : String? = nil, - @timeout = Timeout.new, - @follow = Follow.new, - @tls : OpenSSL::SSL::Context::Client? = nil, - @features = {} of String => Feature) - @endpoint = parse_endpoint(endpoint) - @headers = parse_headers(headers) - @cookies = parse_cookies(cookies) - @params = parse_params(params) - @form = parse_form(form) - @json = parse_json(json) - end - - def initialize(*, - @endpoint : URI?, - @headers : HTTP::Headers, - @cookies : HTTP::Cookies, - @params : Hash(String, Type), - @form : Hash(String, Type), - @json : Hash(String, Type), - @raw : String? = nil, - @timeout = Timeout.new, - @follow = Follow.new, - @tls : OpenSSL::SSL::Context::Client? = nil, - @features = {} of String => Feature) - end - - def with_endpoint(endpoint : String | URI) - self.endpoint = endpoint - self - end - - # Alias `with_headers` method. - def with_headers(**with_headers) : Halite::Options - with_headers(with_headers) - end - - # Returns `Options` self with given headers combined. - def with_headers(headers : Hash(String, _) | NamedTuple) : Halite::Options - @headers.merge!(parse_headers(headers)) - self - end - - # Alias `with_cookies` method. - def with_cookies(**cookies) : Halite::Options - with_cookies(cookies) - end - - # Returns `Options` self with given cookies combined. - def with_cookies(cookies : Hash(String, _) | NamedTuple) : Halite::Options - cookies.each do |key, value| - @cookies[key.to_s] = value.to_s - end - - self - end - - # Returns `Options` self with given cookies combined. - def with_cookies(cookies : HTTP::Cookies) : Halite::Options - cookies.each do |cookie| - with_cookies(cookie) - end - - self - end - - # Returns `Options` self with given cookies combined. - def with_cookies(cookie : HTTP::Cookie) : Halite::Options - cookie_header = HTTP::Headers{"Cookie" => cookie.to_cookie_header} - @headers.merge!(cookie_header) - @cookies.fill_from_client_headers(@headers) - self - end - - # Returns `Options` self with given max hops of redirect times. - # - # ``` - # # Automatically following redirects - # options.with_follow - # # A maximum of 3 subsequent redirects - # options.with_follow(3) - # # Set subsequent redirects - # options.with_follow(3) - # ``` - def with_follow(follow = Follow::MAX_HOPS, strict = Follow::STRICT) : Halite::Options - @follow.hops = follow - @follow.strict = strict - self - end - - # Returns `Options` self with given connect, read timeout. - def with_timeout(connect : (Int32 | Float64 | Time::Span)? = nil, - read : (Int32 | Float64 | Time::Span)? = nil, - write : (Int32 | Float64 | Time::Span)? = nil) : Halite::Options - @timeout.connect = connect.to_f if connect - @timeout.read = read.to_f if read - @timeout.write = write.to_f if write - - self - end - - # Returns `Options` self with the name of features. - def with_features(*features) - features.each do |feature| - with_features(feature, NamedTuple.new) - end - self - end - - # Returns `Options` self with feature name and options. - def with_features(feature_name : String, **opts) - with_features(feature_name, opts) - end - - # Returns `Options` self with feature name and options. - def with_features(name : String, opts : NamedTuple) - raise UnRegisterFeatureError.new("Not available feature: #{name}") unless klass = Halite.feature?(name) - @features[name] = klass.new(**opts) - self - end - - # Returns `Options` self with feature name and feature. - def with_features(name : String, feature : Feature) - @features[name] = feature - self - end - - # Returns `Options` iitself with given format and the options of format. - def with_logging(format : String, **opts) - raise UnRegisterLoggerFormatError.new("Not available logging format: #{format}") unless format_cls = Logging[format]? - with_logging(format_cls.new(**opts)) - end - - # Returns `Options` self with given logging, depend on `with_features`. - def with_logging(logging : Halite::Logging::Abstract) - with_features("logging", logging: logging) - self - end - - # Set endpoint of request - def endpoint=(endpoint : String) - @endpoint = URI.parse(endpoint) - end - - # Set headers of request - def headers=(headers : (Hash(String, _) | NamedTuple)) - @headers = parse_headers(headers) - end - - # Alias `Timeout.connect` - def connect_timeout - @timeout.connect - end - - # Alias `Timeout.connect=` - def connect_timeout=(timeout : Int32 | Float64 | Time::Span) - @timeout.connect = timeout - end - - # Alias `Timeout.read` - def read_timeout - @timeout.read - end - - # Alias `Timeout.read=` - def read_timeout=(timeout : Int32 | Float64 | Time::Span) - @timeout.read = timeout - end - - # Alias `Timeout.write` - def write_timeout - @timeout.write - end - - # Alias `Timeout.write=` - def write_timeout=(timeout : Int32 | Float64 | Time::Span) - @timeout.write = timeout - end - - # Alias `Follow.hops=` - def follow=(hops : Int32) - @follow.hops = hops - end - - # Alias `Follow.strict` - def follow_strict - @follow.strict - end - - # Alias `Follow.strict=` - def follow_strict=(strict : Bool) - @follow.strict = strict - end - - # Get logging status - def logging : Bool - !@features.values.select { |v| v.is_a?(Halite::Logging) }.empty? - end - - # Quick enable logging - # - # By defaults, use `Logging::Common` as logging output. - def logging=(enable : Bool) - if enable - with_features("logging") unless logging - else - @features.delete("logging") - end - end - - # Merge with other `Options` and return new `Halite::Options` - def merge(other : Halite::Options) : Halite::Options - options = Halite::Options.new - options.merge!(dup) - options.merge!(other) - options - end - - # Merge with other `Options` and return self - def merge!(other : Halite::Options) : Halite::Options - @endpoint = other.endpoint if other.endpoint - - @headers.merge!(other.headers) - - other.cookies.each do |cookie| - @cookies << cookie - end if other.cookies != @cookies - - if other.timeout.connect || other.timeout.read || other.timeout.write - @timeout = other.timeout - end - - if other.follow.updated? - @follow = other.follow - end - - @features.merge!(other.features) unless other.features.empty? - @params.merge!(other.params) if other.params - @form.merge!(other.form) if other.form - @json.merge!(other.json) if other.json - @raw = other.raw if other.raw - @tls = other.tls if other.tls - - self - end - - # Reset options - def clear! : Halite::Options - @endpoint = nil - @headers = HTTP::Headers.new - @cookies = HTTP::Cookies.new - @params = {} of String => Type - @form = {} of String => Type - @json = {} of String => Type - @raw = nil - @timeout = Timeout.new - @follow = Follow.new - @features = {} of String => Feature - @tls = nil - - self - end - - # Produces a shallow copy of objโ€”the instance variables of obj are copied, - # but not the objects they reference. dup copies the tainted state of obj. - def dup - Halite::Options.new( - endpoint: @endpoint, - headers: @headers.dup, - cookies: @cookies, - params: @params, - form: @form, - json: @json, - raw: @raw, - timeout: @timeout, - follow: @follow, - features: @features, - tls: @tls - ) - end - - # Returns this collection as a plain Hash. - def to_h - { - "endpoint" => @endpoint, - "headers" => @headers.to_h, - "cookies" => @cookies.to_h, - "params" => @params ? @params.to_h : nil, - "form" => @form ? @form.to_h : nil, - "json" => @json ? @json.to_h : nil, - "raw" => @raw, - "connect_timeout" => @timeout.connect, - "read_timeout" => @timeout.read, - "follow" => @follow.hops, - "follow_strict" => @follow.strict, - } - end - - private def parse_endpoint(endpoint : (String | URI)?) : URI? - case endpoint - when String - URI.parse(endpoint) - when URI - endpoint.as(URI) - else - nil - end - end - - private def parse_headers(raw : (Hash(String, _) | NamedTuple | HTTP::Headers)?) : HTTP::Headers - case raw - when Hash, NamedTuple - HTTP::Headers.encode(raw) - when HTTP::Headers - raw.as(HTTP::Headers) - else - HTTP::Headers.new - end - end - - private def parse_cookies(raw : (Hash(String, _) | NamedTuple | HTTP::Cookies)?) : HTTP::Cookies - cookies = HTTP::Cookies.from_client_headers(@headers) - if objects = raw - objects.each do |key, value| - cookies[key] = case value - when HTTP::Cookie - value - else - value.to_s - end - end - end - cookies - end - - private def parse_cookies(headers : HTTP::Headers) : HTTP::Cookies - cookies = HTTP::Cookies.from_client_headers(headers) - end - - {% for attr in %w(params form json) %} - private def parse_{{ attr.id }}(raw : (Hash(String, _) | NamedTuple)?) : Hash(String, Options::Type) - new_{{ attr.id }} = {} of String => Type - return new_{{ attr.id }} unless {{ attr.id }} = raw - - if {{ attr.id }}.responds_to?(:each) - {{ attr.id }}.each do |key, value| - new_{{ attr.id }}[key.to_s] = case value - when Array - cast_hash(value.as(Array)) - when Hash - cast_hash(value.as(Hash)) - when NamedTuple - cast_hash(value.as(NamedTuple)) - when Type - value - else - value.as(Type) - end - end - end - - new_{{ attr.id }} - end - {% end %} - - private def cast_hash(raw : Array) : Options::Type - raw.each_with_object([] of Type) do |value, obj| - obj << case value - when Array - cast_hash(value.as(Array)) - when Hash - cast_hash(value.as(Hash)) - when NamedTuple - cast_hash(value.as(NamedTuple)) - else - value.as(Type) - end - end.as(Type) - end - - private def cast_hash(raw : Hash) : Options::Type - raw.each_with_object({} of String => Type) do |(key, value), obj| - if key.responds_to?(:to_s) - obj[key.to_s] = case value - when Array - cast_hash(value.as(Array)) - when Hash - cast_hash(value.as(Hash)) - when NamedTuple - cast_hash(value.as(NamedTuple)) - else - value.as(Type) - end - end - end.as(Type) - end - - private def cast_hash(raw : NamedTuple) : Options::Type - cast_hash(raw.to_h) - end - end -end diff --git a/lib/halite/src/halite/options/follow.cr b/lib/halite/src/halite/options/follow.cr deleted file mode 100644 index 9ff8264c..00000000 --- a/lib/halite/src/halite/options/follow.cr +++ /dev/null @@ -1,33 +0,0 @@ -module Halite - class Options - struct Follow - # No follow by default - DEFAULT_HOPS = 0 - - # A maximum of 5 subsequent redirects - MAX_HOPS = 5 - - # Redirector hops policy - STRICT = true - - property hops : Int32 - property strict : Bool - - def initialize(hops : Int32? = nil, strict : Bool? = nil) - @hops = hops || DEFAULT_HOPS - @strict = strict.nil? ? STRICT : strict - end - - def strict? - @strict == true - end - - def updated? - @hops != DEFAULT_HOPS || @strict != STRICT - end - end - end - - # :nodoc: - alias Follow = Options::Follow -end diff --git a/lib/halite/src/halite/options/timeout.cr b/lib/halite/src/halite/options/timeout.cr deleted file mode 100644 index 9c3b043c..00000000 --- a/lib/halite/src/halite/options/timeout.cr +++ /dev/null @@ -1,46 +0,0 @@ -module Halite - class Options - # Timeout struct - struct Timeout - getter connect : Float64? - getter read : Float64? - getter write : Float64? - - def initialize(connect : (Int32 | Float64 | Time::Span)? = nil, - read : (Int32 | Float64 | Time::Span)? = nil, - write : (Int32 | Float64 | Time::Span)? = nil) - @connect = timeout_value(connect) - @read = timeout_value(read) - @write = timeout_value(write) - end - - def connect=(connect : (Int32 | Float64 | Time::Span)?) - @connect = timeout_value(connect) - end - - def read=(read : (Int32 | Float64 | Time::Span)?) - @read = timeout_value(read) - end - - def write=(write : (Int32 | Float64 | Time::Span)?) - @write = timeout_value(write) - end - - private def timeout_value(value : (Int32 | Float64 | Time::Span)? = nil) : Float64? - case value - when Int32 - value.as(Int32).to_f - when Float64 - value.as(Float64) - when Time::Span - value.as(Time::Span).total_seconds.to_f - else - nil - end - end - end - end - - # :nodoc: - alias Timeout = Options::Timeout -end diff --git a/lib/halite/src/halite/rate_limit.cr b/lib/halite/src/halite/rate_limit.cr deleted file mode 100644 index 1f05e374..00000000 --- a/lib/halite/src/halite/rate_limit.cr +++ /dev/null @@ -1,30 +0,0 @@ -module Halite - # Limit Rate - # - # ref: [https://tools.ietf.org/id/draft-polli-ratelimit-headers-00.html](https://tools.ietf.org/id/draft-polli-ratelimit-headers-00.html) - # - # ``` - # > X-RateLimit-Limit: 5000 - # > X-RateLimit-Remaining: 4987 - # > X-RateLimit-Reset: 1350085394 - # ``` - struct RateLimit - RATELIMIT_LIMIT = "X-RateLimit-Limit" - RATELIMIT_REMAINING = "X-RateLimit-Remaining" - RATELIMIT_RESET = "X-RateLimit-Reset" - - def self.parse(headers : HTTP::Headers) - limit = headers[RATELIMIT_LIMIT]?.try &.to_i - remaining = headers[RATELIMIT_REMAINING]?.try &.to_i - reset = headers[RATELIMIT_RESET]?.try &.to_i - return if !limit && !remaining && !reset - - new(limit, remaining, reset) - end - - getter limit, remaining, reset - - def initialize(@limit : Int32?, @remaining : Int32?, @reset : Int32?) - end - end -end diff --git a/lib/halite/src/halite/redirector.cr b/lib/halite/src/halite/redirector.cr deleted file mode 100644 index d639fe38..00000000 --- a/lib/halite/src/halite/redirector.cr +++ /dev/null @@ -1,84 +0,0 @@ -module Halite - class Redirector - # HTTP status codes which indicate redirects - REDIRECT_CODES = [300, 301, 302, 303, 307, 308] - - # Codes which which should raise StateError in strict mode if original - # request was any of {UNSAFE_VERBS} - STRICT_SENSITIVE_CODES = [300, 301, 302] - - # Insecure http verbs, which should trigger StateError in strict mode - # upon {STRICT_SENSITIVE_CODES} - UNSAFE_VERBS = %w(PUT DELETE POST) - - # Verbs which will remain unchanged upon See Other response. - SEE_OTHER_ALLOWED_VERBS = %w(GET HEAD) - - def self.new(request : Halite::Request, response : Halite::Response, options : Halite::Options) - new(request, response, options.follow.hops, options.follow.strict) - end - - getter strict : Bool - getter max_hops : Int32 - - # Instance a new Redirector - def initialize(@request : Halite::Request, @response : Halite::Response, @max_hops = 5, @strict = true) - @visited = [] of String - end - - # Follows redirects until non-redirect response found - def perform(&block : Halite::Request -> Halite::Response) : Halite::Response - if avaiable? - each_redirect do |request| - block.call(request) - end - end - - @response - end - - # Loop each redirect request with block call - def each_redirect(&block : Halite::Request -> Halite::Response) - while avaiable? - @visited << "#{@request.verb} #{@request.uri}" - - raise TooManyRedirectsError.new if too_many_hops? - raise EndlessRedirectError.new if endless_loop? - - @request = redirect_to(@response.headers["Location"]?) - @response = block.call(@request) - end - end - - # Return `true` if it should redirect, else `false` - def avaiable? - REDIRECT_CODES.includes?(@response.status_code) - end - - # Redirect policy for follow - private def redirect_to(uri : String?) - raise StateError.new("No found `Location` in headers") unless uri - - verb = @request.verb - code = @response.status_code - - if UNSAFE_VERBS.includes?(verb) && STRICT_SENSITIVE_CODES.includes?(code) - raise StateError.new("Can not follow #{code} redirect") if @strict - verb = "GET" - end - - verb = "GET" if !SEE_OTHER_ALLOWED_VERBS.includes?(verb) && code == 303 - @request.redirect(uri, verb) - end - - # Check if we reached max amount of redirect hops - private def too_many_hops? : Bool - 1 <= @max_hops && @max_hops < @visited.size - end - - # Check if we got into an endless loop - def endless_loop? - 2 <= @visited.count(@visited.last) - end - end -end diff --git a/lib/halite/src/halite/request.cr b/lib/halite/src/halite/request.cr deleted file mode 100644 index 637b5362..00000000 --- a/lib/halite/src/halite/request.cr +++ /dev/null @@ -1,92 +0,0 @@ -module Halite - class Request - # Allowed methods - # - # See more: [https://github.com/crystal-lang/crystal/blob/863f301cfe9e9757a6bf1a494ab7bf49bfc07a06/src/http/client.cr#L329](https://github.com/crystal-lang/crystal/blob/863f301cfe9e9757a6bf1a494ab7bf49bfc07a06/src/http/client.cr#L329) - METHODS = %w(GET PUT POST DELETE HEAD PATCH OPTIONS) - - # Allowed schemes - SCHEMES = %w(http https) - - # Request user-agent by default - USER_AGENT = "Halite/#{Halite::VERSION}" - - # The verb name of request - getter verb : String - - # The uri of request - getter uri : URI - - # The scheme name of request - getter scheme : String - - # The headers of request - getter headers : HTTP::Headers - - # The payload of request - getter body : String - - def initialize(verb : String, @uri : URI, @headers : HTTP::Headers = HTTP::Headers.new, @body : String = "") - @verb = verb.upcase - - raise UnsupportedMethodError.new("Unknown method: #{@verb}") unless METHODS.includes?(@verb) - raise UnsupportedSchemeError.new("Missing scheme: #{@uri}") unless @uri.scheme - - @scheme = @uri.scheme.not_nil! - - raise UnsupportedSchemeError.new("Unknown scheme: #{@scheme}") unless SCHEMES.includes?(@scheme) - - @headers["User-Agent"] ||= USER_AGENT - @headers["Connection"] ||= "close" - end - - # Returns new Request with updated uri - def redirect(uri : String, verb = @verb) - headers = @headers.dup - headers.delete("Host") - - Request.new(verb, redirect_uri(domain, uri), headers, body) - end - - # @return `URI` with the scheme, user, password, port and host combined - def domain - URI.new(@uri.scheme, @uri.host, @uri.port, "", nil, @uri.user, @uri.password, nil) - end - - # @return `String` with the path, query and fragment combined - def full_path - String.build do |str| - {% if Crystal::VERSION < "0.36.0" %} - str << @uri.full_path - {% else %} - str << @uri.request_target - {% end %} - if @uri.fragment - str << "#" << @uri.fragment - end - end - end - - private def redirect_uri(source : URI, uri : String) : URI - return source if uri == '/' - - new_uri = URI.parse(uri) - # return a new uri with source and relative path - unless new_uri.scheme && new_uri.host - new_uri = source.dup.tap do |u| - u.path = (uri[0] == '/') ? uri : "/#{uri}" - end - end - - new_uri - end - - # Request data of body - struct Data - getter body, content_type - - def initialize(@body : String, @content_type : String? = nil) - end - end - end -end diff --git a/lib/halite/src/halite/response.cr b/lib/halite/src/halite/response.cr deleted file mode 100644 index 4048d014..00000000 --- a/lib/halite/src/halite/response.cr +++ /dev/null @@ -1,123 +0,0 @@ -module Halite - class Response - def self.new(uri : URI, status_code : Int32, body : String? = nil, headers = HTTP::Headers.new, - status_message = nil, body_io : IO? = nil, version = "HTTP/1.1", history = [] of Halite::Response) - conn = HTTP::Client::Response.new(status_code, body, headers, status_message, version, body_io) - new(uri, conn, history) - end - - getter uri - getter conn - getter history : Array(Response) - - def initialize(@uri : URI, @conn : HTTP::Client::Response, @history = [] of Halite::Response) - end - - delegate version, to: @conn - delegate status_code, to: @conn - delegate status_message, to: @conn - delegate content_type, to: @conn - delegate success?, to: @conn - - delegate headers, to: @conn - delegate charset, to: @conn - - delegate body, to: @conn - delegate body_io, to: @conn - - # Content Length - def content_length : Int64? - if value = @conn.headers["Content-Length"]? - value.to_i64 - end - end - - # Return a `HTTP::Cookies` of parsed cookie headers or else nil. - def cookies : HTTP::Cookies? - cookies = @conn.cookies ? @conn.cookies : HTTP::Cookies.from_server_headers(@conn.headers) - - # Try to fix empty domain - cookies.map do |cookie| - cookie.domain = @uri.host unless cookie.domain - cookie - end - - cookies - end - - # Return a list of parsed link headers proxies or else nil. - def links : Hash(String, Halite::HeaderLink)? - return unless raw = headers["Link"]? - - HeaderLink.parse(raw, uri) - end - - def rate_limit : Halite::RateLimit? - RateLimit.parse(headers) - end - - # Raise `Halite::ClientError`/`Halite::ServerError` if one occurred. - # - # - `4XX` raise an `Halite::ClientError` exception - # - `5XX` raise an `Halite::ServerError` exception - # - return `nil` with other status code - # - # ``` - # Halite.get("https://httpbin.org/status/404").raise_for_status - # # => Unhandled exception: 404 not found error with url: https://httpbin.org/status/404 (Halite::ClientError) - # - # Halite.get("https://httpbin.org/status/500", params: {"foo" => "bar"}).raise_for_status - # # => Unhandled exception: 500 internal server error error with url: https://httpbin.org/status/500?foo=bar (Halite::ServerError) - # - # Halite.get("https://httpbin.org/status/301").raise_for_status - # # => nil - # ``` - def raise_for_status - if status_code >= 400 && status_code < 500 - raise Halite::ClientError.new(status_code: status_code, uri: uri) - elsif status_code >= 500 && status_code < 600 - raise Halite::ServerError.new(status_code: status_code, uri: uri) - end - end - - # Parse response body with corresponding MIME type adapter. - def parse(name : String? = nil) - name ||= content_type - raise Halite::Error.new("Missing media type") unless name - raise Halite::UnRegisterMimeTypeError.new("unregister MIME type adapter: #{name}") unless MimeType[name]? - MimeType[name].decode to_s - end - - # Return filename if it exists, else `Nil`. - def filename : String? - headers["Content-Disposition"]?.try do |value| - value.split("filename=")[1] - end - end - - # Return raw of response - def to_raw - io = IO::Memory.new - @conn.to_io(io) - io - end - - # Return status_code, headers and body in a array - def to_a - [@conn.status_code, @conn.headers, to_s] - end - - # Return String eagerly consume the entire body as a string - def to_s - @conn.body? ? @conn.body : @conn.body_io.to_s - end - - def inspect - "#<#{self.class} #{version} #{status_code} #{status_message} #{headers.to_flat_h}>" - end - - def to_s(io) - io << to_s - end - end -end diff --git a/lib/json_mapping/.editorconfig b/lib/json_mapping/.editorconfig deleted file mode 100644 index 163eb75c..00000000 --- a/lib/json_mapping/.editorconfig +++ /dev/null @@ -1,9 +0,0 @@ -root = true - -[*.cr] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -indent_style = space -indent_size = 2 -trim_trailing_whitespace = true diff --git a/lib/json_mapping/.gitignore b/lib/json_mapping/.gitignore deleted file mode 100644 index 0bbd4a9f..00000000 --- a/lib/json_mapping/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -/docs/ -/lib/ -/bin/ -/.shards/ -*.dwarf - -# Libraries don't need dependency lock -# Dependencies will be locked in applications that use them -/shard.lock diff --git a/lib/json_mapping/.travis.yml b/lib/json_mapping/.travis.yml deleted file mode 100644 index 227f158d..00000000 --- a/lib/json_mapping/.travis.yml +++ /dev/null @@ -1,5 +0,0 @@ -language: crystal - -script: - - crystal spec - - crystal tool format --check diff --git a/lib/json_mapping/LICENSE b/lib/json_mapping/LICENSE deleted file mode 100644 index edcccc57..00000000 --- a/lib/json_mapping/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2020 Manas Technology Solutions - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/lib/json_mapping/README.md b/lib/json_mapping/README.md deleted file mode 100644 index abef1406..00000000 --- a/lib/json_mapping/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# json_mapping - -Provides the legacy `JSON.mapping` macro method. - -This shard is provided as-is and considered deprecated. It won't receive feature enhancements. - -Please consider using [`JSON::Serializable`](https://crystal-lang.org/api/latest/JSON/Serializable.html) instead, the successor included in Crystal's standard library. - -## Installation - -1. Add the dependency to your `shard.yml`: - -```yaml -dependencies: - json_mapping: - github: crystal-lang/json_mapping.cr -``` - -2. Run `shards install` - -## Usage - -```crystal -require "json_mapping" - -class Location - JSON.mapping( - lat: Float64, - lng: Float64, - ) -end - -class House - JSON.mapping( - address: String, - location: {type: Location, nilable: true}, - ) -end - -house = House.from_json(%({"address": "Crystal Road 1234", "location": {"lat": 12.3, "lng": 34.5}})) -house.address # => "Crystal Road 1234" -house.location # => # -house.to_json # => %({"address":"Crystal Road 1234","location":{"lat":12.3,"lng":34.5}}) - -houses = Array(House).from_json(%([{"address": "Crystal Road 1234", "location": {"lat": 12.3, "lng": 34.5}}])) -houses.size # => 1 -houses.to_json # => %([{"address":"Crystal Road 1234","location":{"lat":12.3,"lng":34.5}}]) -``` - -## Contributing - -1. Fork it () -2. Create your feature branch (`git checkout -b my-new-feature`) -3. Commit your changes (`git commit -am 'Add some feature'`) -4. Push to the branch (`git push origin my-new-feature`) -5. Create a new Pull Request diff --git a/lib/json_mapping/lib b/lib/json_mapping/lib deleted file mode 120000 index a96aa0ea..00000000 --- a/lib/json_mapping/lib +++ /dev/null @@ -1 +0,0 @@ -.. \ No newline at end of file diff --git a/lib/json_mapping/shard.yml b/lib/json_mapping/shard.yml deleted file mode 100644 index c42be08f..00000000 --- a/lib/json_mapping/shard.yml +++ /dev/null @@ -1,9 +0,0 @@ -name: json_mapping -version: 0.1.1 - -authors: - - Brian J. Cardiff - -crystal: ">= 0.34.0, < 2.0.0" - -license: MIT diff --git a/lib/json_mapping/src/json_mapping.cr b/lib/json_mapping/src/json_mapping.cr deleted file mode 100644 index b7fee061..00000000 --- a/lib/json_mapping/src/json_mapping.cr +++ /dev/null @@ -1,259 +0,0 @@ -require "json" - -module JSON - module Mapping - VERSION = "0.1.1" - end - - # The `JSON.mapping` macro defines how an object is mapped to JSON. - # - # ### Example - # - # ``` - # require "json_mapping" - # - # class Location - # JSON.mapping( - # lat: Float64, - # lng: Float64, - # ) - # end - # - # class House - # JSON.mapping( - # address: String, - # location: {type: Location, nilable: true}, - # ) - # end - # - # house = House.from_json(%({"address": "Crystal Road 1234", "location": {"lat": 12.3, "lng": 34.5}})) - # house.address # => "Crystal Road 1234" - # house.location # => # - # house.to_json # => %({"address":"Crystal Road 1234","location":{"lat":12.3,"lng":34.5}}) - # - # houses = Array(House).from_json(%([{"address": "Crystal Road 1234", "location": {"lat": 12.3, "lng": 34.5}}])) - # houses.size # => 1 - # houses.to_json # => %([{"address":"Crystal Road 1234","location":{"lat":12.3,"lng":34.5}}]) - # ``` - # - # ### Usage - # - # `JSON.mapping` must receive a series of named arguments, or a named tuple literal, or a hash literal, - # whose keys will define Crystal properties. - # - # The value of each key can be a type. Primitive types (numbers, string, boolean and nil) - # are supported, as well as custom objects which use `JSON.mapping` or define a `new` method - # that accepts a `JSON::PullParser` and returns an object from it. Union types are supported, - # if multiple types in the union can be mapped from the JSON, it is undefined which one will be chosen. - # - # The value can also be another hash literal with the following options: - # * **type**: (required) the type described above (you can use `JSON::Any` too) - # * **key**: the property name in the JSON document (as opposed to the property name in the Crystal code) - # * **nilable**: if `true`, the property can be `Nil`. Passing `T?` as a type has the same effect. - # * **default**: value to use if the property is missing in the JSON document, or if it's `null` and `nilable` was not set to `true`. If the default value creates a new instance of an object (for example `[1, 2, 3]` or `SomeObject.new`), a different instance will be used each time a JSON document is parsed. - # * **emit_null**: if `true`, emits a `null` value for nilable properties (by default nulls are not emitted) - # * **converter**: specify an alternate type for parsing and generation. The converter must define `from_json(JSON::PullParser)` and `to_json(value, JSON::Builder)` as class methods. Examples of converters are `Time::Format` and `Time::EpochConverter` for `Time`. - # * **root**: assume the value is inside a JSON object with a given key (see `Object.from_json(string_or_io, root)`) - # * **setter**: if `true`, will generate a setter for the variable, `true` by default - # * **getter**: if `true`, will generate a getter for the variable, `true` by default - # * **presence**: if `true`, a `{{key}}_present?` method will be generated when the key was present (even if it has a `null` value), `false` by default - # - # This macro by default defines getters and setters for each variable (this can be overrided with *setter* and *getter*). - # The mapping doesn't define a constructor accepting these variables as arguments, but you can provide an overload. - # - # The macro basically defines a constructor accepting a `JSON::PullParser` that reads from - # it and initializes this type's instance variables. It also defines a `to_json(JSON::Builder)` method - # by invoking `to_json(JSON::Builder)` on each of the properties (unless a converter is specified, in - # which case `to_json(value, JSON::Builder)` is invoked). - # - # This macro also declares instance variables of the types given in the mapping. - # - # If *strict* is `true`, unknown properties in the JSON - # document will raise a parse exception. The default is `false`, so unknown properties - # are silently ignored. - macro mapping(_properties_, strict = false) - {% for key, value in _properties_ %} - {% _properties_[key] = {type: value} unless value.is_a?(HashLiteral) || value.is_a?(NamedTupleLiteral) %} - {% end %} - - {% for key, value in _properties_ %} - {% _properties_[key][:key_id] = key.id.gsub(/\?$/, "") %} - {% end %} - - {% for key, value in _properties_ %} - @{{value[:key_id]}} : {{value[:type]}}{{ (value[:nilable] ? "?" : "").id }} - - {% if value[:setter] == nil ? true : value[:setter] %} - def {{value[:key_id]}}=(_{{value[:key_id]}} : {{value[:type]}}{{ (value[:nilable] ? "?" : "").id }}) - @{{value[:key_id]}} = _{{value[:key_id]}} - end - {% end %} - - {% if value[:getter] == nil ? true : value[:getter] %} - def {{key.id}} : {{value[:type]}}{{ (value[:nilable] ? "?" : "").id }} - @{{value[:key_id]}} - end - {% end %} - - {% if value[:presence] %} - @{{value[:key_id]}}_present : Bool = false - - def {{value[:key_id]}}_present? - @{{value[:key_id]}}_present - end - {% end %} - {% end %} - - def initialize(%pull : ::JSON::PullParser) - {% for key, value in _properties_ %} - %var{key.id} = nil - %found{key.id} = false - {% end %} - - %location = %pull.location - begin - %pull.read_begin_object - rescue exc : ::JSON::ParseException - raise ::JSON::MappingError.new(exc.message, self.class.to_s, nil, *%location, exc) - end - until %pull.kind.end_object? - %key_location = %pull.location - key = %pull.read_object_key - case key - {% for key, value in _properties_ %} - when {{value[:key] || value[:key_id].stringify}} - %found{key.id} = true - begin - %var{key.id} = - {% if value[:nilable] || value[:default] != nil %} %pull.read_null_or { {% end %} - - {% if value[:root] %} - %pull.on_key!({{value[:root]}}) do - {% end %} - - {% if value[:converter] %} - {{value[:converter]}}.from_json(%pull) - {% elsif value[:type].is_a?(Path) || value[:type].is_a?(Generic) %} - {{value[:type]}}.new(%pull) - {% else %} - ::Union({{value[:type]}}).new(%pull) - {% end %} - - {% if value[:root] %} - end - {% end %} - - {% if value[:nilable] || value[:default] != nil %} } {% end %} - rescue exc : ::JSON::ParseException - raise ::JSON::MappingError.new(exc.message, self.class.to_s, {{value[:key] || value[:key_id].stringify}}, *%key_location, exc) - end - {% end %} - else - {% if strict %} - raise ::JSON::MappingError.new("Unknown JSON attribute: #{key}", self.class.to_s, nil, *%key_location, nil) - {% else %} - %pull.skip - {% end %} - end - end - %pull.read_next - - {% for key, value in _properties_ %} - {% unless value[:nilable] || value[:default] != nil %} - if %var{key.id}.nil? && !%found{key.id} && !::Union({{value[:type]}}).nilable? - raise ::JSON::MappingError.new("Missing JSON attribute: {{(value[:key] || value[:key_id]).id}}", self.class.to_s, nil, *%location, nil) - end - {% end %} - - {% if value[:nilable] %} - {% if value[:default] != nil %} - @{{value[:key_id]}} = %found{key.id} ? %var{key.id} : {{value[:default]}} - {% else %} - @{{value[:key_id]}} = %var{key.id} - {% end %} - {% elsif value[:default] != nil %} - @{{value[:key_id]}} = %var{key.id}.nil? ? {{value[:default]}} : %var{key.id} - {% else %} - @{{value[:key_id]}} = (%var{key.id}).as({{value[:type]}}) - {% end %} - - {% if value[:presence] %} - @{{value[:key_id]}}_present = %found{key.id} - {% end %} - {% end %} - end - - def to_json(json : ::JSON::Builder) - json.object do - {% for key, value in _properties_ %} - _{{value[:key_id]}} = @{{value[:key_id]}} - - {% unless value[:emit_null] %} - unless _{{value[:key_id]}}.nil? - {% end %} - - json.field({{value[:key] || value[:key_id].stringify}}) do - {% if value[:root] %} - {% if value[:emit_null] %} - if _{{value[:key_id]}}.nil? - nil.to_json(json) - else - {% end %} - - json.object do - json.field({{value[:root]}}) do - {% end %} - - {% if value[:converter] %} - if _{{value[:key_id]}} - {{ value[:converter] }}.to_json(_{{value[:key_id]}}, json) - else - nil.to_json(json) - end - {% else %} - _{{value[:key_id]}}.to_json(json) - {% end %} - - {% if value[:root] %} - {% if value[:emit_null] %} - end - {% end %} - end - end - {% end %} - end - - {% unless value[:emit_null] %} - end - {% end %} - {% end %} - end - end - end - - # This is a convenience method to allow invoking `JSON.mapping` - # with named arguments instead of with a hash/named-tuple literal. - macro mapping(**_properties_) - ::JSON.mapping({{_properties_}}) - end - - class MappingError < ParseException - getter klass : String - getter attribute : String? - - def initialize(message : String?, @klass : String, @attribute : String?, line_number : Int32, column_number : Int32, cause) - message = String.build do |io| - io << message - io << "\n parsing " - io << klass - if attribute = @attribute - io << '#' << attribute - end - end - super(message, line_number, column_number, cause) - if cause - @line_number, @column_number = cause.location - end - end - end -end diff --git a/lib/webmock/.gitignore b/lib/webmock/.gitignore deleted file mode 100644 index 0d6aead9..00000000 --- a/lib/webmock/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.deps/ -.crystal/ -.deps.lock diff --git a/lib/webmock/.travis.yml b/lib/webmock/.travis.yml deleted file mode 100644 index 31f20e13..00000000 --- a/lib/webmock/.travis.yml +++ /dev/null @@ -1,7 +0,0 @@ -language: crystal -crystal: - - latest - - nightly -script: - - crystal spec - - crystal tool format --check diff --git a/lib/webmock/LICENSE b/lib/webmock/LICENSE deleted file mode 100644 index 434acd74..00000000 --- a/lib/webmock/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2015 Manas Technology Solutions - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/lib/webmock/README.md b/lib/webmock/README.md deleted file mode 100644 index 287c1ce2..00000000 --- a/lib/webmock/README.md +++ /dev/null @@ -1,135 +0,0 @@ -# webmock.cr - -[![Build Status](https://travis-ci.org/manastech/webmock.cr.svg?branch=master)](https://travis-ci.org/manastech/webmock.cr) - -Library for stubbing `HTTP::Client` requests in [Crystal](http://crystal-lang.org/). - -Inspired by [webmock ruby gem](https://github.com/bblimke/webmock). - -## Installation - -Add it to `shards.yml`: - -```yaml -development_dependencies: - webmock: - github: manastech/webmock.cr - branch: master -``` - -## Usage - -```crystal -require "webmock" -``` - -By requiring `webmock` unregistered `HTTP::Client` requests will raise an exception. -If you still want to execute real requests, do this: - -```crystal -WebMock.allow_net_connect = true -``` - -### Stub request based on uri only and with the default response - -```crystal -WebMock.stub(:any, "www.example.com") - -response = HTTP::Client.get("http://www.example.com") -response.body #=> "" -response.status_code #=> 200 -``` - -### Stub requests based on method, uri, body, headers and custom response - -```crystal -WebMock.stub(:post, "www.example.com/foo"). - with(body: "abc", headers: {"Content-Type" => "text/plain"}). - to_return(status: 500, body: "oops", headers: {"X-Error" => "true"}) - -response = HTTP::Client.post("http://www.example.com/foo", - body: "abc", - headers: HTTP::Headers{"Content-Type" => "text/plain"}) -response.status_code #=> 500 -response.body #=> "oops" -response.headers["X-Error"] #=> "true" - -# Executing the same request gives the same response -response = HTTP::Client.post("http://www.example.com/foo", - body: "abc", - headers: HTTP::Headers{"Content-Type" => "text/plain"}) -response.body #=> "oops" -``` - -### Stub requests based on query string - -```crystal -WebMock.stub(:get, "www.example.com"). - with(query: {"page" => "1", "count" => "10"}) - -response = HTTP::Client.get("http://www.example.com?count=10&page=1") -response.status_code #=> 200 -``` - -### Stub requests and provide a block for the response - -Your block will be called and passed the `HTTP::Request`, allowing you to construct a response dynamically based upon the request. - -```crystal -WebMock.stub(:post, "www.example.com/foo").to_return do |request| - headers = HTTP::Headers.new.merge!({ "Content-length" => request.body.to_s.length }) - HTTP::Client::Response.new(418, body: request.body.to_s.reverse, headers: headers) -end - -response = HTTP::Client.post("http://www.example.com/foo", - body: "abc", - headers: HTTP::Headers{"Content-Type" => "text/plain"}) -response.status_code #=> 418 -response.body #=> "cba" -response.headers["Content-length"] #=> "3" - -response = HTTP::Client.post("http://www.example.com/foo", - body: "olleh", - headers: HTTP::Headers{"Content-Type" => "text/plain"}) -response.status_code #=> 418 -response.body #=> "hello" -response.headers["Content-length"] #=> "5" -``` - -### Resetting - -```crystal -WebMock.reset -``` - -This clears all stubs and sets `allow_net_connect` to `false`. - -To execute this automatically before each spec, you can do: - -```crystal -Spec.before_each &->WebMock.reset -``` - -Or, for individual specs you can use `WebMock.wrap` and a block to make sure `WebMock` is reset at the end of a spec: - -```crystal -WebMock.wrap do - WebMock.stub(:get, "www.example.com").to_return(body: "Example") - - HTTP::Client.get("http://www.example.com").body #=> "Example" -end - -HTTP::Client.get("http://www.example.com") # Raises WebMock::NetConnectNotAllowedError -``` - -## Todo - -Bring more features found in the [webmock ruby gem](https://github.com/bblimke/webmock). - -## Contributing - -1. Fork it ( https://github.com/manastech/webmock.cr/fork ) -2. Create your feature branch (git checkout -b my-new-feature) -3. Commit your changes (git commit -am 'Add some feature') -4. Push to the branch (git push origin my-new-feature) -5. Create a new Pull Request diff --git a/lib/webmock/lib b/lib/webmock/lib deleted file mode 120000 index a96aa0ea..00000000 --- a/lib/webmock/lib +++ /dev/null @@ -1 +0,0 @@ -.. \ No newline at end of file diff --git a/lib/webmock/shard.yml b/lib/webmock/shard.yml deleted file mode 100644 index 462b940b..00000000 --- a/lib/webmock/shard.yml +++ /dev/null @@ -1,8 +0,0 @@ -name: webmock -version: 0.14.0 - -crystal: ">= 0.36.0, < 2.0.0" - -authors: - - Ary Borenszweig - diff --git a/lib/webmock/src/webmock.cr b/lib/webmock/src/webmock.cr deleted file mode 100644 index ce5210d4..00000000 --- a/lib/webmock/src/webmock.cr +++ /dev/null @@ -1,48 +0,0 @@ -require "./**" - -module WebMock - extend self - - @@allow_net_connect = false - @@registry = StubRegistry.new - @@callbacks = CallbackRegistry.new - - def wrap - yield - ensure - reset - end - - def stub(method, uri) - @@registry.stub(method, uri) - end - - def reset - @@registry.reset - @@allow_net_connect = false - end - - def allow_net_connect=(@@allow_net_connect) - end - - def allows_net_connect? - @@allow_net_connect - end - - def find_stub(request : HTTP::Request) - @@registry.find_stub(request) - end - - def callbacks - @@callbacks - end - - # :nodoc: - def self.body(request : HTTP::Request) - body = request.body.try(&.gets_to_end) - if body - request.body = IO::Memory.new(body) - end - body - end -end diff --git a/lib/webmock/src/webmock/callback_registry.cr b/lib/webmock/src/webmock/callback_registry.cr deleted file mode 100644 index bf0a41e7..00000000 --- a/lib/webmock/src/webmock/callback_registry.cr +++ /dev/null @@ -1,26 +0,0 @@ -class WebMock::CallbackRegistry - getter callbacks - - def initialize - @callbacks = Hash(Symbol, (HTTP::Request, HTTP::Client::Response -> Nil)).new - end - - def reset - @callbacks.clear - end - - def add - with self yield - self - end - - def after_live_request(&block : (HTTP::Request, HTTP::Client::Response) ->) - @callbacks[:after_live_request] = block - end - - def call(name, *args) - if !@callbacks.empty? - @callbacks[name].try &.call(*args) - end - end -end diff --git a/lib/webmock/src/webmock/core_ext.cr b/lib/webmock/src/webmock/core_ext.cr deleted file mode 100644 index 11436ba5..00000000 --- a/lib/webmock/src/webmock/core_ext.cr +++ /dev/null @@ -1,45 +0,0 @@ -require "http/client" - -class HTTP::Request - property scheme : String = "http" - - def full_uri - "#{scheme}://#{headers["Host"]?}#{resource}" - end -end - -class HTTP::Client - private def exec_internal(request : HTTP::Request) - exec_internal(request, &.itself).tap do |response| - response.consume_body_io - response.headers.delete("Transfer-encoding") - response.headers["Content-length"] = response.body.bytesize.to_s - end - end - - private def exec_internal(request : HTTP::Request, &block : Response -> T) : T forall T - request.scheme = "https" if tls? - request.headers["Host"] = host_header unless request.headers.has_key?("Host") - run_before_request_callbacks(request) - - stub = WebMock.find_stub(request) - return yield(stub.exec(request)) if stub - raise WebMock::NetConnectNotAllowedError.new(request) unless WebMock.allows_net_connect? - - request.headers["User-agent"] ||= "Crystal" - request.to_io(io) - io.flush - - result = nil - - HTTP::Client::Response.from_io(io, request.ignore_body?) do |response| - result = yield(response) - close unless response.keep_alive? - WebMock.callbacks.call(:after_live_request, request, response) - end - - raise "Unexpected end of response" unless result.is_a?(T) - - result - end -end diff --git a/lib/webmock/src/webmock/net_connect_not_allowed_error.cr b/lib/webmock/src/webmock/net_connect_not_allowed_error.cr deleted file mode 100644 index 2036dab0..00000000 --- a/lib/webmock/src/webmock/net_connect_not_allowed_error.cr +++ /dev/null @@ -1,82 +0,0 @@ -class WebMock::NetConnectNotAllowedError < Exception - def initialize(request : HTTP::Request) - super(help_message(request)) - end - - private def help_message(request) - String.build do |io| - io << "Real HTTP connections are disabled. " - io << "Unregistered request: " - signature(request, io) - io << "\n\n" - io << "You can stub this request with the following snippet:" - io << "\n\n" - stubbing_instructions(request, io) - io << "\n\n" - end - end - - private def signature(request, io) - io << request.method << " " - request_uri_to_s request, io - if request.body - io << " with body " - WebMock.body(request).inspect(io) - end - io << " with headers " - headers_to_s request.headers, io - end - - private def stubbing_instructions(request, io) - # For the instructions we remove these two headers because they are automatically - # included in HTTP::Client requests - headers = request.headers.dup - headers.delete("Content-Length") - headers.delete("Connection") - headers.delete("Host") - - io << "WebMock.stub(:" << request.method.downcase << ", " - io << '"' - request_uri_to_s request, io - io << %[").] - io.puts - - if request.body && !headers.empty? - io << " with(" - - if request.body - io << "body: " - WebMock.body(request).inspect(io) - io << ", " unless headers.empty? - end - - unless headers.empty? - io << "headers: " - headers_to_s headers, io - end - io << ")." - io.puts - end - - io << %[ to_return(body: "")] - end - - private def request_uri_to_s(request, io) - io << request.scheme - io << "://" - io << request.headers["Host"] - io << request.path - io << "?#{request.query}" if request.query - end - - private def headers_to_s(headers, io) - io << "{" - headers.each_with_index do |(key, values), index| - io << ", " if index > 0 - key.inspect(io) - io << " => " - values.join(", ").inspect(io) - end - io << "}" - end -end diff --git a/lib/webmock/src/webmock/stub.cr b/lib/webmock/src/webmock/stub.cr deleted file mode 100644 index acdd3d71..00000000 --- a/lib/webmock/src/webmock/stub.cr +++ /dev/null @@ -1,135 +0,0 @@ -class WebMock::Stub - @method : String - @uri : URI | Regex - @expected_headers : HTTP::Headers? - @calls = 0 - @body_io : IO? - - def initialize(method : Symbol | String, uri : String | Regex) - @method = method.to_s.upcase - @uri = uri.is_a?(String) ? parse_uri(uri) : uri - - # For to_return - @status = 200 - @body = "" - @headers = HTTP::Headers{"Content-length" => "0", "Connection" => "close"} - - @block = Proc(HTTP::Request, HTTP::Client::Response).new do |_request| - HTTP::Client::Response.new(@status, body: @body, headers: @headers, body_io: @body_io) - end - end - - def with(query : Hash(String, String)? = nil, body : String? = nil, headers = nil) - @expected_query = query - @expected_body = body - @expected_headers = HTTP::Headers.new.merge!(headers) if headers - self - end - - def to_return(body : String? = "", status = 200, headers = nil) - @body = body - @body_io = nil - @status = status - @headers.delete("Transfer-encoding") - @headers["Content-length"] = body.size.to_s - @headers.merge!(headers) if headers - self - end - - def to_return(body_io : IO, status = 200, headers = nil) - @body = nil - @body_io = body_io - @status = status - @headers.delete("Content-length") - @headers["Transfer-encoding"] = "chunked" - @headers.merge!(headers) if headers - self - end - - def to_return(&block : HTTP::Request -> HTTP::Client::Response) - @block = block - self - end - - def matches?(request) - matches_method?(request) && - matches_uri?(request) && - matches_body?(request) && - matches_headers?(request) - end - - def matches_uri?(request) - case uri = @uri - when URI - matches_scheme?(request, uri) && - matches_host?(request, uri) && - matches_path?(request, uri) - when Regex - uri =~ request.full_uri - end - end - - def matches_method?(request) - return true if @method == "ANY" - - @method == request.method - end - - def matches_scheme?(request, uri) - uri.scheme == request.scheme - end - - def matches_host?(request, uri) - host_uri = parse_uri(request.headers["Host"]) - host_uri.host == uri.host && host_uri.port == uri.port - end - - def matches_path?(request, uri) - uri_path = uri.path.presence || "/" - uri_query = uri.query - - request_uri = parse_uri(request.resource) - request_path = request_uri.path.presence || "/" - request_query = request_uri.query - - request_query = HTTP::Params.parse(request_query || "") - uri_query = HTTP::Params.parse(uri_query || "") - - @expected_query.try &.each do |key, value| - uri_query.add(key.to_s, value.to_s) - end - request_path == uri_path && request_query == uri_query - end - - def matches_body?(request) - @expected_body ? @expected_body == WebMock.body(request) : true - end - - def matches_headers?(request) - expected_headers = @expected_headers - return true unless expected_headers - - expected_headers.each do |key, _| - request_value = request.headers[key]? - expected_value = expected_headers[key]? - return false unless request_value.to_s == expected_value.to_s - end - - true - end - - def exec(request) - @calls += 1 - @block.call(request) - end - - def calls - @calls - end - - private def parse_uri(uri_string) - uri = URI.parse(uri_string) - uri = URI.parse("http://#{uri_string}") unless uri.host - uri - end -end diff --git a/lib/webmock/src/webmock/stub_registry.cr b/lib/webmock/src/webmock/stub_registry.cr deleted file mode 100644 index 789660c9..00000000 --- a/lib/webmock/src/webmock/stub_registry.cr +++ /dev/null @@ -1,19 +0,0 @@ -struct WebMock::StubRegistry - def initialize - @stubs = [] of Stub - end - - def stub(method, uri) - stub = Stub.new(method, uri) - @stubs << stub - stub - end - - def reset - @stubs.clear - end - - def find_stub(request) - @stubs.find &.matches?(request) - end -end diff --git a/script/acceptance b/script/acceptance index 35dc63c6..5fbc421a 100755 --- a/script/acceptance +++ b/script/acceptance @@ -10,4 +10,8 @@ PURPLE='\033[0;35m' # set the working directory to the root of the project DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" +if [[ "$CI" == "true" ]]; then + source script/ci-env +fi + crystal run spec/acceptance/acceptance.cr diff --git a/script/bootstrap b/script/bootstrap index 996ce879..e35134ed 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -12,6 +12,21 @@ YELLOW='\033[0;33m' # set the working directory to the root of the project DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" +VENDOR_DIR="$DIR/vendor" +SHARDS_CACHE_PATH="$VENDOR_DIR/.cache/shards" +SHARDS_INSTALL_PATH="$VENDOR_DIR/shards/install" + +if [[ "$@" == *"--ci"* ]]; then + source script/ci-env +fi + +crystal_path_var="$(crystal env CRYSTAL_PATH)" +# if the crystal_path_var does not contain 'vendor/shards/install' then we need to add it to the CRYSTAL_PATH +if [[ "$crystal_path_var" != *"vendor/shards/install"* ]]; then + echo -e "๐Ÿ’ก ${YELLOW}WARNING${OFF}: This project uses a custom vendoring strategy. Please update your ${PURPLE}CRYSTAL_PATH${OFF} env var to also check the ${PURPLE}vendor/shards/install${OFF} dir relative to the root of this (and other) crystal projects for builds to work properly. Example:\n" + echo -e "${PURPLE}export CRYSTAL_PATH=\"vendor/shards/install:$(crystal env CRYSTAL_PATH)\"${OFF}\n" + echo -e "It is suggested to add this to your ${PURPLE}.bashrc${OFF} or ${PURPLE}.zshrc${OFF} file so you only have to update it once and then can forget about it.\n" +fi # check to ensure both crystal and shards are installed if ! [ -x "$(command -v crystal)" ]; then @@ -64,7 +79,18 @@ if [[ ! -z "$compatability_warning" && "$SUPPRESS_BOOTSTRAP_WARNINGS" != "true" fi script/preinstall +script/unzipper + +# for now, I think I can get away with `--ci` passing in `--skip-postinstall`. This may change one day when I actually need to run the postinstall command for deps in ci +ci_flags="" +if [[ "$@" == *"--ci"* ]]; then + ci_flags="--skip-postinstall --skip-executables" +fi + +# install the shards +SHARDS_CACHE_PATH="$SHARDS_CACHE_PATH" SHARDS_INSTALL_PATH="$SHARDS_INSTALL_PATH" shards install --local --frozen $ci_flags $@ -SHARDS_CACHE_PATH="$DIR/.cache/shards" shards install --frozen $@ +# shards install often wipes out our custom shards sha256 file so we need to recompute it if they are gone +script/compute-dep-shas -script/postinstall +script/postinstall $@ diff --git a/script/build b/script/build new file mode 100755 index 00000000..7ae5ee09 --- /dev/null +++ b/script/build @@ -0,0 +1,31 @@ +#!/bin/bash + +set -e + +# COLORS +OFF='\033[0m' +RED='\033[0;31m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +PURPLE='\033[0;35m' + +# set the working directory to the root of the project +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" +VENDOR_DIR="$DIR/vendor" +SHARDS_CACHE_PATH="$VENDOR_DIR/.cache/shards" +SHARDS_INSTALL_PATH="$VENDOR_DIR/shards/install" + +if [[ "$CI" == "true" ]]; then + source script/ci-env +fi + +# if the --production flag is passed or the CRYSTAL_ENV environment variable is set to production, +# always ensure the CRYSTAL_PATH is set with vendored shards +if [[ "$@" == *"--production"* ]] || [[ "$CRYSTAL_ENV" == "production" ]]; then + echo "๐Ÿ”จ setting CRYSTAL_PATH to $VENDOR_DIR/shards/install:$(crystal env CRYSTAL_PATH)" + export CRYSTAL_PATH="vendor/shards/install:$(crystal env CRYSTAL_PATH)" +fi + +echo -e "๐Ÿ”จ ${BLUE}building in ${PURPLE}release${BLUE} mode${OFF}" +SHARDS_CACHE_PATH="$SHARDS_CACHE_PATH" SHARDS_INSTALL_PATH="$SHARDS_INSTALL_PATH" shards build --production --release --progress --debug --error-trace +echo -e "๐Ÿ“ฆ ${GREEN}build complete${OFF}" diff --git a/script/ci-env b/script/ci-env new file mode 100755 index 00000000..ffa9e579 --- /dev/null +++ b/script/ci-env @@ -0,0 +1,5 @@ +#!/bin/bash + +echo "running ci-env script" + +export CRYSTAL_PATH="vendor/shards/install:$(crystal env CRYSTAL_PATH)" diff --git a/script/compute-dep-shas b/script/compute-dep-shas new file mode 100755 index 00000000..e609dd50 --- /dev/null +++ b/script/compute-dep-shas @@ -0,0 +1,31 @@ +#!/bin/bash + +# set the working directory to the root of the project +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" +VENDOR_DIR="$DIR/vendor" +SHARDS_CACHE_PATH="$VENDOR_DIR/.cache/shards" +SHARDS_INSTALL_PATH="$VENDOR_DIR/shards/install" +SHARDS_CACHED="$VENDOR_DIR/shards/cache" + +SHARD_SHA_FILE=".shard.vendor.cache.sha256" + +file="vendor/shards/install/.shards.info" + +if [ -f "$VENDOR_DIR/shards/install/.shards.info" ]; then + + # Use yq to parse the file and extract shard names and versions + shards=$(yq eval '.shards | to_entries | .[] | "\(.key)|\(.value.git)|\(.value.version)"' $file) + + # Loop over each shard + echo "$shards" | while IFS= read -r shard; do + # Extract name and version + name=$(echo $shard | cut -d'|' -f1) + version=$(echo $shard | cut -d'|' -f3) + + # if the shard sha256 file does not exist, try to compute and create it + if [ ! -f "$SHARDS_INSTALL_PATH/name/$SHARD_SHA_FILE" ]; then + shard_cache_sha=$(shasum -a 256 "$SHARDS_CACHED/$name-$version.shard" | cut -d' ' -f1) + cat > "$SHARDS_INSTALL_PATH/$name/$SHARD_SHA_FILE" <<< "$shard_cache_sha" + fi + done +fi diff --git a/script/format b/script/format index f6621575..f4122a64 100755 --- a/script/format +++ b/script/format @@ -12,6 +12,10 @@ PURPLE='\033[0;35m' # set the working directory to the root of the project DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" +if [[ "$CI" == "true" ]]; then + source script/ci-env +fi + echo -e "๐Ÿงน ${BLUE}formatting ${PURPLE}crystal${BLUE} files...${OFF}" crystal tool format $@ diff --git a/script/lint b/script/lint index 415c08f5..efa8f30c 100755 --- a/script/lint +++ b/script/lint @@ -12,6 +12,10 @@ PURPLE='\033[0;35m' # set the working directory to the root of the project DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" +if [[ "$CI" == "true" ]]; then + source script/ci-env +fi + echo -e "๐Ÿ–Œ๏ธ ${BLUE}linting ${PURPLE}crystal${BLUE} files..." "$DIR/bin/ameba" $@ diff --git a/script/postinstall b/script/postinstall index d807d896..f2831b76 100755 --- a/script/postinstall +++ b/script/postinstall @@ -2,28 +2,76 @@ # set the working directory to the root of the project DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" +VENDOR_DIR="$DIR/vendor" +SHARDS_INSTALL_PATH="$VENDOR_DIR/shards/install" + +LINUX_VENDOR_DIR="$VENDOR_DIR/linux_x86_64/bin" +DARWIN_VENDOR_DIR_X64="$VENDOR_DIR/darwin_x86_64/bin" +DARWIN_VENDOR_DIR_ARM64="$VENDOR_DIR/darwin_arm64/bin" + +if [[ "$CI" == "true" ]]; then + source script/ci-env +fi mkdir -p "$DIR/bin" -# ensure the ameba binary is built and available in the bin directory -AMEBA_UP_TO_DATE=false -# first, check the version of the ameba binary in the lock file -AMEBA_VERSION=$(shards list | grep ameba | awk '{print $3}' | tr -d '()') - -# if the bin/ameba binary exists, check if it is the correct version -if [ -f "$DIR/bin/ameba" ]; then - CURRENT_VERSION=$("$DIR/bin/ameba" --version) - if [ "$AMEBA_VERSION" = "$CURRENT_VERSION" ]; then - AMEBA_UP_TO_DATE=true - else - echo "ameba binary is not up to date" - echo "ameba version (./bin/ameba): $CURRENT_VERSION" - echo "ameba version (shards list): $AMEBA_VERSION" - AMEBA_UP_TO_DATE=false +flags="$@" + +if [[ "$flags" == *"--ci"* ]]; then + echo $OSTYPE + echo $(uname -m) +fi + +# Determine if the system is a Mac or Linux +os="unknown" +arch="unknown" +if [[ "$OSTYPE" == "linux-gnu" ]]; then + os="linux" + if [[ "$flags" == *"--ci"* ]]; then + echo "OSTYPE: $OSTYPE" + echo "running in CI mode, copying vendored binaries to bin/ directory (linux)" + cp -r "$LINUX_VENDOR_DIR/." "$DIR/bin" fi +elif [[ "$OSTYPE" == "darwin"* ]]; then + os="mac" + arch=$(uname -m) + if [[ "$flags" == *"--ci"* ]]; then + echo "OSTYPE: $OSTYPE" + echo "ARCH: $arch" + echo "running in CI mode, copying vendored binaries to bin/ directory (mac)" + if [ "$arch" = "x86_64" ]; then + cp -r "$DARWIN_VENDOR_DIR_X64/." "$DIR/bin" + elif [ "$arch" = "arm64" ]; then + cp -r "$DARWIN_VENDOR_DIR_ARM64/" "$DIR/bin" + else + echo "Unknown architecture: $arch" + fi + fi +else + os="unknown" fi -if [ "$AMEBA_UP_TO_DATE" = false ]; then - echo "building ameba binary" - cd "$DIR/lib/ameba" && shards build && cp bin/ameba "$DIR/bin/ameba" && cd "$DIR" +if [[ ! "$@" == *"--production"* ]]; then + # ensure the ameba binary is built and available in the bin directory + AMEBA_UP_TO_DATE=false + # first, check the version of the ameba binary in the lock file + AMEBA_VERSION=$(SHARDS_INSTALL_PATH="$SHARDS_INSTALL_PATH" shards list | grep ameba | awk '{print $3}' | tr -d '()') + + # if the bin/ameba binary exists, check if it is the correct version + if [ -f "$DIR/bin/ameba" ]; then + CURRENT_VERSION=$("$DIR/bin/ameba" --version) + if [ "$AMEBA_VERSION" = "$CURRENT_VERSION" ]; then + AMEBA_UP_TO_DATE=true + else + echo "ameba binary is not up to date" + echo "ameba version (./bin/ameba): $CURRENT_VERSION" + echo "ameba version (shards list): $AMEBA_VERSION" + AMEBA_UP_TO_DATE=false + fi + fi + + if [ "$AMEBA_UP_TO_DATE" = false ]; then + echo "building ameba binary" + cd "$SHARDS_INSTALL_PATH/ameba" && shards build && cp bin/ameba "$DIR/bin/ameba" && cd "$DIR" + fi fi diff --git a/script/test b/script/test index a9b4385d..396f1350 100755 --- a/script/test +++ b/script/test @@ -14,6 +14,10 @@ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" echo -e "๐Ÿงช ${BLUE}running unit tests...${OFF}" +if [[ "$CI" == "true" ]]; then + source script/ci-env +fi + crystal spec echo -e "โœ… ${GREEN}tests complete!${OFF}" diff --git a/script/unzipper b/script/unzipper new file mode 100755 index 00000000..f323ab3e --- /dev/null +++ b/script/unzipper @@ -0,0 +1,92 @@ +#!/bin/bash + +# set the working directory to the root of the project +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" +VENDOR_DIR="$DIR/vendor" +SHARDS_CACHE_PATH="$VENDOR_DIR/.cache/shards" +SHARDS_INSTALL_PATH="$VENDOR_DIR/shards/install" +SHARDS_CACHED="$VENDOR_DIR/shards/cache" + +SHARD_SHA_FILE=".shard.vendor.cache.sha256" + +TRASHDIR=$(mktemp -d /tmp/bootstrap.XXXXXXXXXXXXXXXXX) +cleanup() { + rm -rf "$TRASHDIR" +} +trap cleanup EXIT + +mkdir -p "$SHARDS_INSTALL_PATH" +mkdir -p "$SHARDS_CACHE_PATH/github.com" + +# check if the .shards.info file exists, if it doesn't this is a fresh bootstrapping +if [ ! -f "$VENDOR_DIR/shards/install/.shards.info" ]; then + # if no .shards.info file found, this must be a fresh bootstrapping + + # iterate over all the cached shards in the vendor/shards/cache directory + for shard in $(find "$SHARDS_CACHED" -type f -maxdepth 1); do + + # unzip the file into the TRASHDIR + unzip -q -o "$shard" -d "$TRASHDIR" + + # get the only name of the dir in the TRASHDIR + shard_name=$(ls "$TRASHDIR/shard/") + + # clear up the shard in the install dir if it exists + rm -rf "$SHARDS_INSTALL_PATH/$shard_name" + + # move the shard and cache directories to the correct location + mv -f "$TRASHDIR/shard/"* "$SHARDS_INSTALL_PATH/" 2>/dev/null || true + mv -f "$TRASHDIR/cache/"* "$SHARDS_CACHE_PATH/github.com/" 2>/dev/null || true + + # cleanup the TRASHDIR + rm -rf "$TRASHDIR/shard" + rm -rf "$TRASHDIR/cache" + + shard_cache_sha=$(shasum -a 256 "$shard" | cut -d' ' -f1) + + # write the new sha to the $SHARD_SHA_FILE file + cat > "$SHARDS_INSTALL_PATH/$shard_name/$SHARD_SHA_FILE" <<< "$shard_cache_sha" + done + +else + # if found .shards.info file, this must be a bootstrap re-run - we will check if the shards have changed by comparing the sha256 of the cached shard and the sha256 of the current shard + + file="vendor/shards/install/.shards.info" + + # Use yq to parse the file and extract shard names and versions + shards=$(yq eval '.shards | to_entries | .[] | "\(.key)|\(.value.git)|\(.value.version)"' $file) + + # Loop over each shard + echo "$shards" | while IFS= read -r shard; do + # Extract name and version + name=$(echo $shard | cut -d'|' -f1) + version=$(echo $shard | cut -d'|' -f3) + + shard_cache_sha=$(shasum -a 256 "$SHARDS_CACHED/$name-$version.shard" | cut -d' ' -f1) + shard_current_sha="" + if [ -f "$SHARDS_INSTALL_PATH/$name/$SHARD_SHA_FILE" ]; then + shard_current_sha=$(cat "$SHARDS_INSTALL_PATH/$name/$SHARD_SHA_FILE") + fi + + if [ "$shard_cache_sha" != "$shard_current_sha" ]; then + echo "shard $name $version has changed, updating" + + # unzip the file into the TRASHDIR + unzip -q -o "$SHARDS_CACHED/$name-$version.shard" -d "$TRASHDIR" + + # clear up the shard in the install dir if it exists + rm -rf "$SHARDS_INSTALL_PATH/$name" + + # move the shard and cache directories to the correct location + mv -f "$TRASHDIR/shard/"* "$SHARDS_INSTALL_PATH/" 2>/dev/null || true + mv -f "$TRASHDIR/cache/"* "$SHARDS_CACHE_PATH/github.com/" 2>/dev/null || true + + # write the new sha to the $SHARD_SHA_FILE file + cat > "$SHARDS_INSTALL_PATH/$name/$SHARD_SHA_FILE" <<< "$shard_cache_sha" + + # cleanup the TRASHDIR + rm -rf "$TRASHDIR/shard" + rm -rf "$TRASHDIR/cache" + fi + done +fi diff --git a/script/update b/script/update index f344f868..2f7244ce 100755 --- a/script/update +++ b/script/update @@ -11,13 +11,17 @@ PURPLE='\033[0;35m' # set the working directory to the root of the project DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" +VENDOR_DIR="$DIR/vendor" +SHARDS_CACHE_PATH="$VENDOR_DIR/.cache/shards" +SHARDS_INSTALL_PATH="$VENDOR_DIR/shards/install" echo -e "๐Ÿ“ฆ ${BLUE}Running ${PURPLE}shards update${BLUE} to update all dependencies${OFF}" script/preinstall -SHARDS_CACHE_PATH="$DIR/.cache/shards" shards update --skip-postinstall --skip-executables $@ +SHARDS_CACHE_PATH="$SHARDS_CACHE_PATH" SHARDS_INSTALL_PATH="$SHARDS_INSTALL_PATH" shards update $@ +script/zipper script/postinstall echo -e "โœ… ${GREEN}All dependencies have been updated!${OFF}" diff --git a/script/zipper b/script/zipper new file mode 100755 index 00000000..977ccaaa --- /dev/null +++ b/script/zipper @@ -0,0 +1,59 @@ +#!/bin/bash + +# set the working directory to the root of the project +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" +VENDOR_DIR="$DIR/vendor" +SHARDS_CACHE_PATH="$VENDOR_DIR/.cache/shards" +SHARDS_INSTALL_PATH="$VENDOR_DIR/shards/install" +SHARDS_CACHED="$VENDOR_DIR/shards/cache" + +file="vendor/shards/install/.shards.info" + +# Function to parse owner and repo from URL +parse_owner_repo() { + local url=$1 + # Use awk to extract the owner and repo name + echo $url | awk -F'/' '{print $(NF-1)"/"$(NF)}' | sed 's/.git$//' +} + +if ! [ -x "$(command -v yq)" ]; then + echo -e "โŒ ${RED}Error${OFF}: yq is not installed" + echo "Please install yq -> https://github.com/mikefarah/yq" + exit 1 +fi + +TRASHDIR=$(mktemp -d /tmp/bootstrap.XXXXXXXXXXXXXXXXX) +cleanup() { + rm -rf "$TRASHDIR" +} +trap cleanup EXIT + +# Use yq to parse the file and extract shard names and versions +shards=$(yq eval '.shards | to_entries | .[] | "\(.key)|\(.value.git)|\(.value.version)"' $file) + +# Loop over each shard +echo "$shards" | while IFS= read -r shard; do + # Extract name and version + name=$(echo $shard | cut -d'|' -f1) + git_url=$(echo $shard | cut -d'|' -f2) + owner_repo=$(parse_owner_repo $git_url) + owner=$(echo $owner_repo | cut -d'/' -f1) + repo=$(echo $owner_repo | cut -d'/' -f2) + version=$(echo $shard | cut -d'|' -f3) + + mkdir -p "$TRASHDIR/$name-$version.shard/shard/$name" + mkdir -p "$TRASHDIR/$name-$version.shard/cache/$owner/$repo.git" + cp -r "$SHARDS_INSTALL_PATH/$name/"* "$TRASHDIR/$name-$version.shard/shard/$name" 2>/dev/null || true + cp -r "$SHARDS_CACHE_PATH/github.com/$owner/$repo.git/"* "$TRASHDIR/$name-$version.shard/cache/$owner/$repo.git" + + echo '{"name": "'$name'", "version": "'$version'", "repository": "'$owner/$repo'"}' > "$TRASHDIR/$name-$version.shard/metadata.json" + + # Change to the temporary directory and zip the shard and cache directories + (cd "$TRASHDIR/$name-$version.shard" && zip -q -r "$TRASHDIR/$name-$version.zip" shard cache metadata.json -x "*.shard.vendor.cache.sha256") + + # Move the zip to the cache + mkdir -p "$SHARDS_CACHED" + mv "$TRASHDIR/$name-$version.zip" "$SHARDS_CACHED/$name-$version.shard" + + # echo "cached $name $version" +done diff --git a/vendor/darwin_arm64/bin/ameba b/vendor/darwin_arm64/bin/ameba new file mode 100755 index 00000000..92e80f27 Binary files /dev/null and b/vendor/darwin_arm64/bin/ameba differ diff --git a/vendor/darwin_x86_64/bin/ameba b/vendor/darwin_x86_64/bin/ameba new file mode 100755 index 00000000..24a8f36b Binary files /dev/null and b/vendor/darwin_x86_64/bin/ameba differ diff --git a/vendor/linux_x86_64/bin/ameba b/vendor/linux_x86_64/bin/ameba new file mode 100755 index 00000000..66f9d744 Binary files /dev/null and b/vendor/linux_x86_64/bin/ameba differ diff --git a/vendor/shards/cache/ameba-1.6.1.shard b/vendor/shards/cache/ameba-1.6.1.shard new file mode 100644 index 00000000..a04a60a2 Binary files /dev/null and b/vendor/shards/cache/ameba-1.6.1.shard differ diff --git a/vendor/shards/cache/halite-0.12.0.shard b/vendor/shards/cache/halite-0.12.0.shard new file mode 100644 index 00000000..5eb7ba57 Binary files /dev/null and b/vendor/shards/cache/halite-0.12.0.shard differ diff --git a/vendor/shards/cache/json_mapping-0.1.1.shard b/vendor/shards/cache/json_mapping-0.1.1.shard new file mode 100644 index 00000000..98f41bf4 Binary files /dev/null and b/vendor/shards/cache/json_mapping-0.1.1.shard differ diff --git a/vendor/shards/cache/webmock-0.14.0.shard b/vendor/shards/cache/webmock-0.14.0.shard new file mode 100644 index 00000000..81bf9ce0 Binary files /dev/null and b/vendor/shards/cache/webmock-0.14.0.shard differ