├── .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 |
--------------------------------------------------------------------------------