├── lib ├── config │ ├── flipper.yml │ ├── okcomputer.yml │ ├── rack.yml │ ├── rollbar.yml │ ├── jbuilder.yml │ ├── simplecov.yml │ ├── builder.yml │ ├── pry.yml │ ├── capistrano.yml │ ├── guard.yml │ ├── rake.yml │ ├── haml.yml │ ├── slim.yml │ ├── sidekiq.yml │ ├── test-unit.yml │ ├── datagrid.yml │ ├── audited.yml │ ├── will_paginate.yml │ ├── attr_encrypted.yml │ ├── graphql.yml │ ├── selenium-webdriver.yml │ ├── rails.yml │ ├── redcarpet.yml │ ├── activestorage.yml │ ├── railties.yml │ ├── actionmailbox.yml │ ├── activejob.yml │ ├── actioncable.yml │ ├── leftovers.yml │ ├── parser.yml │ ├── actiontext.yml │ └── rspec.yml ├── leftovers │ ├── version.rb │ ├── error.rb │ ├── ast.rb │ ├── matchers.rb │ ├── processors.rb │ ├── matcher_builders.rb │ ├── processor_builders.rb │ ├── unexpected_case.rb │ ├── matcher_builders │ │ ├── string.rb │ │ ├── unless.rb │ │ ├── document.rb │ │ ├── node_name.rb │ │ ├── node_path.rb │ │ ├── node_privacy.rb │ │ ├── node_pair_key.rb │ │ ├── node_pair_value.rb │ │ ├── node_has_block.rb │ │ ├── path.rb │ │ ├── node_has_receiver.rb │ │ ├── name.rb │ │ ├── node_has_keyword_argument.rb │ │ ├── and.rb │ │ ├── string_pattern.rb │ │ ├── node_type.rb │ │ ├── node_has_positional_argument.rb │ │ ├── node.rb │ │ ├── node_has_argument.rb │ │ └── node_value.rb │ ├── matchers │ │ ├── node_is_proc.rb │ │ ├── node_has_block.rb │ │ ├── node_has_any_receiver.rb │ │ ├── not.rb │ │ ├── node_path.rb │ │ ├── node_type.rb │ │ ├── path.rb │ │ ├── node_pair_key.rb │ │ ├── node_pair_value.rb │ │ ├── node_privacy.rb │ │ ├── or.rb │ │ ├── and.rb │ │ ├── node_name.rb │ │ ├── all.rb │ │ ├── any.rb │ │ ├── node_has_positional_argument.rb │ │ ├── node_has_receiver.rb │ │ ├── node_has_any_positional_argument_with_value.rb │ │ ├── node_scalar_value.rb │ │ ├── node_has_positional_argument_with_value.rb │ │ └── node_has_any_keyword_argument.rb │ ├── ast │ │ ├── block_node.rb │ │ ├── array_node.rb │ │ ├── var_node.rb │ │ ├── module_node.rb │ │ ├── hash_node.rb │ │ ├── def_node.rb │ │ ├── const_node.rb │ │ ├── defs_node.rb │ │ ├── nil_node.rb │ │ ├── vasgn_node.rb │ │ ├── casgn_node.rb │ │ ├── true_node.rb │ │ ├── false_node.rb │ │ ├── numeric_node.rb │ │ ├── str_node.rb │ │ ├── sym_node.rb │ │ ├── has_arguments.rb │ │ ├── send_node.rb │ │ ├── builder.rb │ │ └── node.rb │ ├── processor_builders │ │ ├── itself.rb │ │ ├── receiver.rb │ │ ├── value.rb │ │ ├── each_for_definition_set.rb │ │ ├── keyword_argument.rb │ │ ├── keyword.rb │ │ ├── add_prefix.rb │ │ ├── add_suffix.rb │ │ ├── transform_set.rb │ │ ├── transform_chain.rb │ │ ├── argument.rb │ │ ├── dynamic.rb │ │ └── transform.rb │ ├── config_loader │ │ ├── require_schema.rb │ │ ├── built_in_precompiler_schema.rb │ │ ├── string_value_processor_schema.rb │ │ ├── precompiler_schema.rb │ │ ├── privacy_processor_schema.rb │ │ ├── privacy_schema.rb │ │ ├── string_schema.rb │ │ ├── scalar_argument_schema.rb │ │ ├── value_type_schema.rb │ │ ├── precompile_schema.rb │ │ ├── true_schema.rb │ │ ├── keep_test_only_schema.rb │ │ ├── value_processor_schema.rb │ │ ├── keyword_argument_schema.rb │ │ ├── argument_position_schema.rb │ │ ├── has_receiver_schema.rb │ │ ├── has_argument_schema.rb │ │ ├── bool_schema.rb │ │ ├── scalar_value_schema.rb │ │ ├── string_pattern_schema.rb │ │ ├── value_matcher_condition_schema.rb │ │ ├── argumentless_transform_schema.rb │ │ ├── suggester.rb │ │ ├── inherit_schema_attributes.rb │ │ ├── schema.rb │ │ ├── regexp_schema.rb │ │ ├── dynamic_schema.rb │ │ ├── value_or_array_schema.rb │ │ ├── value_matcher_schema.rb │ │ ├── has_value_schema.rb │ │ ├── value_or_object_schema.rb │ │ ├── rule_pattern_schema.rb │ │ ├── array_schema.rb │ │ ├── attribute.rb │ │ ├── transform_schema.rb │ │ ├── string_enum_schema.rb │ │ └── document_schema.rb │ ├── processors │ │ ├── append_sym.rb │ │ ├── add_call.rb │ │ ├── eval.rb │ │ ├── add_definition_node.rb │ │ ├── placeholder.rb │ │ ├── set_default_privacy.rb │ │ ├── set_privacy.rb │ │ ├── itself.rb │ │ ├── upcase.rb │ │ ├── downcase.rb │ │ ├── swapcase.rb │ │ ├── capitalize.rb │ │ ├── replace_value.rb │ │ ├── each.rb │ │ ├── add_prefix.rb │ │ ├── add_suffix.rb │ │ ├── receiver.rb │ │ ├── delete_prefix.rb │ │ ├── delete_suffix.rb │ │ ├── split.rb │ │ ├── match_current_node.rb │ │ ├── match_matched_node.rb │ │ ├── delete_after.rb │ │ ├── delete_after_last.rb │ │ ├── each_keyword.rb │ │ ├── each_positional_argument.rb │ │ ├── delete_before.rb │ │ ├── each_keyword_argument.rb │ │ ├── delete_before_last.rb │ │ ├── add_dynamic_prefix.rb │ │ ├── add_dynamic_suffix.rb │ │ ├── keyword_argument.rb │ │ ├── positional_argument.rb │ │ ├── each_positional_argument_from.rb │ │ ├── camelize.rb │ │ ├── titleize.rb │ │ ├── pluralize.rb │ │ ├── demodulize.rb │ │ ├── underscore.rb │ │ ├── singularize.rb │ │ ├── deconstantize.rb │ │ ├── parameterize.rb │ │ └── each_for_definition_set.rb │ ├── file_collector │ │ ├── node_processor │ │ │ └── error.rb │ │ └── comments_processor.rb │ ├── precompilers │ │ ├── slim.rb │ │ ├── erb.rb │ │ ├── yaml.rb │ │ ├── json.rb │ │ ├── precompiler.rb │ │ ├── haml.rb │ │ └── yaml │ │ │ └── builder.rb │ ├── definition_node.rb │ ├── definition_node_set.rb │ ├── comparable_instance.rb │ ├── file_list.rb │ ├── precompile_error.rb │ ├── file.rb │ ├── runner.rb │ ├── definition_to_add.rb │ ├── definition_set.rb │ ├── autoloader.rb │ ├── parser.rb │ ├── definition_collection.rb │ ├── precompilers.rb │ ├── config_loader.rb │ ├── definition.rb │ ├── rake_task.rb │ ├── collection.rb │ ├── reporter.rb │ ├── collector.rb │ ├── config.rb │ └── cli.rb └── leftovers.rb ├── .spellr_wordlists ├── shell.txt ├── lorem.txt ├── ruby.txt └── english.txt ├── .rspec ├── .gitignore ├── Gemfile ├── bin ├── setup ├── size ├── console ├── prof ├── time └── test_autoload.rb ├── exe └── leftovers ├── .leftovers.yml ├── .spellr.yml ├── spec ├── support │ ├── cli_helper.rb │ ├── ruby_version_helper.rb │ ├── expects_output_helper.rb │ └── temp_file_helper.rb ├── definition_set_spec.rb ├── config_loader │ └── suggester_spec.rb ├── config_fuzz_spec.rb ├── config_documentation_spec.rb ├── leftovers_spec.rb ├── file_collector │ ├── json_spec.rb │ ├── yaml_spec.rb │ └── erb_spec.rb ├── config │ ├── audited_spec.rb │ └── rspec_spec.rb ├── config_built_in_spec.rb └── matcher_builders │ └── string_pattern_spec.rb ├── .simplecov ├── Rakefile ├── LICENSE.txt ├── docs └── Custom-Precompilers.md └── leftovers.gemspec /lib/config/flipper.yml: -------------------------------------------------------------------------------- 1 | keep: flipper_id 2 | -------------------------------------------------------------------------------- /lib/config/okcomputer.yml: -------------------------------------------------------------------------------- 1 | keep: check 2 | -------------------------------------------------------------------------------- /.spellr_wordlists/shell.txt: -------------------------------------------------------------------------------- 1 | env 2 | euo 3 | usr 4 | -------------------------------------------------------------------------------- /lib/config/rack.yml: -------------------------------------------------------------------------------- 1 | include_paths: 2 | - '*.ru' 3 | -------------------------------------------------------------------------------- /lib/config/rollbar.yml: -------------------------------------------------------------------------------- 1 | keep: current_user_for_rollbar 2 | -------------------------------------------------------------------------------- /.spellr_wordlists/lorem.txt: -------------------------------------------------------------------------------- 1 | barxbaz 2 | baz 3 | kchecked 4 | -------------------------------------------------------------------------------- /lib/config/jbuilder.yml: -------------------------------------------------------------------------------- 1 | include_paths: 2 | - '*.jbuilder' 3 | -------------------------------------------------------------------------------- /lib/config/simplecov.yml: -------------------------------------------------------------------------------- 1 | include_paths: 2 | - .simplecov 3 | -------------------------------------------------------------------------------- /lib/config/builder.yml: -------------------------------------------------------------------------------- 1 | 2 | include_paths: 3 | - '*.builder' 4 | -------------------------------------------------------------------------------- /lib/config/pry.yml: -------------------------------------------------------------------------------- 1 | include_paths: 2 | - pryrc 3 | - .pryrc 4 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/config/capistrano.yml: -------------------------------------------------------------------------------- 1 | include_paths: 2 | - Capfile 3 | - capfile 4 | -------------------------------------------------------------------------------- /lib/config/guard.yml: -------------------------------------------------------------------------------- 1 | include_paths: 2 | - Guardfile 3 | - .Guardfile 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .rspec_status 2 | Gemfile.lock 3 | /pkg/ 4 | /coverage/ 5 | .ruby-version 6 | TODO.md 7 | -------------------------------------------------------------------------------- /lib/config/rake.yml: -------------------------------------------------------------------------------- 1 | include_paths: 2 | - '*.rake' 3 | - Rakefile 4 | - rakefile 5 | - Rake 6 | -------------------------------------------------------------------------------- /lib/leftovers/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | VERSION = '0.12.2' 5 | end 6 | -------------------------------------------------------------------------------- /lib/config/haml.yml: -------------------------------------------------------------------------------- 1 | include_paths: 2 | - '*.haml' 3 | requires: 'haml' 4 | precompile: 5 | format: haml 6 | paths: '*.haml' 7 | -------------------------------------------------------------------------------- /lib/config/slim.yml: -------------------------------------------------------------------------------- 1 | include_paths: 2 | - '*.slim' 3 | requires: 'slim' 4 | precompile: 5 | paths: '*.slim' 6 | format: slim 7 | -------------------------------------------------------------------------------- /lib/leftovers/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class Error < ::StandardError 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/leftovers/ast.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module AST 5 | include Autoloader 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/leftovers/matchers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | include Autoloader 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in leftovers.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /lib/leftovers/processors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | include Autoloader 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module MatcherBuilders 5 | include Autoloader 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/leftovers/processor_builders.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module ProcessorBuilders 5 | include Autoloader 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/leftovers/unexpected_case.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # :nocov: 4 | module Leftovers 5 | class UnexpectedCase < Error 6 | end 7 | end 8 | # :nocov: 9 | -------------------------------------------------------------------------------- /lib/config/sidekiq.yml: -------------------------------------------------------------------------------- 1 | keep: on_complete 2 | dynamic: 3 | - names: 4 | - perform_async 5 | calls: 6 | value: perform 7 | - name: register 8 | calls: 9 | argument: 1 10 | -------------------------------------------------------------------------------- /lib/config/test-unit.yml: -------------------------------------------------------------------------------- 1 | # https://ruby-doc.com/stdlib-3.1.1/libdoc/test-unit/rdoc/Test/Unit.html 2 | 3 | keep: 4 | - setup 5 | - teardown 6 | - startup 7 | - shutdown 8 | - has_prefix: test 9 | -------------------------------------------------------------------------------- /lib/config/datagrid.yml: -------------------------------------------------------------------------------- 1 | dynamic: 2 | - name: filter 3 | calls: 4 | arguments: 5 | - default 6 | - select 7 | - name: column 8 | calls: 9 | arguments: [if, unless] 10 | -------------------------------------------------------------------------------- /lib/config/audited.yml: -------------------------------------------------------------------------------- 1 | dynamic: 2 | - name: current_user_method= 3 | calls: 0 4 | - name: audited 5 | calls: 6 | argument: 7 | - associated_with 8 | - if 9 | - unless 10 | -------------------------------------------------------------------------------- /exe/leftovers: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require_relative '../lib/leftovers' 5 | 6 | Leftovers.stderr = $stderr 7 | Leftovers.stdout = $stdout 8 | exit ::Leftovers::CLI.new(argv: ::ARGV).run 9 | -------------------------------------------------------------------------------- /.leftovers.yml: -------------------------------------------------------------------------------- 1 | exclude_paths: 2 | - /vendor 3 | 4 | keep: 5 | - add_insert_cmd # called by ::ERB::Compiler 6 | - add_put_cmd # called by ::ERB::Compiler 7 | - 'n' # called by Parser::AST::Builder 8 | - string_value # called by Parser::AST::Builder 9 | -------------------------------------------------------------------------------- /.spellr.yml: -------------------------------------------------------------------------------- 1 | excludes: 2 | - vendor 3 | languages: 4 | english: 5 | locale: [US, AU] 6 | lorem: 7 | includes: 8 | - /spec/ 9 | ruby: 10 | includes: 11 | - README.md 12 | - Configuration.md 13 | - '*.yml' 14 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders/string.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module MatcherBuilders 5 | module String 6 | def self.build(pattern) 7 | pattern.to_sym 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/node_is_proc.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | module NodeIsProc 6 | def self.===(node) 7 | node.proc? 8 | end 9 | 10 | freeze 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/leftovers/ast/block_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module AST 5 | class BlockNode < Node 6 | def proc? 7 | name = first.name 8 | name == :lambda || name == :proc 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders/unless.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module MatcherBuilders 5 | module Unless 6 | def self.build(matcher) 7 | Matchers::Not.new(matcher) if matcher 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/node_has_block.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | module NodeHasBlock 6 | def self.===(node) 7 | node.block_given? 8 | end 9 | 10 | freeze 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/config/will_paginate.yml: -------------------------------------------------------------------------------- 1 | keep: 2 | # WillPaginate::ViewHelpers::LinkRenderer 3 | - page_number 4 | - gap 5 | - previous_page 6 | - next_page 7 | - previous_or_next_page 8 | - html_container 9 | - url 10 | - prepare 11 | - to_html 12 | - container_attributes 13 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/node_has_any_receiver.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | module NodeHasAnyReceiver 6 | def self.===(node) 7 | node.receiver 8 | end 9 | 10 | freeze 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/leftovers/ast/array_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module AST 5 | class ArrayNode < Node 6 | include HasArguments 7 | 8 | alias_method :arguments, :children 9 | alias_method :as_arguments_list, :arguments 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/leftovers/ast/var_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module AST 5 | class VarNode < Node 6 | alias_method :name, :first 7 | alias_method :to_sym, :first 8 | 9 | def to_s 10 | name.to_s 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders/document.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module MatcherBuilders 5 | module Document 6 | def self.build(true_arg) 7 | Matchers::NodeName.new(:__leftovers_document) if true_arg 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/cli_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'shellwords' 4 | 5 | module CLIHelper 6 | def run(argv = '') 7 | ::Leftovers::CLI.new(argv: ::Shellwords.split(argv)).run 8 | end 9 | end 10 | 11 | ::RSpec.configure do |c| 12 | c.include CLIHelper, type: :cli 13 | end 14 | -------------------------------------------------------------------------------- /lib/leftovers/processor_builders/itself.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module ProcessorBuilders 5 | module Itself 6 | def self.build(true_arg, then_processor) 7 | Processors::Itself.new(then_processor) if true_arg 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/leftovers/processor_builders/receiver.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module ProcessorBuilders 5 | module Receiver 6 | def self.build(true_arg, then_processor) 7 | Processors::Receiver.new(then_processor) if true_arg 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/require_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class RequireSchema < ValueOrObjectSchema 6 | attribute :quiet, StringSchema, require_group: :quiet 7 | 8 | self.or_value_schema = StringSchema 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/leftovers/processors/append_sym.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | module AppendSym 6 | def self.process(str, _current_node, _matched_node, acc) 7 | return unless str 8 | 9 | acc << str.to_sym 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/leftovers/ast/module_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module AST 5 | class ModuleNode < Node 6 | def name 7 | first.name 8 | end 9 | alias_method :to_sym, :name 10 | 11 | def to_s 12 | name.to_s 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/definition_set_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | ::RSpec.describe ::Leftovers::DefinitionSet do 6 | describe 'to_s' do 7 | it 'has multiple names' do 8 | ds = described_class.new(%w{one two}) 9 | 10 | expect(ds.to_s).to eq 'one, two' 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/leftovers/ast/hash_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module AST 5 | class HashNode < Node 6 | include HasArguments 7 | 8 | def arguments 9 | @memo[:arguments] ||= [self] 10 | end 11 | 12 | def hash? 13 | true 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/built_in_precompiler_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class BuiltInPrecompilerSchema < StringEnumSchema 6 | value :erb 7 | value :yaml 8 | value :json 9 | value :slim 10 | value :haml 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/string_value_processor_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class StringValueProcessorSchema < ValueOrObjectSchema 6 | inherit_attributes_from ValueProcessorSchema 7 | 8 | self.or_value_schema = StringSchema 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/leftovers/ast/def_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module AST 5 | class DefNode < Node 6 | alias_method :name, :first 7 | alias_method :to_sym, :first 8 | 9 | def to_s 10 | name.to_s 11 | end 12 | alias_method :to_literal_s, :to_s 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/precompiler_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class PrecompilerSchema < ValueOrObjectSchema 6 | attribute :custom, StringSchema, require_group: :custom 7 | 8 | self.or_value_schema = BuiltInPrecompilerSchema 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/privacy_processor_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class PrivacyProcessorSchema < ObjectSchema 6 | inherit_attributes_from ValueProcessorSchema 7 | attribute :to, PrivacySchema, require_group: :privacy_setting 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders/node_name.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module MatcherBuilders 5 | module NodeName 6 | def self.build(name_pattern) 7 | matcher = Name.build(name_pattern) 8 | 9 | Matchers::NodeName.new(matcher) if matcher 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders/node_path.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module MatcherBuilders 5 | module NodePath 6 | def self.build(path_pattern) 7 | matcher = Path.build(path_pattern) 8 | 9 | Matchers::NodePath.new(matcher) if matcher 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/leftovers/ast/const_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module AST 5 | class ConstNode < Node 6 | alias_method :receiver, :first 7 | alias_method :name, :second 8 | alias_method :to_sym, :second 9 | 10 | def to_s 11 | name.to_s 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/leftovers/ast/defs_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module AST 5 | class DefsNode < Node 6 | alias_method :name, :second 7 | alias_method :to_sym, :second 8 | 9 | def to_s 10 | name.to_s 11 | end 12 | alias_method :to_literal_s, :to_s 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/leftovers/processors/add_call.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | module AddCall 6 | def self.process(str, _current_node, _matched_node, acc) 7 | return unless str 8 | return if str.empty? 9 | 10 | acc.calls << str.to_sym 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/privacy_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class PrivacySchema < StringEnumSchema 6 | value :public 7 | value :protected 8 | value :private 9 | 10 | def self.to_ruby(node) 11 | super.to_sym 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/string_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class StringSchema < Schema 6 | class << self 7 | def validate(node) 8 | error(node, 'be a string') unless node.string? 9 | super 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders/node_privacy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module MatcherBuilders 5 | module NodePrivacy 6 | def self.build(privacy_settings) 7 | Or.each_or_self(privacy_settings) do |privacy| 8 | Matchers::NodePrivacy.new(privacy) 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/leftovers/processor_builders/value.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module ProcessorBuilders 5 | module Value 6 | def self.build(values, then_processor) 7 | Each.each_or_self(values) do |value| 8 | Processors::ReplaceValue.new(value, then_processor) 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/leftovers/processor_builders/each_for_definition_set.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module ProcessorBuilders 5 | class EachForDefinitionSet < Each 6 | class << self 7 | private 8 | 9 | def processor_class 10 | Processors::EachForDefinitionSet 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/leftovers/ast/nil_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module AST 5 | class NilNode < Node 6 | def to_scalar_value 7 | nil 8 | end 9 | 10 | def scalar? 11 | true 12 | end 13 | 14 | def to_s 15 | '' 16 | end 17 | 18 | def to_sym 19 | :nil 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/leftovers/processors/eval.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | module Eval 6 | def self.process(str, current_node, _matched_node, acc) 7 | return unless str 8 | return if str.empty? 9 | 10 | acc.collect_subfile(str, current_node.loc.expression) 11 | end 12 | 13 | freeze 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /bin/size: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require_relative '../lib/leftovers' 5 | require 'objspace' 6 | require 'pathname' 7 | 8 | files = Pathname.glob("#{__dir__}/../lib/config/*.yml") 9 | gems = files.map { |f| f.basename.sub_ext('').to_s } 10 | 11 | gems.each { |gem| Leftovers.config << Leftovers::Config.new(gem) } 12 | 13 | GC.start 14 | puts ObjectSpace.memsize_of_all 15 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/scalar_argument_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class ScalarArgumentSchema < Schema 6 | class << self 7 | def validate(node) 8 | error(node, 'be a string or an integer') unless node.string? || node.integer? 9 | super 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/not.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | class Not 6 | include ComparableInstance 7 | 8 | def initialize(matcher) 9 | @matcher = matcher 10 | 11 | freeze 12 | end 13 | 14 | def ===(value) 15 | !(@matcher === value) 16 | end 17 | 18 | freeze 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/support/ruby_version_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ::RSpec.configure do |c| 4 | c.before do |example| 5 | ruby_version = example.metadata[:ruby_version_at_least] 6 | next unless ruby_version 7 | 8 | if Gem::Version.new(ruby_version) > Gem::Version.new(RUBY_VERSION) 9 | skip "Only run this example in ruby version >= #{ruby_version}" 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/leftovers/file_collector/node_processor/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class FileCollector 5 | class NodeProcessor 6 | class Error < ::Leftovers::FileCollector::Error 7 | attr_reader :node 8 | 9 | def initialize(message, node) 10 | super(message) 11 | @node = node 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/leftovers/ast/vasgn_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module AST 5 | class VasgnNode < Node 6 | include HasArguments 7 | 8 | alias_method :name, :first 9 | alias_method :to_sym, :first 10 | 11 | def to_s 12 | name.to_s 13 | end 14 | 15 | def arguments 16 | second.as_arguments_list 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/value_type_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class ValueTypeSchema < StringEnumSchema 6 | value :String 7 | value :Symbol 8 | value :Integer 9 | value :Float 10 | value :Array 11 | value :Hash 12 | value :Proc 13 | value :Method 14 | value :Constant 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders/node_pair_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module MatcherBuilders 5 | module NodePairKey 6 | def self.build(key_matcher) 7 | return unless key_matcher 8 | 9 | And.build([ 10 | Matchers::NodeType.new(:pair), 11 | Matchers::NodePairKey.new(key_matcher) 12 | ]) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/leftovers/precompilers/slim.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'slim' 4 | 5 | module Leftovers 6 | module Precompilers 7 | module Slim 8 | def self.precompile(file) 9 | ::Slim::Engine.new(file: file).call(file) 10 | rescue ::Slim::Parser::SyntaxError => e 11 | raise PrecompileError.new(e.error, line: e.lineno, column: e.column) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/leftovers/ast/casgn_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module AST 5 | class CasgnNode < Node 6 | include HasArguments 7 | 8 | alias_method :name, :second 9 | alias_method :to_sym, :second 10 | 11 | def to_s 12 | name.to_s 13 | end 14 | 15 | def arguments 16 | children[2].as_arguments_list 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/precompile_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class PrecompileSchema < ObjectSchema 6 | attribute :paths, ValueOrArraySchema[StringSchema], 7 | aliases: %i{path include_paths include_path}, require_group: :paths 8 | 9 | attribute :format, PrecompilerSchema, require_group: :precompiler 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders/node_pair_value.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module MatcherBuilders 5 | module NodePairValue 6 | def self.build(value_matcher) 7 | return unless value_matcher 8 | 9 | And.build([ 10 | Matchers::NodeType.new(:pair), 11 | Matchers::NodePairValue.new(value_matcher) 12 | ]) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/leftovers/processors/add_definition_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | module AddDefinitionNode 6 | def self.process(str, current_node, _matched_node, acc) 7 | return unless str 8 | return if str.empty? 9 | 10 | acc.add_definition_node DefinitionNode.new(current_node, name: str.to_sym) 11 | end 12 | 13 | freeze 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/config/attr_encrypted.yml: -------------------------------------------------------------------------------- 1 | dynamic: 2 | # this doesn't handle custom prefix, suffix, attribute 3 | - name: attr_encrypted 4 | defines: 0 5 | calls: 6 | - argument: 0 7 | transforms: 8 | - add_prefix: encrypted_ 9 | - add_prefix: encrypted_ 10 | add_suffix: _iv 11 | - arguments: 12 | - encryptor 13 | - encrypt_method 14 | - decrypt_method 15 | - key 16 | -------------------------------------------------------------------------------- /lib/leftovers/ast/true_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module AST 5 | class TrueNode < Node 6 | def to_scalar_value 7 | true 8 | end 9 | 10 | def scalar? 11 | true 12 | end 13 | 14 | def to_s 15 | 'true' 16 | end 17 | alias_method :to_literal_s, :to_s 18 | 19 | def to_sym 20 | :true 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/leftovers/processors/placeholder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class Placeholder 6 | def processor=(value) 7 | @processor = value 8 | 9 | freeze 10 | end 11 | 12 | def process(str, current_node, matched_node, acc) 13 | @processor.process(str, current_node, matched_node, acc) 14 | end 15 | 16 | freeze 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require_relative '../lib/leftovers' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /lib/leftovers/ast/false_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module AST 5 | class FalseNode < Node 6 | def to_scalar_value 7 | false 8 | end 9 | 10 | def scalar? 11 | true 12 | end 13 | 14 | def to_s 15 | 'false' 16 | end 17 | alias_method :to_literal_s, :to_s 18 | 19 | def to_sym 20 | :false 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/node_path.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | class NodePath 6 | include ComparableInstance 7 | 8 | attr_reader :matcher 9 | 10 | def initialize(matcher) 11 | @matcher = matcher 12 | 13 | freeze 14 | end 15 | 16 | def ===(node) 17 | @matcher === node.path 18 | end 19 | 20 | freeze 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/node_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | class NodeType 6 | include ComparableInstance 7 | 8 | attr_reader :matcher 9 | 10 | def initialize(matcher) 11 | @matcher = matcher 12 | 13 | freeze 14 | end 15 | 16 | def ===(node) 17 | @matcher === node.type 18 | end 19 | 20 | freeze 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/path.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | class Path 6 | include ComparableInstance 7 | 8 | def initialize(fast_ignore) 9 | @fast_ignore = fast_ignore 10 | 11 | freeze 12 | end 13 | 14 | def ===(path) 15 | @fast_ignore.allowed?(path, exists: true, directory: false) 16 | end 17 | 18 | freeze 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/leftovers/ast/numeric_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module AST 5 | class NumericNode < Node 6 | alias_method :to_scalar_value, :first 7 | 8 | def scalar? 9 | true 10 | end 11 | 12 | def to_s 13 | to_scalar_value.to_s 14 | end 15 | alias_method :to_literal_s, :to_s 16 | 17 | def to_sym 18 | to_s.to_sym 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders/node_has_block.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module MatcherBuilders 5 | module NodeHasBlock 6 | class << self 7 | def build(has_block) 8 | if has_block 9 | Matchers::NodeHasBlock 10 | elsif has_block == false 11 | Matchers::Not.new(Matchers::NodeHasBlock) 12 | end 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/node_pair_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | class NodePairKey 6 | include ComparableInstance 7 | 8 | attr_reader :matcher 9 | 10 | def initialize(matcher) 11 | @matcher = matcher 12 | 13 | freeze 14 | end 15 | 16 | def ===(node) 17 | @matcher === node.first 18 | end 19 | 20 | freeze 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/true_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class TrueSchema < Schema 6 | class << self 7 | def validate(node) 8 | error(node, 'be true') unless to_ruby(node) 9 | super 10 | end 11 | 12 | def to_ruby(node) 13 | node.to_ruby == true || node.to_ruby == 'true' 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/node_pair_value.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | class NodePairValue 6 | include ComparableInstance 7 | 8 | attr_reader :matcher 9 | 10 | def initialize(matcher) 11 | @matcher = matcher 12 | 13 | freeze 14 | end 15 | 16 | def ===(node) 17 | @matcher === node.second 18 | end 19 | 20 | freeze 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/node_privacy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | class NodePrivacy 6 | include ComparableInstance 7 | 8 | attr_reader :matcher 9 | 10 | def initialize(matcher) 11 | @matcher = matcher 12 | 13 | freeze 14 | end 15 | 16 | def ===(node) 17 | @matcher === node.privacy 18 | end 19 | 20 | freeze 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/or.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | class Or 6 | include ComparableInstance 7 | 8 | attr_reader :lhs, :rhs 9 | 10 | def initialize(lhs, rhs) 11 | @lhs = lhs 12 | @rhs = rhs 13 | 14 | freeze 15 | end 16 | 17 | def ===(value) 18 | @lhs === value || @rhs === value 19 | end 20 | 21 | freeze 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/leftovers/definition_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class DefinitionNode < AST::Node 5 | attr_reader :name, :path 6 | alias_method :to_sym, :name 7 | 8 | def initialize(node, name:, location: node.loc.expression) 9 | @name = name 10 | @path = node.path 11 | @location = location 12 | super(:leftovers_definition) 13 | end 14 | 15 | def to_s 16 | name.to_s 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders/path.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fast_ignore' 4 | 5 | module Leftovers 6 | module MatcherBuilders 7 | module Path 8 | def self.build(path_pattern) 9 | return if path_pattern.nil? || path_pattern.empty? 10 | 11 | Matchers::Path.new( 12 | ::FastIgnore.new(include_rules: path_pattern, gitignore: false, root: ::Leftovers.pwd) 13 | ) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/and.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | class And 6 | include ComparableInstance 7 | 8 | attr_reader :lhs, :rhs 9 | 10 | def initialize(lhs, rhs) 11 | @lhs = lhs 12 | @rhs = rhs 13 | 14 | freeze 15 | end 16 | 17 | def ===(value) 18 | @lhs === value && @rhs === value 19 | end 20 | 21 | freeze 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/leftovers/processor_builders/keyword_argument.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module ProcessorBuilders 5 | module KeywordArgument 6 | def self.build(pattern, then_processor) 7 | Processors::KeywordArgument.new( 8 | MatcherBuilders::NodePairKey.build( 9 | MatcherBuilders::NodeName.build(pattern) 10 | ), 11 | then_processor 12 | ) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/leftovers/processors/set_default_privacy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class SetDefaultPrivacy 6 | include ComparableInstance 7 | 8 | def initialize(to) 9 | @to = to 10 | 11 | freeze 12 | end 13 | 14 | def process(_str, _current_node, _matched_node, acc) 15 | acc.default_method_privacy = @to 16 | end 17 | 18 | freeze 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/config/graphql.yml: -------------------------------------------------------------------------------- 1 | keep: 2 | - cursor_from_node 3 | - paged_nodes 4 | - sliced_nodes 5 | dynamic: 6 | - name: field 7 | calls: 8 | argument: method 9 | - name: field 10 | unless: 11 | has_argument: method 12 | calls: 0 13 | - name: argument 14 | unless: 15 | has_argument: as 16 | calls: 17 | - argument: 0 18 | add_suffix: '=' 19 | - name: argument 20 | calls: 21 | - argument: as 22 | add_suffix: '=' 23 | -------------------------------------------------------------------------------- /lib/leftovers/definition_node_set.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class DefinitionNodeSet 5 | attr_reader :definitions 6 | 7 | def initialize 8 | @definitions = [] 9 | end 10 | 11 | def add_definition_node(definition_node) 12 | @definitions << definition_node 13 | end 14 | 15 | def add_definition_set(definition_node_set) 16 | @definitions.concat(definition_node_set.definitions) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/leftovers/ast/str_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module AST 5 | class StrNode < Node 6 | alias_method :to_scalar_value, :first 7 | 8 | def name 9 | first.to_sym 10 | end 11 | 12 | alias_method :to_s, :first 13 | alias_method :to_literal_s, :to_s 14 | 15 | def to_sym 16 | to_s.to_sym 17 | end 18 | 19 | def scalar? 20 | true 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/leftovers/comparable_instance.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module ComparableInstance 5 | def eql?(other) 6 | frozen? && other.frozen? && 7 | self.class == other.class && 8 | instance_variables.all? do |var| 9 | instance_variable_get(var) == other.instance_variable_get(var) 10 | end 11 | end 12 | alias_method :==, :eql? 13 | 14 | def hash 15 | self.class.hash 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/config/selenium-webdriver.yml: -------------------------------------------------------------------------------- 1 | keep: 2 | - before_navigate_to 3 | - before_navigate_back 4 | - after_navigate_back 5 | - before_navigate_forward 6 | - after_navigate_forward 7 | - before_find 8 | - after_find 9 | - before_click 10 | - before_change_value_of 11 | - after_change_value_of 12 | - before_execute_script 13 | - after_execute_script 14 | - before_quit 15 | - before_close 16 | - after_navigate_to 17 | - after_click 18 | - after_quit 19 | - after_close 20 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/node_name.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | class NodeName 6 | include ComparableInstance 7 | 8 | attr_reader :matcher 9 | 10 | def initialize(matcher) 11 | @matcher = matcher 12 | 13 | freeze 14 | end 15 | 16 | def ===(node) 17 | name = node.name 18 | 19 | @matcher === name if name 20 | end 21 | 22 | freeze 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/leftovers/processors/set_privacy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class SetPrivacy 6 | include ComparableInstance 7 | 8 | def initialize(to) 9 | @to = to 10 | 11 | freeze 12 | end 13 | 14 | def process(str, _current_node, _matched_node, acc) 15 | return unless str 16 | 17 | acc.set_privacy(str.to_sym, @to) 18 | end 19 | 20 | freeze 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/support/expects_output_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ExpectsOutput 4 | def expects_output! 5 | @expects_output = true 6 | end 7 | end 8 | 9 | ::RSpec.configure do |c| 10 | c.include ExpectsOutput 11 | 12 | c.before { ::Leftovers.reset } 13 | 14 | c.after do 15 | next if defined?(@expects_output) && @expects_output 16 | 17 | expect(::Leftovers.stderr.string).to be_empty 18 | expect(::Leftovers.stdout.string).to be_empty 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/keep_test_only_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class KeepTestOnlySchema < ValueOrObjectSchema 6 | inherit_attributes_from StringPatternSchema, except: :unless 7 | inherit_attributes_from RulePatternSchema, except: :unless 8 | attribute :unless, ValueOrArraySchema[KeepTestOnlySchema], require_group: :matcher 9 | 10 | self.or_value_schema = StringSchema 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/all.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | class All 6 | include ComparableInstance 7 | 8 | attr_reader :matchers 9 | 10 | def initialize(matchers) 11 | @matchers = matchers 12 | 13 | freeze 14 | end 15 | 16 | def ===(value) 17 | @matchers.all? do |matcher| 18 | matcher === value 19 | end 20 | end 21 | 22 | freeze 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/any.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | class Any 6 | include ComparableInstance 7 | 8 | attr_reader :matchers 9 | 10 | def initialize(matchers) 11 | @matchers = matchers 12 | 13 | freeze 14 | end 15 | 16 | def ===(value) 17 | @matchers.any? do |matcher| 18 | matcher === value 19 | end 20 | end 21 | 22 | freeze 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/leftovers/file_list.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fast_ignore' 4 | 5 | module Leftovers 6 | class FileList < ::FastIgnore 7 | def initialize(**arguments) 8 | super( 9 | ignore_rules: ::Leftovers.config.exclude_paths, 10 | include_rules: ::Leftovers.config.include_paths, 11 | root: ::Leftovers.pwd, 12 | **arguments 13 | ) 14 | end 15 | 16 | def each 17 | super { |file| yield(File.new(file)) } 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /bin/prof: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'ruby-prof' 5 | 6 | RubyProf.measure_mode = RubyProf::WALL_TIME 7 | # RubyProf.measure_mode = RubyProf::ALLOCATIONS 8 | # RubyProf.measure_mode = RubyProf::MEMORY 9 | 10 | RubyProf.start 11 | 12 | require_relative '../lib/leftovers' 13 | 14 | Leftovers::CLI.new(argv: ARGV).run 15 | 16 | profile = RubyProf.stop 17 | 18 | printer = RubyProf::GraphPrinter.new(profile) 19 | printer.print($stdout, min_percent: 2, sort_method: :self_time) 20 | -------------------------------------------------------------------------------- /lib/leftovers/ast/sym_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module AST 5 | class SymNode < Node 6 | alias_method :name, :first 7 | alias_method :to_scalar_value, :first 8 | alias_method :to_sym, :first 9 | 10 | def scalar? 11 | true 12 | end 13 | 14 | def to_s 15 | name.to_s 16 | end 17 | 18 | alias_method :to_literal_s, :to_s 19 | 20 | def sym? 21 | true 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/value_processor_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class ValueProcessorSchema < ValueOrObjectSchema 6 | inherit_attributes_from ValueMatcherSchema 7 | 8 | attribute :transforms, ValueOrArraySchema[TransformSchema], aliases: :transform 9 | inherit_attributes_from TransformSchema, require_group: nil, except: :transforms 10 | 11 | self.or_value_schema = ScalarArgumentSchema 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/node_has_positional_argument.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | class NodeHasPositionalArgument 6 | include ComparableInstance 7 | 8 | def initialize(position) 9 | @position = position 10 | 11 | freeze 12 | end 13 | 14 | def ===(node) 15 | args = node.positional_arguments 16 | 17 | args.length > @position if args 18 | end 19 | 20 | freeze 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/node_has_receiver.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | class NodeHasReceiver 6 | include ComparableInstance 7 | 8 | attr_reader :matcher 9 | 10 | def initialize(matcher) 11 | @matcher = matcher 12 | 13 | freeze 14 | end 15 | 16 | def ===(node) 17 | receiver = node.receiver 18 | @matcher === receiver if receiver 19 | end 20 | 21 | freeze 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/keyword_argument_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class KeywordArgumentSchema < ValueOrObjectSchema 6 | inherit_attributes_from StringPatternSchema, except: :unless 7 | attribute :type, ValueOrArraySchema[ValueTypeSchema], require_group: :matcher 8 | attribute :unless, ValueOrArraySchema[KeywordArgumentSchema], require_group: :matcher 9 | 10 | self.or_value_schema = StringSchema 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/leftovers/processors/itself.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class Itself 6 | include ComparableInstance 7 | 8 | def initialize(then_processor) 9 | @then_processor = then_processor 10 | 11 | freeze 12 | end 13 | 14 | def process(_str, current_node, matched_node, acc) 15 | @then_processor.process(current_node.to_s, current_node, matched_node, acc) 16 | end 17 | 18 | freeze 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/argument_position_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class ArgumentPositionSchema < ValueOrObjectSchema 6 | inherit_attributes_from StringPatternSchema, except: :unless 7 | attribute :type, ValueOrArraySchema[ValueTypeSchema], require_group: :matcher 8 | attribute :unless, ValueOrArraySchema[KeywordArgumentSchema], require_group: :matcher 9 | 10 | self.or_value_schema = ScalarArgumentSchema 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/node_has_any_positional_argument_with_value.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | class NodeHasAnyPositionalArgumentWithValue 6 | include ComparableInstance 7 | 8 | attr_reader :matcher 9 | 10 | def initialize(matcher) 11 | @matcher = matcher 12 | 13 | freeze 14 | end 15 | 16 | def ===(node) 17 | node.positional_arguments&.any?(@matcher) 18 | end 19 | 20 | freeze 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/has_receiver_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class HasReceiverSchema < ValueOrObjectSchema 6 | inherit_attributes_from HasValueSchema, except: :unless 7 | 8 | attribute :literal, ValueOrArraySchema[ScalarValueSchema], 9 | require_group: :matcher 10 | attribute :unless, ValueOrArraySchema[HasReceiverSchema], require_group: :matcher 11 | 12 | self.or_value_schema = ScalarValueSchema 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/has_argument_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class HasArgumentSchema < ValueOrObjectSchema 6 | attribute :at, ValueOrArraySchema[ArgumentPositionSchema], require_group: :matcher 7 | attribute :has_value, ValueOrArraySchema[HasValueSchema], require_group: :matcher 8 | attribute :unless, ValueOrArraySchema[HasArgumentSchema], require_group: :matcher 9 | 10 | self.or_value_schema = ScalarArgumentSchema 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/bool_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class BoolSchema < Schema 6 | class << self 7 | def validate(node) 8 | error(node, 'be true or false') if to_ruby(node).nil? 9 | super 10 | end 11 | 12 | def to_ruby(node) 13 | case node.to_ruby 14 | when true, 'true' then true 15 | when false, 'false' then false 16 | end 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/leftovers/precompilers/erb.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'erb' 4 | 5 | module Leftovers 6 | module Precompilers 7 | class ERB < ::ERB::Compiler 8 | def self.precompile(erb) 9 | @compiler ||= new('-') 10 | @compiler.compile(erb).first 11 | end 12 | 13 | def add_insert_cmd(out, content) # leftovers:keep 14 | out.push("\n#{content}\n") 15 | end 16 | 17 | def add_put_cmd(out, _content) # leftovers:keep 18 | out.push("\n") 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/leftovers/processors/upcase.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class Upcase 6 | include ComparableInstance 7 | 8 | def initialize(then_processor) 9 | @then_processor = then_processor 10 | 11 | freeze 12 | end 13 | 14 | def process(str, current_node, matched_node, acc) 15 | return unless str 16 | 17 | @then_processor.process(str.upcase, current_node, matched_node, acc) 18 | end 19 | 20 | freeze 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/leftovers/processors/downcase.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class Downcase 6 | include ComparableInstance 7 | 8 | def initialize(then_processor) 9 | @then_processor = then_processor 10 | 11 | freeze 12 | end 13 | 14 | def process(str, current_node, matched_node, acc) 15 | return unless str 16 | 17 | @then_processor.process(str.downcase, current_node, matched_node, acc) 18 | end 19 | 20 | freeze 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/leftovers/processors/swapcase.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class Swapcase 6 | include ComparableInstance 7 | 8 | def initialize(then_processor) 9 | @then_processor = then_processor 10 | 11 | freeze 12 | end 13 | 14 | def process(str, current_node, matched_node, acc) 15 | return unless str 16 | 17 | @then_processor.process(str.swapcase, current_node, matched_node, acc) 18 | end 19 | 20 | freeze 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/leftovers/processors/capitalize.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class Capitalize 6 | include ComparableInstance 7 | 8 | def initialize(then_processor) 9 | @then_processor = then_processor 10 | 11 | freeze 12 | end 13 | 14 | def process(str, current_node, matched_node, acc) 15 | return unless str 16 | 17 | @then_processor.process(str.capitalize, current_node, matched_node, acc) 18 | end 19 | 20 | freeze 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/leftovers/processors/replace_value.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class ReplaceValue 6 | include ComparableInstance 7 | 8 | def initialize(value, then_processor) 9 | @value = value 10 | @then_processor = then_processor 11 | 12 | freeze 13 | end 14 | 15 | def process(_str, current_node, matched_node, acc) 16 | @then_processor.process(@value, current_node, matched_node, acc) 17 | end 18 | 19 | freeze 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/scalar_value_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class ScalarValueSchema < Schema 6 | class << self 7 | def validate(node) 8 | error(node, 'be any scalar value') unless node.scalar? 9 | super 10 | end 11 | 12 | def to_ruby(node) 13 | if node.to_ruby.nil? 14 | :_leftovers_nil_value 15 | else 16 | node.to_ruby 17 | end 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/config_loader/suggester_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'did_you_mean' 4 | 5 | ::RSpec.describe ::Leftovers::ConfigLoader::Suggester do 6 | subject { described_class.new(%w{cat bar dog}) } 7 | 8 | describe '#suggest' do 9 | it 'returns spelling suggestions' do 10 | expect(subject.suggest('car')).to eq %w{cat bar} 11 | end 12 | 13 | it "returns all options if DidYouMean isn't loaded" do 14 | hide_const('DidYouMean') 15 | 16 | expect(subject.suggest('car')).to eq %w{cat bar dog} 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/string_pattern_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class StringPatternSchema < ValueOrObjectSchema 6 | attribute :match, RegexpSchema, aliases: :matches, require_group: :matcher 7 | attribute :has_prefix, StringSchema, require_group: :matcher 8 | attribute :has_suffix, StringSchema, require_group: :matcher 9 | attribute :unless, ValueOrArraySchema[StringPatternSchema], require_group: :matcher 10 | 11 | self.or_value_schema = StringSchema 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/leftovers/processors/each.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class Each 6 | include ComparableInstance 7 | 8 | attr_reader :processors 9 | 10 | def initialize(processors) 11 | @processors = processors 12 | 13 | freeze 14 | end 15 | 16 | def process(str, current_node, matched_node, acc) 17 | @processors.each do |processor| 18 | processor.process(str, current_node, matched_node, acc) 19 | end 20 | end 21 | 22 | freeze 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/value_matcher_condition_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class ValueMatcherConditionSchema < ObjectSchema 6 | attribute :has_arguments, ValueOrArraySchema[HasArgumentSchema], aliases: :has_argument 7 | attribute :has_receiver, ValueOrArraySchema[HasReceiverSchema] 8 | attribute :unless, ValueOrArraySchema[ValueMatcherConditionSchema] 9 | attribute :all, ArraySchema[ValueMatcherConditionSchema] 10 | attribute :any, ArraySchema[ValueMatcherConditionSchema] 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/node_scalar_value.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | class NodeScalarValue 6 | include ComparableInstance 7 | 8 | attr_reader :matcher 9 | 10 | def initialize(matcher) 11 | @matcher = matcher 12 | 13 | freeze 14 | end 15 | 16 | def ===(node) 17 | # can't just check to_scalar_value, it might be false/nil on purpose. 18 | return unless node.scalar? 19 | 20 | @matcher === node.to_scalar_value 21 | end 22 | 23 | freeze 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/leftovers/processors/add_prefix.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class AddPrefix 6 | include ComparableInstance 7 | 8 | def initialize(prefix, then_processor) 9 | @prefix = prefix 10 | @then_processor = then_processor 11 | 12 | freeze 13 | end 14 | 15 | def process(str, current_node, matched_node, acc) 16 | return unless str 17 | 18 | @then_processor.process("#{@prefix}#{str}", current_node, matched_node, acc) 19 | end 20 | 21 | freeze 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/leftovers/processors/add_suffix.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class AddSuffix 6 | include ComparableInstance 7 | 8 | def initialize(suffix, then_processor) 9 | @suffix = suffix 10 | @then_processor = then_processor 11 | 12 | freeze 13 | end 14 | 15 | def process(str, current_node, matched_node, acc) 16 | return unless str 17 | 18 | @then_processor.process("#{str}#{@suffix}", current_node, matched_node, acc) 19 | end 20 | 21 | freeze 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/leftovers/processors/receiver.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class Receiver 6 | include ComparableInstance 7 | 8 | def initialize(then_processor) 9 | @then_processor = then_processor 10 | 11 | freeze 12 | end 13 | 14 | def process(_str, current_node, matched_node, acc) 15 | receiver = matched_node.receiver 16 | return unless receiver 17 | 18 | @then_processor.process(receiver.to_s, current_node, matched_node, acc) 19 | end 20 | 21 | freeze 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/leftovers/precompilers/yaml.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'yaml' 4 | 5 | module Leftovers 6 | module Precompilers 7 | module YAML 8 | include Autoloader 9 | 10 | def self.precompile(yaml) 11 | builder = Builder.new 12 | parser = ::Psych::Parser.new(builder) 13 | parser.parse(yaml) 14 | 15 | builder.to_ruby_file 16 | rescue ::Psych::SyntaxError => e 17 | message = [e.problem, e.context].compact.join(' ') 18 | raise PrecompileError.new(message, line: e.line, column: e.column) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/leftovers/processors/delete_prefix.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class DeletePrefix 6 | include ComparableInstance 7 | 8 | def initialize(prefix, then_processor) 9 | @prefix = prefix 10 | @then_processor = then_processor 11 | 12 | freeze 13 | end 14 | 15 | def process(str, current_node, matched_node, acc) 16 | return unless str 17 | 18 | @then_processor.process(str.delete_prefix(@prefix), current_node, matched_node, acc) 19 | end 20 | 21 | freeze 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/leftovers/processors/delete_suffix.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class DeleteSuffix 6 | include ComparableInstance 7 | 8 | def initialize(suffix, then_processor) 9 | @suffix = suffix 10 | @then_processor = then_processor 11 | 12 | freeze 13 | end 14 | 15 | def process(str, current_node, matched_node, acc) 16 | return unless str 17 | 18 | @then_processor.process(str.delete_suffix(@suffix), current_node, matched_node, acc) 19 | end 20 | 21 | freeze 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/argumentless_transform_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class ArgumentlessTransformSchema < StringEnumSchema 6 | value :original 7 | value :pluralize 8 | value :singularize 9 | value :camelize, aliases: :camelcase 10 | value :underscore 11 | value :titleize, aliases: :titlecase 12 | value :demodulize 13 | value :deconstantize 14 | value :parameterize 15 | value :downcase 16 | value :upcase 17 | value :capitalize 18 | value :swapcase 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/suggester.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class Suggester 6 | def initialize(words) 7 | @words = words 8 | @did_you_mean = ::DidYouMean::SpellChecker.new(dictionary: words) if defined?(::DidYouMean) 9 | end 10 | 11 | def suggest(word) 12 | suggestions = did_you_mean.correct(word) if did_you_mean 13 | suggestions = words if !suggestions || suggestions.empty? 14 | suggestions 15 | end 16 | 17 | private 18 | 19 | attr_reader :words, :did_you_mean 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/config/rails.yml: -------------------------------------------------------------------------------- 1 | # THIS IS INCOMPLETE (you can help by expanding it) 2 | # rails is _really complicated_ and has a lot of magic which calls methods for you. 3 | # some is currently impossible to handle (with_options). 4 | # Some is just corners of rails I haven't hit yet. 5 | gems: 6 | - activesupport 7 | - actionpack 8 | - actionview 9 | - activemodel 10 | - activerecord 11 | - actionmailer 12 | - activejob 13 | - actioncable 14 | - activestorage 15 | - actionmailbox 16 | - actiontext 17 | - railties 18 | 19 | keep: 20 | - default_url_options # called by url_for, unsure what gem does 21 | - APP_PATH 22 | - APP_ROOT 23 | -------------------------------------------------------------------------------- /lib/leftovers/precompilers/json.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | 5 | module Leftovers 6 | module Precompilers 7 | module JSON 8 | class << self 9 | def precompile(json) 10 | "__leftovers_document(#{to_ruby_argument(::JSON.parse(json))})" 11 | end 12 | 13 | private 14 | 15 | def to_ruby_argument(value) 16 | ruby = value.inspect 17 | return ruby unless value.is_a?(::Array) 18 | 19 | ruby.delete_prefix!('[') 20 | ruby.delete_suffix!(']') 21 | 22 | ruby 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders/node_has_receiver.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module MatcherBuilders 5 | module NodeHasReceiver 6 | class << self 7 | def build(pattern) 8 | case pattern 9 | when true 10 | Matchers::NodeHasAnyReceiver 11 | when false, :_leftovers_nil_value 12 | Matchers::Not.new(Matchers::NodeHasAnyReceiver) 13 | else 14 | matcher = NodeValue.build(pattern) 15 | 16 | Matchers::NodeHasReceiver.new(matcher) if matcher 17 | end 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/node_has_positional_argument_with_value.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | class NodeHasPositionalArgumentWithValue 6 | include ComparableInstance 7 | 8 | def initialize(position, matcher) 9 | @position = position 10 | @matcher = matcher 11 | 12 | freeze 13 | end 14 | 15 | def ===(node) 16 | args = node.positional_arguments 17 | return unless args 18 | 19 | value_node = args[@position] 20 | @matcher === value_node if value_node 21 | end 22 | 23 | freeze 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /bin/time: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # use mac terminal not vs code terminal 5 | # ensure nothing else is watching that dir in the filesystem 6 | 7 | require 'open3' 8 | require 'shellwords' 9 | 10 | RUNS = 10 11 | SCRIPT = "time #{__dir__}/../exe/leftovers #{Shellwords.join(ARGV)}" 12 | 13 | times = Array.new(RUNS).map do 14 | run_times = Open3.capture3(SCRIPT)[1].chomp.split("\n").last 15 | puts run_times.lstrip 16 | run_times.scan(/(?:\d+(?:.\d+)?)/) 17 | end 18 | 19 | puts format( 20 | "\e[1mAverage:\n\e[32m%0.2f real%13.2f user%13.2f sys\e[0m", 21 | *times.transpose.map { |n| (n.sum(&:to_f) / RUNS) } 22 | ) 23 | -------------------------------------------------------------------------------- /lib/config/redcarpet.yml: -------------------------------------------------------------------------------- 1 | keep: 2 | - autolink 3 | - block_code 4 | - block_html 5 | - block_quote 6 | - codespan 7 | - doc_footer 8 | - doc_header 9 | - double_emphasis 10 | - emphasis 11 | - entity 12 | - footnote_def 13 | - footnote_ref 14 | - footnotes 15 | - header 16 | - highlight 17 | - hrule 18 | - image 19 | - linebreak 20 | - link 21 | - list 22 | - list_item 23 | - normal_text 24 | - paragraph 25 | - postprocess 26 | - preprocess 27 | - quote 28 | - raw_html 29 | - strikethrough 30 | - superscript 31 | - table 32 | - table_cell 33 | - table_row 34 | - triple_emphasis 35 | - underline 36 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/inherit_schema_attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class InheritSchemaAttributes 6 | def initialize(schema, require_group: true, except: nil) 7 | @schema = schema 8 | @use_require_groups = require_group 9 | @except = Array(except) 10 | end 11 | 12 | def attributes 13 | @schema.attributes.map do |attr| 14 | next if @except.include?(attr.name) 15 | next attr.without_require_group unless @use_require_groups 16 | 17 | attr 18 | end.compact 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/leftovers/matchers/node_has_any_keyword_argument.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Matchers 5 | class NodeHasAnyKeywordArgument 6 | include ComparableInstance 7 | 8 | attr_reader :matcher 9 | 10 | def initialize(matcher) 11 | @matcher = matcher 12 | 13 | freeze 14 | end 15 | 16 | def ===(node) 17 | kwargs = node.kwargs 18 | 19 | kwargs.children.any?(@matcher) if kwargs # rubocop:disable Style/SafeNavigation because there are multiple steps and this should be a configuration option 20 | end 21 | 22 | freeze 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/leftovers/processors/split.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class Split 6 | include ComparableInstance 7 | 8 | def initialize(split_on, then_processor) 9 | @split_on = split_on 10 | @then_processor = then_processor 11 | 12 | freeze 13 | end 14 | 15 | def process(str, current_node, matched_node, acc) 16 | return unless str 17 | 18 | str.split(@split_on).each do |sub_str| 19 | @then_processor.process(sub_str, current_node, matched_node, acc) 20 | end 21 | end 22 | 23 | freeze 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/config/activestorage.yml: -------------------------------------------------------------------------------- 1 | # https://guides.rubyonrails.org/active_storage_overview.html 2 | gems: 3 | - activesupport 4 | - actionpack 5 | - activejob 6 | - activerecord 7 | 8 | dynamic: 9 | - name: has_one_attached 10 | defines: 11 | argument: 0 12 | transforms: 13 | - original 14 | - add_prefix: with_attached_ 15 | - add_suffix: _attachment 16 | - add_suffix: _blob 17 | 18 | - name: has_many_attached 19 | defines: 20 | argument: 0 21 | transforms: 22 | - original 23 | - add_prefix: with_attached 24 | - add_suffix: _attachments 25 | - add_suffix: _blobs 26 | 27 | -------------------------------------------------------------------------------- /lib/leftovers/processors/match_current_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class MatchCurrentNode 6 | include ComparableInstance 7 | 8 | attr_reader :matcher, :then_processor 9 | 10 | def initialize(matcher, then_processor) 11 | @matcher = matcher 12 | @then_processor = then_processor 13 | 14 | freeze 15 | end 16 | 17 | def process(str, current_node, matched_node, acc) 18 | return unless @matcher === current_node 19 | 20 | @then_processor.process(str, current_node, matched_node, acc) 21 | end 22 | 23 | freeze 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/leftovers/processors/match_matched_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class MatchMatchedNode 6 | include ComparableInstance 7 | 8 | attr_reader :matcher, :then_processor 9 | 10 | def initialize(matcher, then_processor) 11 | @matcher = matcher 12 | @then_processor = then_processor 13 | 14 | freeze 15 | end 16 | 17 | def process(str, current_node, matched_node, acc) 18 | return unless @matcher === matched_node 19 | 20 | @then_processor.process(str, current_node, matched_node, acc) 21 | end 22 | 23 | freeze 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'simplecov-console' 5 | 6 | SimpleCov.enable_coverage(:branch) 7 | SimpleCov.root __dir__ 8 | SimpleCov.add_filter '/spec/' 9 | SimpleCov.add_filter 'lib/leftovers/rake_task.rb' # TODO 10 | SimpleCov.add_filter 'lib/leftovers/version.rb' # loads early 11 | SimpleCov.track_files 'lib/**/*.rb' 12 | SimpleCov.enable_for_subprocesses true 13 | SimpleCov.print_error_status = true 14 | SimpleCov.minimum_coverage line: 100, branch: 100 15 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ 16 | SimpleCov::Formatter::HTMLFormatter, 17 | SimpleCov::Formatter::Console 18 | ]) 19 | 20 | SimpleCov.start 21 | -------------------------------------------------------------------------------- /lib/config/railties.yml: -------------------------------------------------------------------------------- 1 | gems: 2 | - actionpack 3 | - activesupport 4 | 5 | keep: 6 | - # https://guides.rubyonrails.org/generators.html#generators-lookup 7 | path: '**/generators/**/*_generator.rb' 8 | # "When a generator is invoked, each public method in the generator is executed sequentially in 9 | # the order that it is defined When a generator is invoked, each public method in the generator 10 | # is executed sequentially in the order that it is defined" 11 | # 12 | # https://guides.rubyonrails.org/generators.html 13 | privacy: public 14 | type: Method 15 | 16 | - path: '**/generators/**/*_generator.rb' 17 | name: { has_suffix: Generator } 18 | 19 | -------------------------------------------------------------------------------- /lib/leftovers/processors/delete_after.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class DeleteAfter 6 | include ComparableInstance 7 | 8 | def initialize(delete_after, then_processor) 9 | @delete_after = delete_after 10 | @then_processor = then_processor 11 | 12 | freeze 13 | end 14 | 15 | def process(str, current_node, matched_node, acc) 16 | return unless str 17 | 18 | index = str.index(@delete_after) 19 | str = str[0...index] if index 20 | @then_processor.process(str, current_node, matched_node, acc) 21 | end 22 | 23 | freeze 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/leftovers/processors/delete_after_last.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class DeleteAfterLast 6 | include ComparableInstance 7 | 8 | def initialize(delete_after, then_processor) 9 | @delete_after = delete_after 10 | @then_processor = then_processor 11 | 12 | freeze 13 | end 14 | 15 | def process(str, current_node, matched_node, acc) 16 | return unless str 17 | 18 | index = str.rindex(@delete_after) 19 | str = str[0...index] if index 20 | @then_processor.process(str, current_node, matched_node, acc) 21 | end 22 | 23 | freeze 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/leftovers/processor_builders/keyword.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module ProcessorBuilders 5 | module Keyword 6 | def self.build(value, then_processor) 7 | return unless value 8 | 9 | then_processor = case value 10 | when true, '**' then then_processor 11 | when ::String, ::Hash, ::Array 12 | Processors::MatchCurrentNode.new( 13 | MatcherBuilders::NodeName.build(value), then_processor 14 | ) 15 | # :nocov: 16 | else raise UnexpectedCase, "Unhandled value #{value.inspect}" 17 | # :nocov: 18 | end 19 | 20 | Processors::EachKeyword.new(then_processor) 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/leftovers/processors/each_keyword.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class EachKeyword 6 | include ComparableInstance 7 | 8 | def initialize(then_processor) 9 | @then_processor = then_processor 10 | 11 | freeze 12 | end 13 | 14 | def process(_str, current_node, matched_node, acc) 15 | kwargs = current_node.kwargs 16 | return unless kwargs 17 | 18 | kwargs.children.each do |pair| 19 | next unless pair.type == :pair 20 | 21 | key_node = pair.first 22 | @then_processor.process(key_node.to_literal_s, key_node, matched_node, acc) 23 | end 24 | end 25 | 26 | freeze 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/leftovers/processors/each_positional_argument.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class EachPositionalArgument 6 | include ComparableInstance 7 | 8 | def initialize(then_processor) 9 | @then_processor = then_processor 10 | 11 | freeze 12 | end 13 | 14 | def process(_str, current_node, matched_node, acc) 15 | positional_arguments = current_node.positional_arguments 16 | 17 | return unless positional_arguments 18 | 19 | positional_arguments.each do |argument_node| 20 | @then_processor.process(argument_node.to_literal_s, argument_node, matched_node, acc) 21 | end 22 | end 23 | 24 | freeze 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class Schema 6 | class << self 7 | def error(node, requirement) 8 | node.error = "#{node.name_}must #{requirement}" 9 | 10 | false 11 | end 12 | 13 | def validate(node) 14 | node.valid? 15 | end 16 | 17 | def to_ruby(node) 18 | node.to_ruby 19 | end 20 | 21 | def ===(other) # leftovers:test_only 22 | # :nocov: 23 | if other.is_a?(::Module) 24 | self >= other 25 | else 26 | other.is_a?(self) 27 | end 28 | # :nocov: 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/leftovers/precompile_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class PrecompileError < Error 5 | attr_reader :line, :column 6 | 7 | def initialize(message, line: nil, column: nil, display_class: nil) 8 | @line = line 9 | @column = column 10 | @display_class = display_class 11 | super(message) 12 | end 13 | 14 | def warn(path:) 15 | ::Leftovers.warn "#{display_class}: #{path}#{location} #{message}" 16 | end 17 | 18 | private 19 | 20 | def display_class 21 | @display_class || cause&.class || self.class 22 | end 23 | 24 | def location 25 | return unless line 26 | return ":#{line}" unless column 27 | 28 | ":#{line}:#{column}" 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/leftovers/precompilers/precompiler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | 5 | module Leftovers 6 | module Precompilers 7 | class Precompiler 8 | def initialize(precompiler, matcher) 9 | @precompiler = precompiler 10 | @matcher = matcher 11 | end 12 | 13 | def precompile(content, file) 14 | return unless @matcher === file.relative_path 15 | 16 | begin 17 | @precompiler.precompile(content) 18 | rescue PrecompileError => e 19 | e.warn(path: file.relative_path) 20 | '' 21 | rescue ::StandardError => e 22 | ::Leftovers.warn "#{e.class}: #{file.relative_path} #{e.message}" 23 | '' 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/leftovers/ast/has_arguments.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module AST 5 | module HasArguments 6 | def positional_arguments 7 | @memo.fetch(:positional_arguments) do 8 | @memo[:positional_arguments] = begin 9 | if kwargs 10 | arguments[0...-1] 11 | else 12 | arguments 13 | end 14 | end 15 | end 16 | end 17 | 18 | def kwargs 19 | @memo.fetch(:kwargs) do 20 | @memo[:kwargs] = begin 21 | args = arguments 22 | next unless args 23 | 24 | last_arg = args[-1] 25 | last_arg if last_arg&.hash? 26 | end 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/regexp_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class RegexpSchema < StringSchema 6 | class << self 7 | def validate(node) 8 | validate_string(node) && validate_regexp(node) 9 | end 10 | 11 | def validate_string(node) 12 | error(node, 'be a string with a valid ruby regexp') unless node.string? 13 | 14 | node.valid? 15 | end 16 | 17 | def validate_regexp(node) 18 | /#{node.to_ruby}/ 19 | rescue ::RegexpError, ::ArgumentError => e 20 | error(node, "be a string with a valid ruby regexp (#{e.message})") 21 | else 22 | true 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/leftovers/processors/delete_before.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class DeleteBefore 6 | include ComparableInstance 7 | 8 | def initialize(delete_before, then_processor) 9 | @delete_before = delete_before 10 | @delete_before_length = delete_before.length 11 | @then_processor = then_processor 12 | 13 | freeze 14 | end 15 | 16 | def process(str, current_node, matched_node, acc) 17 | return unless str 18 | 19 | index = str.index(@delete_before) 20 | str = str[(index + @delete_before_length)..-1] if index 21 | @then_processor.process(str, current_node, matched_node, acc) 22 | end 23 | 24 | freeze 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/leftovers/processors/each_keyword_argument.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class EachKeywordArgument 6 | include ComparableInstance 7 | 8 | def initialize(then_processor) 9 | @then_processor = then_processor 10 | 11 | freeze 12 | end 13 | 14 | def process(_str, current_node, matched_node, acc) 15 | kwargs = current_node.kwargs 16 | return unless kwargs 17 | 18 | kwargs.children.each do |pair| 19 | next unless pair.type == :pair 20 | 21 | value_node = pair.second 22 | @then_processor.process(value_node.to_literal_s, value_node, matched_node, acc) 23 | end 24 | end 25 | 26 | freeze 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/leftovers/file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'pathname' 4 | 5 | module Leftovers 6 | class File < ::Pathname 7 | def relative_path 8 | @relative_path ||= begin 9 | relative_path_from(::Leftovers.pwd) 10 | rescue ::ArgumentError 11 | self 12 | end 13 | end 14 | 15 | def test? 16 | return @test if defined?(@test) 17 | 18 | @test = ::Leftovers.config.test_paths === relative_path 19 | end 20 | 21 | def ruby 22 | read = self.read 23 | 24 | precompiled = ::Leftovers.config.precompilers.map do |precompiler| 25 | precompiler.precompile(read, self) 26 | end.compact 27 | 28 | return read if precompiled.empty? 29 | 30 | precompiled.join("\n") 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/leftovers/processors/delete_before_last.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class DeleteBeforeLast 6 | include ComparableInstance 7 | 8 | def initialize(delete_before, then_processor) 9 | @delete_before = delete_before 10 | @delete_before_length = delete_before.length 11 | @then_processor = then_processor 12 | 13 | freeze 14 | end 15 | 16 | def process(str, current_node, matched_node, acc) 17 | return unless str 18 | 19 | index = str.rindex(@delete_before) 20 | str = str[(index + @delete_before_length)..-1] if index 21 | @then_processor.process(str, current_node, matched_node, acc) 22 | end 23 | 24 | freeze 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/dynamic_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class DynamicSchema < ObjectSchema 6 | inherit_attributes_from RulePatternSchema 7 | 8 | attribute :call, ValueOrArraySchema[ValueProcessorSchema], aliases: :calls, 9 | require_group: :processor 10 | attribute :define, ValueOrArraySchema[ValueProcessorSchema], aliases: :defines, 11 | require_group: :processor 12 | attribute :set_privacy, ValueOrArraySchema[PrivacyProcessorSchema], 13 | require_group: :processor 14 | attribute :set_default_privacy, PrivacySchema, require_group: :processor 15 | attribute :eval, ValueOrArraySchema[ValueProcessorSchema], require_group: :processor 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders/name.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module MatcherBuilders 5 | module Name 6 | class << self 7 | def build(patterns) 8 | Or.each_or_self(patterns) do |pat| 9 | case pat 10 | when ::String then String.build(pat) 11 | when ::Hash then build_from_hash(**pat) 12 | # :nocov: 13 | else raise UnexpectedCase, "Unhandled value #{pat.inspect}" 14 | # :nocov: 15 | end 16 | end 17 | end 18 | 19 | private 20 | 21 | def build_from_hash(unless_arg: nil, **pattern) 22 | And.build([StringPattern.build(**pattern), Unless.build(build(unless_arg))]) 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/leftovers/processors/add_dynamic_prefix.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class AddDynamicPrefix 6 | include ComparableInstance 7 | 8 | def initialize(prefix_processor, then_processor) 9 | @prefix_processor = prefix_processor 10 | @then_processor = then_processor 11 | 12 | freeze 13 | end 14 | 15 | def process(str, current_node, matched_node, acc) 16 | return unless str 17 | 18 | prefixes = [] 19 | @prefix_processor.process(nil, matched_node, matched_node, prefixes) 20 | 21 | prefixes.each do |prefix| 22 | @then_processor.process("#{prefix}#{str}", current_node, matched_node, acc) 23 | end 24 | end 25 | 26 | freeze 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/leftovers/processors/add_dynamic_suffix.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class AddDynamicSuffix 6 | include ComparableInstance 7 | 8 | def initialize(suffix_processor, then_processor) 9 | @suffix_processor = suffix_processor 10 | @then_processor = then_processor 11 | 12 | freeze 13 | end 14 | 15 | def process(str, current_node, matched_node, acc) 16 | return unless str 17 | 18 | suffixes = [] 19 | @suffix_processor.process(nil, matched_node, matched_node, suffixes) 20 | 21 | suffixes.each do |suffix| 22 | @then_processor.process("#{str}#{suffix}", current_node, matched_node, acc) 23 | end 24 | end 25 | 26 | freeze 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/leftovers/processors/keyword_argument.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class KeywordArgument 6 | include ComparableInstance 7 | 8 | def initialize(matcher, then_processor) 9 | @matcher = matcher 10 | @then_processor = then_processor 11 | 12 | freeze 13 | end 14 | 15 | def process(_str, current_node, matched_node, acc) 16 | kwargs = current_node.kwargs 17 | return unless kwargs 18 | 19 | kwargs.children.each do |pair| 20 | next unless @matcher === pair 21 | 22 | value_node = pair.second 23 | @then_processor.process(value_node.to_literal_s, value_node, matched_node, acc) 24 | end 25 | end 26 | 27 | freeze 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/leftovers/processors/positional_argument.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class PositionalArgument 6 | include ComparableInstance 7 | 8 | def initialize(index, then_processor) 9 | @index = index 10 | @then_processor = then_processor 11 | 12 | freeze 13 | end 14 | 15 | def process(_str, current_node, matched_node, acc) 16 | positional_arguments = current_node.positional_arguments 17 | return unless positional_arguments 18 | 19 | argument_node = positional_arguments[@index] 20 | return unless argument_node 21 | 22 | @then_processor.process(argument_node.to_literal_s, argument_node, matched_node, acc) 23 | end 24 | 25 | freeze 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/leftovers/runner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class Runner 5 | attr_writer :reporter 6 | 7 | def run 8 | reporter.prepare 9 | return reporter.report_success if collection.empty? 10 | 11 | reporter.report(collection) 12 | end 13 | 14 | def parallel=(value) 15 | collector.parallel = value 16 | end 17 | 18 | def progress=(value) 19 | collector.progress = value 20 | end 21 | 22 | def collection 23 | @collection ||= begin 24 | collector.collect 25 | 26 | collector.collection 27 | end 28 | end 29 | 30 | private 31 | 32 | def reporter 33 | @reporter ||= Reporter 34 | end 35 | 36 | def collector 37 | @collector ||= Collector.new 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/value_or_array_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class ValueOrArraySchema < ArraySchema 6 | def validate(node) 7 | if node.array? 8 | validate_length(node) && validate_values(node) 9 | else 10 | validate_or_schema(node) 11 | end 12 | end 13 | 14 | def to_ruby(node) 15 | if node.array? 16 | ::Leftovers.unwrap_array(super) 17 | else 18 | value_schema.to_ruby(node) 19 | end 20 | end 21 | 22 | private 23 | 24 | def validate_or_schema(node) 25 | value_schema.validate(node) 26 | return true if node.valid? 27 | 28 | node.error += ' or an array' 29 | 30 | false 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/leftovers/processor_builders/add_prefix.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module ProcessorBuilders 5 | module AddPrefix 6 | class << self 7 | def build(argument, then_processor) 8 | case argument 9 | when ::Hash then build_hash(argument, then_processor) 10 | when ::String then Processors::AddPrefix.new(argument, then_processor) 11 | # :nocov: 12 | else raise UnexpectedCase, "Unhandled value #{argument.inspect}" 13 | # :nocov: 14 | end 15 | end 16 | 17 | private 18 | 19 | def build_hash(argument, then_processor) 20 | dynamic_prefix = Action.build(argument, Processors::AppendSym) 21 | Processors::AddDynamicPrefix.new(dynamic_prefix, then_processor) 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/leftovers/processor_builders/add_suffix.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module ProcessorBuilders 5 | module AddSuffix 6 | class << self 7 | def build(argument, then_processor) 8 | case argument 9 | when ::Hash then build_hash(argument, then_processor) 10 | when ::String then Processors::AddSuffix.new(argument, then_processor) 11 | # :nocov: 12 | else raise UnexpectedCase, "Unhandled value #{argument.inspect}" 13 | # :nocov: 14 | end 15 | end 16 | 17 | private 18 | 19 | def build_hash(argument, then_processor) 20 | dynamic_suffix = Action.build(argument, Processors::AppendSym) 21 | Processors::AddDynamicSuffix.new(dynamic_suffix, then_processor) 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/leftovers/processors/each_positional_argument_from.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class EachPositionalArgumentFrom 6 | include ComparableInstance 7 | 8 | def initialize(position, then_processor) 9 | @position = position 10 | @then_processor = then_processor 11 | 12 | freeze 13 | end 14 | 15 | def process(_str, current_node, matched_node, acc) 16 | positional_arguments = current_node.positional_arguments 17 | 18 | return unless positional_arguments 19 | 20 | positional_arguments.each_with_index do |argument_node, index| 21 | next if index < @position 22 | 23 | @then_processor.process(argument_node.to_literal_s, argument_node, matched_node, acc) 24 | end 25 | end 26 | 27 | freeze 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/config/actionmailbox.yml: -------------------------------------------------------------------------------- 1 | # https://guides.rubyonrails.org/action_mailbox_basics.html 2 | 3 | gems: 4 | - activesupport 5 | - activerecord 6 | - activestorage 7 | - activejob 8 | - actionpack 9 | 10 | keep: 11 | # https://api.rubyonrails.org/v7.0.2.2/classes/ActionMailbox/Base.html 12 | # Overwrite in subclasses 13 | - process 14 | 15 | dynamic: 16 | # https://api.rubyonrails.org/v7.0.2.2/classes/ActionMailbox/Base.html 17 | - names: [before_processing, after_processing, around_processing] 18 | calls: 19 | - arguments: '*' 20 | - arguments: [if, unless] 21 | nested: '*' 22 | 23 | # https://guides.rubyonrails.org/action_mailbox_basics.html#examples 24 | # i'm guessing a lot about how this is supposed to work 25 | - names: routing 26 | calls: 27 | arguments: '**' 28 | camelize: true 29 | add_suffix: Mailbox 30 | 31 | 32 | -------------------------------------------------------------------------------- /lib/leftovers/definition_to_add.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class DefinitionToAdd 5 | attr_reader :node, :name, :location 6 | 7 | def initialize(node, name: node.name, location: node.loc.name) 8 | @node = node 9 | @name = name 10 | @location = location 11 | end 12 | 13 | def privacy=(value) 14 | @node.privacy = value 15 | end 16 | 17 | def keep?(file_collector) 18 | @keep ||= file_collector.keep_line?(location.line) || ::Leftovers.config.keep === node 19 | end 20 | 21 | def test?(file_collector) 22 | file_collector.test_line?(location.line) || ::Leftovers.config.test_only === node 23 | end 24 | 25 | def to_definition(file_collector) 26 | return if keep?(file_collector) 27 | 28 | Definition.new(name, location: location, test: test?(file_collector)) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/leftovers/processors/camelize.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class Camelize 6 | include ComparableInstance 7 | 8 | def initialize(then_processor) 9 | @then_processor = then_processor 10 | 11 | freeze 12 | end 13 | 14 | def process(str, current_node, matched_node, acc) 15 | return unless str 16 | 17 | @then_processor.process(str.camelize, current_node, matched_node, acc) 18 | rescue ::NoMethodError 19 | ::Leftovers.error <<~MESSAGE 20 | Tried using the ::String#camelize method, but the activesupport gem was not available and/or not required 21 | `gem install activesupport`, and/or add `requires: ['active_support', 'active_support/core_ext/string']` to your .leftovers.yml 22 | MESSAGE 23 | end 24 | 25 | freeze 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/leftovers/processors/titleize.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class Titleize 6 | include ComparableInstance 7 | 8 | def initialize(then_processor) 9 | @then_processor = then_processor 10 | 11 | freeze 12 | end 13 | 14 | def process(str, current_node, matched_node, acc) 15 | return unless str 16 | 17 | @then_processor.process(str.titleize, current_node, matched_node, acc) 18 | rescue ::NoMethodError 19 | ::Leftovers.error <<~MESSAGE 20 | Tried using the ::String#titleize method, but the activesupport gem was not available and/or not required 21 | `gem install activesupport`, and/or add `requires: ['active_support', 'active_support/core_ext/string']` to your .leftovers.yml 22 | MESSAGE 23 | end 24 | 25 | freeze 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/leftovers/processors/pluralize.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class Pluralize 6 | include ComparableInstance 7 | 8 | def initialize(then_processor) 9 | @then_processor = then_processor 10 | 11 | freeze 12 | end 13 | 14 | def process(str, current_node, matched_node, acc) 15 | return unless str 16 | 17 | @then_processor.process(str.pluralize, current_node, matched_node, acc) 18 | rescue ::NoMethodError 19 | ::Leftovers.error <<~MESSAGE 20 | Tried using the ::String#pluralize method, but the activesupport gem was not available and/or not required 21 | `gem install activesupport`, and/or add `requires: ['active_support', 'active_support/core_ext/string']` to your .leftovers.yml 22 | MESSAGE 23 | end 24 | 25 | freeze 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /.spellr_wordlists/ruby.txt: -------------------------------------------------------------------------------- 1 | activesupport 2 | asgn 3 | ast 4 | autolink 5 | bigint 6 | camelcase 7 | camelize 8 | capfile 9 | casgn 10 | cattr 11 | changelog 12 | codespan 13 | configs 14 | coverband 15 | csend 16 | cvasgn 17 | cyclomatic 18 | deconstantize 19 | demodulize 20 | diffable 21 | dstr 22 | dsym 23 | eflipflop 24 | encryptor 25 | gitignore 26 | guardfile 27 | gvasgn 28 | haml 29 | hrule 30 | iflipflop 31 | irange 32 | ivasgn 33 | jbuilder 34 | kwbegin 35 | kwoptarg 36 | kwrestarg 37 | linebreak 38 | lvars 39 | lvasgn 40 | masgn 41 | mattr 42 | memoizations 43 | optarg 44 | pascalcase 45 | postexe 46 | postprocess 47 | precompile 48 | preexe 49 | preload 50 | procarg 51 | pryrc 52 | regexps 53 | resbody 54 | restarg 55 | rhtml 56 | rjs 57 | rspec 58 | rubocop 59 | shadowarg 60 | simplecov 61 | stdlib 62 | strikethrough 63 | subnodes 64 | syms 65 | titlecase 66 | titleize 67 | usr 68 | vasgn 69 | xcontext 70 | xstr 71 | -------------------------------------------------------------------------------- /lib/leftovers/processors/demodulize.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class Demodulize 6 | include ComparableInstance 7 | 8 | def initialize(then_processor) 9 | @then_processor = then_processor 10 | 11 | freeze 12 | end 13 | 14 | def process(str, current_node, matched_node, acc) 15 | return unless str 16 | 17 | @then_processor.process(str.demodulize, current_node, matched_node, acc) 18 | rescue ::NoMethodError 19 | ::Leftovers.error <<~MESSAGE 20 | Tried using the ::String#demodulize method, but the activesupport gem was not available and/or not required 21 | `gem install activesupport`, and/or add `requires: ['active_support', 'active_support/core_ext/string']` to your .leftovers.yml 22 | MESSAGE 23 | end 24 | 25 | freeze 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/leftovers/processors/underscore.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class Underscore 6 | include ComparableInstance 7 | 8 | def initialize(then_processor) 9 | @then_processor = then_processor 10 | 11 | freeze 12 | end 13 | 14 | def process(str, current_node, matched_node, acc) 15 | return unless str 16 | 17 | @then_processor.process(str.underscore, current_node, matched_node, acc) 18 | rescue ::NoMethodError 19 | ::Leftovers.error <<~MESSAGE 20 | Tried using the ::String#underscore method, but the activesupport gem was not available and/or not required 21 | `gem install activesupport`, and/or add `requires: ['active_support', 'active_support/core_ext/string']` to your .leftovers.yml 22 | MESSAGE 23 | end 24 | 25 | freeze 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/config_fuzz_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'support/config_fuzzer' 4 | 5 | ::RSpec.describe ::Leftovers::Config do 6 | next if ::ENV['COVERAGE'] 7 | 8 | config_methods = described_class.new(:rails).public_methods - ::Class.new.new.public_methods 9 | 10 | describe 'fuzzed config' do 11 | ::ENV.fetch('FUZZ_ITERATIONS', 10).to_i.times do |n| 12 | context "iteration #{n}" do # rubocop:disable RSpec/ContextWording 13 | let(:yaml) { ::Leftovers::ConfigLoader::Fuzzer.new(n).to_yaml } 14 | 15 | it do 16 | puts yaml 17 | 18 | expect do 19 | described_class.new('fuzz', content: yaml).tap do |c| 20 | config_methods.each { |method| c.send(method) } 21 | end 22 | end.not_to throw_symbol(:leftovers_exit) 23 | 24 | expects_output! 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/leftovers/processors/singularize.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class Singularize 6 | include ComparableInstance 7 | 8 | def initialize(then_processor) 9 | @then_processor = then_processor 10 | 11 | freeze 12 | end 13 | 14 | def process(str, current_node, matched_node, acc) 15 | return unless str 16 | 17 | @then_processor.process(str.singularize, current_node, matched_node, acc) 18 | rescue ::NoMethodError 19 | ::Leftovers.error <<~MESSAGE 20 | Tried using the ::String#singularize method, but the activesupport gem was not available and/or not required 21 | `gem install activesupport`, and/or add `requires: ['active_support', 'active_support/core_ext/string']` to your .leftovers.yml 22 | MESSAGE 23 | end 24 | 25 | freeze 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/config_documentation_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fast_ignore' 4 | 5 | ::RSpec.describe ::Leftovers::Config do 6 | config_methods = described_class.new(:rails).public_methods - ::Class.new.new.public_methods 7 | 8 | describe 'config in documentation' do 9 | files = ::FastIgnore.new(include_rules: ['*.md', '!CHANGELOG.md', '!vendor']) 10 | files.each do |file| 11 | file = ::Leftovers::File.new(file) 12 | file.read.scan(/(?<=```yml\n)[^`]*(?=\n```\n)/).each.with_index(1) do |yaml, index| 13 | it "#{file.relative_path} example #{index}\n\e[0m#{yaml}" do 14 | expect do 15 | described_class.new('docs', path: "#{file}:#{index}", content: yaml).tap do |c| 16 | config_methods.each { |method| c.send(method) } 17 | end 18 | end.not_to output.to_stderr 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/leftovers/processors/deconstantize.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class Deconstantize 6 | include ComparableInstance 7 | 8 | def initialize(then_processor) 9 | @then_processor = then_processor 10 | 11 | freeze 12 | end 13 | 14 | def process(str, current_node, matched_node, acc) 15 | return unless str 16 | 17 | @then_processor.process(str.deconstantize, current_node, matched_node, acc) 18 | rescue ::NoMethodError 19 | ::Leftovers.error <<~MESSAGE 20 | Tried using the ::String#deconstantize method, but the activesupport gem was not available and/or not required 21 | `gem install activesupport`, and/or add `requires: ['active_support', 'active_support/core_ext/string']` to your .leftovers.yml 22 | MESSAGE 23 | end 24 | 25 | freeze 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/leftovers/processors/parameterize.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class Parameterize 6 | include ComparableInstance 7 | 8 | def initialize(then_processor) 9 | @then_processor = then_processor 10 | 11 | freeze 12 | end 13 | 14 | def process(str, current_node, matched_node, acc) 15 | return unless str 16 | 17 | @then_processor.process(str.parameterize, current_node, matched_node, acc) 18 | rescue ::NoMethodError 19 | ::Leftovers.error <<~MESSAGE 20 | Tried using the ::String#parameterize method, but the activesupport gem was not available and/or not required 21 | `gem install activesupport`, and/or add `requires: ['active_support', 'active_support/core_ext/string']` to your .leftovers.yml 22 | MESSAGE 23 | end 24 | 25 | freeze 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders/node_has_keyword_argument.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module MatcherBuilders 5 | module NodeHasKeywordArgument 6 | class << self 7 | def build(keywords, value_matcher) 8 | value_matcher = NodePairValue.build(value_matcher) 9 | keyword_matcher = build_keyword_matcher(keywords) 10 | pair_matcher = And.build([keyword_matcher, value_matcher]) 11 | 12 | return unless pair_matcher 13 | 14 | Matchers::NodeHasAnyKeywordArgument.new(pair_matcher) 15 | end 16 | 17 | private 18 | 19 | def build_keyword_matcher(keywords) 20 | if ::Leftovers.wrap_array(keywords).include?('**') 21 | Matchers::NodeType.new(:pair) 22 | else 23 | NodePairKey.build(Node.build(keywords)) 24 | end 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/leftovers/processor_builders/transform_set.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module ProcessorBuilders 5 | module TransformSet 6 | class << self 7 | def build(transforms, final_processor) 8 | each_builder(final_processor).each_or_self(transforms) do |transform| 9 | case transform 10 | when ::Hash, ::Symbol then TransformChain.build(transform, final_processor) 11 | # :nocov: 12 | else raise UnexpectedCase, "Unhandled value #{transform.inspect}" 13 | # :nocov: 14 | end 15 | end 16 | end 17 | 18 | private 19 | 20 | def each_builder(final_processor) 21 | if final_processor == Processors::AddDefinitionNode 22 | EachForDefinitionSet 23 | else 24 | Each 25 | end 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/value_matcher_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class ValueMatcherSchema < ValueOrObjectSchema 6 | attribute :arguments, ValueOrArraySchema[ArgumentPositionSchema], aliases: :argument, 7 | require_group: :matcher 8 | attribute :keywords, ValueOrArraySchema[StringPatternSchema], aliases: :keyword, 9 | require_group: :matcher 10 | attribute :itself, TrueSchema, require_group: :matcher 11 | attribute :nested, ValueOrArraySchema[ValueMatcherSchema] 12 | attribute :value, ValueOrArraySchema[StringSchema], require_group: :matcher, aliases: :values 13 | attribute :receiver, TrueSchema, require_group: :matcher 14 | attribute :recursive, TrueSchema 15 | 16 | inherit_attributes_from ValueMatcherConditionSchema 17 | 18 | self.or_value_schema = ScalarArgumentSchema 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/leftovers/processors/each_for_definition_set.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Processors 5 | class EachForDefinitionSet 6 | include ComparableInstance 7 | 8 | attr_reader :processors 9 | 10 | def initialize(processors) 11 | @processors = processors 12 | 13 | freeze 14 | end 15 | 16 | def process(str, current_node, matched_node, acc) # rubocop:disable Metrics/MethodLength 17 | set = DefinitionNodeSet.new 18 | 19 | @processors.each do |processor| 20 | processor.process(str, current_node, matched_node, set) 21 | end 22 | 23 | case set.definitions.length 24 | when 1 25 | acc.add_definition_node set.definitions.first 26 | when 0 27 | nil 28 | else 29 | acc.add_definition_set set 30 | end 31 | end 32 | 33 | freeze 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/leftovers/definition_set.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class DefinitionSet 5 | attr_reader :definitions 6 | 7 | def initialize(definitions) 8 | @definitions = definitions 9 | 10 | freeze 11 | end 12 | 13 | def names 14 | @definitions.map(&:names) 15 | end 16 | 17 | def to_s 18 | @definitions.map(&:to_s).join(', ') 19 | end 20 | 21 | def location_s 22 | @definitions.first.location_s 23 | end 24 | 25 | def highlighted_source(*args) 26 | @definitions.first.highlighted_source(*args) 27 | end 28 | 29 | def in_collection?(collection) 30 | @definitions.any? { |d| d.in_collection?(collection) } 31 | end 32 | 33 | def test? 34 | @definitions.any?(&:test?) 35 | end 36 | 37 | def in_test_collection?(collection) 38 | @definitions.any? { |d| d.in_test_collection?(collection) } 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | require 'bundler/gem_tasks' 5 | require 'rspec/core/rake_task' 6 | require 'rubocop/rake_task' 7 | require 'spellr/rake_task' 8 | require_relative 'lib/leftovers/rake_task' 9 | 10 | ::RuboCop::RakeTask.new 11 | ::RSpec::Core::RakeTask.new(:spec) 12 | ::Spellr::RakeTask.generate_task 13 | ::Leftovers::RakeTask.generate_task 14 | 15 | desc 'Test autoload' 16 | task :test_autoload, [:times] do |_, args| 17 | exitstatus = 0 18 | puts 'Shuffled loading attempt: 1' 19 | exitstatus = 1 unless system('bin/test_autoload.rb --verbose') 20 | (args[:times]&.to_i&.-(1) || 2).times do |i| 21 | puts "Shuffled loading attempt: #{i + 2}" 22 | 23 | exitstatus = 1 unless system('bin/test_autoload.rb --verbose --only-errors') 24 | end 25 | exit exitstatus unless exitstatus == 0 26 | end 27 | ENV['COVERAGE'] = '1' 28 | task default: %i{test_autoload spec spellr rubocop leftovers build} 29 | -------------------------------------------------------------------------------- /lib/leftovers/processor_builders/transform_chain.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module ProcessorBuilders 5 | module TransformChain 6 | class << self 7 | def build(transforms, next_transform) 8 | case transforms 9 | when ::Hash then build_from_hash(transforms, next_transform) 10 | when ::Symbol then Transform.build(transforms, true, next_transform) 11 | # :nocov: 12 | else raise UnexpectedCase, "Unhandled value #{transforms.inspect}" 13 | # :nocov: 14 | end 15 | end 16 | 17 | private 18 | 19 | def build_from_hash(transforms, next_transform) 20 | transforms.reverse_each do |(transform, transform_arg)| 21 | next_transform = Transform.build(transform, transform_arg, next_transform) 22 | end 23 | 24 | next_transform 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/leftovers/ast/send_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module AST 5 | class SendNode < Node 6 | include HasArguments 7 | 8 | alias_method :receiver, :first 9 | alias_method :name, :second 10 | alias_method :to_sym, :second 11 | 12 | def to_s 13 | name.to_s 14 | end 15 | 16 | def arguments 17 | @memo[:arguments] ||= if block_pass_argument? 18 | children[2...-1] 19 | else 20 | children.drop(2) 21 | end 22 | end 23 | 24 | def as_arguments_list 25 | first.as_arguments_list if name == :freeze 26 | end 27 | 28 | def block_pass_argument? 29 | last_child = children.last 30 | last_child.respond_to?(:type) && last_child.type == :block_pass 31 | end 32 | 33 | def block_given? 34 | block_pass_argument? || parent&.type == :block 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/leftovers_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ::RSpec.describe ::Leftovers do 4 | describe 'version' do 5 | changelog = ::File.read(::File.expand_path('../CHANGELOG.md', __dir__)) 6 | changelog_version = changelog.match(/^# v([\d.]+)$/)&.captures&.first 7 | 8 | it "has the version number: #{changelog_version}, matching the changelog" do 9 | expect(described_class::VERSION).to eq changelog_version 10 | end 11 | end 12 | 13 | describe '.reset' do 14 | it 'unmemoizes everything' do 15 | described_class.try_require('not here') 16 | 17 | described_class.stdout 18 | described_class.stderr 19 | described_class.config 20 | described_class.pwd 21 | 22 | expect( 23 | subject.instance_variables 24 | ).to match_array(::Leftovers::MEMOIZED_IVARS) 25 | 26 | described_class.reset 27 | 28 | expect(subject.instance_variables).to be_empty 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/file_collector/json_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | ::RSpec.describe ::Leftovers::Precompilers::JSON do 6 | subject(:collector) do 7 | collector = ::Leftovers::FileCollector.new(ruby, file) 8 | collector.collect 9 | collector 10 | end 11 | 12 | let(:path) { 'foo.json' } 13 | let(:file) do 14 | ::Leftovers::File.new(::Leftovers.pwd + path) 15 | .tap { |f| allow(f).to receive_messages(read: json) } 16 | end 17 | let(:json) { '' } 18 | let(:ruby) { file.ruby } 19 | 20 | context 'with invalid json files' do 21 | let(:json) do 22 | <<~JSON 23 | [ 24 | JSON 25 | end 26 | 27 | it 'outputs an error and collects nothing' do 28 | expect { subject }.to print_warning(match( 29 | /\AJSON::ParserError: foo.json (\d+: )?unexpected token at ''\n\z/ 30 | )) 31 | expect(subject).to have_no_definitions.and(have_no_calls) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/support/temp_file_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'tmpdir' 4 | require 'pathname' 5 | 6 | module TempFileHelper 7 | def temp_file(filename, body = '') 8 | raise 'Add :with_temp_dir to the metadata' unless @__temp_dir 9 | 10 | path = @__temp_dir.join(filename) 11 | path.parent.mkpath 12 | path.write(body) 13 | path 14 | end 15 | 16 | def temp_dir 17 | @__temp_dir 18 | end 19 | end 20 | 21 | ::RSpec.configure do |config| 22 | config.include TempFileHelper 23 | 24 | config.before(:each, :with_temp_dir) do 25 | @__temp_dir = ::Pathname.new(::Dir.mktmpdir + '/') 26 | ::Leftovers::Config.reset # MatcherBuilders::Path calls Leftovers.pwd, make it forget 27 | allow(::Leftovers).to receive_messages(pwd: @__temp_dir) 28 | end 29 | 30 | config.after(:each, :with_temp_dir) do 31 | @__temp_dir.rmtree 32 | ::Leftovers::Config.reset # MatcherBuilders::Path calls Leftovers.pwd, make it forget 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/leftovers/autoloader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | # zero dependency zeitwerk 5 | module Autoloader 6 | ALL_CAPS_NAMES = %w{ast cli version erb json yaml}.freeze 7 | 8 | def self.included(klass) 9 | ::Dir[glob_children(klass)].each_entry do |path| 10 | klass.autoload(class_from_path(path), path) 11 | end 12 | end 13 | 14 | def self.class_from_path(path) 15 | name = ::File.basename(path).delete_suffix('.rb') 16 | if ALL_CAPS_NAMES.include?(name) 17 | name.upcase 18 | else 19 | name.gsub(/(?:^|_)(\w)/, &:upcase).delete('_') 20 | end 21 | end 22 | 23 | def self.dir_path_from_class(klass) 24 | klass.name.gsub(/::/, '/') 25 | .gsub(/(?<=[a-z])([A-Z])/, '_\1').downcase 26 | end 27 | 28 | def self.glob_children(klass) 29 | "#{root}/#{dir_path_from_class(klass)}/*.rb" 30 | end 31 | 32 | def self.root 33 | ::File.dirname(__dir__) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/leftovers/precompilers/haml.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'haml' 4 | 5 | module Leftovers 6 | module Precompilers 7 | module Haml 8 | HAML_RUNTIME_ERROR_RE = %r{ 9 | \A 10 | _buf\s=\s'' # preamble 11 | [\s;]* 12 | # https://github.com/haml/haml/blob/main/lib/haml/compiler.rb#L93 13 | raise\s(?:::)?(?.*)\.new\(%q\[(?.*)\],\s(?\d)+\) 14 | [\s;]* 15 | _buf # postamble 16 | \z 17 | }x.freeze 18 | 19 | def self.precompile(haml) 20 | out = ::Haml::TempleEngine.new.compile(haml) 21 | 22 | if (e = out.match(HAML_RUNTIME_ERROR_RE)) 23 | raise PrecompileError.new(e[:message], line: e[:line], display_class: e[:class]) 24 | end 25 | 26 | out 27 | # :nocov: 28 | # this is for haml < 6 29 | rescue ::Haml::Error => e 30 | raise PrecompileError.new(e.message, line: e.line) 31 | # :nocov: 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders/and.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module MatcherBuilders 5 | module And 6 | class << self 7 | def build(matchers) 8 | matchers = flatten(matchers).compact 9 | case matchers.length 10 | when 0 then nil 11 | when 1 then matchers.first 12 | when 2 then Matchers::And.new(matchers.first, matchers[1]) 13 | else Matchers::All.new(matchers.dup) 14 | end 15 | end 16 | 17 | private 18 | 19 | def flatten(value) 20 | case value 21 | when Matchers::And 22 | [*flatten(value.lhs), *flatten(value.rhs)] 23 | # :nocov: # not sure how to make this happen 24 | when Matchers::All 25 | flatten(value.matchers) 26 | # :nocov: 27 | when ::Array 28 | value.flat_map { |v| flatten(v) } 29 | else 30 | [value] 31 | end 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders/string_pattern.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module MatcherBuilders 5 | module StringPattern 6 | def self.build(match: nil, has_prefix: nil, has_suffix: nil) # rubocop:disable Metrics 7 | has_prefix = ::Regexp.escape(has_prefix) if has_prefix 8 | has_suffix = ::Regexp.escape(has_suffix) if has_suffix 9 | 10 | if match && has_prefix && has_suffix 11 | /\A(?=#{match}\z)(?=#{has_prefix}).*#{has_suffix}\z/ 12 | elsif match && has_prefix 13 | /\A(?=#{match}\z)#{has_prefix}/ 14 | elsif match && has_suffix 15 | /\A(?=#{match}\z).*#{has_suffix}\z/ 16 | elsif match 17 | /\A#{match}\z/ 18 | elsif has_prefix && has_suffix 19 | /\A(?=#{has_prefix}).*#{has_suffix}\z/ 20 | elsif has_prefix 21 | /\A#{has_prefix}/ 22 | elsif has_suffix 23 | /#{has_suffix}\z/ 24 | else 25 | nil 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/has_value_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class HasValueSchema < ValueOrObjectSchema 6 | attribute :names, ValueOrArraySchema[StringPatternSchema], aliases: :name, 7 | require_group: :matcher 8 | inherit_attributes_from StringPatternSchema, except: :unless 9 | attribute :has_arguments, ValueOrArraySchema[HasArgumentSchema], aliases: :has_argument, 10 | require_group: :matcher 11 | 12 | attribute :at, ValueOrArraySchema[ArgumentPositionSchema], require_group: :matcher 13 | attribute :has_value, ValueOrArraySchema[HasValueSchema], require_group: :matcher 14 | 15 | attribute :has_receiver, ValueOrArraySchema[HasReceiverSchema], require_group: :matcher 16 | attribute :type, ValueOrArraySchema[ValueTypeSchema], require_group: :matcher 17 | attribute :unless, ValueOrArraySchema[HasValueSchema], require_group: :matcher 18 | 19 | self.or_value_schema = ScalarValueSchema 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/value_or_object_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class ValueOrObjectSchema < ObjectSchema 6 | class << self 7 | attr_accessor :or_value_schema 8 | 9 | def validate(node) 10 | if node.hash? 11 | super(node) 12 | else 13 | validate_or_value_schema(node) 14 | end 15 | end 16 | 17 | def to_ruby(node) 18 | if node.hash? 19 | super 20 | else 21 | or_value_schema.to_ruby(node) 22 | end 23 | end 24 | 25 | private 26 | 27 | def validate_or_value_schema(node) 28 | or_value_schema.validate(node) 29 | return true if node.valid? 30 | 31 | if node.string? && attribute_for_key(node) 32 | node.error = "#{node.name_}#{node.to_sym} must be a hash key" 33 | else 34 | node.error += " or a hash with any of #{suggestions.join(', ')}" 35 | end 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Dana Sherson 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 | -------------------------------------------------------------------------------- /lib/config/activejob.yml: -------------------------------------------------------------------------------- 1 | gem: 2 | - activesupport 3 | 4 | keep: 5 | # https://guides.rubyonrails.org/active_job_basics.html#serializers 6 | - serialize? 7 | - serialize 8 | - deserialize 9 | dynamic: 10 | # https://guides.rubyonrails.org/active_job_basics.html#enqueue-the-job 11 | - name: 12 | - perform_later 13 | - perform_now 14 | calls: 15 | - value: perform 16 | # https://api.rubyonrails.org/v7.0.2.2/classes/ActiveJob/Callbacks/ClassMethods.html 17 | # These all use if/unless because they call set_callback, though this isn't documented 18 | - names: 19 | - before_enqueue 20 | - around_enqueue 21 | - after_enqueue 22 | - before_perform 23 | - around_perform 24 | - after_perform 25 | calls: 26 | - arguments: '*' 27 | - arguments: [if, unless] 28 | nested: '*' 29 | 30 | # https://api.rubyonrails.org/v7.0.2.2/classes/ActiveJob/Exceptions/ClassMethods.html#method-i-discard_on 31 | # https://api.rubyonrails.org/v7.0.2.2/classes/ActiveJob/Exceptions/ClassMethods.html#method-i-retry_on 32 | - names: [discard_on, retry_on] 33 | calls: 34 | - argument: '*' 35 | split: '::' 36 | -------------------------------------------------------------------------------- /lib/config/actioncable.yml: -------------------------------------------------------------------------------- 1 | # https://guides.rubyonrails.org/action_cable_overview.html 2 | gems: 3 | - activesupport 4 | - actionpack 5 | 6 | keep: 7 | # https://guides.rubyonrails.org/action_cable_overview.html#server-side-components-channels-subscriptions 8 | # https://api.rubyonrails.org/v7.0.2.2/classes/ActionCable/Channel/Base.html#method-i-subscribed 9 | # it's for overriding 10 | - subscribed 11 | # https://api.rubyonrails.org/v7.0.2.2/classes/ActionCable/Channel/Base.html#method-i-unsubscribed 12 | # it's for overriding 13 | - unsubscribed 14 | 15 | # https://api.rubyonrails.org/v7.0.2.2/classes/ActionCable/Channel/Base.html 16 | - type: Method 17 | privacy: public 18 | path: /app/channels/**/*_channel.rb 19 | 20 | - path: /app/channels/**/*_channel.rb 21 | has_suffix: Channel 22 | 23 | 24 | dynamic: 25 | # https://guides.rubyonrails.org/action_cable_overview.html#connection-setup 26 | # it's just attr_accessor 27 | - names: identified_by 28 | define: 29 | - argument: '*' 30 | transforms: 31 | - original 32 | - add_suffix: '=' 33 | call: 34 | - argument: '*' 35 | add_prefix: '@' 36 | 37 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/rule_pattern_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class RulePatternSchema < ObjectSchema 6 | attribute :names, ValueOrArraySchema[StringPatternSchema], aliases: :name, 7 | require_group: :matcher 8 | attribute :paths, ValueOrArraySchema[StringSchema], aliases: :path, require_group: :matcher 9 | attribute :document, TrueSchema, require_group: :matcher 10 | attribute :has_arguments, ValueOrArraySchema[HasArgumentSchema], aliases: :has_argument, 11 | require_group: :matcher 12 | attribute :has_receiver, ValueOrArraySchema[HasReceiverSchema], require_group: :matcher 13 | attribute :has_block, BoolSchema, require_group: :matcher 14 | attribute :type, ValueOrArraySchema[ValueTypeSchema], require_group: :matcher 15 | attribute :privacy, ValueOrArraySchema[PrivacySchema], require_group: :matcher 16 | attribute :unless, ValueOrArraySchema[RulePatternSchema], require_group: :matcher 17 | attribute :all, ArraySchema[RulePatternSchema], require_group: :matcher 18 | attribute :any, ArraySchema[RulePatternSchema], require_group: :matcher 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/leftovers/parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'parser' 4 | require 'parser/current' 5 | 6 | module Leftovers 7 | module Parser 8 | class << self 9 | # mostly copied from https://github.com/whitequark/parser/blob/master/lib/parser/base.rb 10 | # but with our parser 11 | def parse_with_comments(string, file = '(string)', line = 1) 12 | PARSER.reset 13 | PARSER.parse_with_comments(new_source_buffer(string, file, line)) 14 | end 15 | 16 | private 17 | 18 | def new_source_buffer(string, file, line) 19 | ::Parser::CurrentRuby.send( 20 | :setup_source_buffer, file, line, string, PARSER.default_encoding 21 | ) 22 | end 23 | 24 | # mostly copied from https://github.com/whitequark/parser/blob/master/lib/parser/base.rb 25 | # but with our builder 26 | def parser 27 | p = ::Parser::CurrentRuby.new(AST::Builder.new) 28 | p.diagnostics.all_errors_are_fatal = true 29 | p.diagnostics.ignore_warnings = true 30 | 31 | p.diagnostics.consumer = lambda do |diagnostic| 32 | diagnostic 33 | end 34 | 35 | p 36 | end 37 | end 38 | PARSER = parser 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/config/leftovers.yml: -------------------------------------------------------------------------------- 1 | precompile: 2 | - { paths: ['*.yml', '*.yaml'], format: 'yaml' } 3 | - { paths: '*.json', format: 'json' } 4 | 5 | include_paths: 6 | .leftovers.yml 7 | 8 | dynamic: 9 | - document: true 10 | path: '.leftovers.yml' 11 | has_argument: 12 | at: precompile 13 | has_value: 14 | has_argument: 15 | at: '*' 16 | has_value: 17 | has_argument: 18 | at: 'format' 19 | has_value: 20 | has_argument: custom 21 | calls: 22 | - value: precompile 23 | - argument: precompile 24 | nested: 25 | - argument: '*' 26 | nested: 27 | argument: format 28 | nested: 29 | argument: custom 30 | split: '::' 31 | 32 | - document: true 33 | path: '.leftovers.yml' 34 | has_argument: 35 | at: precompile 36 | has_value: 37 | has_argument: 38 | at: 'format' 39 | has_value: 40 | has_argument: custom 41 | calls: 42 | - value: precompile 43 | - argument: precompile 44 | nested: 45 | argument: format 46 | nested: 47 | argument: custom 48 | split: '::' 49 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders/node_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'set' 4 | 5 | module Leftovers 6 | module MatcherBuilders 7 | module NodeType 8 | def self.build(types_pattern) # rubocop:disable Metrics 9 | Or.each_or_self(types_pattern) do |type| 10 | case type 11 | when :Symbol then Matchers::NodeType.new(:sym) 12 | when :String then Matchers::NodeType.new(:str) 13 | when :Integer then Matchers::NodeType.new(:int) 14 | when :Float then Matchers::NodeType.new(:float) 15 | when :Array then Matchers::NodeType.new(:array) 16 | when :Hash then Matchers::NodeType.new(:hash) 17 | when :Proc then Matchers::NodeIsProc 18 | when :Method 19 | Matchers::NodeType.new( 20 | ::Set[:send, :csend, :def, :defs].compare_by_identity.freeze 21 | ) 22 | when :Constant 23 | Matchers::NodeType.new( 24 | ::Set[:const, :class, :module, :casgn].compare_by_identity.freeze 25 | ) 26 | # :nocov: 27 | else raise UnexpectedCase, "Unhandled value #{type.inspect}" 28 | # :nocov: 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/leftovers/definition_collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class DefinitionCollection 5 | def initialize 6 | @definitions_to_add = {} 7 | @definition_sets_to_add = [] 8 | end 9 | 10 | def add_definition_node(definition_node) 11 | add(definition_node, loc: definition_node.loc) 12 | end 13 | 14 | def add(node, name: node.name, loc: node.loc.name) 15 | @definitions_to_add[name] = DefinitionToAdd.new(node, name: name, location: loc) 16 | end 17 | 18 | def add_definition_set(definition_node_set) 19 | @definition_sets_to_add << definition_node_set.definitions.map do |definition_node| 20 | DefinitionToAdd.new(definition_node, location: definition_node.loc) 21 | end 22 | end 23 | 24 | def set_privacy(name, to) 25 | @definitions_to_add[name]&.privacy = to 26 | end 27 | 28 | def to_definitions(file_collector) 29 | @definitions_to_add.each_value.map { |d| d.to_definition(file_collector) } + 30 | @definition_sets_to_add.map do |definition_set| 31 | next if definition_set.any? { |d| d.keep?(file_collector) } 32 | 33 | DefinitionSet.new(definition_set.map { |d| d.to_definition(file_collector) }) 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/array_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class ArraySchema < Schema 6 | class << self 7 | def [](value_schema) 8 | new(value_schema) 9 | end 10 | end 11 | 12 | attr_reader :value_schema 13 | 14 | def initialize(value_schema) 15 | @value_schema = value_schema 16 | 17 | super() 18 | end 19 | 20 | def validate(node) 21 | validate_array(node) && validate_length(node) && validate_values(node) 22 | end 23 | 24 | def to_ruby(node) 25 | node.children.map do |value| 26 | value_schema.to_ruby(value) 27 | end 28 | end 29 | 30 | private 31 | 32 | def validate_array(node) 33 | self.class.error(node, 'be an array') unless node.array? 34 | 35 | node.valid? 36 | end 37 | 38 | def validate_length(node) 39 | self.class.error(node, 'not be empty') if node.children.empty? 40 | 41 | node.valid? 42 | end 43 | 44 | def validate_values(node) 45 | node.children.each do |value| 46 | value_schema.validate(value) 47 | end 48 | 49 | node.children.all?(&:valid?) 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/attribute.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class Attribute 6 | attr_reader( 7 | :name, 8 | :aliases, :schema # leftovers:test_only 9 | ) 10 | 11 | attr_accessor :require_group 12 | 13 | def initialize(name, schema, aliases: nil, require_group: nil, suggest: true) 14 | @name = name 15 | @schema = schema 16 | @aliases = Array(aliases) 17 | @require_group = require_group 18 | @suggest = suggest 19 | end 20 | 21 | def without_require_group 22 | new = dup 23 | new.require_group = nil 24 | new 25 | end 26 | 27 | def suggest? 28 | @suggest 29 | end 30 | 31 | def attributes 32 | [self] 33 | end 34 | 35 | def name?(name) 36 | name = name.to_sym 37 | 38 | @name == name || @aliases.include?(name) 39 | end 40 | 41 | def to_ruby(value) 42 | [key_to_ruby, @schema.to_ruby(value)] 43 | end 44 | 45 | def validate_value(value) 46 | @schema.validate(value) 47 | end 48 | 49 | private 50 | 51 | def key_to_ruby 52 | name == :unless ? :unless_arg : name 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/leftovers/processor_builders/argument.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module ProcessorBuilders 5 | module Argument 6 | class << self 7 | def build(patterns, processor) # rubocop:disable Metrics/MethodLength 8 | Each.each_or_self(patterns) do |pat| 9 | case pat 10 | when ::Integer then Processors::PositionalArgument.new(pat, processor) 11 | when '*' then Processors::EachPositionalArgument.new(processor) 12 | when '**' then Processors::EachKeywordArgument.new(processor) 13 | when /\A(\d+)\+\z/ then Processors::EachPositionalArgumentFrom.new(pat.to_i, processor) 14 | when ::String then KeywordArgument.build(pat, processor) 15 | when ::Hash then build_hash(processor, pat) 16 | # :nocov: 17 | else raise UnexpectedCase, "Unhandled value #{pat.inspect}" 18 | # :nocov: 19 | end 20 | end 21 | end 22 | 23 | private 24 | 25 | def build_hash(then_processor, pat) 26 | Processors::KeywordArgument.new( 27 | MatcherBuilders::NodePairKey.build(MatcherBuilders::Node.build_from_hash(**pat)), 28 | then_processor 29 | ) 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/leftovers/ast/builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'parser' 4 | 5 | module Leftovers 6 | module AST 7 | class Builder < ::Parser::Builders::Default 8 | def n(type, children, source_map) # leftovers:keep 9 | self.class.node_class(type).new(type, children, location: source_map) 10 | end 11 | 12 | def self.node_class(type) # rubocop:disable Metrics 13 | case type 14 | when :array then ArrayNode 15 | when :block then BlockNode 16 | when :casgn then CasgnNode 17 | when :const then ConstNode 18 | when :def then DefNode 19 | when :defs then DefsNode 20 | when :false then FalseNode 21 | when :hash then HashNode 22 | when :int, :float then NumericNode 23 | when :lvar, :ivar, :gvar, :cvar then VarNode 24 | when :ivasgn, :cvasgn, :gvasgn then VasgnNode 25 | when :module, :class then ModuleNode 26 | when :nil then NilNode 27 | when :send, :csend then SendNode 28 | when :str then StrNode 29 | when :sym then SymNode 30 | when :true then TrueNode 31 | else Node 32 | end 33 | end 34 | 35 | # Don't complain about invalid strings 36 | # This is called by ::Parser::AST internals 37 | def string_value(token) # leftovers:keep 38 | value(token) 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/leftovers/precompilers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Precompilers 5 | include Autoloader 6 | 7 | class << self 8 | def build(precompilers) 9 | precompilers.group_by { |p| build_precompiler(p[:format]) }.map do |format, precompiler| 10 | Precompiler.new( 11 | format, 12 | MatcherBuilders::Path.build(precompiler.flat_map { |p| p[:paths] }) 13 | ) 14 | end 15 | end 16 | 17 | private 18 | 19 | def build_precompiler(format) 20 | case format 21 | when :erb then ERB 22 | when :haml then Haml 23 | when :json then JSON 24 | when :slim then Slim 25 | when :yaml then YAML 26 | when ::Hash then constantize_precompiler(format[:custom]) 27 | # :nocov: 28 | else raise UnexpectedCase, "Unhandled value #{format}" 29 | # :nocov: 30 | end 31 | end 32 | 33 | def constantize_precompiler(precompiler) 34 | precompiler = "::#{precompiler}" unless precompiler.start_with?('::') 35 | 36 | ::Object.const_get(precompiler, false) 37 | rescue ::NameError 38 | ::Leftovers.error <<~MESSAGE 39 | Tried using #{precompiler}, but it wasn't available. 40 | add its path to `requires:` in your .leftovers.yml 41 | MESSAGE 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'yaml' 4 | 5 | module Leftovers 6 | class ConfigLoader 7 | include Autoloader 8 | 9 | def self.load(name, path: nil, content: nil) 10 | new(name, path: path, content: content).load 11 | end 12 | 13 | attr_reader :name 14 | 15 | def initialize(name, path: nil, content: nil) 16 | @name = name 17 | @path = path 18 | @content = content 19 | end 20 | 21 | def load 22 | document = Node.new(parse, file) 23 | DocumentSchema.validate(document) 24 | 25 | all_errors = document.all_errors 26 | return DocumentSchema.to_ruby(document) if all_errors.empty? 27 | 28 | ::Leftovers.error(all_errors.join("\n")) 29 | end 30 | 31 | private 32 | 33 | def path 34 | @path ||= ::File.expand_path("../config/#{name}.yml", __dir__) 35 | end 36 | 37 | def file 38 | @file ||= File.new(path) 39 | end 40 | 41 | def content 42 | @content ||= file.exist? ? file.read : '' 43 | end 44 | 45 | def parse 46 | parsed = ::Psych.parse(content) 47 | parsed ||= ::Psych.parse('{}') 48 | parsed.children.first 49 | rescue ::Psych::SyntaxError => e 50 | message = [e.problem, e.context].compact.join(' ') 51 | ::Leftovers.error "Config SyntaxError: #{file.relative_path}:#{e.line}:#{e.column} #{message}" 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/leftovers/definition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class Definition 5 | attr_reader :name, :test, :location_s, :source_line 6 | alias_method :names, :name 7 | 8 | alias_method :test?, :test 9 | 10 | def initialize( 11 | name, 12 | location: method_node.loc.expression, 13 | test: method_node.test_line? || ::Leftovers.config.test_only === method_node 14 | ) 15 | @name = name 16 | @path = location.source_buffer.name.to_s 17 | @source_line = location.source_line.to_s 18 | @location_column_range_begin = location.column_range.begin.to_i 19 | @location_column_range_end = location.column_range.end.to_i 20 | @location_source = location.source.to_s 21 | @location_s = location.to_s 22 | @test = test 23 | 24 | freeze 25 | end 26 | 27 | def to_s 28 | @name.to_s 29 | end 30 | 31 | def highlighted_source(highlight = "\e[31m", normal = "\e[0m") 32 | @source_line[0...@location_column_range_begin].lstrip + 33 | highlight + @location_source + normal + 34 | @source_line[@location_column_range_end..-1].rstrip 35 | end 36 | 37 | def in_collection?(collection) 38 | collection.calls.include?(@name) || (@test && in_test_collection?(collection)) 39 | end 40 | 41 | def in_test_collection?(collection) 42 | collection.test_calls.include?(@name) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/leftovers/rake_task.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rake' 4 | require 'shellwords' 5 | require_relative '../leftovers' 6 | 7 | module Leftovers 8 | class RakeTask 9 | include ::Rake::DSL 10 | 11 | def self.generate_task(name = :leftovers, *default_argv) 12 | new(name, default_argv) 13 | 14 | name 15 | end 16 | 17 | def initialize(name, default_argv) 18 | @name = name 19 | @default_argv = default_argv 20 | 21 | describe_task 22 | define_task 23 | end 24 | 25 | private 26 | 27 | def escaped_argv(argv = @default_argv) 28 | return if argv.empty? 29 | 30 | ::Shellwords.shelljoin(argv) 31 | end 32 | 33 | def describe_task 34 | return desc('Run leftovers') if @default_argv.empty? 35 | 36 | desc("Run leftovers (default args: #{escaped_argv})") 37 | end 38 | 39 | def define_task 40 | task(@name, :'*args') do |_, task_argv| 41 | argv = argv_or_default(task_argv) 42 | write_cli_cmd(argv) 43 | run(argv) 44 | end 45 | end 46 | 47 | def write_cli_cmd(argv) 48 | $stdout.puts("\e[2mleftovers #{escaped_argv(argv)}\e[0m") 49 | end 50 | 51 | def run(argv) 52 | exitstatus = CLI.new(argv: argv).run 53 | 54 | exit exitstatus unless exitstatus == 0 55 | end 56 | 57 | def argv_or_default(task_argv) 58 | task_argv = task_argv.to_a.compact 59 | task_argv.empty? ? @default_argv : task_argv 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders/node_has_positional_argument.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module MatcherBuilders 5 | module NodeHasPositionalArgument 6 | class << self 7 | def build(positions, value_matcher) 8 | positions = ::Leftovers.wrap_array(positions) 9 | if !positions.empty? && !all_positions?(positions) && value_matcher 10 | build_has_positional_value_matcher(positions, value_matcher) 11 | elsif !positions.empty? && !value_matcher 12 | build_has_position_matcher(positions) 13 | elsif value_matcher 14 | build_has_any_positional_value_matcher(value_matcher) 15 | end 16 | end 17 | 18 | private 19 | 20 | def all_positions?(positions) 21 | positions.include?('*') 22 | end 23 | 24 | def build_has_position_matcher(positions) 25 | last_position = all_positions?(positions) ? 0 : positions.min 26 | 27 | Matchers::NodeHasPositionalArgument.new(last_position) 28 | end 29 | 30 | def build_has_any_positional_value_matcher(value_matcher) 31 | Matchers::NodeHasAnyPositionalArgumentWithValue.new(value_matcher) 32 | end 33 | 34 | def build_has_positional_value_matcher(positions, value_matcher) 35 | Or.each_or_self(positions) do |position| 36 | Matchers::NodeHasPositionalArgumentWithValue.new(position, value_matcher) 37 | end 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/config/audited_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | ::RSpec.describe 'audited gem' do 6 | subject(:collector) do 7 | collector = ::Leftovers::FileCollector.new(ruby, file) 8 | collector.collect 9 | collector 10 | end 11 | 12 | before do 13 | ::Leftovers.config << :audited 14 | end 15 | 16 | let(:path) { 'foo.rb' } 17 | let(:file) { ::Leftovers::File.new(::Leftovers.pwd + path) } 18 | let(:ruby) { '' } 19 | 20 | context 'with current_user_method=' do 21 | let(:ruby) { 'Audited.current_user_method = :authenticated_user' } 22 | 23 | it do 24 | expect(subject).to have_no_definitions 25 | .and have_calls(:Audited, :current_user_method=, :authenticated_user) 26 | end 27 | end 28 | 29 | context 'with associated_with' do 30 | let(:ruby) { 'audited associated_with: :company' } 31 | 32 | it { is_expected.to have_no_definitions.and have_calls(:audited, :company) } 33 | end 34 | 35 | context 'with if' do 36 | let(:ruby) { 'audited if: :active?' } 37 | 38 | it { is_expected.to have_no_definitions.and have_calls(:audited, :active?) } 39 | end 40 | 41 | context 'with unless' do 42 | let(:ruby) { 'audited unless: :active?' } 43 | 44 | it { is_expected.to have_no_definitions.and have_calls(:audited, :active?) } 45 | end 46 | 47 | context 'with only:/except:' do 48 | let(:ruby) { 'audited only: :name, except: :password' } 49 | 50 | it { is_expected.to have_no_definitions.and have_calls(:audited) } 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /.spellr_wordlists/english.txt: -------------------------------------------------------------------------------- 1 | accessors 2 | actioncable 3 | actionmailbox 4 | actionmailer 5 | actionpack 6 | actiontext 7 | actionview 8 | activejob 9 | activemodel 10 | activerecord 11 | activestorage 12 | activesupport 13 | affixxed 14 | args 15 | argumentless 16 | ast 17 | attr 18 | autoloaded 19 | barx 20 | barxfoo 21 | classnames 22 | codebase 23 | compactable 24 | config 25 | constantize 26 | cov 27 | datagrid 28 | deserialize 29 | erb 30 | esque 31 | eval 32 | eval'd 33 | filesystem 34 | firstname 35 | fuzzer 36 | gemfile 37 | gentlemich 38 | graphql 39 | haml 40 | hashbang 41 | i'd 42 | i'm 43 | ips 44 | jpg 45 | json 46 | juanlujoanne 47 | kamoh 48 | kchecked 49 | kjson 50 | lhs 51 | lol 52 | lorem 53 | memoization 54 | memoized 55 | mergeable 56 | namespace 57 | namespaced 58 | nocov 59 | nonexcluded 60 | noninfringement 61 | numericality 62 | ofs 63 | okcomputer 64 | orien 65 | orm 66 | overwritable 67 | palexvs 68 | postamble 69 | precompilable 70 | precompile 71 | precompiled 72 | precompiler 73 | precompilers 74 | precompiling 75 | qux 76 | railties 77 | readme 78 | redcarpet 79 | requirables 80 | rhs 81 | rspec 82 | rubo 83 | rubocop 84 | rvm 85 | schemas 86 | sclarsen 87 | sclarson 88 | sherson 89 | sidekiq 90 | simplecov 91 | stabby 92 | stderr 93 | timecop 94 | todo 95 | uncompactable 96 | unfuzzable 97 | unmemoize 98 | unmemoizes 99 | veganstraightedge 100 | voltron 101 | warmup 102 | webdriver 103 | whatevers 104 | wtf 105 | yaml 106 | yml 107 | zeitwerk 108 | -------------------------------------------------------------------------------- /lib/leftovers/collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'set' 4 | 5 | module Leftovers 6 | class Collection 7 | attr_reader :calls, :test_calls, :definitions 8 | 9 | def initialize 10 | @calls = [] 11 | @test_calls = [] 12 | @definitions = [] 13 | end 14 | 15 | def leftovers 16 | @leftovers ||= begin 17 | freeze_calls 18 | 19 | @definitions 20 | .reject { |definition| definition.in_collection?(self) } 21 | .sort_by(&:location_s).freeze 22 | end 23 | end 24 | 25 | def with_tests 26 | split_leftovers.first 27 | end 28 | 29 | def without_tests 30 | split_leftovers[1] 31 | end 32 | 33 | def empty? 34 | leftovers.empty? 35 | end 36 | 37 | def concat(calls:, definitions:, test:) 38 | if test 39 | @test_calls.concat(calls) 40 | else 41 | @calls.concat(calls) 42 | end 43 | 44 | @definitions.concat(definitions) 45 | end 46 | 47 | private 48 | 49 | def split_leftovers 50 | return @split_leftovers if defined?(@split_leftovers) 51 | 52 | @split_leftovers = leftovers.partition do |definition| 53 | !definition.test? && definition.in_test_collection?(self) 54 | end.each(&:freeze).freeze 55 | 56 | freeze 57 | 58 | @split_leftovers 59 | end 60 | 61 | def freeze_calls 62 | @calls = @calls.to_set.compare_by_identity.freeze 63 | @test_calls = @test_calls.to_set.compare_by_identity.freeze 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/transform_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class TransformSchema < ValueOrObjectSchema 6 | ArgumentlessTransformSchema.each_value do |transform| 7 | attribute( 8 | transform, TrueSchema, 9 | aliases: ArgumentlessTransformSchema.aliases_for(transform), 10 | require_group: :processor 11 | ) 12 | end 13 | 14 | attribute :add_prefix, ValueOrArraySchema[StringValueProcessorSchema], 15 | require_group: :processor 16 | attribute :add_suffix, ValueOrArraySchema[StringValueProcessorSchema], 17 | require_group: :processor 18 | 19 | attribute :split, StringSchema, require_group: :processor 20 | attribute :delete_prefix, ValueOrArraySchema[StringSchema], require_group: :processor 21 | attribute :delete_suffix, ValueOrArraySchema[StringSchema], require_group: :processor 22 | attribute :delete_before, ValueOrArraySchema[StringSchema], require_group: :processor 23 | attribute :delete_before_last, ValueOrArraySchema[StringSchema], require_group: :processor 24 | attribute :delete_after, ValueOrArraySchema[StringSchema], require_group: :processor 25 | attribute :delete_after_last, ValueOrArraySchema[StringSchema], require_group: :processor 26 | attribute :transforms, ValueOrArraySchema[TransformSchema], require_group: :processor, 27 | aliases: :transform 28 | 29 | self.or_value_schema = ArgumentlessTransformSchema 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/leftovers/reporter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Reporter 5 | class << self 6 | def prepare; end 7 | 8 | def report(collection) 9 | report_list('Only directly called in tests:', collection.with_tests) 10 | report_list('Not directly called at all:', collection.without_tests) 11 | report_instructions 12 | 13 | 1 14 | end 15 | 16 | def report_success 17 | puts green('Everything is used') 18 | 19 | 0 20 | end 21 | 22 | private 23 | 24 | def report_instructions 25 | puts <<~HELP 26 | 27 | how to resolve: #{green ::Leftovers.resolution_instructions_link} 28 | HELP 29 | end 30 | 31 | def report_list(title, list) 32 | return if list.empty? 33 | 34 | puts red(title) 35 | list.each { |d| print_definition(d) } 36 | end 37 | 38 | def print_definition(definition) 39 | puts "#{aqua definition.location_s} " \ 40 | "#{definition} " \ 41 | "#{grey definition.highlighted_source("\e[33m", "\e[0;2m")}" 42 | end 43 | 44 | def puts(string) 45 | ::Leftovers.puts(string) 46 | end 47 | 48 | def red(string) 49 | "\e[31m#{string}\e[0m" 50 | end 51 | 52 | def green(string) 53 | "\e[32m#{string}\e[0m" 54 | end 55 | 56 | def aqua(string) 57 | "\e[36m#{string}\e[0m" 58 | end 59 | 60 | def grey(string) 61 | "\e[2m#{string}\e[0m" 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/leftovers/precompilers/yaml/builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module Precompilers 5 | module YAML 6 | class Builder < ::Psych::TreeBuilder 7 | def initialize 8 | @constants = [] 9 | 10 | super 11 | end 12 | 13 | def add_constant_for_tag(tag, value = nil) 14 | match = %r{\A!ruby/[^:]*(?::(.*))?\z}.match(tag) 15 | return unless match 16 | 17 | @constants << (match[1] || value) 18 | end 19 | 20 | def start_mapping(_anchor, tag, *rest) # leftovers:keep 21 | add_constant_for_tag(tag) 22 | tag = nil 23 | 24 | super 25 | end 26 | 27 | def start_sequence(_anchor, tag, *rest) # leftovers:keep 28 | add_constant_for_tag(tag) 29 | tag = nil 30 | 31 | super 32 | end 33 | 34 | def scalar(value, _anchor, tag, *rest) # leftovers:keep 35 | add_constant_for_tag(tag, value) 36 | tag = nil 37 | 38 | super 39 | end 40 | 41 | def to_ruby_file 42 | <<~FILE 43 | __leftovers_document(#{to_ruby_argument(root.to_ruby.first)}) 44 | #{@constants.join("\n")} 45 | FILE 46 | end 47 | 48 | private 49 | 50 | def to_ruby_argument(value) 51 | ruby = value.inspect 52 | return ruby unless value.is_a?(::Array) 53 | 54 | ruby.delete_prefix!('[') 55 | ruby.delete_suffix!(']') 56 | 57 | ruby 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/config/parser.yml: -------------------------------------------------------------------------------- 1 | keep: 2 | - on_alias 3 | - on_and 4 | - on_and_asgn 5 | - on_arg 6 | - on_arg_expr 7 | - on_args 8 | - on_argument 9 | - on_array 10 | - on_back_ref 11 | - on_begin 12 | - on_block 13 | - on_block_pass 14 | - on_blockarg 15 | - on_blockarg_expr 16 | - on_break 17 | - on_case 18 | - on_casgn 19 | - on_class 20 | - on_const 21 | - on_csend 22 | - on_cvar 23 | - on_cvasgn 24 | - on_def 25 | - on_defined? 26 | - on_defs 27 | - on_dstr 28 | - on_dsym 29 | - on_eflipflop 30 | - on_ensure 31 | - on_erange 32 | - on_for 33 | - on_gvar 34 | - on_gvasgn 35 | - on_hash 36 | - on_if 37 | - on_iflipflop 38 | - on_irange 39 | - on_ivar 40 | - on_ivasgn 41 | - on_kwarg 42 | - on_kwbegin 43 | - on_kwoptarg 44 | - on_kwrestarg 45 | - on_lvar 46 | - on_lvasgn 47 | - on_masgn 48 | - on_match_current_line 49 | - on_match_with_lvasgn 50 | - on_mlhs 51 | - on_module 52 | - on_next 53 | - on_not 54 | - on_nth_ref 55 | - on_op_asgn 56 | - on_optarg 57 | - on_or 58 | - on_or_asgn 59 | - on_pair 60 | - on_postexe 61 | - on_preexe 62 | - on_procarg0 63 | - on_redo 64 | - on_regexp 65 | - on_resbody 66 | - on_rescue 67 | - on_restarg 68 | - on_restarg_expr 69 | - on_retry 70 | - on_return 71 | - on_sclass 72 | - on_send 73 | - on_shadowarg 74 | - on_splat 75 | - on_super 76 | - on_undef 77 | - on_until 78 | - on_until_post 79 | - on_var 80 | - on_vasgn 81 | - on_when 82 | - on_while 83 | - on_while_post 84 | - on_xstr 85 | - on_yield 86 | - process_argument_node 87 | - process_regular_node 88 | - process_var_asgn_node 89 | - process_variable_node 90 | -------------------------------------------------------------------------------- /lib/leftovers/collector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'parallel' 4 | 5 | require 'parser' 6 | require 'parser/current' # to get the error message early and once before we parallel things 7 | 8 | module Leftovers 9 | class Collector 10 | attr_writer :progress, :parallel 11 | attr_reader :collection 12 | 13 | def initialize 14 | @count = 0 15 | @count_calls = 0 16 | @count_definitions = 0 17 | @progress = true 18 | @parallel = true 19 | @collection ||= Collection.new 20 | end 21 | 22 | def collect 23 | collect_file_list(FileList.new) 24 | ::Leftovers.puts progress_message 25 | end 26 | 27 | def collect_file_list(list) 28 | if @parallel 29 | ::Parallel.each(list, finish: method(:finish_file)) do |file| 30 | collect_file(file) 31 | end 32 | else 33 | list.each { |file| finish_file(nil, nil, collect_file(file)) } 34 | end 35 | end 36 | 37 | def collect_file(file) 38 | file_collector = FileCollector.new(file.ruby, file) 39 | file_collector.collect 40 | 41 | file_collector.to_h 42 | end 43 | 44 | def progress_message 45 | "checked #{@count} files, collected #{@count_calls} calls, #{@count_definitions} definitions" 46 | end 47 | 48 | def finish_file(_item, _index, result) 49 | @count += 1 50 | @count_calls += result[:calls].length 51 | @count_definitions += result[:definitions].length 52 | ::Leftovers.print(progress_message) if @progress 53 | 54 | @collection.concat(**result) 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/config_built_in_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ::RSpec.describe ::Leftovers::Config do 4 | config_methods = described_class.new(:rails).public_methods - ::Class.new.new.public_methods 5 | 6 | describe 'built in config' do 7 | files = ::Pathname.glob("#{__dir__}/../lib/config/*.yml") 8 | gems = files.map { |f| f.basename.sub_ext('').to_s } 9 | 10 | gems.each do |gem| 11 | it gem do 12 | expect do 13 | described_class.new(gem).tap do |c| 14 | config_methods.each { |method| c.send(method) } 15 | end 16 | end.not_to output.to_stderr 17 | end 18 | end 19 | 20 | context 'when merged' do 21 | merged_config_methods = ::Leftovers.config.public_methods 22 | merged_config_methods -= ::Class.new.new.public_methods 23 | merged_config_methods -= %i{<<} 24 | 25 | it 'can build the voltron (sorted)' do 26 | expect do 27 | gems.sort.each { |gem| ::Leftovers.config << gem } 28 | merged_config_methods.each { |method| ::Leftovers.config.send(method) } 29 | end.not_to output.to_stderr 30 | end 31 | 32 | 10.times do |iteration| 33 | next if ::ENV['COVERAGE'] 34 | 35 | it "can build the voltron (shuffle #{iteration})" do 36 | srand ::RSpec.configuration.seed + iteration 37 | 38 | expect do 39 | gems.shuffle.each { |gem| ::Leftovers.config << gem } 40 | merged_config_methods.each { |method| ::Leftovers.config.send(method) } 41 | end.not_to output.to_stderr 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/leftovers/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class Config 5 | attr_reader :name 6 | alias_method :to_sym, :name 7 | 8 | def self.[](name_or_config) 9 | return name_or_config if name_or_config.is_a?(self) 10 | 11 | @loaded_configs ||= {} 12 | @loaded_configs[name_or_config] ||= new(name_or_config) 13 | end 14 | 15 | def self.reset 16 | @loaded_configs = {} 17 | end 18 | 19 | def initialize(name, path: nil, content: nil) 20 | @name = name.to_sym 21 | @path = path 22 | @content = content 23 | end 24 | 25 | def gems 26 | @gems ||= Array(yaml[:gems]).map(&:to_sym) 27 | end 28 | 29 | def exclude_paths 30 | @exclude_paths ||= Array(yaml[:exclude_paths]) 31 | end 32 | 33 | def include_paths 34 | @include_paths ||= Array(yaml[:include_paths]) 35 | end 36 | 37 | def test_paths 38 | @test_paths ||= Array(yaml[:test_paths]) 39 | end 40 | 41 | def precompile 42 | @precompile ||= ::Leftovers.wrap_array(yaml[:precompile]) 43 | end 44 | 45 | def dynamic 46 | @dynamic ||= ProcessorBuilders::Dynamic.build(yaml[:dynamic]) 47 | end 48 | 49 | def keep 50 | @keep ||= MatcherBuilders::Node.build(yaml[:keep]) 51 | end 52 | 53 | def test_only 54 | @test_only ||= MatcherBuilders::Node.build(yaml[:test_only]) 55 | end 56 | 57 | def requires 58 | @requires ||= Array(yaml[:requires]) 59 | end 60 | 61 | private 62 | 63 | def yaml 64 | @yaml ||= ConfigLoader.load(name, path: @path, content: @content) 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/string_enum_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class StringEnumSchema < Schema 6 | class << self 7 | def value(value, aliases: nil) 8 | values << value 9 | Array(aliases).each do |alias_name| 10 | self.aliases[alias_name] = value 11 | end 12 | end 13 | 14 | def aliases 15 | @aliases ||= {} 16 | end 17 | 18 | def aliases_for(value) 19 | aliases.select { |_k, v| v == value }.keys 20 | end 21 | 22 | def values 23 | @values ||= [] 24 | end 25 | 26 | def each_value(&block) 27 | @values.each(&block) 28 | end 29 | 30 | def to_ruby(node) 31 | aliases[node.to_sym] || node.to_sym 32 | end 33 | 34 | def validate(node) 35 | if node.string? 36 | node.error = error_message_with_suggestions(node) unless valid_value?(node.to_sym) 37 | else 38 | error(node, 'be a string') 39 | end 40 | 41 | super 42 | end 43 | 44 | private 45 | 46 | def valid_value?(val) 47 | values.include?(val) || aliases.key?(val) 48 | end 49 | 50 | def suggester 51 | @suggester ||= Suggester.new(values) 52 | end 53 | 54 | def error_message_with_suggestions(node) 55 | suggestions = suggester.suggest(node.to_ruby) 56 | 57 | "unrecognized value #{node} for #{node.name}\nDid you mean: #{suggestions.join(', ')}" 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/config/actiontext.yml: -------------------------------------------------------------------------------- 1 | # https://edgeguides.rubyonrails.org/action_text_overview.html 2 | 3 | gems: 4 | - activesupport 5 | - activerecord 6 | - activestorage 7 | - actionpack 8 | 9 | keep: 10 | # https://edgeguides.rubyonrails.org/action_text_overview.html#rendering-attachments 11 | - to_attachable_partial_path 12 | 13 | dynamic: 14 | # https://edgeapi.rubyonrails.org/classes/ActionView/Helpers/FormHelper.html#method-i-rich_text_area 15 | - name: rich_text_area 16 | has_receiver: true 17 | calls: 18 | argument: 0 19 | transforms: 20 | - original 21 | - add_suffix: '=' 22 | 23 | - name: rich_text_area 24 | unless: 25 | has_receiver: true 26 | calls: 27 | - argument: 1 28 | transforms: 29 | - original 30 | - add_suffix: '=' 31 | - argument: 0 32 | add_prefix: '@' 33 | 34 | # https://edgeapi.rubyonrails.org/classes/ActionText/Attribute.html#method-i-has_rich_text 35 | - name: has_rich_text 36 | defines: 37 | argument: 0 38 | transforms: 39 | # defines its own methods 40 | - original 41 | - add_suffix: '?' 42 | - add_suffix: '=' 43 | # some scopes 44 | - add_prefix: with_rich_text_ 45 | - { add_prefix: with_rich_text_, add_suffix: _and_embeds } 46 | # and has_one with this prefix 47 | - add_prefix: rich_text_ 48 | transforms: 49 | # these are copied from has_one 50 | - original 51 | - add_suffix: '=' 52 | - add_prefix: build_ 53 | - add_prefix: create_ 54 | - { add_prefix: create_, add_suffix: '!' } 55 | - add_prefix: reload_ 56 | 57 | -------------------------------------------------------------------------------- /bin/test_autoload.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # frozen_string_literal: true 4 | 5 | @count = 0 6 | 7 | module AutoloadRegistry 8 | def autoload(name, path) 9 | ::AutoloadRegistry[self] << name 10 | super 11 | end 12 | 13 | def self.[](klass) 14 | @autoload_registry ||= {} 15 | @autoload_registry[klass] ||= [] 16 | end 17 | end 18 | 19 | ::Module.prepend ::AutoloadRegistry 20 | 21 | def print_overwritable_blue(text) 22 | print "\e[2K\r\e[33m#{text}\e[0m\r" 23 | end 24 | 25 | def print_green(text) 26 | puts "\e[2K\e[32m#{text}\e[0m" 27 | end 28 | 29 | def print_red(text) 30 | puts "\e[2K\e[31m#{text}\e[0m" 31 | end 32 | 33 | def print_error(error) 34 | puts "#{error.class.name}: #{error.message}" 35 | puts(*error.backtrace) 36 | end 37 | 38 | def try_get_const(parent, const_name) 39 | print_overwritable_blue("#{parent}::#{const_name}") 40 | const = parent.const_get(const_name, false) 41 | rescue ::LoadError, ::NameError => e 42 | print_red("#{parent}::#{const_name}") 43 | print_error(e) if ::ARGV.include?('--verbose') 44 | 45 | false 46 | else 47 | print_green("#{parent}::#{const_name}") unless ::ARGV.include?('--only-errors') 48 | const 49 | end 50 | 51 | def try_require(parent) 52 | ::AutoloadRegistry[parent].shuffle.each do |const_name| 53 | const = try_get_const(parent, const_name) 54 | exit(1) unless const 55 | 56 | @count += 1 57 | 58 | try_require(const) if const.is_a?(::Module) 59 | end 60 | end 61 | 62 | require_relative '../lib/leftovers' 63 | 64 | try_require(::Leftovers) 65 | 66 | require 'fast_ignore' 67 | 68 | if @count < ::FastIgnore.new(include_rules: '/lib/leftovers/**/*.rb').to_a.length 69 | print_red('Not all files were autoloaded') 70 | exit 1 71 | end 72 | -------------------------------------------------------------------------------- /lib/leftovers/processor_builders/dynamic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module ProcessorBuilders 5 | module Dynamic 6 | class << self 7 | def build(dynamic_rules) 8 | Each.each_or_self(dynamic_rules) do |dynamic| 9 | build_processors(**dynamic) 10 | end 11 | end 12 | 13 | private 14 | 15 | def build_processors( # rubocop:disable Metrics/ParameterLists 16 | call: nil, define: nil, 17 | set_privacy: nil, set_default_privacy: nil, 18 | eval: nil, **matcher_rules 19 | ) 20 | matcher = MatcherBuilders::Node.build_from_hash(**matcher_rules) 21 | 22 | processor = Each.build([ 23 | Action.build(call, Processors::AddCall), 24 | Action.build(define, Processors::AddDefinitionNode), 25 | build_set_privacy_action(set_privacy), 26 | build_set_default_privacy_action(set_default_privacy), 27 | Action.build(eval, Processors::Eval) 28 | ]) 29 | 30 | Processors::MatchMatchedNode.new(matcher, processor) 31 | end 32 | 33 | def build_set_privacy_action(set_privacies) 34 | Each.each_or_self(set_privacies) do |set_privacy| 35 | processor = Processors::SetPrivacy.new(set_privacy.delete(:to)) 36 | Action.build_from_hash_value( 37 | **set_privacy, final_processor: processor 38 | ) 39 | end 40 | end 41 | 42 | def build_set_default_privacy_action(set_default_privacy) 43 | return unless set_default_privacy 44 | 45 | Processors::SetDefaultPrivacy.new(set_default_privacy) 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/config/rspec.yml: -------------------------------------------------------------------------------- 1 | test_paths: 2 | - /spec/ 3 | 4 | # split into gems too 5 | 6 | keep: 7 | - path: /spec/ 8 | name: 9 | # matchers 10 | - diffable? 11 | - actual 12 | # formatters: 13 | - start 14 | - example_group_started 15 | - example_started 16 | - example_passed 17 | - example_failed 18 | - example_pending 19 | - message 20 | - stop 21 | - start_dump 22 | - dump_pending 23 | - dump_failures 24 | - dump_summary 25 | - seed 26 | - close 27 | dynamic: 28 | - name: 29 | - have_attributes 30 | - has_attributes? 31 | - receive_messages 32 | calls: 33 | keywords: '**' 34 | path: /spec/ 35 | - name: 36 | - receive 37 | - respond_to 38 | calls: 39 | argument: 0 40 | path: /spec/ 41 | - name: define_negated_matcher 42 | defines: 0 43 | calls: 1 44 | path: /spec/ 45 | - name: 46 | has_prefix: be_ 47 | calls: 48 | itself: true 49 | delete_prefix: be_ 50 | add_suffix: '?' 51 | path: /spec/ 52 | - name: 53 | has_prefix: have_ 54 | calls: 55 | itself: true 56 | add_suffix: '?' 57 | delete_prefix: have_ 58 | add_prefix: has_ 59 | path: /spec/ 60 | - name: let 61 | unless: 62 | has_argument: 1 63 | defines: 0 64 | path: /spec/ 65 | 66 | - name: define 67 | has_receiver: 68 | match: Matchers 69 | has_receiver: RSpec 70 | defines: 71 | argument: 0 72 | path: /spec/ 73 | 74 | - name: matcher 75 | has_block: true 76 | defines: 77 | argument: 0 78 | path: /spec/ 79 | 80 | - name: alias_matcher 81 | defines: 82 | argument: 0 83 | calls: 84 | argument: 1 85 | path: /spec/ 86 | -------------------------------------------------------------------------------- /spec/file_collector/yaml_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | ::RSpec.describe ::Leftovers::Precompilers::YAML do 6 | subject(:collector) do 7 | collector = ::Leftovers::FileCollector.new(ruby, file) 8 | collector.collect 9 | collector 10 | end 11 | 12 | let(:path) { 'foo.yaml' } 13 | let(:file) do 14 | ::Leftovers::File.new(::Leftovers.pwd + path) 15 | .tap { |f| allow(f).to receive_messages(read: yaml) } 16 | end 17 | let(:yaml) { '' } 18 | let(:ruby) { file.ruby } 19 | 20 | context 'with yaml files with constant' do 21 | let(:yaml) do 22 | stub_const('This::That', ::Module.new) 23 | [This::That].to_yaml 24 | end 25 | 26 | it { is_expected.to have_no_definitions.and(have_calls_including(:This, :That)) } 27 | end 28 | 29 | context 'with yaml files with instance' do 30 | let(:yaml) do 31 | stub_const('This::That', ::Class.new) 32 | [This::That.new].to_yaml 33 | end 34 | 35 | it { is_expected.to have_no_definitions.and(have_calls_including(:This, :That)) } 36 | end 37 | 38 | context 'with yaml files with e.g. exception subclass' do 39 | let(:yaml) do 40 | stub_const('This::That', ::Class.new(RuntimeError)) 41 | [This::That].to_yaml 42 | end 43 | 44 | it { is_expected.to have_no_definitions.and(have_calls_including(:This, :That)) } 45 | end 46 | 47 | context 'with invalid yaml files' do 48 | let(:yaml) do 49 | <<~YAML 50 | my_class: ' 51 | YAML 52 | end 53 | 54 | it 'outputs an error and collects nothing' do 55 | expect { subject }.to print_warning(<<~STDERR) 56 | Psych::SyntaxError: foo.yaml:1:11 found unexpected end of stream while scanning a quoted scalar 57 | STDERR 58 | expect(subject).to have_no_definitions.and(have_no_calls) 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /docs/Custom-Precompilers.md: -------------------------------------------------------------------------------- 1 | # Custom Precompilers 2 | 3 | In addition to the built in precompilers, it's possible to add a custom precompiler 4 | 5 | It must be a class or module with a singleton method `precompile`. take a string of whatever code it likes, and return a string of valid ruby. 6 | 7 | ```ruby 8 | require 'not_ruby' # require the gem that does the actual transformation 9 | 10 | module MyNotRubyPrecompiler 11 | def self.precompile(not_ruby_content) 12 | # not_ruby_content is a string of (hopefully) valid precompilable code 13 | NotRuby::Parser.parse(not_ruby_content).to_ruby # output a string of valid ruby 14 | end 15 | end 16 | ``` 17 | 18 | See the [build in precompilers](https://github.com/robotdana/leftovers/tree/main/lib/precompilers) for other examples. 19 | 20 | To configure the precompiler to be used by leftovers, add something similar to this to the `.leftovers.yml` file 21 | 22 | ```yml 23 | include_paths: 24 | - 'lib/**/*.not_rb' 25 | require: './path/to/my_not_ruby_precompiler' 26 | precompile: 27 | - paths: '*.not_rb' 28 | format: { custom: MyNotRubyPrecompiler } 29 | ``` 30 | 31 | Ensure any necessary extension patterns are added to to [`include_paths:`](https://github.com/robotdana/leftovers/tree/main/docs/Configuration.md#include_paths) for those particular files you wish to check. 32 | 33 | Require the custom precompiler using [`require:`](https://github.com/robotdana/leftovers/tree/main/docs/Configuration.md#requires) 34 | 35 | Define which paths use the custom precompiler using [`precompile:`](https://github.com/robotdana/leftovers/tree/main/docs/Configuration.md#precompile), 36 | reference the name of the precompiler with `format: { custom: MyNotRubyPrecompiler }` 37 | 38 | If the `precompile` method raises any errors while precompiling, a warning will be printed to stderr and the file will be skipped 39 | 40 | To test the output of the precompiler you can use the `--view-compiled` flag with a list of paths or path patterns, like so: 41 | 42 | `bundle exec leftovers --view-compiled '*.not_rb'` 43 | 44 | 45 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders/node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module MatcherBuilders 5 | module Node 6 | class << self 7 | def build(patterns) 8 | Or.each_or_self(patterns) do |pattern| 9 | case pattern 10 | when ::String then NodeName.build(pattern) 11 | when ::Hash then build_from_hash(**pattern) 12 | # :nocov: 13 | else raise UnexpectedCase, "Unhandled value #{pattern.inspect}" 14 | # :nocov: 15 | end 16 | end 17 | end 18 | 19 | def build_from_hash( # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength 20 | names: nil, match: nil, has_prefix: nil, has_suffix: nil, 21 | document: false, 22 | paths: nil, 23 | has_arguments: nil, 24 | has_receiver: nil, 25 | has_block: nil, 26 | type: nil, 27 | privacy: nil, 28 | unless_arg: nil, all: nil, any: nil 29 | ) 30 | And.build([ 31 | build_node_name_matcher(names, match, has_prefix, has_suffix), 32 | Document.build(document), 33 | NodePath.build(paths), 34 | NodeHasArgument.build(has_arguments), 35 | NodeHasBlock.build(has_block), 36 | NodeHasReceiver.build(has_receiver), 37 | NodePrivacy.build(privacy), 38 | NodeType.build(type), 39 | Unless.build(build(unless_arg)), 40 | build_all_matcher(all), 41 | build(any) 42 | ]) 43 | end 44 | 45 | private 46 | 47 | def build_node_name_matcher(names, match, has_prefix, has_suffix) 48 | Or.build([ 49 | NodeName.build(names), 50 | NodeName.build(match: match, has_prefix: has_prefix, has_suffix: has_suffix) 51 | ]) 52 | end 53 | 54 | def build_all_matcher(all) 55 | And.build(all.map { |pattern| build(pattern) }) if all 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders/node_has_argument.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module MatcherBuilders 5 | module NodeHasArgument 6 | class << self 7 | def build(patterns) 8 | Or.each_or_self(patterns) do |pat| 9 | case pat 10 | when ::String then NodeHasKeywordArgument.build(pat, nil) 11 | when ::Integer then NodeHasPositionalArgument.build(pat, nil) 12 | when ::Hash then build_from_hash(**pat) 13 | # :nocov: 14 | else raise UnexpectedCase, "Unhandled value #{pat.inspect}" 15 | # :nocov: 16 | end 17 | end 18 | end 19 | 20 | private 21 | 22 | def build_from_hash(at: nil, has_value: nil, unless_arg: nil) 23 | And.build([ 24 | build_argument_matcher(NodeValue.build(has_value), **separate_argument_types(at)), 25 | Unless.build(build(unless_arg)) 26 | ]) 27 | end 28 | 29 | def separate_argument_types(at) 30 | groups = ::Leftovers.wrap_array(at).group_by do |index| 31 | case index 32 | when '*', ::Integer then :positions 33 | when ::String, ::Hash then :keys 34 | # :nocov: 35 | else raise UnexpectedCase, "Unhandled value #{index.inspect}" 36 | # :nocov: 37 | end 38 | end 39 | 40 | groups.transform_values { |v| ::Leftovers.unwrap_array(v) } 41 | end 42 | 43 | def build_argument_matcher(value_matcher, keys: nil, positions: nil) 44 | if keys && !positions 45 | NodeHasKeywordArgument.build(keys, value_matcher) 46 | elsif positions && !keys 47 | NodeHasPositionalArgument.build(positions, value_matcher) 48 | else 49 | Or.build([ 50 | NodeHasPositionalArgument.build(positions, value_matcher), 51 | NodeHasKeywordArgument.build(keys, value_matcher) 52 | ]) 53 | end 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/file_collector/erb_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | ::RSpec.describe ::Leftovers::FileCollector do 6 | subject(:collector) do 7 | collector = described_class.new(ruby, file) 8 | collector.collect 9 | collector 10 | end 11 | 12 | let(:path) { 'foo.erb' } 13 | let(:file) do 14 | ::Leftovers::File.new(::Leftovers.pwd + path) 15 | .tap { |f| allow(f).to receive_messages(read: erb) } 16 | end 17 | let(:erb) { '' } 18 | let(:ruby) { file.ruby } 19 | 20 | context 'with erb files' do 21 | let(:erb) do 22 | <<~ERB 23 | label' 24 | ERB 25 | end 26 | 27 | it do 28 | expect(subject).to have_no_definitions 29 | .and(have_calls_including(:whatever)) 30 | end 31 | end 32 | 33 | context 'with erb files when newline trimmed' do 34 | let(:erb) do 35 | <<~ERB 36 | <%- if foo.present? -%> 37 | label 38 | <%- end -%> 39 | ERB 40 | end 41 | 42 | it do 43 | expect(subject).to have_no_definitions 44 | .and(have_calls(:foo, :present?)) 45 | end 46 | end 47 | 48 | context 'with erb files when block begins' do 49 | let(:erb) do 50 | <<~ERB 51 | <% bar do %> 52 | label 53 | <% end %> 54 | ERB 55 | end 56 | 57 | it do 58 | expect(subject).to have_no_definitions 59 | .and(have_calls(:foo, :bar)) 60 | end 61 | end 62 | 63 | context 'with erb files when comments' do 64 | let(:erb) do 65 | <<~ERB 66 | <% #Comment %> 67 | <% if query? %> 68 | <%= call %> 69 | <% end %> 70 | ERB 71 | end 72 | 73 | it do 74 | expect(subject).to have_no_definitions.and(have_calls(:query?, :call)) 75 | end 76 | end 77 | 78 | context 'with invalid? erb file' do 79 | # erb just interprets this as literal text 80 | let(:erb) do 81 | <<~ERB 82 | <%= true if query? 83 | ERB 84 | end 85 | 86 | it do 87 | expect(subject).to have_no_definitions.and(have_no_calls) 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/leftovers/matcher_builders/node_value.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module MatcherBuilders 5 | module NodeValue 6 | class << self 7 | def build(patterns) 8 | Or.each_or_self(patterns) do |pattern| 9 | case pattern 10 | when ::Integer, ::Float, true, false then Matchers::NodeScalarValue.new(pattern) 11 | # matching scalar on nil will fall afoul of compact and each_or_self etc. 12 | when :_leftovers_nil_value then Matchers::NodeType.new(:nil) 13 | when ::String then NodeName.build(pattern) 14 | when ::Hash then build_from_hash(**pattern) 15 | # :nocov: 16 | else raise UnexpectedCase, "Unhandled value #{pattern.inspect}" 17 | # :nocov: 18 | end 19 | end 20 | end 21 | 22 | private 23 | 24 | def build_node_name_matcher(names, match, has_prefix, has_suffix) 25 | Or.build([ 26 | NodeName.build(names), 27 | NodeName.build(match: match, has_prefix: has_prefix, has_suffix: has_suffix) 28 | ]) 29 | end 30 | 31 | def build_node_has_argument_matcher(has_arguments, at, has_value) 32 | Or.build([ 33 | NodeHasArgument.build(has_arguments), 34 | NodeHasArgument.build(at: at, has_value: has_value) 35 | ]) 36 | end 37 | 38 | def build_from_hash( # rubocop:disable Metrics/ParameterLists 39 | has_arguments: nil, at: nil, has_value: nil, 40 | names: nil, match: nil, has_prefix: nil, has_suffix: nil, 41 | type: nil, 42 | has_receiver: nil, 43 | literal: nil, 44 | unless_arg: nil 45 | ) 46 | And.build([ 47 | build_node_has_argument_matcher(has_arguments, at, has_value), 48 | build_node_name_matcher(names, match, has_prefix, has_suffix), 49 | NodeType.build(type), 50 | NodeHasReceiver.build(has_receiver), 51 | NodeValue.build(literal), 52 | Unless.build(build(unless_arg)) 53 | ]) 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /leftovers.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 'leftovers/version' 6 | 7 | ::Gem::Specification.new do |spec| 8 | spec.name = 'leftovers' 9 | spec.version = ::Leftovers::VERSION 10 | spec.authors = ['Dana Sherson'] 11 | spec.email = ['robot@dana.sh'] 12 | 13 | spec.summary = 'Find unused methods and classes/modules' 14 | spec.homepage = 'http://github.com/robotdana/leftovers' 15 | spec.license = 'MIT' 16 | 17 | spec.metadata['homepage_uri'] = spec.homepage 18 | spec.metadata['source_code_uri'] = 'http://github.com/robotdana/leftovers' 19 | spec.metadata['changelog_uri'] = 'http://github.com/robotdana/leftovers/blob/main/CHANGELOG.md' 20 | 21 | spec.metadata['rubygems_mfa_required'] = 'true' 22 | spec.required_ruby_version = '>= 2.5.0' 23 | 24 | spec.files = ::Dir.glob('{lib,exe,docs}/**/{*,.*}') + %w{ 25 | CHANGELOG.md 26 | Gemfile 27 | LICENSE.txt 28 | README.md 29 | leftovers.gemspec 30 | } 31 | spec.bindir = 'exe' 32 | spec.executables = spec.files.grep(%r{^exe/}) { |f| ::File.basename(f) } 33 | spec.require_paths = ['lib'] 34 | 35 | spec.add_development_dependency 'activesupport' 36 | spec.add_development_dependency 'benchmark-ips' 37 | spec.add_development_dependency 'bundler', '~> 2.0' 38 | spec.add_development_dependency 'haml' 39 | spec.add_development_dependency 'pry', '~> 0.1' 40 | spec.add_development_dependency 'rake', '>= 13' 41 | spec.add_development_dependency 'rspec', '~> 3.0' 42 | spec.add_development_dependency 'rubocop' 43 | spec.add_development_dependency 'rubocop-performance' 44 | spec.add_development_dependency 'rubocop-rake' 45 | spec.add_development_dependency 'rubocop-rspec' 46 | spec.add_development_dependency 'ruby-prof' 47 | spec.add_development_dependency 'simplecov', '>= 0.18.5' 48 | spec.add_development_dependency 'simplecov-console' 49 | spec.add_development_dependency 'slim' 50 | spec.add_development_dependency 'timecop' 51 | spec.add_development_dependency 'tty_string', '>= 0.2.1' 52 | 53 | spec.add_development_dependency 'spellr', '>= 0.8.1' 54 | spec.add_dependency 'fast_ignore', '>= 0.17.0' 55 | spec.add_dependency 'parallel' 56 | spec.add_dependency 'parser' 57 | end 58 | -------------------------------------------------------------------------------- /lib/leftovers/config_loader/document_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class ConfigLoader 5 | class DocumentSchema < ObjectSchema 6 | attribute :include_paths, ValueOrArraySchema[StringSchema], aliases: :include_path 7 | attribute :exclude_paths, ValueOrArraySchema[StringSchema], aliases: :exclude_path 8 | attribute :test_paths, ValueOrArraySchema[StringSchema], aliases: :test_path 9 | attribute :haml_paths, ValueOrArraySchema[StringSchema], aliases: :haml_path, suggest: false 10 | attribute :slim_paths, ValueOrArraySchema[StringSchema], aliases: :slim_path, suggest: false 11 | attribute :yaml_paths, ValueOrArraySchema[StringSchema], aliases: :yaml_path, suggest: false 12 | attribute :json_paths, ValueOrArraySchema[StringSchema], aliases: :json_path, suggest: false 13 | attribute :erb_paths, ValueOrArraySchema[StringSchema], aliases: :erb_path, suggest: false 14 | attribute :precompile, ValueOrArraySchema[PrecompileSchema] 15 | attribute :requires, ValueOrArraySchema[RequireSchema], aliases: :require 16 | attribute :gems, ValueOrArraySchema[StringSchema], aliases: :gem 17 | attribute :keep, ValueOrArraySchema[KeepTestOnlySchema] 18 | attribute :test_only, ValueOrArraySchema[KeepTestOnlySchema] 19 | attribute :dynamic, ValueOrArraySchema[DynamicSchema] 20 | 21 | PRECOMPILERS = %i{haml_paths slim_paths json_paths yaml_paths erb_paths}.freeze 22 | 23 | def self.to_ruby(node) # rubocop:disable Metrics 24 | read_hash = super 25 | write_hash = read_hash.dup 26 | 27 | read_hash.each do |key, value| 28 | next unless PRECOMPILERS.include?(key) 29 | 30 | value = { paths: value, format: key.to_s.delete_suffix('_paths').to_sym } 31 | yaml = { 'precompile' => [value.transform_keys(&:to_s).transform_values(&:to_s)] } 32 | .to_yaml.delete_prefix("---\n") 33 | 34 | ::Leftovers.warn(<<~MESSAGE) 35 | \e[33m`#{key}:` is deprecated\e[0m 36 | Replace with: 37 | \e[32m#{yaml}\e[0m 38 | MESSAGE 39 | 40 | write_hash[:precompile] = ::Leftovers.wrap_array(write_hash[:precompile]) 41 | write_hash[:precompile] << value 42 | write_hash.delete(key) 43 | end 44 | 45 | write_hash 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/leftovers/processor_builders/transform.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | module ProcessorBuilders 5 | module Transform 6 | def self.build(transform, arguments, then_processor) # rubocop:disable Metrics 7 | case transform 8 | when :original, nil then then_processor 9 | when :downcase then Processors::Downcase.new(then_processor) 10 | when :upcase then Processors::Upcase.new(then_processor) 11 | when :capitalize then Processors::Capitalize.new(then_processor) 12 | when :swapcase then Processors::Swapcase.new(then_processor) 13 | when :pluralize then Processors::Pluralize.new(then_processor) 14 | when :singularize then Processors::Singularize.new(then_processor) 15 | when :camelize then Processors::Camelize.new(then_processor) 16 | when :titleize then Processors::Titleize.new(then_processor) 17 | when :demodulize then Processors::Demodulize.new(then_processor) 18 | when :deconstantize then Processors::Deconstantize.new(then_processor) 19 | when :parameterize then Processors::Parameterize.new(then_processor) 20 | when :underscore then Processors::Underscore.new(then_processor) 21 | when :transforms then TransformSet.build(arguments, then_processor) 22 | else 23 | Each.each_or_self(arguments) do |arg| 24 | case transform 25 | when :split then Processors::Split.new(arg, then_processor) 26 | when :delete_before then Processors::DeleteBefore.new(arg, then_processor) 27 | when :delete_before_last then Processors::DeleteBeforeLast.new(arg, then_processor) 28 | when :delete_after then Processors::DeleteAfter.new(arg, then_processor) 29 | when :delete_after_last then Processors::DeleteAfterLast.new(arg, then_processor) 30 | when :add_prefix then AddPrefix.build(arg, then_processor) 31 | when :add_suffix then AddSuffix.build(arg, then_processor) 32 | when :delete_prefix then Processors::DeletePrefix.new(arg, then_processor) 33 | when :delete_suffix then Processors::DeleteSuffix.new(arg, then_processor) 34 | # :nocov: 35 | else raise UnexpectedCase, "Unhandled value #{transform.to_s.inspect}" 36 | # :nocov: 37 | end 38 | end 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/leftovers/ast/node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'parser' 4 | 5 | module Leftovers 6 | module AST 7 | class Node < ::Parser::AST::Node 8 | def initialize(type, children = [], properties = {}) 9 | # ::AST::Node#initialize freezes itself. 10 | # so can't use normal memoizations 11 | @memo = {} 12 | 13 | super 14 | end 15 | 16 | # This is called by ::Parser::AST internals 17 | def updated(type = nil, children = nil, properties = nil) # leftovers:keep 18 | maybe_copy = super 19 | 20 | class_for_type = Builder.node_class(maybe_copy.type) 21 | return maybe_copy if maybe_copy.instance_of?(class_for_type) 22 | 23 | class_for_type.new(maybe_copy.type, maybe_copy.children, location: maybe_copy.loc) 24 | end 25 | 26 | def first 27 | children.first 28 | end 29 | 30 | def second 31 | children[1] 32 | end 33 | 34 | def path 35 | loc.expression.source_buffer.name 36 | end 37 | 38 | def privacy=(value) 39 | @memo[:privacy] = value 40 | end 41 | 42 | def privacy 43 | @memo[:privacy] || :public 44 | end 45 | 46 | def parent 47 | @memo[:parent] 48 | end 49 | 50 | def parent=(value) 51 | @memo[:parent] = value 52 | end 53 | 54 | def to_scalar_value 55 | nil 56 | end 57 | 58 | def scalar? 59 | false 60 | end 61 | 62 | def to_s 63 | '' 64 | end 65 | 66 | def to_sym 67 | :'' 68 | end 69 | 70 | def to_literal_s 71 | nil 72 | end 73 | 74 | def hash? 75 | false 76 | end 77 | 78 | def proc? 79 | false 80 | end 81 | 82 | def sym? 83 | false 84 | end 85 | 86 | def as_arguments_list 87 | @memo[:as_arguments_list] ||= [self] 88 | end 89 | 90 | def arguments 91 | nil 92 | end 93 | 94 | def positional_arguments 95 | nil 96 | end 97 | 98 | def receiver 99 | nil 100 | end 101 | 102 | def kwargs 103 | nil 104 | end 105 | 106 | def name 107 | nil 108 | end 109 | 110 | def block_given? 111 | false 112 | end 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /spec/matcher_builders/string_pattern_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ::RSpec.describe ::Leftovers::MatcherBuilders::StringPattern do 4 | describe '.build' do 5 | it 'returns nil when given nothing' do 6 | expect(described_class.build).to be_nil 7 | end 8 | 9 | it 'returns an anchored regex when given match' do 10 | expect(described_class.build(match: 'a')).to match 'a' 11 | expect(described_class.build(match: 'a')).not_to match 'aa' 12 | expect(described_class.build(match: 'a*')).to match 'aa' 13 | end 14 | 15 | it 'matches exact strings when given has_prefix' do 16 | expect(described_class.build(has_prefix: 'a')).to match 'ax' 17 | expect(described_class.build(has_prefix: '[a]')).not_to match 'ax' 18 | expect(described_class.build(has_prefix: '[a]')).to match '[a]x' 19 | end 20 | 21 | it 'matches exact strings when given has_suffix' do 22 | expect(described_class.build(has_suffix: 'x')).to match 'ax' 23 | expect(described_class.build(has_suffix: '[x]')).not_to match 'ax' 24 | expect(described_class.build(has_suffix: '[x]')).to match 'a[x]' 25 | end 26 | 27 | it 'matches strings when given has_prefix and has_suffix' do 28 | expect(described_class.build(has_prefix: 'a', has_suffix: 'x')).to match 'ax' 29 | expect(described_class.build(has_prefix: '[a]', has_suffix: '[x]')).not_to match 'ax' 30 | expect(described_class.build(has_prefix: '[a]', has_suffix: '[x]')).to match '[a][x]' 31 | end 32 | 33 | it 'can overlap the match for has_prefix and has_suffix' do 34 | expect(described_class.build(has_prefix: 'a_', has_suffix: '_x')).to match 'a_x' 35 | end 36 | 37 | it 'can combine match and has_prefix' do 38 | expect(described_class.build(match: '[a-z]*', has_prefix: 'a')).to match 'ax' 39 | expect(described_class.build(match: '[a-z]*', has_prefix: 'a')).not_to match 'a_x' 40 | end 41 | 42 | it 'can combine match and has_suffix' do 43 | expect(described_class.build(match: '[a-z]*', has_suffix: 'x')).to match 'ax' 44 | expect(described_class.build(match: '[a-z]*', has_suffix: 'x')).not_to match 'a_x' 45 | end 46 | 47 | it 'can combine match and has_prefix and has_suffix' do 48 | expect(described_class.build(match: '[a-z]*', has_prefix: 'l', has_suffix: 'x')) 49 | .to match 'lax' 50 | expect(described_class.build(match: '[a-z]*', has_prefix: 'l', has_suffix: 'x')) 51 | .not_to match 'l a x' 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/leftovers/file_collector/comments_processor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class FileCollector 5 | module CommentsProcessor 6 | class << self 7 | def process(comments, collector) 8 | comments.each do |comment| 9 | process_leftovers_keep_comment(comment, collector) 10 | process_leftovers_test_comment(comment, collector) 11 | process_leftovers_dynamic_comment(comment, collector) 12 | process_leftovers_call_comment(comment, collector) 13 | end 14 | end 15 | 16 | private 17 | 18 | method_name_re = /[[:alpha:]_][[:alnum:]_]*\b[?!=]?/.freeze 19 | non_alnum_method_name_re = ::Regexp.union(%w{ 20 | []= [] ** ~ +@ -@ * / % + - >> << & 21 | ^ | <=> <= >= < > === == != =~ !~ ! 22 | }.map { |op| /#{::Regexp.escape(op)}/ }) 23 | constant_name_re = /[[:upper:]][[:alnum:]_]*\b/.freeze 24 | NAME_RE = ::Regexp.union(method_name_re, non_alnum_method_name_re, constant_name_re) 25 | name_list_re = /#{NAME_RE}(?:[, :]+#{NAME_RE})*/o.freeze 26 | 27 | CALL_RE = /\bleftovers:call(?:s|e(?:d|rs?))? (#{name_list_re})/.freeze 28 | ALLOW_RE = /\bleftovers:(?:keeps?|skip(?:s|ped|)|allow(?:s|ed|))\b/.freeze 29 | TEST_ONLY_RE = /\bleftovers:(?:for_tests?|tests?|testing|test_only)\b/.freeze 30 | DYNAMIC_RE = /\bleftovers:dynamic[: ](#{name_list_re})/.freeze 31 | 32 | private_constant :NAME_RE, :CALL_RE, :ALLOW_RE, :TEST_ONLY_RE, :DYNAMIC_RE 33 | 34 | def process_leftovers_keep_comment(comment, collector) 35 | return unless comment.text.match?(ALLOW_RE) 36 | 37 | collector.allow_lines << comment.loc.line 38 | end 39 | 40 | def process_leftovers_test_comment(comment, collector) 41 | return unless comment.text.match?(TEST_ONLY_RE) 42 | 43 | collector.test_lines << comment.loc.line 44 | end 45 | 46 | def process_leftovers_dynamic_comment(comment, collector) 47 | match = comment.text.match(DYNAMIC_RE) 48 | return unless match 49 | 50 | collector.dynamic_lines[comment.loc.line] = match[1].scan(NAME_RE) 51 | end 52 | 53 | def process_leftovers_call_comment(comment, collector) 54 | match = comment.text.match(CALL_RE) 55 | return unless match 56 | 57 | match[1].scan(NAME_RE).each { |s| collector.calls << s.to_sym } 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/leftovers/cli.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'optparse' 4 | 5 | module Leftovers 6 | class CLI 7 | def initialize(argv: []) 8 | @argv = argv 9 | end 10 | 11 | def run 12 | parse_options 13 | 14 | runner.run 15 | rescue ::Leftovers::Error => e 16 | Leftovers.warn("\e[31m#{e.class}: #{e.message}\e[0m\n\n#{e.backtrace.join("\n")}") 17 | 1 18 | rescue ::Leftovers::Exit => e 19 | e.status # what why? 20 | end 21 | 22 | private 23 | 24 | attr_reader :argv 25 | 26 | def option_parser 27 | @option_parser ||= ::OptionParser.new do |o| 28 | o.banner = 'Usage: leftovers [options]' 29 | 30 | o.on('--[no-]parallel', 'Run in parallel or not, default --parallel') { |p| parallel(p) } 31 | o.on('--[no-]progress', 'Show live counts or not, default --progress') { |p| progress(p) } 32 | o.on('--dry-run', 'Print a list of files that would be looked at') { dry_run } 33 | o.on('--view-compiled', 'Print the compiled content of the files') { view_compiled } 34 | o.on('--write-todo', 'Create a config file with the existing unused items') { write_todo } 35 | o.on('-v', '--version', 'Print the current version') { print_version } 36 | o.on('-h', '--help', 'Print this message') { print_help } 37 | end 38 | end 39 | 40 | def parse_options 41 | option_parser.parse!(argv) 42 | rescue ::OptionParser::ParseError => e 43 | ::Leftovers.error("CLI Error: #{e.message}", option_parser.help) 44 | end 45 | 46 | def runner 47 | @runner ||= Runner.new 48 | end 49 | 50 | def exit(status = 0) 51 | ::Leftovers.exit status 52 | end 53 | 54 | def dry_run 55 | FileList.new.each { |file| ::Leftovers.puts file.relative_path } 56 | 57 | exit 58 | end 59 | 60 | def view_compiled 61 | FileList.new(argv_rules: argv).each do |file| 62 | ::Leftovers.puts "\e[0;2m#{file.relative_path}\e[0m\n#{file.ruby}" 63 | end 64 | 65 | exit 66 | end 67 | 68 | def print_version 69 | ::Leftovers.puts(VERSION) 70 | 71 | exit 72 | end 73 | 74 | def print_help 75 | ::Leftovers.puts(option_parser.help) 76 | 77 | exit 78 | end 79 | 80 | def parallel(value) 81 | runner.parallel = value 82 | end 83 | 84 | def progress(value) 85 | runner.progress = value 86 | end 87 | 88 | def write_todo 89 | runner.reporter = TodoReporter 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /spec/config/rspec_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | ::RSpec.describe 'rspec gem' do 6 | subject(:collector) do 7 | collector = ::Leftovers::FileCollector.new(ruby, file) 8 | collector.collect 9 | collector 10 | end 11 | 12 | before do 13 | ::Leftovers.config << :rspec 14 | end 15 | 16 | let(:path) { 'spec/file_spec.rb' } 17 | let(:file) { ::Leftovers::File.new(::Leftovers.pwd + path) } 18 | let(:ruby) { '' } 19 | 20 | context 'with method calls using be_' do 21 | let(:ruby) { 'expect(array).to be_empty' } 22 | 23 | it do 24 | expect(subject).to have_no_definitions 25 | .and(have_calls(:expect, :array, :to, :empty?, :be_empty)) 26 | end 27 | end 28 | 29 | context 'with method calls using have_' do 30 | let(:ruby) { 'expect(array).to have_key(:key)' } 31 | 32 | it do 33 | expect(subject).to have_no_definitions 34 | .and(have_calls(:expect, :array, :to, :has_key?, :have_key)) 35 | end 36 | end 37 | 38 | context 'with method calls using receive_messages' do 39 | let(:ruby) { 'expect(array).to receive_messages(my_method: true)' } 40 | 41 | it do 42 | expect(subject).to have_no_definitions 43 | .and(have_calls(:expect, :array, :to, :receive_messages, :my_method)) 44 | end 45 | end 46 | 47 | context 'with a custom matcher' do 48 | let(:ruby) do 49 | <<~RUBY 50 | ::RSpec::Matchers.define :custom_eq do |expected| 51 | match do |actual| 52 | actual == expected 53 | end 54 | end 55 | RUBY 56 | end 57 | 58 | it do 59 | expect(subject).to have_definitions(:custom_eq) 60 | .and(have_calls(:RSpec, :Matchers, :define, :match, :==)) 61 | end 62 | end 63 | 64 | context 'with a custom matcher defined another way' do 65 | let(:ruby) do 66 | <<~RUBY 67 | # extend RSpec::Matchers::DSL 68 | matcher :custom_eq do |expected| 69 | match { |actual| actual == expected } 70 | end 71 | RUBY 72 | end 73 | 74 | it do 75 | expect(subject).to have_definitions(:custom_eq) 76 | .and(have_calls(:matcher, :match, :==)) 77 | end 78 | end 79 | 80 | context 'with a custom alias matcher' do 81 | let(:ruby) do 82 | <<~RUBY 83 | # extend RSpec::Matchers::DSL 84 | RSpec::Matchers.alias_matcher :custom_eq, :eq 85 | RUBY 86 | end 87 | 88 | it do 89 | expect(subject).to have_definitions(:custom_eq) 90 | .and(have_calls(:RSpec, :Matchers, :alias_matcher, :eq)) 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/leftovers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Leftovers 4 | class Exit < ::StandardError 5 | attr_reader :status 6 | 7 | def initialize(status) # rubocop:disable Lint/MissingSuper 8 | @status = status 9 | end 10 | end 11 | 12 | require_relative 'leftovers/autoloader' 13 | include Autoloader 14 | 15 | MEMOIZED_IVARS = %i{ 16 | @config 17 | @try_require_cache 18 | @stdout 19 | @stderr 20 | @pwd 21 | }.freeze 22 | 23 | class << self 24 | attr_writer :stdout, :stderr 25 | 26 | def stdout 27 | @stdout ||= ::StringIO.new 28 | end 29 | 30 | def stderr 31 | @stderr ||= ::StringIO.new 32 | end 33 | 34 | def config 35 | @config ||= MergedConfig.new(load_defaults: true) 36 | end 37 | 38 | def reset 39 | MEMOIZED_IVARS.each do |ivar| 40 | remove_instance_variable(ivar) if instance_variable_get(ivar) 41 | end 42 | end 43 | 44 | def resolution_instructions_link 45 | "https://github.com/robotdana/leftovers/tree/v#{VERSION}/README.md#how-to-resolve" 46 | end 47 | 48 | def warn(message) 49 | stderr.puts("\e[2K#{message}") 50 | end 51 | 52 | def error(message, did_you_mean = nil) 53 | warn("\e[31m#{message}\e[0m") 54 | warn("\n#{did_you_mean}") if did_you_mean 55 | raise Exit, 1 56 | end 57 | 58 | def puts(message) 59 | stdout.puts("\e[2K#{message}") 60 | end 61 | 62 | def print(message) 63 | stdout.print("\e[2K#{message}\r") 64 | end 65 | 66 | def pwd 67 | @pwd ||= ::Pathname.new(::Dir.pwd + '/') 68 | end 69 | 70 | def exit(status = 0) 71 | raise Exit, status 72 | end 73 | 74 | def try_require(requirable, message: nil) 75 | warn message if !try_require_cache(requirable) && message 76 | try_require_cache(requirable) 77 | end 78 | 79 | def wrap_array(value) 80 | case value 81 | when nil then [] 82 | when ::Array then value 83 | else [value] 84 | end 85 | end 86 | 87 | def unwrap_array(array) 88 | if array.length <= 1 89 | array.first 90 | else 91 | array 92 | end 93 | end 94 | 95 | private 96 | 97 | def try_require_cache(requirable) 98 | @try_require_cache ||= {} 99 | 100 | @try_require_cache.fetch(requirable) do 101 | require requirable 102 | @try_require_cache[requirable] = true 103 | rescue ::LoadError 104 | @try_require_cache[requirable] = false 105 | end 106 | end 107 | end 108 | end 109 | --------------------------------------------------------------------------------