diff --git a/.editorconfig b/.editorconfig index ac225590..cffab408 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,5 +11,5 @@ indent_size = 2 [*.{rb,js}] max_line_length = 80 -[README.md] +[*.md] max_line_length = unset diff --git a/.github/workflows/super_diff.yml b/.github/workflows/super_diff.yml index c225488d..f4339ee5 100644 --- a/.github/workflows/super_diff.yml +++ b/.github/workflows/super_diff.yml @@ -7,6 +7,7 @@ on: pull_request: types: - opened + - closed - reopened - synchronize concurrency: @@ -15,6 +16,7 @@ concurrency: jobs: analyze: runs-on: ubuntu-latest + if: ${{ github.event_name == 'push' || github.event.action != 'closed' }} steps: - uses: actions/checkout@v4 - name: Download actionlint @@ -88,3 +90,179 @@ jobs: - test steps: - run: echo "Analysis and tests passed. Ready to merge." + + collect-release-info: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + - name: Run command + id: command + run: scripts/collect-release-info.rb + outputs: + IS_NEW_RELEASE: ${{ steps.command.outputs.IS_NEW_RELEASE }} + RELEASE_VERSION: ${{ steps.command.outputs.RELEASE_VERSION }} + + collect-docsite-release-info: + runs-on: ubuntu-latest + needs: + - collect-release-info + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Run command + id: command + run: | + set -x + + if [[ "$IS_NEW_RELEASE" == "true" ]]; then + DOCSITE_RELEASE_VERSION="$RELEASE_VERSION" + DOCSITE_DESTINATION_PATH="releases/$RELEASE_VERSION" + HAS_CHANGES_TO_DOCS="true" + else + DOCSITE_RELEASE_VERSION="$COMMIT_ID" + DOCSITE_DESTINATION_PATH="branches/$BRANCH_NAME/$COMMIT_ID" + # Check if there any changes to docs/ + if git diff --quiet --merge-base "origin/$GITHUB_BASE_REF" -- docs; then + HAS_CHANGES_TO_DOCS="false" + else + HAS_CHANGES_TO_DOCS="true" + fi + fi + + { + echo "DOCSITE_RELEASE_VERSION=$DOCSITE_RELEASE_VERSION" + echo "DOCSITE_DESTINATION_PATH=$DOCSITE_DESTINATION_PATH" + echo "HAS_CHANGES_TO_DOCS=$HAS_CHANGES_TO_DOCS" + } >> "$GITHUB_OUTPUT" + env: + IS_NEW_RELEASE: ${{ needs.collect-release-info.outputs.IS_NEW_RELEASE }} + RELEASE_VERSION: ${{ needs.collect-release-info.outputs.RELEASE_VERSION }} + BRANCH_NAME: ${{ github.head_ref }} + COMMIT_ID: ${{ github.event.pull_request.head.sha }} + outputs: + DOCSITE_RELEASE_VERSION: ${{ steps.command.outputs.DOCSITE_RELEASE_VERSION }} + DOCSITE_DESTINATION_PATH: ${{ steps.command.outputs.DOCSITE_DESTINATION_PATH }} + HAS_CHANGES_TO_DOCS: ${{ steps.command.outputs.HAS_CHANGES_TO_DOCS }} + + build-docsite: + runs-on: ubuntu-latest + needs: + - analyze + - collect-release-info + - collect-docsite-release-info + if: ${{ github.event_name == 'pull_request' && ((needs.collect-release-info.outputs.IS_NEW_RELEASE == 'false' && needs.collect-docsite-release-info.outputs.HAS_CHANGES_TO_DOCS == 'true') || (needs.collect-release-info.outputs.IS_NEW_RELEASE == 'true' && github.event.merged)) }} + steps: + - uses: actions/checkout@v4 + - name: Install poetry + run: pipx install poetry + - name: Set up Python + uses: actions/setup-python@v5 + - name: Install Python dependencies + run: poetry install + - name: Build docsite + run: poetry run mkdocs build + - name: Save site/ for later jobs + uses: actions/cache/save@v3 + with: + path: site + key: docsite-${{ github.sha }} + + publish-docsite: + runs-on: ubuntu-latest + needs: + - collect-release-info + - collect-docsite-release-info + - build-docsite + steps: + - uses: actions/checkout@v4 + with: + ref: gh-pages + - name: Restore cache from previous job + uses: actions/cache/restore@v3 + with: + path: site + key: docsite-${{ github.sha }} + - name: Update redirect in index (for a release) + if: ${{ needs.collect-release-info.outputs.IS_NEW_RELEASE == 'true' }} + run: | + cat <<-EOT > index.html + + + + SuperDiff Documentation + + + +

+ This page has moved to a different URL. + Please click + + this link + + if you are not redirected. +

+ + + EOT + - name: Copy site/ to ${{ needs.collect-docsite-release-info.outputs.DOCSITE_DESTINATION_PATH }} + run: | + mkdir -p "$(dirname "$DOCSITE_DESTINATION_PATH")" + mv site "$DOCSITE_DESTINATION_PATH" + env: + DOCSITE_DESTINATION_PATH: ${{ needs.collect-docsite-release-info.outputs.DOCSITE_DESTINATION_PATH }} + - name: Publish new version of docsite + run: | + git add -A . + git config user.name "${GITHUB_ACTOR}" + git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" + git commit -m "Publish docs at $DOCSITE_DESTINATION_PATH" + git push + env: + DOCSITE_DESTINATION_PATH: ${{ needs.collect-docsite-release-info.outputs.DOCSITE_DESTINATION_PATH }} + - name: Announce publishing of docsite as a comment on the PR + run: | + gh pr comment "$PULL_REQUEST_NUMBER" --body ":book: A new version of the docsite has been published at: " + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PULL_REQUEST_NUMBER: ${{ github.event.number }} + DOCSITE_DESTINATION_PATH: ${{ needs.collect-docsite-release-info.outputs.DOCSITE_DESTINATION_PATH }} + + unpublish_docsite: + runs-on: ubuntu-latest + needs: + - collect-release-info + - collect-docsite-release-info + if: ${{ github.event_name == 'pull_request' && needs.collect-release-info.outputs.IS_NEW_RELEASE == 'false' && github.event.action == 'closed' }} + steps: + - uses: actions/checkout@v4 + with: + ref: gh-pages + - name: Set DOCSITE_DESTINATION_PARENT_PATH + run: | + set -x + DOCSITE_DESTINATION_PARENT_PATH="$(dirname "$DOCSITE_DESTINATION_PATH")" + echo "DOCSITE_DESTINATION_PARENT_PATH=$DOCSITE_DESTINATION_PARENT_PATH" >> "GITHUB_ENV" + env: + DOCSITE_DESTINATION_PATH: ${{ needs.collect-docsite-release-info.outputs.DOCSITE_DESTINATION_PATH }} + - name: Remove ${{ env.DOCSITE_DESTINATION_PARENT_PATH }} on gh-pages + run: | + set -x + if [[ "$DOCSITE_DESTINATION_PARENT_PATH" == "releases" || "$DOCSITE_DESTINATION_PARENT_PATH" == "branches" ]]; then + echo "Not removing $DOCSITE_DESTINATION_PARENT_PATH." + exit 1 + fi + rm -rf "$DOCSITE_DESTINATION_PARENT_PATH" + - name: Re-push docsite if necessary + run: | + git add -A . + if ! git diff --cached --quiet; then + git config user.name "${GITHUB_ACTOR}" + git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" + git commit -m "Remove $DOCSITE_DESTINATION_PARENT_PATH" + git push + fi diff --git a/.gitignore b/.gitignore index 87d7767b..fed4404a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,9 @@ zeus.server-start.log !.yarn/releases !.yarn/sdks !.yarn/versions + +# Ignore Python stuff +poetry.lock + +# Ignore mkdocs stuff +site diff --git a/.prettierignore b/.prettierignore index 8fe001fd..22e00978 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,7 @@ .yarn/cache .yarn/releases gemfiles +site +tmp vendor/bundle yarn.lock diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..8531a3b7 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12.2 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index 3440b775..00000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,33 +0,0 @@ -# Architecture - -I'll have more later around this, -but here are some quick hits: - -## Basic concepts - -- An **object inspector** generates a multi-line textual representation of an object, - similar to PrettyPrinter in Ruby or AwesomePrint, - but more appropriate for showing within a diff. -- An **operation** represents a difference in one value from another - in the context of a data structure. - That difference can either be an _delete_, _insert_, or _change_. -- An **operation tree** is the set of all operations between two data structures, - where each operation represents the difference between an inner element within the structure - (value for an array or a key/value pair for a hash). - Since change operations represent elements that have child elements, - they also have child operations to represent those child elements. - Those child operations can themselves have children, etc. - This descendancy is what forms the tree. -- An **operation tree builder** makes a comparison between two like data structures - and generates an operation tree to represent the differences. -- A **diff formatter** takes an operation tree - and spits out a textual representation of that tree in the form of a conventional diff. - Each operation may in fact generate more than one line in the final diff - because the object that is specific to the operation is run through an object inspector. -- Finally, a **differ** ties everything together - and figures out which operation tree builder and diff formatter to use for a particular pair of values - (where one value is the "expected" and the other is the "actual"). - -## Code flow diagram - -[![code flow](./docs/code-flow-diagram.png)](https://docs.google.com/drawings/d/1nKi4YKXgzzIIM-eY0P4uwjkglmuwlf8nTRFne8QZhBg/edit) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 20094255..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,72 +0,0 @@ -# Contributing - -Want to make a change to this library? -Great! Here's how you do that. - -First, create a fork of this repo, -cloning it to your computer -and running the following command in the resulting directory -in order to install dependencies: - -``` -bin/setup -``` - -After this, you can run all of the tests -to make sure everything is kosher: - -``` -bundle exec rake -``` - -Next, make changes to the code as necessary. - -Code is linted and formatted using Prettier, -so [make sure that's set up in your editor first][prettier-editors], -or you can always fix any lint violations by running: - -``` -yarn lint:fix -``` - -[prettier-editors]: https://prettier.io/docs/en/editors.html - -If you update one of the tests, -you can run it like so: - -``` -bin/rspec spec/integration/... -bin/rspec spec/unit/... -``` - -Finally, submit your PR. -I'll try to respond as quickly as I can. -I may have suggestions about code style or your approach, -but hopefully everything looks good and your changes get merged! -Now you're a contributor! 🎉 - -## Speeding up the integration tests - -The integration tests, -located in `spec/integration`, -can be quite slow to run. -If you'd like to speed them up, -run the following command in a separate tab: - -``` -zeus start -``` - -Now the next time you run an integration test by saying - -``` -bin/rspec spec/integration/... -``` - -it should run twice as fast. - -## Understanding the codebase - -If you want to make a change -but you're having trouble where to start, -you might find the [Architecture](./ARCHITECTURE.md) document helpful. diff --git a/README.md b/README.md index 7bed3d5e..9bc7347d 100644 --- a/README.md +++ b/README.md @@ -8,24 +8,26 @@ [issuehunt-badge]: https://img.shields.io/badge/sponsored_through-IssueHunt-2EC28C [issuehunt]: https://issuehunt.io/r/mcmire/super_diff -SuperDiff is a gem that hooks into RSpec -to intelligently display the differences between two data structures of any type. +**SuperDiff** is a Ruby gem +which is designed to display the differences between two objects of any type +in a familiar and intelligent fashion. đŸ“ĸ **[See what's changed in recent versions.][changelog]** -[changelog]: CHANGELOG.md +[changelog]: ./CHANGELOG.md ## Introduction The primary motivation behind this gem is to vastly improve upon RSpec's built-in diffing capabilities. -Sometimes, whenever you use a matcher such as `eq`, `match`, `include`, or `have_attributes`, +The primary motivation behind this gem +is to vastly improve upon RSpec's built-in diffing capabilities. +RSpec has many nice features, +and one of them is that whenever you use a matcher such as `eq`, `match`, `include`, or `have_attributes`, you will get a diff of the two data structures you are trying to match against. This is great if all you want to do is compare multi-line strings. -But if you want to compare other, more "real world" kinds of values, -such as what you might work with when developing API endpoints -or testing methods that make database calls and return a set of model objects, +But if you want to compare other, more "real world" kinds of values such as API or database data, then you are out of luck. Since [RSpec merely runs your `expected` and `actual` values through Ruby's PrettyPrinter library][rspec-differ-fail] and then performs a diff of these strings, @@ -33,8 +35,7 @@ the output it produces leaves much to be desired. [rspec-differ-fail]: https://github.com/rspec/rspec-support/blob/c69a231d7369dd165ad7ce4742e1a2e21e3462b5/lib/rspec/support/differ.rb#L178 -For instance, -let's say you wanted to compare these two hashes: +For instance, let's say you wanted to compare these two hashes: ```ruby actual = { @@ -78,7 +79,7 @@ expect(actual).to eq(expected) You would get output that looks like this: -![Before super_diff](docs/before.png) +![Before super_diff](docs/assets/before.png) What this library does is to provide a diff engine @@ -87,162 +88,14 @@ and display them in a sensible way. So, using the example above, you'd get this instead: -![After super_diff](docs/after.png) - -## Installation - -There are a few different ways to install `super_diff` -depending on your type of project. - -### Rails apps - -If you're developing a Rails app, -add the following to your Gemfile: - -```ruby -group :test do - gem "super_diff" -end -``` - -After running `bundle install`, -add the following to your `rails_helper`: - -```ruby -require "super_diff/rspec-rails" -``` - -### Projects using some part of Rails (e.g. ActiveModel) - -If you're developing an app using Hanami or Sinatra, -or merely using a part of Rails such as ActiveModel, -add the following to your Gemfile where appropriate: +![After super_diff](docs/assets/after.png) -```ruby -gem "super_diff" -``` - -After running `bundle install`, -add the following to your `spec_helper`: - -```ruby -require "super_diff/rspec" -require "super_diff/active_support" -``` - -### Gems - -If you're developing a gem, -add the following to your gemspec: - -```ruby -spec.add_development_dependency "super_diff" -``` - -Now add the following to your `spec_helper`: - -```ruby -require "super_diff/rspec" -``` +## Installation & Usage -## Configuration +📘 For more on how to install and use SuperDiff, +[read the user documentation][user-docs]. -You can customize the behavior of the gem -by adding a configuration block -to your test helper file -(`rails_helper` or `spec_helper`) -which looks something like this: - -```ruby -SuperDiff.configure do |config| - # ... -end -``` - -### Customizing colors - -If you don't like the colors that SuperDiff uses, -you can change them like this: - -```ruby -SuperDiff.configure do |config| - config.actual_color = :green - config.expected_color = :red - config.border_color = :yellow - config.header_color = :yellow -end -``` - -See [eight_bit_color.rb](lib/super_diff/csi/eight_bit_color.rb) -for the list of available colors. - -You can also completely disable colorized output. - - -```ruby -SuperDiff.configure do |config| - config.color_enabled = false - end -``` - - -### Disabling the key - -You can disable the key by changing the following config (default: true): - - -```ruby -SuperDiff.configure do |config| - config.key_enabled = false -end -``` - - -### Hiding unimportant lines - -When looking at a large diff for which many of the lines do not change, -it can be difficult to locate the lines which do. Text-oriented -diffs such as those you get from a conventional version control system -solve this problem by removing those unchanged lines from the diff -entirely. The same can be done in SuperDiff. - -```ruby -SuperDiff.configure do |config| - config.diff_elision_enabled = false - config.diff_elision_maximum = 3 -end -``` - -- `diff_elision_enabled` — The elision logic is disabled by default so - as not to surprise people, so setting this to `true` will turn it on. -- `diff_elision_maximum` — This number controls what happens to - unchanged lines (i.e. lines that are neither "insert" lines nor - "delete" lines) that are in between changed lines. If a section of - unchanged lines is beyond this number, the gem will elide (a fancy - word for remove) the data structures within that section as much as - possible until the limit is reached or it cannot go further. Elided - lines are replaced with a `# ...` marker. - -### Diffing custom objects - -If you are comparing two data structures -that involve a class that is specific to your project, -the resulting diff may not look as good as diffs involving native or primitive objects. -This happens because if SuperDiff doesn't recognize a class, -it will fall back to a generic representation when diffing instances of that class. -Fortunately, the gem has a pluggable interface -that allows you to insert your own implementations -of key pieces involved in the diffing process. -I'll have more about how that works soon, -but here is what such a configuration would look like: - -```ruby -SuperDiff.configure do |config| - config.add_extra_differ_class(YourDiffer) - config.add_extra_operation_tree_builder_class(YourOperationTreeBuilder) - config.add_extra_operation_tree_class(YourOperationTree) -end -``` +[user-docs]: ./docs/users/getting-started.md ## Support @@ -257,16 +110,19 @@ I'll try to respond to it as soon as I can! ## Contributing Any code contributions to improve this library are welcome! -Please see the [contributing](./CONTRIBUTING.md) document for more on how to do that. +Please see the [contributing](./docs/contributors/index.md) document +for more on how to do that. ## Sponsoring If there's a change you want implemented, you can choose to sponsor that change! `super_diff` is set up on IssueHunt, so feel free to search for an existing issue (or make your own) -and [add a bounty](https://issuehunt.io/r/mcmire/super_diff). +and [add a bounty][issuehunt]. I'll get notified right away! +[issuehunt]: https://issuehunt.io/r/mcmire/super_diff + ## Compatibility `super_diff` is [tested][gh-actions] to work with diff --git a/bin/setup b/bin/setup index 8533cb28..6c9551fa 100755 --- a/bin/setup +++ b/bin/setup @@ -19,7 +19,7 @@ provision-project() { # # To regenerate this section, install the gem and run: # -# generate-setup -p ruby -p node +# generate-setup -p ruby -p node -p python # # --- SETUP -------------------------------------------------------------------- @@ -332,9 +332,12 @@ ensure-project-node-dependencies-installed() { banner 'Installing Node dependencies' npm install elif [[ -f yarn.lock ]]; then - if ! type yarn &>/dev/null || ! yarn --version &>/dev/null; then - banner 'Installing Yarn' - npm install -g yarn + if ! has-executable yarn || ! yarn --version &>/dev/null; then + banner 'Enabling Yarn' + corepack enable + if has-executable asdf; then + asdf reshim nodejs + fi fi banner 'Installing Node dependencies' yarn install @@ -344,15 +347,88 @@ ensure-project-node-dependencies-installed() { It doesn't look like you have a package-lock.json or yarn.lock in your project yet. I'm not sure which package manager you plan on using, so you'll need to run either \`npm install\` or \`yarn install\` once first. Additionally, if you want -to use Yarn 2+, then now is the time to switch to that. Then you can re-run this +to use Yarn 2, then now is the time to switch to that. Then you can re-run this script." exit 1 fi } +# --- PYTHON ------------------------------------------------------------------- + +REQUIRED_PYTHON_VERSION= + +provision-python() { + if [[ -f .tool-versions ]]; then + REQUIRED_PYTHON_VERSION=$(cat .tool-versions | grep '^python ' | head -n 1 | sed -Ee 's/^python (.+)$/\1/') + elif [[ -f .python-version ]]; then + REQUIRED_PYTHON_VERSION=$(cat .python-version | head -n 1 | sed -Ee 's/^python-([[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+)$/\1/') + fi + + if [[ -z $REQUIRED_PYTHON_VERSION ]]; then + error "Could not determine required Python version for this project." + print-wrapped "\ +Your project needs to include either a valid .tool-versions file with a 'python' +line or a valid .python-version file." + exit 1 + fi + + ensure-python-installed + ensure-pipx-installed + + if [[ -f pyproject.toml ]] || [[ -f requirements.txt ]]; then + ensure-project-python-dependencies-installed + fi +} + +ensure-python-installed() { + if has-executable asdf; then + if ! (asdf current python | grep $REQUIRED_PYTHON_VERSION'\>' &>/dev/null); then + banner "Installing Python $REQUIRED_PYTHON_VERSION with asdf" + asdf install python $REQUIRED_PYTHON_VERSION + fi + elif has-executable pyenv; then + if ! (pyenv versions | grep $REQUIRED_PYTHON_VERSION'\>' &>/dev/null); then + banner "Installing Python $REQUIRED_PYTHON_VERSION with pyenv" + pyenv install --skip-existing "$REQUIRED_PYTHON_VERSION" + fi + else + error "You don't seem to have a Python manager installed." + print-wrapped "\ +We recommend using asdf. You can find instructions to install it here: + + https://asdf-vm.com + +When you're done, close and re-open this terminal tab and re-run this script." + exit 1 + fi +} + +ensure-pipx-installed() { + if ! has-executable pipx; then + banner "Installing pipx" + pip install --user pipx + fi +} + +ensure-project-python-dependencies-installed() { + banner 'Installing Python dependencies' + + if [[ -f pyproject.toml ]]; then + if ! has-executable poetry; then + banner "Installing Poetry" + pipx install poetry + fi + + poetry install + else + warning "Did not detect a way to install Python dependencies." + fi +} + run-provisions() { provision-ruby provision-node + provision-python } # --- FIN ---------------------------------------------------------------------- diff --git a/docs/after.rb b/docs-support/after.rb similarity index 100% rename from docs/after.rb rename to docs-support/after.rb diff --git a/docs/before.rb b/docs-support/before.rb similarity index 100% rename from docs/before.rb rename to docs-support/before.rb diff --git a/docs/carbon-config.json b/docs-support/carbon-config.json similarity index 100% rename from docs/carbon-config.json rename to docs-support/carbon-config.json diff --git a/docs/carbon.md b/docs-support/carbon.md similarity index 100% rename from docs/carbon.md rename to docs-support/carbon.md diff --git a/docs/code-flow-diagram.png b/docs-support/code-flow-diagram.png similarity index 100% rename from docs/code-flow-diagram.png rename to docs-support/code-flow-diagram.png diff --git a/docs/after.png b/docs/assets/after.png similarity index 100% rename from docs/after.png rename to docs/assets/after.png diff --git a/docs/before.png b/docs/assets/before.png similarity index 100% rename from docs/before.png rename to docs/assets/before.png diff --git a/docs/contributors/architecture/how-rspec-works.md b/docs/contributors/architecture/how-rspec-works.md new file mode 100644 index 00000000..e2c7240b --- /dev/null +++ b/docs/contributors/architecture/how-rspec-works.md @@ -0,0 +1,224 @@ +# How RSpec works + +In order to understand how the RSpec integration in SuperDiff works, +it's important to study the pieces in play within RSpec itself. + +## Context + +Imagine a file such as the following: + +```ruby +# spec/some_spec.rb +describe "Some tests" do + it "does something" do + expect([1, 2, 3]).to eq([1, 6, 3]) + end +end +``` + +Then, imagine that the user runs: + +``` +rspec +``` + +Without SuperDiff activated, +this will produce the following output: + +``` +Some tests + does something (FAILED - 1) + +Failures: + + 1) Some tests does something + Failure/Error: expect([1, 2, 3]).to eq([1, 6, 3]) + + expected: [1, 6, 3] + got: [1, 2, 3] + + (compared using ==) + # ./spec/some_spec.rb:3:in `block (2 levels) in ' + +Finished in 0.01186 seconds (files took 0.07765 seconds to load) +1 example, 1 failure + +Failed examples: + +rspec ./spec/some_spec.rb:2 # Some tests does something +``` + +Now imagine that we want to modify this output +to replace the "expected:"/"actual:" lines with a diff. +How would we do this? + +## RSpec's cast of characters + +First, we will review several concepts in RSpec: [^fn1] + +- Since RSpec tests are "just Ruby", + parts of tests map to objects + which are created when those tests are loaded. + `describe`s and `context`s are represented by + **example groups**, + instances of [`RSpec::Core::ExampleGroup`](https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/example_group.rb), + and `it`s and `specify`s are represented by + **examples**, + instances of [`RSpec::Core::Example`](https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/example.rb). +- Most notably, + within tests themselves, + the `expect` method — + [mixed into tests via the syntax layer][rspec-exp-syntax] — + returns an instance of [`RSpec::Expectations::ExpectationTarget`](https://github.com/rspec/rspec-expectations/blob/v3.13.0/lib/rspec/expectations/expectation_target.rb), + and may raise an error if the check it is performing fails. +- **Configuration** is kept in an instance of [`RSpec::Core::Configuration`](https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/configuration.rb), + which is accessible via `RSpec.configuration` + and is [initialized the first time it's used][rspec-configuration-init] +- The **runner**, + an instance of [`RSpec::Core::Runner`](https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/runner.rb), + is the entrypoint to all of RSpec — + [it's called directly by the `rspec` executable][rspec-core-runner-call] — + and executes the tests the user has specified. +- **Formatters** change RSpec's output after running tests. + Since the user can specify one formatter when running `rspec`, + the collection of registered formatters is managed by the formatter loader, + an instance of [`RSpec::Core::Formatters::Loader`](https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/formatters.rb#L96). + The default formatter is "progress", + [set in the configuration object][rspec-default-formatter-set], + which maps to an instance of [`RSpec::Core::Formatters::ProgressFormatter`](https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/formatters/progress_formatter.rb). +- [Notifications](https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/notifications.rb) + represent events that occur while running tests, + such as "these tests failed" + or "this test was skipped". +- The **reporter**, + an instance of [`RSpec::Core::Reporter`](https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/reporter.rb), + acts as sort of the brain of the whole operation. + Implementing a publish/subscribe model, + it tracks the state of tests as they are run, + including errors captured during the process, + packaging key moments into notifications + and delegating them to all registered formatters (or anything else listening to the reporter). + Like the configuration object, + it is also global, + accessible via the configuration object, + and is [initialized the first time it's used][rspec-reporter-init] +- The **exception presenter**, + an instance of [`RSpec::Core::Formatters::ExceptionPresenter`](https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/formatters/exception_presenter.rb), + is a special type of formatter + which does not respond to events, + but is rather responsible for managing all of the logic involved + in building all of the output that appears + when a test fails. + +## What RSpec does + +Given the above, RSpec performs the following sequence of events: + +1. The developer adds an failing assertion to a test using the following forms + (filling in ``, ``, ``, and `` appropriately): + - `expect().to ()` + - `expect { }.to ()` + - `expect().not_to ()` + - `expect { }.not_to ()` +1. The developer runs the test using the `rspec` executable. +1. The `rspec` executable [calls `RSpec::Core::Runner.invoke`][rspec-core-runner-call]. +1. Skipping a few steps, `RSpec::Core::Runner#run_specs` is called, + which [runs all tests by surrounding them in a call to `RSpec::Core::Reporter#report`][rspec-reporter-report-call]. +1. Skipping a few more steps, [`RSpec::Core::Example#run` is called to run the current example][rspec-core-example-run-call]. +1. From here one of two paths is followed + depending on whether the assertion is positive (`.to`) or negative (`.not_to`). + - If the assertion is positive: + 1. Within the test, + after `expect` is called to build a `RSpec::Expectations::ExpectationTarget`, + [the `to` method calls `RSpec::Expectations::PositiveExpectationHandler.handle_matcher`][rspec-positive-expectation-handler-handle-matcher-call]. + 1. The matcher is then used to know + whether the assertion passes or fails: + `PositiveExpectationHandler` + [calls the `matches?` method on the matcher][rspec-positive-expectation-handler-matcher-matches]. + 1. Assuming that `matches?` returns false, + `PositiveExpectationHandler` then [calls `RSpec::Expectations::ExpectationHelper.handle_failure`][rspec-expectation-helper-handle-failure-call-positive], + telling it to get the positive failure message from the matcher + by calling `failure_message`. + - If the assertion is negative: + 1. Within the test, + after `expect` is called to build a `RSpec::Expectations::ExpectationTarget`, + [the `not_to` method calls `RSpec::Expectations::NegativeExpectationHandler.handle_matcher`][rspec-negative-expectation-handler-handle-matcher-call]. + 1. The matcher is then used to know + whether the assertion passes or fails: + `NegativeExpectationHandler`, + [calls the `does_not_match?` method on the matcher][rspec-negative-expectation-handler-matcher-does-not-match]. + 1. Assuming that `does_not_match?` returns false, + `NegativeExpectationHandler` then [calls `RSpec::Expectations::ExpectationHelper.handle_failure`][via `NegativeExpectationHandler`][rspec-expectation-helper-handle-failure-call-negative], + telling it to get the negative failure message from the matcher + by calling `failure_message_when_negated`. +1. `RSpec::Expectations::ExpectationHelper.handle_failure` [calls `RSpec::Expectations.fail_with`][rspec-expectations-fail-with-call]. +1. `RSpec::Expectations.fail_with` [creates a diff using `RSpec::Matchers::MultiMatcherDiff`, + wraps it in an exception, + and feeds the exception to `RSpec::Support.notify_failure`][rspec-support-notify-failure-call]. +1. `RSpec::Support.notify_failure` calls the currently set failure notifier, + which by default [raises the given exception][rspec-support-exception-raise]. +1. Returning to `RSpec::Core::Example#run`, + this method [rescues the exception][rspec-core-example-run-rescue] + and then calls `finish`, + which [calls `example_failed` on the reporter][rspec-reporter-example-failed-call]. +1. `RSpec::Core::Reporter#example_failed` uses `RSpec::Core::Notifications::ExampleNotification.for` + to [construct a notification][rspec-reporter-construct-failed-example-notification], + which in this case is an `RSpec::Core::Notifications::FailedExampleNotification`. + `RSpec::Core::Notifications::FailedExampleNotification` in turn + [constructs an `RSpec::Core::Formatters::ExceptionPresenter`][rspec-exception-presenter-init]. +1. `RSpec::Core::Reporter#example_failed` then [passes the notification object + along with an event of `:example_failed` to the `notify` method][rspec-reporter-example-failed-call]. + Because `RSpec::Core::Formatters::ProgressFormatter` is a listener on the reporter, + [its `example_failed` method gets called][rspec-progress-formatter-example-failed-call], + which prints a message `Failure:` to the terminal. +1. Returning to `RSpec::Core::Reporter#report`, + it now [calls `finish` after all tests are run][rspec-reporter-finish-call]. +1. `RSpec::Core::Reporter#finish` [notifies listeners of the `:dump_failures` event][rspec-reporter-notify-dump-failures], + this time using an instance of `RSpec::Core::Notifications::ExamplesNotification`. + Again, because `RSpec::Core::Formatters::ProgressFormatter` is registered, + its `dump_failures` method is called, + which is actually defined in `RSpec::Core::Formatters::BaseTextFormatter`. +1. `RSpec::Core::Formatters::BaseTextFormatter#dump_failures` + [calls `RSpec::Core::Notifications::ExamplesNotification#fully_formatted_failed_examples`][rspec-examples-notification-fully-formatted-failed-examples-call]. +1. `RSpec::Core::Notifications::ExamplesNotification#fully_formatted_failed_examples` + [formats all of the failed examples][rspec-failed-examples-notification-fully-formatted-call] + by wrapping them in `RSpec::Core::Notifications::FailedExampleNotification`s and calling `fully_formatted` on them. +1. `RSpec::Core::Notifications::FailedExampleNotification#fully_formatted` then [calls `fully_formatted` + on its `RSpec::Core::Formatters::ExceptionPresenter`][rspec-exception-presenter-fully-formatted-call]. +1. `RSpec::Core::Formatters::ExceptionPresenter#fully_formatted` then [constructs various pieces + of what will eventually be printed to the terminal][rspec-exception-presenter-main], + including the name of the test, + the line that failed, + the error and backtrace, + and other pertinent details. + +[^fn1]: Note that the analysis of the RSpec source code in this document is accurate as of RSpec v3.13.0, released February 4, 2024. + +[rspec-exp-syntax]: https://github.com/rspec/rspec-expectations/blob/v3.13.0/lib/rspec/expectations/syntax.rb#L73 +[rspec-configuration-init]: https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core.rb#L86 +[rspec-core-runner-call]: https://github.com/rspec/rspec-core/blob/v3.13.0/exe/rspec#L4 +[rspec-default-formatter-set]: https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/configuration.rb#L1030 +[rspec-reporter-init]: https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/configuration.rb#L1056 +[rspec-reporter-report-call]: https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/runner.rb#L115 +[rspec-core-example-run-call]: https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/example_group.rb#L646 +[rspec-positive-expectation-handler-handle-matcher-call]: https://github.com/rspec/rspec-expectations/blob/v3.13.0/lib/rspec/expectations/expectation_target.rb#L65 +[rspec-negative-expectation-handler-handle-matcher-call]: https://github.com/rspec/rspec-expectations/blob/v3.13.0/lib/rspec/expectations/expectation_target.rb#L78 +[rspec-positive-expectation-handler-matcher-matches]: https://github.com/rspec/rspec-expectations/blob/v3.13.0/lib/rspec/expectations/handler.rb#L51 +[rspec-negative-expectation-handler-matcher-does-not-match]: https://github.com/rspec/rspec-expectations/blob/v3.13.0/lib/rspec/expectations/handler.rb#L79 +[rspec-expectation-helper-handle-failure-call-positive]: https://github.com/rspec/rspec-expectations/blob/v3.13.0/lib/rspec/expectations/handler.rb#L56 +[rspec-expectation-helper-handle-failure-call-negative]: https://github.com/rspec/rspec-expectations/blob/v3.13.0/lib/rspec/expectations/handler.rb#L84 +[rspec-expectations-fail-with-call]: https://github.com/rspec/rspec-expectations/blob/v3.13.0/lib/rspec/expectations/handler.rb#L37-L41 +[rspec-support-notify-failure-call]: https://github.com/rspec/rspec-expectations/blob/v3.13.0/lib/rspec/expectations/fail_with.rb#L27-L35 +[rspec-support-exception-raise]: https://github.com/rspec/rspec-support/blob/v3.13.0/lib/rspec/support.rb#L110 +[rspec-core-example-run-rescue]: https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/example.rb#L280 +[rspec-reporter-example-failed-call]: https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/example.rb#L484 +[rspec-reporter-construct-failed-example-notification]: https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/notifications.rb#L52 +[rspec-exception-presenter-init]: https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/notifications.rb#L213 +[rspec-reporter-example-failed-call]: https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/reporter.rb#L145 +[rspec-progress-formatter-example-failed-call]: https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/reporter.rb#L209 +[rspec-reporter-finish-call]: https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/reporter.rb#L76 +[rspec-reporter-notify-dump-failures]: https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/reporter.rb#L178 +[rspec-examples-notification-fully-formatted-failed-examples-call]: https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/formatters/base_text_formatter.rb#L32 +[rspec-failed-examples-notification-fully-formatted-call]: https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/notifications.rb#L114 +[rspec-exception-presenter-fully-formatted-call]: https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/notifications.rb#L202 +[rspec-exception-presenter-main]: https://github.com/rspec/rspec-core/blob/v3.13.0/lib/rspec/core/formatters/exception_presenter.rb#L84-L100 diff --git a/docs/contributors/architecture/how-super-diff-works.md b/docs/contributors/architecture/how-super-diff-works.md new file mode 100644 index 00000000..6a86ba85 --- /dev/null +++ b/docs/contributors/architecture/how-super-diff-works.md @@ -0,0 +1,186 @@ +# How SuperDiff works + +## SuperDiff's cast of characters + +- An **inspection tree builder** (or, casually, an _inspector_) + makes use of an **inspection tree** + to generate a multi-line textual representation of an object, + similar to PrettyPrinter in Ruby or AwesomePrint, + but more appropriate for showing within a diff. +- An **operation tree builder** makes a comparison between two objects + (the "expected" vs. the "actual") + and generates an **operation tree** to represent the differences. +- An operation tree is made up of **operations**, + which designate differences in the inner parts of the two objects. + Those differences can be of type _delete_, _insert_, or _change_. + Since objects can be nested, + some operations can have children operations themselves, + hence the tree. +- An **operation tree flattener** takes an operation tree + and converts them to a set of **lines**, + which will aid in generating a diff. + Logic is applied to determine whether to place prefixes, suffixes, or commas. + Each operation may in fact generate more than one line + because the object that is specific to the operation is run through an inspector. +- A **diff formatter** takes a set of lines + and spits out a textual representation in the form of a conventional diff. +- A **differ** ties everything together + by figuring out which operation tree builder to use for a pair of expected and actual values, + building an operation tree, + and then converting it to a diff. + +## Where SuperDiff integrates into RSpec + +As described in ["How RSpec works"](./how-rspec-works.md#what-rspec-does), +when an assertion in a test fails — +which happens when a matcher whose `matches?` method returns `false` +is passed to `expect(...).to`, +or when a matcher whose `does_not_match?` method returns `true` +is passed to `expect(...).not_to` — +RSpec will call the `RSpec::Expectations::ExpectationHelper#handle_failure` method, +which will call `RSpec::Expectations.fail_with`. +This method will use `RSpec::Matchers::ExpectedsForMultipleDiffs` +and the differ object that `RSpec::Expectations.differ` returns +to generate a diff, +combining it with the failure message from the matcher, +obtained by either calling `failure_message` or `failure_messsage_when_negated`, +and then it will bundle them both into an error object. +RSpec's runner will eventually capture this error and hand it to the reporter, +which will display it via `RSpec::Core::Formatters::ExceptionPresenter`. + +Given this, there are a few things that SuperDiff needs to do +in order to integrate fully with RSpec. + +1. First, + SuperDiff needs to get RSpec to use its differ instead of its own. + Unfortunately, while RSpec is very configurable, + it does not allow its differ to be substituted, + so the gem needs to do some amount of patching in order to achieve this. +2. Second, + the gem needs to provide intelligent diffing + for all kinds of built-in matchers. + Many matchers in RSpec are marked as non-diffable — + their `diffable?` method returns `false` — + causing RSpec to not show a diff after the matcher's failure message + in the failure output. + The `contain_exactly` matcher is one such example. + SuperDiff turns this on — + but the only way to do this is via patching. +3. Lastly, + SuperDiff also modifies the failure messages for RSpec's built-in matchers + so that key words belonging to the "expected" and "actual" values + get recolored. + Again, the only real way to do this is via patching. + +Here are all of the places that SuperDiff patches RSpec: + +- `RSpec::Expectations.differ` + (to use `SuperDiff::RSpec::Differ` instead of RSpec's own differ) +- `RSpec::Expectations::ExpectationHelper#handle_failure` + (to consult the matcher for the "expected" and "actual" values, + under special methods `expected_for_diff` and `actual_for_diff`) +- `RSpec::Core::Formatters::ConsoleCodes` + (to allow for using SuperDiff's colors + and to remove the fallback in the absence of a specified color) +- `RSpec::Core::Formatters::ExceptionPresenter` + (to recolor failure output) +- `RSpec::Core::SyntaxHighlighter` + (to turn off syntax highlighting for code, + as it interferes with the previous patches) +- `RSpec::Support::ObjectFormatter` + (to use SuperDiff's object inspectors) +- `RSpec::Matchers::ExpectedsForMultipleDiffs` + (to add a key above the diff, + add spacing around the diff, + and colorize the word "Diff:") +- `RSpec::Matchers::Builtin::*` + (to reword failure messages across various matchers) +- `RSpec::Matchers` + (to reset the `an_array_matching` alias for `match_array`, + and to ensure that `match_array` preserves behavior, + as it is backed by MatchArray class specific to SuperDiff) + +## How SuperDiff's diff engine works + +With the internals of RSpec thoroughly explored, +the internals of SuperDiff can finally be enumerated. + +Once a test fails +and RSpec delegates to SuperDiff's differ, +this sequence of events occurs: + +1. `SuperDiff::Differs::Main.call` is called with a pair of values: `expected` and `actual`. + This method looks for a differ that is suitable for the pair + among a set of defaults and the list of differs registered via SuperDiff's configuration. + It does this by calling `.applies_to?` on each, + passing the `expected` and `actual`; + the first differ for whom this method returns `true` wins. + (This is a common pattern throughout the codebase.) + In most cases, if no differs are suitable, + then an error is raised, + although this is sometimes overridden. +1. Once a differ is found, + its `.call` method is called. + Since all differs inherit from `SuperDiff::Differs::Base`, + `.call` always builds an operation tree, + but the type of operation tree to build + — or, more specifically, the operation tree builder subclass — + is determined by the differ itself, + via the `operation_tree_builder_class` method. + For instance, + `SuperDiff::Differs::Array` uses a `SuperDiff::OperationTreeBuilder::Array`, + `SuperDiff::Differs::Hash` uses a `SuperDiff::OperationTreeBuilder::Hash`, + etc. +1. Once the differ has an operation tree builder, + the differ calls `.call` on it + to build an operation tree. + Different operation tree builders do different things + depending on the types of objects, + but the end goal is to iterate over both the expected and actual values in tandem, + find the differences between them, + and represent those differences as operations. + An operation may be one of four types: + `insert`, `delete`, `change`, or `noop`. + In the case of collections — + which covers most types of values — + the diff is performed recursively. + This means that just as collections can have multiple levels, + so too can operation trees. +1. Once the differ has an operation tree, + it then calls `to_diff` on it. + This method is defined in `SuperDiff::OperationTrees::Base`, + and it starts by first flattening the tree. +1. This means that we need an operation tree flattener class. + Like differs, + operation trees specify which operation tree flattener they want to use + via the `operation_tree_flattener_class` method. +1. Once the operation tree has a flattener class, + it calls `.call` on the class + to flatten the tree. +1. Different types of flatteners also do different things, + but most of them operate on collection-based operation trees. + Since operation trees can have multiple level, + the flattening must be performed recursively. + The end result is a list of Line objects. +1. Once the operation tree has been flattened, + then if the user has configured the gem to do so, + a step is performed to look for unchanged lines + (that is, operations of type `noop`) + and _elide_ them — + collapse them in such a way that the surrounding context is still visible. +1. Once a set of elided lines is obtained, + the operation tree runs them through a formatter — + so called `TieredLinesFormatter` — + which will add the `-`s and `+`s along with splashes of color + to create the final format you see at the very end. + +In summary: + +```mermaid +graph TB + DiffersMain["Differs::Main"] -- Differs --> Differ; + Differ -- Operation tree builder --> OperationTree[Operation tree]; + OperationTree -- Operation tree flattener --> Lines; + Lines -- Tiered lines elider --> ElidedLines[Elided lines]; + ElidedLines -- Tiered lines formatter --> FinalDiff[Final diff]; +``` diff --git a/docs/contributors/architecture/introduction.md b/docs/contributors/architecture/introduction.md new file mode 100644 index 00000000..3ca96d41 --- /dev/null +++ b/docs/contributors/architecture/introduction.md @@ -0,0 +1,10 @@ +# Architecture + +The SuperDiff codebase is sufficiently complex +that diving into the source code for the first time may be daunting. + +This section aims to point contributors in the right direction. +Since most of the code in this codebase services the RSpec integration, +these guides heavily skew toward that topic, +and you can start with a [guide to how RSpec works if you like](./how-rspec-works.md). +But you can also find [details around the diff engine](./how-super-diff-works.md). diff --git a/docs/contributors/how-to-contribute.md b/docs/contributors/how-to-contribute.md new file mode 100644 index 00000000..e0c1c86f --- /dev/null +++ b/docs/contributors/how-to-contribute.md @@ -0,0 +1,123 @@ +# How to Contribute + +Want to make a change to this project? +Great! Here's how you do that. + +## 1. Install dependencies + +First, [create a fork of the SuperDiff repo](https://github.com/mcmire/super_diff/fork) +and clone it to your computer. + +Next, run the following command in the resulting directory +in order to install dependencies. +This will also install a Git hook +which ensures all code is formatted whenever a commit is pushed: + +``` +bin/setup +``` + +## 2. Make a new branch + +It's best to follow [GitHub Flow][github-flow] when working on this repo. +Start by making a new branch to hold your changes: + +``` +git checkout -b +``` + +[github-flow]: https://docs.github.com/en/get-started/using-github/github-flow + +## 3. Understand the codebase + +Some architectural documents have been provided +to aid you in understanding the codebase. +You might find the guide on [how SuperDiff works](./architecture/how-super-diff-works.md) to be helpful, for example. + +## 4. Write and run tests + +All code is backed by tests, +so if you want to submit a pull request, +make sure to update the existing tests or write new ones as you find necessary. + +There are two kinds of tests in this project: + +- **Unit tests**, kept in `spec/unit`, + exercise individual classes and methods in isolation. +- **Integration tests**, kept in `spec/integration`, + exercise the interaction between SuperDiff, RSpec, and parts of Rails. + +It's best to run all of the tests after cloning SuperDiff +to establish a baseline for any changes you want to make, +but you can also run them at any time: + +``` +bundle exec rake +``` + +If you want to run one of the tests, say: + +``` +bin/rspec spec/integration/... +bin/rspec spec/unit/... +``` + +Note that the integration tests +can be quite slow to run. +If you'd like to speed them up, +run the following command in a separate terminal session: + +``` +zeus start +``` + +Now the next time you run an integration test by saying + +``` +bin/rspec spec/integration/... +``` + +it should run twice as fast. + +## 5. Run the linter + +Code is linted and formatted using Prettier, +so [make sure that's set up in your editor][prettier-editors]. +If you don't want to do this, +you can also fix any lint violations by running: + +``` +yarn lint:fix +``` + +Provided that you ran `bin/setup` above, +any code you've changed will also be linted +whenever you push a commit. + +[prettier-editors]: https://prettier.io/docs/en/editors.html + +## 6. (Optional) Update the documentation + +If there's any part of this documentation that you wish to update, +then in a free terminal session, run: + +``` +poetry run mkdocs serve +``` + +Now open `http://localhost:8000` to view a preview of the documentation locally. + +The files themselves are located in `docs/` +and are written in Markdown. +Thanks to the command above, +updating any of these files will automatically be reflected in the preview. + +## 7. Submit a pull request + +When you're done, +push your branch +and create a new pull request. +I'll try to respond as quickly as I can. +I may have suggestions about code style or your approach, +but hopefully everything looks good and your changes get merged! +Now you're a contributor! 🎉 diff --git a/docs/contributors/index.md b/docs/contributors/index.md new file mode 100644 index 00000000..61087dfe --- /dev/null +++ b/docs/contributors/index.md @@ -0,0 +1,7 @@ +# Contributor Documentation + +If you're looking to contribute to SuperDiff's codebase, +you're in the right place. + +Here you can find [instructions on setting up your development environment](./how-to-contribute.md) +as well as a [guide to the codebase itself](./architecture/introduction.md). diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..c0a60f32 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,14 @@ +--- +hide: + - navigation +--- + +# SuperDiff + +This site hosts user- and contributor-facing documentation for SuperDiff, +a Ruby gem that hooks into RSpec +to intelligently display the differences between two data structures of any type. + +- [Learn how to use SuperDiff ➤](./users/index.md) +- [Learn how to contribute to SuperDiff and dive into the source code ➤](./contributors/index.md) +- [File a bug, submit a feature request, or suggest another change ➤](https://github.com/mcmire/super_diff/issues) diff --git a/docs/users/customization.md b/docs/users/customization.md new file mode 100644 index 00000000..1cd7a55c --- /dev/null +++ b/docs/users/customization.md @@ -0,0 +1,281 @@ +# Customizing SuperDiff + +You can customize the behavior of the gem +by opening your test helper file +(`spec/rails_helper.rb` or `spec/spec_helper.rb`) +and calling `SuperDiff.configure` with a configuration block: + +```ruby +SuperDiff.configure do |config| + # ... +end +``` + +The following is a list of options you can set on the configuration object +along with their defaults: + +| name | description | default | +| ---------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `actual_color` | The color used to display "actual" values in diffs | `:yellow` | +| `border_color` | The color used to display the border in diff keys | `:blue` | +| `color_enabled` | Whether to colorize output | `true` if `ENV["CI"]` or stdout is a TTY, `false` otherwise | +| `diff_elision_enabled` | Whether to elide (remove) unchanged lines in diff | `false` | +| `diff_elision_maximum` | How large a section of consecutive unchanged lines can be before being elided | `0` | +| `elision_marker_color` | The color used to display the marker substituted for elided lines in a diff | `:cyan` | +| `expected_color` | The color used to display "expected" values in diffs | `:magenta` | +| `header_color` | The color used to display the "Diff:" header in failure messages | `:white` | +| `key_enabled` | Whether to show the key above diffs | `true` | + +The following is a list of methods you can call on the configuration object: + +| name | description | +| ------------------------------------------- | ------------------------------------------------------------------- | +| `add_extra_diff_formatter_classes` | Additional classes with which to format diffs | +| `add_extra_differ_classes` | Additional classes with which to compute diffs for objects | +| `add_extra_inspection_tree_builder_classes` | Additional classes used to inspect objects | +| `add_extra_operation_tree_builder_classes` | Additional classes used to build operation trees for objects | +| `add_extra_operation_tree_classes` | Additional classes used to hold operations in diffs between objects | + +Read on for more information about available kinds of customizations. + +### Customizing colors + +If you don't like the colors that SuperDiff uses, +you can change them like so: + +```ruby +SuperDiff.configure do |config| + config.actual_color = :green + config.expected_color = :red + config.border_color = :yellow + config.header_color = :yellow +end +``` + +See `CSI::EightBitColor` in the codebase +for the list of available colors you can use as values here. + +You can also completely disable colorized output: + +```ruby +SuperDiff.configure { |config| config.color_enabled = false } +``` + +### Disabling the key + +By default, when a diff is displayed, +a key appears above it. +This key serves to clarify +which colors and symbols belong to the "expected" and "actual" values. +However, you can disable the key as follows: + +```ruby +SuperDiff.configure { |config| config.key_enabled = false } +``` + +### Hiding unchanged lines + +When looking at a large diff made up of many lines that do not change, +it can be difficult to make out the lines that do. +Text-oriented diffs, +such as those you get from a conventional version control system, +solve this problem by removing or "eliding" those unchanged lines from the diff entirely. +The same can be done in SuperDiff. + +For instance, the following configuration enables diff elision +and ensures that within a block of unchanged lines, +a maximum of only 3 lines are displayed: + +```ruby +SuperDiff.configure do |config| + config.diff_elision_enabled = true + config.diff_elision_maximum = 3 +end +``` + +A diff in which some lines are elided may look like this: + +```diff + [ + # ... + "American Samoa", + "Andorra", +- "Angola", ++ "Anguilla", + "Antarctica", + "Antigua And Barbuda", + # ... + ] +``` + +as opposed to: + +```diff + [ + "Afghanistan", + "Aland Islands", + "Albania", + "Algeria", + "American Samoa", + "Andorra", +- "Angola", ++ "Anguilla", + "Antarctica", + "Antigua And Barbuda", + "Argentina", + "Armenia", + "Aruba", + "Australia" + ] +``` + +### Diffing custom objects + +If you are comparing two instances of a class +which are specific to your project, +the resulting diff may not look as good +as diffs involving native or primitive objects. +This happens because if SuperDiff doesn't recognize a class, +it will fall back to a generic representation for the diff. + +There are two ways to solve this problem. + +#### Adding an `attributes_for_super_diff` method + +This is the easiest approach. +If two objects have this method, +SuperDiff will use the hash that this method returns to compare those objects +and will compute a diff between them, +which will show up in the output. + +##### Example + +For instance, say we have the following classes: + +```ruby +class Http + # ... +end + +class Order + def initialize(id, number) + @id = id + @number = number + end +end + +class OrderRequestor + def initialize(order) + @order = order + @http_library = Http.new + end + + def request + @http_library.get("/orders/#{order.id}") + end +end + +class OrderTracker + def initialize(order) + @order = order + @requestor = OrderRequestor.new(order) + end +end +``` + +and we have two instances of these class as follows: + +```ruby +actual = OrderTracker.new(Order.new(id: 1, number: "1000")) +expected = OrderTracker.new(Order.new(id: 2, number: "2000")) +``` + +If we diff these two objects, +then we will see something like: + +```diff + #, +- @requestor=#, +- @http_library=# +- }> ++ @order=#, ++ @requestor=#, ++ @http_library=# ++ }> + }> +``` + +It is not difficult to see that this diff is fairly noisy. +It would be good if we could exclude `requestor`, +since it's a bit redundant, +and it would help if we could collapse some of the lines as well. +We also don't need to know the address of each object +(the `0xXXXXXXXXX` bit). + +We can easily solve this +by adding an `attributes_for_super_diff` method to OrderTracker, +making sure to exclude `requestor`, +and by adding a similar method to Order as well. + +```diff + class Order + def initialize(id, number) + @id = id + @number = number + end ++ ++ def attributes_for_super_diff ++ { id: @id, number: @number } ++ end + end + + class OrderTracker + def initialize(order) + @order = order + @requestor = OrderRequestor.new(order) + end ++ ++ def attributes_for_super_diff ++ { order: @order } ++ end + end +``` + +If we performed another diff, we would now get: + +```diff + # + }> +``` + +#### Registering new building blocks + +This approach is more advanced, +but also offers the greatest flexibility. + +More information will be added here on how to do this, +but in the meantime, +the best example is the [RSpec integration](https://github.com/mcmire/super_diff/blob/v0.11.0/lib/super_diff/rspec.rb#L91) in SuperDiff itself. diff --git a/docs/users/getting-started.md b/docs/users/getting-started.md new file mode 100644 index 00000000..019b1ec5 --- /dev/null +++ b/docs/users/getting-started.md @@ -0,0 +1,118 @@ +# Getting Started + +SuperDiff is designed to be used different ways, depending on the type of project. + +## Using SuperDiff with RSpec in a Rails app + +If you're developing a Rails app, +run the following command to add SuperDiff to your project: + +```bash +bundle add super_diff --group test +``` + +Then add the following toward the top of `spec/rails_helper.rb`: + +```ruby +require "super_diff/rspec-rails" +``` + +At this point, you can write tests for parts of your app, +and SuperDiff will be able to diff Rails-specific objects +such as ActiveRecord models, +ActionController response objects, +instances of HashWithIndifferentAccess, etc., +in addition to objects that ship with RSpec, +such as matchers. + +You can now continue on to [customizing SuperDiff](./customization.md). + +## Using SuperDiff with RSpec in a project using parts of Rails + +If you're developing an app using Hanami or Sinatra, +or merely using a part of Rails such as ActiveModel, +run the following command to add SuperDiff to your project: + +```bash +bundle add super_diff +``` + +After running `bundle install`, +add the following toward the top of `spec/spec_helper.rb`: + +```ruby +require "super_diff/rspec" +``` + +Then, add one or all of the following lines: + +```ruby +require "super_diff/active_support" +require "super_diff/active_record" +``` + +At this point, you can write tests for parts of your app, +and SuperDiff will be able to diff objects depending on which path you required. +For instance, if you required `super_diff/active_support`, +then SuperDiff will be able to diff objects defined in ActiveSupport, +such as HashWithIndifferentAccess, +and if you required `super_diff/active_record`, +it will be able to diff ActiveRecord models. +In addition to these, +it will also be able to diff objects that ship with RSpec, +such as matchers. + +You can now continue on to [customizing SuperDiff](./customization.md). + +## Using SuperDiff with RSpec in a Ruby project + +If you're developing a library or other project +that does not depend on any part of Rails, +run the following command to add SuperDiff to your project: + +```bash +bundle add super_diff +``` + +Now add the following toward the top of `spec/spec_helper.rb`: + +```ruby +require "super_diff/rspec" +``` + +At this point, you can write tests for parts of your app, +and SuperDiff will be able to diff objects that ship with RSpec, +such as matchers. + +You can now continue on to [customizing SuperDiff](./customization.md). + +## Using parts of SuperDiff directly + +Although SuperDiff is primarily designed to integrate with RSpec, +it can also be used on its own in other kinds of applications. + +First, install the gem: + +```bash +bundle add super_diff +``` + +Then, require it somewhere: + +```ruby +require "super_diff" +``` + +If you want to compare two objects and display a friendly diff, +you can use the equality matcher interface: + +```ruby +SuperDiff::EqualityMatchers::Main.call(expected, actual) +``` + +Or, if you want to compare two objects and get a lower-level list of operations, +you can use the differ interface: + +```ruby +SuperDiff::Differs::Main.call(expected, actual) +``` diff --git a/docs/users/index.md b/docs/users/index.md new file mode 100644 index 00000000..e2624697 --- /dev/null +++ b/docs/users/index.md @@ -0,0 +1,74 @@ +# Introduction to SuperDiff + +**SuperDiff** is a Ruby gem +which is designed to display the differences between two objects of any type +in a familiar and intelligent fashion. + +The primary motivation behind this gem +is to vastly improve upon RSpec's built-in diffing capabilities. +RSpec has many nice features, +and one of them is that whenever you use a matcher such as `eq`, `match`, `include`, or `have_attributes`, +you will get a diff of the two data structures you are trying to match against. +This is great if all you want to do is compare multi-line strings. +But if you want to compare other, more "real world" kinds of values such as API or database data, +then you are out of luck. +Since [RSpec merely runs your `expected` and `actual` values through Ruby's PrettyPrinter library][rspec-differ-fail] +and then performs a diff of these strings, +the output it produces leaves much to be desired. + +[rspec-differ-fail]: https://github.com/rspec/rspec-support/blob/c69a231d7369dd165ad7ce4742e1a2e21e3462b5/lib/rspec/support/differ.rb#L178 + +For instance, let's say you wanted to compare these two hashes: + +```ruby +actual = { + customer: { + person: SuperDiff::Test::Person.new(name: "Marty McFly, Jr.", age: 17), + shipping_address: { + line_1: "456 Ponderosa Ct.", + city: "Hill Valley", + state: "CA", + zip: "90382" + } + }, + items: [ + { name: "Fender Stratocaster", cost: 100_000, options: %w[red blue green] }, + { name: "Mattel Hoverboard" } + ] +} + +expected = { + customer: { + person: SuperDiff::Test::Person.new(name: "Marty McFly", age: 17), + shipping_address: { + line_1: "123 Main St.", + city: "Hill Valley", + state: "CA", + zip: "90382" + } + }, + items: [ + { name: "Fender Stratocaster", cost: 100_000, options: %w[red blue green] }, + { name: "Chevy 4x4" } + ] +} +``` + +If, somewhere in a test, you were to say: + +```ruby +expect(actual).to eq(expected) +``` + +You would get output that looks like this: + +![Before super_diff](../assets/before.png) + +What this library does +is to provide a diff engine +that knows how to figure out the differences between any two data structures +and display them in a sensible way. +So, using the example above, +you'd get this instead: + +![After super_diff](../assets/after.png) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..e2fb439d --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,48 @@ +site_name: SuperDiff Documentation +site_description: User- and contributor-facing documentation for SuperDiff +copyright: Šī¸ Elliot Winkler. + +# NOTE: Don't do this for now as it loads the version and stars dynamically +#repo_url: https://github.com/mcmire/super_diff +#repo_name: SuperDiff on GitHub + +theme: + name: material + features: + - navigation.instant + - navigation.instant.progress + - navigation.tabs + - navigation.tabs.sticky + - navigation.path + - toc.integrate + #plugins: + #- search + +nav: + - Home: index.md + - User Documentation: + - "Introduction to SuperDiff": "users/index.md" + - "Getting Started": "users/getting-started.md" + - "Customizing SuperDiff": "users/customization.md" + - Contributor Documentation: + - "Home": "contributors/index.md" + - "How to Contribute": "contributors/how-to-contribute.md" + - Architecture: + - "Introduction": "contributors/architecture/introduction.md" + - "How RSpec works": "contributors/architecture/how-rspec-works.md" + - "How SuperDiff works": "contributors/architecture/how-super-diff-works.md" + +#plugins: +#- entangled # this also runs `entangled sync` as a pre-build action + +markdown_extensions: + - admonition + - footnotes + - smarty + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..94cb9988 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[tool.poetry] +name = "super-diff" +version = "0.0.0" +description = "A more helpful way to view differences between complex data structures in RSpec" +authors = ["Elliot Winkler "] +license = "MIT" +readme = "README.md" + +[tool.poetry.group.dev.dependencies] +python = "^3.12" +mkdocs = { version = "^1.5.3", python = ">=3.7" } +mkdocs-material = { version = "^9.5.9", python = ">=3.8" } + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/scripts/collect-release-info.rb b/scripts/collect-release-info.rb new file mode 100755 index 00000000..f74a8179 --- /dev/null +++ b/scripts/collect-release-info.rb @@ -0,0 +1,19 @@ +#!/usr/bin/env ruby + +github_output = File.open(ENV.fetch("GITHUB_OUTPUT"), "a") + +spec = Gem::Specification.load("super_diff.gemspec") +current_version = spec.version + +latest_version = Gem.latest_version_for("super_diff") + +puts "Current version is #{current_version}, latest version is #{latest_version}" + +if current_version == latest_version + puts "This isn't a new release." + github_output.puts("IS_NEW_RELEASE=false") +else + puts "Looks like a new release!" + github_output.puts("IS_NEW_RELEASE=true") + github_output.puts("RELEASE_VERSION=#{current_version}") +end