normalized_match 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d8557ff5b699123c45a96ea1cf0f01f5600d0a4eb545861b6d30e64c41a07288
4
+ data.tar.gz: 3fd9d9f8754a5f76073709f9666f35423bca7a49ccc4d87c7672102d126bb169
5
+ SHA512:
6
+ metadata.gz: 0302d4db823ceb4f60c5e3949f25bdd67f3673539d4da52b54d6e1cbf0a3c28e38e61bbc1b99a5d5bde95d8e7da37fe973078454865c82030c981aeba2d84801
7
+ data.tar.gz: bf5dec8b78ccc0ce2c574c92ec7663f3367581ca3e86ed8005f414bf618d2b6c35f30aee9a4ff323081048c72442cc5cece22955a5520c6b5c7645e2a7f04b42
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 FIRSTDRAFT, LLC
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # Normalized Match
2
+
3
+ A normalized string matcher for RSpec that ignores case, punctuation, and some whitespace differences.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem "normalized_match"
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ ```
16
+ $ bundle install
17
+ ```
18
+
19
+ Or install it yourself as:
20
+
21
+ ```
22
+ $ gem install normalized_match
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ```ruby
28
+ require "normalized_match"
29
+
30
+ RSpec.describe "My test" do
31
+ it "matches strings normalized" do
32
+ actual = "Hello, World!"
33
+ expected = "hello world"
34
+
35
+ expect(actual).to normalized_match(expected)
36
+ end
37
+ end
38
+ ```
39
+
40
+ The `normalized_match` matcher normalizes both strings by:
41
+
42
+ - Converting to lowercase.
43
+ - Removing all punctuation.
44
+ - Collapsing internal whitespace (multiple spaces/tabs become single spaces).
45
+ - Preserving line breaks for multi-line comparisons.
46
+ - Stripping leading and trailing whitespace from lines.
47
+
48
+ When a match fails, it displays a helpful side-by-side comparison table showing both the normalized and original values.
49
+
50
+ ## Using Heredocs
51
+
52
+ When testing output that contains multiple lines, you can use Ruby's squiggly heredoc (`<<~`) to maintain nice indentation in your test files while automatically stripping leading whitespace:
53
+
54
+ ```ruby
55
+ RSpec.describe "Letter counter" do
56
+ it "displays the letter count analysis" do
57
+ # Your actual output might come from a method, script, etc.
58
+ actual = run_codeblock(filename)
59
+
60
+ # Use squiggly heredoc for expected output
61
+ # This strips leading whitespace while preserving internal formatting
62
+ expected = <<~EXPECTED
63
+ Letter count:
64
+ H: 1
65
+ e: 1
66
+ l: 3
67
+ o: 2
68
+ W: 1
69
+ r: 1
70
+ d: 1
71
+ EXPECTED
72
+
73
+ expect(actual).to normalized_match(expected)
74
+ end
75
+ end
76
+ ```
77
+
78
+ **Important:** The squiggly heredoc (`<<~`) technique is especially useful because newlines _within_ the expected content are significant to the matcher. In other words, the above test would not pass if it was written as:
79
+
80
+ ```ruby
81
+ expected = "Letter count: H: 1 e: 1 l: 3 o: 2 W: 1 r: 1 d: 1"
82
+ ```
83
+
84
+ And the actual output was:
85
+
86
+ ```
87
+ Letter count:
88
+ H: 1
89
+ e: 1
90
+ l: 3
91
+ o: 2
92
+ W: 1
93
+ r: 1
94
+ d: 1
95
+ ```
96
+
97
+ ## Example Output
98
+
99
+ When strings don't match after normalization:
100
+
101
+ ```
102
+ Normalized match failed!
103
+
104
+ To make it easier to match the expected output,
105
+ we are "normalizing" both the actual output and
106
+ expected output in this test. That means we
107
+ lowercased, removed punctuation, and compacted
108
+ whitespace in both.
109
+
110
+ But the actual output still doesn't contain the
111
+ expected output. Can you spot the difference?
112
+
113
+ ╔════════════╦══════════════════╦══════════════════╗
114
+ ║ ║ EXPECTED ║ ACTUAL ║
115
+ ╠════════════╬══════════════════╬══════════════════╣
116
+ ║ NORMALIZED ║ hello world ║ goodbye world ║
117
+ ╠════════════╬══════════════════╬══════════════════╣
118
+ ║ ORIGINAL ║ Hello, World! ║ Goodbye, World! ║
119
+ ╚════════════╩══════════════════╩══════════════════╝
120
+ ```
121
+
122
+ ## Development
123
+
124
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `bin/rake` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
125
+
126
+ ## Contributing
127
+
128
+ Bug reports and pull requests are welcome on GitHub at https://github.com/firstdraft/normalized_match.
129
+
130
+ ## License
131
+
132
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NormalizedMatch
4
+ module NormalizedMatchMatcher
5
+ # Constants for table formatting
6
+ COLUMN_PADDING = 4 # 2 spaces on each side
7
+ LABEL_PADDING = 2 # For borders in label column
8
+ RSpec::Matchers.define :normalized_match do |expected|
9
+ match do |actual|
10
+ # Store the original values
11
+ @original_expected = expected
12
+ @original_actual = actual
13
+
14
+ # Store normalized versions for cleaner diffs
15
+ @expected = normalize_string(expected)
16
+ @actual = normalize_string(actual)
17
+
18
+ # Check if actual contains expected (substring match)
19
+ @actual.include?(@expected)
20
+ end
21
+
22
+ failure_message do |actual|
23
+ # Split into lines for side-by-side comparison
24
+ orig_expected_lines = @original_expected.to_s.split("\n")
25
+ orig_actual_lines = @original_actual.to_s.split("\n")
26
+ norm_expected_lines = @expected.to_s.split("\n")
27
+ norm_actual_lines = @actual.to_s.split("\n")
28
+
29
+ # Check if normalization changed anything
30
+ expected_changed = @original_expected.to_s != @expected.to_s
31
+ actual_changed = @original_actual.to_s != @actual.to_s
32
+ normalization_needed = expected_changed || actual_changed
33
+
34
+ # Calculate column widths
35
+ max_orig_expected = orig_expected_lines.map(&:length).max || 0
36
+ max_orig_actual = orig_actual_lines.map(&:length).max || 0
37
+ max_norm_expected = norm_expected_lines.map(&:length).max || 0
38
+ max_norm_actual = norm_actual_lines.map(&:length).max || 0
39
+
40
+ # Ensure minimum column width for headers
41
+ expected_col_width = [max_orig_expected, max_norm_expected, "EXPECTED".length].max + COLUMN_PADDING
42
+ actual_col_width = [max_orig_actual, max_norm_actual, "ACTUAL".length].max + COLUMN_PADDING
43
+
44
+ # Calculate label column width (for "NORMALIZED" and "ORIGINAL")
45
+ label_col_width = ["NORMALIZED".length, "ORIGINAL".length].max + COLUMN_PADDING
46
+
47
+ # Build the side-by-side table components
48
+
49
+ # Helper to pad and center text
50
+ def center_pad(text, width)
51
+ text = text.to_s
52
+ padding = width - text.length
53
+ left_pad = padding / 2
54
+ right_pad = padding - left_pad
55
+ " " * left_pad + text + " " * right_pad
56
+ end
57
+
58
+ # Helper to left-pad text with 2 spaces on each side
59
+ def pad_line(text, width)
60
+ " #{text.to_s.ljust(width - COLUMN_PADDING)} "
61
+ end
62
+
63
+ # Build header output
64
+ header_output = []
65
+ header_output << "Normalized match failed!"
66
+ header_output << ""
67
+
68
+ if normalization_needed
69
+ header_output << "To make it easier to match the expected output,"
70
+ header_output << "we are \"normalizing\" both the actual output and"
71
+ header_output << "expected output in this test. That means we"
72
+ header_output << "lowercased, removed punctuation, and compacted"
73
+ header_output << "whitespace in both."
74
+ header_output << ""
75
+ header_output << "But the actual output still doesn't contain the"
76
+ header_output << "expected output. Can you spot the difference?"
77
+ header_output << ""
78
+ else
79
+ header_output << "The actual output doesn't contain the expected"
80
+ header_output << "output. Can you spot the difference?"
81
+ header_output << ""
82
+ end
83
+
84
+ # Helper to center label text (NORMALIZED/ORIGINAL) in first column
85
+ def center_label(label, width, total_rows)
86
+ middle_row = total_rows / 2
87
+ (0...total_rows).map do |i|
88
+ if i == middle_row
89
+ label.center(width)
90
+ else
91
+ " " * width
92
+ end
93
+ end
94
+ end
95
+
96
+ # Build the table
97
+ table_output = []
98
+
99
+ if normalization_needed
100
+ # Build two-section table with labels
101
+ # Top border
102
+ table_output << "╔" + "═" * label_col_width + "╦" + "═" * expected_col_width + "╦" + "═" * actual_col_width + "╗"
103
+
104
+ # Header row
105
+ table_output << "║" + " " * label_col_width + "║" + center_pad("EXPECTED", expected_col_width) + "║" + center_pad("ACTUAL", actual_col_width) + "║"
106
+
107
+ # Header separator
108
+ table_output << "╠" + "═" * label_col_width + "╬" + "═" * expected_col_width + "╬" + "═" * actual_col_width + "╣"
109
+
110
+ # NORMALIZED section
111
+ max_norm_lines = [norm_expected_lines.length, norm_actual_lines.length].max
112
+ normalized_labels = center_label("NORMALIZED", label_col_width - LABEL_PADDING, max_norm_lines)
113
+
114
+ max_norm_lines.times do |i|
115
+ expected_line = norm_expected_lines[i] || ""
116
+ actual_line = norm_actual_lines[i] || ""
117
+ label = normalized_labels[i]
118
+ table_output << "║ #{label} ║" + pad_line(expected_line, expected_col_width) + "║" + pad_line(actual_line, actual_col_width) + "║"
119
+ end
120
+
121
+ # Middle separator
122
+ table_output << "╠" + "═" * label_col_width + "╬" + "═" * expected_col_width + "╬" + "═" * actual_col_width + "╣"
123
+
124
+ # ORIGINAL section
125
+ max_lines = [orig_expected_lines.length, orig_actual_lines.length].max
126
+ original_labels = center_label("ORIGINAL", label_col_width - LABEL_PADDING, max_lines)
127
+
128
+ max_lines.times do |i|
129
+ expected_line = orig_expected_lines[i] || ""
130
+ actual_line = orig_actual_lines[i] || ""
131
+ label = original_labels[i]
132
+ table_output << "║ #{label} ║" + pad_line(expected_line, expected_col_width) + "║" + pad_line(actual_line, actual_col_width) + "║"
133
+ end
134
+
135
+ # Bottom border
136
+ table_output << "╚" + "═" * label_col_width + "╩" + "═" * expected_col_width + "╩" + "═" * actual_col_width + "╝"
137
+ else
138
+ # Build simple table without labels column
139
+ # Top border
140
+ table_output << "╔" + "═" * expected_col_width + "╦" + "═" * actual_col_width + "╗"
141
+
142
+ # Header row
143
+ table_output << "║" + center_pad("EXPECTED", expected_col_width) + "║" + center_pad("ACTUAL", actual_col_width) + "║"
144
+
145
+ # Header separator
146
+ table_output << "╠" + "═" * expected_col_width + "╬" + "═" * actual_col_width + "╣"
147
+
148
+ # Data rows (using original since no normalization happened)
149
+ max_lines = [orig_expected_lines.length, orig_actual_lines.length].max
150
+
151
+ max_lines.times do |i|
152
+ expected_line = orig_expected_lines[i] || ""
153
+ actual_line = orig_actual_lines[i] || ""
154
+ table_output << "║" + pad_line(expected_line, expected_col_width) + "║" + pad_line(actual_line, actual_col_width) + "║"
155
+ end
156
+
157
+ # Bottom border
158
+ table_output << "╚" + "═" * expected_col_width + "╩" + "═" * actual_col_width + "╝"
159
+ end
160
+
161
+ # Combine all parts - header, then table (with extra newline between)
162
+ # Add a trailing newline so there's a blank line before RSpec's diff (when it appears)
163
+ message = header_output.join("\n") + "\n" + table_output.join("\n") + "\n"
164
+
165
+ message
166
+ end
167
+
168
+ # These methods make the matcher work better with rspec-enriched_json
169
+ attr_reader :expected, :actual
170
+
171
+ # Provide access to original values if needed
172
+ attr_reader :original_expected, :original_actual
173
+
174
+ # Tell RSpec that expected and actual can be diffed
175
+ def diffable?
176
+ true
177
+ end
178
+
179
+ def normalize_string(str)
180
+ str.to_s
181
+ .gsub(/[^a-zA-Z0-9\s]/, "") # Remove punctuation but keep all whitespace
182
+ .downcase
183
+ .split("\n") # Split by newlines to preserve them
184
+ .map { |line| line.gsub(/\s+/, " ").strip } # Normalize spaces within each line
185
+ .reject(&:empty?) # Remove empty lines
186
+ .join("\n") # Rejoin with newlines
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/matchers"
4
+ require_relative "normalized_match/normalized_match_matcher"
5
+
6
+ # Main namespace.
7
+ module NormalizedMatch
8
+ end
9
+
10
+ # Include the matcher in RSpec when this gem is loaded
11
+ if defined?(RSpec)
12
+ RSpec.configure do |config|
13
+ config.include NormalizedMatch::NormalizedMatchMatcher
14
+ end
15
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "normalized_match"
5
+ spec.version = "0.1.0"
6
+ spec.authors = ["Raghu Betina"]
7
+ spec.email = ["raghu@firstdraft.com"]
8
+ spec.homepage = "https://github.com/firstdraft/normalized_match"
9
+ spec.summary = "A normalized string matcher for RSpec that ignores case, punctuation, and whitespace differences"
10
+ spec.license = "MIT"
11
+
12
+ spec.metadata = {
13
+ "bug_tracker_uri" => "https://github.com/firstdraft/normalized_match/issues",
14
+ "changelog_uri" => "https://github.com/firstdraft/normalized_match/blob/main/CHANGELOG.md",
15
+ "homepage_uri" => "https://github.com/firstdraft/normalized_match",
16
+ "label" => "Normalized Match",
17
+ "rubygems_mfa_required" => "true",
18
+ "source_code_uri" => "https://github.com/firstdraft/normalized_match"
19
+ }
20
+
21
+ spec.required_ruby_version = ">= 3.0"
22
+ spec.add_dependency "rspec", "~> 3.0"
23
+
24
+ spec.extra_rdoc_files = Dir["README*", "LICENSE*"]
25
+ spec.files = Dir["*.gemspec", "lib/**/*"]
26
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: normalized_match
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Raghu Betina
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rspec
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '3.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.0'
26
+ email:
27
+ - raghu@firstdraft.com
28
+ executables: []
29
+ extensions: []
30
+ extra_rdoc_files:
31
+ - LICENSE
32
+ - README.md
33
+ files:
34
+ - LICENSE
35
+ - README.md
36
+ - lib/normalized_match.rb
37
+ - lib/normalized_match/normalized_match_matcher.rb
38
+ - normalized_match.gemspec
39
+ homepage: https://github.com/firstdraft/normalized_match
40
+ licenses:
41
+ - MIT
42
+ metadata:
43
+ bug_tracker_uri: https://github.com/firstdraft/normalized_match/issues
44
+ changelog_uri: https://github.com/firstdraft/normalized_match/blob/main/CHANGELOG.md
45
+ homepage_uri: https://github.com/firstdraft/normalized_match
46
+ label: Normalized Match
47
+ rubygems_mfa_required: 'true'
48
+ source_code_uri: https://github.com/firstdraft/normalized_match
49
+ rdoc_options: []
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '3.0'
57
+ required_rubygems_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ requirements: []
63
+ rubygems_version: 3.6.9
64
+ specification_version: 4
65
+ summary: A normalized string matcher for RSpec that ignores case, punctuation, and
66
+ whitespace differences
67
+ test_files: []