├── .gitignore ├── .rspec ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console ├── gh-md-toc └── setup ├── check_please.gemspec ├── exe └── check_please ├── lib ├── check_please.rb └── check_please │ ├── cli.rb │ ├── cli │ ├── parser.rb │ └── runner.rb │ ├── comparison.rb │ ├── diff.rb │ ├── diffs.rb │ ├── error.rb │ ├── flag.rb │ ├── flags.rb │ ├── path.rb │ ├── path_segment.rb │ ├── path_segment_matcher.rb │ ├── printers.rb │ ├── printers │ ├── base.rb │ ├── json.rb │ ├── long.rb │ └── table_print.rb │ ├── reification.rb │ └── version.rb ├── spec ├── check_please │ ├── cli_spec.rb │ ├── comparison_spec.rb │ ├── diffs_spec.rb │ ├── flags_spec.rb │ ├── path_segment_spec.rb │ ├── path_spec.rb │ └── printers │ │ ├── json_spec.rb │ │ ├── long_spec.rb │ │ ├── shared.rb │ │ └── table_print_spec.rb ├── check_please_spec.rb ├── cli_integration_spec.rb ├── fixtures │ ├── cli-help-output │ ├── forty-two-candidate.json │ ├── forty-two-candidate.yaml │ ├── forty-two-expected-table │ ├── forty-two-reference.json │ ├── forty-two-reference.yaml │ ├── match-by-key-candidate.json │ └── match-by-key-reference.json ├── printers_spec.rb ├── spec_helper.rb └── support │ ├── helpers.rb │ └── matchers.rb └── usage_examples.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | /vendor/ 13 | spec/examples.txt 14 | README.md.orig.* 15 | README.md.toc.* 16 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: ruby 3 | cache: bundler 4 | rvm: 5 | - 2.6.5 6 | before_install: gem install bundler -v 2.1.4 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at geeksam@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in check_please.gemspec 4 | gemspec 5 | 6 | gem "rake", "~> 12.0" 7 | gem "rspec", "~> 3.0" 8 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | check_please (0.5.7) 5 | table_print 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | amazing_print (1.2.2) 11 | diff-lcs (1.4.4) 12 | rake (12.3.3) 13 | rspec (3.10.0) 14 | rspec-core (~> 3.10.0) 15 | rspec-expectations (~> 3.10.0) 16 | rspec-mocks (~> 3.10.0) 17 | rspec-core (3.10.0) 18 | rspec-support (~> 3.10.0) 19 | rspec-expectations (3.10.0) 20 | diff-lcs (>= 1.2.0, < 2.0) 21 | rspec-support (~> 3.10.0) 22 | rspec-mocks (3.10.0) 23 | diff-lcs (>= 1.2.0, < 2.0) 24 | rspec-support (~> 3.10.0) 25 | rspec-support (3.10.0) 26 | table_print (1.5.7) 27 | 28 | PLATFORMS 29 | ruby 30 | 31 | DEPENDENCIES 32 | amazing_print 33 | check_please! 34 | rake (~> 12.0) 35 | rspec (~> 3.0) 36 | 37 | BUNDLED WITH 38 | 2.1.4 39 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Sam Livingston-Gray 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 | # check_please 2 | 3 | Check for differences between two JSON documents, YAML documents, or Ruby data 4 | structures parsed from either of those. 5 | 6 | 7 | 8 | * [check_please](#check_please) 9 | * [Installation](#installation) 10 | * [Terminology](#terminology) 11 | * [Usage](#usage) 12 | * [From the Terminal / Command Line Interface (CLI)](#from-the-terminal--command-line-interface-cli) 13 | * [From RSpec](#from-rspec) 14 | * [From Ruby](#from-ruby) 15 | * [Understanding the Output](#understanding-the-output) 16 | * [Diff Types](#diff-types) 17 | * [Paths](#paths) 18 | * [Output Formats](#output-formats) 19 | * [Flags](#flags) 20 | * [Setting Flags in the CLI](#setting-flags-in-the-cli) 21 | * [Setting Flags in Ruby](#setting-flags-in-ruby) 22 | * [Repeatable Flags](#repeatable-flags) 23 | * [Expanded Documentation for Specific Flags](#expanded-documentation-for-specific-flags) 24 | * [Flag: match_by_key](#flag-match_by_key) 25 | * [Flag: normalize_values](#flag-normalize_values) 26 | * [TODO (maybe)](#todo-maybe) 27 | * [Development](#development) 28 | * [Contributing](#contributing) 29 | * [License](#license) 30 | * [Code of Conduct](#code-of-conduct) 31 | 32 | 33 | 34 | 35 | 36 | # Installation 37 | 38 | Add this line to your application's Gemfile: 39 | 40 | ```ruby 41 | gem 'check_please' 42 | ``` 43 | 44 | And then execute: 45 | 46 | $ bundle install 47 | 48 | Or install it yourself as: 49 | 50 | $ gem install check_please 51 | 52 | # Terminology 53 | 54 | I know, you just want to see how to use this thing. Feel free to scroll down, 55 | but be aware that CheckPlease uses a few words in a jargony way: 56 | 57 | * **Reference** is always used to refer to the "target" or "source of truth." 58 | We assume you're comparing two things because you want one of them to be like 59 | the other; the **reference** is what you're aiming for. 60 | * **Candidate** is always used to refer to some JSON you'd like to compare 61 | against the **reference**. _(We could've also used "sample," but it turns 62 | out that "reference" and "candidate" are the same length, which makes code 63 | line up neatly in a monospaced font...)_ 64 | * A **diff** is what CheckPlease calls an individual discrepancy between the 65 | **reference** and the **candidate**. More on this in "Understanding the Output", 66 | below. 67 | 68 | Also, even though this gem was born from a need to compare JSON documents, I'll 69 | be talking about "hashes" instead of "objects", because I assume this will 70 | mostly be used by Ruby developers. Feel free to substitute "object" wherever 71 | you see "hash" if that's easier for you. :) 72 | 73 | # Usage 74 | 75 | ## From the Terminal / Command Line Interface (CLI) 76 | 77 | Use the `bin/check_please` executable. (To get started, run it with the '-h' flag.) 78 | 79 | Note that the executable assumes you've saved your **reference** to a file. 80 | Once that's done, you can either save the **candidate** to a file as well if 81 | that fits your workflow, **or** you can pipe it to `bin/check_please` in lieu 82 | of giving it a second filename as the argument. (This is especially useful if 83 | you're copying an XHR response out of a web browser's dev tools and have a tool 84 | like MacOS's `pbpaste` utility.) 85 | 86 | ## From RSpec 87 | 88 | See [check_please_rspec_matcher](https://github.com/RealGeeks/check_please_rspec_matcher). 89 | 90 | If you'd like more control over the output formatting, and especially if you'd 91 | like to provide custom logic for diffing your own classes, you might be better 92 | served by the [super_diff](https://github.com/mcmire/super_diff) gem. Check it 93 | out! 94 | 95 | ## From Ruby 96 | 97 | See also: [./usage_examples.rb](usage_examples.rb). 98 | 99 | Create two strings, each containing a JSON or YAML document, and pass them to 100 | `CheckPlease.render_diff`. You'll get back a third string containing a report 101 | of all the differences CheckPlease found in the two JSON strings. 102 | 103 | Or, if you'd like to inspect the diffs in your own way, use `CheckPlease.diff` 104 | instead. You'll get back a `CheckPlease::Diffs` custom collection that 105 | contains `CheckPlease::Diff` instances. 106 | 107 | ## Understanding the Output 108 | 109 | CheckPlease follows the Unix philosophy of "no news is good news". If your 110 | **candidate** matches your **reference**, you'll get an empty message. 111 | 112 | But let's be honest: how often is that going to happen? No, you're using this 113 | tool because you want a human-friendly summary of all the places that your 114 | **candidate** fell short. 115 | 116 | When CheckPlease compares your two samples, it generates a list of diffs to 117 | describe any discrepancies it encounters. 118 | 119 | An example would probably help here. 120 | 121 | _(NOTE: these examples may fall out of date with the code. They're swiped 122 | from [the CLI integration spec](spec/cli_integration_spec.rb), so please 123 | consider that more authoritative than this README. If you do spot a 124 | difference, please feel free to open an issue!)_ 125 | 126 | Given the following **reference** JSON: 127 | ``` 128 | { 129 | "id": 42, 130 | "name": "The Answer", 131 | "words": [ "what", "do", "you", "get", "when", "you", "multiply", "six", "by", "nine" ], 132 | "meta": { "foo": "spam", "bar": "eggs", "yak": "bacon" } 133 | } 134 | ``` 135 | 136 | And the following **candidate** JSON: 137 | ``` 138 | { 139 | "id": 42, 140 | "name": [ "I am large, and contain multitudes." ], 141 | "words": [ "what", "do", "we", "get", "when", "I", "multiply", "six", "by", "nine", "dude" ], 142 | "meta": { "foo": "foo", "yak": "bacon" } 143 | } 144 | ``` 145 | 146 | CheckPlease should produce the following output: 147 | 148 | ``` 149 | TYPE | PATH | REFERENCE | CANDIDATE 150 | --------------|-----------|------------|------------------------------- 151 | type_mismatch | /name | The Answer | ["I am large, and contain m... 152 | mismatch | /words/3 | you | we 153 | mismatch | /words/6 | you | I 154 | extra | /words/11 | | dude 155 | missing | /meta/bar | eggs | 156 | mismatch | /meta/foo | spam | foo 157 | ``` 158 | 159 | Let's start with the leftmost column... 160 | 161 | ### Diff Types 162 | 163 | The above example is intended to illustrate every possible type of diff that 164 | CheckPlease defines: 165 | 166 | * **type_mismatch** means that both the **reference** and the **candidate** had 167 | a value at the given path, but one value was an Array or a Hash and the other 168 | was not. **When CheckPlease encounters a type mismatch, it does not compare 169 | anything "below" the given path.** _(Technical note: CheckPlease uses a 170 | "recursive descent" strategy to traverse the **reference** data structure, 171 | and it stops when it encounters a type mismatch in order to avoid producing a 172 | lot of "garbage" diff output.)_ 173 | * **mismatch** means that both the **reference** and the **candidate** had a 174 | value at the given path, and neither value was an Array or a Hash, and the 175 | two values were not equal. 176 | * **extra** means that, inside an Array or a Hash, the **candidate** contained 177 | elements that were not found in the **reference**. 178 | * **missing** is the opposite of **extra**: inside an Array or a Hash, the 179 | **reference** contained elements that were not found in the **candidate**. 180 | 181 | ### Paths 182 | 183 | The second column contains a path expression. This is extremely lo-fi: 184 | 185 | * The root of the data structure is defined as "/". 186 | * If an element in the data structure is an array, its child elements will have 187 | a **one-based** index appended to their parent's path. 188 | * If an element in the data structure is an object ("Hash" in Ruby), the key 189 | for each element will be appended to their parent's path, and the values will 190 | be compared. 191 | 192 | _**Being primarily a Ruby developer, I'm quite ignorant of conventions in the 193 | JS community; if there's an existing convention for paths, please open an 194 | issue!**_ 195 | 196 | ### Output Formats 197 | 198 | CheckPlease produces tabular output by default. (It leans heavily on the 199 | amazing [table_print](http://tableprintgem.com) gem for this.) 200 | 201 | If you want to incorporate CheckPlease into some other toolchain, it can also 202 | print diffs as JSON to facilitate parsing. How you do this depends on whether 203 | you're using CheckPlease from the command line or in Ruby, which is a good time 204 | to talk about... 205 | 206 | ## Flags 207 | 208 | CheckPlease has several flags that control its behavior. 209 | 210 | For quick help on which flags are available, as well as some terse help text, 211 | you can run the `check_please` executable with no arguments (or the `-h` or 212 | `--help` flags if that makes you feel better). 213 | 214 | While of course we aspire to keep this README up to date, it's probably best to 215 | believe things in the following priority order: 216 | 217 | * observed behavior 218 | * the code (start from `./lib/check_please.rb` and search for `Flags.define`, 219 | then trace through as needed) 220 | * the tests (`spec/check_please/flags_spec.rb` describes how the flags work; 221 | from there, you'll have to search on the flag's name to see how it shows up 222 | in code) 223 | * the output of `check_please --help` 224 | * this README :) 225 | 226 | All flags have exactly one "Ruby name" and one or more "CLI names". When the 227 | CLI runs, it parses the values in `ARGV` (using Ruby's native `OptionParser`) 228 | and uses that information to build a `CheckPlease::Flags` instance. After that 229 | point, a flag will be referred to within the CheckPlease code exclusively by 230 | its "Ruby name". 231 | 232 | For example, the flag that controls the format in which diffs are displayed has 233 | a Ruby name of `format`, and CLI names of `-f` and `--format`. 234 | 235 | ### Setting Flags in the CLI 236 | 237 | This should behave more or less as an experienced Unix CLI user might expect. 238 | 239 | As such, you can specify, e.g., that you want output in JSON format using 240 | either `--format json` or `-f json`. 241 | 242 | (I might expand this section some day. In the meantime, if you are not yet an 243 | experienced Unix CLI user, feel free to ask for help! You can either open an 244 | issue or look for emails in the `.gemspec` file...) 245 | 246 | ### Setting Flags in Ruby 247 | 248 | All external API entry points allow you to specify flags using their Ruby names 249 | in the idiomatic "options Hash at the end of the argument list" that should be 250 | familiar to most Rubyists. (Again, I assume that, if you're using this tool, I 251 | don't need to explain this further, but feel free to ask for help if you need 252 | it.) 253 | 254 | (Internally, CheckPlease immediately converts that options hash into a 255 | `CheckPlease::Flags` object, but that should be considered an implementation 256 | detail unless you're interested in hacking on CheckPlease itself.) 257 | 258 | For example, to get back a String containing the diffs between two data 259 | structures in JSON format, you might do: 260 | 261 | ``` 262 | reference = { "foo" => "wibble" } 263 | candidate = { "bar" => "wibble" } 264 | puts CheckPlease.render_diff( 265 | reference, 266 | candidate, 267 | format: :json # <--- flags 268 | ) 269 | ``` 270 | 271 | ### Repeatable Flags 272 | 273 | Several flags **may** be specified more than once when invoking the CLI. I've 274 | tried to make both the CLI and the Ruby API follow their respective 275 | environment's conventions. 276 | 277 | For example, if you want to specify a path to ignore using the 278 | `--reject-paths` flag, you'd invoke the CLI like this: 279 | 280 | * `[bundle exec] check_please reference.json candidate.json --select-paths /foo` 281 | 282 | And if you want to specify more than one path, that would look like: 283 | 284 | * `[bundle exec] check_please reference.json candidate.json --select-paths /foo --select-paths /bar` 285 | 286 | In Ruby, you can specify this in the options hash as a single key with an Array 287 | value: 288 | 289 | * `CheckPlease.render_diff(reference, candidate, select_paths: [ "/foo", "/bar" ])` 290 | 291 | _(NOTE TO MAINTAINERS: internally, the way `CheckPlease::CLI::Parser` uses 292 | Ruby's `OptionParser` leads to some less than obvious behavior. Search 293 | [./spec/check_please/flags_spec.rb](spec/check_please/flags_spec.rb) for the 294 | word "surprising" for details.)_ 295 | 296 | ### Expanded Documentation for Specific Flags 297 | 298 | #### Flag: `match_by_key` 299 | 300 | > **I know this looks like a LOT of information, but it's really not that 301 | > bad!** This feature just requires specific examples to describe, and talking 302 | > about it in English (rather than code) is hard. Take a moment for some deep 303 | > breaths if you need it. :) 304 | 305 | > _If you're comfortable reading RSpec and/or want to check out all the edge 306 | > cases, go look in `./spec/check_please/comparison_spec.rb` and check out the 307 | > `describe` block labeled `"comparing arrays by keys"`._ 308 | 309 | The `match_by_key` flag allows you to match up arrays of hashes using the value 310 | of a single key that is treated as the identifier for each hash. 311 | 312 | There's a lot going on in that sentence, so let's unpack it a bit. 313 | 314 | Imagine you're comparing two documents that contain the same data, but in 315 | different orders. To use a contrived example, let's say that both documents 316 | consist of a single array of two simple hashes, but the reference array and the 317 | candidate array are reversed: 318 | 319 | ```ruby 320 | # REFERENCE 321 | [ { "id" => 1, "foo" => "bar" }, { "id" => 2, "foo" => "spam" } ] 322 | 323 | # CANDIDATE 324 | [ { "id" => 2, "foo" => "spam" }, { "id" => 1, "foo" => "bar" } ] 325 | ``` 326 | 327 | By default, CheckPlease will match up array elements by their position in the 328 | array, resulting in a diff report like this: 329 | 330 | ``` 331 | TYPE | PATH | REFERENCE | CANDIDATE 332 | ---------|--------|-----------|---------- 333 | mismatch | /1/id | 1 | 2 334 | mismatch | /1/foo | "bar" | "bat" 335 | mismatch | /2/id | 2 | 1 336 | mismatch | /2/foo | "bat" | "bar" 337 | ``` 338 | 339 | To solve this problem, CheckPlease adds a **key expression** to its (very 340 | simple) path syntax that lets you specify a **key** to use to match up elements 341 | in both lists, rather than simply comparing elements by position. 342 | 343 | Continuing with the above example, if we give `match_by_key` a value of 344 | `["/:id"]`, it will use the "id" value in both hashes (remember, A's `id` is 345 | `1` and B's `id` is `2`) to identify every element in both the reference array 346 | and the candidate array, and correctly match A and B, giving you an empty list 347 | of diffs. 348 | 349 | Please note that the CLI and Ruby implementations of these are a bit different 350 | (see "Setting Flags in the CLI" versus "Setting Flags in Ruby"), so if you're 351 | doing this from the command line, it'll look like: `--match-by-key /:id` 352 | 353 | Here, have another example. If you want to specify a match_by_key expression 354 | below the root of the document, you can put the **key expression** further down 355 | the path: `/books/:isbn` 356 | 357 | This would correctly match up the following documents: 358 | 359 | ```ruby 360 | # REFERENCE 361 | { 362 | "books" => [ 363 | { "isbn" => "12345", "title" => "Who Am I, Really?" }, 364 | { "isbn" => "67890", "title" => "Who Are Any Of Us, Really?" }, 365 | ] 366 | } 367 | 368 | # CANDIDATE 369 | { 370 | "books" => [ 371 | { "isbn" => "67890", "title" => "Who Are Any Of Us, Really?" }, 372 | { "isbn" => "12345", "title" => "Who Am I, Really?" }, 373 | ] 374 | } 375 | ``` 376 | 377 | Finally, if you have deeply nested data with arrays of hashes at multiple 378 | levels, you can specify more than one **key expression** in a single path, 379 | like: `/authors/:id/books/:isbn` 380 | 381 | This would correctly match up the following documents: 382 | 383 | ```ruby 384 | # REFERENCE 385 | { 386 | "authors" => [ 387 | { 388 | "id" => 1, 389 | "name" => "Anne Onymous", 390 | "books" => [ 391 | { "isbn" => "12345", "title" => "Who Am I, Really?" }, 392 | { "isbn" => "67890", "title" => "Who Are Any Of Us, Really?" }, 393 | ] 394 | }, 395 | ] 396 | } 397 | 398 | # CANDIDATE 399 | { 400 | "authors" => [ 401 | { 402 | "id" => 1, 403 | "name" => "Anne Onymous", 404 | "books" => [ 405 | { "isbn" => "67890", "title" => "Who Are Any Of Us, Really?" }, 406 | { "isbn" => "12345", "title" => "Who Am I, Really?" }, 407 | ] 408 | }, 409 | ] 410 | } 411 | ``` 412 | 413 | Finally, if there are any diffs to report, CheckPlease uses a **key/value 414 | expression** to report mismatches. 415 | 416 | Using the last example above (the one with `/authors/:id/books/:isbn`), if the 417 | reference had Anne Onymous' book title as "Who Am I, Really?" and the candidate 418 | listed it as "Who The Heck Am I?", CheckPlease would show the mismatch using 419 | the following path expression: `/authors/id=1/books/isbn=12345` 420 | 421 | **This syntax is intended to be readable by humans first.** If you need to 422 | build tooling that consumes it... well, I'm open to suggestions. :) 423 | 424 | #### Flag: `normalize_values` 425 | 426 | NOTE: This flag is only accessible via the Ruby API. 427 | (I have no idea how to reasonably express it in a CLI flag.) 428 | 429 | Before comparing values at specified paths, normalize both values using 430 | the provided message or Proc. 431 | 432 | To use an example from the tests, the following reference/candidate pair would 433 | normally create three "mismatch" diffs: 434 | 435 | ```ruby 436 | ref = { list: [ "foo", "bar", "yak" ] } 437 | can = { list: [ :foo, :bar, :yak ] } 438 | ``` 439 | 440 | However, the values can be converted to String before comparison via any of the following: 441 | 442 | ```ruby 443 | CheckPlease.diff(ref, can, normalize_values: { "/list/*" => ->(v) { v.to_s } }) 444 | CheckPlease.diff(ref, can, normalize_values: { "/list/*" => :to_s }) 445 | CheckPlease.diff(ref, can, normalize_values: { "/list/*" => "to_s" }) 446 | ``` 447 | 448 | Note that the value of the flag is a Hash. 449 | * Its keys must be strings representing path expressions. 450 | * If the value associated with a given path is a lambda/proc, it will be 451 | called with both the reference value and the candidate value. 452 | * If the value is a String or Symbol, it will be sent as a message to 453 | both the reference and candidate values using Object#send. 454 | 455 | ----- 456 | 457 | # TODO (maybe) 458 | 459 | * document flags for rspec matcher 460 | * command line flags for :allthethings:! 461 | * change display width for table format 462 | (for example, "2020-07-16T19:42:41.312978" gets cut off) 463 | * sort by path? 464 | * detect timestamps and compare after parsing? 465 | * ignore sub-second precision (option / CLI flag)? 466 | * possibly support plugins for other folks to add custom coercions? 467 | * display filters? (e.g., { a: 1, b: 2 } ==> "Hash#3") 468 | * shorter descriptions of values with different classes 469 | (but maybe just the existing :type_mismatch diffs?) 470 | * another "possibly support plugins" expansion point here 471 | * more output formats, maybe? 472 | * [0xABAD1DEA] support wildcards in --select-paths and --reject-paths? 473 | * `#` for indexes, `**` to match one or more path segments? 474 | (This could get ugly fast.) 475 | * [0xABAD1DEA] look for a config file in ./.check_please_config or ~/.check_please_config, 476 | combine flags found there with those in ARGV in order of precedence: 477 | 1) ARGV 478 | 2) ./.check_please 479 | 3) ~/.check_please 480 | * but this may not actually be worth the time and complexity to implement, so 481 | think about this first... 482 | 483 | ----- 484 | 485 | # Development 486 | 487 | After checking out the repo, run `bin/setup` to install dependencies. Then, run 488 | `rake spec` to run the tests. You can also run `bin/console` for an interactive 489 | prompt that will allow you to experiment. 490 | 491 | To install this gem onto your local machine, run `bundle exec rake install`. To 492 | release a new version, update the version number in `version.rb`, and then run 493 | `bundle exec rake release`, which will create a git tag for the version, push 494 | git commits and tags, and push the `.gem` file to 495 | [rubygems.org](https://rubygems.org). 496 | 497 | # Contributing 498 | 499 | Bug reports and pull requests are welcome on GitHub at 500 | https://github.com/RealGeeks/check_please. This project is intended to be a 501 | safe, welcoming space for collaboration, and contributors are expected to 502 | adhere to the [code of 503 | conduct](https://github.com/RealGeeks/check_please/blob/master/CODE_OF_CONDUCT.md). 504 | 505 | # License 506 | 507 | The gem is available as open source under the terms of the [MIT 508 | License](https://opensource.org/licenses/MIT). 509 | 510 | # Code of Conduct 511 | 512 | Everyone interacting in the CheckPlease project's codebases, issue trackers, 513 | chat rooms and mailing lists is expected to follow the [code of 514 | conduct](https://github.com/RealGeeks/check_please/blob/master/CODE_OF_CONDUCT.md). 515 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | require "pathname" 4 | require "tempfile" 5 | 6 | 7 | PROJECT_ROOT = Pathname.new(File.dirname(__FILE__)) 8 | 9 | 10 | namespace :spec do 11 | desc "All tests *except* those that exercise the executable CLI" 12 | RSpec::Core::RakeTask.new(:not_cli) do |t| 13 | t.rspec_opts = "--tag ~cli" 14 | end 15 | 16 | desc "fast tests only" 17 | task fast: :not_cli 18 | 19 | # These are much slower than the rest, since they use Kernel#` 20 | desc "Only tests that exercise the executable CLI (slower)" 21 | RSpec::Core::RakeTask.new(:cli) do |t| 22 | t.rspec_opts = "--tag cli" 23 | end 24 | 25 | desc "approve changes to the CLI's `--help` output" 26 | task :approve_cli_help_output do 27 | output = `exe/check_please` 28 | File.open(PROJECT_ROOT.join("spec/fixtures/cli-help-output"), "w") do |f| 29 | f << output 30 | end 31 | end 32 | end 33 | 34 | # By default, `rake spec` should run fast specs first, then cli if those all pass 35 | desc "Run all tests (fast tests first, then the slower CLI ones)" 36 | task :spec => [ "spec:fast", "spec:cli" ] 37 | 38 | task :default => :spec 39 | 40 | 41 | 42 | 43 | desc "Generate TOC for the README" 44 | task :toc do 45 | # the `--no-backup` flag skips the creation of README.md.* backup files, 46 | # WHICH IS FINE because we're using Git 47 | puts "generating TOC..." 48 | `bin/gh-md-toc --no-backup README.md` 49 | 50 | # Now, strip out the 'Added by:` line so we can detect if there were actual changes 51 | # Use a tempfile just in case sed barfs, I guess? 52 | tmp = Tempfile.new('check-please-readme') 53 | begin 54 | `sed '/Added by: /d' README.md > #{tmp.path}` 55 | FileUtils.mv tmp.path, PROJECT_ROOT.join("README.md") 56 | ensure 57 | tmp.close 58 | tmp.unlink 59 | end 60 | end 61 | 62 | # Okay, so. We want the TOC to be up to date *before* the `release` task runs. 63 | # 64 | # We tried making the 'toc' task a dependency of 'release', but that just adds 65 | # it to the end of the dependencies, and generates the TOC after publishing. 66 | # 67 | # Trying the 'build' task instead... 68 | task :build => :toc 69 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "check_please" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/gh-md-toc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # 4 | # Steps: 5 | # 6 | # 1. Download corresponding html file for some README.md: 7 | # curl -s $1 8 | # 9 | # 2. Discard rows where no substring 'user-content-' (github's markup): 10 | # awk '/user-content-/ { ... 11 | # 12 | # 3.1 Get last number in each row like ' ... sitemap.js.*<\/h/)+2, RLENGTH-5) 21 | # 22 | # 5. Find anchor and insert it inside "(...)": 23 | # substr($0, match($0, "href=\"[^\"]+?\" ")+6, RLENGTH-8) 24 | # 25 | 26 | gh_toc_version="0.7.0" 27 | 28 | gh_user_agent="gh-md-toc v$gh_toc_version" 29 | 30 | # 31 | # Download rendered into html README.md by its url. 32 | # 33 | # 34 | gh_toc_load() { 35 | local gh_url=$1 36 | 37 | if type curl &>/dev/null; then 38 | curl --user-agent "$gh_user_agent" -s "$gh_url" 39 | elif type wget &>/dev/null; then 40 | wget --user-agent="$gh_user_agent" -qO- "$gh_url" 41 | else 42 | echo "Please, install 'curl' or 'wget' and try again." 43 | exit 1 44 | fi 45 | } 46 | 47 | # 48 | # Converts local md file into html by GitHub 49 | # 50 | # -> curl -X POST --data '{"text": "Hello world github/linguist#1 **cool**, and #1!"}' https://api.github.com/markdown 51 | #

Hello world github/linguist#1 cool, and #1!

'" 52 | gh_toc_md2html() { 53 | local gh_file_md=$1 54 | URL=https://api.github.com/markdown/raw 55 | 56 | if [ ! -z "$GH_TOC_TOKEN" ]; then 57 | TOKEN=$GH_TOC_TOKEN 58 | else 59 | TOKEN_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/token.txt" 60 | if [ -f "$TOKEN_FILE" ]; then 61 | TOKEN="$(cat $TOKEN_FILE)" 62 | fi 63 | fi 64 | if [ ! -z "${TOKEN}" ]; then 65 | AUTHORIZATION="Authorization: token ${TOKEN}" 66 | fi 67 | 68 | # echo $URL 1>&2 69 | OUTPUT=$(curl -s \ 70 | --user-agent "$gh_user_agent" \ 71 | --data-binary @"$gh_file_md" \ 72 | -H "Content-Type:text/plain" \ 73 | -H "$AUTHORIZATION" \ 74 | "$URL") 75 | 76 | if [ "$?" != "0" ]; then 77 | echo "XXNetworkErrorXX" 78 | fi 79 | if [ "$(echo "${OUTPUT}" | awk '/API rate limit exceeded/')" != "" ]; then 80 | echo "XXRateLimitXX" 81 | else 82 | echo "${OUTPUT}" 83 | fi 84 | } 85 | 86 | 87 | # 88 | # Is passed string url 89 | # 90 | gh_is_url() { 91 | case $1 in 92 | https* | http*) 93 | echo "yes";; 94 | *) 95 | echo "no";; 96 | esac 97 | } 98 | 99 | # 100 | # TOC generator 101 | # 102 | gh_toc(){ 103 | local gh_src=$1 104 | local gh_src_copy=$1 105 | local gh_ttl_docs=$2 106 | local need_replace=$3 107 | local no_backup=$4 108 | 109 | if [ "$gh_src" = "" ]; then 110 | echo "Please, enter URL or local path for a README.md" 111 | exit 1 112 | fi 113 | 114 | 115 | # Show "TOC" string only if working with one document 116 | if [ "$gh_ttl_docs" = "1" ]; then 117 | 118 | echo "Table of Contents" 119 | echo "=================" 120 | echo "" 121 | gh_src_copy="" 122 | 123 | fi 124 | 125 | if [ "$(gh_is_url "$gh_src")" == "yes" ]; then 126 | gh_toc_load "$gh_src" | gh_toc_grab "$gh_src_copy" 127 | if [ "${PIPESTATUS[0]}" != "0" ]; then 128 | echo "Could not load remote document." 129 | echo "Please check your url or network connectivity" 130 | exit 1 131 | fi 132 | if [ "$need_replace" = "yes" ]; then 133 | echo 134 | echo "!! '$gh_src' is not a local file" 135 | echo "!! Can't insert the TOC into it." 136 | echo 137 | fi 138 | else 139 | local rawhtml=$(gh_toc_md2html "$gh_src") 140 | if [ "$rawhtml" == "XXNetworkErrorXX" ]; then 141 | echo "Parsing local markdown file requires access to github API" 142 | echo "Please make sure curl is installed and check your network connectivity" 143 | exit 1 144 | fi 145 | if [ "$rawhtml" == "XXRateLimitXX" ]; then 146 | echo "Parsing local markdown file requires access to github API" 147 | echo "Error: You exceeded the hourly limit. See: https://developer.github.com/v3/#rate-limiting" 148 | TOKEN_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/token.txt" 149 | echo "or place GitHub auth token here: ${TOKEN_FILE}" 150 | exit 1 151 | fi 152 | local toc=`echo "$rawhtml" | gh_toc_grab "$gh_src_copy"` 153 | echo "$toc" 154 | if [ "$need_replace" = "yes" ]; then 155 | if grep -Fxq "" $gh_src && grep -Fxq "" $gh_src; then 156 | echo "Found markers" 157 | else 158 | echo "You don't have or in your file...exiting" 159 | exit 1 160 | fi 161 | local ts="<\!--ts-->" 162 | local te="<\!--te-->" 163 | local dt=`date +'%F_%H%M%S'` 164 | local ext=".orig.${dt}" 165 | local toc_path="${gh_src}.toc.${dt}" 166 | local toc_footer="" 167 | # http://fahdshariff.blogspot.ru/2012/12/sed-mutli-line-replacement-between-two.html 168 | # clear old TOC 169 | sed -i${ext} "/${ts}/,/${te}/{//!d;}" "$gh_src" 170 | # create toc file 171 | echo "${toc}" > "${toc_path}" 172 | echo -e "\n${toc_footer}\n" >> "$toc_path" 173 | # insert toc file 174 | if [[ "`uname`" == "Darwin" ]]; then 175 | sed -i "" "/${ts}/r ${toc_path}" "$gh_src" 176 | else 177 | sed -i "/${ts}/r ${toc_path}" "$gh_src" 178 | fi 179 | echo 180 | if [ $no_backup = "yes" ]; then 181 | rm ${toc_path} ${gh_src}${ext} 182 | fi 183 | echo "!! TOC was added into: '$gh_src'" 184 | if [ -z $no_backup ]; then 185 | echo "!! Origin version of the file: '${gh_src}${ext}'" 186 | echo "!! TOC added into a separate file: '${toc_path}'" 187 | fi 188 | echo 189 | fi 190 | fi 191 | } 192 | 193 | # 194 | # Grabber of the TOC from rendered html 195 | # 196 | # $1 - a source url of document. 197 | # It's need if TOC is generated for multiple documents. 198 | # 199 | gh_toc_grab() { 200 | common_awk_script=' 201 | modified_href = "" 202 | split(href, chars, "") 203 | for (i=1;i <= length(href); i++) { 204 | c = chars[i] 205 | res = "" 206 | if (c == "+") { 207 | res = " " 208 | } else { 209 | if (c == "%") { 210 | res = "\\\\x" 211 | } else { 212 | res = c "" 213 | } 214 | } 215 | modified_href = modified_href res 216 | } 217 | print sprintf("%*s", level*3, " ") "* [" text "](" gh_url modified_href ")" 218 | ' 219 | if [ `uname -s` == "OS/390" ]; then 220 | grepcmd="pcregrep -o" 221 | echoargs="" 222 | awkscript='{ 223 | level = substr($0, length($0), 1) 224 | text = substr($0, match($0, /a>.*<\/h/)+2, RLENGTH-5) 225 | href = substr($0, match($0, "href=\"([^\"]+)?\"")+6, RLENGTH-7) 226 | '"$common_awk_script"' 227 | }' 228 | else 229 | grepcmd="grep -Eo" 230 | echoargs="-e" 231 | awkscript='{ 232 | level = substr($0, length($0), 1) 233 | text = substr($0, match($0, /a>.*<\/h/)+2, RLENGTH-5) 234 | href = substr($0, match($0, "href=\"[^\"]+?\"")+6, RLENGTH-7) 235 | '"$common_awk_script"' 236 | }' 237 | fi 238 | href_regex='href=\"[^\"]+?\"' 239 | 240 | # if closed is on the new line, then move it on the prev line 241 | # for example: 242 | # was: The command foo1 243 | # 244 | # became: The command foo1 245 | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n<\/h/<\/h/g' | 246 | 247 | # find strings that corresponds to template 248 | $grepcmd '//g' | sed 's/<\/code>//g' | 252 | 253 | # remove g-emoji 254 | sed 's/]*[^<]*<\/g-emoji> //g' | 255 | 256 | # now all rows are like: 257 | # ... / placeholders" 286 | echo " $app_name - Create TOC for markdown from STDIN" 287 | echo " $app_name --help Show help" 288 | echo " $app_name --version Show version" 289 | return 290 | fi 291 | 292 | if [ "$1" = '--version' ]; then 293 | echo "$gh_toc_version" 294 | echo 295 | echo "os: `lsb_release -d | cut -f 2`" 296 | echo "kernel: `cat /proc/version`" 297 | echo "shell: `$SHELL --version`" 298 | echo 299 | for tool in curl wget grep awk sed; do 300 | printf "%-5s: " $tool 301 | echo `$tool --version | head -n 1` 302 | done 303 | return 304 | fi 305 | 306 | if [ "$1" = "-" ]; then 307 | if [ -z "$TMPDIR" ]; then 308 | TMPDIR="/tmp" 309 | elif [ -n "$TMPDIR" -a ! -d "$TMPDIR" ]; then 310 | mkdir -p "$TMPDIR" 311 | fi 312 | local gh_tmp_md 313 | if [ `uname -s` == "OS/390" ]; then 314 | local timestamp=$(date +%m%d%Y%H%M%S) 315 | gh_tmp_md="$TMPDIR/tmp.$timestamp" 316 | else 317 | gh_tmp_md=$(mktemp $TMPDIR/tmp.XXXXXX) 318 | fi 319 | while read input; do 320 | echo "$input" >> "$gh_tmp_md" 321 | done 322 | gh_toc_md2html "$gh_tmp_md" | gh_toc_grab "" 323 | return 324 | fi 325 | 326 | if [ "$1" = '--insert' ]; then 327 | need_replace="yes" 328 | shift 329 | fi 330 | 331 | if [ "$1" = '--no-backup' ]; then 332 | need_replace="yes" 333 | no_backup="yes" 334 | shift 335 | fi 336 | for md in "$@" 337 | do 338 | echo "" 339 | gh_toc "$md" "$#" "$need_replace" "$no_backup" 340 | done 341 | 342 | echo "" 343 | echo "Created by [gh-md-toc](https://github.com/ekalinin/github-markdown-toc)" 344 | } 345 | 346 | # 347 | # Entry point 348 | # 349 | gh_toc_app "$@" 350 | 351 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /check_please.gemspec: -------------------------------------------------------------------------------- 1 | require_relative 'lib/check_please/version' 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "check_please" 5 | spec.version = CheckPlease::VERSION 6 | spec.authors = ["Sam Livingston-Gray"] 7 | spec.email = ["geeksam@gmail.com"] 8 | 9 | spec.summary = %q{Check for differences between two JSON strings (or Ruby data structures parsed from them)} 10 | spec.description = %q{Check for differences between two JSON strings (or Ruby data structures parsed from them)} 11 | spec.homepage = "https://github.com/RealGeeks/check_please" 12 | spec.license = "MIT" 13 | spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0") 14 | 15 | spec.metadata["homepage_uri"] = spec.homepage 16 | # spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here." 17 | # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." 18 | 19 | # Specify which files should be added to the gem when it is released. 20 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 21 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do 22 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 23 | end 24 | spec.bindir = "exe" 25 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 26 | spec.require_paths = ["lib"] 27 | 28 | spec.add_dependency "table_print" 29 | 30 | spec.add_development_dependency "rspec", "~> 3.9" 31 | spec.add_development_dependency "amazing_print" 32 | end 33 | -------------------------------------------------------------------------------- /exe/check_please: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'check_please' 4 | CheckPlease::CLI.run(__FILE__) 5 | -------------------------------------------------------------------------------- /lib/check_please.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | require 'json' 3 | 4 | 5 | # easier to just require these 6 | require "check_please/error" 7 | require "check_please/version" 8 | 9 | module CheckPlease 10 | autoload :Reification, "check_please/reification" 11 | autoload :CLI, "check_please/cli" 12 | autoload :Comparison, "check_please/comparison" 13 | autoload :Diff, "check_please/diff" 14 | autoload :Diffs, "check_please/diffs" 15 | autoload :Flag, "check_please/flag" 16 | autoload :Flags, "check_please/flags" 17 | autoload :Path, "check_please/path" 18 | autoload :PathSegment, "check_please/path_segment" 19 | autoload :PathSegmentMatcher, "check_please/path_segment_matcher" 20 | autoload :Printers, "check_please/printers" 21 | end 22 | 23 | 24 | 25 | module CheckPlease 26 | ELEVATOR_PITCH = "Tool for parsing and diffing two JSON documents." 27 | 28 | def self.diff(reference, candidate, flags = {}) 29 | reference = maybe_parse(reference) 30 | candidate = maybe_parse(candidate) 31 | Comparison.perform(reference, candidate, flags) 32 | end 33 | 34 | def self.render_diff(reference, candidate, flags = {}) 35 | diffs = diff(reference, candidate, flags) 36 | Printers.render(diffs, flags) 37 | end 38 | 39 | class << self 40 | private 41 | 42 | # Maybe you gave us JSON strings, maybe you gave us Ruby objects. 43 | # Heck, maybe you even gave us some YAML! We just don't know! 44 | # That's what makes it so exciting! 45 | def maybe_parse(document) 46 | 47 | case document 48 | when String ; return YAML.load(document) # don't worry, if this raises we'll assume you've already parsed it 49 | else ; return document 50 | end 51 | 52 | rescue JSON::ParserError, Psych::SyntaxError 53 | return document 54 | end 55 | end 56 | 57 | 58 | 59 | Flags.define :format do |flag| 60 | allowed_values = CheckPlease::Printers::FORMATS.sort 61 | 62 | flag.coerce &:to_sym 63 | flag.default = CheckPlease::Printers::DEFAULT_FORMAT 64 | flag.validate { |flags, value| allowed_values.include?(value) } 65 | 66 | flag.cli_long = "--format FORMAT" 67 | flag.cli_short = "-f FORMAT" 68 | flag.description = <<~EOF 69 | Format in which to present diffs. 70 | (Allowed values: [#{allowed_values.join(", ")}]) 71 | EOF 72 | end 73 | 74 | Flags.define :max_diffs do |flag| 75 | flag.coerce &:to_i 76 | flag.validate { |flags, value| value.to_i > 0 } 77 | 78 | flag.cli_long = "--max-diffs MAX_DIFFS" 79 | flag.cli_short = "-n MAX_DIFFS" 80 | flag.description = "Stop after encountering a specified number of diffs." 81 | end 82 | 83 | Flags.define :fail_fast do |flag| 84 | flag.default = false 85 | flag.coerce { |value| !!value } 86 | flag.cli_long = "--fail-fast" 87 | flag.description = <<~EOF 88 | Stop after encountering the first diff. 89 | (equivalent to '--max-diffs 1') 90 | EOF 91 | end 92 | 93 | Flags.define :max_depth do |flag| 94 | flag.coerce &:to_i 95 | flag.validate { |flags, value| value.to_i > 0 } 96 | 97 | flag.cli_long = "--max_depth MAX_DEPTH" 98 | flag.cli_short = "-d MAX_DEPTH" 99 | flag.description = <<~EOF 100 | Limit the number of levels to descend when comparing documents. 101 | (NOTE: root has depth = 1) 102 | EOF 103 | end 104 | 105 | Flags.define :select_paths do |flag| 106 | flag.repeatable 107 | flag.mutually_exclusive_to :reject_paths 108 | flag.coerce { |value| CheckPlease::Path.reify(value) } 109 | 110 | flag.cli_short = "-s PATH_EXPR" 111 | flag.cli_long = "--select-paths PATH_EXPR" 112 | flag.description = <<~EOF 113 | ONLY record diffs matching the provided PATH expression. 114 | May be repeated; values will be treated as an 'OR' list. 115 | Can't be combined with --reject-paths. 116 | EOF 117 | end 118 | 119 | Flags.define :reject_paths do |flag| 120 | flag.repeatable 121 | flag.mutually_exclusive_to :select_paths 122 | flag.coerce { |value| CheckPlease::Path.reify(value) } 123 | 124 | flag.cli_short = "-r PATH_EXPR" 125 | flag.cli_long = "--reject-paths PATH_EXPR" 126 | flag.description = <<~EOF 127 | DON'T record diffs matching the provided PATH expression. 128 | May be repeated; values will be treated as an 'OR' list. 129 | Can't be combined with --select-paths. 130 | EOF 131 | end 132 | 133 | Flags.define :match_by_key do |flag| 134 | flag.repeatable 135 | flag.coerce { |value| CheckPlease::Path.reify(value) } 136 | 137 | flag.cli_long = "--match-by-key FOO" 138 | flag.description = <<~EOF 139 | Specify how to match reference/candidate pairs in arrays of hashes. 140 | May be repeated; values will be treated as an 'OR' list. 141 | See the README for details on how to actually use this. 142 | NOTE: this does not yet handle non-string keys. 143 | EOF 144 | end 145 | 146 | Flags.define :match_by_value do |flag| 147 | flag.repeatable 148 | flag.coerce { |value| CheckPlease::Path.reify(value) } 149 | 150 | flag.cli_long = "--match-by-value FOO" 151 | flag.description = <<~EOF 152 | When comparing two arrays that match a specified path, the candidate 153 | array will be scanned for each element in the reference array. 154 | May be repeated; values will be treated as an 'OR' list. 155 | NOTE: explodes if either array at a given path contains other collections. 156 | NOTE: paths of 'extra' diffs use the index in the candidate array. 157 | EOF 158 | end 159 | 160 | Flags.define :indifferent_keys do |flag| 161 | flag.default = false 162 | flag.coerce { |value| !!value } 163 | 164 | flag.cli_long = "--indifferent-keys" 165 | flag.description = <<~EOF 166 | When comparing hashes, convert symbol keys to strings 167 | EOF 168 | end 169 | 170 | Flags.define :indifferent_values do |flag| 171 | flag.default = false 172 | flag.coerce { |value| !!value } 173 | 174 | flag.cli_long = "--indifferent-values" 175 | flag.description = <<~EOF 176 | When comparing values (that aren't arrays or hashes), convert symbols to strings 177 | EOF 178 | end 179 | 180 | Flags.define :normalize_values do |flag| 181 | # NOTE: This flag is only accessible via the Ruby API. 182 | # See the README for documentation. 183 | end 184 | 185 | end 186 | -------------------------------------------------------------------------------- /lib/check_please/cli.rb: -------------------------------------------------------------------------------- 1 | module CheckPlease 2 | 3 | module CLI 4 | autoload :Runner, "check_please/cli/parser" 5 | autoload :Parser, "check_please/cli/runner" 6 | 7 | def self.run(exe_file_name) 8 | Runner.new(exe_file_name).run(*ARGV.dup) 9 | end 10 | end 11 | 12 | end 13 | -------------------------------------------------------------------------------- /lib/check_please/cli/parser.rb: -------------------------------------------------------------------------------- 1 | require 'optparse' 2 | 3 | module CheckPlease 4 | module CLI 5 | 6 | class Parser 7 | def initialize(exe_file_name) 8 | @exe_file_name = File.basename(exe_file_name) 9 | end 10 | 11 | # Unfortunately, OptionParser *really* wants to use closures. I haven't 12 | # yet figured out how to get around this, but at least it's closing on a 13 | # local instead of an ivar... progress? 14 | def flags_from_args!(args) 15 | flags = Flags.new 16 | optparse = option_parser(flags: flags) 17 | optparse.parse!(args) # removes recognized flags from `args` 18 | return flags 19 | rescue OptionParser::InvalidOption, OptionParser::AmbiguousOption => e 20 | raise InvalidFlag, e.message, cause: e 21 | end 22 | 23 | def help 24 | option_parser.help 25 | end 26 | 27 | private 28 | 29 | # NOTE: if flags is nil, you'll get something that can print help, but will explode when sent :parse 30 | def option_parser(flags: nil) 31 | OptionParser.new.tap do |optparse| 32 | optparse.banner = banner 33 | CheckPlease::Flags.each_flag do |flag| 34 | args = [ flag.cli_short, flag.cli_long, flag.description ].flatten.compact 35 | optparse.on(*args) do |value| 36 | flags.send "#{flag.name}=", value 37 | end 38 | end 39 | end 40 | end 41 | 42 | def banner 43 | <<~EOF 44 | Usage: #{@exe_file_name} [FLAGS] 45 | 46 | #{CheckPlease::ELEVATOR_PITCH} 47 | 48 | Arguments: 49 | is the name of a file to use as, well, the reference. 50 | is the name of a file to compare against the reference. 51 | 52 | NOTE: If you have a utility like MacOS's `pbpaste`, you MAY omit 53 | the arg, and pipe the second document instead, like: 54 | 55 | $ pbpaste | #{@exe_file_name} 56 | 57 | FLAGS: 58 | EOF 59 | end 60 | end 61 | 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/check_please/cli/runner.rb: -------------------------------------------------------------------------------- 1 | module CheckPlease 2 | module CLI 3 | 4 | class Runner 5 | def initialize(exe_file_name) 6 | @parser = Parser.new(exe_file_name) 7 | end 8 | 9 | # NOTE: unusually for me, I'm using Ruby's `or` keyword in this method. 10 | # `or` short circuits just like `||`, but has lower precedence, which 11 | # enables some shenanigans... 12 | def run(*args) 13 | args.flatten! 14 | print_help_and_exit if args.empty? 15 | 16 | begin 17 | flags = @parser.flags_from_args!(args) 18 | rescue InvalidFlag => e 19 | print_help_and_exit e.message 20 | end 21 | 22 | # The reference MUST be the first arg... 23 | reference = \ 24 | read_file(args.shift) \ 25 | or print_help_and_exit "Missing argument" 26 | 27 | # The candidate MAY be the second arg, or it might have been piped in... 28 | candidate = \ 29 | read_file(args.shift) \ 30 | || read_piped_stdin \ 31 | or print_help_and_exit "Missing argument, AND nothing was piped in" 32 | 33 | # Looks like we're good to go! 34 | diff_view = CheckPlease.render_diff(reference, candidate, flags) 35 | puts diff_view 36 | end 37 | 38 | 39 | 40 | private 41 | 42 | def print_help_and_exit(message = nil) 43 | puts "\n>>> #{message}\n\n" if message 44 | puts @parser.help 45 | exit 46 | end 47 | 48 | def read_file(filename) 49 | return nil if filename.nil? 50 | File.read(filename) 51 | rescue Errno::ENOENT 52 | return nil 53 | end 54 | 55 | # Unfortunately, ARGF won't help us here because it doesn't seem to want to 56 | # read from stdin after it's already pulled a file out of ARGV. So, we 57 | # have to read from stdin ourselves. 58 | # 59 | # BUT THAT'S NOT ALL! If the user didn't actually pipe any data, 60 | # $stdin.read will block until they manually send EOF or hit Ctrl+C. 61 | # 62 | # Fortunately, we can detect whether $stdin.read will block by checking to 63 | # see if it is a TTY. (Wait, what century is this again?) 64 | # 65 | # For fun and posterity, here's an experiment you can use to demonstrate this: 66 | # 67 | # $ ruby -e 'puts $stdin.tty? ? "YES YOU ARE A TTY" : "nope, no tty here"' 68 | # YES YOU ARE A TTY 69 | # 70 | # $ cat foo | ruby -e 'puts $stdin.tty? ? "YES YOU ARE A TTY" : "nope, no tty here"' 71 | # nope, no tty here 72 | def read_piped_stdin 73 | return nil if $stdin.tty? 74 | return $stdin.read 75 | end 76 | end 77 | 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/check_please/comparison.rb: -------------------------------------------------------------------------------- 1 | module CheckPlease 2 | 3 | class Comparison 4 | def self.perform(reference, candidate, flags = {}) 5 | new.perform(reference, candidate, flags) 6 | end 7 | 8 | def perform(reference, candidate, flags = {}) 9 | @flags = Flags.reify(flags) 10 | @diffs = Diffs.new(flags: @flags) 11 | 12 | catch(:max_diffs_reached) do 13 | compare reference, candidate, CheckPlease::Path.root 14 | end 15 | diffs.filter_by_flags(@flags) 16 | end 17 | 18 | private 19 | attr_reader :diffs, :flags 20 | 21 | def compare(ref, can, path) 22 | return if path.excluded?(flags) 23 | 24 | case types_for_compare(ref, can) 25 | when [ :array, :array ] ; compare_arrays ref, can, path 26 | when [ :hash, :hash ] ; compare_hashes ref, can, path 27 | when [ :other, :other ] ; compare_others ref, can, path 28 | else 29 | record_diff ref, can, path, :type_mismatch 30 | end 31 | end 32 | 33 | def types_for_compare(*list) 34 | list.map { |e| 35 | case e 36 | when Array ; :array 37 | when Hash ; :hash 38 | else ; :other 39 | end 40 | } 41 | end 42 | 43 | def compare_arrays(ref_array, can_array, path) 44 | case 45 | when ( key = path.key_to_match_by(flags) ) 46 | compare_arrays_by_key ref_array, can_array, path, key 47 | when path.match_by_value?(flags) 48 | compare_arrays_by_value ref_array, can_array, path 49 | else 50 | compare_arrays_by_index ref_array, can_array, path 51 | end 52 | end 53 | 54 | def compare_arrays_by_key(ref_array, can_array, path, key_name) 55 | refs_by_key = index_array!(ref_array, path, key_name, "reference") 56 | cans_by_key = index_array!(can_array, path, key_name, "candidate") 57 | 58 | key_values = (refs_by_key.keys | cans_by_key.keys) 59 | 60 | key_values.compact! # NOTE: will break if nil is ever used as a key (but WHO WOULD DO THAT?!) 61 | key_values.sort! 62 | 63 | key_values.each do |key_value| 64 | new_path = path + "#{key_name}=#{key_value}" 65 | ref = refs_by_key[key_value] 66 | can = cans_by_key[key_value] 67 | case 68 | when ref.nil? ; record_diff ref, can, new_path, :extra 69 | when can.nil? ; record_diff ref, can, new_path, :missing 70 | else ; compare ref, can, new_path 71 | end 72 | end 73 | end 74 | 75 | def index_array!(array_of_hashes, path, key_name, ref_or_can) 76 | elements_by_key = {} 77 | 78 | array_of_hashes.each.with_index do |h, i| 79 | # make sure we have a hash 80 | unless h.is_a?(Hash) 81 | raise CheckPlease::TypeMismatchError, \ 82 | "The element at position #{i} in the #{ref_or_can} array is not a hash." 83 | end 84 | 85 | if flags.indifferent_keys 86 | h = stringify_symbol_keys(h) 87 | end 88 | 89 | # try to get the value of the attribute identified by key_name 90 | key_value = h.fetch(key_name) { 91 | raise CheckPlease::NoSuchKeyError, \ 92 | <<~EOF 93 | The #{ref_or_can} hash at position #{i} has no #{key_name.inspect} key. 94 | Keys it does have: #{h.keys.inspect} 95 | EOF 96 | } 97 | 98 | # complain about dupes 99 | if elements_by_key.has_key?(key_value) 100 | key_val_expr = "#{key_name}=#{key_value}" 101 | raise CheckPlease::DuplicateKeyError, \ 102 | "Duplicate #{ref_or_can} element found at path '#{path + key_val_expr}'." 103 | end 104 | 105 | # ok, now we can proceed 106 | elements_by_key[key_value] = h 107 | end 108 | 109 | elements_by_key 110 | end 111 | 112 | # FIXME: this can generate duplicate paths. 113 | # Time to introduce lft_path, rgt_path ? 114 | def compare_arrays_by_value(ref_array, can_array, path) 115 | assert_can_match_by_value! ref_array 116 | assert_can_match_by_value! can_array 117 | 118 | matches = can_array.map { false } 119 | 120 | # Look for missing values 121 | ref_array.each.with_index do |ref, i| 122 | new_path = path + (i+1) # count in human pls 123 | 124 | # Weird, but necessary to handle duplicates properly 125 | j = can_array.index.with_index { |can, j| 126 | matches[j] == false && can == ref 127 | } 128 | 129 | if j 130 | matches[j] = true 131 | else 132 | record_diff ref, nil, new_path, :missing 133 | end 134 | end 135 | 136 | # Look for extra values 137 | can_array.zip(matches).each.with_index do |(can, match), i| 138 | next if match 139 | new_path = path + (i+1) # count in human pls 140 | record_diff nil, can, new_path, :extra 141 | end 142 | end 143 | 144 | def assert_can_match_by_value!(array) 145 | if array.any? { |e| Array === e || Hash === e } 146 | raise CheckPlease::BehaviorUndefined, 147 | "match_by_value behavior is not defined for collections!" 148 | end 149 | end 150 | 151 | def compare_arrays_by_index(ref_array, can_array, path) 152 | max_len = [ ref_array, can_array ].map(&:length).max 153 | (0...max_len).each do |i| 154 | n = i + 1 # count in human pls 155 | new_path = path + n 156 | 157 | ref = ref_array[i] 158 | can = can_array[i] 159 | 160 | case 161 | when ref_array.length < n ; record_diff ref, can, new_path, :extra 162 | when can_array.length < n ; record_diff ref, can, new_path, :missing 163 | else 164 | compare ref, can, new_path 165 | end 166 | end 167 | end 168 | 169 | def compare_hashes(ref_hash, can_hash, path) 170 | if flags.indifferent_keys 171 | ref_hash = stringify_symbol_keys(ref_hash) 172 | can_hash = stringify_symbol_keys(can_hash) 173 | end 174 | record_missing_keys ref_hash, can_hash, path 175 | compare_common_keys ref_hash, can_hash, path 176 | record_extra_keys ref_hash, can_hash, path 177 | end 178 | 179 | def stringify_symbol_keys(h) 180 | Hash[ 181 | h.map { |k,v| 182 | [ stringify_symbol(k), v ] 183 | } 184 | ] 185 | end 186 | 187 | def stringify_symbol(x) 188 | Symbol === x ? x.to_s : x 189 | end 190 | 191 | def record_missing_keys(ref_hash, can_hash, path) 192 | keys = ref_hash.keys - can_hash.keys 193 | keys.each do |k| 194 | record_diff ref_hash[k], nil, path + k, :missing 195 | end 196 | end 197 | 198 | def compare_common_keys(ref_hash, can_hash, path) 199 | keys = ref_hash.keys & can_hash.keys 200 | keys.each do |k| 201 | compare ref_hash[k], can_hash[k], path + k 202 | end 203 | end 204 | 205 | def record_extra_keys(ref_hash, can_hash, path) 206 | keys = can_hash.keys - ref_hash.keys 207 | keys.each do |k| 208 | record_diff nil, can_hash[k], path + k, :extra 209 | end 210 | end 211 | 212 | def compare_others(ref, can, path) 213 | ref_prime = normalize_value(path, ref) 214 | can_prime = normalize_value(path, can) 215 | return if ref_prime == can_prime 216 | 217 | record_diff ref, can, path, :mismatch 218 | end 219 | 220 | def normalize_value(path, value) 221 | if flags.indifferent_values 222 | value = stringify_symbol(value) 223 | end 224 | 225 | if flags.normalize_values 226 | # We assume that normalize_values is a hash of path expression strings to a proc, string, or symbol that will be used to normalize the value 227 | _, normalizer = flags.normalize_values.detect { |path_string, a_proc| 228 | path.match?(path_string) 229 | } 230 | 231 | case normalizer 232 | when nil ; value 233 | when Proc ; normalizer.call(value) 234 | when String, Symbol ; value.send(normalizer) 235 | else ; raise ArgumentError, "Not sure how to use #{normalizer.inspect} to normalize #{value.inspect}" 236 | end 237 | else 238 | return value 239 | end 240 | 241 | end 242 | 243 | def record_diff(ref, can, path, type) 244 | diff = Diff.new(type, path, ref, can) 245 | diffs << diff 246 | end 247 | end 248 | 249 | end 250 | -------------------------------------------------------------------------------- /lib/check_please/diff.rb: -------------------------------------------------------------------------------- 1 | module CheckPlease 2 | 3 | class Diff 4 | COLUMNS = %i[ type path reference candidate ] 5 | 6 | attr_reader(*COLUMNS) 7 | def initialize(type, path, reference, candidate) 8 | @type = type 9 | @path = path.to_s 10 | @reference = reference 11 | @candidate = candidate 12 | end 13 | 14 | def attributes 15 | Hash[ COLUMNS.map { |name| [ name, send(name) ] } ] 16 | end 17 | 18 | def inspect 19 | s = "<" 20 | s << self.class.name 21 | s << " type=#{type}" 22 | s << " path=#{path}" 23 | s << " ref=#{reference.inspect}" 24 | s << " can=#{candidate.inspect}" 25 | s << ">" 26 | s 27 | end 28 | end 29 | 30 | end 31 | -------------------------------------------------------------------------------- /lib/check_please/diffs.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | 3 | module CheckPlease 4 | 5 | # Custom collection class for Diff instances. 6 | # Can retrieve members using indexes or paths. 7 | class Diffs 8 | attr_reader :flags 9 | def initialize(diff_list = nil, flags: {}) 10 | @flags = Flags.reify(flags) 11 | @list = [] 12 | @hash = {} 13 | Array(diff_list).each do |diff| 14 | self << diff 15 | end 16 | end 17 | 18 | # this is probably a terrible idea, but this method: 19 | # - treats integer keys as array-style positional indexes 20 | # - treats string keys as path strings and does a hash-like lookup (raising if the path is not found) 21 | # 22 | # (In my defense, I only did it to make the tests easier to write.) 23 | def [](key) 24 | if key.is_a?(Integer) 25 | @list[key] 26 | else 27 | @hash.fetch(key) 28 | end 29 | end 30 | 31 | def <<(diff) 32 | if flags.fail_fast && length > 0 33 | throw :max_diffs_reached 34 | end 35 | 36 | if (n = flags.max_diffs) 37 | # It seems no one can help me now / I'm in too deep, there's no way out 38 | throw :max_diffs_reached if length >= n 39 | end 40 | 41 | @list << diff 42 | @hash[diff.path] = diff 43 | end 44 | 45 | def data 46 | @list.map(&:attributes) 47 | end 48 | 49 | def filter_by_flags(flags) 50 | new_list = @list.reject { |diff| Path.new(diff.path).excluded?(flags) } 51 | self.class.new(new_list, flags: flags) 52 | end 53 | 54 | def to_s(flags = {}) 55 | CheckPlease::Printers.render(self, flags) 56 | end 57 | 58 | def method_missing(meth, *args, &blk) 59 | if formats.include?(meth.to_sym) 60 | CheckPlease::Printers.render(self, format: meth) 61 | else 62 | super 63 | end 64 | end 65 | 66 | def formats 67 | CheckPlease::Printers::FORMATS 68 | end 69 | 70 | extend Forwardable 71 | def_delegators :@list, *%i[ 72 | each 73 | empty? 74 | length 75 | map 76 | to_a 77 | ] 78 | end 79 | 80 | end 81 | -------------------------------------------------------------------------------- /lib/check_please/error.rb: -------------------------------------------------------------------------------- 1 | module CheckPlease 2 | 3 | module Error 4 | # Rather than having a common error superclass, I'm taking a cue from 5 | # https://avdi.codes/exceptionalruby and tagging things with a module 6 | # instead.... 7 | end 8 | 9 | class BehaviorUndefined < ::StandardError 10 | include CheckPlease::Error 11 | end 12 | 13 | class DuplicateKeyError < ::IndexError 14 | include CheckPlease::Error 15 | end 16 | 17 | class InvalidFlag < ArgumentError 18 | include CheckPlease::Error 19 | end 20 | 21 | class InvalidPath < ArgumentError 22 | include CheckPlease::Error 23 | end 24 | 25 | class InvalidPathSegment < ArgumentError 26 | include CheckPlease::Error 27 | end 28 | 29 | class NoSuchKeyError < ::KeyError 30 | include CheckPlease::Error 31 | end 32 | 33 | class TypeMismatchError < ::TypeError 34 | include CheckPlease::Error 35 | end 36 | 37 | end 38 | -------------------------------------------------------------------------------- /lib/check_please/flag.rb: -------------------------------------------------------------------------------- 1 | module CheckPlease 2 | 3 | class Flag 4 | attr_accessor :name 5 | attr_writer :default # reader is defined below 6 | attr_accessor :default_proc 7 | attr_accessor :description 8 | attr_accessor :cli_long 9 | attr_accessor :cli_short 10 | 11 | def initialize(attrs = {}) 12 | @validators = [] 13 | attrs.each do |name, value| 14 | set_attribute! name, value 15 | end 16 | yield self if block_given? 17 | freeze 18 | end 19 | 20 | def default 21 | if default_proc 22 | default_proc.call 23 | else 24 | @default 25 | end 26 | end 27 | 28 | def coerce(&block) 29 | @coercer = block 30 | end 31 | 32 | def description=(value) 33 | if value.is_a?(String) && value =~ /\n/m 34 | lines = value.lines 35 | else 36 | lines = Array(value).map(&:to_s) 37 | end 38 | 39 | @description = lines.map(&:rstrip) 40 | end 41 | 42 | def mutually_exclusive_to(flag_name) 43 | @validators << ->(flags, _) { flags.send(flag_name).empty? } 44 | end 45 | 46 | def repeatable 47 | @repeatable = true 48 | self.default_proc = ->{ Array.new } 49 | end 50 | 51 | def validate(&block) 52 | @validators << block 53 | end 54 | 55 | protected 56 | 57 | def __set__(value, on:, flags:) 58 | val = _coerce(value) 59 | _validate(flags, val) 60 | if @repeatable 61 | on[name] ||= [] 62 | on[name].concat(Array(val)) 63 | else 64 | on[name] = val 65 | end 66 | end 67 | 68 | private 69 | 70 | def _coerce(value) 71 | return value if @coercer.nil? 72 | @coercer.call(value) 73 | end 74 | 75 | def _validate(flags, value) 76 | return if @validators.empty? 77 | return if @validators.all? { |block| block.call(flags, value) } 78 | raise InvalidFlag, "#{value.inspect} is not a legal value for #{name}" 79 | end 80 | 81 | def set_attribute!(name, value) 82 | self.send "#{name}=", value 83 | rescue NoMethodError 84 | raise ArgumentError, "unrecognized attribute: #{name}" 85 | end 86 | end 87 | 88 | end 89 | -------------------------------------------------------------------------------- /lib/check_please/flags.rb: -------------------------------------------------------------------------------- 1 | module CheckPlease 2 | 3 | # NOTE: this gets all of its attributes defined (via .define) in ../check_please.rb 4 | 5 | class Flags 6 | include CheckPlease::Reification 7 | can_reify Hash 8 | 9 | BY_NAME = {} ; private_constant :BY_NAME 10 | 11 | def self.[](name) 12 | BY_NAME[name.to_sym] 13 | end 14 | 15 | def self.define(name, &block) 16 | flag = Flag.new(name: name.to_sym, &block) 17 | BY_NAME[flag.name] = flag 18 | define_accessors flag 19 | 20 | nil 21 | end 22 | 23 | def self.each_flag 24 | BY_NAME.each do |_, flag| 25 | yield flag 26 | end 27 | end 28 | 29 | def self.define_accessors(flag) 30 | getter = flag.name 31 | define_method(getter) { 32 | @attributes.fetch(flag.name) { flag.default } 33 | } 34 | 35 | setter = :"#{flag.name}=" 36 | define_method(setter) { |value| 37 | flag.send :__set__, value, on: @attributes, flags: self 38 | } 39 | end 40 | 41 | def initialize(attrs = {}) 42 | @attributes = {} 43 | attrs.each do |name, value| 44 | send "#{name}=", value 45 | end 46 | end 47 | end 48 | 49 | end 50 | -------------------------------------------------------------------------------- /lib/check_please/path.rb: -------------------------------------------------------------------------------- 1 | module CheckPlease 2 | 3 | # TODO: this class is getting a bit large; maybe split out some of the stuff that uses flags? 4 | class Path 5 | include CheckPlease::Reification 6 | can_reify String, Symbol, Numeric, nil 7 | 8 | SEPARATOR = "/" 9 | 10 | def self.root 11 | new('/') 12 | end 13 | 14 | 15 | 16 | attr_reader :to_s, :segments 17 | def initialize(name_or_segments = []) 18 | case name_or_segments 19 | when String, Symbol, Numeric, nil 20 | string = name_or_segments.to_s 21 | if string =~ %r(//) 22 | raise InvalidPath, "paths cannot have empty segments" 23 | end 24 | 25 | names = string.split(SEPARATOR) 26 | names.shift until names.empty? || names.first =~ /\S/ 27 | segments = PathSegment.reify(names) 28 | when Array 29 | segments = PathSegment.reify(name_or_segments) 30 | else 31 | raise InvalidPath, "not sure what to do with #{name_or_segments.inspect}" 32 | end 33 | 34 | @segments = Array(segments) 35 | 36 | @to_s = SEPARATOR + @segments.join(SEPARATOR) 37 | freeze 38 | rescue InvalidPathSegment => e 39 | raise InvalidPath, e.message 40 | end 41 | 42 | def +(new_basename) 43 | new_segments = self.segments.dup 44 | new_segments << new_basename # don't reify here; it'll get done on Path#initialize 45 | self.class.new(new_segments) 46 | end 47 | 48 | def ==(other) 49 | self.to_s == other.to_s 50 | end 51 | 52 | def ancestors 53 | list = [] 54 | p = self 55 | loop do 56 | break if p.root? 57 | p = p.parent 58 | list.unshift p 59 | end 60 | list.reverse 61 | end 62 | 63 | def basename 64 | segments.last.to_s 65 | end 66 | 67 | def depth 68 | 1 + segments.length 69 | end 70 | 71 | def excluded?(flags) 72 | return false if root? # that would just be silly 73 | 74 | return true if too_deep?(flags) 75 | return true if explicitly_excluded?(flags) 76 | return true if implicitly_excluded?(flags) 77 | 78 | false 79 | end 80 | 81 | def inspect 82 | "<#{self.class.name} '#{to_s}'>" 83 | end 84 | 85 | def key_to_match_by(flags) 86 | key_exprs = unpack_key_exprs(flags.match_by_key) 87 | # NOTE: match on parent because if self.to_s == '/foo', MBK '/foo/:id' should return 'id' 88 | matches = key_exprs.select { |e| e.parent.match?(self) } 89 | 90 | case matches.length 91 | when 0 ; nil 92 | when 1 ; matches.first.segments.last.key 93 | else ; raise "More than one match_by_key expression for path '#{self}': #{matches.map(&:to_s).inspect}" 94 | end 95 | end 96 | 97 | def match_by_value?(flags) 98 | flags.match_by_value.any? { |e| e.match?(self) } 99 | end 100 | 101 | def match?(path_or_string) 102 | # If the strings are literally equal, we're good.. 103 | return true if self == path_or_string 104 | 105 | # Otherwise, compare segments: do we have the same number, and do they all #match? 106 | other = reify(path_or_string) 107 | return false if other.depth != self.depth 108 | 109 | seg_pairs = self.segments.zip(other.segments) 110 | seg_pairs.all? { |a, b| a.match?(b) } 111 | end 112 | 113 | def parent 114 | return nil if root? # TODO: consider the Null Object pattern 115 | self.class.new(segments[0..-2]) 116 | end 117 | 118 | def root? 119 | @segments.empty? 120 | end 121 | 122 | private 123 | 124 | # O(n^2) check to see if any of the path's ancestors are on a list 125 | # (as of this writing, this should never actually happen, but I'm being thorough) 126 | def ancestor_on_list?(paths) 127 | paths.any? { |path| 128 | ancestors.any? { |ancestor| ancestor.match?(path) } 129 | } 130 | end 131 | 132 | def explicitly_excluded?(flags) 133 | return false if flags.reject_paths.empty? 134 | return true if self_on_list?(flags.reject_paths) 135 | return true if ancestor_on_list?(flags.reject_paths) 136 | false 137 | end 138 | 139 | def implicitly_excluded?(flags) 140 | return false if flags.select_paths.empty? 141 | return false if self_on_list?(flags.select_paths) 142 | return false if ancestor_on_list?(flags.select_paths) 143 | true 144 | end 145 | 146 | # A path of "/foo/:id/bar/:name" has two key expressions: 147 | # - "/foo/:id" 148 | # - "/foo/:id/bar/:name" 149 | def key_exprs 150 | ( [self] + ancestors ) 151 | .reject { |path| path.root? } 152 | .select { |path| path.segments.last&.key_expr? } 153 | end 154 | 155 | # O(n) check to see if the path itself is on a list 156 | def self_on_list?(paths) 157 | paths.any? { |path| self.match?(path) } 158 | end 159 | 160 | def too_deep?(flags) 161 | return false if flags.max_depth.nil? 162 | depth > flags.max_depth 163 | end 164 | 165 | def unpack_key_exprs(path_list) 166 | path_list 167 | .map { |path| path.send(:key_exprs) } 168 | .flatten 169 | .uniq { |e| e.to_s } # use the block form so we don't have to implement #hash and #eql? in horrible ways 170 | end 171 | 172 | end 173 | 174 | end 175 | -------------------------------------------------------------------------------- /lib/check_please/path_segment.rb: -------------------------------------------------------------------------------- 1 | module CheckPlease 2 | 3 | class PathSegment 4 | include CheckPlease::Reification 5 | can_reify String, Symbol, Numeric, nil 6 | 7 | KEY_EXPR = %r{ 8 | ^ 9 | \: # a literal colon 10 | ( # capture key 11 | [^\:]+ # followed by one or more things that aren't colons 12 | ) # end capture key 13 | $ 14 | }x 15 | 16 | KEY_VAL_EXPR = %r{ 17 | ^ 18 | ( # capture key 19 | [^=]+ # stuff (just not an equal sign) 20 | ) # end capture key 21 | \= # an equal sign 22 | ( # capture key value 23 | [^=]+ # stuff (just not an equal sign) 24 | ) # end capture key value 25 | $ 26 | }x 27 | 28 | attr_reader :name, :key, :key_value 29 | alias_method :to_s, :name 30 | 31 | def initialize(name = nil) 32 | @name = name.to_s.strip 33 | 34 | case @name 35 | when "", /\s/ # blank or has any whitespace 36 | raise InvalidPathSegment, "#{name.inspect} is not a valid #{self.class} name" 37 | end 38 | 39 | parse_key_and_value 40 | freeze 41 | end 42 | 43 | def key_expr? 44 | name.match?(KEY_EXPR) 45 | end 46 | 47 | def key_val_expr? 48 | name.match?(KEY_VAL_EXPR) 49 | end 50 | 51 | def match?(other_segment_or_string) 52 | other = reify(other_segment_or_string) 53 | PathSegmentMatcher.call(self, other) 54 | end 55 | 56 | def splat? 57 | name == '*' 58 | end 59 | 60 | private 61 | 62 | def parse_key_and_value 63 | case name 64 | when KEY_EXPR 65 | @key = $1 66 | when KEY_VAL_EXPR 67 | @key, @key_value = $1, $2 68 | else 69 | # :nothingtodohere: 70 | end 71 | end 72 | end 73 | 74 | end 75 | -------------------------------------------------------------------------------- /lib/check_please/path_segment_matcher.rb: -------------------------------------------------------------------------------- 1 | module CheckPlease 2 | 3 | class PathSegmentMatcher 4 | def self.call(a,b) 5 | new(a,b).call 6 | end 7 | 8 | attr_reader :a, :b, :types 9 | def initialize(a, b) 10 | @a, @b = a, b 11 | @types = [ _type(a), _type(b) ].sort 12 | end 13 | 14 | def call 15 | return true if either?(:splat) 16 | return a.name == b.name if both?(:plain) 17 | return a.key == b.key if key_and_key_value? 18 | 19 | false 20 | end 21 | 22 | private 23 | 24 | def _type(x) 25 | return :splat if x.splat? 26 | return :key if x.key_expr? 27 | return :key_value if x.key_val_expr? 28 | :plain 29 | end 30 | 31 | def both?(type) 32 | types.uniq == [type] 33 | end 34 | 35 | def either?(type) 36 | types.include?(type) 37 | end 38 | 39 | def key_and_key_value? 40 | types == [ :key, :key_value ] 41 | end 42 | end 43 | 44 | end 45 | -------------------------------------------------------------------------------- /lib/check_please/printers.rb: -------------------------------------------------------------------------------- 1 | module CheckPlease 2 | 3 | module Printers 4 | autoload :Base, "check_please/printers/base" 5 | autoload :JSON, "check_please/printers/json" 6 | autoload :Long, "check_please/printers/long" 7 | autoload :TablePrint, "check_please/printers/table_print" 8 | 9 | PRINTERS_BY_FORMAT = { 10 | table: Printers::TablePrint, 11 | json: Printers::JSON, 12 | long: Printers::Long, 13 | } 14 | FORMATS = PRINTERS_BY_FORMAT.keys.sort 15 | DEFAULT_FORMAT = :table 16 | 17 | def self.render(diffs, flags = {}) 18 | flags = Flags.reify(flags) 19 | printer = PRINTERS_BY_FORMAT[flags.format] 20 | printer.render(diffs) 21 | end 22 | end 23 | 24 | end 25 | -------------------------------------------------------------------------------- /lib/check_please/printers/base.rb: -------------------------------------------------------------------------------- 1 | module CheckPlease 2 | module Printers 3 | 4 | class Base 5 | def self.render(diffs) 6 | new(diffs).to_s 7 | end 8 | 9 | def initialize(diffs) 10 | @diffs = diffs 11 | end 12 | 13 | private 14 | 15 | attr_reader :diffs 16 | 17 | def build_string 18 | io = StringIO.new 19 | yield io 20 | io.string.strip 21 | end 22 | end 23 | 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/check_please/printers/json.rb: -------------------------------------------------------------------------------- 1 | module CheckPlease 2 | module Printers 3 | 4 | class JSON < Base 5 | def to_s 6 | return "[]" if diffs.empty? 7 | 8 | build_string do |io| 9 | io.puts "[" 10 | io.puts diffs.map { |diff| diff_json(diff) }.join(",\n") 11 | io.puts "]" 12 | end 13 | end 14 | 15 | private 16 | 17 | def diff_json(diff, prefix = " ") 18 | h = diff.attributes 19 | json = ::JSON.pretty_generate(h) 20 | prefix.to_s + json.gsub(/\n\s*/, " ") 21 | end 22 | end 23 | 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/check_please/printers/long.rb: -------------------------------------------------------------------------------- 1 | module CheckPlease 2 | module Printers 3 | 4 | class Long < Base 5 | def to_s 6 | return "" if diffs.empty? 7 | 8 | out = build_string do |io| 9 | diffs.each do |diff| 10 | t = diff.type.to_sym 11 | ref, can = *[ diff.reference, diff.candidate ].map(&:inspect) 12 | diff_string = <<~EOF.strip 13 | #{diff.path} [#{diff.type}] 14 | reference: #{( t == :extra ) ? "[no value]" : ref} 15 | candidate: #{( t == :missing ) ? "[no value]" : can} 16 | EOF 17 | 18 | io.puts diff_string 19 | io.puts 20 | end 21 | end 22 | 23 | out.strip 24 | end 25 | 26 | private 27 | end 28 | 29 | end 30 | end 31 | 32 | -------------------------------------------------------------------------------- /lib/check_please/printers/table_print.rb: -------------------------------------------------------------------------------- 1 | require 'table_print' 2 | 3 | module CheckPlease 4 | module Printers 5 | 6 | class TablePrint < Base 7 | InspectStrings = Object.new.tap do |obj| 8 | def obj.format(value) 9 | value.is_a?(String) ? value.inspect : value 10 | end 11 | end 12 | 13 | PATH_MAX_WIDTH = 250 # if you hit this limit, you have other problems 14 | 15 | TP_OPTS = [ 16 | { type: { display_name: "Type" } }, 17 | { path: { display_name: "Path", width: PATH_MAX_WIDTH } }, 18 | { reference: { display_name: "Reference", formatters: [ InspectStrings ] } }, 19 | { candidate: { display_name: "Candidate", formatters: [ InspectStrings ] } }, 20 | ] 21 | 22 | def to_s 23 | return "" if diffs.empty? 24 | 25 | out = build_string do |io| 26 | switch_tableprint_io(io) do 27 | tp diffs.data, *TP_OPTS 28 | end 29 | end 30 | strip_trailing_whitespace(out) 31 | end 32 | 33 | private 34 | 35 | def switch_tableprint_io(new_io) 36 | config = ::TablePrint::Config 37 | @old_io = config.io 38 | config.io = new_io 39 | yield 40 | ensure 41 | config.io = @old_io 42 | end 43 | 44 | def strip_trailing_whitespace(s) 45 | s.lines.map(&:rstrip).join("\n") 46 | end 47 | end 48 | 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/check_please/reification.rb: -------------------------------------------------------------------------------- 1 | module CheckPlease 2 | 3 | module Reification 4 | def self.included(receiver) 5 | receiver.extend ClassMethods 6 | receiver.send :include, InstanceMethods 7 | end 8 | 9 | module ClassMethods 10 | def reifiable 11 | @_reifiable ||= [] 12 | end 13 | 14 | def can_reify(*klasses) 15 | klasses.flatten! 16 | 17 | unless ( klasses - [nil] ).all? { |e| e.is_a?(Class) } 18 | raise ArgumentError, "classes (or nil) only, please" 19 | end 20 | 21 | reifiable.concat klasses 22 | reifiable.uniq! 23 | nil 24 | end 25 | 26 | def reify(primitive_or_object) 27 | case primitive_or_object 28 | when self ; return primitive_or_object 29 | when Array ; return primitive_or_object.map { |e| reify(e) } 30 | when *reifiable ; return new(primitive_or_object) 31 | end 32 | # note early return ^^^ 33 | 34 | # that didn't work? complain! 35 | acceptable = reifiable.map { |e| Class === e ? e.name : e.inspect } 36 | raise ArgumentError, <<~EOF 37 | #{self}.reify was given: #{primitive_or_object.inspect} 38 | but only accepts: #{acceptable.join(", ")} 39 | EOF 40 | end 41 | end 42 | 43 | module InstanceMethods 44 | def reify(x) 45 | self.class.reify(x) 46 | end 47 | end 48 | end 49 | 50 | end 51 | -------------------------------------------------------------------------------- /lib/check_please/version.rb: -------------------------------------------------------------------------------- 1 | module CheckPlease 2 | # NOTE: 'check_please_rspec_matcher' depends on this, 3 | # so try to keep them roughly in sync 4 | VERSION = "0.5.7" # about to release? rerun `bundle lock` to update Gemfile.lock 5 | end 6 | -------------------------------------------------------------------------------- /spec/check_please/cli_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe CheckPlease::CLI do 2 | 3 | describe CheckPlease::CLI::Parser do 4 | subject { described_class.new("wibble") } 5 | 6 | describe "#flags_from_args!" do 7 | def invoke!(args) 8 | subject.flags_from_args!(args) 9 | end 10 | 11 | it "recognizes '-f json' as a valid format" do 12 | flags = invoke!(%w[ -f json ]) 13 | expect( flags.format ).to eq( :json ) 14 | end 15 | 16 | it "recognizes '--format json' as a valid format" do 17 | flags = invoke!(%w[ --format json ]) 18 | expect( flags.format ).to eq( :json ) 19 | end 20 | 21 | it "recognizes '-n 3' as limiting output to 3 diffs" do 22 | flags = invoke!(%w[ -n 3 ]) 23 | expect( flags.max_diffs ).to eq( 3 ) 24 | end 25 | 26 | it "recognizes '--max-diffs 3' as limiting output to 3 diffs" do 27 | flags = invoke!(%w[ --max-diffs 3 ]) 28 | expect( flags.max_diffs ).to eq( 3 ) 29 | end 30 | 31 | it "recognizes '--fail-fast' as setting the :fail_fast flag to true" do 32 | flags = invoke!(%w[ --fail-fast ]) 33 | expect( flags.fail_fast ).to be true 34 | end 35 | 36 | it "recognizes '--max-depth 2' as limiting recursion to 2 levels" do 37 | flags = invoke!(%w[ --max-depth 2 ]) 38 | expect( flags.max_depth ).to eq( 2 ) 39 | end 40 | 41 | it "recognizes '--match-by-key' as adding a path/key expression" do 42 | flags = invoke!(%w[ --match-by-key /foo/:id ]) 43 | expect( flags.match_by_key ).to eq( [ "/foo/:id" ] ) 44 | end 45 | 46 | it "recognizes '--match-by-key' as adding more than one path/key expression" do 47 | flags = invoke!(%w[ --match-by-key /foo/:id --match-by-key /bar/:id ]) 48 | expect( flags.match_by_key ).to eq( [ "/foo/:id", "/bar/:id" ] ) 49 | end 50 | 51 | it "complains if given an arg it doesn't recognize" do 52 | expect { invoke!(%w[ --welcome-to-zombocom ]) }.to \ 53 | raise_error( CheckPlease::InvalidFlag ) 54 | end 55 | 56 | specify "recognized args are removed from the args" do 57 | args = %w[ -f json ] 58 | invoke!(args) 59 | expect( args ).to be_empty 60 | end 61 | end 62 | end 63 | 64 | end 65 | -------------------------------------------------------------------------------- /spec/check_please/comparison_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe CheckPlease::Comparison do 2 | def invoke!(ref, can, flags = {}) 3 | CheckPlease::Comparison.perform(ref, can, flags) 4 | end 5 | 6 | context "when given two integers" do 7 | let(:reference) { 42 } 8 | let(:candidate) { 43 } 9 | 10 | it "has one diff for the top-level mismatch" do 11 | diffs = invoke!(reference, candidate) 12 | expect( diffs.length ).to eq( 1 ) 13 | expect( diffs[0] ).to eq_diff( :mismatch, "/", ref: 42, can: 43 ) 14 | end 15 | end 16 | 17 | context "when given a String and a Symbol" do 18 | let(:reference) { "foo" } 19 | let(:candidate) { :foo } 20 | 21 | specify "by default, it has one diff for the mismatch" do 22 | diffs = invoke!(reference, candidate) 23 | expect( diffs.length ).to eq( 1 ) 24 | expect( diffs[0] ).to eq_diff( :mismatch, "/", ref: "foo", can: :foo ) 25 | end 26 | 27 | specify "it has no diffs when the `indifferent_values` flag is true" do 28 | diffs = invoke!(reference, candidate, indifferent_values: true) 29 | expect( diffs ).to be_empty 30 | end 31 | end 32 | 33 | context "when given a Symbol and a String" do 34 | let(:reference) { :foo } 35 | let(:candidate) { "foo" } 36 | 37 | specify "by default, it has one diff for the mismatch" do 38 | diffs = invoke!(reference, candidate) 39 | expect( diffs.length ).to eq( 1 ) 40 | expect( diffs[0] ).to eq_diff( :mismatch, "/", ref: :foo, can: "foo" ) 41 | end 42 | 43 | specify "it has no diffs when the `indifferent_values` flag is true" do 44 | diffs = invoke!(reference, candidate, indifferent_values: true) 45 | expect( diffs ).to be_empty 46 | end 47 | end 48 | 49 | context "when given two arrays of scalars" do 50 | context "same length, same order, different elements" do 51 | let(:reference) { [ 1, 2, 3 ] } 52 | let(:candidate) { [ 1, 2, 5 ] } 53 | 54 | it "has one diff for the second-level mismatch" do 55 | diffs = invoke!(reference, candidate) 56 | expect( diffs.length ).to eq( 1 ) 57 | expect( diffs[0] ).to eq_diff( :mismatch, "/3", ref: 3, can: 5 ) 58 | end 59 | end 60 | 61 | context "same length, different order, different elements" do 62 | let(:reference) { [ 1, 2, 3 ] } 63 | let(:candidate) { [ 5, 1, 2 ] } 64 | 65 | it "has three diffs" do 66 | diffs = invoke!(reference, candidate) 67 | expect( diffs.length ).to eq( 3 ) 68 | expect( diffs[0] ).to eq_diff( :mismatch, "/1", ref: 1, can: 5 ) 69 | expect( diffs[1] ).to eq_diff( :mismatch, "/2", ref: 2, can: 1 ) 70 | expect( diffs[2] ).to eq_diff( :mismatch, "/3", ref: 3, can: 2 ) 71 | end 72 | 73 | specify "it has two diffs (one missing, one extra) when the `match_by_value` list contains a matching path" do 74 | diffs = invoke!(reference, candidate, match_by_value: [ "/" ]) 75 | expect( diffs.length ).to eq( 2 ) 76 | expect( diffs[0] ).to eq_diff( :missing, "/3", ref: 3, can: nil ) 77 | expect( diffs[1] ).to eq_diff( :extra, "/1", ref: nil, can: 5 ) 78 | end 79 | end 80 | 81 | context "same order, reference longer than candidate" do 82 | let(:reference) { [ 1, 2, 3 ] } 83 | let(:candidate) { [ 1, 2 ] } 84 | 85 | it "has one diff for the missing element" do 86 | diffs = invoke!(reference, candidate) 87 | expect( diffs.length ).to eq( 1 ) 88 | expect( diffs[0] ).to eq_diff( :missing, "/3", ref: 3, can: nil ) 89 | end 90 | end 91 | 92 | context "different order, reference longer than candidate, extra reference value is a duplicate" do 93 | let(:reference) { [ 1, 2, 1 ] } 94 | let(:candidate) { [ 2, 1 ] } 95 | 96 | it "has three diffs" do 97 | diffs = invoke!(reference, candidate) 98 | expect( diffs.length ).to eq( 3 ) 99 | expect( diffs[0] ).to eq_diff( :mismatch, "/1", ref: 1, can: 2 ) 100 | expect( diffs[1] ).to eq_diff( :mismatch, "/2", ref: 2, can: 1 ) 101 | expect( diffs[2] ).to eq_diff( :missing, "/3", ref: 1, can: nil ) 102 | end 103 | 104 | specify "it has one diff for the missing element when the `match_by_value` list contains a matching path" do 105 | diffs = invoke!(reference, candidate, match_by_value: [ "/" ]) 106 | expect( diffs.length ).to eq( 1 ) 107 | expect( diffs[0] ).to eq_diff( :missing, "/3", ref: 1, can: nil ) 108 | end 109 | end 110 | 111 | context "different order, reference longer than candidate" do 112 | let(:reference) { [ 1, 2, 3 ] } 113 | let(:candidate) { [ 2, 1 ] } 114 | 115 | it "has three diffs" do 116 | diffs = invoke!(reference, candidate) 117 | expect( diffs.length ).to eq( 3 ) 118 | expect( diffs[0] ).to eq_diff( :mismatch, "/1", ref: 1, can: 2 ) 119 | expect( diffs[1] ).to eq_diff( :mismatch, "/2", ref: 2, can: 1 ) 120 | expect( diffs[2] ).to eq_diff( :missing, "/3", ref: 3, can: nil ) 121 | end 122 | 123 | specify "it has one diff for the missing element when the `match_by_value` list contains a matching path" do 124 | diffs = invoke!(reference, candidate, match_by_value: [ "/" ]) 125 | expect( diffs.length ).to eq( 1 ) 126 | expect( diffs[0] ).to eq_diff( :missing, "/3", ref: 3, can: nil ) 127 | end 128 | end 129 | 130 | context "same order, reference shorter than candidate" do 131 | let(:reference) { [ 1, 2 ] } 132 | let(:candidate) { [ 1, 2, 3 ] } 133 | 134 | it "has one diff for the extra element" do 135 | diffs = invoke!(reference, candidate) 136 | expect( diffs.length ).to eq( 1 ) 137 | expect( diffs[0] ).to eq_diff( :extra, "/3", ref: nil, can: 3 ) 138 | end 139 | end 140 | 141 | context "different order, reference shorter than candidate, extra candidate value is a duplicate" do 142 | let(:reference) { [ 1, 2 ] } 143 | let(:candidate) { [ 2, 1, 1 ] } 144 | 145 | it "has three diffs" do 146 | diffs = invoke!(reference, candidate) 147 | expect( diffs.length ).to eq( 3 ) 148 | expect( diffs[0] ).to eq_diff( :mismatch, "/1", ref: 1, can: 2 ) 149 | expect( diffs[1] ).to eq_diff( :mismatch, "/2", ref: 2, can: 1 ) 150 | expect( diffs[2] ).to eq_diff( :extra, "/3", ref: nil, can: 1 ) 151 | end 152 | 153 | it "has one diff for the extra element when the `match_by_value` list contains a matching path" do 154 | diffs = invoke!(reference, candidate, match_by_value: [ "/" ]) 155 | expect( diffs.length ).to eq( 1 ) 156 | expect( diffs[0] ).to eq_diff( :extra, "/3", ref: nil, can: 1 ) 157 | end 158 | end 159 | 160 | context "different order, reference shorter than candidate" do 161 | let(:reference) { [ 1, 2 ] } 162 | let(:candidate) { [ 2, 1, 3 ] } 163 | 164 | it "has three diffs" do 165 | diffs = invoke!(reference, candidate) 166 | expect( diffs.length ).to eq( 3 ) 167 | expect( diffs[0] ).to eq_diff( :mismatch, "/1", ref: 1, can: 2 ) 168 | expect( diffs[1] ).to eq_diff( :mismatch, "/2", ref: 2, can: 1 ) 169 | expect( diffs[2] ).to eq_diff( :extra, "/3", ref: nil, can: 3 ) 170 | end 171 | 172 | it "has one diff for the extra element when the `match_by_value` list contains a matching path" do 173 | diffs = invoke!(reference, candidate, match_by_value: [ "/" ]) 174 | expect( diffs.length ).to eq( 1 ) 175 | expect( diffs[0] ).to eq_diff( :extra, "/3", ref: nil, can: 3 ) 176 | end 177 | end 178 | 179 | context "same elements, different order" do 180 | let(:reference) { [ 1, 2, 3 ] } 181 | let(:candidate) { [ 3, 2, 1 ] } 182 | 183 | it "has a diff for each mismatch" do 184 | diffs = invoke!(reference, candidate) 185 | expect( diffs.length ).to eq( 2 ) 186 | expect( diffs[0] ).to eq_diff( :mismatch, "/1", ref: 1, can: 3 ) 187 | expect( diffs[1] ).to eq_diff( :mismatch, "/3", ref: 3, can: 1 ) 188 | end 189 | end 190 | end 191 | 192 | context "when given two hashes of integers" do 193 | context "same length, one key mismatch" do 194 | let(:reference) { { foo: 1, bar: 2, yak: 3 } } 195 | let(:candidate) { { foo: 1, bar: 2, quux: 3 } } 196 | 197 | it "has two diffs: one missing, one extra" do 198 | diffs = invoke!(reference, candidate) 199 | expect( diffs.length ).to eq( 2 ) 200 | expect( diffs["/yak"] ).to eq_diff( :missing, "/yak", ref: 3, can: nil ) 201 | expect( diffs["/quux"] ).to eq_diff( :extra, "/quux", ref: nil, can: 3 ) 202 | end 203 | end 204 | 205 | context "same length, same keys, one value mismatch" do 206 | let(:reference) { { foo: 1, bar: 2, yak: 3 } } 207 | let(:candidate) { { foo: 1, bar: 2, yak: 5 } } 208 | 209 | it "has one diff for the mismatch" do 210 | diffs = invoke!(reference, candidate) 211 | expect( diffs.length ).to eq( 1 ) 212 | expect( diffs[0] ).to eq_diff( :mismatch, "/yak", ref: 3, can: 5 ) 213 | end 214 | end 215 | end 216 | 217 | context "when given a reference hash with String keys and a candidate hash with Symbol keys" do 218 | context "same length, one key name mismatch" do 219 | let(:reference) { { "foo" => 1, "bar" => 2, "yak" => 3 } } 220 | let(:candidate) { { :foo => 1, :bar => 2, :quux => 3 } } 221 | 222 | context "by default" do 223 | it "has six diffs" do 224 | diffs = invoke!(reference, candidate) 225 | expect( diffs.length ).to eq( 6 ) 226 | 227 | expect( diffs[0] ).to eq_diff( :missing, "/foo", ref: 1, can: nil ) 228 | expect( diffs[1] ).to eq_diff( :missing, "/bar", ref: 2, can: nil ) 229 | expect( diffs[2] ).to eq_diff( :missing, "/yak", ref: 3, can: nil ) 230 | expect( diffs[3] ).to eq_diff( :extra, "/foo", ref: nil, can: 1 ) 231 | expect( diffs[4] ).to eq_diff( :extra, "/bar", ref: nil, can: 2 ) 232 | expect( diffs[5] ).to eq_diff( :extra, "/quux", ref: nil, can: 3 ) 233 | end 234 | end 235 | 236 | context "when the indifferent_keys flag is true" do 237 | it "has two diffs: one missing, one extra" do 238 | diffs = invoke!(reference, candidate, indifferent_keys: true) 239 | expect( diffs.length ).to eq( 2 ) 240 | expect( diffs[0] ).to eq_diff( :missing, "/yak", ref: 3, can: nil ) 241 | expect( diffs[1] ).to eq_diff( :extra, "/quux", ref: nil, can: 3 ) 242 | end 243 | end 244 | end 245 | end 246 | 247 | context "when given a reference hash with Symbol keys and a candidate hash with String keys" do 248 | context "same length, one key name mismatch" do 249 | let(:reference) { { :foo => 1, :bar => 2, :yak => 3 } } 250 | let(:candidate) { { "foo" => 1, "bar" => 2, "quux" => 3 } } 251 | 252 | context "by default" do 253 | it "has six diffs" do 254 | diffs = invoke!(reference, candidate) 255 | expect( diffs.length ).to eq( 6 ) 256 | 257 | expect( diffs[0] ).to eq_diff( :missing, "/foo", ref: 1, can: nil ) 258 | expect( diffs[1] ).to eq_diff( :missing, "/bar", ref: 2, can: nil ) 259 | expect( diffs[2] ).to eq_diff( :missing, "/yak", ref: 3, can: nil ) 260 | expect( diffs[3] ).to eq_diff( :extra, "/foo", ref: nil, can: 1 ) 261 | expect( diffs[4] ).to eq_diff( :extra, "/bar", ref: nil, can: 2 ) 262 | expect( diffs[5] ).to eq_diff( :extra, "/quux", ref: nil, can: 3 ) 263 | end 264 | end 265 | 266 | context "when the indifferent_keys flag is true" do 267 | it "has two diffs: one missing, one extra" do 268 | diffs = invoke!(reference, candidate, indifferent_keys: true) 269 | expect( diffs.length ).to eq( 2 ) 270 | expect( diffs[0] ).to eq_diff( :missing, "/yak", ref: 3, can: nil ) 271 | expect( diffs[1] ).to eq_diff( :extra, "/quux", ref: nil, can: 3 ) 272 | end 273 | end 274 | end 275 | end 276 | 277 | context "when given two hashes of arrays" do 278 | context "top-level hash keys differ" do 279 | let(:reference) { { foo: [ 1, 2, 3 ], bar: [ 4, 5, 6 ] } } 280 | let(:candidate) { { foo: [ 1, 2, 3 ], yak: [ 7, 8, 9 ] } } 281 | 282 | it "has two diffs: one missing, one extra" do 283 | diffs = invoke!(reference, candidate) 284 | expect( diffs.length ).to eq( 2 ) 285 | expect( diffs["/bar"] ).to eq_diff( :missing, "/bar", ref: [4,5,6], can: nil ) 286 | expect( diffs["/yak"] ).to eq_diff( :extra, "/yak", ref: nil, can: [7,8,9] ) 287 | end 288 | end 289 | 290 | context "top-level hash keys differ but the arrays are the same" do 291 | let(:reference) { { foo: [ 1, 2, 3 ], bar: [ 4, 5, 6 ] } } 292 | let(:candidate) { { foo: [ 1, 2, 3 ], yak: [ 4, 5, 6 ] } } 293 | 294 | it "has two diffs: one missing, one extra" do 295 | diffs = invoke!(reference, candidate) 296 | expect( diffs.length ).to eq( 2 ) 297 | expect( diffs["/bar"] ).to eq_diff( :missing, "/bar", ref: [4,5,6], can: nil ) 298 | expect( diffs["/yak"] ).to eq_diff( :extra, "/yak", ref: nil, can: [4,5,6] ) 299 | end 300 | end 301 | 302 | context "nested array elements differ" do 303 | let(:reference) { { foo: [ 1, 2, 3 ], bar: [ 2, 3, 4 ] } } 304 | let(:candidate) { { foo: [ 1, 2, 3 ], bar: [ 2, 3, 5 ] } } 305 | 306 | it "has one diff for the mismatch in the nested array elements" do 307 | diffs = invoke!(reference, candidate) 308 | expect( diffs.length ).to eq( 1 ) 309 | expect( diffs[0] ).to eq_diff( :mismatch, "/bar/3", ref: 4, can: 5 ) 310 | end 311 | end 312 | end 313 | 314 | context "when given two arrays of hashes" do 315 | it "raises an exception when the `match_by_value` list contains a matching path" do 316 | reference = [ { foo: 1 } ] 317 | 318 | candidate = [ { foo: 1 } ] 319 | expect { invoke!(reference, candidate, match_by_value: [ "/" ]) }.to \ 320 | raise_error( CheckPlease::BehaviorUndefined ) 321 | end 322 | 323 | context "same length, nested hash keys differ" do 324 | let(:reference) { [ { foo: 1, bar: 2, yak: 3 } ] } 325 | let(:candidate) { [ { foo: 1, bar: 2, quux: 3 } ] } 326 | 327 | it "has two diffs: one missing, one extra" do 328 | diffs = invoke!(reference, candidate) 329 | expect( diffs.length ).to eq( 2 ) 330 | 331 | expect( diffs["/1/yak"] ).to eq_diff( :missing, "/1/yak", ref: 3, can: nil ) 332 | expect( diffs["/1/quux"] ).to eq_diff( :extra, "/1/quux", ref: nil, can: 3 ) 333 | end 334 | end 335 | 336 | context "same length, nested hash keys same, one value mismatch" do 337 | let(:reference) { [ { foo: 1, bar: 2, yak: 3 } ] } 338 | let(:candidate) { [ { foo: 1, bar: 2, yak: 5 } ] } 339 | 340 | it "has one diff for the mismatch" do 341 | diffs = invoke!(reference, candidate) 342 | expect( diffs.length ).to eq( 1 ) 343 | expect( diffs[0] ).to eq_diff( :mismatch, "/1/yak", ref: 3, can: 5 ) 344 | end 345 | end 346 | 347 | context "reference longer than candidate" do 348 | let(:reference) { [ { foo: 1 }, { bar: 2 } ] } 349 | let(:candidate) { [ { foo: 1 } ] } 350 | 351 | it "has one diff for the missing hash" do 352 | diffs = invoke!(reference, candidate) 353 | expect( diffs.length ).to eq( 1 ) 354 | expect( diffs[0] ).to eq_diff( :missing, "/2", ref: { bar: 2 }, can: nil ) 355 | end 356 | end 357 | 358 | context "candidate longer than reference" do 359 | let(:reference) { [ { foo: 1 } ] } 360 | let(:candidate) { [ { foo: 1 }, { bar: 2 } ] } 361 | 362 | it "has one diff for the extra hash" do 363 | diffs = invoke!(reference, candidate) 364 | expect( diffs.length ).to eq( 1 ) 365 | expect( diffs[0] ).to eq_diff( :extra, "/2", ref: nil, can: { bar: 2 } ) 366 | end 367 | end 368 | end 369 | 370 | describe "comparing arrays by keys" do 371 | shared_examples "compare_arrays_by_key" do 372 | specify "comparing [A,B] with [B,A] with no match_by_key expressions complains a lot" do 373 | ref = [ a, b ] 374 | can = [ b, a ] 375 | diffs = invoke!( ref, can, match_by_key: [] ) # note empty list 376 | expect( diffs.length ).to eq( 4 ) 377 | expect( diffs[0] ).to eq_diff( :mismatch, "/1/id", ref: a["id"], can: b["id"] ) 378 | expect( diffs[1] ).to eq_diff( :mismatch, "/1/foo", ref: a["foo"], can: b["foo"] ) 379 | expect( diffs[2] ).to eq_diff( :mismatch, "/2/id", ref: b["id"], can: a["id"] ) 380 | expect( diffs[3] ).to eq_diff( :mismatch, "/2/foo", ref: b["foo"], can: a["foo"] ) 381 | # ^ ^ 382 | end 383 | 384 | specify "comparing [A,B] with [B,A] correctly matches up A and B using the :id value, resulting in zero diffs" do 385 | ref = [ a, b ] 386 | can = [ b, a ] 387 | diffs = invoke!( ref, can, match_by_key: [ "/:id" ] ) 388 | expect( diffs.length ).to eq( 0 ) 389 | end 390 | 391 | specify "comparing [A,B] with [A] complains that B is missing" do 392 | ref = [ a, b ] 393 | can = [ a ] 394 | diffs = invoke!( ref, can, match_by_key: [ "/:id" ] ) 395 | expect( diffs.length ).to eq( 1 ) 396 | expect( diffs[0] ).to eq_diff( :missing, "/id=#{b[b_key_name]}", ref: b, can: nil ) 397 | end 398 | 399 | specify "comparing [A,B] with [B] complains that A is missing" do 400 | ref = [ a, b ] 401 | can = [ b ] 402 | diffs = invoke!( ref, can, match_by_key: [ "/:id" ] ) 403 | expect( diffs.length ).to eq( 1 ) 404 | expect( diffs[0] ).to eq_diff( :missing, "/id=#{a[a_key_name]}", ref: a, can: nil ) 405 | end 406 | 407 | specify "comparing [A] with [A,B] complains that B is extra" do 408 | ref = [ a ] 409 | can = [ a, b ] 410 | diffs = invoke!( ref, can, match_by_key: [ "/:id" ] ) 411 | expect( diffs.length ).to eq( 1 ) 412 | expect( diffs[0] ).to eq_diff( :extra, "/id=#{b[b_key_name]}", ref: nil, can: b ) 413 | end 414 | 415 | specify "comparing [B] with [A,B] complains that B is extra" do 416 | ref = [ b ] 417 | can = [ a, b ] 418 | diffs = invoke!( ref, can, match_by_key: [ "/:id" ] ) 419 | expect( diffs.length ).to eq( 1 ) 420 | expect( diffs[0] ).to eq_diff( :extra, "/id=#{a[a_key_name]}", ref: nil, can: a ) 421 | end 422 | 423 | specify "comparing two lists where the top-level elements can be matched by key but have different child values... works (explicit keys for both levels)" do 424 | ref = [ { "id" => 1, "deeply" => { "nested" => [ a, b ] } } ] 425 | can = [ { "id" => 1, "deeply" => { "nested" => [ c, a ] } } ] 426 | 427 | diffs = invoke!( ref, can, match_by_key: [ "/:id", "/:id/deeply/nested/:id" ] ) 428 | expect( diffs.length ).to eq( 1 ) 429 | expect( diffs[0] ).to eq_diff( :mismatch, "/id=1/deeply/nested/id=2/foo", ref: "bat", can: "yak" ) 430 | end 431 | 432 | specify "comparing two lists where the top-level elements can be matched by key but have different child values... works (implicit key for top level)" do 433 | ref = [ { "id" => 1, "deeply" => { "nested" => [ a, b ] } } ] 434 | can = [ { "id" => 1, "deeply" => { "nested" => [ c, a ] } } ] 435 | 436 | diffs = invoke!( ref, can, match_by_key: [ "/:id/deeply/nested/:id" ] ) 437 | # ^^^^^^^ no "/:id" here 438 | expect( diffs.length ).to eq( 1 ) 439 | expect( diffs[0] ).to eq_diff( :mismatch, "/id=1/deeply/nested/id=2/foo", ref: "bat", can: "yak" ) 440 | end 441 | 442 | specify "comparing [A,B] with [B,A] raises NoSuchKeyError if given a bogus key expression" do 443 | ref = [ a, b ] 444 | can = [ b, a ] 445 | expect { invoke!( ref, can, match_by_key: [ "/:identifier" ] ) }.to \ 446 | raise_error(CheckPlease::NoSuchKeyError, /The reference hash at position 0 has no "identifier" key/) 447 | end 448 | 449 | specify "comparing [A,A] with [A] raises DuplicateKeyError" do 450 | ref = [ a, a ] 451 | can = [ a ] 452 | expect { invoke!( ref, can, match_by_key: [ "/:id" ] ) }.to \ 453 | raise_error(CheckPlease::DuplicateKeyError, /Duplicate reference element found/) 454 | end 455 | 456 | specify "comparing [A,A] with [A,A] raises DuplicateKeyError" do 457 | ref = [ a, a ] 458 | can = [ a, a ] 459 | expect { invoke!( ref, can, match_by_key: [ "/:id" ] ) }.to \ 460 | raise_error(CheckPlease::DuplicateKeyError, /Duplicate reference element found/) 461 | end 462 | 463 | specify "comparing [A] with [A,A] raises DuplicateKeyError" do 464 | ref = [ a ] 465 | can = [ a, a ] 466 | expect { invoke!( ref, can, match_by_key: [ "/:id" ] ) }.to \ 467 | raise_error(CheckPlease::DuplicateKeyError, /Duplicate candidate element found/) 468 | end 469 | 470 | specify "comparing [A] with [A,A,B] raises DuplicateKeyError" do 471 | ref = [ a ] 472 | can = [ a, a, b ] 473 | expect { invoke!( ref, can, match_by_key: [ "/:id" ] ) }.to \ 474 | raise_error(CheckPlease::DuplicateKeyError, /Duplicate candidate element found/) 475 | end 476 | 477 | specify "comparing [42] with [A] raises TypeMismatchError" do 478 | ref = [ 42 ] 479 | can = [ a ] 480 | expect { invoke!( ref, can, match_by_key: [ "/:id" ] ) }.to \ 481 | raise_error(CheckPlease::TypeMismatchError, /The element at position \d+ in the reference array is not a hash/) 482 | end 483 | 484 | specify "comparing [A] with [42] raises TypeMismatchError" do 485 | ref = [ a ] 486 | can = [ 42 ] 487 | expect { invoke!( ref, can, match_by_key: [ "/:id" ] ) }.to \ 488 | raise_error(CheckPlease::TypeMismatchError, /The element at position \d+ in the candidate array is not a hash/) 489 | end 490 | end 491 | 492 | context "when both ref and can use strings for keys" do 493 | let(:a) { { "id" => 1, "foo" => "bar" } } 494 | let(:b) { { "id" => 2, "foo" => "bat" } } 495 | let(:c) { { "id" => 2, "foo" => "yak" } } 496 | let(:a_key_name) { "id" } 497 | let(:b_key_name) { "id" } 498 | 499 | include_examples "compare_arrays_by_key" 500 | end 501 | 502 | ############################################### 503 | ## ## 504 | ## ######## ##### ###### ##### ## 505 | ## ## ## ## ## ## ## ## ## 506 | ## ## ## ## ## ## ## ## ## 507 | ## ## ## ## ## ## ## ## ## 508 | ## ## ## ## ## ## ## ## ## 509 | ## ## ## ## ## ## ## ## ## 510 | ## ## ##### ###### ##### ## 511 | ## ## 512 | ############################################### 513 | # TODO: decide how to handle non-string keys. Symbols? Integers? E_CAN_OF_WORMS 514 | ############################################### 515 | 516 | # context "when ref keys are symbols and can keys are strings" do 517 | # let(:a) { { :id => 1, :foo => "bar" } } 518 | # let(:b) { { "id" => 2, "foo" => "bat" } } 519 | # let(:a_key_name) { :id } 520 | # let(:b_key_name) { "id" } 521 | # 522 | # include_examples "compare_arrays_by_key" 523 | # end 524 | 525 | # context "when ref keys are strings and can keys are symbols" do 526 | # let(:a) { { "id" => 1, "foo" => "bar" } } 527 | # let(:b) { { :id => 2, :foo => "bat" } } 528 | # let(:a_key_name) { "id" } 529 | # let(:b_key_name) { :id } 530 | # 531 | # include_examples "compare_arrays_by_key" 532 | # end 533 | 534 | # context "when both ref and can use symbols for keys" do 535 | # let(:a) { { :id => 1, :foo => "bar" } } 536 | # let(:b) { { :id => 2, :foo => "bat" } } 537 | # let(:a_key_name) { :id } 538 | # let(:b_key_name) { :id } 539 | # 540 | # include_examples "compare_arrays_by_key" 541 | # end 542 | end 543 | 544 | context "when given an Array :reference and an Integer :candidate" do 545 | let(:reference) { [ 42 ] } 546 | let(:candidate) { 42 } 547 | 548 | it "has one diff for the top-level mismatch" do 549 | diffs = invoke!(reference, candidate) 550 | expect( diffs.length ).to eq( 1 ) 551 | expect( diffs[0] ).to eq_diff( :type_mismatch, "/", ref: [42], can: 42 ) 552 | end 553 | end 554 | 555 | context "when given an Integer :reference and an Array :candidate" do 556 | let(:reference) { 42 } 557 | let(:candidate) { [ 42 ] } 558 | 559 | it "has one diff for the top-level mismatch" do 560 | diffs = invoke!(reference, candidate) 561 | expect( diffs.length ).to eq( 1 ) 562 | expect( diffs[0] ).to eq_diff( :type_mismatch, "/", ref: 42, can: [42] ) 563 | end 564 | end 565 | 566 | context "for two data structures four levels deep, with one diff at each level" do 567 | let(:reference) { { a: 1, b: { c: 3, d: { e: 5, f: { g: 7 } } } } } 568 | let(:candidate) { { a: 2, b: { c: 4, d: { e: 6, f: { g: 8 } } } } } 569 | 570 | it "has four diffs" do 571 | diffs = invoke!(reference, candidate) 572 | expect( diffs.length ).to eq( 4 ) 573 | expect( diffs[0] ).to eq_diff( :mismatch, "/a", ref: 1, can: 2 ) 574 | expect( diffs[1] ).to eq_diff( :mismatch, "/b/c", ref: 3, can: 4 ) 575 | expect( diffs[2] ).to eq_diff( :mismatch, "/b/d/e", ref: 5, can: 6 ) 576 | expect( diffs[3] ).to eq_diff( :mismatch, "/b/d/f/g", ref: 7, can: 8 ) 577 | end 578 | 579 | it "has no diffs when passed a max_depth of 1" do 580 | diffs = invoke!(reference, candidate, max_depth: 1) 581 | expect( diffs.length ).to eq( 0 ) 582 | end 583 | 584 | it "only has the first diff when passed a max_depth of 2" do 585 | diffs = invoke!(reference, candidate, max_depth: 2) 586 | expect( diffs.length ).to eq( 1 ) 587 | expect( diffs[0] ).to eq_diff( :mismatch, "/a", ref: 1, can: 2 ) 588 | end 589 | 590 | it "only has the first two diffs when passed a max_depth of 3" do 591 | diffs = invoke!(reference, candidate, max_depth: 3) 592 | expect( diffs.length ).to eq( 2 ) 593 | expect( diffs[0] ).to eq_diff( :mismatch, "/a", ref: 1, can: 2 ) 594 | expect( diffs[1] ).to eq_diff( :mismatch, "/b/c", ref: 3, can: 4 ) 595 | end 596 | 597 | it "only has the first three diffs when passed a max_depth of 4" do 598 | diffs = invoke!(reference, candidate, max_depth: 4) 599 | expect( diffs.length ).to eq( 3 ) 600 | expect( diffs[0] ).to eq_diff( :mismatch, "/a", ref: 1, can: 2 ) 601 | expect( diffs[1] ).to eq_diff( :mismatch, "/b/c", ref: 3, can: 4 ) 602 | expect( diffs[2] ).to eq_diff( :mismatch, "/b/d/e", ref: 5, can: 6 ) 603 | end 604 | 605 | it "has all four diffs when passed a max_depth of 5" do 606 | diffs = invoke!(reference, candidate, max_depth: 5) 607 | expect( diffs.length ).to eq( 4 ) 608 | expect( diffs[0] ).to eq_diff( :mismatch, "/a", ref: 1, can: 2 ) 609 | expect( diffs[1] ).to eq_diff( :mismatch, "/b/c", ref: 3, can: 4 ) 610 | expect( diffs[2] ).to eq_diff( :mismatch, "/b/d/e", ref: 5, can: 6 ) 611 | expect( diffs[3] ).to eq_diff( :mismatch, "/b/d/f/g", ref: 7, can: 8 ) 612 | end 613 | end 614 | 615 | context "when given a complex data structure with more than one discrepancy" do 616 | let(:reference) { 617 | { 618 | id: 42, 619 | name: "The Answer", 620 | words: %w[ what do you get when you multiply six by nine ], 621 | meta: { foo: "spam", bar: "eggs", yak: "bacon" } 622 | } 623 | } 624 | let(:candidate) { 625 | { 626 | id: 42, 627 | name: "Charlie", 628 | # ^^^^^^^^^ 629 | words: %w[ what do we get when I multiply six by nine dude ], 630 | # ^^ ^ ^^^^ 631 | meta: { foo: "foo", yak: "bacon" } 632 | # ^^^^^ ^^^^^^^^^^^^ 633 | } 634 | } 635 | 636 | it "has the correct number of mismatches" do 637 | diffs = invoke!(reference, candidate) 638 | expect( diffs.length ).to eq( 6 ) 639 | 640 | expect( diffs["/name"] ).to eq_diff( :mismatch, "/name", ref: "The Answer", can: "Charlie" ) 641 | expect( diffs["/words/3"] ).to eq_diff( :mismatch, "/words/3", ref: "you", can: "we" ) 642 | expect( diffs["/words/6"] ).to eq_diff( :mismatch, "/words/6", ref: "you", can: "I" ) 643 | expect( diffs["/words/11"] ).to eq_diff( :extra, "/words/11", ref: nil, can: "dude" ) 644 | expect( diffs["/meta/foo"] ).to eq_diff( :mismatch, "/meta/foo", ref: "spam", can: "foo" ) 645 | expect( diffs["/meta/bar"] ).to eq_diff( :missing, "/meta/bar", ref: "eggs", can: nil ) 646 | end 647 | 648 | it "can be told to stop after N mismatches" do 649 | diffs = invoke!(reference, candidate, max_diffs: 3) 650 | expect( diffs.length ).to eq( 3 ) 651 | 652 | expect( diffs["/name"] ).to eq_diff( :mismatch, "/name", ref: "The Answer", can: "Charlie" ) 653 | expect( diffs["/words/3"] ).to eq_diff( :mismatch, "/words/3", ref: "you", can: "we" ) 654 | expect( diffs["/words/6"] ).to eq_diff( :mismatch, "/words/6", ref: "you", can: "I" ) 655 | end 656 | 657 | it "can be told to record ONLY diffs matching ONE specified path" do 658 | diffs = invoke!(reference, candidate, select_paths: ["/words"]) 659 | expect( diffs.length ).to eq( 3 ) 660 | 661 | expect( diffs["/words/3"] ).to eq_diff( :mismatch, "/words/3", ref: "you", can: "we" ) 662 | expect( diffs["/words/6"] ).to eq_diff( :mismatch, "/words/6", ref: "you", can: "I" ) 663 | expect( diffs["/words/11"] ).to eq_diff( :extra, "/words/11", ref: nil, can: "dude" ) 664 | end 665 | 666 | it "can be told to record ONLY diffs matching TWO specified paths" do 667 | diffs = invoke!(reference, candidate, select_paths: ["/name", "/words"]) 668 | expect( diffs.length ).to eq( 4 ) 669 | 670 | expect( diffs["/name"] ).to eq_diff( :mismatch, "/name", ref: "The Answer", can: "Charlie" ) 671 | expect( diffs["/words/3"] ).to eq_diff( :mismatch, "/words/3", ref: "you", can: "we" ) 672 | expect( diffs["/words/6"] ).to eq_diff( :mismatch, "/words/6", ref: "you", can: "I" ) 673 | expect( diffs["/words/11"] ).to eq_diff( :extra, "/words/11", ref: nil, can: "dude" ) 674 | end 675 | 676 | it "can be told to NOT record diffs matching ONE specified path" do 677 | diffs = invoke!(reference, candidate, reject_paths: ["/words"]) 678 | expect( diffs.length ).to eq( 3 ) 679 | 680 | expect( diffs["/name"] ).to eq_diff( :mismatch, "/name", ref: "The Answer", can: "Charlie" ) 681 | expect( diffs["/meta/foo"] ).to eq_diff( :mismatch, "/meta/foo", ref: "spam", can: "foo" ) 682 | expect( diffs["/meta/bar"] ).to eq_diff( :missing, "/meta/bar", ref: "eggs", can: nil ) 683 | end 684 | 685 | it "can be told to NOT record diffs matching TWO specified paths" do 686 | diffs = invoke!(reference, candidate, reject_paths: ["/name", "/words"]) 687 | expect( diffs.length ).to eq( 2 ) 688 | 689 | expect( diffs["/meta/foo"] ).to eq_diff( :mismatch, "/meta/foo", ref: "spam", can: "foo" ) 690 | expect( diffs["/meta/bar"] ).to eq_diff( :missing, "/meta/bar", ref: "eggs", can: nil ) 691 | end 692 | 693 | it "can be told to NOT record diffs matching a wildcard path" do 694 | diffs = invoke!(reference, candidate, reject_paths: ["/meta/*"]) 695 | expect( diffs.length ).to eq( 4 ) 696 | 697 | expect( diffs["/name"] ).to eq_diff( :mismatch, "/name", ref: "The Answer", can: "Charlie" ) 698 | expect( diffs["/words/3"] ).to eq_diff( :mismatch, "/words/3", ref: "you", can: "we" ) 699 | expect( diffs["/words/6"] ).to eq_diff( :mismatch, "/words/6", ref: "you", can: "I" ) 700 | expect( diffs["/words/11"] ).to eq_diff( :extra, "/words/11", ref: nil, can: "dude" ) 701 | end 702 | 703 | it "can be told to NOT record diffs matching a wildcard path, part 2" do 704 | reference = { posts: [ { id: 1, name: "Alice" } ] } 705 | candidate = { posts: [ { id: 2, name: "Bob" } ] } 706 | diffs = invoke!(reference, candidate, reject_paths: ["/posts/*/name"]) 707 | expect( diffs.length ).to eq( 1 ) 708 | 709 | expect( diffs[0] ).to eq_diff( :mismatch, "/posts/1/id", ref: 1, can: 2) 710 | end 711 | 712 | it "can be told to NOT record diffs matching a wildcard path, part 3" do 713 | reference = { posts: [ { id: 1, ads: [ { time: "soon" } ] } ] } 714 | candidate = { posts: [ { id: 2, ads: [ { time: "late" } ] } ] } 715 | diffs = invoke!(reference, candidate, reject_paths: ["/posts/*/ads/*/time"]) 716 | expect( diffs.length ).to eq( 1 ) 717 | 718 | expect( diffs[0] ).to eq_diff( :mismatch, "/posts/1/id", ref: 1, can: 2) 719 | end 720 | 721 | specify "attempting to invoke with both :select_paths and :reject_paths asplodes" do 722 | expect { invoke!(reference, candidate, select_paths: ["/foo"], reject_paths: ["/bar"]) }.to \ 723 | raise_error( CheckPlease::InvalidFlag ) 724 | end 725 | end 726 | 727 | specify "match_by_key and match_by_value play well together" do 728 | a = { "id" => 1, "list" => [ 1, 2, 3 ] } 729 | b1 = { "id" => 2, "list" => [ 4, 5, 6 ] } 730 | b2 = { "id" => 2, "list" => [ 4, 5, 1 ] } # degrees Fahrenheit 731 | 732 | reference = [ a, b1 ] 733 | candidate = [ b2, a ] 734 | diffs = invoke!( reference, candidate, match_by_key: [ "/:id" ], match_by_value: [ "/:id/list" ] ) 735 | expect( diffs.length ).to eq( 2 ) 736 | 737 | expect( diffs[0] ).to eq_diff( :missing, "/id=2/list/3", ref: 6, can: nil ) 738 | expect( diffs[1] ).to eq_diff( :extra, "/id=2/list/3", ref: nil, can: 1 ) 739 | end 740 | 741 | specify "match_by_value and wildcards play well together" do 742 | reference = { "data" => { "letters" => %w[ a b c ], "numbers" => [ 1, 2, 3 ] } } 743 | candidate = { "data" => { "letters" => %w[ b a d ], "numbers" => [ 3, 2, 5 ] } } 744 | 745 | diffs = invoke!( reference, candidate, match_by_value: [ "/data/*" ] ) 746 | expect( diffs.length ).to eq( 4 ) 747 | 748 | expect( diffs[0] ).to eq_diff( :missing, "/data/letters/3", ref: "c", can: nil ) 749 | expect( diffs[1] ).to eq_diff( :extra, "/data/letters/3", ref: nil, can: "d" ) 750 | expect( diffs[2] ).to eq_diff( :missing, "/data/numbers/1", ref: 1, can: nil ) 751 | expect( diffs[3] ).to eq_diff( :extra, "/data/numbers/3", ref: nil, can: 5 ) 752 | end 753 | 754 | describe "the normalize_values flag" do 755 | it "transforms values that match a given path" do 756 | iso8601 = "2021-03-15T12:34:56+00:00" 757 | rfc2822 = "Mon, 15 Mar 2021 12:34:56 +0000" 758 | 759 | reference = { id: 42, time: iso8601, number: 123 } 760 | candidate = { id: 42, time: rfc2822, number: "123" } 761 | 762 | # Make sure the diffs actually have what I expect 763 | diffs = invoke!( reference, candidate ) 764 | expect( diffs.length ).to eq( 2 ) 765 | expect( diffs[0] ).to eq_diff( :mismatch, "/time", ref: iso8601, can: rfc2822 ) 766 | expect( diffs[1] ).to eq_diff( :mismatch, "/number", ref: 123, can: "123" ) 767 | 768 | require 'time' 769 | # now actually test with normalize_values 770 | diffs = invoke!( reference, candidate, normalize_values: { 771 | "/time" => ->(v) { Time.parse(v) }, 772 | }) 773 | expect( diffs.length ).to eq( 1 ) 774 | expect( diffs[0] ).to eq_diff( :mismatch, "/number", ref: 123, can: "123" ) 775 | 776 | diffs = invoke!( reference, candidate, normalize_values: { 777 | "/number" => ->(v) { v.to_i }, 778 | }) 779 | expect( diffs.length ).to eq( 1 ) 780 | expect( diffs[0] ).to eq_diff( :mismatch, "/time", ref: iso8601, can: rfc2822 ) 781 | 782 | diffs = invoke!( reference, candidate, normalize_values: { 783 | "/time" => ->(v) { Time.parse(v) }, 784 | "/number" => ->(v) { v.to_i }, 785 | }) 786 | expect( diffs.length ).to eq( 0 ) 787 | end 788 | 789 | it "reports the un-transformed value on transformed diffs" do 790 | reference = { id: 42, number: 123 } 791 | candidate = { id: 42, number: "234" } 792 | 793 | # Make sure the diffs actually have what I expect 794 | diffs = invoke!( reference, candidate ) 795 | expect( diffs.length ).to eq( 1 ) 796 | expect( diffs[0] ).to eq_diff( :mismatch, "/number", ref: 123, can: "234" ) 797 | 798 | diffs = invoke!( reference, candidate, normalize_values: { 799 | "/number" => ->(v) { v.to_i }, 800 | }) 801 | expect( diffs.length ).to eq( 1 ) 802 | expect( diffs[0] ).to eq_diff( :mismatch, "/number", ref: 123, can: "234" ) 803 | end 804 | 805 | context "when comparing two lists, one of strings and the other of symbols" do 806 | let(:list_of_strings) { { list: %w[ foo bar yak ] } } 807 | let(:list_of_symbols) { { list: %i[ foo bar yak ] } } 808 | 809 | def invoke_with!(paths_to_procs = {}) 810 | invoke!( list_of_strings, list_of_symbols, normalize_values: paths_to_procs ) 811 | end 812 | 813 | before do 814 | diffs = invoke!( list_of_strings, list_of_symbols ) 815 | expect( diffs.length ).to eq( 3 ) 816 | expect( diffs[0] ).to eq_diff( :mismatch, "/list/1", ref: "foo", can: :foo ) 817 | expect( diffs[1] ).to eq_diff( :mismatch, "/list/2", ref: "bar", can: :bar ) 818 | expect( diffs[2] ).to eq_diff( :mismatch, "/list/3", ref: "yak", can: :yak ) 819 | end 820 | 821 | it "works with path expressions" do 822 | diffs = invoke_with!( 823 | "/list/*" => ->(v) { v.to_s }, 824 | ) 825 | expect( diffs.length ).to eq( 0 ) 826 | end 827 | 828 | it "works with symbol values instead of procs" do 829 | diffs = invoke_with!( 830 | "/list/*" => :to_s, 831 | ) 832 | expect( diffs.length ).to eq( 0 ) 833 | end 834 | 835 | it "works with string values instead of procs" do 836 | diffs = invoke_with!( 837 | "/list/*" => "to_s", 838 | ) 839 | expect( diffs.length ).to eq( 0 ) 840 | end 841 | end 842 | 843 | end 844 | 845 | end 846 | -------------------------------------------------------------------------------- /spec/check_please/diffs_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe CheckPlease::Diffs do 2 | subject { described_class.new } 3 | 4 | describe "lookups using #[]" do 5 | # NOTE: this feature makes other tests slightly more robust, in that they 6 | # can be indifferent to the specific ordering of diffs while still 7 | # comprehensively specifying that the correct diffs are there. 8 | 9 | let(:foo) { instance_double(CheckPlease::Diff, path: "foo") } 10 | let(:bar) { instance_double(CheckPlease::Diff, path: "bar") } 11 | 12 | before do 13 | subject << foo 14 | subject << bar 15 | end 16 | 17 | it "treats integers like array indices" do 18 | expect( subject[0] ).to be foo 19 | expect( subject[1] ).to be bar 20 | end 21 | 22 | it "treats strings like hash keys, looking diffs up by their path" do 23 | expect( subject["bar"] ).to be bar 24 | expect( subject["foo"] ).to be foo 25 | end 26 | end 27 | 28 | describe "output" do 29 | before do 30 | subject << CheckPlease::Diff.new(:missing, "/foo", 42, nil) 31 | subject << CheckPlease::Diff.new(:extra, "/spam", nil, 23) 32 | subject << CheckPlease::Diff.new(:mismatch, "/yak", "Hello world!", "Howdy globe!") 33 | end 34 | 35 | def render_as(format) 36 | CheckPlease::Printers.render(subject, format: format) 37 | end 38 | 39 | specify "#to_s defaults to the :table format" do 40 | expected = render_as(:table) 41 | expect( subject.to_s ).to eq( expected ) 42 | end 43 | 44 | specify "#to_s takes an optional :format kwarg" do 45 | CheckPlease::Printers::FORMATS.each do |format| 46 | expected = render_as(format) 47 | expect( subject.to_s(format: format) ).to eq( expected ) 48 | end 49 | end 50 | 51 | describe "format-specific output methods" do 52 | CheckPlease::Printers::PRINTERS_BY_FORMAT.each do |format, klass| 53 | specify "##{format} renders using #{klass}" do 54 | expected = render_as(format) 55 | expect( subject.send(format) ).to eq( expected ) 56 | end 57 | end 58 | 59 | specify "#bogus_format raises NoMethodError" do 60 | expect { subject.bogus_format }.to raise_error( NoMethodError ) 61 | end 62 | 63 | specify "#formats returns a list of formats to help remind forgetful developers what's available" do 64 | expect( subject.formats ).to eq( CheckPlease::Printers::FORMATS ) 65 | end 66 | end 67 | end 68 | 69 | end 70 | -------------------------------------------------------------------------------- /spec/check_please/flags_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe CheckPlease::Flags do 2 | shared_examples "a boolean flag" do 3 | it "defaults to false" do 4 | flags = flagify() 5 | expect( flags.send(flag_name) ).to be false 6 | end 7 | 8 | it "can be set to true at initialization time" do 9 | flags = flagify( flag_name => true ) 10 | expect( flags.send(flag_name) ).to be true 11 | end 12 | 13 | def self.it_coerces(value, to:) 14 | expected_value = to 15 | it "coerces #{value.inspect} to #{to.inspect}" do 16 | flags = flagify( flag_name => value ) 17 | 18 | actual = flags.send(flag_name) # <-- where the magic happens 19 | 20 | expect( actual ).to be( expected_value ) 21 | end 22 | end 23 | 24 | it_coerces false, to: false 25 | it_coerces nil, to: false 26 | 27 | it_coerces true, to: true 28 | it_coerces 0, to: true 29 | it_coerces 1, to: true 30 | it_coerces "", to: true 31 | it_coerces "yarp" , to: true 32 | end 33 | 34 | shared_examples "a path list" do 35 | it "defaults to an empty array" do 36 | flags = flagify() 37 | expect( flags.send(flag_name) ).to eq( [] ) 38 | end 39 | 40 | # Sorry, this got... super abstract when DRYed up :/ 41 | 42 | spec_body = ->(_example) { 43 | flags = flagify() 44 | 45 | expected = [] 46 | paths_to_add.each do |path_name| 47 | expected << path_name 48 | flags.send "#{flag_name}=", path_name 49 | actual = flags.send(flag_name) 50 | expect( actual ).to eq( pathify(expected) ) 51 | end 52 | } 53 | 54 | specify "the setter is a little surprising: it [reifies and] appends any values it's given to a list", &spec_body 55 | specify "the list doesn't persist between instances", &spec_body 56 | end 57 | 58 | shared_examples "an optional positive integer flag" do 59 | it "defaults to nil" do 60 | flags = flagify() 61 | expect( flags.send(flag_name) ).to be nil 62 | end 63 | 64 | it "can be set to an integer larger than zero at initialization time" do 65 | flags = flagify(max_diffs: 1) 66 | expect( flags.send(flag_name) ).to eq( 1 ) 67 | end 68 | 69 | it "can't be set to zero" do 70 | expect { flagify(flag_name => 0) }.to \ 71 | raise_error( CheckPlease::InvalidFlag ) 72 | end 73 | 74 | it "can't be set to a negative integer" do 75 | expect { flagify(flag_name => -1) }.to \ 76 | raise_error( CheckPlease::InvalidFlag ) 77 | end 78 | 79 | it "coerces a string value to an integer" do 80 | flags = flagify(flag_name => "42") 81 | expect( flags.send(flag_name) ).to eq( 42 ) 82 | end 83 | end 84 | 85 | 86 | describe "#format" do 87 | it "defaults to CheckPlease::Printers::DEFAULT_FORMAT" do 88 | flags = flagify() 89 | expect( flags.format ).to eq( CheckPlease::Printers::DEFAULT_FORMAT ) 90 | end 91 | 92 | it "can be set to :json at initialization time" do 93 | flags = flagify(format: :json) 94 | expect( flags.format ).to eq( :json ) 95 | end 96 | 97 | it "can't be set to just anything" do 98 | expect { flagify(format: :wibble) }.to \ 99 | raise_error( CheckPlease::InvalidFlag ) 100 | end 101 | end 102 | 103 | describe "#max_diffs" do 104 | let(:flag_name) { :max_diffs } 105 | it_behaves_like "an optional positive integer flag" 106 | end 107 | 108 | describe "#fail_fast" do 109 | let(:flag_name) { :fail_fast } 110 | it_behaves_like "a boolean flag" 111 | end 112 | 113 | describe "#max_depth" do 114 | let(:flag_name) { :max_diffs } 115 | it_behaves_like "an optional positive integer flag" 116 | end 117 | 118 | describe "select_paths" do 119 | let(:flag_name) { :select_paths } 120 | it_behaves_like "a path list" do 121 | let(:paths_to_add) { [ "/foo", "/bar" ] } 122 | end 123 | end 124 | 125 | describe "reject_paths" do 126 | let(:flag_name) { :reject_paths } 127 | it_behaves_like "a path list" do 128 | let(:paths_to_add) { [ "/foo", "/bar" ] } 129 | end 130 | end 131 | 132 | specify "select_paths and reject_paths can't both be set" do 133 | expect { flagify(select_paths: ["/foo"], reject_paths: ["/bar"]) }.to \ 134 | raise_error( CheckPlease::InvalidFlag ) 135 | end 136 | 137 | describe "match_by_key" do 138 | let(:flag_name) { :match_by_key } 139 | it_behaves_like "a path list" do 140 | let(:paths_to_add) { [ "/:id", "/foo/:id", "/bar/:id" ] } 141 | end 142 | end 143 | 144 | describe "match_by_value" do 145 | let(:flag_name) { :match_by_value } 146 | it_behaves_like "a path list" do 147 | let(:paths_to_add) { [ "/foo", "/bar" ] } 148 | end 149 | end 150 | 151 | describe "#indifferent_keys" do 152 | let(:flag_name) { :indifferent_keys } 153 | it_behaves_like "a boolean flag" 154 | end 155 | 156 | describe "#indifferent_values" do 157 | let(:flag_name) { :indifferent_values } 158 | it_behaves_like "a boolean flag" 159 | end 160 | 161 | end 162 | -------------------------------------------------------------------------------- /spec/check_please/path_segment_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe CheckPlease::PathSegment do 2 | def self.match_eh_returns(values_and_expected_returns = {}) 3 | line = caller[0].split(":")[1] 4 | values_and_expected_returns.each do |value, expected| 5 | specify "[line #{line}] #match?(#{value.inspect}) returns #{expected.inspect}" do 6 | 7 | actual = subject.match?(value) # <-- where the magic happens 8 | 9 | _compare expected, actual 10 | end 11 | end 12 | end 13 | 14 | describe ".reify" do 15 | it "returns the instance when given an instance of itself" do 16 | foo = described_class.reify("foo") 17 | returned = described_class.reify(foo) 18 | expect( returned ).to be( foo ) # object identity check 19 | end 20 | 21 | it "raises CheckPlease::PathSegment::InvalidPathSegment when given a string containing a space between non-space characters " do 22 | expect { described_class.reify("hey bob") }.to \ 23 | raise_error( CheckPlease::InvalidPathSegment ) 24 | end 25 | 26 | it "returns an instance with name='foo' when given 'foo' (a string)" do 27 | instance = described_class.reify("foo") 28 | expect( instance ).to be_a(described_class) 29 | expect( instance.name ).to eq( "foo" ) 30 | end 31 | 32 | it "returns an instance with name='foo' when given ' foo ' (a string with leading/trailing whitespace)" do 33 | instance = described_class.reify(" foo ") 34 | expect( instance ).to be_a(described_class) 35 | expect( instance.name ).to eq( "foo" ) 36 | end 37 | 38 | it "returns an instance with name='foo' when given :foo (a symbol)" do 39 | instance = described_class.reify(:foo) 40 | expect( instance ).to be_a(described_class) 41 | expect( instance.name ).to eq( "foo" ) 42 | end 43 | 44 | it "returns an instance with name='42' when given 42 (an integer)" do 45 | instance = described_class.reify(42) 46 | expect( instance ).to be_a(described_class) 47 | expect( instance.name ).to eq( "42" ) 48 | end 49 | 50 | it "raises when given a boolean" do 51 | expect { described_class.reify(true) }.to \ 52 | raise_error(ArgumentError, /reify was given: true.*but only accepts/m) 53 | end 54 | 55 | it "returns a list of instances when given [ 'foo', 'bar' ]" do 56 | list = described_class.reify( %w[ foo bar ] ) 57 | expect( list ).to be_an(Array) 58 | expect( list.length ).to eq( 2 ) 59 | 60 | foo, bar = *list 61 | expect( foo ).to be_a(described_class) 62 | expect( bar ).to be_a(described_class) 63 | expect( foo.name ).to eq( "foo" ) 64 | expect( bar.name ).to eq( "bar" ) 65 | end 66 | end 67 | 68 | specify "name must be a non-blank string with no whitespace after trimming" do 69 | aggregate_failures do 70 | expect { described_class.new() }.to raise_error( CheckPlease::InvalidPathSegment ) 71 | expect { described_class.new("") }.to raise_error( CheckPlease::InvalidPathSegment ) 72 | expect { described_class.new(" ") }.to raise_error( CheckPlease::InvalidPathSegment ) 73 | expect { described_class.new("a b") }.to raise_error( CheckPlease::InvalidPathSegment ) 74 | end 75 | end 76 | 77 | describe "created with 'foo'" do 78 | subject { described_class.new('foo') } 79 | 80 | has_these_basic_properties( 81 | :name => "foo", 82 | :key => nil, 83 | :key_value => nil, 84 | :key_expr? => false, 85 | :key_val_expr? => false, 86 | :splat? => false, 87 | ) 88 | 89 | match_eh_returns( 90 | "*" => true, # wildcard 91 | "foo" => true, # names match 92 | "bar" => false, 93 | ":foo" => false, 94 | "foo=23" => false, 95 | "foo=42" => false, 96 | ) 97 | end 98 | 99 | describe "created with ':foo' (a 'key expression')" do 100 | subject { described_class.new(':foo') } 101 | 102 | has_these_basic_properties( 103 | :name => ":foo", 104 | :key => "foo", 105 | :key_value => nil, 106 | :key_expr? => true, 107 | :key_val_expr? => false, 108 | :splat? => false, 109 | ) 110 | 111 | match_eh_returns( 112 | "*" => true, # wildcard 113 | "foo" => false, 114 | "bar" => false, 115 | ":foo" => false, # key exprs can't match other key exprs 116 | "foo=23" => true, # segment is a key expr that matches the given key/value 117 | "foo=42" => true, # segment is a key expr that matches the given key/value 118 | ) 119 | end 120 | 121 | describe "created with 'foo=42' (a 'key/value expression')" do 122 | subject { described_class.new('foo=42') } 123 | 124 | has_these_basic_properties( 125 | :name => "foo=42", 126 | :key => "foo", 127 | :key_value => "42", 128 | :key_expr? => false, 129 | :key_val_expr? => true, 130 | :splat? => false, 131 | ) 132 | 133 | match_eh_returns( 134 | "*" => true, # wildcard 135 | "foo" => false, 136 | "bar" => false, 137 | ":foo" => true, # segment is a key/value that matches the given key expr 138 | "foo=23" => false, # key/val exprs can't match other key/val exprs 139 | "foo=42" => false, # key/val exprs can't match other key/val exprs 140 | ) 141 | end 142 | 143 | describe "created with '*'" do 144 | subject { described_class.new('*') } 145 | 146 | has_these_basic_properties( 147 | :name => "*", 148 | :key => nil, 149 | :key_value => nil, 150 | :key_expr? => false, 151 | :key_val_expr? => false, 152 | :splat? => true, 153 | ) 154 | 155 | match_eh_returns( 156 | "*" => true, # wildcard matches wildcard 157 | "foo" => true, # wildcard matches wildcard 158 | "bar" => true, 159 | ":foo" => true, 160 | "foo=23" => true, 161 | "foo=42" => true, 162 | ) 163 | end 164 | 165 | end 166 | -------------------------------------------------------------------------------- /spec/check_please/path_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe CheckPlease::Path do 2 | specify ".root returns a root path" do 3 | root = described_class.root 4 | expect( root ).to be_a( described_class ) 5 | expect( root.to_s ).to eq( "/" ) 6 | expect( root.depth ).to eq( 1 ) 7 | expect( root.root? ).to be true 8 | end 9 | 10 | describe "an instance created with" do 11 | describe "no arguments" do 12 | subject { described_class.new() } 13 | 14 | has_these_basic_properties( 15 | :to_s => "/", 16 | :depth => 1, 17 | :root? => true, 18 | ) 19 | end 20 | 21 | describe "an empty string" do 22 | subject { described_class.new("") } 23 | 24 | has_these_basic_properties( 25 | :to_s => "/", 26 | :depth => 1, 27 | :root? => true, 28 | ) 29 | end 30 | 31 | describe "'foo' (a string)" do 32 | subject { described_class.new('foo') } 33 | 34 | has_these_basic_properties( 35 | :to_s => "/foo", 36 | :depth => 2, 37 | :root? => false, 38 | ) 39 | end 40 | 41 | describe ":foo (a symbol)" do 42 | subject { described_class.new(:foo) } 43 | 44 | has_these_basic_properties( 45 | :to_s => "/foo", 46 | :depth => 2, 47 | :root? => false, 48 | ) 49 | end 50 | 51 | describe "'/foo'" do 52 | subject { described_class.new('/foo') } 53 | 54 | has_these_basic_properties( 55 | :to_s => "/foo", 56 | :depth => 2, 57 | :root? => false, 58 | ) 59 | end 60 | 61 | describe "'/foo/bar'" do 62 | subject { described_class.new('/foo/bar') } 63 | 64 | has_these_basic_properties( 65 | :to_s => "/foo/bar", 66 | :depth => 3, 67 | :root? => false, 68 | ) 69 | 70 | specify "its .parent is a Path with name='/foo'" do 71 | expect( subject.parent ).to eq( pathify('/foo') ) 72 | end 73 | end 74 | 75 | describe "'/foo/*'" do 76 | subject { described_class.new('/foo/*') } 77 | 78 | has_these_basic_properties( 79 | :to_s => "/foo/*", 80 | :depth => 3, 81 | :root? => false, 82 | ) 83 | 84 | specify "its .parent is a Path with name='/foo'" do 85 | expect( subject.parent ).to eq( pathify('/foo') ) 86 | end 87 | end 88 | 89 | describe "'/foo/bar/yak'" do 90 | subject { described_class.new('/foo/bar/yak') } 91 | 92 | has_these_basic_properties( 93 | :to_s => "/foo/bar/yak", 94 | :depth => 4, 95 | :root? => false, 96 | ) 97 | end 98 | 99 | describe "'/foo/*/yak'" do 100 | subject { described_class.new('/foo/*/yak') } 101 | 102 | has_these_basic_properties( 103 | :to_s => "/foo/*/yak", 104 | :depth => 4, 105 | :root? => false, 106 | ) 107 | end 108 | 109 | describe "'1' (a string)" do 110 | subject { described_class.new('1') } 111 | 112 | has_these_basic_properties( 113 | :to_s => "/1", 114 | :depth => 2, 115 | :root? => false, 116 | ) 117 | end 118 | 119 | describe "1 (an integer)" do 120 | subject { described_class.new(1) } 121 | 122 | has_these_basic_properties( 123 | :to_s => "/1", 124 | :depth => 2, 125 | :root? => false, 126 | ) 127 | end 128 | 129 | describe "'1.1' (a string)" do 130 | subject { described_class.new('1.1') } 131 | 132 | has_these_basic_properties( 133 | :to_s => "/1.1", 134 | :depth => 2, 135 | :root? => false, 136 | ) 137 | end 138 | 139 | describe "1.1 (a float)" do 140 | subject { described_class.new(1.1) } 141 | 142 | has_these_basic_properties( 143 | :to_s => "/1.1", 144 | :depth => 2, 145 | :root? => false, 146 | ) 147 | end 148 | 149 | describe "'/:id' (a string representing a key expression)" do 150 | subject { described_class.new('/:id') } 151 | 152 | has_these_basic_properties( 153 | :to_s => "/:id", 154 | :depth => 2, 155 | :root? => false, 156 | ) 157 | end 158 | 159 | describe "'/foo/:id/bar/:name' (a string representing two key expressions)" do 160 | subject { described_class.new('/foo/:id/bar/:name') } 161 | 162 | has_these_basic_properties( 163 | :to_s => "/foo/:id/bar/:name", 164 | :depth => 5, 165 | :root? => false, 166 | ) 167 | end 168 | end 169 | 170 | describe "#==" do 171 | def self.it_returns(expected, when_given:) 172 | desc = when_given.is_a?(described_class) \ 173 | ? "path '%s'" % when_given.to_s \ 174 | : when_given.inspect 175 | specify "returns #{expected.inspect} when given #{desc}" do 176 | 177 | actual = (subject == when_given) # <-- where the magic happens 178 | 179 | _compare expected, actual 180 | end 181 | end 182 | 183 | context "for path '/foo'" do 184 | subject(:foo) { pathify('/foo') } 185 | 186 | it_returns true, when_given: pathify('/foo') 187 | it_returns true, when_given: '/foo' 188 | it_returns false, when_given: pathify('/foo/bar') 189 | it_returns false, when_given: '/foo/bar' 190 | end 191 | 192 | context "for path '/foo/bar'" do 193 | subject(:foobar) { pathify('/foo/bar') } 194 | 195 | it_returns false, when_given: pathify('/foo') 196 | it_returns false, when_given: '/foo' 197 | it_returns true, when_given: pathify('/foo/bar') 198 | it_returns true, when_given: '/foo/bar' 199 | end 200 | 201 | it "returns false if the operand has the same length but a different name" do 202 | foo = pathify('/foo') 203 | bar = pathify('/bar') 204 | expect( foo ).to_not eq( bar ) 205 | end 206 | 207 | it "returns false if the operand should #match? but is literally different" do 208 | foo_id_42 = pathify('/foo/id=42') 209 | foo_id_expr = pathify('/foo/:id') 210 | expect( foo_id_42 ).to_not eq( foo_id_expr ) 211 | end 212 | end 213 | 214 | describe "#+" do 215 | specify "path '/' plus the string 'wibble' returns path '/wibble'" do 216 | root = described_class.root 217 | path = root + "wibble" 218 | expect( path ).to be_a(described_class) 219 | expect( path.to_s ).to eq( "/wibble" ) 220 | end 221 | 222 | specify "path '/foo' plus the string 'bar' returns path '/foo/bar'" do 223 | foo = pathify('/foo') 224 | bar = foo + 'bar' 225 | expect( bar ).to be_a(described_class) 226 | expect( bar.to_s ).to eq( "/foo/bar" ) 227 | end 228 | end 229 | 230 | describe ".reify" do 231 | it "returns the instance when given an instance of itself" do 232 | foo = described_class.reify("foo") 233 | returned = described_class.reify(foo) 234 | expect( returned ).to be( foo ) # object identity check 235 | end 236 | 237 | it "raises CheckPlease::PathSegment::InvalidPath when given a string containing a space between non-space characters " do 238 | expect { described_class.reify("hey bob") }.to \ 239 | raise_error( CheckPlease::InvalidPath ) 240 | end 241 | 242 | it "returns an instance with to_s='/foo' when given 'foo' (a string)" do 243 | instance = described_class.reify("foo") 244 | expect( instance ).to be_a(described_class) 245 | expect( instance.to_s ).to eq( "/foo" ) 246 | end 247 | 248 | it "returns an instance with to_s='/foo' when given ' foo ' (a string with leading/trailing whitespace)" do 249 | instance = described_class.reify(" foo ") 250 | expect( instance ).to be_a(described_class) 251 | expect( instance.to_s ).to eq( "/foo" ) 252 | end 253 | 254 | it "returns an instance with to_s='/foo' when given :foo (a symbol)" do 255 | instance = described_class.reify(:foo) 256 | expect( instance ).to be_a(described_class) 257 | expect( instance.to_s ).to eq( "/foo" ) 258 | end 259 | 260 | it "returns an instance with to_s='/42' when given 42 (an integer)" do 261 | instance = described_class.reify(42) 262 | expect( instance ).to be_a(described_class) 263 | expect( instance.to_s ).to eq( "/42" ) 264 | end 265 | 266 | it "raises when given a boolean" do 267 | expect { described_class.reify(true) }.to \ 268 | raise_error(ArgumentError, /reify was given: true.*but only accepts/m) 269 | end 270 | 271 | it "returns a list of instances when given [ 'foo', 'bar' ]" do 272 | list = described_class.reify( %w[ foo bar ] ) 273 | expect( list ).to be_an(Array) 274 | expect( list.length ).to eq( 2 ) 275 | 276 | foo, bar = *list 277 | expect( foo ).to be_a(described_class) 278 | expect( bar ).to be_a(described_class) 279 | expect( foo.to_s ).to eq( "/foo" ) 280 | expect( bar.to_s ).to eq( "/bar" ) 281 | end 282 | end 283 | 284 | describe "#parent" do 285 | specify "the parent of '/foo/bar' is 'foo'" do 286 | foobar = pathify('/foo/bar') 287 | expect( foobar.parent ).to be_a(described_class) 288 | expect( foobar.parent.to_s ).to eq( '/foo' ) 289 | end 290 | 291 | specify "the parent of '/foo' is root" do 292 | foo = pathify('/foo') 293 | expect( foo.parent ).to be_a(described_class) 294 | expect( foo.parent.to_s ).to eq( '/' ) 295 | expect( foo.parent ).to be_root 296 | end 297 | 298 | specify "the parent of root is nil" do 299 | root = described_class.root 300 | expect( root.parent ).to be nil 301 | end 302 | end 303 | 304 | describe "#ancestors" do 305 | specify "the ancestors of '/foo/bar' are root and '/foo' (ancestors are listed bottom-up)" do 306 | expect( pathify('/foo/bar').ancestors.map(&:to_s) ).to eq( [ "/foo", "/"] ) 307 | end 308 | 309 | specify "the ancestors of '/foo' are (just) root" do 310 | expect( pathify('/foo').ancestors.map(&:to_s) ).to eq( [ "/" ]) 311 | end 312 | 313 | specify "the ancestors of root are empty" do 314 | expect( described_class.root.ancestors ).to be_empty 315 | end 316 | end 317 | 318 | describe "#excluded?" do 319 | it "answers true if the path's depth exceeds the max_depth flag (NOTE: root has depth=1)" do 320 | flags = flagify(max_depth: 2) 321 | aggregate_failures do 322 | expect( pathify('/foo') ).to_not be_excluded(flags) 323 | expect( pathify('/foo/bar') ).to be_excluded(flags) 324 | expect( pathify('/foo/bar/yak') ).to be_excluded(flags) 325 | expect( pathify('/foo/bar/yak/spam') ).to be_excluded(flags) 326 | end 327 | 328 | flags = flagify(max_depth: 3) 329 | aggregate_failures do 330 | expect( pathify('/foo') ).to_not be_excluded(flags) 331 | expect( pathify('/foo/bar') ).to_not be_excluded(flags) 332 | expect( pathify('/foo/bar/yak') ).to be_excluded(flags) 333 | expect( pathify('/foo/bar/yak/spam') ).to be_excluded(flags) 334 | end 335 | 336 | flags = flagify(max_depth: 4) 337 | aggregate_failures do 338 | expect( pathify('/foo') ).to_not be_excluded(flags) 339 | expect( pathify('/foo/bar') ).to_not be_excluded(flags) 340 | expect( pathify('/foo/bar/yak') ).to_not be_excluded(flags) 341 | expect( pathify('/foo/bar/yak/spam') ).to be_excluded(flags) 342 | end 343 | end 344 | 345 | # NOTE: the /name, /words/*, and /meta/* examples were swiped from spec/check_please/comparison_spec.rb 346 | 347 | it "answers true if select_paths is present and the path IS NOT on/under the list" do 348 | flags = flagify(select_paths: "/words") 349 | aggregate_failures do 350 | expect( pathify('/name') ).to be_excluded(flags) 351 | expect( pathify('/words') ).to_not be_excluded(flags) 352 | expect( pathify('/words/3') ).to_not be_excluded(flags) 353 | expect( pathify('/words/6') ).to_not be_excluded(flags) 354 | expect( pathify('/words/11') ).to_not be_excluded(flags) 355 | expect( pathify('/meta') ).to be_excluded(flags) 356 | expect( pathify('/meta/foo') ).to be_excluded(flags) 357 | expect( pathify('/meta/bar') ).to be_excluded(flags) 358 | end 359 | end 360 | 361 | it "answers true if reject_paths is present and the path IS on/under the list" do 362 | flags = flagify(reject_paths: "/words") 363 | aggregate_failures do 364 | expect( pathify('/name') ).to_not be_excluded(flags) 365 | expect( pathify('/words') ).to be_excluded(flags) 366 | expect( pathify('/words/3') ).to be_excluded(flags) 367 | expect( pathify('/words/6') ).to be_excluded(flags) 368 | expect( pathify('/words/11') ).to be_excluded(flags) 369 | expect( pathify('/meta') ).to_not be_excluded(flags) 370 | expect( pathify('/meta/foo') ).to_not be_excluded(flags) 371 | expect( pathify('/meta/bar') ).to_not be_excluded(flags) 372 | end 373 | end 374 | 375 | it "answers true if reject_paths is present and the path IS on/under the list" do 376 | flags = flagify(reject_paths: ["/meta/foo"]) 377 | aggregate_failures do 378 | expect( pathify('/name') ).to_not be_excluded(flags) 379 | expect( pathify('/words') ).to_not be_excluded(flags) 380 | expect( pathify('/words/3') ).to_not be_excluded(flags) 381 | expect( pathify('/words/6') ).to_not be_excluded(flags) 382 | expect( pathify('/words/11') ).to_not be_excluded(flags) 383 | expect( pathify('/meta') ).to_not be_excluded(flags) 384 | expect( pathify('/meta/foo') ).to be_excluded(flags) 385 | expect( pathify('/meta/bar') ).to_not be_excluded(flags) 386 | end 387 | end 388 | 389 | it "answers true if reject_paths is present and the path IS on/under the list, with a wildcard" do 390 | flags = flagify(reject_paths: ["/meta/*"]) 391 | aggregate_failures do 392 | expect( pathify('/name') ).to_not be_excluded(flags) 393 | expect( pathify('/words') ).to_not be_excluded(flags) 394 | expect( pathify('/words/3') ).to_not be_excluded(flags) 395 | expect( pathify('/words/6') ).to_not be_excluded(flags) 396 | expect( pathify('/words/11') ).to_not be_excluded(flags) 397 | expect( pathify('/meta') ).to_not be_excluded(flags) 398 | expect( pathify('/meta/foo') ).to be_excluded(flags) 399 | expect( pathify('/meta/bar') ).to be_excluded(flags) 400 | 401 | # BONUS TEST 402 | expect( pathify('/meta/bar/yak') ).to be_excluded(flags) 403 | end 404 | end 405 | end 406 | 407 | describe "#match?" do 408 | def self.paths_to_test 409 | [ 410 | '/foo', 411 | '/bar', 412 | '/foo/bar', 413 | '/foo/yak', 414 | '/*', 415 | '/foo/*', 416 | '/foo/:id', 417 | '/foo/id=23', 418 | '/foo/id=42', 419 | '/foo/id=42/bar', 420 | '/foo/id=42/bar/id=23', 421 | '/foo/:name', 422 | '/foo/:name/bar/:id', 423 | '/foo/*/bar/*', 424 | ] 425 | end 426 | 427 | def self.it_returns(expected, when_given:, line: nil) 428 | specify "returns #{expected} when given #{when_given.inspect}" do 429 | actual = subject.match?(when_given) # <-- where the magic happens 430 | _compare expected, actual 431 | end 432 | end 433 | 434 | def self.it_matches(*paths_expected_to_match) 435 | true_paths = Array(paths_expected_to_match).flatten 436 | false_paths = paths_to_test - true_paths 437 | 438 | true_paths.each do |true_path| 439 | it_returns true, when_given: true_path 440 | end 441 | false_paths.each do |false_path| 442 | it_returns false, when_given: false_path 443 | end 444 | end 445 | 446 | context "for path '/foo'" do 447 | subject { pathify('/foo') } 448 | 449 | it_matches( 450 | '/foo', # literal string equality 451 | '/*', # wildcard 452 | ) 453 | end 454 | 455 | context "for path '/*'" do 456 | subject { pathify('/*') } 457 | 458 | it_matches( 459 | '/foo', # wildcard 460 | '/bar', # wildcard 461 | '/*', # literal string equality 462 | ) 463 | end 464 | 465 | context "for path '/foo/id=42'" do 466 | subject { pathify('/foo/id=42') } 467 | 468 | it_matches( 469 | '/foo/id=42', # literal string equality 470 | '/foo/:id', # key/val expr in subject matches key expr in argument 471 | '/foo/*', # wildcard 472 | ) 473 | end 474 | 475 | context "for path '/foo/id=42/bar/id=23'" do 476 | subject { pathify('/foo/id=42/bar/id=23') } 477 | 478 | it_matches( 479 | '/foo/id=42/bar/id=23', # literal string equality 480 | '/foo/:id/bar/:id', # key/val expr in subject matches key expr in argument 481 | '/foo/*/bar/*', # wildcard 482 | ) 483 | end 484 | 485 | context "for path '/foo/name=42/bar/id=23'" do 486 | subject { pathify('/foo/name=42/bar/id=23') } 487 | 488 | it_matches( 489 | '/foo/name=42/bar/id=23', # literal string equality 490 | '/foo/:name/bar/:id', # key/val expr in subject matches key expr in argument 491 | '/foo/*/bar/*', # wildcard 492 | ) 493 | end 494 | end 495 | 496 | describe "#key_to_match_by (note: MBK=match_by_key)" do 497 | def self.it_returns(expected, for_path:) 498 | line = caller[0].split(":")[1] 499 | specify "[line #{line}] returns #{expected.inspect} for path '#{for_path}'" do 500 | the_path = pathify(for_path) 501 | 502 | actual = the_path.key_to_match_by(flags) # <-- where the magic happens 503 | 504 | _compare expected, actual 505 | end 506 | end 507 | 508 | context "when given flags with no MBK expressions" do 509 | let(:flags) { flagify({}) } 510 | 511 | it_returns nil, for_path: '/' 512 | it_returns nil, for_path: '/id=42' 513 | it_returns nil, for_path: '/foo' 514 | it_returns nil, for_path: '/foo/id=42' 515 | it_returns nil, for_path: '/foo/id=42/bar' 516 | it_returns nil, for_path: '/foo/id=42/bar/id=23' 517 | it_returns nil, for_path: '/foo/name=42/bar/id=23' 518 | end 519 | 520 | context "when given flags with a '/:id' MBK expression" do 521 | let(:flags) { flagify(match_by_key: "/:id") } 522 | 523 | it_returns "id", for_path: '/' 524 | it_returns nil, for_path: '/id=42' 525 | it_returns nil, for_path: '/foo' 526 | it_returns nil, for_path: '/foo/id=42' 527 | it_returns nil, for_path: '/foo/id=42/bar' 528 | it_returns nil, for_path: '/foo/id=42/bar/id=23' 529 | it_returns nil, for_path: '/foo/name=42/bar/id=23' 530 | end 531 | 532 | context "when given flags with a '/foo/:id' MBK expression" do 533 | let(:flags) { flagify(match_by_key: "/foo/:id") } 534 | 535 | it_returns nil, for_path: '/' 536 | it_returns nil, for_path: '/id=42' 537 | it_returns "id", for_path: '/foo' 538 | it_returns nil, for_path: '/foo/id=42' 539 | it_returns nil, for_path: '/foo/id=42/bar' 540 | it_returns nil, for_path: '/foo/id=42/bar/id=23' 541 | it_returns nil, for_path: '/foo/name=42/bar/id=23' 542 | end 543 | 544 | context "when given flags with a '/foo/:id/bar/:id' MBK expression" do 545 | let(:flags) { flagify(match_by_key: "/foo/:id/bar/:id") } 546 | 547 | it_returns nil, for_path: '/' 548 | it_returns nil, for_path: '/id=42' 549 | it_returns "id", for_path: '/foo' 550 | it_returns nil, for_path: '/foo/id=42' 551 | it_returns "id", for_path: '/foo/id=42/bar' 552 | it_returns nil, for_path: '/foo/id=42/bar/id=23' 553 | it_returns nil, for_path: '/foo/name=42/bar' 554 | it_returns nil, for_path: '/foo/name=42/bar/id=23' 555 | end 556 | 557 | context "when given flags with a '/foo/:name/bar/:id' MBK expression" do 558 | let(:flags) { flagify(match_by_key: "/foo/:name/bar/:id") } 559 | 560 | it_returns nil, for_path: '/' 561 | it_returns nil, for_path: '/id=42' 562 | it_returns "name", for_path: '/foo' 563 | it_returns nil, for_path: '/foo/id=42' 564 | it_returns nil, for_path: '/foo/id=42/bar' 565 | it_returns nil, for_path: '/foo/id=42/bar/id=23' 566 | it_returns "id", for_path: '/foo/name=42/bar' 567 | it_returns nil, for_path: '/foo/name=42/bar/id=23' 568 | it_returns nil, for_path: '/foo/name=42/bar/id=23' 569 | end 570 | end 571 | end 572 | -------------------------------------------------------------------------------- /spec/check_please/printers/json_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'shared' 2 | 3 | RSpec.describe CheckPlease::Printers::JSON do 4 | context "for two very simple hashes" do 5 | let(:ref) { { foo: "wibble" } } 6 | let(:can) { { bar: "wibble" } } 7 | let(:expected_output) { 8 | <<~EOF.strip 9 | [ 10 | { "type": "missing", "path": "/foo", "reference": "wibble", "candidate": null }, 11 | { "type": "extra", "path": "/bar", "reference": null, "candidate": "wibble" } 12 | ] 13 | EOF 14 | } 15 | 16 | include_examples ".render" 17 | end 18 | 19 | context "for two very simple hashes that are equal" do 20 | let(:ref) { { foo: "wibble" } } 21 | let(:can) { { foo: "wibble" } } 22 | let(:expected_output) { "[]" } 23 | 24 | include_examples ".render" 25 | end 26 | 27 | context "for two hashes with relatively long keys" do 28 | let(:ref) { { the_sun_is_a_mass_of_incandescent_gas_a_gigantic_nuclear_furnace: "where hydrogen is built into helium at a temperature of millions of degrees" } } 29 | let(:can) { { the_sun_is_a_miasma_of_incandescent_plasma: "the sun's not simply made out of gas" } } 30 | let(:expected_output) { 31 | <<~EOF.strip 32 | [ 33 | { "type": "missing", "path": "/the_sun_is_a_mass_of_incandescent_gas_a_gigantic_nuclear_furnace", "reference": "where hydrogen is built into helium at a temperature of millions of degrees", "candidate": null }, 34 | { "type": "extra", "path": "/the_sun_is_a_miasma_of_incandescent_plasma", "reference": null, "candidate": "the sun's not simply made out of gas" } 35 | ] 36 | EOF 37 | } 38 | 39 | include_examples ".render" 40 | end 41 | end 42 | 43 | -------------------------------------------------------------------------------- /spec/check_please/printers/long_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'shared' 2 | 3 | RSpec.describe CheckPlease::Printers::Long do 4 | context "for two very simple hashes" do 5 | let(:ref) { { foo: "wibble", yak: "Hello world!" } } 6 | let(:can) { { bar: "wibble", yak: "Howdy globe!" } } 7 | let(:expected_output) { 8 | <<~EOF.strip 9 | /foo [missing] 10 | reference: "wibble" 11 | candidate: [no value] 12 | 13 | /yak [mismatch] 14 | reference: "Hello world!" 15 | candidate: "Howdy globe!" 16 | 17 | /bar [extra] 18 | reference: [no value] 19 | candidate: "wibble" 20 | EOF 21 | } 22 | 23 | include_examples ".render" 24 | end 25 | 26 | context "for two very simple hashes that are equal" do 27 | let(:ref) { { foo: "wibble" } } 28 | let(:can) { { foo: "wibble" } } 29 | let(:expected_output) { "" } 30 | include_examples ".render" 31 | end 32 | 33 | context "for two hashes with relatively long keys" do 34 | let(:ref) { { the_sun_is_a_mass_of_incandescent_gas_a_gigantic_nuclear_furnace: "where hydrogen is built into helium at a temperature of millions of degrees" } } 35 | let(:can) { { the_sun_is_a_miasma_of_incandescent_plasma: "the sun's not simply made out of gas" } } 36 | let(:expected_output) { 37 | <<~EOF.strip 38 | /the_sun_is_a_mass_of_incandescent_gas_a_gigantic_nuclear_furnace [missing] 39 | reference: "where hydrogen is built into helium at a temperature of millions of degrees" 40 | candidate: [no value] 41 | 42 | /the_sun_is_a_miasma_of_incandescent_plasma [extra] 43 | reference: [no value] 44 | candidate: "the sun's not simply made out of gas" 45 | EOF 46 | } 47 | 48 | include_examples ".render" 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/check_please/printers/shared.rb: -------------------------------------------------------------------------------- 1 | RSpec.shared_examples ".render" do 2 | specify ".render produces the expected output" do 3 | diffs = CheckPlease.diff(ref, can) 4 | actual = described_class.render(diffs) 5 | 6 | if $debug 7 | puts "\nEXPECTED: <<<", expected_output, ">>>" 8 | puts "\nACTUAL: <<<", actual, ">>>" 9 | end 10 | 11 | expect( actual ).to eq( expected_output ) 12 | end 13 | end 14 | 15 | -------------------------------------------------------------------------------- /spec/check_please/printers/table_print_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'shared' 2 | 3 | RSpec.describe CheckPlease::Printers::TablePrint do 4 | context "for two very simple hashes" do 5 | let(:ref) { { foo: "wibble" } } 6 | let(:can) { { bar: "wibble" } } 7 | let(:expected_output) { 8 | <<~EOF.strip 9 | TYPE | PATH | REFERENCE | CANDIDATE 10 | --------|------|-----------|---------- 11 | missing | /foo | "wibble" | 12 | extra | /bar | | "wibble" 13 | EOF 14 | } 15 | 16 | include_examples ".render" 17 | end 18 | 19 | context "for two very simple hashes that are equal" do 20 | let(:ref) { { foo: "wibble" } } 21 | let(:can) { { foo: "wibble" } } 22 | let(:expected_output) { "" } 23 | 24 | include_examples ".render" 25 | end 26 | 27 | context "for two hashes with relatively long keys" do 28 | let(:ref) { { the_sun_is_a_mass_of_incandescent_gas_a_gigantic_nuclear_furnace: "where hydrogen is built into helium at a temperature of millions of degrees" } } 29 | let(:can) { { the_sun_is_a_miasma_of_incandescent_plasma: "the sun's not simply made out of gas" } } 30 | let(:expected_output) { 31 | <<~EOF.strip 32 | TYPE | PATH | REFERENCE | CANDIDATE 33 | --------|-------------------------------------------------------------------|--------------------------------|------------------------------- 34 | missing | /the_sun_is_a_mass_of_incandescent_gas_a_gigantic_nuclear_furnace | "where hydrogen is built in... | 35 | extra | /the_sun_is_a_miasma_of_incandescent_plasma | | "the sun's not simply made ... 36 | EOF 37 | } 38 | 39 | include_examples ".render" 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/check_please_spec.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'yaml' 3 | 4 | RSpec.describe CheckPlease do 5 | it "has a version number" do 6 | expect(CheckPlease::VERSION).not_to be nil 7 | end 8 | 9 | describe ".diff" do 10 | context "with two very simple hashes" do 11 | let(:ref) { { "answer" => 42 } } 12 | let(:can) { { "answer" => 43 } } 13 | 14 | def assert_diffs(diffs) 15 | expect( diffs ).to be_a(CheckPlease::Diffs) 16 | expect( diffs.length ).to eq( 1 ) 17 | 18 | diff = diffs[0] 19 | expect( diff.path ).to eq( "/answer" ) 20 | expect( diff.reference ).to eq( 42 ) 21 | expect( diff.candidate ).to eq( 43 ) 22 | end 23 | 24 | it "takes a reference and a candidate, compares them, and returns a Diffs" do 25 | assert_diffs CheckPlease.diff(ref, can) 26 | end 27 | 28 | it "parses the reference from JSON if it's in JSON" do 29 | assert_diffs CheckPlease.diff(ref.to_json, can) 30 | end 31 | 32 | it "parses the candidate from JSON if it's in JSON" do 33 | assert_diffs CheckPlease.diff(ref, can.to_json) 34 | end 35 | 36 | it "parses the reference from YAML if it's in YAML" do 37 | assert_diffs CheckPlease.diff(ref.to_yaml, can) 38 | end 39 | 40 | it "parses the candidate from YAML if it's in YAML" do 41 | assert_diffs CheckPlease.diff(ref, can.to_yaml) 42 | end 43 | end 44 | 45 | describe "across supported input formats (not that anyone would actually DO this...)" do 46 | let(:ref_json) { fixture_file_contents("forty-two-reference.json") } 47 | let(:ref_yaml) { fixture_file_contents("forty-two-reference.yaml") } 48 | let(:can_json) { fixture_file_contents("forty-two-candidate.json") } 49 | let(:can_yaml) { fixture_file_contents("forty-two-candidate.yaml") } 50 | 51 | FORMATS = %w[ json yaml ] 52 | 53 | # Don't metaprogram your specs at home, kids! 54 | FORMATS.each do |ref_format| 55 | FORMATS.each do |can_format| 56 | it "can compare #{ref_format.upcase} to #{can_format.upcase}" do 57 | ref = send("ref_#{ref_format}") 58 | can = send("can_#{can_format}") 59 | diffs = CheckPlease.diff(ref, can) 60 | expect( diffs.length ).to eq( 6 ) 61 | end 62 | end 63 | end 64 | end 65 | end 66 | 67 | # NOTE: .render_diff is so simple it's not even worth testing on its own 68 | # (also it's exercised by the integration specs) 69 | end 70 | -------------------------------------------------------------------------------- /spec/cli_integration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'open3' 2 | require 'timeout' 3 | 4 | RSpec.describe "bin/check_please executable", :cli do 5 | 6 | ############################################### 7 | ## ## 8 | ## ## ## ##### ######## ######## ## 9 | ## ### ## ## ## ## ## ## 10 | ## #### ## ## ## ## ## ## 11 | ## ## ## ## ## ## ## ###### ## 12 | ## ## #### ## ## ## ## ## 13 | ## ## ### ## ## ## ## ## 14 | ## ## ## ##### ## ######## ## 15 | ## ## 16 | ############################################### 17 | # NOTE: These tests are slow (relative to everything else). 18 | # Please only add specs here for behavior that you can't possibly test any other way. 19 | ############################################### 20 | 21 | specify "output of -h/--help" do 22 | # NOTE: this spec is hand-rolled because I don't expect to have any more like 23 | # it. I considered using the 'approvals' gem, but it drags in a dependency 24 | # on Nokogiri that I wanted to avoid. If you do find yourself needing to do 25 | # more specs like this, 'approvals' might be useful... 26 | 27 | expected = fixture_file_contents("cli-help-output").rstrip 28 | output = run_cli("--help").rstrip 29 | 30 | begin 31 | expect( output ).to eq( expected ) 32 | rescue RSpec::Expectations::ExpectationNotMetError => e 33 | puts <<~EOF 34 | 35 | --> NOTE: the output of the executable's `--help` flag has changed. 36 | --> If you want to keep these changes, please run: 37 | --> 38 | --> bundle exec rake spec:approve_cli_help_output 39 | 40 | EOF 41 | raise e 42 | end 43 | end 44 | 45 | context "for a ref/can pair with a few discrepancies" do 46 | let(:ref_file) { "spec/fixtures/forty-two-reference.json" } 47 | let(:can_file) { "spec/fixtures/forty-two-candidate.json" } 48 | let(:expected_output) { fixture_file_contents("forty-two-expected-table").strip } 49 | 50 | describe "running the executable with two filenames" do 51 | it "produces tabular output" do 52 | output = run_cli(ref_file, can_file) 53 | expect( output ).to eq( expected_output ) 54 | end 55 | 56 | specify "adding `--fail-fast` limits output to one row" do 57 | output = run_cli(ref_file, can_file, "--fail-fast") 58 | expect( output.lines.length ).to be < expected_output.lines.length 59 | end 60 | end 61 | 62 | describe "running the executable with one filename" do 63 | it "reads the candidate from piped stdin" do 64 | output = run_cli(ref_file, pipe: can_file) 65 | expect( output ).to eq( expected_output ) 66 | end 67 | 68 | specify "prints help and exits if the user didn't pipe anything in" do 69 | output = run_cli(ref_file) 70 | expect( output ).to include( CheckPlease::ELEVATOR_PITCH ) 71 | end 72 | end 73 | 74 | describe "running the executable with no arguments" do 75 | specify "prints a message about the missing reference and exits" do 76 | output = run_cli() 77 | expect( output ).to include( CheckPlease::ELEVATOR_PITCH ) 78 | expect( output ).to_not include( "Missing " ) 79 | end 80 | end 81 | end 82 | 83 | context "for a ref/can pair with two simple objects in reverse order" do 84 | let(:ref_file) { "spec/fixtures/match-by-key-reference.json" } 85 | let(:can_file) { "spec/fixtures/match-by-key-candidate.json" } 86 | 87 | specify "--match-by-key works end to end" do 88 | output = run_cli(ref_file, can_file, "--match-by-key", "/:id") 89 | expect( output ).to be_empty 90 | end 91 | end 92 | 93 | TIME_OUT_CLI_AFTER = 1 # seconds 94 | def run_cli(*args, pipe: nil) 95 | args.flatten! 96 | 97 | cmd = [] 98 | if pipe 99 | cmd << "cat" 100 | cmd << pipe 101 | cmd << "|" 102 | end 103 | cmd << "exe/check_please" 104 | cmd.concat args 105 | 106 | out = nil # scope hack 107 | Timeout.timeout(TIME_OUT_CLI_AFTER) do 108 | out = `#{cmd.compact.join(' ')}` 109 | end 110 | strip_trailing_whitespace(out) 111 | end 112 | 113 | end 114 | -------------------------------------------------------------------------------- /spec/fixtures/cli-help-output: -------------------------------------------------------------------------------- 1 | Usage: check_please [FLAGS] 2 | 3 | Tool for parsing and diffing two JSON documents. 4 | 5 | Arguments: 6 | is the name of a file to use as, well, the reference. 7 | is the name of a file to compare against the reference. 8 | 9 | NOTE: If you have a utility like MacOS's `pbpaste`, you MAY omit 10 | the arg, and pipe the second document instead, like: 11 | 12 | $ pbpaste | check_please 13 | 14 | FLAGS: 15 | -f, --format FORMAT Format in which to present diffs. 16 | (Allowed values: [json, long, table]) 17 | -n, --max-diffs MAX_DIFFS Stop after encountering a specified number of diffs. 18 | --fail-fast Stop after encountering the first diff. 19 | (equivalent to '--max-diffs 1') 20 | -d, --max_depth MAX_DEPTH Limit the number of levels to descend when comparing documents. 21 | (NOTE: root has depth = 1) 22 | -s, --select-paths PATH_EXPR ONLY record diffs matching the provided PATH expression. 23 | May be repeated; values will be treated as an 'OR' list. 24 | Can't be combined with --reject-paths. 25 | -r, --reject-paths PATH_EXPR DON'T record diffs matching the provided PATH expression. 26 | May be repeated; values will be treated as an 'OR' list. 27 | Can't be combined with --select-paths. 28 | --match-by-key FOO Specify how to match reference/candidate pairs in arrays of hashes. 29 | May be repeated; values will be treated as an 'OR' list. 30 | See the README for details on how to actually use this. 31 | NOTE: this does not yet handle non-string keys. 32 | --match-by-value FOO When comparing two arrays that match a specified path, the candidate 33 | array will be scanned for each element in the reference array. 34 | May be repeated; values will be treated as an 'OR' list. 35 | NOTE: explodes if either array at a given path contains other collections. 36 | NOTE: paths of 'extra' diffs use the index in the candidate array. 37 | --indifferent-keys When comparing hashes, convert symbol keys to strings 38 | --indifferent-values When comparing values (that aren't arrays or hashes), convert symbols to strings 39 | -------------------------------------------------------------------------------- /spec/fixtures/forty-two-candidate.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 42, 3 | "name": [ "I am large, and contain multitudes." ], 4 | "words": [ "what", "do", "we", "get", "when", "I", "multiply", "six", "by", "nine", "dude" ], 5 | "meta": { "foo": "foo", "yak": "bacon" } 6 | } 7 | -------------------------------------------------------------------------------- /spec/fixtures/forty-two-candidate.yaml: -------------------------------------------------------------------------------- 1 | id: 42 2 | name: 3 | - I am large, and contain multitudes. 4 | words: 5 | - what 6 | - do 7 | - we 8 | - get 9 | - when 10 | - I 11 | - multiply 12 | - six 13 | - by 14 | - nine 15 | - dude 16 | meta: 17 | foo: foo 18 | yak: bacon 19 | -------------------------------------------------------------------------------- /spec/fixtures/forty-two-expected-table: -------------------------------------------------------------------------------- 1 | TYPE | PATH | REFERENCE | CANDIDATE 2 | --------------|-----------|------------|------------------------------- 3 | type_mismatch | /name | "The An... | ["I am large, and contain m... 4 | mismatch | /words/3 | "you" | "we" 5 | mismatch | /words/6 | "you" | "I" 6 | extra | /words/11 | | "dude" 7 | missing | /meta/bar | "eggs" | 8 | mismatch | /meta/foo | "spam" | "foo" 9 | -------------------------------------------------------------------------------- /spec/fixtures/forty-two-reference.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 42, 3 | "name": "The Answer", 4 | "words": [ "what", "do", "you", "get", "when", "you", "multiply", "six", "by", "nine" ], 5 | "meta": { "foo": "spam", "bar": "eggs", "yak": "bacon" } 6 | } 7 | -------------------------------------------------------------------------------- /spec/fixtures/forty-two-reference.yaml: -------------------------------------------------------------------------------- 1 | id: 42 2 | name: The Answer 3 | words: 4 | - what 5 | - do 6 | - you 7 | - get 8 | - when 9 | - you 10 | - multiply 11 | - six 12 | - by 13 | - nine 14 | meta: 15 | foo: spam 16 | bar: eggs 17 | yak: bacon 18 | -------------------------------------------------------------------------------- /spec/fixtures/match-by-key-candidate.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "id": 2, "foo": "spam" }, 3 | { "id": 1, "foo": "bar" } 4 | ] 5 | -------------------------------------------------------------------------------- /spec/fixtures/match-by-key-reference.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "id": 1, "foo": "bar" }, 3 | { "id": 2, "foo": "spam" } 4 | ] 5 | -------------------------------------------------------------------------------- /spec/printers_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe CheckPlease::Printers do 2 | 3 | describe ".render" do 4 | let(:ref) { { "answer" => 42 } } 5 | let(:can) { { "answer" => 43 } } 6 | let(:diffs) { CheckPlease.diff(ref, can) } 7 | 8 | it "renders using the TablePrint printer when given `format: :table`" do 9 | expect( CheckPlease::Printers::TablePrint ).to receive(:render).with(diffs) 10 | CheckPlease::Printers.render(diffs, format: :table) 11 | end 12 | 13 | it "renders using the TablePrint printer when not given a `format:` kwarg" do 14 | expect( CheckPlease::Printers::TablePrint ).to receive(:render).with(diffs) 15 | CheckPlease::Printers.render(diffs) 16 | end 17 | 18 | it "renders using the JSON printer when given `format: :json`" do 19 | expect( CheckPlease::Printers::JSON ).to receive(:render).with(diffs) 20 | CheckPlease::Printers.render(diffs, format: :json) 21 | end 22 | end 23 | 24 | end 25 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "check_please" 3 | require "amazing_print" 4 | 5 | 6 | 7 | require 'pathname' 8 | PROJECT_ROOT = Pathname.new( 9 | File.expand_path( 10 | File.join( File.dirname(__FILE__), '..' ) 11 | ) 12 | ) 13 | Dir[ PROJECT_ROOT.join('spec', 'support', '**', '*.rb') ].each { |f| require f } 14 | 15 | 16 | 17 | RSpec.configure do |config| 18 | # Enable flags like --only-failures and --next-failure 19 | config.example_status_persistence_file_path = ".rspec_status" 20 | 21 | # Disable RSpec exposing methods globally on `Module` and `main` 22 | config.disable_monkey_patching! 23 | 24 | config.expect_with :rspec do |c| 25 | c.syntax = :expect 26 | c.include_chain_clauses_in_custom_matcher_descriptions = true 27 | end 28 | 29 | config.mock_with :rspec do |c| 30 | c.verify_partial_doubles = true 31 | end 32 | 33 | config.shared_context_metadata_behavior = :apply_to_host_groups 34 | config.filter_run_when_matching :focus 35 | config.example_status_persistence_file_path = "spec/examples.txt" 36 | config.disable_monkey_patching! 37 | if config.files_to_run.one? 38 | config.default_formatter = "doc" 39 | end 40 | config.order = :random 41 | Kernel.srand config.seed 42 | end 43 | 44 | 45 | 46 | # Always clear the global that Sam has a tendency to set 47 | RSpec.configure do |config| 48 | config.after(:each) do 49 | $debug = false 50 | end 51 | end 52 | 53 | -------------------------------------------------------------------------------- /spec/support/helpers.rb: -------------------------------------------------------------------------------- 1 | def strip_trailing_whitespace(s) 2 | s.lines.map(&:rstrip).join("\n") 3 | end 4 | 5 | def fixture_file_name(basename) 6 | "spec/fixtures/#{basename}" 7 | end 8 | 9 | def fixture_file_contents(basename) 10 | File.read(fixture_file_name(basename)) 11 | end 12 | 13 | def _compare(expected, actual) 14 | case actual 15 | when true, false, nil 16 | expect( actual ).to be( expected ) # identity 17 | else 18 | expect( actual ).to eq( expected ) # equality 19 | end 20 | end 21 | 22 | # NOTE: "basic" only means there are no arguments to the method :) 23 | def has_these_basic_properties(messages_and_expected_returns = {}) 24 | messages_and_expected_returns.each do |message, expected| 25 | specify "##{message} returns #{expected.inspect}" do 26 | actual = subject.send(message) 27 | _compare expected, actual 28 | end 29 | end 30 | end 31 | 32 | def pathify(name) 33 | CheckPlease::Path.reify(name) 34 | end 35 | 36 | def flagify(attrs = {}) 37 | CheckPlease::Flags.new(attrs) 38 | end 39 | -------------------------------------------------------------------------------- /spec/support/matchers.rb: -------------------------------------------------------------------------------- 1 | module EqDiff 2 | def eq_diff(type, path, ref:, can:) 3 | expected = ExpectedDiff.new(type, path, ref, can) 4 | Matcher.new(expected) 5 | end 6 | 7 | ExpectedDiff = Struct.new(:type, :path, :reference, :candidate) do 8 | def ==(actual) 9 | type == actual.type \ 10 | && path == actual.path \ 11 | && reference == actual.reference \ 12 | && candidate == actual.candidate 13 | end 14 | end 15 | 16 | class Matcher 17 | def initialize(expected) 18 | @expected = expected 19 | end 20 | 21 | def matches?(actual) 22 | @actual = actual 23 | @expected == @actual 24 | end 25 | 26 | def failure_message 27 | mismatches = [] 28 | %i[ type path reference candidate ].each do |attr_name| 29 | exp = @expected.send(attr_name) 30 | act = @actual.send(attr_name) 31 | next if exp == act 32 | mismatches << "- expected #{attr_name} to be #{exp.inspect} but got #{act.inspect}" 33 | end 34 | 35 | "Diff comparison failed!\n#{mismatches.join("\n")}" 36 | end 37 | end 38 | end 39 | 40 | RSpec::configure do |config| 41 | config.include EqDiff 42 | end 43 | -------------------------------------------------------------------------------- /usage_examples.rb: -------------------------------------------------------------------------------- 1 | require 'check_please' 2 | 3 | reference = { "foo" => "wibble" } 4 | candidate = { "bar" => "wibble" } 5 | 6 | 7 | 8 | ##### Printing diffs ##### 9 | 10 | puts CheckPlease.render_diff(reference, candidate) 11 | 12 | # this should print the following to stdout: 13 | _ = <