├── .augment-guidelines ├── .bundle └── config ├── .devcontainer ├── Dockerfile ├── devcontainer.json └── post-create.sh ├── .editorconfig ├── .gitattributes ├── .github └── workflows │ ├── main.yml │ └── release.yml ├── .gitignore ├── .idea ├── .gitignore ├── AugmentWebviewStateStore.xml ├── ivar.iml ├── misc.xml ├── modules.xml └── vcs.xml ├── .rubocop.yml ├── .standard.yml ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── OLD_README.md ├── README.md ├── Rakefile ├── VERSION.md ├── examples ├── check_all_block_example.rb ├── check_all_example.rb ├── inheritance_with_kwarg_init.rb ├── inheritance_with_positional_init.rb ├── mixed_positional_and_kwarg_init.rb ├── require_check_all_example.rb ├── sandwich_inheritance.rb ├── sandwich_with_accessors.rb ├── sandwich_with_block_values.rb ├── sandwich_with_checked.rb ├── sandwich_with_checked_once.rb ├── sandwich_with_initial_values.rb ├── sandwich_with_ivar_block.rb ├── sandwich_with_ivar_macro.rb ├── sandwich_with_kwarg_init.rb ├── sandwich_with_positional_init.rb └── sandwich_with_shared_values.rb ├── hooks ├── README.md ├── install.sh └── pre-commit ├── ivar.gemspec ├── lib ├── ivar.rb └── ivar │ ├── check_all.rb │ ├── check_all_manager.rb │ ├── check_policy.rb │ ├── checked.rb │ ├── checked │ ├── class_methods.rb │ └── instance_methods.rb │ ├── declaration.rb │ ├── explicit_declaration.rb │ ├── explicit_keyword_declaration.rb │ ├── explicit_positional_declaration.rb │ ├── macros.rb │ ├── manifest.rb │ ├── policies.rb │ ├── project_root.rb │ ├── targeted_prism_analysis.rb │ ├── validation.rb │ └── version.rb ├── script ├── console ├── de-lint ├── de-lint-unsafe ├── lint ├── release ├── setup └── test ├── sig └── ivar.rbs ├── test ├── fixtures │ ├── check_all_project │ │ ├── Gemfile │ │ ├── Rakefile │ │ ├── lib │ │ │ ├── block_classes.rb │ │ │ ├── block_test.rb │ │ │ ├── dynamic_class.rb │ │ │ └── inside_class.rb │ │ ├── test_block_scope.rb │ │ ├── test_inside_class.rb │ │ └── test_outside_class.rb │ ├── child_with_checked_ivars.rb │ ├── child_with_ivar_tools.rb │ ├── outside_project │ │ └── outside_class.rb │ ├── parent_with_checked_ivars.rb │ ├── parent_with_ivar_tools.rb │ ├── sandwich.rb │ ├── sandwich_with_checked_ivars.rb │ ├── sandwich_with_checked_once.rb │ ├── sandwich_with_ivar_tools.rb │ ├── sandwich_with_validation.rb │ ├── split_class.rb │ ├── split_class_part1.rb │ ├── split_class_part2.rb │ └── targeted_analysis │ │ ├── mixed_methods_class.rb │ │ ├── multi_class_file.rb │ │ ├── split_target_class.rb │ │ ├── split_target_class_part1.rb │ │ └── split_target_class_part2.rb ├── test_check_all.rb ├── test_checked.rb ├── test_checked_integration.rb ├── test_checked_once_integration.rb ├── test_class_level_ivars.rb ├── test_class_method_ivars.rb ├── test_helper.rb ├── test_inheritance.rb ├── test_ivar.rb ├── test_ivar_attr_methods.rb ├── test_ivar_macros.rb ├── test_ivar_with_initial_values.rb ├── test_ivar_with_kwarg_init.rb ├── test_ivar_with_kwarg_init_inheritance.rb ├── test_ivar_with_positional_init.rb ├── test_ivar_with_positional_init_inheritance.rb ├── test_ivar_with_positional_init_ordering.rb ├── test_policies.rb ├── test_project_root.rb ├── test_targeted_prism_analysis.rb ├── test_validation.rb └── test_warn_once_policy.rb └── test_file.rb /.augment-guidelines: -------------------------------------------------------------------------------- 1 | - tests are in minitest 2 | - use modern Ruby 3.4 style and features, including pattern-matching, endless methods, etc., where appropriate. 3 | - avoid over-engineering. Prefer basic Ruby data types to building new abstractions. (But extract abstractions when I tell you to.) 4 | - avoid inline comments. Prefer "explaining variables", intention-revealing method names, and the "composed method" pattern where appropriate. Class, module, and method-documenting comments are fine. 5 | - Code style is managed with standardrb. Run it (with script/de-lint) before committing. Manually fix the rest. script/lint can be used to check for issues without updating the code. 6 | - Run all tests and fix failures before committing. 7 | - Never "cheat" by making implementation code test-aware. But it is fine to make interactions with the world dependency-injectable for testing purposes. 8 | - Include a quote of my instruction that led to the commit in the commit message 9 | - Sign your commit messages with "-- Auggie". 10 | - Update the CHANGELOG.md unreleased section as appropriate 11 | - Commit and push after making successful changes. 12 | - In methods that get information and where mutation is not the goal, prefer a concise, functional-transformation style leveraging Enumerable methods over imperative approaches. 13 | -------------------------------------------------------------------------------- /.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_PATH: "vendor/bundle" 3 | BUNDLE_BIN: "vendor/bundle/bin" 4 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3 2 | 3 | ARG USERNAME=devcontainer 4 | ARG USER_UID=1000 5 | ARG USER_GID=$USER_UID 6 | 7 | # Install basic development tools 8 | RUN apt update && apt install -y less man-db sudo 9 | 10 | # Set up unprivileged local user 11 | RUN groupadd --gid $USER_GID $USERNAME \ 12 | && groupadd bundler \ 13 | && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME --shell /bin/bash --groups bundler \ 14 | && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \ 15 | && chmod 0440 /etc/sudoers.d/$USERNAME 16 | 17 | # Set unprivileged user as default user 18 | USER $USERNAME 19 | 20 | # Set `DEVCONTAINER` environment variable to help with orientation 21 | ENV DEVCONTAINER=true 22 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // See https://containers.dev/implementors/json_reference/ for configuration reference 2 | { 3 | "name": "ivar", 4 | "build": { 5 | "dockerfile": "Dockerfile" 6 | }, 7 | "remoteUser": "devcontainer", 8 | "mounts": [ 9 | "source=ivar-vendor-bundle,target=${containerWorkspaceFolder}/vendor/bundle,type=volume" 10 | ], 11 | "features": { 12 | "ghcr.io/devcontainers/features/github-cli:1": {}, 13 | "ghcr.io/jungaretti/features/ripgrep:1": {} 14 | }, 15 | "customizations": { 16 | "vscode": { 17 | "extensions": [ 18 | "connorshea.vscode-ruby-test-adapter", 19 | "stripe.endsmart", 20 | "testdouble.vscode-standard-ruby", 21 | "castwide.solargraph", 22 | "github.vscode-github-actions", 23 | "Shopify.ruby-lsp", 24 | "EditorConfig.EditorConfig" 25 | ], 26 | "settings": { 27 | "standardRuby.commandPath": "${containerWorkspaceFolder}/vendor/bundle/bin/standardrb", 28 | "solargraph.bundlerPath": "/usr/local/bin/bundle" 29 | } 30 | } 31 | }, 32 | "postCreateCommand": "bash .devcontainer/post-create.sh" 33 | } 34 | -------------------------------------------------------------------------------- /.devcontainer/post-create.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "Running post-create setup script..." 5 | 6 | # Ensure vendor/bundle directory exists and has correct permissions 7 | echo "Setting up vendor/bundle directory..." 8 | mkdir -p vendor/bundle 9 | sudo chown -R devcontainer:devcontainer vendor/bundle 10 | 11 | # Install Ruby dependencies 12 | echo "Installing Ruby dependencies with bundle install..." 13 | bundle install 14 | 15 | # Install Git hooks 16 | echo "Installing Git hooks..." 17 | if [ -f "hooks/install.sh" ]; then 18 | ./hooks/install.sh 19 | else 20 | echo "Hooks installation script not found. Skipping." 21 | fi 22 | 23 | echo "Post-create setup completed successfully!" 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | charset = utf-8 12 | 13 | # 2 space indentation for Ruby files 14 | [*.rb] 15 | indent_style = space 16 | indent_size = 2 17 | 18 | # 2 space indentation for YAML files 19 | [*.{yml,yaml}] 20 | indent_style = space 21 | indent_size = 2 22 | 23 | # 2 space indentation for JSON files 24 | [*.json] 25 | indent_style = space 26 | indent_size = 2 27 | 28 | # 2 space indentation for Gemfile and Rakefile 29 | [{Gemfile,Rakefile}] 30 | indent_style = space 31 | indent_size = 2 32 | 33 | # Markdown files 34 | [*.md] 35 | trim_trailing_whitespace = false 36 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically normalize line endings 2 | * text=auto eol=lf 3 | 4 | # Explicitly declare text files you want to always be normalized and converted 5 | # to native line endings on checkout. 6 | *.rb text eol=lf 7 | *.yml text eol=lf 8 | *.yaml text eol=lf 9 | *.json text eol=lf 10 | *.md text eol=lf 11 | *.txt text eol=lf 12 | *.gemspec text eol=lf 13 | Gemfile text eol=lf 14 | Rakefile text eol=lf 15 | 16 | # Declare files that will always have CRLF line endings on checkout. 17 | *.bat text eol=crlf 18 | 19 | # Denote all files that are truly binary and should not be modified. 20 | *.png binary 21 | *.jpg binary 22 | *.gif binary 23 | *.ico binary 24 | *.gz binary 25 | *.zip binary 26 | *.7z binary 27 | *.ttf binary 28 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '3.3.0' 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Set up Ruby 22 | uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby }} 25 | # Don't use bundler-cache: true to avoid frozen Gemfile.lock issues 26 | bundler-cache: false 27 | - name: Install dependencies 28 | run: bundle install --jobs 4 29 | - name: Run the default task 30 | run: bundle exec rake 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | name: Build + Publish 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Set up Ruby 17 | uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: '3.3.0' 20 | # Don't use bundler-cache: true to avoid frozen Gemfile.lock issues 21 | bundler-cache: false 22 | 23 | - name: Update Gemfile.lock 24 | run: | 25 | # Run bundle install without deployment mode to update Gemfile.lock 26 | bundle install --jobs 4 27 | # Commit the updated Gemfile.lock if it changed 28 | git config --local user.email "github-actions@github.com" 29 | git config --local user.name "GitHub Actions" 30 | git diff --exit-code Gemfile.lock || git commit -m "Update Gemfile.lock for release" Gemfile.lock 31 | 32 | - name: Run tests 33 | run: bundle exec rake test 34 | 35 | - name: Run linter 36 | run: bundle exec rake standard 37 | 38 | - name: Build gem 39 | run: bundle exec rake build 40 | 41 | - name: Publish to RubyGems 42 | env: 43 | RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} 44 | run: | 45 | mkdir -p ~/.gem 46 | echo -e "---\n:rubygems_api_key: ${RUBYGEMS_API_KEY}" > ~/.gem/credentials 47 | chmod 600 ~/.gem/credentials 48 | gem push pkg/ivar-*.gem 49 | rm -f ~/.gem/credentials 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/* 2 | !/.bundle/config 3 | /.yardoc 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /vendor/bundle/ 11 | *.gem 12 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | standard: config/base.yml 3 | 4 | AllCops: 5 | NewCops: enable 6 | SuggestExtensions: false 7 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | # For available configuration options, see: 2 | # https://github.com/testdouble/standard 3 | ruby_version: 3.3 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "connorshea.vscode-ruby-test-adapter", 4 | "stripe.endsmart", 5 | "testdouble.vscode-standard-ruby", 6 | "castwide.solargraph", 7 | "github.vscode-github-actions", 8 | "qwtel.sqlite-viewer" 9 | ] 10 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "ruby_lsp", 9 | "name": "Debug script", 10 | "request": "launch", 11 | "program": "ruby ${file}" 12 | }, 13 | { 14 | "type": "ruby_lsp", 15 | "name": "Debug test", 16 | "request": "launch", 17 | "program": "ruby -Itest ${relativeFile}" 18 | }, 19 | { 20 | "type": "ruby_lsp", 21 | "name": "Attach debugger", 22 | "request": "attach" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rubyTestExplorer.testFramework": "minitest", 3 | "[ruby]": { 4 | "editor.formatOnType": true, 5 | "editor.defaultFormatter": "Shopify.ruby-lsp", 6 | "editor.formatOnSave": true 7 | }, 8 | "editor.formatOnSave": true, 9 | "standardRuby.mode": "enableUnconditionally", 10 | "solargraph.diagnostics": true, 11 | "solargraph.formatting": true, 12 | "solargraph.useBundler": true, 13 | "rubyLsp.formatter": "standard", 14 | "standardRuby.autofix": true, 15 | "rubyLsp.enabledFeatures": { 16 | "diagnostics": true, 17 | "documentHighlights": true, 18 | "documentLink": true, 19 | "documentSymbols": true, 20 | "foldingRanges": true, 21 | "formatting": true, 22 | "hover": true, 23 | "inlayHint": true, 24 | "onTypeFormatting": true, 25 | "selectionRanges": true, 26 | "semanticHighlighting": true, 27 | "completion": true, 28 | "codeLens": true, 29 | "definition": true, 30 | "workspaceSymbol": true, 31 | }, 32 | "rubyLsp.rubyVersionManager": { 33 | "identifier": "auto" 34 | }, 35 | "rubyLsp.addonSettings": { 36 | "rubocop": { 37 | "command": "standardrb", 38 | "except": [] 39 | } 40 | }, 41 | "editor.detectIndentation": true, 42 | "editor.insertFinalNewline": true, 43 | "files.insertFinalNewline": true, 44 | "files.trimTrailingWhitespace": true, 45 | "editor.trimAutoWhitespace": true, 46 | "files.eol": "\n", 47 | "editor.codeActionsOnSave": { 48 | "source.fixAll": "explicit" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Changed 11 | - Updated policies to use Ruby's built-in `warn` method instead of `$stderr.write` 12 | - Reduced duplication in policy classes by moving common warning logic to the base class 13 | 14 | ## [0.4.7] - 2025-05-07 15 | 16 | ## [0.4.6] - 2025-05-07 17 | 18 | ## [0.4.5] - 2025-05-07 19 | 20 | ## [0.4.4] - 2025-05-07 21 | 22 | ### Changed 23 | - Enhanced release script to detect and handle uncommitted changes after the release process 24 | - Improved release script to update Gemfile.lock before committing version changes 25 | 26 | ## [0.4.2] - 2025-05-07 27 | 28 | ### Added 29 | - Enhanced release script to automatically push changes and tags to the remote repository 30 | 31 | ### Fixed 32 | - Fixed release script to include Gemfile.lock changes in version bump commit 33 | - Fixed GitHub Actions workflow to prevent "frozen Gemfile.lock" errors during gem publishing 34 | 35 | ## [0.4.0] - 2025-05-07 36 | 37 | ### Added 38 | - Support for initializing instance variables from keyword arguments using `ivar :@foo, init: :kwarg` or `ivar :@foo, init: :keyword` 39 | - Proper inheritance handling for keyword argument initialization, with child class declarations taking precedence over parent class declarations 40 | - Added Ivar::Manifest class to formalize tracking of instance variables 41 | - Added ExplicitDeclaration and ImplicitDeclaration classes to represent different types of variable declarations 42 | - Added callbacks for declarations: on_declare and before_init 43 | - Added CheckPolicy module to handle class-level check policy configuration 44 | - Added support for policy inheritance in subclasses 45 | - Added method stash abstraction with `stash_method`, `get_method_stash`, and `get_stashed_method` on the Ivar module 46 | - Added `get_or_create_manifest` method to make it clearer when a manifest may be created 47 | 48 | ### Changed 49 | - Split declaration classes into separate files for better organization: 50 | - `Declaration` → `lib/ivar/declaration.rb` 51 | - `ExplicitDeclaration` → `lib/ivar/explicit_declaration.rb` 52 | - `ExplicitKeywordDeclaration` → `lib/ivar/explicit_keyword_declaration.rb` 53 | - `ExplicitPositionalDeclaration` → `lib/ivar/explicit_positional_declaration.rb` 54 | - Centralized handling of internal variables (those starting with `@__ivar_`) to avoid explicit declarations 55 | - Improved filtering of internal variables during analysis phase rather than validation phase 56 | - Refactored internal variable tracking to use the Manifest system 57 | - Removed backwards compatibility variables (@__ivar_declared_ivars, @__ivar_initial_values, @__ivar_init_methods) 58 | - Improved manifest ancestry handling to walk the entire ancestor chain instead of just the direct parent 59 | - Enhanced declaration inheritance to properly handle overrides from modules and included mixins 60 | - Optimized manifest ancestry to avoid creating unnecessary manifests for classes/modules that don't declare anything 61 | - Simplified Manifest class to use a single declarations hash instead of separate explicit and implicit declarations 62 | - Improved Manifest API with clearer separation between declarations (array of values) and declarations_by_name (hash) 63 | - Simplified initialization process by combining keyword argument handling into the before_init callback 64 | - Refactored Checked module to use the CheckPolicy module for policy configuration 65 | - Changed default policy for Checked module from :warn_once to :warn 66 | - Enhanced initialization process in Checked module to properly handle manifest processing 67 | - Simplified external-process tests to directly check for warnings in stderr instead of using custom capture logic 68 | - Updated TargetedPrismAnalysis and Checked::InstanceMethods to use the new method stash abstraction 69 | - Extracted modules from auto_check.rb into their own files and removed auto_check.rb 70 | - Removed PrismAnalysis class as it has been superseded by TargetedPrismAnalysis 71 | 72 | ### Documentation 73 | - Improved documentation for the CheckPolicy module explaining its purpose and inheritance behavior 74 | - Enhanced documentation for the Checked module detailing its functionality and initialization process 75 | - Added comprehensive documentation for the Manifest#add_implicits method explaining its role in tracking instance variables 76 | - Added documentation for the new method stash abstraction methods 77 | 78 | ## [0.3.2] - 2025-05-05 79 | 80 | ## [0.3.1] - 2025-05-05 81 | 82 | ## [0.3.0] - 2025-05-05 83 | 84 | ### Added 85 | - Support for initializing multiple instance variables to the same value using `ivar :@foo, :@bar, value: 123` 86 | - Support for ivar declarations with a block that generates default values based on the variable name 87 | - Support for reader, writer, and accessor keyword arguments to automatically generate attr methods 88 | 89 | ### Changed 90 | - Extracted check_all functionality to its own class (CheckAllManager) for better organization 91 | - Converted module instance variables to constants where appropriate 92 | - Moved scripts from bin/ to script/ directory for better organization 93 | - Improved development environment with consistent line endings and editor configuration 94 | 95 | ### Fixed 96 | - Fixed missing trailing newlines in files 97 | - Fixed Gemfile.lock version synchronization 98 | - Fixed release script to use $stdin.gets instead of gets 99 | 100 | ## [0.2.1] - 2025-05-05 101 | 102 | ### Added 103 | - Release automation via GitHub Actions 104 | 105 | ## [0.2.0] - 2025-05-01 106 | 107 | ### Added 108 | - CheckDynamic module that overrides instance_variable_get/set to check against a list of allowed variables saved at the end of initialization 109 | 110 | ## [0.1.0] - 2025-04-29 111 | 112 | ### Added 113 | - Initial release 114 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or 22 | advances of any kind 23 | * Trolling, insulting or derogatory comments, and personal or political attacks 24 | * Public or private harassment 25 | * Publishing others' private information, such as a physical or email 26 | address, without their explicit permission 27 | * Other conduct which could reasonably be considered inappropriate in a 28 | professional setting 29 | 30 | ## Enforcement Responsibilities 31 | 32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 33 | 34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 35 | 36 | ## Scope 37 | 38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 39 | 40 | ## Enforcement 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at avdi@avdi.codes. All complaints will be reviewed and investigated promptly and fairly. 43 | 44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 45 | 46 | ## Enforcement Guidelines 47 | 48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 49 | 50 | ### 1. Correction 51 | 52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 53 | 54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 55 | 56 | ### 2. Warning 57 | 58 | **Community Impact**: A violation through a single incident or series of actions. 59 | 60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 67 | 68 | ### 4. Permanent Ban 69 | 70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 71 | 72 | **Consequence**: A permanent ban from any sort of public interaction within the community. 73 | 74 | ## Attribution 75 | 76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 78 | 79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see the FAQ at 84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 85 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in ivar.gemspec 6 | gemspec 7 | 8 | gem "rake", "~> 13.0" 9 | 10 | gem "minitest", "~> 5.0" 11 | 12 | gem "standard", "~> 1.3" 13 | 14 | gem "solargraph", "~> 0.54.2" 15 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | ivar (0.4.7) 5 | prism (~> 1.2) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | ast (2.4.3) 11 | backport (1.2.0) 12 | benchmark (0.4.0) 13 | diff-lcs (1.6.1) 14 | jaro_winkler (1.6.0) 15 | json (2.11.3) 16 | kramdown (2.5.1) 17 | rexml (>= 3.3.9) 18 | kramdown-parser-gfm (1.1.0) 19 | kramdown (~> 2.0) 20 | language_server-protocol (3.17.0.4) 21 | lint_roller (1.1.0) 22 | logger (1.7.0) 23 | minitest (5.25.5) 24 | nokogiri (1.18.8-x86_64-linux-gnu) 25 | racc (~> 1.4) 26 | observer (0.1.2) 27 | ostruct (0.6.1) 28 | parallel (1.27.0) 29 | parser (3.3.8.0) 30 | ast (~> 2.4.1) 31 | racc 32 | prism (1.4.0) 33 | racc (1.8.1) 34 | rainbow (3.1.1) 35 | rake (13.2.1) 36 | rbs (3.9.2) 37 | logger 38 | regexp_parser (2.10.0) 39 | reverse_markdown (3.0.0) 40 | nokogiri 41 | rexml (3.4.1) 42 | rubocop (1.75.4) 43 | json (~> 2.3) 44 | language_server-protocol (~> 3.17.0.2) 45 | lint_roller (~> 1.1.0) 46 | parallel (~> 1.10) 47 | parser (>= 3.3.0.2) 48 | rainbow (>= 2.2.2, < 4.0) 49 | regexp_parser (>= 2.9.3, < 3.0) 50 | rubocop-ast (>= 1.44.0, < 2.0) 51 | ruby-progressbar (~> 1.7) 52 | unicode-display_width (>= 2.4.0, < 4.0) 53 | rubocop-ast (1.44.1) 54 | parser (>= 3.3.7.2) 55 | prism (~> 1.4) 56 | rubocop-performance (1.25.0) 57 | lint_roller (~> 1.1) 58 | rubocop (>= 1.75.0, < 2.0) 59 | rubocop-ast (>= 1.38.0, < 2.0) 60 | ruby-progressbar (1.13.0) 61 | solargraph (0.54.2) 62 | backport (~> 1.2) 63 | benchmark (~> 0.4) 64 | bundler (~> 2.0) 65 | diff-lcs (~> 1.4) 66 | jaro_winkler (~> 1.6) 67 | kramdown (~> 2.3) 68 | kramdown-parser-gfm (~> 1.1) 69 | logger (~> 1.6) 70 | observer (~> 0.1) 71 | ostruct (~> 0.6) 72 | parser (~> 3.0) 73 | rbs (~> 3.3) 74 | reverse_markdown (~> 3.0) 75 | rubocop (~> 1.38) 76 | thor (~> 1.0) 77 | tilt (~> 2.0) 78 | yard (~> 0.9, >= 0.9.24) 79 | yard-solargraph (~> 0.1) 80 | standard (1.49.0) 81 | language_server-protocol (~> 3.17.0.2) 82 | lint_roller (~> 1.0) 83 | rubocop (~> 1.75.2) 84 | standard-custom (~> 1.0.0) 85 | standard-performance (~> 1.8) 86 | standard-custom (1.0.2) 87 | lint_roller (~> 1.0) 88 | rubocop (~> 1.50) 89 | standard-performance (1.8.0) 90 | lint_roller (~> 1.1) 91 | rubocop-performance (~> 1.25.0) 92 | thor (1.3.2) 93 | tilt (2.6.0) 94 | unicode-display_width (3.1.4) 95 | unicode-emoji (~> 4.0, >= 4.0.4) 96 | unicode-emoji (4.0.4) 97 | yard (0.9.37) 98 | yard-solargraph (0.1.0) 99 | yard (~> 0.9) 100 | 101 | PLATFORMS 102 | x86_64-linux 103 | 104 | DEPENDENCIES 105 | ivar! 106 | minitest (~> 5.0) 107 | rake (~> 13.0) 108 | solargraph (~> 0.54.2) 109 | standard (~> 1.3) 110 | 111 | BUNDLED WITH 112 | 2.6.7 113 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Avdi Grimm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ivar 2 | 3 | Go check out [the strict_ivars gem](https://github.com/joeldrapper/strict_ivars). This repo is kept around as a technical curiosity. 4 | 5 | (The [old README is here](https://github.com/avdi/ivar/blob/main/README.md)) 6 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | 6 | Rake::TestTask.new(:test) do |t| 7 | t.libs << "test" 8 | t.libs << "lib" 9 | t.test_files = FileList["test/**/test_*.rb"].exclude("test/fixtures/**/*") 10 | end 11 | 12 | require "standard/rake" 13 | 14 | task default: %i[test standard] 15 | -------------------------------------------------------------------------------- /VERSION.md: -------------------------------------------------------------------------------- 1 | # Versioning and Release Process 2 | 3 | This project follows [Semantic Versioning](https://semver.org/) (SemVer). 4 | 5 | ## Version Numbers 6 | 7 | Version numbers are in the format `MAJOR.MINOR.PATCH`: 8 | 9 | - **MAJOR**: Incremented for incompatible API changes 10 | - **MINOR**: Incremented for new functionality in a backward-compatible manner 11 | - **PATCH**: Incremented for backward-compatible bug fixes 12 | 13 | ## Release Process 14 | 15 | To release a new version: 16 | 17 | 1. Make sure all changes are documented in the `CHANGELOG.md` file under the "Unreleased" section 18 | 2. Run the release script with the appropriate version bump type: 19 | ``` 20 | script/release [major|minor|patch] [options] 21 | ``` 22 | 23 | Available options: 24 | - `--yes` or `-y`: Skip confirmation prompt 25 | - `--no-push`: Skip pushing changes to remote repository 26 | 27 | 3. The script will: 28 | - Run tests and linter to ensure everything is working 29 | - Update the version number in `lib/ivar/version.rb` 30 | - Update the `CHANGELOG.md` file with the new version and date 31 | - Commit these changes 32 | - Create a git tag for the new version 33 | - Push the changes and tag to GitHub (unless `--no-push` is specified) 34 | 4. The GitHub Actions workflow will automatically: 35 | - Build the gem 36 | - Run tests 37 | - Publish the gem to RubyGems.org 38 | 39 | ## Setting Up RubyGems API Key 40 | 41 | To allow GitHub Actions to publish to RubyGems.org, you need to add your RubyGems API key as a secret: 42 | 43 | 1. Get your API key from RubyGems.org (account settings) 44 | 2. Go to your GitHub repository settings 45 | 3. Navigate to "Secrets and variables" → "Actions" 46 | 4. Add a new repository secret named `RUBYGEMS_API_KEY` with your RubyGems API key as the value 47 | -------------------------------------------------------------------------------- /examples/check_all_block_example.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | require "ivar" 5 | 6 | # Define a class before enabling check_all 7 | class BeforeClass 8 | def initialize 9 | @name = "before" 10 | end 11 | 12 | def to_s 13 | "Name: #{@naem}" # Intentional typo to demonstrate lack of checking 14 | end 15 | end 16 | 17 | # Define classes that will be used within the block 18 | class WithinBlockClass 19 | def initialize 20 | @name = "within block" 21 | end 22 | 23 | def to_s 24 | "Name: #{@naem}" # Intentional typo to demonstrate lack of checking yet 25 | end 26 | end 27 | 28 | module WithinBlockModule 29 | class NestedClass 30 | def initialize 31 | @name = "nested" 32 | end 33 | 34 | def to_s 35 | "Name: #{@naem}" # Intentional typo to demonstrate lack of checking yet 36 | end 37 | end 38 | end 39 | 40 | # Only classes loaded within this block will have Ivar::Checked included 41 | Ivar.check_all do 42 | # Load the classes by referencing them 43 | puts "Loading WithinBlockClass: #{WithinBlockClass}" 44 | puts "Loading WithinBlockModule::NestedClass: #{WithinBlockModule::NestedClass}" 45 | 46 | # We could also define anonymous classes here 47 | @anonymous_class = Class.new do 48 | def initialize 49 | @name = "anonymous" 50 | end 51 | 52 | def to_s 53 | "Name: #{@naem}" # Intentional typo to demonstrate checking 54 | end 55 | end 56 | end 57 | 58 | # Define a class after the check_all block 59 | class AfterClass 60 | def initialize 61 | @name = "after" 62 | end 63 | 64 | def to_s 65 | "Name: #{@naem}" # Intentional typo to demonstrate lack of checking 66 | end 67 | end 68 | 69 | # Create instances of each class 70 | puts "Creating BeforeClass instance:" 71 | before = BeforeClass.new 72 | puts before 73 | 74 | puts "\nCreating WithinBlockClass instance:" 75 | within = WithinBlockClass.new 76 | puts within 77 | 78 | puts "\nCreating WithinBlockModule::NestedClass instance:" 79 | nested = WithinBlockModule::NestedClass.new 80 | puts nested 81 | 82 | puts "\nCreating AfterClass instance:" 83 | after = AfterClass.new 84 | puts after 85 | -------------------------------------------------------------------------------- /examples/check_all_example.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | require "ivar" 5 | 6 | # Enable checking for all classes and modules defined in the project 7 | Ivar.check_all 8 | 9 | # Now any class or module defined in the project will have Ivar::Checked included 10 | class Sandwich 11 | # No need to include Ivar::Checked manually 12 | 13 | def initialize 14 | @bread = "wheat" 15 | @cheese = "muenster" 16 | @condiments = ["mayo", "mustard"] 17 | end 18 | 19 | def to_s 20 | "A #{@bread} sandwich with #{@chese} and #{@condiments.join(", ")}" # Intentional typo in @cheese 21 | end 22 | end 23 | 24 | # Create a sandwich - this will automatically check instance variables 25 | sandwich = Sandwich.new 26 | puts sandwich 27 | 28 | # Define another class to demonstrate that it also gets Ivar::Checked 29 | class Drink 30 | def initialize 31 | @type = "soda" 32 | @size = "medium" 33 | end 34 | 35 | def to_s 36 | "A #{@sise} #{@type}" # Intentional typo in @size 37 | end 38 | end 39 | 40 | # Create a drink - this will also automatically check instance variables 41 | drink = Drink.new 42 | puts drink 43 | -------------------------------------------------------------------------------- /examples/inheritance_with_kwarg_init.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ivar" 4 | 5 | # Base class for all food items 6 | class Food 7 | include Ivar::Checked 8 | 9 | # Declare common properties with keyword initialization and defaults 10 | ivar :@name, init: :kwarg, value: "Unknown Food" 11 | ivar :@calories, init: :kwarg, value: 0 12 | ivar :@vegetarian, init: :kwarg, value: false 13 | ivar :@vegan, init: :kwarg, value: false 14 | 15 | # Declare extra_info 16 | ivar :@extra_info 17 | 18 | def initialize(extra_info: nil) 19 | # The declared variables are already initialized from keyword arguments or defaults 20 | @extra_info = :extra_info 21 | end 22 | 23 | def to_s 24 | result = "#{@name} (#{@calories} calories)" 25 | result += ", vegetarian" if @vegetarian 26 | result += ", vegan" if @vegan 27 | result += ", #{@extra_info}" if @extra_info 28 | result 29 | end 30 | end 31 | 32 | # Sandwich class that inherits from Food 33 | class Sandwich < Food 34 | # Override name with a more specific default 35 | ivar :@name, value: "Sandwich" 36 | 37 | # Declare sandwich-specific properties 38 | ivar :@bread, init: :kwarg, value: "white" 39 | ivar :@fillings, init: :kwarg, value: [] 40 | 41 | # Override vegetarian with a different default 42 | ivar :@vegetarian, value: true 43 | 44 | def initialize(condiments: [], **kwargs) 45 | # Pass any remaining kwargs to parent 46 | super(**kwargs) 47 | 48 | # Initialize fillings if needed 49 | @fillings ||= [] 50 | 51 | # Add condiments to fillings 52 | @fillings += condiments 53 | end 54 | 55 | def to_s 56 | result = super 57 | result += " on #{@bread} bread" 58 | result += " with #{@fillings.join(", ")}" unless @fillings.empty? 59 | result 60 | end 61 | end 62 | 63 | # VeganSandwich class that inherits from Sandwich 64 | class VeganSandwich < Sandwich 65 | # Override defaults for vegan properties 66 | ivar :@name, value: "Vegan Sandwich" 67 | ivar :@vegan, value: true 68 | 69 | # Override bread default 70 | ivar :@bread, value: "whole grain" 71 | 72 | # Add vegan-specific properties 73 | ivar :@plant_protein, init: :kwarg, value: "tofu" 74 | 75 | def initialize(**kwargs) 76 | super 77 | 78 | # Initialize fillings if needed 79 | @fillings ||= [] 80 | 81 | # Ensure no non-vegan fillings 82 | @fillings.reject! { |filling| non_vegan_fillings.include?(filling) } 83 | 84 | # Add plant protein if fillings are empty 85 | @fillings << @plant_protein if @fillings.empty? 86 | end 87 | 88 | def non_vegan_fillings 89 | ["cheese", "mayo", "ham", "turkey", "roast beef", "tuna"] 90 | end 91 | 92 | def to_s 93 | result = super 94 | result += " (#{@plant_protein} based)" if @fillings.include?(@plant_protein) 95 | result 96 | end 97 | end 98 | 99 | # Create a basic food item with defaults 100 | puts "Basic food with defaults:" 101 | food = Food.new 102 | puts food 103 | # => "Unknown Food (0 calories)" 104 | 105 | # Create a food item with custom properties 106 | puts "\nCustom food:" 107 | custom_food = Food.new(name: "Apple", calories: 95, vegetarian: true, vegan: true) 108 | puts custom_food 109 | # => "Apple (95 calories), vegetarian, vegan" 110 | 111 | # Create a sandwich with defaults 112 | puts "\nDefault sandwich:" 113 | sandwich = Sandwich.new 114 | puts sandwich 115 | # => "Sandwich (0 calories), vegetarian on white bread" 116 | 117 | # Create a sandwich with custom properties 118 | puts "\nCustom sandwich:" 119 | custom_sandwich = Sandwich.new( 120 | name: "Club Sandwich", 121 | calories: 450, 122 | bread: "sourdough", 123 | fillings: ["turkey", "bacon", "lettuce", "tomato"], 124 | condiments: ["mayo", "mustard"], 125 | extra_info: "triple-decker" 126 | ) 127 | puts custom_sandwich 128 | # => "Club Sandwich (450 calories), vegetarian on sourdough bread with turkey, bacon, lettuce, tomato, mayo, mustard, triple-decker" 129 | 130 | # Create a vegan sandwich with defaults 131 | puts "\nDefault vegan sandwich:" 132 | vegan_sandwich = VeganSandwich.new 133 | puts vegan_sandwich 134 | # => "Vegan Sandwich (0 calories), vegetarian, vegan on whole grain bread with tofu (tofu based)" 135 | 136 | # Create a vegan sandwich with custom properties 137 | puts "\nCustom vegan sandwich:" 138 | custom_vegan = VeganSandwich.new( 139 | name: "Mediterranean Vegan", 140 | calories: 380, 141 | bread: "pita", 142 | fillings: ["hummus", "falafel", "lettuce", "tomato", "cucumber"], 143 | plant_protein: "chickpeas", 144 | extra_info: "with tahini sauce" 145 | ) 146 | puts custom_vegan 147 | # => "Mediterranean Vegan (380 calories), vegetarian, vegan on pita bread with hummus, falafel, lettuce, tomato, cucumber, with tahini sauce" 148 | 149 | # Try to create a vegan sandwich with non-vegan fillings 150 | puts "\nVegan sandwich with non-vegan fillings (will be removed):" 151 | non_vegan_fillings = VeganSandwich.new( 152 | fillings: ["cheese", "ham", "lettuce", "tomato"], 153 | plant_protein: "seitan" 154 | ) 155 | puts non_vegan_fillings 156 | # => "Vegan Sandwich (0 calories), vegetarian, vegan on whole grain bread with lettuce, tomato, seitan (seitan based)" 157 | -------------------------------------------------------------------------------- /examples/inheritance_with_positional_init.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ivar" 4 | 5 | # Base class for all food items 6 | class Food 7 | include Ivar::Checked 8 | 9 | # Declare common properties with positional initialization and defaults 10 | ivar :@name, init: :positional, value: "Unknown Food" 11 | ivar :@calories, init: :positional, value: 0 12 | ivar :@vegetarian, init: :positional, value: false 13 | ivar :@vegan, init: :positional, value: false 14 | 15 | # Declare extra_info 16 | ivar :@extra_info 17 | 18 | def initialize(extra_info = nil) 19 | # The declared variables are already initialized from positional arguments or defaults 20 | @extra_info = extra_info 21 | end 22 | 23 | def to_s 24 | result = "#{@name} (#{@calories} calories)" 25 | result += ", vegetarian" if @vegetarian 26 | result += ", vegan" if @vegan 27 | result += ", #{@extra_info}" if @extra_info 28 | result 29 | end 30 | end 31 | 32 | # Sandwich class that inherits from Food 33 | class Sandwich < Food 34 | # Override name with a more specific default 35 | ivar :@name, value: "Sandwich" 36 | 37 | # Declare sandwich-specific properties 38 | ivar :@bread, init: :positional, value: "white" 39 | ivar :@fillings, init: :positional, value: [] 40 | 41 | # Override vegetarian with a different default 42 | ivar :@vegetarian, value: true 43 | 44 | def initialize(condiments = [], *args) 45 | # Pass any remaining args to parent 46 | super(*args) 47 | 48 | # Initialize fillings if needed 49 | @fillings ||= [] 50 | 51 | # Add condiments to fillings 52 | @fillings += condiments 53 | end 54 | 55 | def to_s 56 | result = super 57 | result += " on #{@bread} bread" 58 | result += " with #{@fillings.join(", ")}" unless @fillings.empty? 59 | result 60 | end 61 | end 62 | 63 | # VeganSandwich class that inherits from Sandwich 64 | class VeganSandwich < Sandwich 65 | # Override defaults for vegan properties 66 | ivar :@name, value: "Vegan Sandwich" 67 | ivar :@vegan, value: true 68 | 69 | # Override bread default 70 | ivar :@bread, value: "whole grain" 71 | 72 | # Add vegan-specific properties 73 | ivar :@plant_protein, init: :positional, value: "tofu" 74 | 75 | def initialize(*args) 76 | super 77 | 78 | # Initialize fillings if needed 79 | @fillings ||= [] 80 | 81 | # Ensure no non-vegan fillings 82 | @fillings.reject! { |filling| non_vegan_fillings.include?(filling) } 83 | 84 | # Add plant protein if fillings are empty 85 | @fillings << @plant_protein if @fillings.empty? 86 | end 87 | 88 | def non_vegan_fillings 89 | ["cheese", "mayo", "ham", "turkey", "roast beef", "tuna"] 90 | end 91 | end 92 | 93 | # Create a basic food item with positional arguments 94 | # (name, calories, vegetarian, vegan) 95 | apple = Food.new("Apple", 95, true, true, "Fresh and crisp") 96 | puts "Food: #{apple}" 97 | # => "Food: Apple (95 calories), vegetarian, vegan, Fresh and crisp" 98 | 99 | # Create a sandwich with positional arguments 100 | # (bread, fillings, name, calories, vegetarian, vegan, extra_info) 101 | # Note: condiments is a separate parameter not part of the ivar declarations 102 | turkey_sandwich = Sandwich.new( 103 | ["mustard", "mayo"], # condiments 104 | "wheat", # bread 105 | ["turkey", "lettuce", "tomato"], # fillings 106 | "Turkey Sandwich", # name 107 | 450, # calories 108 | false, # vegetarian 109 | false, # vegan 110 | "Classic lunch option" # extra_info 111 | ) 112 | puts "Sandwich: #{turkey_sandwich}" 113 | # => "Sandwich: Turkey Sandwich (450 calories), Classic lunch option on wheat bread with turkey, lettuce, tomato, mustard, mayo" 114 | 115 | # Create a vegan sandwich with positional arguments 116 | # (plant_protein, bread, fillings, name, calories, vegetarian, vegan, extra_info) 117 | vegan_sandwich = VeganSandwich.new( 118 | ["hummus", "mustard"], # condiments 119 | "tempeh", # plant_protein 120 | "rye", # bread 121 | ["lettuce", "tomato", "avocado"], # fillings 122 | "Tempeh Sandwich", # name 123 | 350, # calories 124 | true, # vegetarian 125 | true, # vegan 126 | "High protein option" # extra_info 127 | ) 128 | puts "Vegan Sandwich: #{vegan_sandwich}" 129 | # => "Vegan Sandwich: Tempeh Sandwich (350 calories), vegetarian, vegan, High protein option on rye bread with lettuce, tomato, avocado, hummus, mustard" 130 | 131 | # Create items with default values 132 | default_food = Food.new 133 | default_sandwich = Sandwich.new 134 | default_vegan = VeganSandwich.new 135 | 136 | puts "\nDefaults:" 137 | puts "Default Food: #{default_food}" 138 | # => "Default Food: Unknown Food (0 calories)" 139 | puts "Default Sandwich: #{default_sandwich}" 140 | # => "Default Sandwich: Sandwich (0 calories), vegetarian on white bread" 141 | puts "Default Vegan Sandwich: #{default_vegan}" 142 | # => "Default Vegan Sandwich: Vegan Sandwich (0 calories), vegetarian, vegan on whole grain bread with tofu" 143 | -------------------------------------------------------------------------------- /examples/mixed_positional_and_kwarg_init.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ivar" 4 | 5 | class Recipe 6 | include Ivar::Checked 7 | 8 | # Declare instance variables with positional initialization 9 | ivar :@name, init: :positional, value: "Unnamed Recipe" 10 | ivar :@servings, init: :positional, value: 4 11 | 12 | # Declare instance variables with keyword initialization 13 | ivar :@prep_time, init: :kwarg, value: 15 14 | ivar :@cook_time, init: :kwarg, value: 30 15 | ivar :@difficulty, init: :kwarg, value: "medium" 16 | 17 | # Regular instance variables 18 | ivar :@ingredients, value: [] 19 | ivar :@instructions, value: [] 20 | 21 | def initialize(ingredients = [], instructions = []) 22 | # At this point, @name and @servings are set from positional args 23 | # @prep_time, @cook_time, and @difficulty are set from keyword args 24 | @ingredients = ingredients unless ingredients.empty? 25 | @instructions = instructions unless instructions.empty? 26 | end 27 | 28 | def total_time 29 | @prep_time + @cook_time 30 | end 31 | 32 | def to_s 33 | result = "#{@name} (Serves: #{@servings})\n" 34 | result += "Prep: #{@prep_time} min, Cook: #{@cook_time} min, Difficulty: #{@difficulty}\n" 35 | result += "\nIngredients:\n" 36 | @ingredients.each { |ingredient| result += "- #{ingredient}\n" } 37 | result += "\nInstructions:\n" 38 | @instructions.each_with_index { |instruction, i| result += "#{i + 1}. #{instruction}\n" } 39 | result 40 | end 41 | end 42 | 43 | class DessertRecipe < Recipe 44 | # Additional positional parameters 45 | ivar :@dessert_type, init: :positional, value: "cake" 46 | # Additional keyword parameters 47 | ivar :@sweetness, init: :kwarg, value: "medium" 48 | ivar :@calories_per_serving, init: :kwarg, value: 300 49 | 50 | def initialize(special_equipment = [], *args, **kwargs) 51 | @special_equipment = special_equipment 52 | super(*args, **kwargs) 53 | end 54 | 55 | def to_s 56 | result = super 57 | result += "\nDessert Type: #{@dessert_type}\n" 58 | result += "Sweetness: #{@sweetness}, Calories: #{@calories_per_serving} per serving\n" 59 | result += "\nSpecial Equipment:\n" 60 | @special_equipment.each { |equipment| result += "- #{equipment}\n" } unless @special_equipment.empty? 61 | result 62 | end 63 | end 64 | 65 | # Create a basic recipe with positional and keyword arguments 66 | pasta_recipe = Recipe.new( 67 | "Spaghetti Carbonara", # name (positional) 68 | 2, # servings (positional) 69 | [ # ingredients (regular parameter) 70 | "200g spaghetti", 71 | "100g pancetta", 72 | "2 large eggs", 73 | "50g pecorino cheese", 74 | "50g parmesan", 75 | "Freshly ground black pepper" 76 | ], 77 | [ # instructions (regular parameter) 78 | "Cook the spaghetti in salted water.", 79 | "Fry the pancetta until crispy.", 80 | "Whisk the eggs and cheese together.", 81 | "Drain pasta, mix with pancetta, then quickly mix in egg mixture.", 82 | "Season with black pepper and serve immediately." 83 | ], 84 | prep_time: 10, # prep_time (keyword) 85 | cook_time: 15, # cook_time (keyword) 86 | difficulty: "easy" # difficulty (keyword) 87 | ) 88 | 89 | puts "Basic Recipe:\n#{pasta_recipe}\n\n" 90 | 91 | # Create a dessert recipe with positional and keyword arguments 92 | chocolate_cake = DessertRecipe.new( 93 | ["Stand mixer", "9-inch cake pans", "Cooling rack"], # special_equipment (regular parameter) 94 | "chocolate", # dessert_type (positional) 95 | "Chocolate Layer Cake", # name (positional) 96 | 12, # servings (positional) 97 | [ # ingredients (regular parameter) 98 | "2 cups all-purpose flour", 99 | "2 cups sugar", 100 | "3/4 cup unsweetened cocoa powder", 101 | "2 tsp baking soda", 102 | "1 tsp salt", 103 | "2 large eggs", 104 | "1 cup buttermilk", 105 | "1/2 cup vegetable oil", 106 | "2 tsp vanilla extract", 107 | "1 cup hot coffee" 108 | ], 109 | [ # instructions (regular parameter) 110 | "Preheat oven to 350°F (175°C).", 111 | "Mix dry ingredients in a large bowl.", 112 | "Add eggs, buttermilk, oil, and vanilla; beat for 2 minutes.", 113 | "Stir in hot coffee (batter will be thin).", 114 | "Pour into greased and floured cake pans.", 115 | "Bake for 30-35 minutes.", 116 | "Cool completely before frosting." 117 | ], 118 | prep_time: 25, # prep_time (keyword) 119 | cook_time: 35, # cook_time (keyword) 120 | difficulty: "medium", # difficulty (keyword) 121 | sweetness: "high", # sweetness (keyword) 122 | calories_per_serving: 450 # calories_per_serving (keyword) 123 | ) 124 | 125 | puts "Dessert Recipe:\n#{chocolate_cake}" 126 | -------------------------------------------------------------------------------- /examples/require_check_all_example.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add lib directory to load path 4 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 5 | 6 | # This automatically enables Ivar.check_all 7 | require "ivar/check_all" 8 | 9 | # Now all classes and modules defined in the project will have Ivar::Checked included 10 | class Sandwich 11 | def initialize 12 | @bread = "wheat" 13 | @cheese = "muenster" 14 | end 15 | 16 | def to_s 17 | "A #{@bread} sandwich with #{@chese}" # Intentional typo in @cheese 18 | end 19 | end 20 | 21 | # Create a sandwich - this will automatically check instance variables 22 | sandwich = Sandwich.new 23 | puts sandwich 24 | -------------------------------------------------------------------------------- /examples/sandwich_inheritance.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ivar" 4 | 5 | class BaseSandwich 6 | include Ivar::Checked 7 | 8 | def initialize 9 | @bread = "wheat" 10 | @cheese = "muenster" 11 | end 12 | 13 | def base_to_s 14 | "A #{@bread} sandwich with #{@cheese}" 15 | end 16 | end 17 | 18 | class SpecialtySandwich < BaseSandwich 19 | def initialize 20 | super 21 | @condiments = ["mayo", "mustard"] 22 | @special_sauce = "secret sauce" 23 | end 24 | 25 | def to_s 26 | result = "#{base_to_s} with #{@condiments.join(", ")}" 27 | result += " and #{@special_sause}" # Intentional typo in @special_sauce 28 | result 29 | end 30 | end 31 | 32 | # Create a specialty sandwich - this will automatically check instance variables 33 | SpecialtySandwich.new 34 | -------------------------------------------------------------------------------- /examples/sandwich_with_accessors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ivar" 4 | 5 | class SandwichWithAccessors 6 | include Ivar::Checked 7 | 8 | # Declare instance variables with accessors 9 | ivar :@bread, :@cheese, accessor: true, value: "default" 10 | 11 | # Declare condiments with a reader 12 | ivar :@condiments, reader: true, value: ["mayo", "mustard"] 13 | 14 | # Declare pickles with a writer 15 | ivar :@pickles, writer: true, value: true 16 | 17 | # Declare a variable without any accessors 18 | ivar :@side 19 | 20 | def initialize(options = {}) 21 | # Override defaults if options provided 22 | @bread = options[:bread] if options[:bread] 23 | @cheese = options[:cheese] if options[:cheese] 24 | 25 | # Add extra condiments if provided 26 | @condiments += options[:extra_condiments] if options[:extra_condiments] 27 | 28 | # Set pickles based on options 29 | @pickles = options[:pickles] if options.key?(:pickles) 30 | 31 | # Set side if provided 32 | @side = options[:side] if options[:side] 33 | end 34 | 35 | def to_s 36 | result = "A #{@bread} sandwich with #{@cheese}" 37 | result += " and #{@condiments.join(", ")}" unless @condiments.empty? 38 | result += " with pickles" if @pickles 39 | result += " and a side of #{@side}" if defined?(@side) && @side 40 | result 41 | end 42 | 43 | # Custom reader for side since we didn't create an accessor 44 | attr_reader :side 45 | 46 | # Custom method to toggle pickles 47 | def toggle_pickles 48 | @pickles = !@pickles 49 | end 50 | end 51 | 52 | # Create a sandwich with default values 53 | sandwich = SandwichWithAccessors.new 54 | puts "Default sandwich: #{sandwich}" 55 | puts "Bread: #{sandwich.bread}" 56 | puts "Cheese: #{sandwich.cheese}" 57 | puts "Condiments: #{sandwich.condiments.join(", ")}" 58 | puts "Side: #{sandwich.side.inspect}" 59 | 60 | # Modify the sandwich using accessors 61 | sandwich.bread = "rye" 62 | sandwich.cheese = "swiss" 63 | sandwich.pickles = false 64 | puts "\nModified sandwich: #{sandwich}" 65 | 66 | # Create a sandwich with custom options 67 | custom = SandwichWithAccessors.new( 68 | bread: "sourdough", 69 | cheese: "provolone", 70 | extra_condiments: ["pesto"], 71 | pickles: false, 72 | side: "chips" 73 | ) 74 | puts "\nCustom sandwich: #{custom}" 75 | 76 | # Toggle pickles and show the result 77 | custom.toggle_pickles 78 | puts "After toggling pickles: #{custom}" 79 | -------------------------------------------------------------------------------- /examples/sandwich_with_block_values.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ivar" 4 | 5 | class SandwichWithBlockValues 6 | include Ivar::Checked 7 | 8 | # Declare condiments with a block that generates default values based on the variable name 9 | ivar(:@mayo, :@mustard, :@ketchup) { |varname| !varname.include?("mayo") } 10 | 11 | # Declare bread and cheese with individual values 12 | ivar "@bread": "wheat", "@cheese": "cheddar" 13 | 14 | # Declare a variable without an initial value 15 | ivar :@side 16 | 17 | def initialize(options = {}) 18 | # Override any condiments based on options 19 | @mayo = true if options[:add_mayo] 20 | @mustard = false if options[:no_mustard] 21 | @ketchup = false if options[:no_ketchup] 22 | 23 | # Set the side if provided 24 | @side = options[:side] if options[:side] 25 | end 26 | 27 | def to_s 28 | result = "A #{@bread} sandwich with #{@cheese}" 29 | 30 | condiments = [] 31 | condiments << "mayo" if @mayo 32 | condiments << "mustard" if @mustard 33 | condiments << "ketchup" if @ketchup 34 | 35 | result += " with #{condiments.join(", ")}" unless condiments.empty? 36 | result += " and a side of #{@side}" if defined?(@side) && @side 37 | result 38 | end 39 | end 40 | 41 | # Create a sandwich with default values (no mayo, but has mustard and ketchup) 42 | sandwich = SandwichWithBlockValues.new 43 | puts sandwich 44 | # => "A wheat sandwich with cheddar with mustard, ketchup" 45 | 46 | # Create a sandwich with mayo added 47 | sandwich_with_mayo = SandwichWithBlockValues.new(add_mayo: true) 48 | puts sandwich_with_mayo 49 | # => "A wheat sandwich with cheddar with mayo, mustard, ketchup" 50 | 51 | # Create a sandwich with a side 52 | sandwich_with_side = SandwichWithBlockValues.new(side: "chips") 53 | puts sandwich_with_side 54 | # => "A wheat sandwich with cheddar with mustard, ketchup and a side of chips" 55 | -------------------------------------------------------------------------------- /examples/sandwich_with_checked.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ivar" 4 | 5 | class SandwichWithChecked 6 | include Ivar::Checked 7 | 8 | def initialize 9 | @bread = "wheat" 10 | @cheese = "muenster" 11 | @condiments = ["mayo", "mustard"] 12 | end 13 | 14 | def to_s 15 | result = "A #{@bread} sandwich with #{@chese} and #{@condiments.join(", ")}" 16 | result += " and a side of #{@side}" if @side 17 | result 18 | end 19 | end 20 | 21 | # Create a sandwich - this will automatically check instance variables 22 | SandwichWithChecked.new 23 | -------------------------------------------------------------------------------- /examples/sandwich_with_checked_once.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ivar" 4 | 5 | class SandwichWithCheckedOnce 6 | include Ivar::Checked 7 | ivar_check_policy :warn_once 8 | 9 | def initialize 10 | @bread = "wheat" 11 | @cheese = "muenster" 12 | @condiments = %w[mayo mustard] 13 | end 14 | 15 | def to_s 16 | result = "A #{@bread} sandwich with #{@chese} and #{@condiments.join(", ")}" 17 | result += " and a side of #{@side}" if @side 18 | result 19 | end 20 | end 21 | 22 | # Create a sandwich - this will automatically check instance variables once 23 | puts "Creating first sandwich..." 24 | SandwichWithCheckedOnce.new 25 | 26 | # Create another sandwich - this should not emit warnings 27 | puts "Creating second sandwich..." 28 | SandwichWithCheckedOnce.new 29 | -------------------------------------------------------------------------------- /examples/sandwich_with_initial_values.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ivar" 4 | 5 | class SandwichWithInitialValues 6 | include Ivar::Checked 7 | 8 | # Declare instance variables with initial values 9 | ivar "@bread": "wheat", 10 | "@cheese": "muenster", 11 | "@condiments": ["mayo", "mustard"], 12 | "@pickles": true 13 | 14 | # Declare a variable without an initial value 15 | ivar :@side 16 | 17 | def initialize(extra_condiments = []) 18 | # The declared variables are already initialized with their values 19 | # We can modify them here 20 | @condiments += extra_condiments unless extra_condiments.empty? 21 | 22 | # We can also check if pickles were requested and adjust condiments 23 | @condiments.delete("mayo") if @pickles 24 | end 25 | 26 | def to_s 27 | result = "A #{@bread} sandwich with #{@cheese}" 28 | result += " and #{@condiments.join(", ")}" unless @condiments.empty? 29 | result += " with pickles" if @pickles 30 | result += " and a side of #{@side}" if defined?(@side) && @side 31 | result 32 | end 33 | 34 | def add_side(side) 35 | @side = side 36 | end 37 | end 38 | 39 | # Create a sandwich with default values 40 | sandwich = SandwichWithInitialValues.new 41 | puts sandwich 42 | # => "A wheat sandwich with muenster and mustard with pickles" 43 | 44 | # Create a sandwich with extra condiments 45 | sandwich_with_extras = SandwichWithInitialValues.new(["ketchup", "relish"]) 46 | puts sandwich_with_extras 47 | # => "A wheat sandwich with muenster and mustard, ketchup, relish with pickles" 48 | 49 | # Add a side 50 | sandwich.add_side("chips") 51 | puts sandwich 52 | # => "A wheat sandwich with muenster and mustard with pickles and a side of chips" 53 | -------------------------------------------------------------------------------- /examples/sandwich_with_ivar_block.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ivar" 4 | 5 | class SandwichWithIvarBlock 6 | include Ivar::Checked 7 | 8 | # Declare instance variables 9 | ivar :@side 10 | 11 | def initialize 12 | @bread = "wheat" 13 | @cheese = "muenster" 14 | @pickles = true 15 | @condiments = [] 16 | @condiments << "mayo" if !@pickles 17 | @condiments << "mustard" 18 | # @side is declared but intentionally not initialized here 19 | end 20 | 21 | def to_s 22 | result = "A #{@bread} sandwich with #{@cheese}" 23 | result += " and #{@condiments.join(", ")}" unless @condiments.empty? 24 | result += " with pickles" if @pickles 25 | result += " and a side of #{@side}" if defined?(@side) && @side 26 | result 27 | end 28 | 29 | def add_side(side) 30 | @side = side 31 | end 32 | end 33 | 34 | # Create a sandwich - this will automatically check instance variables 35 | sandwich = SandwichWithIvarBlock.new 36 | puts sandwich 37 | 38 | # Add a side 39 | sandwich.add_side("chips") 40 | puts sandwich 41 | -------------------------------------------------------------------------------- /examples/sandwich_with_ivar_macro.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ivar" 4 | 5 | class SandwichWithIvarMacro 6 | include Ivar::Checked 7 | 8 | # Declare instance variables that might be referenced before being set 9 | # You don't need to include variables that are always set in initialize 10 | ivar :@side 11 | 12 | def initialize 13 | @bread = "wheat" 14 | @cheese = "muenster" 15 | @condiments = %w[mayo mustard] 16 | # @side is declared but intentionally not initialized here 17 | end 18 | 19 | def to_s 20 | result = "A #{@bread} sandwich with #{@cheese} and #{@condiments.join(", ")}" 21 | # Using defined? to safely check for optional @side 22 | result += " and a side of #{@side}" if defined?(@side) && @side 23 | result 24 | end 25 | 26 | def add_side(side) 27 | @side = side 28 | end 29 | end 30 | 31 | # Create a sandwich - this will automatically check instance variables 32 | sandwich = SandwichWithIvarMacro.new 33 | puts sandwich 34 | 35 | # Add a side and print again 36 | sandwich.add_side("chips") 37 | puts sandwich 38 | -------------------------------------------------------------------------------- /examples/sandwich_with_kwarg_init.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ivar" 4 | 5 | class SandwichWithKwargInit 6 | include Ivar::Checked 7 | 8 | # Declare instance variables with keyword argument initialization 9 | # and default values in case they're not provided 10 | ivar :@bread, init: :kwarg, value: "wheat" 11 | ivar :@cheese, init: :kwarg, value: "cheddar" 12 | 13 | # Declare condiments with a default value 14 | ivar :@condiments, value: [] 15 | 16 | # Declare pickles with both a default value and kwarg initialization 17 | ivar :@pickles, value: false, init: :kwarg 18 | 19 | def initialize(extra_condiments: []) 20 | # The declared variables are already initialized with their values 21 | # from keyword arguments or defaults 22 | # Note: bread, cheese, and pickles keywords are "peeled off" and won't be passed to this method 23 | # But extra_condiments will be passed through 24 | 25 | # Add default condiments (clear first to avoid duplicates) 26 | @condiments = [] 27 | @condiments << "mayo" unless @pickles 28 | @condiments << "mustard" 29 | 30 | # Add any extra condiments 31 | @condiments.concat(extra_condiments) 32 | 33 | # For demonstration, we'll print what keywords were actually received 34 | received_vars = [] 35 | local_variables.each do |v| 36 | next if v == :_ || binding.local_variable_get(v).nil? 37 | received_vars << "#{v}: #{binding.local_variable_get(v).inspect}" 38 | end 39 | puts " Initialize received: #{received_vars.join(", ")}" 40 | end 41 | 42 | def to_s 43 | result = "A #{@bread} sandwich with #{@cheese}" 44 | result += " and #{@condiments.join(", ")}" unless @condiments.empty? 45 | result += " with pickles" if @pickles 46 | result 47 | end 48 | end 49 | 50 | # Create a sandwich with default values 51 | puts "Default sandwich:" 52 | sandwich = SandwichWithKwargInit.new 53 | puts sandwich 54 | # => "A wheat sandwich with cheddar and mayo, mustard" 55 | 56 | # Create a sandwich with custom bread and cheese 57 | puts "\nCustom bread and cheese:" 58 | custom_sandwich = SandwichWithKwargInit.new(bread: "rye", cheese: "swiss") 59 | puts custom_sandwich 60 | # => "A rye sandwich with swiss and mayo, mustard" 61 | 62 | # Create a sandwich with pickles 63 | puts "\nSandwich with pickles:" 64 | pickle_sandwich = SandwichWithKwargInit.new(pickles: true) 65 | puts pickle_sandwich 66 | # => "A wheat sandwich with cheddar and mustard with pickles" 67 | 68 | # Create a sandwich with extra condiments (not peeled off) 69 | puts "\nSandwich with extra condiments:" 70 | extra_sandwich = SandwichWithKwargInit.new(extra_condiments: ["ketchup", "relish"]) 71 | puts extra_sandwich 72 | # => "A wheat sandwich with cheddar and mayo, mustard, ketchup, relish" 73 | 74 | # Create a sandwich with both peeled off and passed through kwargs 75 | puts "\nSandwich with both types of kwargs:" 76 | combo_sandwich = SandwichWithKwargInit.new(bread: "sourdough", pickles: true, extra_condiments: ["hot sauce"]) 77 | puts combo_sandwich 78 | # => "A sourdough sandwich with cheddar and mustard, hot sauce with pickles" 79 | -------------------------------------------------------------------------------- /examples/sandwich_with_positional_init.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ivar" 4 | 5 | class SandwichWithPositionalInit 6 | include Ivar::Checked 7 | 8 | # Declare instance variables with positional argument initialization 9 | # and default values in case they're not provided 10 | ivar :@bread, init: :positional, value: "wheat" 11 | ivar :@cheese, init: :positional, value: "cheddar" 12 | 13 | # Declare condiments with a default value 14 | ivar :@condiments, value: [] 15 | 16 | # Declare pickles with both a default value and positional initialization 17 | ivar :@pickles, value: false, init: :positional 18 | 19 | # Note: Don't define parameters for the peeled-off positional arguments 20 | def initialize(extra_condiments = []) 21 | # The declared variables are already initialized with their values 22 | # from positional arguments or defaults 23 | @condiments += extra_condiments unless extra_condiments.empty? 24 | 25 | # We can also check if pickles were requested and adjust condiments 26 | @condiments.delete("mayo") if @pickles 27 | end 28 | 29 | def to_s 30 | result = "#{@bread} sandwich with #{@cheese} cheese" 31 | result += " and pickles" if @pickles 32 | result += ", condiments: #{@condiments.join(", ")}" unless @condiments.empty? 33 | result 34 | end 35 | end 36 | 37 | # Create a sandwich with all positional arguments 38 | sandwich1 = SandwichWithPositionalInit.new("rye", "swiss", true, ["mustard"]) 39 | puts "Sandwich 1: #{sandwich1}" 40 | # => "Sandwich 1: rye sandwich with swiss cheese and pickles, condiments: mustard" 41 | 42 | # Create a sandwich with some positional arguments 43 | sandwich2 = SandwichWithPositionalInit.new("sourdough", "provolone", ["mayo", "mustard"]) 44 | puts "Sandwich 2: #{sandwich2}" 45 | # => "Sandwich 2: sourdough sandwich with provolone cheese, condiments: mayo, mustard" 46 | 47 | # Create a sandwich with default values 48 | sandwich3 = SandwichWithPositionalInit.new 49 | puts "Sandwich 3: #{sandwich3}" 50 | # => "Sandwich 3: wheat sandwich with cheddar cheese" 51 | -------------------------------------------------------------------------------- /examples/sandwich_with_shared_values.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ivar" 4 | 5 | class SandwichWithSharedValues 6 | include Ivar::Checked 7 | 8 | # Declare multiple condiments with the same initial value (true) 9 | ivar :@mayo, :@mustard, :@ketchup, value: true 10 | 11 | # Declare bread and cheese with individual values 12 | ivar "@bread": "wheat", "@cheese": "cheddar" 13 | 14 | # Declare a variable without an initial value 15 | ivar :@side 16 | 17 | def initialize(options = {}) 18 | # Override any condiments based on options 19 | @mayo = false if options[:no_mayo] 20 | @mustard = false if options[:no_mustard] 21 | @ketchup = false if options[:no_ketchup] 22 | 23 | # Set the side if provided 24 | @side = options[:side] if options[:side] 25 | end 26 | 27 | def to_s 28 | result = "A #{@bread} sandwich with #{@cheese}" 29 | 30 | condiments = [] 31 | condiments << "mayo" if @mayo 32 | condiments << "mustard" if @mustard 33 | condiments << "ketchup" if @ketchup 34 | 35 | result += " with #{condiments.join(", ")}" unless condiments.empty? 36 | result += " and a side of #{@side}" if defined?(@side) && @side 37 | result 38 | end 39 | end 40 | 41 | # Create a sandwich with default values (all condiments) 42 | sandwich = SandwichWithSharedValues.new 43 | puts sandwich 44 | # => "A wheat sandwich with cheddar with mayo, mustard, ketchup" 45 | 46 | # Create a sandwich with no mayo 47 | sandwich_no_mayo = SandwichWithSharedValues.new(no_mayo: true) 48 | puts sandwich_no_mayo 49 | # => "A wheat sandwich with cheddar with mustard, ketchup" 50 | 51 | # Create a sandwich with a side 52 | sandwich_with_side = SandwichWithSharedValues.new(side: "chips") 53 | puts sandwich_with_side 54 | # => "A wheat sandwich with cheddar with mayo, mustard, ketchup and a side of chips" 55 | -------------------------------------------------------------------------------- /hooks/README.md: -------------------------------------------------------------------------------- 1 | # Git Hooks 2 | 3 | This directory contains Git hooks for the ivar project. 4 | 5 | ## Available Hooks 6 | 7 | - **pre-commit**: Automatically checks and fixes linting issues before committing. 8 | 9 | ## Installation 10 | 11 | To install the hooks, run: 12 | 13 | ```bash 14 | ./hooks/install.sh 15 | ``` 16 | 17 | This will copy the hooks to your local `.git/hooks` directory and make them executable. 18 | 19 | ## Automatic Installation 20 | 21 | The hooks are automatically installed when you open the project in a devcontainer. 22 | 23 | ## Manual Installation 24 | 25 | If you prefer to install the hooks manually, you can copy them to your `.git/hooks` directory: 26 | 27 | ```bash 28 | cp hooks/pre-commit .git/hooks/pre-commit 29 | chmod +x .git/hooks/pre-commit 30 | ``` 31 | 32 | ## How the Pre-commit Hook Works 33 | 34 | The pre-commit hook: 35 | 36 | 1. Identifies staged Ruby files 37 | 2. Checks them for linting issues using standardrb 38 | 3. If issues are found, attempts to automatically fix them 39 | 4. Adds the fixed files back to the staging area 40 | 5. Performs a final check to ensure all issues are fixed 41 | 42 | If any issues cannot be automatically fixed, the commit will be aborted with an error message. 43 | -------------------------------------------------------------------------------- /hooks/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Create hooks directory if it doesn't exist 4 | mkdir -p .git/hooks 5 | 6 | # Copy pre-commit hook 7 | cp hooks/pre-commit .git/hooks/pre-commit 8 | 9 | # Make pre-commit hook executable 10 | chmod +x .git/hooks/pre-commit 11 | 12 | echo "Git hooks installed successfully!" 13 | -------------------------------------------------------------------------------- /hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Get list of staged Ruby files 4 | STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep "\.rb$") 5 | 6 | # Exit if no Ruby files are staged 7 | if [ -z "$STAGED_FILES" ]; then 8 | echo "No Ruby files staged for commit. Skipping linting." 9 | exit 0 10 | fi 11 | 12 | # Check if there are any linting issues 13 | echo "Checking for linting issues..." 14 | ./script/lint $STAGED_FILES 15 | LINT_RESULT=$? 16 | 17 | # If there are linting issues, try to fix them automatically 18 | if [ $LINT_RESULT -ne 0 ]; then 19 | echo "Linting issues found. Attempting to fix automatically..." 20 | 21 | # Stash unstaged changes 22 | git stash -q --keep-index 23 | 24 | # Run de-lint to auto-fix 25 | ./script/de-lint $STAGED_FILES 26 | FIX_RESULT=$? 27 | 28 | # If auto-fix was successful, add the fixed files back to staging 29 | if [ $FIX_RESULT -eq 0 ]; then 30 | echo "Auto-fix successful. Adding fixed files to staging area..." 31 | git add $STAGED_FILES 32 | else 33 | echo "Auto-fix failed. Please fix the issues manually." 34 | # Restore unstaged changes 35 | git stash pop -q 36 | exit 1 37 | fi 38 | 39 | # Restore unstaged changes 40 | git stash pop -q 2>/dev/null || true 41 | fi 42 | 43 | # Run a final check to make sure everything is fixed 44 | echo "Running final linting check..." 45 | ./script/lint $STAGED_FILES 46 | FINAL_RESULT=$? 47 | 48 | if [ $FINAL_RESULT -ne 0 ]; then 49 | echo "Linting issues still exist after auto-fix. Please fix them manually." 50 | exit 1 51 | fi 52 | 53 | echo "Linting passed. Proceeding with commit." 54 | exit 0 55 | -------------------------------------------------------------------------------- /ivar.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/ivar/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "ivar" 7 | spec.version = Ivar::VERSION 8 | spec.authors = ["Avdi Grimm"] 9 | spec.email = ["avdi@avdi.codes"] 10 | 11 | spec.summary = "A Ruby gem that automatically checks for typos in instance variables." 12 | spec.description = <<~DESCRIPTION 13 | Ivar is a Ruby gem that automatically checks for typos in instance variables. 14 | DESCRIPTION 15 | 16 | spec.homepage = "https://github.com/avdi/ivar" 17 | spec.license = "MIT" 18 | spec.required_ruby_version = ">= 3.3.0" 19 | 20 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 21 | 22 | spec.metadata["homepage_uri"] = spec.homepage 23 | spec.metadata["source_code_uri"] = "https://github.com/avdi/ivar" 24 | spec.metadata["changelog_uri"] = "https://github.com/avdi/ivar/blob/main/CHANGELOG.md" 25 | 26 | # Specify which files should be added to the gem when it is released. 27 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 28 | spec.files = Dir.chdir(__dir__) do 29 | `git ls-files -z`.split("\x0").reject do |f| 30 | (File.expand_path(f) == __FILE__) || 31 | f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile]) || 32 | f.end_with?(".gem") 33 | end 34 | end 35 | spec.require_paths = ["lib"] 36 | 37 | # Dependencies 38 | spec.add_dependency "prism", "~> 1.2" 39 | end 40 | -------------------------------------------------------------------------------- /lib/ivar.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "ivar/version" 4 | require_relative "ivar/policies" 5 | require_relative "ivar/validation" 6 | require_relative "ivar/macros" 7 | require_relative "ivar/project_root" 8 | require_relative "ivar/check_all_manager" 9 | require_relative "ivar/check_policy" 10 | require_relative "ivar/checked" 11 | require_relative "ivar/manifest" 12 | require_relative "ivar/targeted_prism_analysis" 13 | require "prism" 14 | require "did_you_mean" 15 | require "pathname" 16 | 17 | module Ivar 18 | @analysis_cache = {} 19 | @checked_classes = {} 20 | @default_check_policy = :warn_once 21 | @manifest_registry = {} 22 | @project_root = nil 23 | MUTEX = Mutex.new 24 | PROJECT_ROOT_FINDER = ProjectRoot.new 25 | CHECK_ALL_MANAGER = CheckAllManager.new 26 | 27 | # Pattern for internal instance variables 28 | INTERNAL_IVAR_PREFIX = "@__ivar_" 29 | 30 | # Checks if an instance variable name is an internal variable 31 | # @param ivar_name [Symbol, String] The instance variable name to check 32 | # @return [Boolean] Whether the variable is an internal variable 33 | def self.internal_ivar?(ivar_name) 34 | ivar_name.to_s.start_with?(INTERNAL_IVAR_PREFIX) 35 | end 36 | 37 | # Returns a list of known internal instance variables 38 | # @return [Array] List of known internal instance variables 39 | def self.known_internal_ivars 40 | [ 41 | :@__ivar_check_policy, 42 | :@__ivar_initialized_vars, 43 | :@__ivar_method_impl_stash, 44 | :@__ivar_skip_init 45 | ] 46 | end 47 | 48 | def self.get_ancestral_analyses(klass) 49 | klass 50 | .ancestors.filter_map { |ancestor| maybe_get_analysis(ancestor) } 51 | .reverse 52 | end 53 | 54 | def self.maybe_get_analysis(klass) 55 | if klass.include?(Validation) 56 | get_analysis(klass) 57 | end 58 | end 59 | 60 | # Returns a cached analysis for the given class or module 61 | # Creates a new analysis if one doesn't exist in the cache 62 | # Thread-safe: Multiple readers are allowed, but writers block all other access 63 | def self.get_analysis(klass) 64 | return @analysis_cache[klass] if @analysis_cache.key?(klass) 65 | 66 | MUTEX.synchronize do 67 | @analysis_cache[klass] ||= TargetedPrismAnalysis.new(klass) 68 | end 69 | end 70 | 71 | # Checks if a class has been validated already 72 | # @param klass [Class] The class to check 73 | # @return [Boolean] Whether the class has been validated 74 | # Thread-safe: Read-only operation 75 | def self.class_checked?(klass) 76 | MUTEX.synchronize { @checked_classes.key?(klass) } 77 | end 78 | 79 | # Marks a class as having been checked 80 | # @param klass [Class] The class to mark as checked 81 | # Thread-safe: Write operation protected by mutex 82 | def self.mark_class_checked(klass) 83 | MUTEX.synchronize { @checked_classes[klass] = true } 84 | end 85 | 86 | # For testing purposes - allows clearing the cache 87 | # Thread-safe: Write operation protected by mutex 88 | def self.clear_analysis_cache 89 | MUTEX.synchronize do 90 | @analysis_cache.clear 91 | @checked_classes.clear 92 | @manifest_registry.clear 93 | end 94 | PROJECT_ROOT_FINDER.clear_cache 95 | end 96 | 97 | # Get or create a manifest for a class or module 98 | # @param klass [Class, Module] The class or module to get a manifest for 99 | # @param create [Boolean] Whether to create a new manifest if one doesn't exist 100 | # @return [Manifest, nil] The manifest for the class or module, or nil if not found and create_if_missing is false 101 | def self.get_manifest(klass, create: true) 102 | return @manifest_registry[klass] if @manifest_registry.key?(klass) 103 | return nil unless create 104 | 105 | MUTEX.synchronize do 106 | @manifest_registry[klass] ||= Manifest.new(klass) 107 | end 108 | end 109 | 110 | # Alias for get_manifest that makes it clearer that it may create a manifest 111 | # @param klass [Class, Module] The class or module to get a manifest for 112 | # @return [Manifest] The manifest for the class or module 113 | def self.get_or_create_manifest(klass) 114 | get_manifest(klass, create: true) 115 | end 116 | 117 | # Check if a manifest exists for a class or module 118 | # @param klass [Class, Module] The class or module to check 119 | # @return [Boolean] Whether a manifest exists for the class or module 120 | def self.manifest_exists?(klass) 121 | @manifest_registry.key?(klass) 122 | end 123 | 124 | # Get the default check policy 125 | # @return [Symbol] The default check policy 126 | def self.check_policy 127 | @default_check_policy 128 | end 129 | 130 | # Set the default check policy 131 | # @param policy [Symbol, Policy] The default check policy 132 | def self.check_policy=(policy) 133 | MUTEX.synchronize { @default_check_policy = policy } 134 | end 135 | 136 | def self.project_root=(explicit_root) 137 | @project_root = explicit_root 138 | end 139 | 140 | # Determines the project root directory based on the caller's location 141 | # Delegates to ProjectRoot class 142 | # @param caller_location [String, nil] Optional file path to start from (defaults to caller's location) 143 | # @return [String] The absolute path to the project root directory 144 | def self.project_root(caller_location = nil) 145 | @project_root ||= PROJECT_ROOT_FINDER.find(caller_location) 146 | end 147 | 148 | # Enables automatic inclusion of Ivar::Checked in all classes and modules 149 | # defined within the project root. 150 | # 151 | # @param block [Proc] Optional block. If provided, auto-checking is only active 152 | # for the duration of the block. Otherwise, it remains active indefinitely. 153 | # @return [void] 154 | def self.check_all(&block) 155 | root = project_root 156 | CHECK_ALL_MANAGER.enable(root, &block) 157 | end 158 | 159 | # Disables automatic inclusion of Ivar::Checked in classes and modules. 160 | # @return [void] 161 | def self.disable_check_all 162 | CHECK_ALL_MANAGER.disable 163 | end 164 | 165 | # Gets a method from the stash or returns nil if not found 166 | # @param klass [Class] The class that owns the method 167 | # @param method_name [Symbol] The name of the method to retrieve 168 | # @return [UnboundMethod, nil] The stashed method or nil if not found 169 | def self.get_stashed_method(klass, method_name) 170 | (klass.instance_variable_get(:@__ivar_method_impl_stash) || {})[method_name] 171 | end 172 | 173 | # Stashes a method implementation for a class 174 | # @param klass [Class] The class that owns the method 175 | # @param method_name [Symbol] The name of the method to stash 176 | # @return [UnboundMethod, nil] The stashed method or nil if the method doesn't exist 177 | def self.stash_method(klass, method_name) 178 | return nil unless klass.method_defined?(method_name) || klass.private_method_defined?(method_name) 179 | 180 | method_impl = klass.instance_method(method_name) 181 | stash = klass.instance_variable_get(:@__ivar_method_impl_stash) || 182 | klass.instance_variable_set(:@__ivar_method_impl_stash, {}) 183 | stash[method_name] = method_impl 184 | end 185 | end 186 | -------------------------------------------------------------------------------- /lib/ivar/check_all.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../ivar" 4 | 5 | # Enable automatic inclusion of Ivar::Checked in all classes and modules 6 | # defined within the project root. 7 | Ivar.check_all 8 | -------------------------------------------------------------------------------- /lib/ivar/check_all_manager.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pathname" 4 | 5 | module Ivar 6 | # Manages automatic inclusion of Ivar::Checked in classes and modules 7 | class CheckAllManager 8 | def initialize 9 | @trace_point = nil 10 | @mutex = Mutex.new 11 | end 12 | 13 | # Enables automatic inclusion of Ivar::Checked in all classes and modules 14 | # defined within the project root. 15 | # 16 | # @param project_root [String] The project root directory path 17 | # @param block [Proc] Optional block. If provided, auto-checking is only active 18 | # for the duration of the block. Otherwise, it remains active indefinitely. 19 | # @return [void] 20 | def enable(project_root, &block) 21 | disable if @trace_point 22 | root_pathname = Pathname.new(project_root) 23 | @mutex.synchronize do 24 | # :end means "end of module or class definition" in TracePoint 25 | @trace_point = TracePoint.new(:end) do |tp| 26 | next unless tp.path 27 | file_path = Pathname.new(File.expand_path(tp.path)) 28 | if file_path.to_s.start_with?(root_pathname.to_s) 29 | klass = tp.self 30 | next if klass.included_modules.include?(Ivar::Checked) 31 | klass.include(Ivar::Checked) 32 | end 33 | end 34 | 35 | @trace_point.enable 36 | end 37 | 38 | if block 39 | begin 40 | yield 41 | ensure 42 | disable 43 | end 44 | end 45 | 46 | nil 47 | end 48 | 49 | # Disables automatic inclusion of Ivar::Checked in classes and modules. 50 | # @return [void] 51 | def disable 52 | @mutex.synchronize do 53 | if @trace_point 54 | @trace_point.disable 55 | @trace_point = nil 56 | end 57 | end 58 | end 59 | 60 | # Returns whether check_all is currently enabled 61 | # @return [Boolean] true if check_all is enabled, false otherwise 62 | def enabled? 63 | @mutex.synchronize { !@trace_point.nil? && @trace_point.enabled? } 64 | end 65 | 66 | # Returns the current trace point (mainly for testing) 67 | # @return [TracePoint, nil] The current trace point or nil if not enabled 68 | def trace_point 69 | @mutex.synchronize { @trace_point } 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/ivar/check_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ivar 4 | # Module for adding instance variable check policy configuration to classes. 5 | # This module provides a way to set and inherit check policies for instance variables. 6 | # When extended in a class, it allows setting a class-specific policy that overrides 7 | # the global Ivar policy. 8 | module CheckPolicy 9 | # Set or get the check policy for this class 10 | # @param policy [Symbol, Policy] The check policy to set 11 | # @param options [Hash] Additional options for the policy 12 | # @return [Symbol, Policy] The current check policy 13 | def ivar_check_policy(policy = nil, **options) 14 | if policy.nil? 15 | @__ivar_check_policy || Ivar.check_policy 16 | else 17 | @__ivar_check_policy = options.empty? ? policy : [policy, options] 18 | end 19 | end 20 | 21 | # Ensure subclasses inherit the check policy from their parent 22 | # This method is called automatically when a class is inherited 23 | # @param subclass [Class] The subclass that is inheriting from this class 24 | def inherited(subclass) 25 | super 26 | subclass.instance_variable_set(:@__ivar_check_policy, @__ivar_check_policy) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/ivar/checked.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "validation" 4 | require_relative "macros" 5 | require_relative "check_policy" 6 | require_relative "checked/class_methods" 7 | require_relative "checked/instance_methods" 8 | 9 | module Ivar 10 | # Provides automatic validation for instance variables. 11 | # When included in a class, this module: 12 | # 1. Automatically calls check_ivars after initialization 13 | # 2. Extends the class with CheckPolicy for policy configuration 14 | # 3. Extends the class with Macros for ivar declarations 15 | # 4. Sets a default check policy of :warn 16 | # 5. Handles proper inheritance of these behaviors in subclasses 17 | module Checked 18 | # When this module is included in a class, it extends the class 19 | # with ClassMethods and includes the Validation module 20 | # @param base [Class] The class that is including this module 21 | def self.included(base) 22 | base.include(Validation) 23 | base.extend(ClassMethods) 24 | base.extend(CheckPolicy) 25 | base.extend(Macros) 26 | base.prepend(InstanceMethods) 27 | 28 | # Set default policy for Checked to :warn 29 | # This can be overridden by calling ivar_check_policy in the class 30 | base.ivar_check_policy(Ivar.check_policy) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/ivar/checked/class_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "instance_methods" 4 | 5 | module Ivar 6 | module Checked 7 | # Class methods added to the including class. 8 | # These methods ensure proper inheritance of Checked functionality. 9 | module ClassMethods 10 | # Ensure subclasses inherit the Checked functionality 11 | # This method is called automatically when a class is inherited 12 | # @param subclass [Class] The subclass that is inheriting from this class 13 | def inherited(subclass) 14 | super 15 | subclass.prepend(Ivar::Checked::InstanceMethods) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/ivar/checked/instance_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ivar 4 | module Checked 5 | # Instance methods that will be prepended to the including class. 6 | # These methods provide the core functionality for automatic instance variable validation. 7 | module InstanceMethods 8 | # The semantics of prepend are such that the super method becomes wholly inaccessible. So if we override a method 9 | # (like, say, initialize), we have to stash the original method implementation if we ever want to find out its 10 | # file and line number. 11 | def self.prepend_features(othermod) 12 | (instance_methods(false) | private_instance_methods(false)).each do |method_name| 13 | Ivar.stash_method(othermod, method_name) 14 | end 15 | super 16 | end 17 | 18 | # Wrap the initialize method to automatically call check_ivars 19 | # This method handles the initialization process, including: 20 | # 1. Processing manifest declarations before calling super 21 | # 3. Checking instance variables for validity 22 | def initialize(*args, **kwargs, &block) 23 | if @__ivar_skip_init 24 | super 25 | else 26 | @__ivar_skip_init = true 27 | manifest = Ivar.get_or_create_manifest(self.class) 28 | manifest.process_before_init(self, args, kwargs) 29 | super 30 | check_ivars 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/ivar/declaration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ivar 4 | # Base class for all declarations 5 | class Declaration 6 | # @return [Symbol] The name of the instance variable 7 | attr_reader :name, :manifest 8 | 9 | # Initialize a new declaration 10 | # @param name [Symbol, String] The name of the instance variable 11 | def initialize(name, manifest) 12 | @name = name.to_sym 13 | @manifest = manifest 14 | end 15 | 16 | # Called when the declaration is added to a class 17 | # @param klass [Class, Module] The class or module the declaration is added to 18 | def on_declare(klass) 19 | # Base implementation does nothing 20 | end 21 | 22 | # Called before object initialization 23 | # @param instance [Object] The object being initialized 24 | # @param args [Array] Positional arguments 25 | # @param kwargs [Hash] Keyword arguments 26 | def before_init(instance, args, kwargs) 27 | # Base implementation does nothing 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/ivar/explicit_declaration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "declaration" 4 | require_relative "macros" 5 | 6 | module Ivar 7 | # Represents an explicit declaration from the ivar macro 8 | class ExplicitDeclaration < Declaration 9 | # Initialize a new explicit declaration 10 | # @param name [Symbol, String] The name of the instance variable 11 | # @param options [Hash] Options for the declaration 12 | def initialize(name, manifest, options = {}) 13 | super(name, manifest) 14 | @init_method = options[:init] 15 | @initial_value = options[:value] 16 | @reader = options[:reader] || false 17 | @writer = options[:writer] || false 18 | @accessor = options[:accessor] || false 19 | @init_block = options[:block] 20 | end 21 | 22 | # Called when the declaration is added to a class 23 | # @param klass [Class, Module] The class or module the declaration is added to 24 | def on_declare(klass) 25 | add_accessor_methods(klass) 26 | end 27 | 28 | # Check if this declaration uses keyword argument initialization 29 | # @return [Boolean] Whether this declaration uses keyword argument initialization 30 | def kwarg_init? = false 31 | 32 | # Called before object initialization 33 | # @param instance [Object] The object being initialized 34 | # @param args [Array] Positional arguments 35 | # @param kwargs [Hash] Keyword arguments 36 | def before_init(instance, args, kwargs) 37 | if @init_block 38 | instance.instance_variable_set(@name, @init_block.call(@name)) 39 | end 40 | if @initial_value != Ivar::Macros::UNSET 41 | instance.instance_variable_set(@name, @initial_value) 42 | end 43 | end 44 | 45 | private 46 | 47 | # Add accessor methods to the class 48 | # @param klass [Class, Module] The class to add methods to 49 | def add_accessor_methods(klass) 50 | var_name = @name.to_s.delete_prefix("@") 51 | 52 | klass.__send__(:attr_reader, var_name) if @reader || @accessor 53 | klass.__send__(:attr_writer, var_name) if @writer || @accessor 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/ivar/explicit_keyword_declaration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "explicit_declaration" 4 | 5 | module Ivar 6 | # Represents an explicit declaration that initializes from keyword arguments 7 | class ExplicitKeywordDeclaration < ExplicitDeclaration 8 | # Check if this declaration uses keyword argument initialization 9 | # @return [Boolean] Whether this declaration uses keyword argument initialization 10 | def kwarg_init? = true 11 | 12 | # Called before object initialization 13 | # @param instance [Object] The object being initialized 14 | # @param args [Array] Positional arguments 15 | # @param kwargs [Hash] Keyword arguments 16 | def before_init(instance, args, kwargs) 17 | super 18 | kwarg_name = @name.to_s.delete_prefix("@").to_sym 19 | if kwargs.key?(kwarg_name) 20 | instance.instance_variable_set(@name, kwargs.delete(kwarg_name)) 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/ivar/explicit_positional_declaration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "explicit_declaration" 4 | 5 | module Ivar 6 | # Represents an explicit declaration that initializes from positional arguments 7 | class ExplicitPositionalDeclaration < ExplicitDeclaration 8 | # Called before object initialization 9 | # @param instance [Object] The object being initialized 10 | # @param args [Array] Positional arguments 11 | # @param kwargs [Hash] Keyword arguments 12 | def before_init(instance, args, kwargs) 13 | super 14 | if args.length > 0 15 | instance.instance_variable_set(@name, args.shift) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/ivar/macros.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ivar 4 | # Provides macros for working with instance variables 5 | module Macros 6 | # Special flag object to detect when a parameter is not provided 7 | UNSET = Object.new.freeze 8 | 9 | # When this module is extended, it adds class methods to the extending class 10 | def self.extended(base) 11 | # Get or create a manifest for this class 12 | Ivar.get_or_create_manifest(base) 13 | end 14 | 15 | # Declares instance variables that should be considered valid 16 | # without being explicitly initialized 17 | # @param ivars [Array] Instance variables to declare 18 | # @param value [Object] Optional value to initialize all declared variables with 19 | # Example: ivar :@foo, :@bar, value: 123 20 | # @param init [Symbol] Initialization method for the variable 21 | # :kwarg or :keyword - initializes from a keyword argument with the same name 22 | # Example: ivar :@foo, init: :kwarg 23 | # @param reader [Boolean] If true, creates attr_reader for all declared variables 24 | # Example: ivar :@foo, :@bar, reader: true 25 | # @param writer [Boolean] If true, creates attr_writer for all declared variables 26 | # Example: ivar :@foo, :@bar, writer: true 27 | # @param accessor [Boolean] If true, creates attr_accessor for all declared variables 28 | # Example: ivar :@foo, :@bar, accessor: true 29 | # @param ivars_with_values [Hash] Individual initial values for instance variables 30 | # Example: ivar "@foo": 123, "@bar": 456 31 | # @yield [varname] Block to generate initial values based on variable name 32 | # Example: ivar(:@foo, :@bar) { |varname| "#{varname} default" } 33 | def ivar(*ivars, value: UNSET, init: nil, reader: false, writer: false, accessor: false, **ivars_with_values, &block) 34 | manifest = Ivar.get_or_create_manifest(self) 35 | 36 | ivar_hash = ivars.map { |ivar| [ivar, value] }.to_h.merge(ivars_with_values) 37 | 38 | ivar_hash.each do |ivar_name, ivar_value| 39 | raise ArgumentError, "ivars must be symbols (#{ivar_name.inspect})" unless ivar_name.is_a?(Symbol) 40 | raise ArgumentError, "ivar names must start with @ (#{ivar_name.inspect})" unless /\A@/.match?(ivar_name) 41 | 42 | options = {init:, value: ivar_value, reader:, writer:, accessor:, block:} 43 | 44 | declaration = case init 45 | when :kwarg, :keyword 46 | Ivar::ExplicitKeywordDeclaration.new(ivar_name, manifest, options) 47 | when :arg, :positional 48 | # TODO: probably fail if a duplicate positional comes in 49 | # There aren't any obvious semantics for it. 50 | Ivar::ExplicitPositionalDeclaration.new(ivar_name, manifest, options) 51 | when nil 52 | Ivar::ExplicitDeclaration.new(ivar_name, manifest, options) 53 | else 54 | raise ArgumentError, "Invalid init method: #{init.inspect}" 55 | end 56 | manifest.add_explicit_declaration(declaration) 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/ivar/manifest.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "declaration" 4 | require_relative "explicit_declaration" 5 | require_relative "explicit_keyword_declaration" 6 | require_relative "explicit_positional_declaration" 7 | 8 | module Ivar 9 | # Represents a manifest of instance variable declarations for a class/module 10 | class Manifest 11 | # @return [Class, Module] The class or module this manifest is associated with 12 | attr_reader :owner 13 | 14 | # Initialize a new manifest 15 | # @param owner [Class, Module] The class or module this manifest is associated with 16 | def initialize(owner) 17 | @owner = owner 18 | @declarations_by_name = {} 19 | end 20 | 21 | # @return [Hash] The declarations hash keyed by variable name 22 | attr_reader :declarations_by_name 23 | 24 | # @return [Array] The declarations in this manifest 25 | def declarations 26 | @declarations_by_name.values 27 | end 28 | 29 | # Add an explicit declaration to the manifest 30 | # @param declaration [ExplicitDeclaration] The declaration to add 31 | # @return [ExplicitDeclaration] The added declaration 32 | def add_explicit_declaration(declaration) 33 | name = declaration.name 34 | @declarations_by_name[name] = declaration 35 | declaration.on_declare(@owner) 36 | declaration 37 | end 38 | 39 | # Get all ancestor manifests in reverse order (from highest to lowest in the hierarchy) 40 | # Only includes ancestors that have existing manifests 41 | # @return [Array] Array of ancestor manifests 42 | def ancestor_manifests 43 | return [] unless @owner.respond_to?(:ancestors) 44 | 45 | @owner 46 | .ancestors.reject { |ancestor| ancestor == @owner } 47 | .filter_map { |ancestor| Ivar.get_manifest(ancestor, create: false) } 48 | .reverse 49 | end 50 | 51 | def explicitly_declared_ivars 52 | all_declarations.grep(ExplicitDeclaration).map(&:name) 53 | end 54 | 55 | # Get all declarations, including those from ancestor manifests 56 | # @return [Array] All declarations 57 | def all_declarations 58 | ancestor_manifests 59 | .flat_map(&:declarations) 60 | .+(declarations) 61 | # use hash stores to preserve order and deduplicate by name 62 | .each_with_object({}) { |decl, acc| acc[decl.name] = decl } 63 | .values 64 | end 65 | 66 | # Check if a variable is declared in this manifest or ancestor manifests 67 | # @param name [Symbol, String] The variable name 68 | # @return [Boolean] Whether the variable is declared 69 | def declared?(name) 70 | name = name.to_sym 71 | 72 | # Check in this manifest first 73 | return true if @declarations_by_name.key?(name) 74 | 75 | # Then check in ancestor manifests 76 | ancestor_manifests.any? do |ancestor_manifest| 77 | ancestor_manifest.declarations_by_name.key?(name) 78 | end 79 | end 80 | 81 | # Get a declaration by name 82 | # @param name [Symbol, String] The variable name 83 | # @return [Declaration, nil] The declaration, or nil if not found 84 | def get_declaration(name) 85 | name = name.to_sym 86 | 87 | # Check in this manifest first 88 | return @declarations_by_name[name] if @declarations_by_name.key?(name) 89 | 90 | # Then check in ancestor manifests, starting from the closest ancestor 91 | ancestor_manifests.each do |ancestor_manifest| 92 | if ancestor_manifest.declarations_by_name.key?(name) 93 | return ancestor_manifest.declarations_by_name[name] 94 | end 95 | end 96 | 97 | nil 98 | end 99 | 100 | # Get all explicit declarations 101 | # @return [Array] All explicit declarations 102 | def explicit_declarations 103 | declarations.select { |decl| decl.is_a?(ExplicitDeclaration) } 104 | end 105 | 106 | # Process before_init callbacks for all declarations 107 | # @param instance [Object] The object being initialized 108 | # @param args [Array] Positional arguments 109 | # @param kwargs [Hash] Keyword arguments 110 | # @return [Array, Hash] The modified args and kwargs 111 | def process_before_init(instance, args, kwargs) 112 | # Get all declarations from parent to child, with child declarations taking precedence 113 | declarations_to_process = all_declarations 114 | 115 | # Process all initializations in a single pass 116 | # The before_init method will handle keyword arguments with proper precedence 117 | declarations_to_process.each do |declaration| 118 | declaration.before_init(instance, args, kwargs) 119 | end 120 | 121 | [args, kwargs] 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/ivar/policies.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "logger" 4 | 5 | module Ivar 6 | # Base class for all ivar checking policies 7 | class Policy 8 | # Handle unknown instance variables 9 | # @param unknown_refs [Array] References to unknown instance variables 10 | # @param klass [Class] The class being checked 11 | # @param allowed_ivars [Array] List of allowed instance variables 12 | def handle_unknown_ivars(unknown_refs, klass, allowed_ivars) 13 | raise NotImplementedError, "Subclasses must implement handle_unknown_ivars" 14 | end 15 | 16 | # Find the closest match for a variable name 17 | # @param ivar [Symbol] The variable to find a match for 18 | # @param known_ivars [Array] List of known variables 19 | # @return [Symbol, nil] The closest match or nil if none found 20 | def find_closest_match(ivar, known_ivars) 21 | finder = DidYouMean::SpellChecker.new(dictionary: known_ivars) 22 | suggestions = finder.correct(ivar.to_s) 23 | suggestions.first&.to_sym if suggestions.any? 24 | end 25 | 26 | # Format a warning message for an unknown instance variable 27 | # @param ref [Hash] Reference to an unknown instance variable 28 | # @param suggestion [Symbol, nil] Suggested correction or nil 29 | # @return [String] Formatted warning message 30 | def format_warning(ref, suggestion) 31 | ivar = ref[:name] 32 | suggestion_text = suggestion ? "Did you mean: #{suggestion}?" : "" 33 | "#{ref[:path]}:#{ref[:line]}: warning: unknown instance variable #{ivar}. #{suggestion_text}" 34 | end 35 | 36 | # Emit warnings for each unknown reference 37 | # @param unknown_refs [Array] References to unknown instance variables 38 | # @param allowed_ivars [Array] List of allowed instance variables 39 | protected def emit_warnings_for_refs(unknown_refs, allowed_ivars) 40 | unknown_refs.each do |ref| 41 | ivar = ref[:name] 42 | suggestion = find_closest_match(ivar, allowed_ivars) 43 | warn(format_warning(ref, suggestion)) 44 | end 45 | end 46 | end 47 | 48 | # Policy that warns about unknown instance variables 49 | class WarnPolicy < Policy 50 | # Handle unknown instance variables by emitting warnings 51 | # @param unknown_refs [Array] References to unknown instance variables 52 | # @param klass [Class] The class being checked 53 | # @param allowed_ivars [Array] List of allowed instance variables 54 | def handle_unknown_ivars(unknown_refs, _klass, allowed_ivars) 55 | emit_warnings_for_refs(unknown_refs, allowed_ivars) 56 | end 57 | end 58 | 59 | # Policy that warns about unknown instance variables only once per class 60 | class WarnOncePolicy < Policy 61 | # Handle unknown instance variables by emitting warnings once per class 62 | # @param unknown_refs [Array] References to unknown instance variables 63 | # @param klass [Class] The class being checked 64 | # @param allowed_ivars [Array] List of allowed instance variables 65 | def handle_unknown_ivars(unknown_refs, klass, allowed_ivars) 66 | # Skip if this class has already been checked 67 | return if Ivar.class_checked?(klass) 68 | 69 | # Emit warnings 70 | emit_warnings_for_refs(unknown_refs, allowed_ivars) 71 | 72 | # Mark this class as having been checked 73 | Ivar.mark_class_checked(klass) 74 | end 75 | end 76 | 77 | # Policy that raises an exception for unknown instance variables 78 | class RaisePolicy < Policy 79 | # Handle unknown instance variables by raising an exception 80 | # @param unknown_refs [Array] References to unknown instance variables 81 | # @param klass [Class] The class being checked 82 | # @param allowed_ivars [Array] List of allowed instance variables 83 | def handle_unknown_ivars(unknown_refs, _klass, allowed_ivars) 84 | return if unknown_refs.empty? 85 | 86 | # Get the first unknown reference 87 | ref = unknown_refs.first 88 | ivar = ref[:name] 89 | suggestion = find_closest_match(ivar, allowed_ivars) 90 | suggestion_text = suggestion ? " Did you mean: #{suggestion}?" : "" 91 | 92 | # Raise an exception with location information 93 | message = "#{ref[:path]}:#{ref[:line]}: unknown instance variable #{ivar}.#{suggestion_text}" 94 | raise NameError, message 95 | end 96 | end 97 | 98 | # Policy that logs unknown instance variables to a logger 99 | class LogPolicy < Policy 100 | # Initialize with a logger 101 | # @param logger [Logger] The logger to use 102 | def initialize(logger: Logger.new($stderr)) 103 | @logger = logger 104 | end 105 | 106 | # Handle unknown instance variables by logging them 107 | # @param unknown_refs [Array] References to unknown instance variables 108 | # @param klass [Class] The class being checked 109 | # @param allowed_ivars [Array] List of allowed instance variables 110 | def handle_unknown_ivars(unknown_refs, _klass, allowed_ivars) 111 | unknown_refs.each do |ref| 112 | ivar = ref[:name] 113 | suggestion = find_closest_match(ivar, allowed_ivars) 114 | suggestion_text = suggestion ? " Did you mean: #{suggestion}?" : "" 115 | message = "#{ref[:path]}:#{ref[:line]}: unknown instance variable #{ivar}.#{suggestion_text}" 116 | @logger.warn(message) 117 | end 118 | end 119 | end 120 | 121 | # Policy that does nothing (no-op) for unknown instance variables 122 | class NonePolicy < Policy 123 | # Handle unknown instance variables by doing nothing 124 | # @param unknown_refs [Array] References to unknown instance variables 125 | # @param klass [Class] The class being checked 126 | # @param allowed_ivars [Array] List of allowed instance variables 127 | def handle_unknown_ivars(_unknown_refs, _klass, _allowed_ivars) 128 | # No-op - do nothing 129 | end 130 | end 131 | 132 | # Map of policy symbols to policy classes 133 | POLICY_CLASSES = { 134 | warn: WarnPolicy, 135 | warn_once: WarnOncePolicy, 136 | raise: RaisePolicy, 137 | log: LogPolicy, 138 | none: NonePolicy 139 | }.freeze 140 | 141 | # Get a policy instance from a symbol or policy object 142 | # @param policy [Symbol, Policy, Array] The policy to get 143 | # @param options [Hash] Options to pass to the policy constructor 144 | # @return [Policy] The policy instance 145 | def self.get_policy(policy, **options) 146 | return policy if policy.is_a?(Policy) 147 | 148 | # Handle the case where policy is an array with [policy_name, options] 149 | if policy.is_a?(Array) && policy.size == 2 && policy[1].is_a?(Hash) 150 | policy_name, policy_options = policy 151 | policy_class = POLICY_CLASSES[policy_name] 152 | raise ArgumentError, "Unknown policy: #{policy_name}" unless policy_class 153 | 154 | return policy_class.new(**policy_options) 155 | end 156 | 157 | policy_class = POLICY_CLASSES[policy] 158 | raise ArgumentError, "Unknown policy: #{policy}" unless policy_class 159 | 160 | policy_class.new(**options) 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /lib/ivar/project_root.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pathname" 4 | 5 | module Ivar 6 | # Handles project root detection and caching 7 | class ProjectRoot 8 | # Project root indicator files, in order of precedence 9 | INDICATORS = %w[Gemfile .git .ruby-version Rakefile].freeze 10 | 11 | def initialize 12 | @cache = {} 13 | @mutex = Mutex.new 14 | end 15 | 16 | # Determines the project root directory based on the caller's location 17 | # @param caller_location [String, nil] Optional file path to start from (defaults to caller's location) 18 | # @return [String] The absolute path to the project root directory 19 | def find(caller_location = nil) 20 | file_path = caller_location || caller_locations(2, 1).first&.path 21 | return Dir.pwd unless file_path 22 | 23 | @mutex.synchronize do 24 | return @cache[file_path] if @cache.key?(file_path) 25 | end 26 | 27 | dir = File.dirname(File.expand_path(file_path)) 28 | root = find_project_root(dir) 29 | 30 | @mutex.synchronize do 31 | @cache[file_path] = root 32 | end 33 | 34 | root 35 | end 36 | 37 | # Clear the cache (mainly for testing) 38 | def clear_cache 39 | @mutex.synchronize { @cache.clear } 40 | end 41 | 42 | private 43 | 44 | # Find the project root by walking up the directory tree 45 | # @param start_dir [String] Directory to start the search from 46 | # @return [String] The project root directory 47 | def find_project_root(start_dir) 48 | path = Pathname.new(start_dir) 49 | 50 | path.ascend do |dir| 51 | INDICATORS.each do |indicator| 52 | return dir.to_s if dir.join(indicator).exist? 53 | end 54 | end 55 | 56 | start_dir 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/ivar/targeted_prism_analysis.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "prism" 4 | 5 | module Ivar 6 | # Analyzes a class to find instance variable references in specific instance methods 7 | # Unlike PrismAnalysis, this targets only the class's own methods (not inherited) 8 | # and precisely locates instance variable references within each method definition 9 | class TargetedPrismAnalysis 10 | attr_reader :ivars, :references 11 | 12 | def initialize(klass) 13 | @klass = klass 14 | @references = [] 15 | @method_locations = {} 16 | collect_method_locations 17 | analyze_methods 18 | @ivars = unique_ivar_names 19 | end 20 | 21 | # Returns a list of hashes each representing a code reference to an ivar 22 | # Each hash includes var name, path, line number, and column number 23 | def ivar_references 24 | @references 25 | end 26 | 27 | private 28 | 29 | def unique_ivar_names 30 | @references.map { |ref| ref[:name] }.uniq.sort 31 | end 32 | 33 | def collect_method_locations 34 | # Get all instance methods defined directly on this class (not inherited) 35 | instance_methods = @klass.instance_methods(false) | @klass.private_instance_methods(false) 36 | instance_methods.each do |method_name| 37 | # Try to get the method from the stash first, then fall back to the current method 38 | method_obj = Ivar.get_stashed_method(@klass, method_name) || @klass.instance_method(method_name) 39 | next unless method_obj.source_location 40 | 41 | file_path, line_number = method_obj.source_location 42 | @method_locations[method_name] = {path: file_path, line: line_number} 43 | end 44 | end 45 | 46 | def analyze_methods 47 | # Group methods by file to avoid parsing the same file multiple times 48 | methods_by_file = @method_locations.group_by { |_, location| location[:path] } 49 | 50 | methods_by_file.each do |file_path, methods_in_file| 51 | code = File.read(file_path) 52 | result = Prism.parse(code) 53 | 54 | methods_in_file.each do |method_name, location| 55 | visitor = MethodTargetedInstanceVariableReferenceVisitor.new( 56 | file_path, 57 | method_name, 58 | location[:line] 59 | ) 60 | 61 | result.value.accept(visitor) 62 | @references.concat(visitor.references) 63 | end 64 | end 65 | end 66 | end 67 | 68 | # Visitor that collects instance variable references within a specific method definition 69 | class MethodTargetedInstanceVariableReferenceVisitor < Prism::Visitor 70 | attr_reader :references 71 | 72 | def initialize(file_path, target_method_name, target_line) 73 | super() 74 | @file_path = file_path 75 | @target_method_name = target_method_name 76 | @target_line = target_line 77 | @references = [] 78 | @in_target_method = false 79 | end 80 | 81 | # Only visit the method definition we're targeting 82 | def visit_def_node(node) 83 | # Check if this is our target method 84 | if node.name.to_sym == @target_method_name && node.location.start_line == @target_line 85 | # Found our target method, now collect all instance variable references within it 86 | collector = IvarCollector.new(@file_path, @target_method_name) 87 | node.body&.accept(collector) 88 | @references = collector.references 89 | false 90 | else 91 | # Sometimes methods are found inside other methods... 92 | node.body&.accept(self) 93 | true 94 | end 95 | end 96 | end 97 | 98 | # Helper visitor that collects all instance variable references 99 | class IvarCollector < Prism::Visitor 100 | attr_reader :references 101 | 102 | def initialize(file_path, method_name) 103 | super() 104 | @file_path = file_path 105 | @method_name = method_name 106 | @references = [] 107 | end 108 | 109 | def visit_instance_variable_read_node(node) 110 | add_reference(node) 111 | true 112 | end 113 | 114 | def visit_instance_variable_write_node(node) 115 | add_reference(node) 116 | true 117 | end 118 | 119 | def visit_instance_variable_operator_write_node(node) 120 | add_reference(node) 121 | true 122 | end 123 | 124 | def visit_instance_variable_target_node(node) 125 | add_reference(node) 126 | true 127 | end 128 | 129 | private 130 | 131 | def add_reference(node) 132 | location = node.location 133 | reference = { 134 | name: node.name.to_sym, 135 | path: @file_path, 136 | line: location.start_line, 137 | column: location.start_column, 138 | method: @method_name 139 | } 140 | 141 | @references << reference 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /lib/ivar/validation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "did_you_mean" 4 | 5 | module Ivar 6 | # Provides validation for instance variables 7 | module Validation 8 | # Checks instance variables against class analysis 9 | # @param add [Array] Additional instance variables to allow 10 | # @param policy [Symbol, Policy] The policy to use for handling unknown variables 11 | def check_ivars(add: [], policy: nil) 12 | policy ||= get_check_policy 13 | analyses = Ivar.get_ancestral_analyses(self.class) 14 | manifest = Ivar.get_or_create_manifest(self.class) 15 | declared_ivars = manifest.all_declarations.map(&:name) 16 | allowed_ivars = (Ivar.known_internal_ivars | instance_variables | declared_ivars | add).uniq 17 | instance_refs = analyses.flat_map(&:references) 18 | unknown_refs = instance_refs.reject { |ref| allowed_ivars.include?(ref[:name]) } 19 | policy_instance = Ivar.get_policy(policy) 20 | policy_instance.handle_unknown_ivars(unknown_refs, self.class, allowed_ivars) 21 | end 22 | 23 | private 24 | 25 | # Get the check policy for this instance 26 | # @return [Symbol, Policy] The check policy 27 | def get_check_policy 28 | return self.class.ivar_check_policy if self.class.respond_to?(:ivar_check_policy) 29 | Ivar.check_policy 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/ivar/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ivar 4 | VERSION = "0.4.7" 5 | end 6 | -------------------------------------------------------------------------------- /script/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "ivar" 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 | require "irb" 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /script/de-lint: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | STANDARDOPTS="--fix $*" bundle exec rake standard 3 | -------------------------------------------------------------------------------- /script/de-lint-unsafe: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | STANDARDOPTS="--fix-unsafely $*" bundle exec rake standard 3 | -------------------------------------------------------------------------------- /script/lint: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | STANDARDOPTS="$*" bundle exec rake standard 3 | -------------------------------------------------------------------------------- /script/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # This script helps with releasing a new version of the gem 5 | # Usage: script/release [major|minor|patch] [options] 6 | # 7 | # Options: 8 | # --yes, -y Skip confirmation prompt 9 | # --no-push Skip pushing changes to remote repository 10 | 11 | require "bundler/gem_tasks" 12 | require_relative "../lib/ivar/version" 13 | 14 | def error(message) 15 | puts "\e[31mError: #{message}\e[0m" 16 | exit 1 17 | end 18 | 19 | def success(message) 20 | puts "\e[32m#{message}\e[0m" 21 | end 22 | 23 | def info(message) 24 | puts "\e[34m#{message}\e[0m" 25 | end 26 | 27 | def get_new_version(current_version, bump_type) 28 | major, minor, patch = current_version.split(".").map(&:to_i) 29 | 30 | case bump_type 31 | when "major" 32 | "#{major + 1}.0.0" 33 | when "minor" 34 | "#{major}.#{minor + 1}.0" 35 | when "patch" 36 | "#{major}.#{minor}.#{patch + 1}" 37 | else 38 | error "Invalid bump type. Use 'major', 'minor', or 'patch'." 39 | end 40 | end 41 | 42 | def update_version_file(new_version) 43 | version_file_path = "lib/ivar/version.rb" 44 | version_content = File.read(version_file_path) 45 | updated_content = version_content.gsub(/VERSION = "[0-9]+\.[0-9]+\.[0-9]+"/, "VERSION = \"#{new_version}\"") 46 | File.write(version_file_path, updated_content) 47 | end 48 | 49 | def update_changelog(new_version) 50 | changelog_path = "CHANGELOG.md" 51 | changelog_content = File.read(changelog_path) 52 | 53 | # Check if there are unreleased changes 54 | unless changelog_content.include?("## [Unreleased]") 55 | error "No unreleased changes found in CHANGELOG.md. Add changes before releasing." 56 | end 57 | 58 | # Update the changelog with the new version 59 | today = Time.now.strftime("%Y-%m-%d") 60 | updated_content = changelog_content.gsub( 61 | "## [Unreleased]", 62 | "## [Unreleased]\n\n## [#{new_version}] - #{today}" 63 | ) 64 | 65 | File.write(changelog_path, updated_content) 66 | end 67 | 68 | def run_tests 69 | info "Running tests..." 70 | system("bundle exec rake test") || error("Tests failed. Fix the tests before releasing.") 71 | end 72 | 73 | def run_linter 74 | info "Running linter..." 75 | system("bundle exec rake standard") || error("Linter found issues. Fix them before releasing.") 76 | end 77 | 78 | def clean_build_artifacts 79 | info "Cleaning build artifacts..." 80 | system("bundle exec rake clean clobber") 81 | # Also remove any stray .gem files in the project root 82 | Dir.glob("*.gem").each do |gem_file| 83 | info "Removing stray gem file: #{gem_file}" 84 | File.delete(gem_file) 85 | end 86 | end 87 | 88 | def check_for_uncommitted_changes 89 | info "Checking for uncommitted changes..." 90 | uncommitted_changes = `git status --porcelain`.strip 91 | 92 | if uncommitted_changes.empty? 93 | info "No uncommitted changes detected." 94 | false 95 | else 96 | info "Uncommitted changes detected:" 97 | puts uncommitted_changes 98 | true 99 | end 100 | end 101 | 102 | def commit_remaining_changes(new_version) 103 | info "Committing remaining changes after release process..." 104 | system("git add --all") 105 | system("git commit -m \"Post-release cleanup for v#{new_version}\"") 106 | info "Remaining changes committed." 107 | end 108 | 109 | def push_changes_and_tag(new_version) 110 | # Check for any uncommitted changes before pushing 111 | has_uncommitted_changes = check_for_uncommitted_changes 112 | 113 | # If there are uncommitted changes, commit them 114 | if has_uncommitted_changes 115 | commit_remaining_changes(new_version) 116 | end 117 | 118 | info "Pushing changes to remote repository..." 119 | system("git push origin main") || error("Failed to push changes to remote repository.") 120 | 121 | info "Pushing tag v#{new_version} to remote repository..." 122 | system("git push origin v#{new_version}") || error("Failed to push tag to remote repository.") 123 | 124 | success "Changes and tag pushed successfully!" 125 | end 126 | 127 | def update_gemfile_lock 128 | info "Updating Gemfile.lock with new version..." 129 | system("bundle install") || error("Failed to update Gemfile.lock. Run 'bundle install' manually and try again.") 130 | info "Gemfile.lock updated successfully." 131 | end 132 | 133 | def commit_and_tag(new_version, skip_push = false) 134 | info "Committing version bump..." 135 | 136 | # Add all relevant files to staging 137 | system("git add lib/ivar/version.rb CHANGELOG.md Gemfile.lock") 138 | 139 | # Commit the changes 140 | system("git commit -m \"Bump version to #{new_version}\"") 141 | 142 | info "Creating tag v#{new_version}..." 143 | system("git tag -a v#{new_version} -m \"Version #{new_version}\"") 144 | 145 | if skip_push 146 | info "Skipping push to remote repository." 147 | info "To push the new version manually, run:" 148 | puts " git push origin main && git push origin v#{new_version}" 149 | else 150 | push_changes_and_tag(new_version) 151 | end 152 | end 153 | 154 | # Main script 155 | error "Please specify a version bump type: major, minor, or patch" if ARGV.empty? 156 | 157 | # Parse arguments 158 | args = ARGV.dup 159 | skip_confirmation = args.delete("--yes") || args.delete("-y") 160 | skip_push = args.delete("--no-push") 161 | bump_type = args[0].downcase if args[0] 162 | 163 | error "Please specify a version bump type: major, minor, or patch" unless bump_type 164 | 165 | current_version = Ivar::VERSION 166 | new_version = get_new_version(current_version, bump_type) 167 | 168 | info "Current version: #{current_version}" 169 | info "New version: #{new_version}" 170 | 171 | # Skip confirmation if --yes/-y option is provided 172 | confirmation = "y" if skip_confirmation 173 | 174 | unless confirmation 175 | puts "Continue? (y/n)" 176 | confirmation = $stdin.gets.chomp.downcase 177 | end 178 | 179 | if confirmation == "y" 180 | clean_build_artifacts 181 | run_tests 182 | run_linter 183 | update_version_file(new_version) 184 | update_changelog(new_version) 185 | update_gemfile_lock 186 | commit_and_tag(new_version, skip_push) 187 | success "Version bumped to #{new_version}!" 188 | 189 | if skip_push 190 | success "Remember to push changes manually to trigger the release workflow." 191 | else 192 | success "Release workflow triggered!" 193 | 194 | # Final check for any remaining uncommitted changes 195 | if check_for_uncommitted_changes 196 | info "There are still uncommitted changes after the release process." 197 | puts "Would you like to commit and push these changes? (y/n)" 198 | cleanup_confirmation = $stdin.gets.chomp.downcase 199 | 200 | if cleanup_confirmation == "y" 201 | commit_remaining_changes(new_version) 202 | system("git push origin main") || error("Failed to push cleanup changes.") 203 | success "Post-release cleanup completed and pushed successfully!" 204 | else 205 | info "Uncommitted changes left in working directory." 206 | end 207 | else 208 | success "Working directory is clean. Release completed successfully!" 209 | end 210 | end 211 | else 212 | info "Release cancelled." 213 | end 214 | -------------------------------------------------------------------------------- /script/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 | -------------------------------------------------------------------------------- /script/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | TESTOPTS="$*" bundle exec rake test 3 | -------------------------------------------------------------------------------- /sig/ivar.rbs: -------------------------------------------------------------------------------- 1 | module Ivar 2 | VERSION: String 3 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 4 | end 5 | -------------------------------------------------------------------------------- /test/fixtures/check_all_project/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # This is a fake Gemfile to serve as a project root indicator 6 | -------------------------------------------------------------------------------- /test/fixtures/check_all_project/Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # I just exist to make this look like a project root 4 | -------------------------------------------------------------------------------- /test/fixtures/check_all_project/lib/block_classes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file contains classes that will be loaded within a block 4 | 5 | # Class defined before the block 6 | class BeforeBlockClass 7 | def initialize 8 | @name = "before block" 9 | end 10 | 11 | def to_s 12 | # Intentional typo in @name 13 | "Name: #{@naem}" 14 | end 15 | end 16 | 17 | # Class that will be referenced within the block 18 | class WithinBlockClass 19 | def initialize 20 | @name = "within block" 21 | end 22 | 23 | def to_s 24 | # Intentional typo in @name 25 | "Name: #{@naem}" 26 | end 27 | end 28 | 29 | # Function to reference the class within a block 30 | def reference_within_block 31 | # Reference the class to trigger TracePoint 32 | WithinBlockClass 33 | end 34 | 35 | # Class defined after the block 36 | class AfterBlockClass 37 | def initialize 38 | @name = "after block" 39 | end 40 | 41 | def to_s 42 | # Intentional typo in @name 43 | "Name: #{@naem}" 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/fixtures/check_all_project/lib/block_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file contains classes for testing block-scoped activation 4 | 5 | # This class will be defined outside the block 6 | class OutsideBlockClass 7 | def initialize 8 | @name = "outside block" 9 | end 10 | 11 | def to_s 12 | # Intentional typo in @name 13 | "Name: #{@naem}" 14 | end 15 | end 16 | 17 | # This function will define a class inside the block 18 | def define_class_in_block 19 | # Define the class using the class keyword to trigger the TracePoint 20 | eval <<~RUBY, binding, __FILE__, __LINE__ + 1 21 | # This class will be defined inside the block 22 | class InsideBlockClass 23 | def initialize 24 | @name = "inside block" 25 | end 26 | 27 | def to_s 28 | # Intentional typo in @name 29 | "Name: \#{@naem}" 30 | end 31 | end 32 | RUBY 33 | end 34 | -------------------------------------------------------------------------------- /test/fixtures/check_all_project/lib/dynamic_class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file contains a function to dynamically create a class 4 | def create_dynamic_class 5 | # Remove the class if it already exists 6 | Object.send(:remove_const, :DynamicClass) if defined?(DynamicClass) 7 | 8 | # Define a new class 9 | dynamic_class = Class.new do 10 | def initialize 11 | @name = "dynamic" 12 | end 13 | 14 | def to_s 15 | # Intentional typo in @name 16 | "Name: #{@naem}" 17 | end 18 | end 19 | 20 | # Assign the class to a constant 21 | Object.const_set(:DynamicClass, dynamic_class) 22 | 23 | # Return the class 24 | DynamicClass 25 | end 26 | -------------------------------------------------------------------------------- /test/fixtures/check_all_project/lib/inside_class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This class is defined inside the project 4 | class InsideClass 5 | def initialize 6 | @name = "inside" 7 | end 8 | 9 | def to_s 10 | # Intentional typo in @name 11 | "Name: #{@naem}" 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/fixtures/check_all_project/test_block_scope.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This script tests block-scoped activation of check_all 4 | # It will be run as a subprocess 5 | 6 | require_relative "../../../lib/ivar" 7 | 8 | # Define a class before enabling check_all 9 | class BeforeClass 10 | def initialize 11 | @beforeclass_name = "before" 12 | end 13 | 14 | def to_s 15 | # Intentional typo in @name 16 | "Name: #{@beforeclass_naem}" 17 | end 18 | end 19 | 20 | # Verify that Ivar::Checked is not included before the block 21 | if BeforeClass.included_modules.include?(Ivar::Checked) 22 | puts "FAILURE: BeforeClass includes Ivar::Checked before block" 23 | exit 1 24 | else 25 | puts "SUCCESS: BeforeClass does not include Ivar::Checked before block" 26 | end 27 | 28 | # Use check_all with a block 29 | Ivar.check_all do 30 | class WithinBlockClass # rubocop:disable Lint/ConstantDefinitionInBlock 31 | def initialize 32 | @withinclass_name = "within block" 33 | end 34 | 35 | def to_s 36 | # Intentional typo in @name 37 | "Name: #{@withinclass_naem}" 38 | end 39 | end 40 | 41 | # Verify that Ivar::Checked is included in the class defined within the block 42 | if WithinBlockClass.included_modules.include?(Ivar::Checked) 43 | puts "SUCCESS: WithinBlockClass includes Ivar::Checked within block" 44 | else 45 | puts "FAILURE: WithinBlockClass does not include Ivar::Checked within block" 46 | exit 1 47 | end 48 | 49 | # Create an instance 50 | WithinBlockClass.new 51 | end 52 | class AfterClass 53 | def initialize 54 | @afterclass_name = "after" 55 | end 56 | 57 | def to_s 58 | # Intentional typo in @name 59 | "Name: #{@afterclass_naem}" 60 | end 61 | end 62 | 63 | # Verify that Ivar::Checked is not included after the block 64 | if AfterClass.included_modules.include?(Ivar::Checked) 65 | puts "FAILURE: AfterClass includes Ivar::Checked after block" 66 | exit 1 67 | else 68 | puts "SUCCESS: AfterClass does not include Ivar::Checked after block" 69 | end 70 | 71 | # Create instances and call to_s to trigger the typo 72 | BeforeClass.new.to_s 73 | AfterClass.new.to_s 74 | 75 | exit 0 76 | -------------------------------------------------------------------------------- /test/fixtures/check_all_project/test_inside_class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This script tests that classes inside the project get Ivar::Checked included 4 | # It will be run as a subprocess 5 | 6 | require_relative "../../../lib/ivar" 7 | 8 | Ivar.project_root = __dir__ 9 | Ivar.check_all 10 | 11 | # Define a class inside the project 12 | class InsideClass 13 | def initialize 14 | @name = "inside" 15 | end 16 | 17 | def to_s 18 | # Intentional typo in @name 19 | "Name: #{@naem}" 20 | end 21 | end 22 | 23 | # Verify that Ivar::Checked is included 24 | if InsideClass.included_modules.include?(Ivar::Checked) 25 | puts "SUCCESS: InsideClass includes Ivar::Checked" 26 | else 27 | puts "FAILURE: InsideClass does not include Ivar::Checked" 28 | exit 1 29 | end 30 | 31 | # Create an instance to trigger the warning 32 | InsideClass.new.to_s 33 | 34 | exit 0 35 | -------------------------------------------------------------------------------- /test/fixtures/check_all_project/test_outside_class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This script tests that classes outside the project don't get Ivar::Checked included 4 | # It will be run as a subprocess 5 | 6 | require_relative "../../../lib/ivar" 7 | 8 | Ivar.project_root = __dir__ 9 | Ivar.check_all 10 | 11 | # Define a class outside the project (simulated by loading from a different path) 12 | outside_file = File.expand_path("../../outside_project/outside_class.rb", __FILE__) 13 | load outside_file 14 | 15 | # Verify that Ivar::Checked is not included 16 | if OutsideClass.included_modules.include?(Ivar::Checked) 17 | puts "FAILURE: OutsideClass includes Ivar::Checked" 18 | exit 1 19 | else 20 | puts "SUCCESS: OutsideClass does not include Ivar::Checked" 21 | end 22 | 23 | # Create an instance and call to_s to trigger the potential typo warning 24 | OutsideClass.new.to_s 25 | 26 | exit 0 27 | -------------------------------------------------------------------------------- /test/fixtures/child_with_checked_ivars.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "parent_with_checked_ivars" 4 | 5 | class ChildWithCheckedIvars < ParentWithCheckedIvars 6 | def initialize 7 | super 8 | @child_var1 = "child1" 9 | @child_var2 = "child2" 10 | end 11 | 12 | def child_method 13 | "Using #{@child_var1} and #{@child_var2} and #{@chyld_var3}" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/fixtures/child_with_ivar_tools.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "parent_with_ivar_tools" 4 | 5 | class ChildWithIvarTools < ParentWithIvarTools 6 | def initialize 7 | super 8 | @child_var1 = "child1" 9 | @child_var2 = "child2" 10 | check_ivars 11 | end 12 | 13 | def child_method 14 | "Using #{@child_var1} and #{@child_var2} and #{@chyld_var3}" 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/fixtures/outside_project/outside_class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This class is defined outside the project 4 | class OutsideClass 5 | def initialize 6 | @name = "outside" 7 | end 8 | 9 | def to_s 10 | # Intentional typo in @name 11 | "Name: #{@naem}" 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/fixtures/parent_with_checked_ivars.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ivar" 4 | 5 | class ParentWithCheckedIvars 6 | include Ivar::Checked 7 | ivar_check_policy :warn_once 8 | 9 | def initialize 10 | @parent_var1 = "parent1" 11 | @parent_var2 = "parent2" 12 | end 13 | 14 | def parent_method 15 | "Using #{@parent_var1} and #{@parent_var2}" 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/fixtures/parent_with_ivar_tools.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ivar" 4 | 5 | class ParentWithIvarTools 6 | include Ivar::IvarTools 7 | 8 | def initialize 9 | @parent_var1 = "parent1" 10 | @parent_var2 = "parent2" 11 | check_ivars 12 | end 13 | 14 | def parent_method 15 | "Using #{@parent_var1} and #{@parent_var2}" 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/fixtures/sandwich.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Sandwich 4 | def initialize 5 | @bread = "wheat" 6 | @cheese = "muenster" 7 | @condiments = %w[mayo mustard] 8 | check_ivars(add: [:@side]) 9 | end 10 | 11 | def to_s 12 | result = "A #{@bread} sandwich with #{@chese} and #{@condiments.join(", ")}" 13 | result += " and a side of #{@side}" if @side 14 | result 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/fixtures/sandwich_with_checked_ivars.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ivar" 4 | 5 | class SandwichWithCheckedIvars 6 | include Ivar::Checked 7 | ivar_check_policy :warn_once 8 | 9 | def initialize 10 | @bread = "wheat" 11 | @cheese = "muenster" 12 | @condiments = %w[mayo mustard] 13 | end 14 | 15 | def to_s 16 | result = "A #{@bread} sandwich with #{@chese} and #{@condiments.join(", ")}" 17 | result += " and a side of #{@side}" if @side 18 | result 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/fixtures/sandwich_with_checked_once.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ivar" 4 | 5 | class SandwichWithCheckedOnce 6 | include Ivar::Checked 7 | ivar_check_policy :warn_once 8 | 9 | def initialize 10 | @bread = "wheat" 11 | @cheese = "muenster" 12 | @condiments = %w[mayo mustard] 13 | end 14 | 15 | def to_s 16 | result = "A #{@bread} sandwich with #{@chese} and #{@condiments.join(", ")}" 17 | result += " and a side of #{@side}" if @side 18 | result 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/fixtures/sandwich_with_ivar_tools.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ivar" 4 | 5 | class SandwichWithIvarTools 6 | include Ivar::IvarTools 7 | 8 | def initialize 9 | @bread = "wheat" 10 | @cheese = "muenster" 11 | @condiments = ["mayo", "mustard"] 12 | check_ivars(add: [:@side]) 13 | end 14 | 15 | def to_s 16 | result = "A #{@bread} sandwich with #{@chese} and #{@condiments.join(", ")}" 17 | result += " and a side of #{@side}" if @side 18 | result 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/fixtures/sandwich_with_validation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ivar" 4 | 5 | class SandwichWithValidation 6 | include Ivar::Validation 7 | 8 | def initialize 9 | @bread = "wheat" 10 | @cheese = "muenster" 11 | @condiments = %w[mayo mustard] 12 | # This variable is not in the analysis because it's not referenced elsewhere 13 | @typo_var = "should trigger warning" 14 | check_ivars(add: [:@side]) 15 | end 16 | 17 | def to_s 18 | # @chese is a typo that should be caught - it appears twice in this method 19 | result = "A #{@bread} sandwich with #{@chese} and #{@condiments.join(", ")}" 20 | # Second occurrence of the same typo 21 | result += " (#{@chese} is delicious!)" 22 | result += " and a side of #{@side}" if @side 23 | result 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/fixtures/split_class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "split_class_part1" 4 | require_relative "split_class_part2" 5 | -------------------------------------------------------------------------------- /test/fixtures/split_class_part1.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This class is intentionally split between two files 4 | class SplitClass 5 | def initialize 6 | @part1_var1 = "value1" 7 | @part1_var2 = "value2" 8 | end 9 | 10 | def part1_method 11 | "Using #{@part1_var1} and #{@part1_var2}" 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/fixtures/split_class_part2.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file adds more methods to the SplitClass 4 | class SplitClass 5 | def part2_method 6 | @part2_var1 = "another value" 7 | @part2_var2 = "yet another value" 8 | "Using #{@part2_var1} and #{@part2_var2} and #{@part2_var3}" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/fixtures/targeted_analysis/mixed_methods_class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This class has both instance and class methods 4 | class MixedMethodsClass 5 | # Class variable 6 | @@class_var = "class variable" 7 | 8 | # Class instance variable (different from class variable) 9 | @class_instance_var = "class instance variable" 10 | 11 | # Class method 12 | def self.class_method 13 | @class_method_var = "class method var" 14 | "Using #{@class_method_var} and #{@@class_var}" 15 | end 16 | 17 | # Another class method 18 | def self.another_class_method 19 | @another_class_var = "another class var" 20 | "Using #{@another_class_var} and #{@class_instance_var}" 21 | end 22 | 23 | # Instance method 24 | def initialize 25 | @instance_var1 = "instance var1" 26 | @instance_var2 = "instance var2" 27 | end 28 | 29 | # Another instance method 30 | def instance_method 31 | @instance_var1 = "modified" 32 | @instance_var3 = "instance var3" 33 | "Using #{@instance_var1}, #{@instance_var2}, and #{@instance_var3}" 34 | end 35 | 36 | # Private instance method 37 | private 38 | 39 | def private_instance_method 40 | @private_var = "private var" 41 | "Using #{@private_var} and #{@instance_var1}" 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/fixtures/targeted_analysis/multi_class_file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file contains multiple class definitions 4 | 5 | class FirstClass 6 | def initialize 7 | @first_var1 = "first class var1" 8 | @first_var2 = "first class var2" 9 | end 10 | 11 | def first_method 12 | @first_var1 = "modified" 13 | "Using #{@first_var1} and #{@first_var2}" 14 | end 15 | end 16 | 17 | class SecondClass 18 | def initialize 19 | @second_var1 = "second class var1" 20 | @second_var2 = "second class var2" 21 | end 22 | 23 | def second_method 24 | @second_var1 = "modified" 25 | "Using #{@second_var1} and #{@second_var2}" 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/fixtures/targeted_analysis/split_target_class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "split_target_class_part1" 4 | require_relative "split_target_class_part2" 5 | 6 | # This file just requires the two parts of the split class 7 | -------------------------------------------------------------------------------- /test/fixtures/targeted_analysis/split_target_class_part1.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This class is intentionally split between two files 4 | class SplitTargetClass 5 | def initialize 6 | @part1_var1 = "value1" 7 | @part1_var2 = "value2" 8 | end 9 | 10 | def part1_method 11 | @part1_var1 = "modified" 12 | @part1_var2 = "also modified" 13 | "Using #{@part1_var1} and #{@part1_var2}" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/fixtures/targeted_analysis/split_target_class_part2.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file adds more methods to the SplitTargetClass 4 | class SplitTargetClass 5 | def part2_method 6 | @part2_var1 = "another value" 7 | @part2_var2 = "yet another value" 8 | "Using #{@part2_var1} and #{@part2_var2}" 9 | end 10 | 11 | def another_part2_method 12 | @part2_var3 = "third value" 13 | "Using #{@part2_var3}" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/test_check_all.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "open3" 5 | 6 | class TestCheckAll < Minitest::Test 7 | FIXTURES_PATH = File.expand_path("fixtures/check_all_project", __dir__) 8 | 9 | def setup 10 | Ivar.send(:disable_check_all) 11 | Ivar.clear_analysis_cache 12 | end 13 | 14 | def teardown 15 | Ivar.send(:disable_check_all) 16 | Ivar.clear_analysis_cache 17 | end 18 | 19 | def test_check_all_enables_trace_point 20 | Ivar.check_all 21 | manager = Ivar::CHECK_ALL_MANAGER 22 | 23 | trace_point = manager.trace_point 24 | refute_nil trace_point 25 | assert trace_point.enabled? 26 | end 27 | 28 | def test_disable_check_all 29 | Ivar.check_all 30 | manager = Ivar::CHECK_ALL_MANAGER 31 | 32 | refute_nil manager.trace_point 33 | assert manager.enabled? 34 | 35 | Ivar.send(:disable_check_all) 36 | 37 | assert_nil manager.trace_point 38 | refute manager.enabled? 39 | end 40 | 41 | def test_check_all_with_block_scope 42 | Ivar.check_all do 43 | manager = Ivar::CHECK_ALL_MANAGER 44 | trace_point = manager.trace_point 45 | refute_nil trace_point 46 | assert trace_point.enabled? 47 | end 48 | 49 | manager = Ivar::CHECK_ALL_MANAGER 50 | assert_nil manager.trace_point 51 | refute manager.enabled? 52 | end 53 | 54 | def test_check_all_includes_checked_in_project_classes 55 | script_path = File.join(FIXTURES_PATH, "test_inside_class.rb") 56 | stdout, stderr, status = Open3.capture3("ruby", script_path) 57 | 58 | assert_equal 0, status.exitstatus, "Script failed with: #{stderr}" 59 | assert_includes stdout, "SUCCESS: InsideClass includes Ivar::Checked" 60 | assert_match(/warning.*unknown instance variable @naem/, stderr) 61 | end 62 | 63 | def test_check_all_excludes_outside_classes 64 | script_path = File.join(FIXTURES_PATH, "test_outside_class.rb") 65 | stdout, stderr, status = Open3.capture3("ruby", script_path) 66 | 67 | assert_equal 0, status.exitstatus, "Script failed with: #{stderr}" 68 | assert_includes stdout, "SUCCESS: OutsideClass does not include Ivar::Checked" 69 | refute_match(/warning.*unknown instance variable @naem/, stderr) 70 | end 71 | 72 | def test_check_all_with_block_scope_in_subprocess 73 | script_path = File.join(FIXTURES_PATH, "test_block_scope.rb") 74 | _stdout, stderr, status = Open3.capture3("ruby", script_path, chdir: FIXTURES_PATH) 75 | 76 | assert_equal 0, status.exitstatus, "Script failed with: #{stderr}" 77 | assert_match(/warning.*unknown instance variable @withinclass_naem/, stderr) 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/test_checked.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | class TestChecked < Minitest::Test 6 | def setup 7 | # Clear the cache to ensure a clean test 8 | Ivar.clear_analysis_cache 9 | end 10 | 11 | def test_automatic_check_ivars 12 | # Create a class with a typo in an instance variable 13 | klass = Class.new do 14 | include Ivar::Checked 15 | 16 | def initialize 17 | @correct = "value" 18 | end 19 | 20 | def method_with_typo 21 | @typo_veriable = "misspelled" 22 | end 23 | end 24 | 25 | # Force the analysis to be created and include our method 26 | analysis = Ivar::TargetedPrismAnalysis.new(klass) 27 | # Monkey patch the analysis to include our typo 28 | def analysis.references 29 | [ 30 | {name: :@correct, path: "test_file.rb", line: 1, column: 1, method: :initialize}, 31 | {name: :@typo_veriable, path: "test_file.rb", line: 2, column: 1, method: :method_with_typo} 32 | ] 33 | end 34 | # Replace the cached analysis 35 | Ivar.instance_variable_get(:@analysis_cache)[klass] = analysis 36 | 37 | # Capture stderr output 38 | warnings = capture_stderr do 39 | # Create an instance - this should automatically call check_ivars 40 | klass.new 41 | end 42 | 43 | # Check that we got a warning about the typo 44 | assert_match(/unknown instance variable @typo_veriable/, warnings) 45 | end 46 | 47 | def test_inheritance_with_checked 48 | # Create a parent class with Checked 49 | parent_klass = Class.new do 50 | include Ivar::Checked 51 | 52 | def initialize 53 | @parent_var = "parent" 54 | end 55 | 56 | def parent_method 57 | @parent_typo = "typo" 58 | end 59 | end 60 | 61 | # Create a child class that inherits from parent 62 | child_klass = Class.new(parent_klass) do 63 | def initialize 64 | super 65 | @child_var = "child" 66 | end 67 | 68 | def child_method 69 | @child_typo = "typo" 70 | end 71 | end 72 | 73 | # Force the analysis to be created for parent class 74 | parent_analysis = Ivar::TargetedPrismAnalysis.new(parent_klass) 75 | def parent_analysis.references 76 | [ 77 | {name: :@parent_var, path: "test_file.rb", line: 1, column: 1, method: :initialize}, 78 | {name: :@parent_typo, path: "test_file.rb", line: 2, column: 1, method: :parent_method} 79 | ] 80 | end 81 | Ivar.instance_variable_get(:@analysis_cache)[parent_klass] = parent_analysis 82 | 83 | # Force the analysis to be created for child class 84 | child_analysis = Ivar::TargetedPrismAnalysis.new(child_klass) 85 | def child_analysis.references 86 | [ 87 | {name: :@parent_var, path: "test_file.rb", line: 1, column: 1, method: :initialize}, 88 | {name: :@child_var, path: "test_file.rb", line: 2, column: 1, method: :initialize}, 89 | {name: :@parent_typo, path: "test_file.rb", line: 3, column: 1, method: :parent_method}, 90 | {name: :@child_typo, path: "test_file.rb", line: 4, column: 1, method: :child_method} 91 | ] 92 | end 93 | Ivar.instance_variable_get(:@analysis_cache)[child_klass] = child_analysis 94 | 95 | # Capture stderr output 96 | warnings = capture_stderr do 97 | # Create an instance of the child class - this should automatically call check_ivars 98 | child_klass.new 99 | end 100 | 101 | # Check that we got warnings about the typos 102 | assert_match(/unknown instance variable @parent_typo/, warnings) 103 | assert_match(/unknown instance variable @child_typo/, warnings) 104 | end 105 | 106 | def test_checked_with_warn_policy 107 | # Create a class with a typo in an instance variable 108 | klass = Class.new do 109 | include Ivar::Checked 110 | ivar_check_policy :warn 111 | 112 | def initialize 113 | @correct = "value" 114 | end 115 | 116 | def method_with_typo 117 | @typo_veriable = "misspelled" 118 | end 119 | end 120 | 121 | # Force the analysis to be created and include our method 122 | analysis = Ivar::TargetedPrismAnalysis.new(klass) 123 | # Monkey patch the analysis to include our typo 124 | def analysis.references 125 | [ 126 | {name: :@correct, path: "test_file.rb", line: 1, column: 1, method: :initialize}, 127 | {name: :@typo_veriable, path: "test_file.rb", line: 2, column: 1, method: :method_with_typo} 128 | ] 129 | end 130 | # Replace the cached analysis 131 | Ivar.instance_variable_get(:@analysis_cache)[klass] = analysis 132 | 133 | # First instance - should emit warnings 134 | first_warnings = capture_stderr do 135 | klass.new 136 | end 137 | 138 | # Second instance - should also emit warnings (since Checked doesn't cache) 139 | second_warnings = capture_stderr do 140 | klass.new 141 | end 142 | 143 | # Check that we got warnings for the first instance 144 | assert_match(/unknown instance variable @typo_veriable/, first_warnings) 145 | 146 | # Check that we got warnings for the second instance too (since Checked doesn't cache) 147 | assert_match(/unknown instance variable @typo_veriable/, second_warnings) 148 | end 149 | 150 | def test_checked_with_warn_once_policy_warns_only_once_per_class 151 | # Create a class with a typo in an instance variable 152 | klass = Class.new do 153 | include Ivar::Checked 154 | ivar_check_policy :warn_once 155 | 156 | def initialize 157 | @correct = "value" 158 | end 159 | 160 | def method_with_typo 161 | @typo_veriable = "misspelled" 162 | end 163 | end 164 | 165 | # Force the analysis to be created and include our method 166 | analysis = Ivar::TargetedPrismAnalysis.new(klass) 167 | # Monkey patch the analysis to include our typo 168 | def analysis.references 169 | [ 170 | {name: :@correct, path: "test_file.rb", line: 1, column: 1, method: :initialize}, 171 | {name: :@typo_veriable, path: "test_file.rb", line: 2, column: 1, method: :method_with_typo} 172 | ] 173 | end 174 | # Replace the cached analysis 175 | Ivar.instance_variable_get(:@analysis_cache)[klass] = analysis 176 | 177 | # First instance - should emit warnings 178 | first_warnings = capture_stderr do 179 | klass.new 180 | end 181 | 182 | # Second instance - should not emit warnings 183 | second_warnings = capture_stderr do 184 | klass.new 185 | end 186 | 187 | # Check that we got warnings for the first instance 188 | assert_match(/unknown instance variable @typo_veriable/, first_warnings) 189 | 190 | # Check that we didn't get warnings for the second instance 191 | assert_empty second_warnings 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /test/test_checked_integration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | require_relative "fixtures/sandwich_with_checked_ivars" 5 | require_relative "fixtures/parent_with_checked_ivars" 6 | require_relative "fixtures/child_with_checked_ivars" 7 | 8 | class TestCheckedIntegration < Minitest::Test 9 | def setup 10 | # Clear the cache to ensure a clean test 11 | Ivar.clear_analysis_cache 12 | end 13 | 14 | def test_sandwich_with_checked_ivars 15 | # Capture stderr output 16 | warnings = capture_stderr do 17 | # Create a sandwich with Checked which should trigger warnings 18 | SandwichWithCheckedIvars.new 19 | end 20 | 21 | # Check that we got warnings about the typo in the code 22 | assert_match(/unknown instance variable @chese/, warnings) 23 | 24 | # Check that we didn't get warnings about defined variables 25 | refute_match(/unknown instance variable @bread/, warnings) 26 | refute_match(/unknown instance variable @cheese/, warnings) 27 | refute_match(/unknown instance variable @condiments/, warnings) 28 | end 29 | 30 | def test_parent_child_with_checked_ivars 31 | # Clear the cache to ensure a clean test 32 | Ivar.clear_analysis_cache 33 | 34 | # Capture stderr output 35 | warnings = capture_stderr do 36 | # Create a child instance which should trigger warnings 37 | ChildWithCheckedIvars.new 38 | end 39 | 40 | # Check that we got warnings about the typo in the child class 41 | assert_match(/unknown instance variable @chyld_var3/, warnings) 42 | end 43 | 44 | def test_checked_only_warns_once_per_class 45 | # Capture stderr output for first instance 46 | first_warnings = capture_stderr do 47 | # Create first sandwich instance 48 | SandwichWithCheckedIvars.new 49 | end 50 | 51 | # Capture stderr output for second instance 52 | second_warnings = capture_stderr do 53 | # Create second sandwich instance 54 | SandwichWithCheckedIvars.new 55 | end 56 | 57 | # Check that we got warnings for the first instance 58 | assert_match(/unknown instance variable @chese/, first_warnings) 59 | 60 | # Check that we didn't get warnings for the second instance 61 | assert_empty second_warnings 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/test_checked_once_integration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | require_relative "fixtures/sandwich_with_checked_once" 5 | 6 | class TestCheckedOnceIntegration < Minitest::Test 7 | def setup 8 | Ivar.clear_analysis_cache 9 | end 10 | 11 | def test_sandwich_with_checked_once 12 | warnings = capture_stderr do 13 | SandwichWithCheckedOnce.new 14 | end 15 | 16 | assert_match(/unknown instance variable @chese/, warnings) 17 | refute_match(/unknown instance variable @bread/, warnings) 18 | refute_match(/unknown instance variable @cheese/, warnings) 19 | refute_match(/unknown instance variable @condiments/, warnings) 20 | end 21 | 22 | def test_checked_once_only_warns_once 23 | first_warnings = capture_stderr do 24 | SandwichWithCheckedOnce.new 25 | end 26 | 27 | second_warnings = capture_stderr do 28 | SandwichWithCheckedOnce.new 29 | end 30 | 31 | assert_match(/unknown instance variable @chese/, first_warnings) 32 | assert_empty second_warnings 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/test_class_level_ivars.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | class TestClassLevelIvars < Minitest::Test 6 | def setup 7 | # Clear the cache to ensure a clean test 8 | Ivar.clear_analysis_cache 9 | end 10 | 11 | def test_class_level_ivars_in_class_methods_do_not_trigger_warnings 12 | # Create a class with class-level instance variables used in class methods 13 | klass = Class.new do 14 | extend Ivar::Macros 15 | include Ivar::Checked 16 | 17 | # Class-level instance variable 18 | @class_level_var = "class value" 19 | 20 | # Declare the class-level instance variable to prevent warnings 21 | ivar :@class_level_var 22 | 23 | # Class method that uses the class-level instance variable 24 | def self.get_class_var 25 | @class_level_var 26 | end 27 | 28 | # Instance method that uses an instance variable with the same name 29 | def get_instance_var 30 | @class_level_var = "instance value" 31 | @class_level_var 32 | end 33 | end 34 | 35 | # Force the analysis to be created for the class 36 | analysis = Ivar::TargetedPrismAnalysis.new(klass) 37 | # Monkey patch the analysis to include our variables 38 | def analysis.references 39 | [ 40 | {name: :@class_level_var, path: "test_file.rb", line: 1, column: 1} 41 | ] 42 | end 43 | # Replace the cached analysis 44 | Ivar.instance_variable_get(:@analysis_cache)[klass] = analysis 45 | 46 | # Capture stderr output when creating an instance 47 | warnings = capture_stderr do 48 | instance = klass.new 49 | # Call the instance method to ensure the instance variable is used 50 | instance.get_instance_var 51 | end 52 | 53 | # Check that we didn't get warnings about the class-level instance variable 54 | refute_match(/unknown instance variable @class_level_var/, warnings) 55 | 56 | # Verify that the class method can access the class-level instance variable 57 | assert_equal "class value", klass.get_class_var 58 | end 59 | 60 | def test_module_level_ivars_in_module_methods_do_not_trigger_warnings 61 | # Create a module with module-level instance variables used in module methods 62 | mod = Module.new do 63 | # Module-level instance variable 64 | @module_level_var = "module value" 65 | 66 | # Module method that uses the module-level instance variable 67 | def self.get_module_var 68 | @module_level_var 69 | end 70 | 71 | # Define a method that will be included in classes 72 | def module_method 73 | # This is an instance method in the including class 74 | @instance_var = "instance value from module method" 75 | end 76 | end 77 | 78 | # Create a class that includes the module and uses Ivar::Checked 79 | klass = Class.new do 80 | include mod 81 | include Ivar::Checked 82 | 83 | # Declare the instance variable to prevent warnings 84 | ivar :@instance_var 85 | 86 | def initialize 87 | module_method 88 | end 89 | end 90 | 91 | # Force the analysis to be created for the class 92 | analysis = Ivar::TargetedPrismAnalysis.new(klass) 93 | # Monkey patch the analysis to include our variables 94 | def analysis.references 95 | [ 96 | {name: :@instance_var, path: "test_file.rb", line: 1, column: 1} 97 | ] 98 | end 99 | # Replace the cached analysis 100 | Ivar.instance_variable_get(:@analysis_cache)[klass] = analysis 101 | 102 | # Capture stderr output when creating an instance 103 | warnings = capture_stderr do 104 | klass.new 105 | end 106 | 107 | # Check that we didn't get warnings about the instance variable 108 | refute_match(/unknown instance variable @instance_var/, warnings) 109 | 110 | # Verify that the module method can access the module-level instance variable 111 | assert_equal "module value", mod.get_module_var 112 | end 113 | 114 | def test_class_with_multiple_class_methods_using_class_ivars 115 | # Create a class with multiple class methods using class instance variables 116 | klass = Class.new do 117 | extend Ivar::Macros 118 | include Ivar::Checked 119 | 120 | # Class-level instance variables 121 | @config = {} 122 | @initialized = false 123 | 124 | # Declare the class-level instance variables 125 | ivar :@config, :@initialized 126 | 127 | # Class method that sets a class-level instance variable 128 | def self.configure(options) 129 | @config = options 130 | @initialized = true 131 | end 132 | 133 | # Class method that reads class-level instance variables 134 | def self.configuration 135 | { 136 | config: @config, 137 | initialized: @initialized 138 | } 139 | end 140 | 141 | # Declare the instance variable 142 | ivar :@instance_var 143 | 144 | # Instance method that doesn't use class-level variables 145 | def instance_method 146 | @instance_var = "instance value" 147 | @instance_var 148 | end 149 | end 150 | 151 | # Configure the class 152 | klass.configure(api_key: "secret", timeout: 30) 153 | 154 | # Force the analysis to be created for the class 155 | analysis = Ivar::TargetedPrismAnalysis.new(klass) 156 | # Monkey patch the analysis to include our variables 157 | def analysis.references 158 | [ 159 | {name: :@instance_var, path: "test_file.rb", line: 1, column: 1} 160 | ] 161 | end 162 | # Replace the cached analysis 163 | Ivar.instance_variable_get(:@analysis_cache)[klass] = analysis 164 | 165 | # Capture stderr output when creating an instance 166 | warnings = capture_stderr do 167 | instance = klass.new 168 | # Call the instance method to ensure the instance variable is used 169 | instance.instance_method 170 | end 171 | 172 | # Check that we didn't get warnings about any instance variables 173 | refute_match(/unknown instance variable/, warnings) 174 | 175 | # Verify that the class methods can access the class-level instance variables 176 | config = klass.configuration 177 | assert_equal({api_key: "secret", timeout: 30}, config[:config]) 178 | assert_equal true, config[:initialized] 179 | end 180 | end 181 | -------------------------------------------------------------------------------- /test/test_class_method_ivars.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | class TestClassMethodIvars < Minitest::Test 6 | def setup 7 | # Clear the cache to ensure a clean test 8 | Ivar.clear_analysis_cache 9 | end 10 | 11 | def test_class_method_ivars_do_not_trigger_warnings 12 | # Create a class with class methods that reference class-level instance variables 13 | klass = Class.new do 14 | include Ivar::Checked 15 | 16 | # Class-level instance variables 17 | @config = {} 18 | @initialized = false 19 | 20 | # Class method that uses class-level instance variables 21 | def self.configure(options) 22 | @config = options 23 | @initialized = true 24 | end 25 | 26 | # Another class method that uses class-level instance variables 27 | def self.configuration 28 | { 29 | config: @config, 30 | initialized: @initialized 31 | } 32 | end 33 | 34 | # Instance method that doesn't use class-level variables 35 | def instance_method 36 | @instance_var = "instance value" 37 | end 38 | end 39 | 40 | # Configure the class 41 | klass.configure(api_key: "secret", timeout: 30) 42 | 43 | # Capture stderr output when creating an instance 44 | warnings = capture_stderr do 45 | klass.new 46 | end 47 | 48 | # Check that we didn't get warnings about the class-level instance variables 49 | refute_match(/unknown instance variable @config/, warnings) 50 | refute_match(/unknown instance variable @initialized/, warnings) 51 | # But we should get a warning about the undeclared instance variable 52 | assert_match(/unknown instance variable @instance_var/, warnings) 53 | 54 | # Verify that the class methods can access the class-level instance variables 55 | config = klass.configuration 56 | assert_equal({api_key: "secret", timeout: 30}, config[:config]) 57 | assert_equal true, config[:initialized] 58 | end 59 | 60 | def test_instance_method_with_same_name_as_class_ivar_triggers_warning 61 | # Create a class with both class-level and instance-level variables with the same name 62 | klass = Class.new do 63 | extend Ivar::Macros 64 | include Ivar::Checked 65 | 66 | # Set the check policy to warn (not warn_once) to ensure we get warnings 67 | ivar_check_policy :warn 68 | 69 | # Class-level instance variable 70 | @shared_var = "class value" 71 | 72 | # Class method that uses the class-level instance variable 73 | def self.get_class_var 74 | @shared_var 75 | end 76 | 77 | # Instance method that uses an instance variable with the same name 78 | # but doesn't declare it - should trigger a warning 79 | def get_instance_var 80 | @shared_var 81 | end 82 | end 83 | 84 | # Force the analysis to be created for the class with our custom references 85 | analysis = Ivar::TargetedPrismAnalysis.new(klass) 86 | # Monkey patch the analysis to include our variables with context 87 | def analysis.references 88 | [ 89 | # Class method reference (should be ignored) 90 | {name: :@shared_var, path: "test_file.rb", line: 1, column: 1, context: :class}, 91 | # Instance method reference (should trigger warning) 92 | {name: :@shared_var, path: "test_file.rb", line: 2, column: 1, context: :instance} 93 | ] 94 | end 95 | # Replace the cached analysis 96 | Ivar.instance_variable_get(:@analysis_cache)[klass] = analysis 97 | 98 | # Capture stderr output when creating an instance 99 | warnings = capture_stderr do 100 | # Create an instance and call the method to ensure the instance variable is used 101 | klass.new.get_instance_var 102 | end 103 | 104 | # Check that we got warnings about the undeclared instance variable 105 | assert_match(/unknown instance variable @shared_var/, warnings) 106 | 107 | # Verify that the class method can access the class-level instance variable 108 | assert_equal "class value", klass.get_class_var 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | require "ivar" 5 | require "stringio" 6 | 7 | require "minitest/autorun" 8 | 9 | # Helper method to capture stderr during a block 10 | def capture_stderr 11 | original_stderr = $stderr 12 | $stderr = StringIO.new 13 | yield 14 | $stderr.string 15 | ensure 16 | $stderr = original_stderr 17 | end 18 | -------------------------------------------------------------------------------- /test/test_ivar.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | require_relative "fixtures/sandwich" 5 | require_relative "fixtures/split_class" 6 | require_relative "fixtures/sandwich_with_validation" 7 | 8 | # These fixtures will be uncommented when the corresponding modules are implemented 9 | require_relative "fixtures/sandwich_with_checked_ivars" 10 | require_relative "fixtures/parent_with_checked_ivars" 11 | require_relative "fixtures/child_with_checked_ivars" 12 | 13 | class TestIvar < Minitest::Test 14 | def test_ivar_analysis 15 | analysis = Ivar::TargetedPrismAnalysis.new(Sandwich) 16 | assert_equal %i[@bread @cheese @chese @condiments @side], analysis.ivars 17 | end 18 | 19 | def test_ivar_analysis_with_split_class 20 | analysis = Ivar::TargetedPrismAnalysis.new(SplitClass) 21 | expected_ivars = %i[@part1_var1 @part1_var2 @part2_var1 @part2_var2 @part2_var3] 22 | assert_equal expected_ivars, analysis.ivars 23 | end 24 | 25 | def test_ivar_references 26 | analysis = Ivar::TargetedPrismAnalysis.new(Sandwich) 27 | references = analysis.ivar_references 28 | 29 | assert_equal 8, references.size 30 | 31 | references.each do |ref| 32 | assert_includes ref, :name 33 | assert_includes ref, :path 34 | assert_includes ref, :line 35 | assert_includes ref, :column 36 | end 37 | 38 | bread_refs = references.select { |ref| ref[:name] == :@bread } 39 | assert_equal 2, bread_refs.size 40 | 41 | chese_ref = references.find { |ref| ref[:name] == :@chese } 42 | assert_equal :@chese, chese_ref[:name] 43 | assert chese_ref[:path].end_with?("sandwich.rb") 44 | assert_equal 12, chese_ref[:line] 45 | assert_equal 42, chese_ref[:column] 46 | end 47 | 48 | # Tests for inheritance will be added here when implemented 49 | 50 | def setup_analysis_cache 51 | Ivar.clear_analysis_cache 52 | end 53 | 54 | def test_get_analysis_returns_prism_analysis 55 | setup_analysis_cache 56 | analysis = Ivar.get_analysis(Sandwich) 57 | assert_instance_of Ivar::TargetedPrismAnalysis, analysis 58 | assert_equal %i[@bread @cheese @chese @condiments @side], analysis.ivars 59 | end 60 | 61 | def test_get_analysis_caches_results 62 | setup_analysis_cache 63 | first_analysis = Ivar.get_analysis(Sandwich) 64 | second_analysis = Ivar.get_analysis(Sandwich) 65 | assert_equal first_analysis.object_id, second_analysis.object_id 66 | end 67 | 68 | def test_get_analysis_creates_separate_cache_entries_for_different_classes 69 | setup_analysis_cache 70 | sandwich_analysis = Ivar.get_analysis(Sandwich) 71 | split_class_analysis = Ivar.get_analysis(SplitClass) 72 | 73 | refute_equal sandwich_analysis.object_id, split_class_analysis.object_id 74 | assert_equal %i[@bread @cheese @chese @condiments @side], sandwich_analysis.ivars 75 | assert_equal %i[@part1_var1 @part1_var2 @part2_var1 @part2_var2 @part2_var3], split_class_analysis.ivars 76 | 77 | second_split_class_analysis = Ivar.get_analysis(SplitClass) 78 | assert_equal split_class_analysis.object_id, second_split_class_analysis.object_id 79 | end 80 | 81 | def test_check_ivars_warns_about_unknown_variables 82 | warnings = capture_stderr do 83 | klass = Class.new do 84 | include Ivar::Validation 85 | 86 | def initialize 87 | @known_var = "known" 88 | check_ivars(add: [:@allowed_var]) 89 | end 90 | 91 | def method_with_typo 92 | @unknown_var = "unknown" 93 | end 94 | 95 | def method_with_typo 96 | @unknown_var = "unknown" 97 | end 98 | end 99 | 100 | instance = klass.new 101 | 102 | instance.check_ivars(add: [:@allowed_var]) 103 | end 104 | 105 | assert_match(/unknown instance variable @unknown_var/, warnings) 106 | refute_match(/unknown instance variable @known_var/, warnings) 107 | refute_match(/unknown instance variable @allowed_var/, warnings) 108 | end 109 | 110 | def test_check_ivars_suggests_corrections 111 | warnings = capture_stderr do 112 | klass = Class.new do 113 | include Ivar::Validation 114 | 115 | def initialize 116 | @correct = "value" 117 | check_ivars 118 | end 119 | 120 | def method_with_typo 121 | @typo_veriable = "misspelled" 122 | end 123 | 124 | def method_with_typo 125 | @typo_veriable = "misspelled" 126 | end 127 | end 128 | 129 | instance = klass.new 130 | 131 | instance.check_ivars 132 | end 133 | 134 | assert_match(/unknown instance variable @typo_veriable/, warnings) 135 | 136 | typo_warnings = warnings.scan(/unknown instance variable @typo_veriable/).count 137 | assert typo_warnings >= 1, "Should have at least one warning for @typo_veriable" 138 | end 139 | 140 | def test_thread_safety_of_analysis_cache 141 | setup_analysis_cache 142 | 143 | test_classes = 10.times.map do |i| 144 | Class.new do 145 | define_method(:initialize) do 146 | instance_variable_set(:"@var_#{i}", "value") 147 | end 148 | end 149 | end 150 | 151 | threads = 5.times.map do 152 | Thread.new do 153 | test_classes.shuffle.each do |klass| 154 | analysis = Ivar.get_analysis(klass) 155 | assert_instance_of Ivar::TargetedPrismAnalysis, analysis 156 | end 157 | end 158 | end 159 | 160 | threads.each(&:join) 161 | 162 | test_classes.each do |klass| 163 | assert Ivar.instance_variable_get(:@analysis_cache).key?(klass) 164 | end 165 | end 166 | 167 | def test_thread_safety_of_checked_classes 168 | setup_analysis_cache 169 | 170 | test_classes = 10.times.map do |_i| 171 | Class.new 172 | end 173 | 174 | threads = 5.times.map do 175 | Thread.new do 176 | test_classes.shuffle.each do |klass| 177 | Ivar.mark_class_checked(klass) 178 | assert Ivar.class_checked?(klass) 179 | end 180 | end 181 | end 182 | 183 | threads.each(&:join) 184 | 185 | test_classes.each do |klass| 186 | assert Ivar.class_checked?(klass) 187 | end 188 | end 189 | end 190 | -------------------------------------------------------------------------------- /test/test_ivar_attr_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | class TestIvarAttrMethods < Minitest::Test 6 | def setup 7 | # Clear the cache to ensure a clean test 8 | Ivar.clear_analysis_cache 9 | 10 | # Capture stderr to prevent warnings from appearing in test output 11 | @original_stderr = $stderr 12 | $stderr = StringIO.new 13 | end 14 | 15 | def teardown 16 | # Restore stderr 17 | $stderr = @original_stderr 18 | end 19 | 20 | def test_ivar_with_reader 21 | # Create a class with the ivar macro and reader: true 22 | klass = Class.new do 23 | include Ivar::Checked 24 | 25 | # Declare instance variables with reader 26 | ivar :@foo, :@bar, reader: true, value: "initial" 27 | 28 | def initialize 29 | # Modify one of the variables 30 | @foo = "modified" 31 | end 32 | end 33 | 34 | # Create an instance 35 | instance = klass.new 36 | 37 | # Check that the reader methods were created and work correctly 38 | assert_equal "modified", instance.foo 39 | assert_equal "initial", instance.bar 40 | end 41 | 42 | def test_ivar_with_writer 43 | # Create a class with the ivar macro and writer: true 44 | klass = Class.new do 45 | include Ivar::Checked 46 | 47 | # Declare instance variables with writer 48 | ivar :@foo, :@bar, writer: true, value: "initial" 49 | 50 | def initialize 51 | # No modifications 52 | end 53 | 54 | # Add readers for testing 55 | attr_reader :foo 56 | 57 | attr_reader :bar 58 | end 59 | 60 | # Create an instance 61 | instance = klass.new 62 | 63 | # Check that the writer methods were created and work correctly 64 | instance.foo = "modified foo" 65 | instance.bar = "modified bar" 66 | 67 | assert_equal "modified foo", instance.foo 68 | assert_equal "modified bar", instance.bar 69 | end 70 | 71 | def test_ivar_with_accessor 72 | # Create a class with the ivar macro and accessor: true 73 | klass = Class.new do 74 | include Ivar::Checked 75 | 76 | # Declare instance variables with accessor 77 | ivar :@foo, :@bar, accessor: true, value: "initial" 78 | 79 | def initialize 80 | # Modify one of the variables 81 | @foo = "modified in initialize" 82 | end 83 | end 84 | 85 | # Create an instance 86 | instance = klass.new 87 | 88 | # Check that the reader methods were created and work correctly 89 | assert_equal "modified in initialize", instance.foo 90 | assert_equal "initial", instance.bar 91 | 92 | # Check that the writer methods were created and work correctly 93 | instance.foo = "modified by writer" 94 | instance.bar = "modified by writer" 95 | 96 | assert_equal "modified by writer", instance.foo 97 | assert_equal "modified by writer", instance.bar 98 | end 99 | 100 | def test_ivar_with_hash_syntax_and_accessor 101 | # Create a class with the ivar macro using hash syntax and accessor: true 102 | klass = Class.new do 103 | include Ivar::Checked 104 | 105 | # Declare instance variables with accessor using hash syntax 106 | ivar "@foo": "foo value", "@bar": "bar value", accessor: true 107 | 108 | def initialize 109 | # No modifications 110 | end 111 | end 112 | 113 | # Create an instance 114 | instance = klass.new 115 | 116 | # Check that the reader methods were created and work correctly 117 | assert_equal "foo value", instance.foo 118 | assert_equal "bar value", instance.bar 119 | 120 | # Check that the writer methods were created and work correctly 121 | instance.foo = "new foo" 122 | instance.bar = "new bar" 123 | 124 | assert_equal "new foo", instance.foo 125 | assert_equal "new bar", instance.bar 126 | end 127 | 128 | def test_ivar_with_block_and_reader 129 | # Create a class with the ivar macro using a block and reader: true 130 | klass = Class.new do 131 | include Ivar::Checked 132 | 133 | # Declare instance variables with a block and reader 134 | ivar(:@foo, :@bar, reader: true) { |varname| "#{varname} value" } 135 | 136 | def initialize 137 | # No modifications 138 | end 139 | end 140 | 141 | # Create an instance 142 | instance = klass.new 143 | 144 | # Check that the reader methods were created and work correctly 145 | assert_equal "@foo value", instance.foo 146 | assert_equal "@bar value", instance.bar 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /test/test_ivar_macros.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | class TestMacros < Minitest::Test 6 | def setup 7 | # Clear the cache to ensure a clean test 8 | Ivar.clear_analysis_cache 9 | 10 | # Capture stderr to prevent warnings from appearing in test output 11 | @original_stderr = $stderr 12 | $stderr = StringIO.new 13 | end 14 | 15 | def teardown 16 | # Restore stderr 17 | $stderr = @original_stderr 18 | end 19 | 20 | def test_ivar_macro_declares_variables 21 | # Create a class with the ivar macro 22 | klass = Class.new do 23 | include Ivar::Checked 24 | 25 | # Declare variables that might be referenced before being set 26 | ivar :@declared_var 27 | 28 | def initialize 29 | # We don't set @declared_var here 30 | # But we do set these normal variables 31 | @normal_var1 = "normal1" 32 | @normal_var2 = "normal2" 33 | end 34 | 35 | def method_with_vars 36 | # This should be undefined, not nil 37 | defined?(@declared_var) ? @declared_var : "undefined" 38 | end 39 | end 40 | 41 | # Create an instance 42 | instance = klass.new 43 | 44 | # Check that the declared variable is undefined (not nil) 45 | # This is the key change in behavior 46 | value = instance.method_with_vars 47 | assert_equal "undefined", value, "@declared_var should be undefined" 48 | end 49 | 50 | def test_ivar_macro_with_checked_and_warn_once_policy 51 | # Create a class with the ivar macro 52 | klass = Class.new do 53 | include Ivar::Checked 54 | ivar_check_policy :warn_once 55 | 56 | ivar :@declared_var 57 | 58 | def initialize 59 | # We don't set @declared_var here 60 | @normal_var = "normal" 61 | end 62 | 63 | def method_with_declared_var 64 | # This should be undefined, not nil 65 | [defined?(@declared_var) ? @declared_var : "undefined", @normal_var] 66 | end 67 | end 68 | 69 | # Create an instance 70 | instance = klass.new 71 | 72 | # Check that the declared variable is undefined (not nil) 73 | values = instance.method_with_declared_var 74 | assert_equal "undefined", values[0], "@declared_var should be undefined" 75 | assert_equal "normal", values[1], "@normal_var should be 'normal'" 76 | end 77 | 78 | def test_ivar_macro_with_inheritance 79 | # Create a parent class with the ivar macro 80 | parent_klass = Class.new do 81 | include Ivar::Checked 82 | 83 | ivar :@parent_declared_var 84 | 85 | def initialize 86 | @parent_normal_var = "parent normal" 87 | end 88 | end 89 | 90 | # Create a child class that inherits the ivar macro 91 | child_klass = Class.new(parent_klass) do 92 | ivar :@child_declared_var 93 | 94 | def initialize 95 | super 96 | @child_normal_var = "child normal" 97 | end 98 | 99 | def method_with_declared_vars 100 | [ 101 | defined?(@parent_declared_var) ? @parent_declared_var : "undefined parent", 102 | @parent_normal_var, 103 | defined?(@child_declared_var) ? @child_declared_var : "undefined child", 104 | @child_normal_var 105 | ] 106 | end 107 | end 108 | 109 | # Create an instance of the child class 110 | instance = child_klass.new 111 | 112 | # Check that declared variables are undefined but don't cause warnings 113 | values = instance.method_with_declared_vars 114 | assert_equal "undefined parent", values[0], "@parent_declared_var should be undefined" 115 | assert_equal "parent normal", values[1], "@parent_normal_var should be 'parent normal'" 116 | assert_equal "undefined child", values[2], "@child_declared_var should be undefined" 117 | assert_equal "child normal", values[3], "@child_normal_var should be 'child normal'" 118 | end 119 | 120 | def test_ivar_macro_prevents_warnings 121 | # Create a class with a declared instance variable 122 | klass = Class.new do 123 | include Ivar::Checked 124 | 125 | ivar :@declared_var 126 | 127 | def initialize 128 | @normal_var = "normal" 129 | end 130 | 131 | def method_with_declared_var 132 | # This should not trigger a warning because it's declared 133 | # Even though it's not initialized 134 | @declared_var = "value" 135 | # This would trigger a warning if it wasn't declared 136 | @declared_var.upcase 137 | end 138 | end 139 | 140 | # Force the analysis to be created and include our method 141 | analysis = Ivar::TargetedPrismAnalysis.new(klass) 142 | # Monkey patch the analysis to include our variables 143 | def analysis.references 144 | [ 145 | {name: :@normal_var, path: "test_file.rb", line: 1, column: 1, method: :initialize}, 146 | {name: :@declared_var, path: "test_file.rb", line: 2, column: 1, method: :method_with_declared_var} 147 | ] 148 | end 149 | # Replace the cached analysis 150 | Ivar.instance_variable_get(:@analysis_cache)[klass] = analysis 151 | 152 | # Capture stderr output when creating an instance 153 | warnings = capture_stderr do 154 | # Create an instance - this should automatically call check_ivars 155 | klass.new 156 | end 157 | 158 | # Check that we didn't get warnings about the declared variable 159 | refute_match(/unknown instance variable @declared_var/, warnings) 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /test/test_ivar_with_kwarg_init.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | class TestIvarWithKwargInit < Minitest::Test 6 | def setup 7 | # Clear the cache to ensure a clean test 8 | Ivar.clear_analysis_cache 9 | 10 | # Capture stderr to prevent warnings from appearing in test output 11 | @original_stderr = $stderr 12 | $stderr = StringIO.new 13 | end 14 | 15 | def teardown 16 | # Restore stderr 17 | $stderr = @original_stderr 18 | end 19 | 20 | def test_ivar_with_kwarg_init 21 | # Create a class with the ivar macro and init: :kwarg 22 | klass = Class.new do 23 | include Ivar::Checked 24 | 25 | # Declare instance variables with kwarg initialization 26 | ivar :@foo, init: :kwarg 27 | 28 | # Track what keywords are received by initialize 29 | attr_reader :received_kwargs 30 | 31 | def initialize(bar: nil) 32 | # The foo keyword should be "peeled off" and not passed to this method 33 | # but bar should be passed through 34 | @received_kwargs = { 35 | foo: binding.local_variable_defined?(:foo) ? :received : :not_received, 36 | bar: binding.local_variable_defined?(:bar) ? bar : :not_received 37 | } 38 | end 39 | 40 | def foo_value 41 | @foo 42 | end 43 | end 44 | 45 | # Create an instance with both keywords 46 | instance = klass.new(foo: "from kwarg", bar: "passed through") 47 | 48 | # Check that the instance variable was initialized from the keyword argument 49 | assert_equal "from kwarg", instance.foo_value 50 | 51 | # Check that foo was peeled off and not passed to initialize 52 | assert_equal :not_received, instance.received_kwargs[:foo] 53 | 54 | # Check that bar was passed through to initialize 55 | assert_equal "passed through", instance.received_kwargs[:bar] 56 | end 57 | 58 | def test_ivar_with_keyword_init 59 | # Create a class with the ivar macro and init: :keyword (alias for :kwarg) 60 | klass = Class.new do 61 | include Ivar::Checked 62 | 63 | # Declare instance variables with keyword initialization 64 | ivar :@bar, init: :keyword 65 | 66 | def initialize 67 | # The value should be set from the keyword argument 68 | # before this method is called 69 | end 70 | 71 | def bar_value 72 | @bar 73 | end 74 | end 75 | 76 | # Create an instance with the keyword argument 77 | instance = klass.new(bar: "from keyword") 78 | 79 | # Check that the instance variable was initialized from the keyword argument 80 | assert_equal "from keyword", instance.bar_value 81 | end 82 | 83 | def test_ivar_with_kwarg_init_default_value 84 | # Create a class with the ivar macro and init: :kwarg 85 | klass = Class.new do 86 | include Ivar::Checked 87 | 88 | # Declare instance variables with kwarg initialization and a value 89 | ivar :@foo, init: :kwarg, value: "default value" 90 | 91 | def initialize 92 | # If the keyword argument is not provided, it should use the value from ivar 93 | end 94 | 95 | def foo_value 96 | @foo 97 | end 98 | end 99 | 100 | # Create an instance without the keyword argument 101 | instance = klass.new 102 | 103 | # Check that the instance variable was initialized with the default value from ivar 104 | assert_equal "default value", instance.foo_value 105 | end 106 | 107 | def test_ivar_with_kwarg_init_and_value 108 | # Create a class with the ivar macro, init: :kwarg, and a value 109 | klass = Class.new do 110 | include Ivar::Checked 111 | 112 | # Declare instance variables with kwarg initialization and a default value 113 | ivar :@foo, init: :kwarg, value: "initial value" 114 | 115 | def initialize 116 | # The value should be set from the keyword argument if provided, 117 | # otherwise it should use the initial value 118 | end 119 | 120 | def foo_value 121 | @foo 122 | end 123 | end 124 | 125 | # Create an instance with the keyword argument 126 | instance_with_kwarg = klass.new(foo: "from kwarg") 127 | assert_equal "from kwarg", instance_with_kwarg.foo_value 128 | 129 | # Create an instance without the keyword argument 130 | instance_without_kwarg = klass.new 131 | assert_equal "initial value", instance_without_kwarg.foo_value 132 | end 133 | 134 | def test_ivar_with_kwarg_init_inheritance 135 | # Create a parent class with kwarg initialization 136 | parent_klass = Class.new do 137 | include Ivar::Checked 138 | 139 | # Declare instance variables with kwarg initialization 140 | ivar :@parent_var, init: :kwarg 141 | 142 | def initialize 143 | # The value should be set from the keyword argument 144 | end 145 | 146 | def parent_var_value 147 | @parent_var 148 | end 149 | end 150 | 151 | # Create a child class that inherits and adds its own kwarg initialization 152 | child_klass = Class.new(parent_klass) do 153 | # Declare instance variables with kwarg initialization 154 | ivar :@child_var, init: :kwarg 155 | 156 | def initialize 157 | # Call parent initialize first 158 | super 159 | end 160 | 161 | def child_var_value 162 | @child_var 163 | end 164 | end 165 | 166 | # Create an instance with both keyword arguments 167 | instance = child_klass.new(parent_var: "parent value", child_var: "child value") 168 | 169 | # Check that both instance variables were initialized from keyword arguments 170 | assert_equal "parent value", instance.parent_var_value 171 | assert_equal "child value", instance.child_var_value 172 | end 173 | 174 | def test_ivar_with_multiple_kwarg_init 175 | # Create a class with multiple ivars using kwarg initialization 176 | klass = Class.new do 177 | include Ivar::Checked 178 | 179 | # Declare multiple instance variables with kwarg initialization 180 | ivar :@foo, :@bar, :@baz, init: :kwarg 181 | 182 | def initialize 183 | # All values should be set from keyword arguments 184 | end 185 | 186 | def values 187 | [@foo, @bar, @baz] 188 | end 189 | end 190 | 191 | # Create an instance with all keyword arguments 192 | instance = klass.new(foo: "foo value", bar: "bar value", baz: "baz value") 193 | 194 | # Check that all instance variables were initialized from keyword arguments 195 | assert_equal ["foo value", "bar value", "baz value"], instance.values 196 | end 197 | 198 | def test_ivar_with_kwarg_init_and_accessor 199 | # Create a class with kwarg initialization and accessor 200 | klass = Class.new do 201 | include Ivar::Checked 202 | 203 | # Declare instance variables with kwarg initialization and accessor 204 | ivar :@foo, :@bar, init: :kwarg, accessor: true 205 | 206 | def initialize 207 | # Values should be set from keyword arguments 208 | end 209 | end 210 | 211 | # Create an instance with keyword arguments 212 | instance = klass.new(foo: "foo value", bar: "bar value") 213 | 214 | # Check that the accessor methods work correctly 215 | assert_equal "foo value", instance.foo 216 | assert_equal "bar value", instance.bar 217 | 218 | # Check that the writer methods work correctly 219 | instance.foo = "new foo" 220 | instance.bar = "new bar" 221 | 222 | assert_equal "new foo", instance.foo 223 | assert_equal "new bar", instance.bar 224 | end 225 | end 226 | -------------------------------------------------------------------------------- /test/test_ivar_with_kwarg_init_inheritance.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestIvarWithKwargInitInheritance < Minitest::Test 6 | def setup 7 | # Clear the cache to ensure a clean test 8 | Ivar.clear_analysis_cache 9 | end 10 | 11 | def test_ivar_with_kwarg_init_inheritance_defaults_and_overrides 12 | # Parent class with kwarg initialization and defaults 13 | parent_class = Class.new do 14 | include Ivar::Checked 15 | 16 | # Declare instance variables with kwarg initialization and defaults 17 | ivar :@parent_var1, init: :kwarg, value: "parent default 1" 18 | ivar :@parent_var2, init: :kwarg, value: "parent default 2" 19 | ivar :@shared_var, init: :kwarg, value: "parent shared default" 20 | 21 | def initialize(extra_arg: nil) 22 | @extra_parent = extra_arg 23 | end 24 | 25 | def values 26 | { 27 | parent_var1: @parent_var1, 28 | parent_var2: @parent_var2, 29 | shared_var: @shared_var, 30 | extra_parent: @extra_parent 31 | } 32 | end 33 | end 34 | 35 | # Child class that inherits and adds its own kwarg initialization 36 | child_class = Class.new(parent_class) do 37 | # Declare child-specific instance variables with kwarg initialization 38 | ivar :@child_var1, init: :kwarg, value: "child default 1" 39 | ivar :@child_var2, init: :kwarg, value: "child default 2" 40 | 41 | # Override a parent variable with a different default 42 | ivar :@shared_var, init: :kwarg, value: "child shared default" 43 | 44 | def initialize(child_extra: nil, **kwargs) 45 | @child_extra = child_extra 46 | super(**kwargs) 47 | end 48 | 49 | def all_values 50 | parent_values = values 51 | parent_values.merge({ 52 | child_var1: @child_var1, 53 | child_var2: @child_var2, 54 | child_extra: @child_extra 55 | }) 56 | end 57 | end 58 | 59 | # Test 1: Create instance with defaults only 60 | instance1 = child_class.new 61 | expected1 = { 62 | parent_var1: "parent default 1", 63 | parent_var2: "parent default 2", 64 | shared_var: "child shared default", # Should use child's default 65 | extra_parent: nil, 66 | child_var1: "child default 1", 67 | child_var2: "child default 2", 68 | child_extra: nil 69 | } 70 | assert_equal expected1, instance1.all_values 71 | 72 | # Test 2: Override parent variables 73 | instance2 = child_class.new( 74 | parent_var1: "custom parent 1", 75 | parent_var2: "custom parent 2" 76 | ) 77 | expected2 = { 78 | parent_var1: "custom parent 1", 79 | parent_var2: "custom parent 2", 80 | shared_var: "child shared default", 81 | extra_parent: nil, 82 | child_var1: "child default 1", 83 | child_var2: "child default 2", 84 | child_extra: nil 85 | } 86 | assert_equal expected2, instance2.all_values 87 | 88 | # Test 3: Override child variables 89 | instance3 = child_class.new( 90 | child_var1: "custom child 1", 91 | child_var2: "custom child 2" 92 | ) 93 | expected3 = { 94 | parent_var1: "parent default 1", 95 | parent_var2: "parent default 2", 96 | shared_var: "child shared default", 97 | extra_parent: nil, 98 | child_var1: "custom child 1", 99 | child_var2: "custom child 2", 100 | child_extra: nil 101 | } 102 | assert_equal expected3, instance3.all_values 103 | 104 | # Test 4: Override shared variable 105 | instance4 = child_class.new(shared_var: "custom shared") 106 | 107 | expected4 = { 108 | parent_var1: "parent default 1", 109 | parent_var2: "parent default 2", 110 | shared_var: "custom shared", 111 | extra_parent: nil, 112 | child_var1: "child default 1", 113 | child_var2: "child default 2", 114 | child_extra: nil 115 | } 116 | assert_equal expected4, instance4.all_values 117 | 118 | # Test 5: Override everything and pass through extra args 119 | instance5 = child_class.new( 120 | parent_var1: "custom parent 1", 121 | parent_var2: "custom parent 2", 122 | shared_var: "custom shared", 123 | child_var1: "custom child 1", 124 | child_var2: "custom child 2", 125 | extra_arg: "parent extra", 126 | child_extra: "child extra" 127 | ) 128 | 129 | expected5 = { 130 | parent_var1: "custom parent 1", 131 | parent_var2: "custom parent 2", 132 | shared_var: "custom shared", 133 | extra_parent: "parent extra", 134 | child_var1: "custom child 1", 135 | child_var2: "custom child 2", 136 | child_extra: "child extra" 137 | } 138 | assert_equal expected5, instance5.all_values 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /test/test_ivar_with_positional_init.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | class TestIvarWithPositionalInit < Minitest::Test 6 | def setup 7 | # Clear the cache to ensure a clean test 8 | Ivar.clear_analysis_cache 9 | end 10 | 11 | def test_ivar_with_positional_init 12 | # Create a class with the ivar macro and init: :positional 13 | klass = Class.new do 14 | include Ivar::Checked 15 | 16 | # Declare instance variables with positional initialization 17 | ivar :@foo, init: :positional 18 | 19 | # Track what positional args are received by initialize 20 | attr_reader :received_args 21 | 22 | def initialize(bar = nil) 23 | # The foo arg should be "peeled off" and not passed to this method 24 | # but bar should be passed through if provided 25 | @received_args = { 26 | foo: binding.local_variable_defined?(:foo) ? :received : :not_received, 27 | bar: bar 28 | } 29 | end 30 | 31 | def foo_value 32 | @foo 33 | end 34 | end 35 | 36 | # Create an instance with both positional arguments 37 | instance = klass.new("from arg", "passed through") 38 | 39 | # Check that the instance variable was initialized from the positional argument 40 | assert_equal "from arg", instance.foo_value 41 | 42 | # Check that foo was peeled off and not passed to initialize 43 | assert_equal :not_received, instance.received_args[:foo] 44 | 45 | # Check that bar was passed through to initialize 46 | assert_equal "passed through", instance.received_args[:bar] 47 | end 48 | 49 | def test_ivar_with_arg_init 50 | # Create a class with the ivar macro and init: :arg (alias for :positional) 51 | klass = Class.new do 52 | include Ivar::Checked 53 | 54 | # Declare instance variables with arg initialization 55 | ivar :@bar, init: :arg 56 | 57 | def initialize 58 | # The value should be set from the positional argument 59 | # before this method is called 60 | end 61 | 62 | def bar_value 63 | @bar 64 | end 65 | end 66 | 67 | # Create an instance with the positional argument 68 | instance = klass.new("from arg") 69 | 70 | # Check that the instance variable was initialized from the positional argument 71 | assert_equal "from arg", instance.bar_value 72 | end 73 | 74 | def test_ivar_with_positional_init_default_value 75 | # Create a class with the ivar macro and init: :positional 76 | klass = Class.new do 77 | include Ivar::Checked 78 | 79 | # Declare instance variables with positional initialization and a value 80 | ivar :@foo, init: :positional, value: "default value" 81 | 82 | def initialize 83 | # If the positional argument is not provided, it should use the value from ivar 84 | end 85 | 86 | def foo_value 87 | @foo 88 | end 89 | end 90 | 91 | # Create an instance without the positional argument 92 | instance = klass.new 93 | 94 | # Check that the instance variable was initialized with the default value from ivar 95 | assert_equal "default value", instance.foo_value 96 | end 97 | 98 | def test_ivar_with_positional_init_and_value 99 | # Create a class with the ivar macro, init: :positional, and a value 100 | klass = Class.new do 101 | include Ivar::Checked 102 | 103 | # Declare instance variables with positional initialization and a default value 104 | ivar :@foo, init: :positional, value: "initial value" 105 | 106 | def initialize 107 | # The value should be set from the positional argument if provided, 108 | # otherwise it should use the initial value 109 | end 110 | 111 | def foo_value 112 | @foo 113 | end 114 | end 115 | 116 | # Create an instance with the positional argument 117 | instance_with_arg = klass.new("from arg") 118 | assert_equal "from arg", instance_with_arg.foo_value 119 | 120 | # Create an instance without the positional argument 121 | instance_without_arg = klass.new 122 | assert_equal "initial value", instance_without_arg.foo_value 123 | end 124 | 125 | def test_ivar_with_multiple_positional_init 126 | # Create a class with multiple ivars using positional initialization 127 | klass = Class.new do 128 | include Ivar::Checked 129 | 130 | # Declare multiple instance variables with positional initialization 131 | ivar :@foo, :@bar, :@baz, init: :positional 132 | 133 | def initialize 134 | # All values should be set from positional arguments 135 | end 136 | 137 | def values 138 | [@foo, @bar, @baz] 139 | end 140 | end 141 | 142 | # Create an instance with all positional arguments 143 | instance = klass.new("foo value", "bar value", "baz value") 144 | 145 | # Check that all instance variables were initialized from positional arguments 146 | assert_equal ["foo value", "bar value", "baz value"], instance.values 147 | end 148 | 149 | def test_ivar_with_positional_init_and_accessor 150 | # Create a class with positional initialization and accessor 151 | klass = Class.new do 152 | include Ivar::Checked 153 | 154 | # Declare instance variables with positional initialization and accessor 155 | ivar :@foo, :@bar, init: :positional, accessor: true 156 | 157 | def initialize 158 | # Values should be set from positional arguments 159 | end 160 | end 161 | 162 | # Create an instance with positional arguments 163 | instance = klass.new("foo value", "bar value") 164 | 165 | # Check that the accessor methods work correctly 166 | assert_equal "foo value", instance.foo 167 | assert_equal "bar value", instance.bar 168 | 169 | # Check that the writer methods work correctly 170 | instance.foo = "new foo" 171 | instance.bar = "new bar" 172 | 173 | assert_equal "new foo", instance.foo 174 | assert_equal "new bar", instance.bar 175 | end 176 | 177 | def test_positional_args_ordering 178 | # Create a class with ivars declared in a specific order 179 | klass = Class.new do 180 | include Ivar::Checked 181 | 182 | # Declare instance variables in a specific order 183 | ivar :@first, init: :positional 184 | ivar :@second, init: :positional 185 | ivar :@third, init: :positional 186 | 187 | def values 188 | [@first, @second, @third] 189 | end 190 | end 191 | 192 | # Create an instance with positional arguments 193 | instance = klass.new("value 1", "value 2", "value 3") 194 | 195 | # Check that the instance variables were initialized in the declared order 196 | assert_equal ["value 1", "value 2", "value 3"], instance.values 197 | end 198 | 199 | def test_warnings_for_undeclared_variables 200 | # Create a class with positional initialization and an undeclared variable 201 | klass = Class.new do 202 | include Ivar::Checked 203 | 204 | # Declare instance variables with positional initialization 205 | ivar :@declared_var, init: :positional 206 | 207 | def initialize 208 | # Use a declared variable 209 | @declared_var = @declared_var.to_s.upcase 210 | end 211 | 212 | def use_misspelled_variable 213 | # Use an undeclared variable (should trigger a warning) 214 | @undeclared_var = "this should trigger a warning" 215 | # Misspelled variable (should trigger a warning) 216 | @declraed_var = "misspelled" 217 | end 218 | end 219 | 220 | stderr_output = capture_stderr do 221 | instance = klass.new("value") 222 | instance.use_misspelled_variable 223 | end 224 | 225 | # Check that warnings were generated for undeclared/misspelled variables 226 | assert_match(/unknown instance variable @undeclared_var/, stderr_output, 227 | "Should warn about undeclared variable") 228 | assert_match(/unknown instance variable @declraed_var/, stderr_output, 229 | "Should warn about misspelled variable") 230 | 231 | # Check that no warnings were generated for declared variables 232 | refute_match(/unknown instance variable @declared_var/, stderr_output, 233 | "Should not warn about declared variable") 234 | end 235 | 236 | def test_no_warnings_for_declared_variables 237 | # Create a class with positional initialization 238 | klass = Class.new do 239 | include Ivar::Checked 240 | 241 | # Declare instance variables with positional initialization 242 | ivar :@foo, :@bar, :@baz, init: :positional 243 | 244 | def initialize 245 | # Modify the declared variables 246 | @foo = @foo.to_s.upcase 247 | @bar = @bar.to_s.upcase 248 | @baz = @baz.to_s.upcase 249 | end 250 | 251 | def values 252 | [@foo, @bar, @baz] 253 | end 254 | end 255 | 256 | stderr_output = capture_stderr do 257 | instance = klass.new("foo", "bar", "baz") 258 | instance.values 259 | end 260 | 261 | # Check that no warnings were generated for declared variables 262 | refute_match(/unknown instance variable @foo/, stderr_output, 263 | "Should not warn about declared variable @foo") 264 | refute_match(/unknown instance variable @bar/, stderr_output, 265 | "Should not warn about declared variable @bar") 266 | refute_match(/unknown instance variable @baz/, stderr_output, 267 | "Should not warn about declared variable @baz") 268 | end 269 | end 270 | -------------------------------------------------------------------------------- /test/test_project_root.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | require "fileutils" 5 | require "tmpdir" 6 | 7 | class TestProjectRoot < Minitest::Test 8 | def setup 9 | Ivar.project_root = nil 10 | # Create a temporary directory structure for testing 11 | @temp_dir = Dir.mktmpdir("ivar_test") 12 | 13 | # Create a nested directory structure 14 | @project_dir = File.join(@temp_dir, "project") 15 | @lib_dir = File.join(@project_dir, "lib") 16 | @deep_dir = File.join(@lib_dir, "deep", "nested", "dir") 17 | 18 | FileUtils.mkdir_p(@deep_dir) 19 | 20 | # Create a Gemfile in the project directory 21 | File.write(File.join(@project_dir, "Gemfile"), "source 'https://rubygems.org'") 22 | 23 | # Create a test file in the deep directory 24 | @test_file = File.join(@deep_dir, "test_file.rb") 25 | File.write(@test_file, "# Test file") 26 | end 27 | 28 | def teardown 29 | # Clean up the temporary directory 30 | FileUtils.remove_entry(@temp_dir) 31 | end 32 | 33 | def test_project_root_finds_gemfile 34 | # Test that project_root correctly finds the directory with Gemfile 35 | assert_equal @project_dir, Ivar.project_root(@test_file) 36 | end 37 | 38 | def test_project_root_caching 39 | # Test that project_root caches results 40 | first_result = Ivar.project_root(@test_file) 41 | 42 | # Delete the Gemfile to ensure it's using the cached result 43 | FileUtils.rm(File.join(@project_dir, "Gemfile")) 44 | 45 | second_result = Ivar.project_root(@test_file) 46 | assert_equal first_result, second_result 47 | end 48 | 49 | def test_project_root_with_git 50 | # Test that project_root finds .git directory 51 | git_dir = File.join(@project_dir, ".git") 52 | FileUtils.mkdir_p(git_dir) 53 | 54 | assert_equal @project_dir, Ivar.project_root(@test_file) 55 | end 56 | 57 | def test_project_root_fallback 58 | # Test fallback when no indicators are found 59 | no_indicators_dir = File.join(@temp_dir, "no_indicators") 60 | FileUtils.mkdir_p(no_indicators_dir) 61 | test_file = File.join(no_indicators_dir, "test.rb") 62 | File.write(test_file, "# Test file") 63 | 64 | # Should return the directory of the file 65 | assert_equal no_indicators_dir, Ivar.project_root(test_file) 66 | end 67 | 68 | def test_project_root_with_caller 69 | # Create a file that calls project_root 70 | caller_file = File.join(@deep_dir, "caller.rb") 71 | File.write(caller_file, <<~RUBY) 72 | # frozen_string_literal: true 73 | 74 | def get_project_root_from_caller 75 | Ivar.project_root 76 | end 77 | RUBY 78 | 79 | # Load the file 80 | require caller_file 81 | 82 | # Test that project_root correctly uses the caller's location 83 | assert_equal @project_dir, get_project_root_from_caller 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/test_targeted_prism_analysis.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | require_relative "../lib/ivar/targeted_prism_analysis" 5 | require_relative "fixtures/targeted_analysis/split_target_class" 6 | require_relative "fixtures/targeted_analysis/multi_class_file" 7 | require_relative "fixtures/targeted_analysis/mixed_methods_class" 8 | 9 | class TestTargetedPrismAnalysis < Minitest::Test 10 | def test_split_class_analysis 11 | analysis = Ivar::TargetedPrismAnalysis.new(SplitTargetClass) 12 | 13 | # Check that we found all the instance variables 14 | expected_ivars = %i[@part1_var1 @part1_var2 @part2_var1 @part2_var2 @part2_var3] 15 | assert_equal expected_ivars, analysis.ivars 16 | 17 | # Check that we have the correct number of references 18 | references = analysis.ivar_references 19 | assert_equal 12, references.size 20 | 21 | # Check that each reference has the expected fields 22 | references.each do |ref| 23 | assert_includes ref, :name 24 | assert_includes ref, :path 25 | assert_includes ref, :line 26 | assert_includes ref, :column 27 | assert_includes ref, :method 28 | end 29 | 30 | # Check references for part1_method 31 | part1_refs = references.select { |ref| ref[:method] == :part1_method } 32 | assert_equal 4, part1_refs.size 33 | 34 | # Check references for part2_method 35 | part2_refs = references.select { |ref| ref[:method] == :part2_method } 36 | assert_equal 4, part2_refs.size 37 | 38 | # Check references for another_part2_method 39 | another_part2_refs = references.select { |ref| ref[:method] == :another_part2_method } 40 | assert_equal 2, another_part2_refs.size 41 | assert_equal :@part2_var3, another_part2_refs.first[:name] 42 | end 43 | 44 | def test_multi_class_file_analysis 45 | # Test FirstClass 46 | first_analysis = Ivar::TargetedPrismAnalysis.new(FirstClass) 47 | 48 | # Check that we found all the instance variables 49 | expected_first_ivars = %i[@first_var1 @first_var2] 50 | assert_equal expected_first_ivars, first_analysis.ivars 51 | 52 | # Check references for first_method 53 | first_refs = first_analysis.ivar_references.select { |ref| ref[:method] == :first_method } 54 | assert_equal 3, first_refs.size 55 | 56 | # Test SecondClass 57 | second_analysis = Ivar::TargetedPrismAnalysis.new(SecondClass) 58 | 59 | # Check that we found all the instance variables 60 | expected_second_ivars = %i[@second_var1 @second_var2] 61 | assert_equal expected_second_ivars, second_analysis.ivars 62 | 63 | # Check references for second_method 64 | second_refs = second_analysis.ivar_references.select { |ref| ref[:method] == :second_method } 65 | assert_equal 3, second_refs.size 66 | 67 | # Ensure no cross-contamination between classes 68 | first_analysis.ivar_references.each do |ref| 69 | refute_match(/second_var/, ref[:name].to_s) 70 | end 71 | 72 | second_analysis.ivar_references.each do |ref| 73 | refute_match(/first_var/, ref[:name].to_s) 74 | end 75 | end 76 | 77 | def test_mixed_methods_class_analysis 78 | analysis = Ivar::TargetedPrismAnalysis.new(MixedMethodsClass) 79 | 80 | # Check that we found only instance method variables (not class method variables) 81 | expected_ivars = %i[@instance_var1 @instance_var2 @instance_var3 @private_var] 82 | assert_equal expected_ivars, analysis.ivars 83 | 84 | # Check that we have the correct number of references 85 | references = analysis.ivar_references 86 | assert_equal 10, references.size 87 | 88 | # Check that we don't have any class method variables 89 | references.each do |ref| 90 | refute_match(/class_var/, ref[:name].to_s) 91 | refute_match(/class_method_var/, ref[:name].to_s) 92 | refute_match(/another_class_var/, ref[:name].to_s) 93 | refute_match(/class_instance_var/, ref[:name].to_s) 94 | end 95 | 96 | # Check references for instance_method 97 | instance_method_refs = references.select { |ref| ref[:method] == :instance_method } 98 | assert_equal 5, instance_method_refs.size 99 | 100 | # Check references for private_instance_method 101 | private_method_refs = references.select { |ref| ref[:method] == :private_instance_method } 102 | assert_equal 3, private_method_refs.size 103 | assert_includes private_method_refs.map { |ref| ref[:name] }, :@private_var 104 | assert_includes private_method_refs.map { |ref| ref[:name] }, :@instance_var1 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /test/test_validation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | require_relative "fixtures/sandwich_with_validation" 5 | 6 | class TestValidation < Minitest::Test 7 | def test_check_ivars_warns_about_unknown_variables 8 | # Capture stderr output 9 | warnings = capture_stderr do 10 | # Create a sandwich with validation which should trigger warnings 11 | SandwichWithValidation.new 12 | end 13 | 14 | # Check that we got warnings about the typo in the code 15 | assert_match(/unknown instance variable @chese/, warnings) 16 | 17 | # Check that we get warnings for the variable 18 | assert_match(/unknown instance variable @chese/, warnings) 19 | 20 | # We should have at least one warning 21 | chese_warnings = warnings.scan(/unknown instance variable @chese/).count 22 | assert chese_warnings >= 1, "Should have at least one warning for @chese" 23 | 24 | # Check that we didn't get warnings about defined variables 25 | refute_match(/unknown instance variable @bread/, warnings) 26 | refute_match(/unknown instance variable @cheese/, warnings) 27 | refute_match(/unknown instance variable @condiments/, warnings) 28 | refute_match(/unknown instance variable @typo_var/, warnings) 29 | 30 | # Check that we didn't get warnings about allowed variables 31 | refute_match(/unknown instance variable @side/, warnings) 32 | end 33 | 34 | def test_check_ivars_suggests_corrections 35 | # Create a class with a typo in the code 36 | klass = Class.new do 37 | include Ivar::Validation 38 | 39 | def initialize 40 | @correct = "value" 41 | check_ivars 42 | end 43 | 44 | def use_typo 45 | # First occurrence of the typo 46 | @typo_veriable = "misspelled" 47 | # Second occurrence of the same typo 48 | puts "The value is #{@typo_veriable}" 49 | end 50 | end 51 | 52 | # Capture stderr output 53 | warnings = capture_stderr do 54 | # Create an instance to trigger the warnings 55 | klass.new 56 | end 57 | 58 | # Check that we got a warning about the typo 59 | assert_match(/unknown instance variable @typo_veriable/, warnings) 60 | 61 | # Check that we get warnings for the variable 62 | assert_match(/unknown instance variable @typo_veriable/, warnings) 63 | 64 | # We should have at least one warning 65 | typo_warnings = warnings.scan(/unknown instance variable @typo_veriable/).count 66 | assert typo_warnings >= 1, "Should have at least one warning for @typo_veriable" 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/test_warn_once_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | class TestWarnOncePolicy < Minitest::Test 6 | def setup 7 | # Clear the cache to ensure a clean test 8 | Ivar.clear_analysis_cache 9 | end 10 | 11 | def test_check_ivars_once_warns_on_first_call 12 | # Create a class with a typo in an instance variable 13 | klass = Class.new do 14 | include Ivar::Validation 15 | 16 | def initialize 17 | @correct = "value" 18 | end 19 | 20 | def method_with_typo 21 | @typo_veriable = "misspelled" 22 | end 23 | end 24 | 25 | # Create an instance to define the class and add methods to the analysis 26 | instance = klass.new 27 | 28 | # Force the analysis to be created and include our method 29 | analysis = Ivar::TargetedPrismAnalysis.new(klass) 30 | # Monkey patch the analysis to include our typo 31 | def analysis.references 32 | [ 33 | {name: :@correct, path: "test_file.rb", line: 1, column: 1}, 34 | {name: :@typo_veriable, path: "test_file.rb", line: 2, column: 1} 35 | ] 36 | end 37 | # Replace the cached analysis 38 | Ivar.instance_variable_get(:@analysis_cache)[klass] = analysis 39 | 40 | # Capture stderr output 41 | warnings = capture_stderr do 42 | # Call check_ivars with warn_once policy 43 | instance.check_ivars(policy: :warn_once) 44 | end 45 | 46 | # Check that we got a warning about the typo 47 | assert_match(/unknown instance variable @typo_veriable/, warnings) 48 | end 49 | 50 | def test_check_ivars_once_does_not_warn_on_subsequent_calls 51 | # Create a class with a typo in an instance variable 52 | klass = Class.new do 53 | include Ivar::Validation 54 | 55 | def initialize 56 | @correct = "value" 57 | end 58 | 59 | def method_with_typo 60 | @typo_veriable = "misspelled" 61 | end 62 | end 63 | 64 | # Create an instance to define the class 65 | instance = klass.new 66 | 67 | # Force the analysis to be created and include our method 68 | analysis = Ivar::TargetedPrismAnalysis.new(klass) 69 | # Monkey patch the analysis to include our typo 70 | def analysis.references 71 | [ 72 | {name: :@correct, path: "test_file.rb", line: 1, column: 1}, 73 | {name: :@typo_veriable, path: "test_file.rb", line: 2, column: 1} 74 | ] 75 | end 76 | # Replace the cached analysis 77 | Ivar.instance_variable_get(:@analysis_cache)[klass] = analysis 78 | 79 | # First call to check_ivars with warn_once policy (should emit warnings) 80 | first_warnings = capture_stderr do 81 | instance.check_ivars(policy: :warn_once) 82 | end 83 | 84 | # Second call to check_ivars with warn_once policy (should not emit warnings) 85 | second_warnings = capture_stderr do 86 | instance.check_ivars(policy: :warn_once) 87 | end 88 | 89 | # Check that we got warnings the first time 90 | assert_match(/unknown instance variable @typo_veriable/, first_warnings) 91 | 92 | # Check that we didn't get warnings the second time 93 | assert_empty second_warnings 94 | end 95 | 96 | def test_different_classes_are_tracked_separately 97 | # Create two classes with typos 98 | klass1 = Class.new do 99 | include Ivar::Validation 100 | 101 | def initialize 102 | @correct = "value" 103 | end 104 | 105 | def method_with_typo 106 | @typo_in_class1 = "misspelled" 107 | end 108 | end 109 | 110 | klass2 = Class.new do 111 | include Ivar::Validation 112 | 113 | def initialize 114 | @correct = "value" 115 | end 116 | 117 | def method_with_typo 118 | @typo_in_class2 = "misspelled" 119 | end 120 | end 121 | 122 | # Create instances of both classes 123 | instance1 = klass1.new 124 | instance2 = klass2.new 125 | 126 | # Force the analysis to be created for klass1 127 | analysis1 = Ivar::TargetedPrismAnalysis.new(klass1) 128 | # Monkey patch the analysis to include our typo 129 | def analysis1.references 130 | [ 131 | {name: :@correct, path: "test_file.rb", line: 1, column: 1}, 132 | {name: :@typo_in_class1, path: "test_file.rb", line: 2, column: 1} 133 | ] 134 | end 135 | # Replace the cached analysis 136 | Ivar.instance_variable_get(:@analysis_cache)[klass1] = analysis1 137 | 138 | # Force the analysis to be created for klass2 139 | analysis2 = Ivar::TargetedPrismAnalysis.new(klass2) 140 | # Monkey patch the analysis to include our typo 141 | def analysis2.references 142 | [ 143 | {name: :@correct, path: "test_file.rb", line: 1, column: 1}, 144 | {name: :@typo_in_class2, path: "test_file.rb", line: 2, column: 1} 145 | ] 146 | end 147 | # Replace the cached analysis 148 | Ivar.instance_variable_get(:@analysis_cache)[klass2] = analysis2 149 | 150 | # First call to check_ivars with warn_once policy for instance1 (should emit warnings) 151 | first_class_warnings = capture_stderr do 152 | instance1.check_ivars(policy: :warn_once) 153 | end 154 | 155 | # First call to check_ivars with warn_once policy for instance2 (should emit warnings) 156 | second_class_warnings = capture_stderr do 157 | instance2.check_ivars(policy: :warn_once) 158 | end 159 | 160 | # Check that we got warnings for the first class 161 | assert_match(/unknown instance variable @typo_in_class1/, first_class_warnings) 162 | 163 | # Check that we got warnings for the second class 164 | assert_match(/unknown instance variable @typo_in_class2/, second_class_warnings) 165 | 166 | # Second call to check_ivars with warn_once policy for instance1 (should not emit warnings) 167 | first_class_second_call = capture_stderr do 168 | instance1.check_ivars(policy: :warn_once) 169 | end 170 | 171 | # Second call to check_ivars with warn_once policy for instance2 (should not emit warnings) 172 | second_class_second_call = capture_stderr do 173 | instance2.check_ivars(policy: :warn_once) 174 | end 175 | 176 | # Check that we didn't get warnings for either class on the second call 177 | assert_empty first_class_second_call 178 | assert_empty second_class_second_call 179 | end 180 | 181 | def test_check_ivars_once_with_additional_variables 182 | # Create a class with a typo in an instance variable 183 | klass = Class.new do 184 | include Ivar::Validation 185 | 186 | def initialize 187 | @correct = "value" 188 | end 189 | 190 | def method_with_vars 191 | @allowed_var = "allowed" 192 | @unknown_var = "unknown" 193 | end 194 | end 195 | 196 | # Create an instance to define the class 197 | instance = klass.new 198 | 199 | # Force the analysis to be created and include our method 200 | analysis = Ivar::TargetedPrismAnalysis.new(klass) 201 | # Monkey patch the analysis to include our variables 202 | def analysis.references 203 | [ 204 | {name: :@correct, path: "test_file.rb", line: 1, column: 1}, 205 | {name: :@allowed_var, path: "test_file.rb", line: 2, column: 1}, 206 | {name: :@unknown_var, path: "test_file.rb", line: 3, column: 1} 207 | ] 208 | end 209 | # Replace the cached analysis 210 | Ivar.instance_variable_get(:@analysis_cache)[klass] = analysis 211 | 212 | # Capture stderr output 213 | warnings = capture_stderr do 214 | # Call check_ivars with warn_once policy to validate 215 | instance.check_ivars(add: [:@allowed_var], policy: :warn_once) 216 | end 217 | 218 | # Check that we got a warning about the unknown variable 219 | assert_match(/unknown instance variable @unknown_var/, warnings) 220 | 221 | # Check that we didn't get warnings about allowed variables 222 | refute_match(/unknown instance variable @allowed_var/, warnings) 223 | end 224 | end 225 | -------------------------------------------------------------------------------- /test_file.rb: -------------------------------------------------------------------------------- 1 | class MockClass; end 2 | --------------------------------------------------------------------------------