├── .github └── workflows │ └── linters.yml ├── .rspec ├── .rubocop.yml ├── Gemfile ├── LICENSE ├── README.md ├── bin └── main ├── bug.rb ├── lib ├── checks.rb └── file_reader.rb └── spec ├── checks_spec.rb └── spec_helper.rb /.github/workflows/linters.yml: -------------------------------------------------------------------------------- 1 | name: Linters 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | rubocop: 7 | name: Rubocop 8 | runs-on: ubuntu-18.04 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-ruby@v1 12 | with: 13 | ruby-version: 2.6.x 14 | - name: Setup Rubocop 15 | run: | 16 | gem install --no-document rubocop:'~>0.81.0' # https://docs.rubocop.org/en/stable/installation/ 17 | [ -f .rubocop.yml ] || wget https://raw.githubusercontent.com/microverseinc/linters-config/master/ruby/.rubocop.yml 18 | - name: Rubocop Report 19 | run: rubocop --color -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --format documentation 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Exclude: 3 | - "Guardfile" 4 | - "Rakefile" 5 | - "bug.rb" 6 | 7 | DisplayCopNames: true 8 | 9 | Layout/LineLength: 10 | Max: 120 11 | Metrics/MethodLength: 12 | Max: 20 13 | Metrics/AbcSize: 14 | Max: 50 15 | Metrics/ClassLength: 16 | Max: 150 17 | Metrics/BlockLength: 18 | ExcludedMethods: ['describe'] 19 | Max: 30 20 | 21 | 22 | Style/Documentation: 23 | Enabled: false 24 | Style/ClassAndModuleChildren: 25 | Enabled: false 26 | Style/EachForSimpleLoop: 27 | Enabled: false 28 | Style/AndOr: 29 | Enabled: false 30 | Style/DefWithParentheses: 31 | Enabled: false 32 | Style/FrozenStringLiteralComment: 33 | EnforcedStyle: never 34 | 35 | Layout/HashAlignment: 36 | EnforcedColonStyle: key 37 | Layout/ExtraSpacing: 38 | AllowForAlignment: false 39 | Layout/MultilineMethodCallIndentation: 40 | Enabled: true 41 | EnforcedStyle: indented 42 | Lint/RaiseException: 43 | Enabled: false 44 | Lint/StructNewOverride: 45 | Enabled: false 46 | Style/HashEachMethods: 47 | Enabled: false 48 | Style/HashTransformKeys: 49 | Enabled: false 50 | Style/HashTransformValues: 51 | Enabled: false 52 | 53 | Style/CaseEquality: 54 | Enabled: false -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 4 | 5 | gem 'colorize' 6 | gem 'rspec' 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Uduak Essien 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ruby Capstone Project - Ruby Linter 2 | 3 | [![View Code](https://img.shields.io/badge/View%20-Code-green)](https://github.com/acushlakoncept/ruby-linter) 4 | [![Github Issues](https://img.shields.io/badge/GitHub-Issues-orange)](https://github.com/acushlakoncept/ruby-linter/issues) 5 | [![GitHub Pull Requests](https://img.shields.io/badge/GitHub-Pull%20Requests-blue)](https://github.com/acushlakoncept/ruby-linter/pulls) 6 | 7 | 8 | # About 9 | 10 | The whole idea of writing code to check another code is intriguing at the same time cognitively demanding. 11 | Building Linters for Ruby, the project provides feedback about errors or warnings in code little by little. 12 | The project was built completely with Ruby following all possible best practices. Rubocop was used as a code-linter alongside Gitflow to ensure I maintain good coding standards. 13 | 14 | 15 | # The Build 16 | The custom Ruby linter currently checks/detects for the following errors/warnings. 17 | - check for wrong indentation 18 | - check for trailing spaces 19 | - check for missing/unexpected tags i.e. '( )', '[ ]', and '{ }' 20 | - check missing/unexpected end 21 | - check empty line error 22 | 23 | > Below are demonstrations of good and bad code for the above cases. I will use the pipe '|' symbol to indicate cursor position where necessary. 24 | 25 | ## Indentation Error Check 26 | ~~~ruby 27 | # Good Code 28 | 29 | class Ticket 30 | def initialize(venue, date) 31 | @venue = venue 32 | @date = date 33 | end 34 | end 35 | 36 | # Bad Code 37 | 38 | class Ticket 39 | def initialize(venue, date) 40 | @venue = venue 41 | @date = date 42 | end 43 | end 44 | ~~~ 45 | 46 | ## Trailing spaces 47 | > note where the cursor(|) is on the bad code 48 | ~~~ruby 49 | # Good Code 50 | 51 | class Ticket 52 | def initialize(venue, date) 53 | @venue = venue 54 | @date = date 55 | end 56 | end 57 | 58 | # Bad Code 59 | 60 | class Ticket 61 | def initialize(venue, date) | 62 | @venue = venue 63 | @date = date 64 | end 65 | end 66 | ~~~ 67 | 68 | ## Missing/Unexpected Tag 69 | ~~~ruby 70 | # Good Code 71 | 72 | class Ticket 73 | def initialize(venue, date) 74 | @venue = venue 75 | @date = date 76 | end 77 | end 78 | 79 | # Bad Code 80 | 81 | class Ticket 82 | def initialize(venue, date 83 | @venue = venue 84 | @date = [[date] 85 | end 86 | end 87 | ~~~ 88 | 89 | ## Missing/unexpected end 90 | ~~~ruby 91 | # Good Code 92 | 93 | class Ticket 94 | def initialize(venue, date) 95 | @venue = venue 96 | @date = date 97 | end 98 | end 99 | 100 | # Bad Code 101 | 102 | class Ticket 103 | def initialize(venue, date) 104 | @venue = venue 105 | @date = date 106 | end 107 | end 108 | end 109 | ~~~ 110 | 111 | ## Empty line error 112 | ~~~ruby 113 | # Good Code 114 | 115 | class Ticket 116 | def initialize(venue, date) 117 | @venue = venue 118 | @date = date 119 | end 120 | end 121 | 122 | # Bad Code 123 | 124 | class Ticket 125 | def initialize(venue, date) 126 | 127 | @venue = venue 128 | @date = date 129 | end 130 | end 131 | ~~~ 132 | 133 | ## Built With 134 | - Ruby 135 | - RSpec for Ruby Testing 136 | 137 | 138 | # Getting Started 139 | 140 | To get a local copy of the repository please run the following commands on your terminal: 141 | 142 | ``` 143 | $ cd 144 | ``` 145 | 146 | ``` 147 | $ git clone https://github.com/acushlakoncept/ruby-linter.git 148 | ``` 149 | 150 | **To check for errors on a file:** 151 | 152 | ~~~bash 153 | $ bin/main bug.rb 154 | ~~~ 155 | 156 | ## Testing 157 | 158 | To test the code, run `rspec` from root of the folder using terminal. 159 | Note: `bug.rb` has been excluded from rubocop checks to allow RSpec testing without interfering with Gitflow actions 160 | 161 | > Rspec is used for the test, to install the gem file, run 162 | 163 | ~~~bash 164 | $ bundle install 165 | ~~~ 166 | 167 | > But before that, make sure you have **bundler** installed on your system, else run 168 | 169 | ~~~bash 170 | $ gem install bundler 171 | ~~~ 172 | 173 | > or you simply install the the following directly using 174 | 175 | ~~~bash 176 | $ gem install rspec 177 | ~~~ 178 | 179 | ~~~bash 180 | $ gem install colorize 181 | ~~~ 182 | 183 | 184 | # Author 185 | 186 | 👤 **Uduak Essien** 187 | 188 | - Github: [@acushlakoncept](https://github.com/acushlakoncept/) 189 | - Twitter: [@acushlakoncept](https://twitter.com/acushlakoncept) 190 | - Linkedin: [acushlakoncept](https://www.linkedin.com/in/acushlakoncept/) 191 | 192 | 193 | ## 🤝 Contributing 194 | 195 | Contributions, issues and feature requests are welcome! 196 | 197 | ## Show your support 198 | 199 | Give a ⭐️ if you like this project! 200 | 201 | ## Acknowledgments 202 | 203 | - Project inspired by [Microverse](https://www.microverse.org) 204 | -------------------------------------------------------------------------------- /bin/main: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../lib/checks.rb' 3 | 4 | check = CheckError.new(ARGV.first) 5 | check.check_indentation 6 | check.check_trailing_spaces 7 | check.tag_error 8 | check.end_error 9 | check.empty_line_error 10 | 11 | if check.errors.empty? && check.checker.err_msg.empty? 12 | puts 'No offenses'.colorize(:green) + ' detected' 13 | else 14 | check.errors.uniq.each do |err| 15 | puts "#{check.checker.file_path.colorize(:blue)} : #{err.colorize(:red)}" 16 | end 17 | end 18 | 19 | puts check.checker.err_msg if check.checker.file_lines.empty? 20 | -------------------------------------------------------------------------------- /bug.rb: -------------------------------------------------------------------------------- 1 | class Ticket 2 | def initialize(venue, date) 3 | @venue = venue] 4 | @date = date 5 | end 6 | 7 | def fake_method(lines) 8 | lines.each do |x| 9 | puts x 10 | end 11 | 12 | end -------------------------------------------------------------------------------- /lib/checks.rb: -------------------------------------------------------------------------------- 1 | require 'colorize' 2 | require 'strscan' 3 | require_relative 'file_reader.rb' 4 | 5 | class CheckError 6 | attr_reader :checker, :errors 7 | 8 | def initialize(file_path) 9 | @checker = FileReader.new(file_path) 10 | @errors = [] 11 | @keywords = %w[begin case class def do if module unless] 12 | end 13 | 14 | def check_trailing_spaces 15 | @checker.file_lines.each_with_index do |str_val, index| 16 | if str_val[-2] == ' ' && !str_val.strip.empty? 17 | @errors << "line:#{index + 1}:#{str_val.size - 1}: Error: Trailing whitespace detected." 18 | + " '#{str_val.gsub(/\s*$/, '_')}'" 19 | end 20 | end 21 | end 22 | 23 | def tag_error 24 | check_tag_error(/\(/, /\)/, '(', ')', 'Parenthesis') 25 | check_tag_error(/\[/, /\]/, '[', ']', 'Square Bracket') 26 | check_tag_error(/\{/, /\}/, '{', '}', 'Curly Bracket') 27 | end 28 | 29 | def end_error 30 | keyw_count = 0 31 | end_count = 0 32 | @checker.file_lines.each_with_index do |str_val, _index| 33 | keyw_count += 1 if @keywords.include?(str_val.split(' ').first) || str_val.split(' ').include?('do') 34 | end_count += 1 if str_val.strip == 'end' 35 | end 36 | 37 | status = keyw_count <=> end_count 38 | log_error("Lint/Syntax: Missing 'end'") if status.eql?(1) 39 | log_error("Lint/Syntax: Unexpected 'end'") if status.eql?(-1) 40 | end 41 | 42 | def empty_line_error 43 | @checker.file_lines.each_with_index do |str_val, indx| 44 | check_class_empty_line(str_val, indx) 45 | check_def_empty_line(str_val, indx) 46 | check_end_empty_line(str_val, indx) 47 | check_do_empty_line(str_val, indx) 48 | end 49 | end 50 | 51 | # rubocop: disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity 52 | 53 | def check_indentation 54 | msg = 'IndentationWidth: Use 2 spaces for indentation.' 55 | cur_val = 0 56 | indent_val = 0 57 | 58 | @checker.file_lines.each_with_index do |str_val, indx| 59 | strip_line = str_val.strip.split(' ') 60 | exp_val = cur_val * 2 61 | res_word = %w[class def if elsif until module unless begin case] 62 | 63 | next unless !str_val.strip.empty? || !strip_line.first.eql?('#') 64 | 65 | indent_val += 1 if res_word.include?(strip_line.first) || strip_line.include?('do') 66 | indent_val -= 1 if str_val.strip == 'end' 67 | 68 | next if str_val.strip.empty? 69 | 70 | indent_error(str_val, indx, exp_val, msg) 71 | cur_val = indent_val 72 | end 73 | end 74 | 75 | private 76 | 77 | def indent_error(str_val, indx, exp_val, msg) 78 | strip_line = str_val.strip.split(' ') 79 | emp = str_val.match(/^\s*\s*/) 80 | end_chk = emp[0].size.eql?(exp_val.zero? ? 0 : exp_val - 2) 81 | 82 | if str_val.strip.eql?('end') || strip_line.first == 'elsif' || strip_line.first == 'when' 83 | log_error("line:#{indx + 1} #{msg}") unless end_chk 84 | elsif !emp[0].size.eql?(exp_val) 85 | log_error("line:#{indx + 1} #{msg}") 86 | end 87 | end 88 | 89 | # rubocop: enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity 90 | 91 | def check_tag_error(*args) 92 | @checker.file_lines.each_with_index do |str_val, index| 93 | open_p = [] 94 | close_p = [] 95 | open_p << str_val.scan(args[0]) 96 | close_p << str_val.scan(args[1]) 97 | 98 | status = open_p.flatten.size <=> close_p.flatten.size 99 | 100 | log_error("line:#{index + 1} Lint/Syntax: Unexpected/Missing token '#{args[2]}' #{args[4]}") if status.eql?(1) 101 | log_error("line:#{index + 1} Lint/Syntax: Unexpected/Missing token '#{args[3]}' #{args[4]}") if status.eql?(-1) 102 | end 103 | end 104 | 105 | def check_class_empty_line(str_val, indx) 106 | msg = 'Extra empty line detected at class body beginning' 107 | return unless str_val.strip.split(' ').first.eql?('class') 108 | 109 | log_error("line:#{indx + 2} #{msg}") if @checker.file_lines[indx + 1].strip.empty? 110 | end 111 | 112 | def check_def_empty_line(str_val, indx) 113 | msg1 = 'Extra empty line detected at method body beginning' 114 | msg2 = 'Use empty lines between method definition' 115 | 116 | return unless str_val.strip.split(' ').first.eql?('def') 117 | 118 | log_error("line:#{indx + 2} #{msg1}") if @checker.file_lines[indx + 1].strip.empty? 119 | log_error("line:#{indx + 1} #{msg2}") if @checker.file_lines[indx - 1].strip.split(' ').first.eql?('end') 120 | end 121 | 122 | def check_end_empty_line(str_val, indx) 123 | return unless str_val.strip.split(' ').first.eql?('end') 124 | 125 | log_error("line:#{indx} Extra empty line detected at block body end") if @checker.file_lines[indx - 1].strip.empty? 126 | end 127 | 128 | def check_do_empty_line(str_val, indx) 129 | msg = 'Extra empty line detected at block body beginning' 130 | return unless str_val.strip.split(' ').include?('do') 131 | 132 | log_error("line:#{indx + 2} #{msg}") if @checker.file_lines[indx + 1].strip.empty? 133 | end 134 | 135 | def log_error(error_msg) 136 | @errors << error_msg 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /lib/file_reader.rb: -------------------------------------------------------------------------------- 1 | require 'colorize' 2 | 3 | class FileReader 4 | attr_reader :err_msg, :file_lines, :file_path, :file_lines_count 5 | def initialize(file_path) 6 | @err_msg = '' 7 | @file_path = file_path 8 | begin 9 | @file_lines = File.readlines(@file_path) 10 | @file_lines_count = @file_lines.size 11 | rescue StandardError => e 12 | @file_lines = [] 13 | @err_msg = "Check file name or path again\n".colorize(:light_red) + e.to_s.colorize(:red) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/checks_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../lib/checks.rb' 2 | 3 | describe CheckError do 4 | let(:checker) { CheckError.new('bug.rb') } 5 | 6 | describe '#check_trailing_spaces' do 7 | it 'should return trailing space error on line 3' do 8 | checker.check_trailing_spaces 9 | expect(checker.errors[0]).to eql('line:3:20: Error: Trailing whitespace detected.') 10 | end 11 | end 12 | 13 | describe '#check_indentation' do 14 | it 'should return indentation space error on line 4' do 15 | checker.check_indentation 16 | expect(checker.errors[0]).to eql('line:4 IndentationWidth: Use 2 spaces for indentation.') 17 | end 18 | end 19 | 20 | describe '#tag_error' do 21 | it "returns missing/unexpected tags eg '( )', '[ ]', and '{ }'" do 22 | checker.tag_error 23 | expect(checker.errors[0]).to eql("line:3 Lint/Syntax: Unexpected/Missing token ']' Square Bracket") 24 | end 25 | end 26 | 27 | describe '#end_error' do 28 | it 'returns missing/unexpected end' do 29 | checker.end_error 30 | expect(checker.errors[0]).to eql("Lint/Syntax: Missing 'end'") 31 | end 32 | end 33 | 34 | describe '#empty_line_error' do 35 | it 'returns empty line error' do 36 | checker.empty_line_error 37 | expect(checker.errors[0]).to eql('line:11 Extra empty line detected at block body end') 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it. 14 | # 15 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 16 | RSpec.configure do |config| 17 | # rspec-expectations config goes here. You can use an alternate 18 | # assertion/expectation library such as wrong or the stdlib/minitest 19 | # assertions if you prefer. 20 | config.expect_with :rspec do |expectations| 21 | # This option will default to `true` in RSpec 4. It makes the `description` 22 | # and `failure_message` of custom matchers include text for helper methods 23 | # defined using `chain`, e.g.: 24 | # be_bigger_than(2).and_smaller_than(4).description 25 | # # => "be bigger than 2 and smaller than 4" 26 | # ...rather than: 27 | # # => "be bigger than 2" 28 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 29 | end 30 | 31 | # rspec-mocks config goes here. You can use an alternate test double 32 | # library (such as bogus or mocha) by changing the `mock_with` option here. 33 | config.mock_with :rspec do |mocks| 34 | # Prevents you from mocking or stubbing a method that does not exist on 35 | # a real object. This is generally recommended, and will default to 36 | # `true` in RSpec 4. 37 | mocks.verify_partial_doubles = true 38 | end 39 | 40 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 41 | # have no way to turn it off -- the option exists only for backwards 42 | # compatibility in RSpec 3). It causes shared context metadata to be 43 | # inherited by the metadata hash of host groups and examples, rather than 44 | # triggering implicit auto-inclusion in groups with matching metadata. 45 | config.shared_context_metadata_behavior = :apply_to_host_groups 46 | 47 | # The settings below are suggested to provide a good initial experience 48 | # with RSpec, but feel free to customize to your heart's content. 49 | # # This allows you to limit a spec run to individual examples or groups 50 | # # you care about by tagging them with `:focus` metadata. When nothing 51 | # # is tagged with `:focus`, all examples get run. RSpec also provides 52 | # # aliases for `it`, `describe`, and `context` that include `:focus` 53 | # # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 54 | # config.filter_run_when_matching :focus 55 | # 56 | # # Allows RSpec to persist some state between runs in order to support 57 | # # the `--only-failures` and `--next-failure` CLI options. We recommend 58 | # # you configure your source control system to ignore this file. 59 | # config.example_status_persistence_file_path = "spec/examples.txt" 60 | # 61 | # # Limits the available syntax to the non-monkey patched syntax that is 62 | # # recommended. For more details, see: 63 | # # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 64 | # # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 65 | # # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 66 | # config.disable_monkey_patching! 67 | # 68 | # # This setting enables warnings. It's recommended, but in some cases may 69 | # # be too noisy due to issues in dependencies. 70 | # config.warnings = true 71 | # 72 | # # Many RSpec users commonly either run the entire suite or an individual 73 | # # file, and it's useful to allow more verbose output when running an 74 | # # individual spec file. 75 | # if config.files_to_run.one? 76 | # # Use the documentation formatter for detailed output, 77 | # # unless a formatter has already been configured 78 | # # (e.g. via a command-line flag). 79 | # config.default_formatter = "doc" 80 | # end 81 | # 82 | # # Print the 10 slowest examples and example groups at the 83 | # # end of the spec run, to help surface which specs are running 84 | # # particularly slow. 85 | # config.profile_examples = 10 86 | # 87 | # # Run specs in random order to surface order dependencies. If you find an 88 | # # order dependency and want to debug it, you can fix the order by providing 89 | # # the seed, which is printed after each run. 90 | # # --seed 1234 91 | # config.order = :random 92 | # 93 | # # Seed global randomization in this process using the `--seed` CLI option. 94 | # # Setting this allows you to use `--seed` to deterministically reproduce 95 | # # test failures related to randomization by passing the same `--seed` value 96 | # # as the one that triggered the failure. 97 | # Kernel.srand config.seed 98 | end 99 | --------------------------------------------------------------------------------