├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rubocop.yml ├── Appraisals ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── exe └── rails-route-checker ├── lib ├── rails-route-checker.rb └── rails-route-checker │ ├── app_interface.rb │ ├── config_file.rb │ ├── loaded_app.rb │ ├── parsers │ ├── erb_parser.rb │ ├── haml_parser.rb │ ├── haml_parser │ │ ├── document.rb │ │ ├── ruby_extractor.rb │ │ └── tree │ │ │ ├── filter_node.rb │ │ │ ├── node.rb │ │ │ ├── root_node.rb │ │ │ ├── script_node.rb │ │ │ ├── silent_script_node.rb │ │ │ └── tag_node.rb │ ├── loader.rb │ └── ruby_parser.rb │ ├── runner.rb │ └── version.rb ├── rails-route-checker.gemspec └── test ├── dummy ├── app │ ├── controllers │ │ ├── application_controller.rb │ │ ├── articles_api_controller.rb │ │ ├── articles_controller.rb │ │ └── base_api_controller.rb │ └── views │ │ ├── articles │ │ ├── index_erb.html.erb │ │ └── index_haml.html.haml │ │ ├── articles_api │ │ └── index.json.jbuilder │ │ └── layouts │ │ └── application.html.erb ├── bin │ └── rails ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ └── routes.rb ├── log │ ├── development.log │ └── test.log └── tmp │ └── development_secret.txt └── rails-route-checker ├── loaded_app_test.rb └── parser_test.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Ruby CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | ruby-version: ['3.1', '3.0', '2.7'] 13 | continue-on-error: [false] 14 | 15 | name: ${{ format('Tests (Ruby {0})', matrix.ruby-version) }} 16 | continue-on-error: ${{ matrix.continue-on-error }} 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Ruby ${{ matrix.ruby-version }} 21 | uses: ruby/setup-ruby@ec02537da5712d66d4d50a0f33b7eb52773b5ed1 22 | with: 23 | ruby-version: ${{ matrix.ruby-version }} 24 | - name: Install dependencies 25 | run: bundle install 26 | - name: Install appraisal dependencies 27 | run: bundle exec appraisal install 28 | - name: Run tests 29 | run: bundle exec appraisal rake test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /.idea 11 | *.gem 12 | gemfiles/* -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | DisplayCopNames: true 3 | ExtraDetails: true 4 | TargetRubyVersion: 2.7 5 | 6 | #Metrics/LineLength: 7 | # Max: 120 8 | 9 | Naming/FileName: 10 | Enabled: false 11 | 12 | Style/Documentation: 13 | Enabled: false 14 | 15 | Metrics/ClassLength: 16 | Max: 150 17 | 18 | Metrics/LineLength: 19 | Max: 120 20 | 21 | Metrics/MethodLength: 22 | Max: 30 23 | 24 | Metrics/AbcSize: 25 | Max: 20 26 | 27 | Metrics/CyclomaticComplexity: 28 | Max: 10 29 | 30 | Metrics/PerceivedComplexity: 31 | Max: 10 32 | 33 | Style/HashEachMethods: 34 | Enabled: true 35 | 36 | Style/HashTransformKeys: 37 | Enabled: true 38 | 39 | Style/HashTransformValues: 40 | Enabled: true 41 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise 'haml-4.0' do 2 | gem 'haml', '~> 4.0' 3 | end 4 | 5 | appraise 'haml-5.0' do 6 | gem 'haml', '>= 5.0', '< 5.1' 7 | end 8 | 9 | appraise 'haml-5.1' do 10 | gem 'haml', '>= 5.1', '< 5.2' 11 | end 12 | 13 | appraise 'haml-5.2' do 14 | gem 'haml', '>= 5.2', '< 5.3' 15 | end 16 | 17 | appraise 'haml-6.0' do 18 | gem 'haml', '>= 6.0', '< 6.1' 19 | end 20 | 21 | appraise 'haml-6.1' do 22 | gem 'haml', '>= 6.1', '< 6.2' 23 | end 24 | 25 | appraise 'haml-6.2' do 26 | gem 'haml', '>= 6.2', '< 6.3' 27 | end 28 | 29 | appraise 'haml-6.3' do 30 | gem 'haml', '>= 6.3', '< 6.4' 31 | end 32 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## [Unreleased] 8 | ### Added 9 | 10 | ### Changed 11 | 12 | ### Fixed 13 | 14 | ### Removed 15 | 16 | > _Add your own contributions to the next release on a new line above this; please include your name too._ 17 | > _Please don't set a new version._ 18 | 19 | ## [0.6.0] 20 | ### Fixed 21 | - [#12](https://github.com/daveallie/rails-route-checker/pull/10) - Compatibility with `ActionController::API` - [@emfy0](https://github.com/emfy0) 22 | 23 | ## [0.5.0] 24 | ### Added 25 | - [#11](https://github.com/daveallie/rails-route-checker/pull/11) - Support for Haml 6 - [@stormmaster42](https://github.com/stormmaster42) 26 | 27 | ## [0.4.0] 28 | ### Added 29 | - [#10](https://github.com/daveallie/rails-route-checker/pull/10) - Support for Haml 6.0, 6.1, 6.2 - [@fauresebast](https://github.com/fauresebast) 30 | 31 | ### Chores 32 | - [#9](https://github.com/daveallie/rails-route-checker/pull/9) - Added basic CI tests - [@fauresebast](https://github.com/fauresebast) 33 | 34 | ## [0.3.0] 35 | ### Changed 36 | - [#8](https://github.com/daveallie/rails-route-checker/pull/8) - Ignore Rails default controllers - [@ghiculescu](https://github.com/ghiculescu) 37 | 38 | ## [0.2.9] - 2020-11-26 39 | ### Changed 40 | - [#7](https://github.com/daveallie/rails-route-checker/pull/7) - Support Rails 6.1 - [@ghiculescu](https://github.com/ghiculescu) 41 | 42 | ## [0.2.8] - 2020-11-26 43 | ### Changed 44 | - [#6](https://github.com/daveallie/rails-route-checker/pull/6) - Better error logging - [@ghiculescu](https://github.com/ghiculescu) 45 | 46 | ## [0.2.7] - 2020-10-26 47 | ### Added 48 | - [#5](https://github.com/daveallie/rails-route-checker/pull/5) - Support for Haml 5.2 - [@ghiculescu](https://github.com/ghiculescu) 49 | 50 | ## [0.2.5] - 2019-06-11 51 | ### Added 52 | - Support for Haml 5.1 53 | 54 | ## [0.2.4] - 2018-11-26 55 | ### Fixed 56 | - Crash if using Haml 5 57 | 58 | ## [0.2.3] - 2018-04-15 59 | ### Fixed 60 | - [#3](https://github.com/daveallie/rails-route-checker/pull/3) - Handle implicit rendering by searching for views based on controller's lookup context - [@palkan](https://github.com/palkan) 61 | - [#4](https://github.com/daveallie/rails-route-checker/pull/4) - Correct exit code if there is a single violation - [@ghiculescu](https://github.com/ghiculescu) 62 | 63 | ## [0.2.2] - 2017-12-13 64 | ### Changed 65 | - Replace system bash calls with built-in Ruby methods 66 | 67 | ### Fixed 68 | - Controller whitelist filter now works when parsing view files 69 | 70 | ## [0.2.1] - 2017-12-13 71 | ### Changed 72 | - Removed `L` from files to fix output to make them clickable in newer terminals 73 | 74 | ## [0.2.0] - 2017-12-13 75 | ### Added 76 | - AST parsing for Ruby code (replaces regex searching) 77 | - AST parsing for Haml views (replaces regex searching) 78 | - AST parsing for ERb views 79 | 80 | [Unreleased]: https://github.com/daveallie/rails-route-checker/compare/0.6.0...HEAD 81 | [0.6.0]: https://github.com/daveallie/rails-route-checker/compare/0.5.0...0.6.0 82 | [0.5.0]: https://github.com/daveallie/rails-route-checker/compare/0.4.0...0.5.0 83 | [0.4.0]: https://github.com/daveallie/rails-route-checker/compare/0.3.0...0.4.0 84 | [0.3.0]: https://github.com/daveallie/rails-route-checker/compare/0.2.9...0.3.0 85 | [0.2.9]: https://github.com/daveallie/rails-route-checker/compare/0.2.8...0.2.9 86 | [0.2.8]: https://github.com/daveallie/rails-route-checker/compare/0.2.7...0.2.8 87 | [0.2.7]: https://github.com/daveallie/rails-route-checker/compare/0.2.5...0.2.7 88 | [0.2.5]: https://github.com/daveallie/rails-route-checker/compare/0.2.4...0.2.5 89 | [0.2.4]: https://github.com/daveallie/rails-route-checker/compare/0.2.3...0.2.4 90 | [0.2.3]: https://github.com/daveallie/rails-route-checker/compare/0.2.2...0.2.3 91 | [0.2.2]: https://github.com/daveallie/rails-route-checker/compare/0.2.1...0.2.2 92 | [0.2.1]: https://github.com/daveallie/rails-route-checker/compare/0.2.0...0.2.1 93 | [0.2.0]: https://github.com/daveallie/rails-route-checker/compare/0.1.1...0.2.0 94 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at dave@daveallie.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Dave Allie 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RailsRouteChecker 2 | 3 | A linting tool that helps you find any routes defined in your `routes.rb` file that don't have a corresponding 4 | controller action, and find any `_path` or `_url` calls that don't have a corresponding route in the `routes.rb` file. 5 | 6 | ## Installation 7 | 8 | Add this line to your application's Gemfile: 9 | 10 | ```ruby 11 | gem 'rails-route-checker', require: false 12 | ``` 13 | 14 | And then execute: 15 | 16 | $ bundle 17 | 18 | Or install it yourself as: 19 | 20 | $ gem install rails-route-checker 21 | 22 | ## Usage 23 | 24 | Run `rails-route-checker` from your command line while in the root folder of your Rails application. 25 | 26 | You may also specify a custom config file using the `-c` or `--config` flag. By default, the config file 27 | is search for at `.rails-route-checker.yml`. More information on the config file can be found below. 28 | 29 | `rails-route-checker` will scan controllers along with Haml and ERb view files. 30 | 31 | ``` 32 | bundle exec rails-route-checker 33 | 34 | The following 1 routes are defined, but have no corresponding controller action. 35 | If you have recently added a route to routes.rb, make sure a matching action exists in the controller. 36 | If you have recently removed a controller action, also remove the route in routes.rb. 37 | - oauth_apps/authorizations#show 38 | 39 | 40 | The following 1 url and path methods don't correspond to any route. 41 | - app/controllers/application_controller.rb:L707 - call to potential_url 42 | ``` 43 | 44 | ## Config file 45 | 46 | By default, `rails-route-checker` will look for a config file `.rails-route-checker.yml`. However, you can override 47 | this by using the `--config` command line flag. 48 | 49 | The following is an example config file: 50 | 51 | ```YAML 52 | # Any controllers you don't want to check 53 | ignored_controllers: 54 | - oauth_apps/authorizations 55 | 56 | # Any paths or url methods that you want to be globally ignored 57 | # i.e. confirmation_url and confirmation_path will never be linted against 58 | ignored_paths: 59 | - confirmation 60 | 61 | # For specific files, ignore specific path or url calls 62 | ignored_path_whitelist: 63 | app/controllers/application_controller.rb: 64 | - potential_url 65 | app/views/my_controller/my_view.haml: 66 | - paginate_url 67 | 68 | ``` 69 | 70 | ## Development 71 | 72 | After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 73 | 74 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 75 | 76 | ## Contributing 77 | 78 | Feel free to fork this repo and open a PR. Alongside your changes, please add a line to `CHANGELOG.md`. 79 | 80 | ### Testing 81 | 82 | To test this gem in different envrionments with different gem version (such as the haml gem), we are using [Appraisal](https://github.com/thoughtbot/appraisal). 83 | 84 | First, you need to generate the differents Gemfiles, only needed for the tests: 85 | 86 | ```bash 87 | bundle exec appraisal install 88 | ``` 89 | 90 | Then, to run the tests, you have to use the following command: 91 | 92 | ```bash 93 | bundle exec appraisal rake test 94 | ``` 95 | 96 | ## License 97 | 98 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 99 | 100 | ## Code of Conduct 101 | 102 | Everyone interacting in the Rails::Route::Checker project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/daveallie/rails-route-checker/blob/master/CODE_OF_CONDUCT.md). 103 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rubocop/rake_task' 5 | require "bundler/setup" 6 | require "rake/testtask" 7 | 8 | Rake::TestTask.new do |test| 9 | test.libs << "test" 10 | test.test_files = FileList["test/**/*_test.rb"] 11 | test.warning = true 12 | end 13 | 14 | RuboCop::RakeTask.new(:rubocop) 15 | task default: :rubocop 16 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'rails-route-checker' 6 | 7 | require 'irb' 8 | IRB.start(__FILE__) 9 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /exe/rails-route-checker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'rails-route-checker' 5 | require 'optparse' 6 | 7 | options = {} 8 | OptionParser.new do |parser| 9 | parser.banner = 'Usage: rails-route-checker [options]' 10 | 11 | parser.on('-c', '--config CONFIG_FILE', 'Path to config file') do |path| 12 | unless File.exist?(path) 13 | puts 'Config file does not exist' 14 | exit 1 15 | end 16 | 17 | options[:config_file] = path 18 | end 19 | 20 | parser.on('-h', '--help', 'Prints this help') do 21 | puts parser 22 | exit 23 | end 24 | end.parse! 25 | 26 | options[:config_file] = '.rails-route-checker.yml' if File.exist?('.rails-route-checker.yml') && !options[:config_file] 27 | 28 | rrc = RailsRouteChecker::Runner.new(**options) 29 | puts rrc.output 30 | exit rrc.issues? ? 1 : 0 31 | -------------------------------------------------------------------------------- /lib/rails-route-checker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'rails-route-checker/app_interface' 4 | require_relative 'rails-route-checker/config_file' 5 | require_relative 'rails-route-checker/loaded_app' 6 | require_relative 'rails-route-checker/runner' 7 | require_relative 'rails-route-checker/parsers/loader' 8 | require_relative 'rails-route-checker/version' 9 | 10 | module RailsRouteChecker; end 11 | -------------------------------------------------------------------------------- /lib/rails-route-checker/app_interface.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsRouteChecker 4 | class AppInterface 5 | def initialize(**opts) 6 | @options = { ignored_controllers: [], ignored_paths: [], ignored_path_whitelist: {} }.merge(opts) 7 | end 8 | 9 | def routes_without_actions 10 | loaded_app.routes.map do |r| 11 | controller = r.requirements[:controller] 12 | action = r.requirements[:action] 13 | 14 | next if options[:ignored_controllers].include?(controller) 15 | next if controller_has_action?(controller, action) 16 | 17 | { 18 | controller: controller, 19 | action: action 20 | } 21 | end.compact 22 | end 23 | 24 | def undefined_path_method_calls 25 | generate_undef_view_path_calls + generate_undef_controller_path_calls 26 | end 27 | 28 | private 29 | 30 | attr_reader :options 31 | 32 | def loaded_app 33 | @loaded_app ||= RailsRouteChecker::LoadedApp.new 34 | end 35 | 36 | def controller_information 37 | @controller_information ||= loaded_app.controller_information.reject do |path, _| 38 | options[:ignored_controllers].include?(path) 39 | end 40 | end 41 | 42 | def controller_has_action?(controller, action) 43 | return false unless controller_information.key?(controller) 44 | 45 | info = controller_information[controller] 46 | return true if info[:actions].include?(action) 47 | return true if info[:lookup_context]&.template_exists?("#{controller}/#{action}") 48 | 49 | false 50 | end 51 | 52 | def generate_undef_view_path_calls 53 | generate_undef_view_path_calls_erb + generate_undef_view_path_calls_haml 54 | end 55 | 56 | def generate_undef_view_path_calls_erb 57 | files = Dir['app/**/*.erb'] 58 | return [] if files.none? 59 | 60 | RailsRouteChecker::Parsers::Loader.load_parser(:erb) 61 | 62 | files.map do |filename| 63 | controller = controller_from_view_file(filename) 64 | next unless controller # controller will be nil if it's an ignored controller 65 | 66 | filter = lambda do |path_or_url| 67 | return false if match_in_whitelist?(filename, path_or_url) 68 | return false if match_defined_in_view?(controller, path_or_url) 69 | 70 | true 71 | end 72 | 73 | RailsRouteChecker::Parsers::ErbParser.run(filename, filter: filter) 74 | end.flatten.compact 75 | end 76 | 77 | def generate_undef_view_path_calls_haml 78 | files = Dir['app/**/*.haml'] 79 | return [] if files.none? 80 | 81 | unless RailsRouteChecker::Parsers::Loader.haml_available? 82 | puts 'WARNING: There are Haml files in your codebase, ' \ 83 | "but the Haml parser for rails-route-checker couldn't load!" 84 | return [] 85 | end 86 | 87 | RailsRouteChecker::Parsers::Loader.load_parser(:haml) 88 | 89 | files.map do |filename| 90 | controller = controller_from_view_file(filename) 91 | next unless controller # controller will be nil if it's an ignored controller 92 | 93 | filter = lambda do |path_or_url| 94 | return false if match_in_whitelist?(filename, path_or_url) 95 | return false if match_defined_in_view?(controller, path_or_url) 96 | 97 | true 98 | end 99 | 100 | RailsRouteChecker::Parsers::HamlParser.run(filename, filter: filter) 101 | end.flatten.compact 102 | end 103 | 104 | def generate_undef_controller_path_calls 105 | files = Dir['app/controllers/**/*.rb'] 106 | return [] if files.none? 107 | 108 | RailsRouteChecker::Parsers::Loader.load_parser(:ruby) 109 | 110 | files.map do |filename| 111 | controller = controller_from_ruby_file(filename) 112 | next unless controller # controller will be nil if it's an ignored controller 113 | 114 | filter = lambda do |path_or_url| 115 | return false if match_in_whitelist?(filename, path_or_url) 116 | return false if match_defined_in_ruby?(controller, path_or_url) 117 | 118 | return true 119 | end 120 | 121 | RailsRouteChecker::Parsers::RubyParser.run(filename, filter: filter) 122 | end.flatten.compact 123 | end 124 | 125 | def match_in_whitelist?(filename, path_or_url) 126 | possible_route_name = path_or_url.sub(/_(?:url|path)$/, '') 127 | return true if options[:ignored_paths].include?(possible_route_name) 128 | 129 | (options[:ignored_path_whitelist][filename] || []).include?(path_or_url) 130 | end 131 | 132 | def match_defined_in_view?(controller, path_or_url) 133 | possible_route_name = path_or_url.sub(/_(?:url|path)$/, '') 134 | return true if loaded_app.all_route_names.include?(possible_route_name) 135 | 136 | controller && controller[:helpers].include?(path_or_url) 137 | end 138 | 139 | def match_defined_in_ruby?(controller, path_or_url) 140 | possible_route_name = path_or_url.sub(/_(?:url|path)$/, '') 141 | return true if loaded_app.all_route_names.include?(possible_route_name) 142 | 143 | controller && controller[:instance_methods].include?(path_or_url) 144 | end 145 | 146 | def controller_from_view_file(filename) 147 | split_path = filename.split('/') 148 | possible_controller_path = split_path[(split_path.index('app') + 2)..-2] 149 | 150 | while possible_controller_path.any? 151 | controller_name = possible_controller_path.join('/') 152 | return controller_information[controller_name] if controller_exists?(controller_name) 153 | 154 | possible_controller_path = possible_controller_path[0..-2] 155 | end 156 | controller_information['application'] 157 | end 158 | 159 | def controller_from_ruby_file(filename) 160 | controller_name = (filename.match(%r{app/controllers/(.*)_controller.rb}) || [])[1] 161 | return controller_information[controller_name] if controller_exists?(controller_name) 162 | 163 | controller_information['application'] 164 | end 165 | 166 | def controller_exists?(controller_name) 167 | return false unless controller_name 168 | 169 | File.exist?("app/controllers/#{controller_name}_controller.rb") 170 | end 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /lib/rails-route-checker/config_file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsRouteChecker 4 | class ConfigFile 5 | def initialize(filename) 6 | @filename = filename 7 | end 8 | 9 | def config 10 | @config ||= begin 11 | hash = load_yaml_file 12 | { 13 | ignored_controllers: hash['ignored_controllers'] || [], 14 | ignored_paths: hash['ignored_paths'] || [], 15 | ignored_path_whitelist: hash['ignored_path_whitelist'] || [] 16 | } 17 | end 18 | end 19 | 20 | private 21 | 22 | attr_reader :filename 23 | 24 | def load_yaml_file 25 | require 'yaml' 26 | YAML.safe_load(File.read(filename)) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/rails-route-checker/loaded_app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsRouteChecker 4 | class LoadedApp 5 | def initialize 6 | app_base_path = Dir.pwd 7 | suppress_output do 8 | require_relative "#{app_base_path}/config/boot" 9 | end 10 | 11 | begin 12 | suppress_output do 13 | require_relative "#{Dir.pwd}/config/environment" 14 | end 15 | rescue Exception => e 16 | puts "Requiring your config/environment.rb file failed." 17 | puts "This means that something raised while trying to start Rails." 18 | puts "" 19 | puts e.backtrace 20 | raise(e) 21 | end 22 | 23 | suppress_output do 24 | @app = Rails.application 25 | @app.eager_load! 26 | Rails::Engine.subclasses.each(&:eager_load!) 27 | end 28 | end 29 | 30 | def routes 31 | return @routes if defined?(@routes) 32 | 33 | @routes = app.routes.routes.reject do |r| 34 | reject_route?(r) 35 | end.uniq 36 | 37 | return @routes unless app.config.respond_to?(:assets) 38 | 39 | use_spec = defined?(ActionDispatch::Journey::Route) || defined?(Journey::Route) 40 | @routes.reject do |route| 41 | path = use_spec ? route.path.spec.to_s : route.path 42 | path =~ /^#{app.config.assets.prefix}/ 43 | end 44 | end 45 | 46 | def all_route_names 47 | @all_route_names ||= app.routes.routes.map(&:name).compact 48 | end 49 | 50 | def controller_information 51 | return @controller_information if @controller_information 52 | 53 | base_controllers_descendants = [ActionController::Base, ActionController::API].flat_map(&:descendants) 54 | 55 | @controller_information = base_controllers_descendants.map do |controller| 56 | next if controller.controller_path.nil? || controller.controller_path.start_with?('rails/') 57 | 58 | controller_helper_methods = 59 | if controller.respond_to?(:helpers) 60 | controller.helpers.methods.map(&:to_s) 61 | else 62 | [] 63 | end 64 | 65 | [ 66 | controller.controller_path, 67 | { 68 | helpers: controller_helper_methods, 69 | actions: controller.action_methods.to_a, 70 | instance_methods: instance_methods(controller), 71 | lookup_context: lookup_context(controller) 72 | } 73 | ] 74 | end.compact.to_h 75 | end 76 | 77 | private 78 | 79 | attr_reader :app 80 | 81 | def lookup_context(controller) 82 | return nil unless controller.instance_methods.include?(:default_render) 83 | 84 | ActionView::LookupContext.new(controller._view_paths, {}, controller._prefixes) 85 | end 86 | 87 | def instance_methods(controller) 88 | (controller.instance_methods.map(&:to_s) + controller.private_instance_methods.map(&:to_s)).compact.uniq 89 | end 90 | 91 | def suppress_output 92 | begin 93 | original_stderr = $stderr.clone 94 | original_stdout = $stdout.clone 95 | $stderr.reopen(File.new('/dev/null', 'w')) 96 | $stdout.reopen(File.new('/dev/null', 'w')) 97 | retval = yield 98 | rescue Exception => e # rubocop:disable Lint/RescueException 99 | $stdout.reopen(original_stdout) 100 | $stderr.reopen(original_stderr) 101 | raise e 102 | ensure 103 | $stdout.reopen(original_stdout) 104 | $stderr.reopen(original_stderr) 105 | end 106 | retval 107 | end 108 | 109 | def reject_route?(route) 110 | return true if route.name.nil? && route.requirements.blank? 111 | return true if route.app.is_a?(ActionDispatch::Routing::Mapper::Constraints) && 112 | route.app.app.respond_to?(:call) 113 | return true if route.app.is_a?(ActionDispatch::Routing::Redirect) 114 | 115 | controller = route.requirements[:controller] 116 | action = route.requirements[:action] 117 | return true unless controller && action 118 | return true if controller.start_with?('rails/') 119 | 120 | false 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /lib/rails-route-checker/parsers/erb_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsRouteChecker 4 | module Parsers 5 | module ErbParser 6 | class << self 7 | def run(filename, **opts) 8 | file_source = opts[:source] || File.read(filename) 9 | 10 | opts.merge!(process_file(file_source)) 11 | 12 | RailsRouteChecker::Parsers::RubyParser.run(filename, **opts) 13 | end 14 | 15 | private 16 | 17 | def process_line(line) 18 | lookup_index = 0 19 | ruby_lines = [] 20 | 21 | while lookup_index < line.length 22 | opening = line.index('<%=', lookup_index) 23 | is_write_opening = opening 24 | opening ||= line.index('<%', lookup_index) 25 | break unless opening 26 | 27 | closing = line.index('%>', opening + 2) 28 | break unless closing 29 | 30 | ruby_lines << line[(opening + (is_write_opening ? 3 : 2))..(closing - 1)] 31 | lookup_index = closing + 2 32 | end 33 | ruby_lines 34 | end 35 | 36 | def process_file(source) 37 | next_ruby_source_line_num = 1 38 | ruby_source = '' 39 | source_map = {} 40 | 41 | source.split("\n").each_with_index do |line, line_num| 42 | ruby_lines = process_line(line) 43 | next unless ruby_lines.any? 44 | 45 | ruby_source += ruby_lines.join("\n") + "\n" 46 | ruby_lines.length.times do |i| 47 | source_map[next_ruby_source_line_num + i] = line_num + 1 48 | end 49 | next_ruby_source_line_num += ruby_lines.length 50 | end 51 | 52 | { 53 | source: ruby_source, 54 | source_map: source_map 55 | } 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/rails-route-checker/parsers/haml_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'haml_parser/document' 4 | require_relative 'haml_parser/tree/node' 5 | require_relative 'haml_parser/tree/filter_node' 6 | require_relative 'haml_parser/tree/root_node' 7 | require_relative 'haml_parser/tree/script_node' 8 | require_relative 'haml_parser/tree/silent_script_node' 9 | require_relative 'haml_parser/tree/tag_node' 10 | require_relative 'haml_parser/ruby_extractor' 11 | 12 | module RailsRouteChecker 13 | module Parsers 14 | module HamlParser 15 | class << self 16 | def run(filename, **opts) 17 | file_source = opts[:source] || File.read(filename) 18 | 19 | document = RailsRouteChecker::Parsers::HamlParser::Document.new(file_source) 20 | extracted_ruby = RailsRouteChecker::Parsers::HamlParser::RubyExtractor.extract(document) 21 | 22 | opts[:source] = extracted_ruby.source 23 | opts[:source_map] = extracted_ruby.source_map 24 | 25 | RailsRouteChecker::Parsers::RubyParser.run(filename, **opts) 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/rails-route-checker/parsers/haml_parser/document.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsRouteChecker 4 | module Parsers 5 | module HamlParser 6 | class Document 7 | attr_reader :tree, :source, :source_lines 8 | 9 | def initialize(source) 10 | @source = source.force_encoding(Encoding::UTF_8) 11 | @source_lines = @source.split(/\r\n|\r|\n/) 12 | 13 | version = Gem::Version.new(Haml::VERSION).approximate_recommendation 14 | 15 | original_tree = case version 16 | when '~> 4.0', '~> 4.1' 17 | options = Haml::Options.new 18 | Haml::Parser.new(@source, options).parse 19 | when '~> 5.0', '~> 5.1', '~> 5.2' 20 | options = Haml::Options.new 21 | Haml::Parser.new(options).call(@source) 22 | when '~> 6.0', '~> 6.1', '~> 6.2', '~> 6.3' 23 | Haml::Parser.new({}).call(@source) 24 | else 25 | raise "Cannot handle Haml version: #{version}" 26 | end 27 | 28 | @tree = process_tree(original_tree) 29 | end 30 | 31 | private 32 | 33 | def process_tree(original_tree) 34 | original_tree.children.pop if Gem::Requirement.new('~> 4.0.0').satisfied_by?(Gem.loaded_specs['haml'].version) 35 | 36 | convert_tree(original_tree) 37 | end 38 | 39 | def convert_tree(haml_node, parent = nil) 40 | node_class_name = "#{haml_node.type.to_s.split(/[-_ ]/).collect(&:capitalize).join}Node" 41 | node_class_name = 'Node' unless RailsRouteChecker::Parsers::HamlParser::Tree.const_defined?(node_class_name) 42 | 43 | new_node = RailsRouteChecker::Parsers::HamlParser::Tree.const_get(node_class_name).new(self, haml_node) 44 | new_node.parent = parent 45 | 46 | new_node.children = haml_node.children.map do |child| 47 | convert_tree(child, new_node) 48 | end 49 | 50 | new_node 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/rails-route-checker/parsers/haml_parser/ruby_extractor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsRouteChecker 4 | module Parsers 5 | module HamlParser 6 | class RubyExtractor 7 | RubySource = Struct.new(:source, :source_map) 8 | 9 | def self.extract(document) 10 | new(document).extract 11 | end 12 | 13 | def initialize(document) 14 | @document = document 15 | end 16 | 17 | def extract 18 | @source_lines = [] 19 | @source_map = {} 20 | @line_count = 0 21 | @indent_level = 0 22 | @output_count = 0 23 | 24 | visit_children(document.tree) 25 | 26 | RubySource.new(@source_lines.join("\n"), @source_map) 27 | end 28 | 29 | def visit_tag(node) 30 | additional_attributes = node.dynamic_attributes_sources 31 | 32 | additional_attributes.each do |attributes_code| 33 | attributes_code = attributes_code.gsub(/\s*\n\s*/, ' ').strip 34 | add_line("{}.merge(#{attributes_code.strip})", node) 35 | end 36 | 37 | if node.hash_attributes? && node.dynamic_attributes_sources.empty? 38 | normalized_attr_source = node.dynamic_attributes_source[:hash].gsub(/\s*\n\s*/, ' ') 39 | 40 | add_line(normalized_attr_source, node) 41 | end 42 | 43 | code = node.script.strip 44 | add_line(code, node) unless code.empty? 45 | end 46 | 47 | def visit_script(node) 48 | code = node.text 49 | add_line(code.strip, node) 50 | 51 | start_block = anonymous_block?(code) || start_block_keyword?(code) 52 | 53 | @indent_level += 1 if start_block 54 | 55 | yield 56 | 57 | return unless start_block 58 | 59 | @indent_level -= 1 60 | add_line('end', node) 61 | end 62 | 63 | def visit_filter(node) 64 | return unless node.filter_type == 'ruby' 65 | 66 | node.text.split("\n").each_with_index do |line, index| 67 | add_line(line, node.line + index + 1, false) 68 | end 69 | end 70 | 71 | def visit(node) 72 | block_called = false 73 | 74 | block = lambda do |descend = :children| 75 | block_called = true 76 | visit_children(node) if descend == :children 77 | end 78 | 79 | case node.type 80 | when :tag 81 | visit_tag(node) 82 | when :script, :silent_script 83 | visit_script(node, &block) 84 | when :filter 85 | visit_filter(node) 86 | end 87 | 88 | visit_children(node) unless block_called 89 | end 90 | 91 | def visit_children(parent) 92 | parent.children.each { |node| visit(node) } 93 | end 94 | 95 | private 96 | 97 | attr_reader :document 98 | 99 | def add_line(code, node_or_line, discard_blanks = true) 100 | return if code.empty? && discard_blanks 101 | 102 | indent_level = @indent_level 103 | 104 | if node_or_line.respond_to?(:line) 105 | indent_level -= 1 if mid_block_keyword?(code) 106 | end 107 | 108 | indent = (' ' * 2 * indent_level) 109 | 110 | @source_lines << indent_code(code, indent) 111 | 112 | original_line = 113 | node_or_line.respond_to?(:line) ? node_or_line.line : node_or_line 114 | 115 | (code.count("\n") + 1).times do 116 | @line_count += 1 117 | @source_map[@line_count] = original_line 118 | end 119 | end 120 | 121 | def indent_code(code, indent) 122 | codes = code.split("\n") 123 | codes.map { |c| indent + c }.join("\n") 124 | end 125 | 126 | def anonymous_block?(text) 127 | text =~ /\bdo\s*(\|\s*[^\|]*\s*\|)?(\s*#.*)?\z/ 128 | end 129 | 130 | START_BLOCK_KEYWORDS = %w[if unless case begin for until while].freeze 131 | def start_block_keyword?(text) 132 | START_BLOCK_KEYWORDS.include?(block_keyword(text)) 133 | end 134 | 135 | MID_BLOCK_KEYWORDS = %w[else elsif when rescue ensure].freeze 136 | def mid_block_keyword?(text) 137 | MID_BLOCK_KEYWORDS.include?(block_keyword(text)) 138 | end 139 | 140 | LOOP_KEYWORDS = %w[for until while].freeze 141 | def block_keyword(text) 142 | # Need to handle 'for'/'while' since regex stolen from HAML parser doesn't 143 | keyword = text[/\A\s*([^\s]+)\s+/, 1] 144 | return keyword if keyword && LOOP_KEYWORDS.include?(keyword) 145 | 146 | keyword = text.scan(Haml::Parser::BLOCK_KEYWORD_REGEX)[0] 147 | return unless keyword 148 | 149 | keyword[0] || keyword[1] 150 | end 151 | end 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /lib/rails-route-checker/parsers/haml_parser/tree/filter_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsRouteChecker 4 | module Parsers 5 | module HamlParser 6 | module Tree 7 | class FilterNode < Node 8 | def filter_type 9 | @value[:name] 10 | end 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/rails-route-checker/parsers/haml_parser/tree/node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsRouteChecker 4 | module Parsers 5 | module HamlParser 6 | module Tree 7 | class Node 8 | include Enumerable 9 | 10 | attr_accessor :children, :parent 11 | attr_reader :line, :type 12 | 13 | def initialize(document, parse_node) 14 | @line = parse_node.line 15 | @document = document 16 | @value = parse_node.value 17 | @type = parse_node.type 18 | end 19 | 20 | def each 21 | return to_enum(__callee__) unless block_given? 22 | 23 | node = self 24 | loop do 25 | yield node 26 | break unless (node = node.next_node) 27 | end 28 | end 29 | 30 | def directives 31 | directives = [] 32 | directives << predecessor.directives if predecessor 33 | directives.flatten 34 | end 35 | 36 | def source_code 37 | next_node_line = 38 | if next_node 39 | next_node.line - 1 40 | else 41 | @document.source_lines.count + 1 42 | end 43 | 44 | @document.source_lines[@line - 1...next_node_line] 45 | .join("\n") 46 | .gsub(/^\s*\z/m, '') 47 | end 48 | 49 | def inspect 50 | "#<#{self.class.name}>" 51 | end 52 | 53 | def lines 54 | return [] unless @value && text 55 | 56 | text.split(/\r\n|\r|\n/) 57 | end 58 | 59 | def line_numbers 60 | return (line..line) unless @value && text 61 | 62 | (line..line + lines.count) 63 | end 64 | 65 | def predecessor 66 | siblings.previous(self) || parent 67 | end 68 | 69 | def successor 70 | next_sibling = siblings.next(self) 71 | return next_sibling if next_sibling 72 | 73 | parent&.successor 74 | end 75 | 76 | def next_node 77 | children.first || successor 78 | end 79 | 80 | def subsequents 81 | siblings.subsequents(self) 82 | end 83 | 84 | def text 85 | @value[:text].to_s 86 | end 87 | 88 | private 89 | 90 | def siblings 91 | @siblings ||= Siblings.new(parent ? parent.children : [self]) 92 | end 93 | 94 | class Siblings < SimpleDelegator 95 | def next(node) 96 | subsequents(node).first 97 | end 98 | 99 | def previous(node) 100 | priors(node).last 101 | end 102 | 103 | def priors(node) 104 | position = position(node) 105 | if position.zero? 106 | [] 107 | else 108 | siblings[0..(position - 1)] 109 | end 110 | end 111 | 112 | def subsequents(node) 113 | siblings[(position(node) + 1)..-1] 114 | end 115 | 116 | private 117 | 118 | alias siblings __getobj__ 119 | 120 | def position(node) 121 | siblings.index(node) 122 | end 123 | end 124 | end 125 | end 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /lib/rails-route-checker/parsers/haml_parser/tree/root_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsRouteChecker 4 | module Parsers 5 | module HamlParser 6 | module Tree 7 | class RootNode < Node 8 | def file 9 | @document.file 10 | end 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/rails-route-checker/parsers/haml_parser/tree/script_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsRouteChecker 4 | module Parsers 5 | module HamlParser 6 | module Tree 7 | class ScriptNode < Node 8 | def script 9 | @value[:text] 10 | end 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/rails-route-checker/parsers/haml_parser/tree/silent_script_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsRouteChecker 4 | module Parsers 5 | module HamlParser 6 | module Tree 7 | class SilentScriptNode < Node 8 | def script 9 | @value[:text] 10 | end 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/rails-route-checker/parsers/haml_parser/tree/tag_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsRouteChecker 4 | module Parsers 5 | module HamlParser 6 | module Tree 7 | class TagNode < Node 8 | def dynamic_attributes_sources 9 | @dynamic_attributes_sources ||= 10 | if Gem::Version.new(Haml::VERSION) < Gem::Version.new('5') 11 | @value[:attributes_hashes] 12 | else 13 | Array(@value[:dynamic_attributes].to_literal).reject(&:empty?) 14 | end 15 | end 16 | 17 | def dynamic_attributes_source 18 | @dynamic_attributes_source ||= 19 | attributes_source.reject { |key| key == :static } 20 | end 21 | 22 | def attributes_source 23 | @attr_source ||= # rubocop:disable Naming/MemoizedInstanceVariableName 24 | begin 25 | _explicit_tag, static_attrs, rest = 26 | source_code.scan(/\A\s*(%[-:\w]+)?([-:\w\.\#]*)(.*)/m)[0] 27 | 28 | attr_types = { 29 | '{' => [:hash, %w[{ }]], 30 | '(' => [:html, %w[( )]], 31 | '[' => [:object_ref, %w[[ ]]] 32 | } 33 | 34 | attr_source = { static: static_attrs } 35 | while rest 36 | type, chars = attr_types[rest[0]] 37 | break unless type 38 | break if attr_source[type] 39 | 40 | attr_source[type], rest = Haml::Util.balance(rest, *chars) 41 | end 42 | 43 | attr_source 44 | end 45 | end 46 | 47 | def hash_attributes? 48 | !dynamic_attributes_source[:hash].nil? 49 | end 50 | 51 | def script 52 | (@value[:value] if @value[:parse]) || '' 53 | end 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/rails-route-checker/parsers/loader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsRouteChecker 4 | module Parsers 5 | module Loader 6 | class << self 7 | def load_parser(type) 8 | case type 9 | when :ruby 10 | load_basic_parser(:ruby) 11 | when :erb 12 | load_basic_parser(:ruby) 13 | load_basic_parser(:erb) 14 | when :haml 15 | if haml_available? 16 | load_basic_parser(:ruby) 17 | load_haml_parser 18 | end 19 | else 20 | raise "Unrecognised parser attempting to be loaded: #{type}" 21 | end 22 | end 23 | 24 | def haml_available? 25 | return @haml_available if defined?(@haml_available) 26 | 27 | @haml_available = gem_installed?('haml') 28 | end 29 | 30 | private 31 | 32 | def gem_installed?(name, version_requirement = nil) 33 | Gem::Dependency.new(name, version_requirement).matching_specs.any? 34 | end 35 | 36 | def load_basic_parser(parser_name) 37 | if_unloaded(parser_name) do 38 | require_relative "#{parser_name}_parser" 39 | end 40 | end 41 | 42 | def load_haml_parser 43 | if_unloaded(:haml) do 44 | require 'haml' 45 | require_relative 'haml_parser' 46 | end 47 | end 48 | 49 | def if_unloaded(parser) 50 | @loaded_parsers ||= {} 51 | return false if @loaded_parsers[parser] 52 | 53 | yield 54 | @loaded_parsers[parser] = true 55 | end 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/rails-route-checker/parsers/ruby_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ripper' 4 | 5 | module RailsRouteChecker 6 | module Parsers 7 | module RubyParser 8 | class << self 9 | def run(filename, **opts) 10 | file_source = opts[:source] || File.read(filename) 11 | process_file(filename, file_source, opts) 12 | end 13 | 14 | private 15 | 16 | def process_file(filename, source, opts) 17 | items = [] 18 | 19 | deep_iterator(Ripper.sexp(source)) do |item, extra_data| 20 | next unless item_is_url_call?(item, extra_data) 21 | next if opts[:filter].respond_to?(:call) && !opts[:filter].call(item) 22 | 23 | line = extra_data[:position][0] 24 | line = opts[:source_map][line] || 'unknown' if opts[:source_map] 25 | 26 | items << { file: filename, line: line, method: item } 27 | end 28 | 29 | items 30 | end 31 | 32 | def item_is_url_call?(item, extra_data) 33 | scope = extra_data[:scope] 34 | return false unless %i[vcall fcall].include?(scope[-2]) 35 | return false unless scope[-1] == :@ident 36 | return false unless item.end_with?('_path', '_url') 37 | 38 | true 39 | end 40 | 41 | def deep_iterator(list, current_scope = [], current_line_num = [], &block) 42 | return deep_iterate_array(list, current_scope, current_line_num, &block) if list.is_a?(Array) 43 | 44 | yield(list, { scope: current_scope, position: current_line_num }) unless list.nil? 45 | end 46 | 47 | def deep_iterate_array(list, current_scope, current_line_num, &block) 48 | unless list[0].is_a?(Symbol) 49 | list.each do |item| 50 | deep_iterator(item, current_scope, current_line_num, &block) 51 | end 52 | return 53 | end 54 | 55 | current_scope << list[0] 56 | 57 | last_list_item = list[-1] 58 | if last_list_item.is_a?(Array) && 59 | last_list_item.length == 2 && 60 | last_list_item.all? { |item| item.is_a?(Integer) } 61 | current_line_num = last_list_item 62 | list = list[0..-2] 63 | end 64 | 65 | list[1..-1].each do |item| 66 | deep_iterator(item, current_scope, current_line_num, &block) 67 | end 68 | current_scope.pop 69 | end 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/rails-route-checker/runner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsRouteChecker 4 | class Runner 5 | def initialize(**opts) 6 | @options = { ignored_controllers: [], ignored_paths: [], ignored_path_whitelist: {} } 7 | @options.merge!(RailsRouteChecker::ConfigFile.new(opts[:config_file]).config) if opts[:config_file] 8 | @options.merge!(opts) 9 | end 10 | 11 | def issues 12 | @issues ||= { 13 | missing_actions: app_interface.routes_without_actions, 14 | missing_routes: app_interface.undefined_path_method_calls 15 | } 16 | end 17 | 18 | def issues? 19 | issues.values.flatten(1).count.positive? 20 | end 21 | 22 | def output 23 | output_lines = [] 24 | output_lines += missing_actions_output if issues[:missing_actions].any? 25 | if issues[:missing_routes].any? 26 | output_lines << "\n" if output_lines.any? 27 | output_lines += missing_routes_output 28 | end 29 | output_lines = ['All good in the hood'] if output_lines.empty? 30 | output_lines.join("\n") 31 | end 32 | 33 | private 34 | 35 | def app_interface 36 | @app_interface ||= RailsRouteChecker::AppInterface.new(**@options) 37 | end 38 | 39 | def missing_actions_output 40 | [ 41 | "The following #{issues[:missing_actions].count} routes are defined, " \ 42 | 'but have no corresponding controller action.', 43 | 'If you have recently added a route to routes.rb, make sure a matching action exists in the controller.', 44 | 'If you have recently removed a controller action, also remove the route in routes.rb.', 45 | *issues[:missing_actions].map { |r| " - #{r[:controller]}##{r[:action]}" } 46 | ] 47 | end 48 | 49 | def missing_routes_output 50 | [ 51 | "The following #{issues[:missing_routes].count} url and path methods don't correspond to any route.", 52 | *issues[:missing_routes].map { |line| " - #{line[:file]}:#{line[:line]} - call to #{line[:method]}" } 53 | ] 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/rails-route-checker/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsRouteChecker 4 | VERSION = '0.6.0' 5 | end 6 | -------------------------------------------------------------------------------- /rails-route-checker.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'rails-route-checker/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'rails-route-checker' 9 | spec.version = RailsRouteChecker::VERSION 10 | spec.authors = ['Dave Allie'] 11 | spec.email = ['dave@daveallie.com'] 12 | 13 | spec.summary = 'A linting tool for your Rails routes' 14 | spec.description = 'A linting tool that helps you find any routes defined in your routes.rb file that ' \ 15 | "don't have a corresponding controller action, and find any _path or _url calls that don't " \ 16 | 'have a corresponding route in the routes.rb file.' 17 | spec.homepage = 'https://github.com/daveallie/rails-route-checker' 18 | spec.license = 'MIT' 19 | 20 | spec.files = Dir['exe/*'] + Dir['lib/**/*'] + 21 | %w[Gemfile rails-route-checker.gemspec] 22 | spec.bindir = 'exe' 23 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 24 | spec.require_paths = ['lib'] 25 | 26 | spec.add_dependency 'rails' 27 | 28 | spec.add_development_dependency 'bundler', '~> 2.1' 29 | spec.add_development_dependency 'rake', '~> 13.0' 30 | spec.add_development_dependency 'rubocop', '~> 0.86' 31 | spec.add_development_dependency 'appraisal', '~> 2.5.0' 32 | spec.add_development_dependency 'minitest' 33 | spec.add_development_dependency 'propshaft' 34 | end 35 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/articles_api_controller.rb: -------------------------------------------------------------------------------- 1 | class ArticlesApiController < BaseApiController 2 | def index 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/articles_controller.rb: -------------------------------------------------------------------------------- 1 | class ArticlesController < ApplicationController 2 | def index_erb 3 | end 4 | 5 | def index_haml 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/base_api_controller.rb: -------------------------------------------------------------------------------- 1 | class BaseApiController < ActionController::API 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/views/articles/index_erb.html.erb: -------------------------------------------------------------------------------- 1 |
This is an .erb file
3 |John
6 |Doe
7 |Likes Ruby
10 |Find me in app/views/sample/load_nonexistent_assets.html.erb
50 | 51 | app/views/sample/load_nonexistent_assets.html.erb:1 52 | Started GET "/sample/load_real_assets" for 127.0.0.1 at 2023-09-11 12:47:08 +0200 53 | Processing by SampleController#load_real_assets as HTML 54 | Rendering layout layouts/application.html.erb 55 | Rendering sample/load_real_assets.html.erb within layouts/application 56 | Rendered sample/load_real_assets.html.erb within layouts/application (Duration: 0.4ms | Allocations: 120) 57 | Rendered layout layouts/application.html.erb (Duration: 3.7ms | Allocations: 522) 58 | Completed 200 OK in 291ms (Views: 4.4ms | Allocations: 746) 59 | 60 | 61 | Started GET "/sample/load_real_assets" for 127.0.0.1 at 2023-09-11 12:49:10 +0200 62 | Processing by SampleController#load_real_assets as HTML 63 | Rendering layout layouts/application.html.erb 64 | Rendering sample/load_real_assets.html.erb within layouts/application 65 | Rendered sample/load_real_assets.html.erb within layouts/application (Duration: 0.3ms | Allocations: 120) 66 | Rendered layout layouts/application.html.erb (Duration: 1.3ms | Allocations: 510) 67 | Completed 200 OK in 316ms (Views: 2.7ms | Allocations: 793) 68 | 69 | 70 | Started GET "/sample/load_real_assets" for 127.0.0.1 at 2023-09-11 12:49:29 +0200 71 | Processing by SampleController#load_real_assets as HTML 72 | Rendering layout layouts/application.html.erb 73 | Rendering sample/load_real_assets.html.erb within layouts/application 74 | Rendered sample/load_real_assets.html.erb within layouts/application (Duration: 35.1ms | Allocations: 1786) 75 | Rendered layout layouts/application.html.erb (Duration: 51.8ms | Allocations: 2441) 76 | Completed 200 OK in 533ms (Views: 64.8ms | Allocations: 5335) 77 | 78 | 79 | Started GET "/sample/load_real_assets" for 127.0.0.1 at 2023-09-11 12:49:45 +0200 80 | Processing by SampleController#load_real_assets as HTML 81 | Rendering layout layouts/application.html.erb 82 | Rendering sample/load_real_assets.html.erb within layouts/application 83 | Rendered sample/load_real_assets.html.erb within layouts/application (Duration: 0.2ms | Allocations: 120) 84 | Rendered layout layouts/application.html.erb (Duration: 0.9ms | Allocations: 516) 85 | Completed 200 OK in 245ms (Views: 1.5ms | Allocations: 742) 86 | 87 | 88 | Started GET "/" for 127.0.0.1 at 2023-09-11 12:49:51 +0200 89 | Processing by Rails::WelcomeController#index as HTML 90 | Rendering /home/fauresebast/.asdf/installs/ruby/2.7.6/lib/ruby/gems/2.7.0/gems/railties-7.0.2.2/lib/rails/templates/rails/welcome/index.html.erb 91 | Rendered /home/fauresebast/.asdf/installs/ruby/2.7.6/lib/ruby/gems/2.7.0/gems/railties-7.0.2.2/lib/rails/templates/rails/welcome/index.html.erb (Duration: 0.7ms | Allocations: 355) 92 | Completed 200 OK in 212ms (Views: 1.8ms | Allocations: 1068) 93 | 94 | 95 | Started GET "/" for 127.0.0.1 at 2023-09-11 12:50:22 +0200 96 | Processing by Rails::WelcomeController#index as HTML 97 | Rendering /home/fauresebast/.asdf/installs/ruby/2.7.6/lib/ruby/gems/2.7.0/gems/railties-7.0.2.2/lib/rails/templates/rails/welcome/index.html.erb 98 | Rendered /home/fauresebast/.asdf/installs/ruby/2.7.6/lib/ruby/gems/2.7.0/gems/railties-7.0.2.2/lib/rails/templates/rails/welcome/index.html.erb (Duration: 0.7ms | Allocations: 346) 99 | Completed 200 OK in 248ms (Views: 2.4ms | Allocations: 1070) 100 | 101 | 102 | Started GET "/sample/load_real_assets" for 127.0.0.1 at 2023-09-11 12:50:32 +0200 103 | Processing by SampleController#load_real_assets as HTML 104 | Rendering layout layouts/application.html.erb 105 | Rendering sample/load_real_assets.html.erb within layouts/application 106 | Rendered sample/load_real_assets.html.erb within layouts/application (Duration: 27.3ms | Allocations: 1786) 107 | Rendered layout layouts/application.html.erb (Duration: 40.9ms | Allocations: 2439) 108 | Completed 200 OK in 567ms (Views: 51.9ms | Allocations: 5332) 109 | 110 | 111 | Started GET "/sample/load_real_assets" for 127.0.0.1 at 2023-09-11 12:50:41 +0200 112 | Processing by SampleController#load_real_assets as HTML 113 | Rendering layout layouts/application.html.erb 114 | Rendering sample/load_real_assets.html.erb within layouts/application 115 | Rendered sample/load_real_assets.html.erb within layouts/application (Duration: 0.4ms | Allocations: 120) 116 | Rendered layout layouts/application.html.erb (Duration: 3.8ms | Allocations: 504) 117 | Completed 200 OK in 317ms (Views: 4.7ms | Allocations: 730) 118 | 119 | 120 | Started GET "/sample/load_real_assets" for 127.0.0.1 at 2023-09-11 12:50:47 +0200 121 | Processing by SampleController#load_real_assets as HTML 122 | Rendering layout layouts/application.html.erb 123 | Rendering sample/load_real_assets.html.erb within layouts/application 124 | Rendered sample/load_real_assets.html.erb within layouts/application (Duration: 29.3ms | Allocations: 1786) 125 | Rendered layout layouts/application.html.erb (Duration: 46.6ms | Allocations: 2439) 126 | Completed 200 OK in 578ms (Views: 58.5ms | Allocations: 5333) 127 | 128 | 129 | Started GET "/fezg" for 127.0.0.1 at 2023-09-11 12:50:52 +0200 130 | 131 | ActionController::RoutingError (No route matches [GET] "/fezg"): 132 | 133 | Started GET "/favicon.ico" for 127.0.0.1 at 2023-09-11 12:50:52 +0200 134 | 135 | ActionController::RoutingError (No route matches [GET] "/favicon.ico"): 136 | 137 | Started GET "/sample/load_real_assets" for 127.0.0.1 at 2023-09-11 12:51:01 +0200 138 | Processing by SampleController#load_real_assets as HTML 139 | Rendering layout layouts/application.html.erb 140 | Rendering sample/load_real_assets.html.erb within layouts/application 141 | Rendered sample/load_real_assets.html.erb within layouts/application (Duration: 0.2ms | Allocations: 120) 142 | Rendered layout layouts/application.html.erb (Duration: 1.0ms | Allocations: 436) 143 | Completed 200 OK in 250ms (Views: 1.6ms | Allocations: 662) 144 | 145 | 146 | -------------------------------------------------------------------------------- /test/dummy/tmp/development_secret.txt: -------------------------------------------------------------------------------- 1 | f787305aa73a6b5cd8f8bd49df3e00e6bce871542b3bdd875cfbc23c1cd9099453d2caca280d2c7fdb52b4e6e71fe7b8086e1e6a18c5a7fa1eb9d2c95991104e -------------------------------------------------------------------------------- /test/rails-route-checker/loaded_app_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | 3 | require 'rails-route-checker/loaded_app' 4 | 5 | describe RailsRouteChecker::LoadedApp do 6 | let(:loaded_app) do 7 | Dir.stub(:pwd, File.join(__dir__, '../dummy')) do 8 | RailsRouteChecker::LoadedApp.new 9 | end 10 | end 11 | 12 | it 'parses ActionController::Base and ActionController::API descendants' do 13 | assert_equal( 14 | loaded_app.controller_information.keys.sort, %w[application articles base_api articles_api].sort 15 | ) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/rails-route-checker/parser_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "haml" 3 | require 'rails-route-checker/parsers/ruby_parser' 4 | require 'rails-route-checker/parsers/haml_parser' 5 | require 'rails-route-checker/parsers/haml_parser/document' 6 | 7 | describe "HamlParser" do 8 | filename = Pathname.new("#{__dir__}/../dummy/app/views/articles/index_haml.html.haml") 9 | @haml_parser = RailsRouteChecker::Parsers::HamlParser.run(filename) 10 | it "everything is valid" do 11 | assert true 12 | end 13 | end 14 | 15 | --------------------------------------------------------------------------------