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