canon 0.3.26 → 0.3.28
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 +4 -4
- data/.rubocop.yml +2 -0
- data/README.adoc +30 -2
- data/docs/features/regenerate-expected.adoc +157 -0
- data/lib/canon/comparison/diff_node_builder.rb +9 -0
- data/lib/canon/comparison/xml_comparator/attribute_filter.rb +34 -4
- data/lib/canon/config/env_schema.rb +6 -0
- data/lib/canon/diff_formatter/by_line/html_formatter.rb +4 -296
- data/lib/canon/rebaseliner/atomic_writer.rb +32 -0
- data/lib/canon/rebaseliner/call_site_resolver.rb +126 -0
- data/lib/canon/rebaseliner/heredoc_locator.rb +140 -0
- data/lib/canon/rebaseliner/heredoc_rewriter.rb +56 -0
- data/lib/canon/rebaseliner/heredoc_target.rb +19 -0
- data/lib/canon/rebaseliner/logger.rb +24 -0
- data/lib/canon/rebaseliner.rb +162 -0
- data/lib/canon/rspec_matchers.rb +101 -23
- data/lib/canon/version.rb +1 -1
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6aef461e6f80457ad8fd6c227effe22086b5692f2cfcec5f4226ab0268e4956d
|
|
4
|
+
data.tar.gz: da2c4ae39e5c8a3483c811fe9bd19c49d69542b637fd97e4b62a2d20fda8bc08
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8cd8327564623711f977aec6b31943433ab962bdec534b2ac107f0eb7ead31cb0854551f3ef614ae1f2e2cdd059b5ac6b3655b44e7cc0dd82d34cff0a2c4b045
|
|
7
|
+
data.tar.gz: fcaac6df39848f1fd387710e57de61533dac6543028f412b08bd36d9615157a8252a81b13f015606ce666da31d02a5eeea0896abfadafdca6543dc92ac4977c6
|
data/.rubocop.yml
CHANGED
data/README.adoc
CHANGED
|
@@ -12,9 +12,19 @@ Key features:
|
|
|
12
12
|
|
|
13
13
|
* **Format support**: XML, HTML, JSON, YAML
|
|
14
14
|
* **Canonicalization**: W3C XML C14N 1.1, sorted JSON/YAML keys
|
|
15
|
-
* **Semantic comparison**: Compare meaning, not formatting
|
|
15
|
+
* **Semantic comparison**: Compare meaning, not formatting — attributes by
|
|
16
|
+
expanded name (namespace URI + local name), prefixes never affect results
|
|
16
17
|
* **Multiple interfaces**: Ruby API, CLI, RSpec matchers
|
|
17
18
|
* **Smart diff output**: By-line or by-object modes with syntax highlighting
|
|
19
|
+
* **Fast native engines, zero mandatory C deps**:
|
|
20
|
+
** XML on https://github.com/leptris/leptris[libleptris] (via moxml) whenever
|
|
21
|
+
installed — Nokogiri otherwise and for HTML; both byte-compatible, engine
|
|
22
|
+
parity is spec-gated
|
|
23
|
+
** JSON on https://github.com/leptris/yeptris[libyeptris]' strict JSON surface
|
|
24
|
+
whenever the native materializer is installed (`gem install yeptris` —
|
|
25
|
+
platform gems, zero compilation), the stdlib JSON extension otherwise
|
|
26
|
+
** YAML on yeptris opt-in (`CANON_YAML_BACKEND=yeptris`, ~3x Psych loads);
|
|
27
|
+
Psych otherwise
|
|
18
28
|
|
|
19
29
|
== When to use formatting vs comparison
|
|
20
30
|
|
|
@@ -179,6 +189,9 @@ $ canon help
|
|
|
179
189
|
* **link:docs/CHARACTER_VISUALIZATION[Character visualization]** -
|
|
180
190
|
Whitespace and special characters
|
|
181
191
|
* **link:docs/INPUT_VALIDATION[Input validation]** - Error handling
|
|
192
|
+
* **link:docs/features/regenerate-expected[Regenerating expected heredocs]** -
|
|
193
|
+
`CANON_REGENERATE_EXPECTED=true` to rewrite failing `be_*_equivalent_to`
|
|
194
|
+
heredocs in-place from the prettyprinted received value
|
|
182
195
|
|
|
183
196
|
=== Advanced topics
|
|
184
197
|
|
|
@@ -957,10 +970,25 @@ bundle exec rake performance:yaml
|
|
|
957
970
|
* **XML/HTML Comparison**: Identical, similar, different documents
|
|
958
971
|
* **Format Canonicalization**: XML C14N, JSON, YAML
|
|
959
972
|
|
|
973
|
+
=== Engines
|
|
974
|
+
|
|
975
|
+
Canon is engine-agnostic across its formats:
|
|
976
|
+
|
|
977
|
+
* **XML**: libleptris via moxml whenever installed (parse, comparison, C14N,
|
|
978
|
+
pretty-printing); raw Nokogiri otherwise, and always Nokogiri for HTML.
|
|
979
|
+
`CANON_XML_BACKEND=nokogiri|moxml` forces either engine; parity between the
|
|
980
|
+
two is spec-gated (`spec/canon/xml/engine_parity_spec.rb`).
|
|
981
|
+
* **JSON**: yeptris' strict JSON surface when the native materializer is
|
|
982
|
+
installed (platform gems — zero compilation), the stdlib JSON extension
|
|
983
|
+
otherwise. `CANON_YAML_BACKEND=psych` forces stdlib.
|
|
984
|
+
* **YAML**: Psych by default; yeptris (≈3x loads) behind
|
|
985
|
+
`CANON_YAML_BACKEND=yeptris`. Parity is spec-gated
|
|
986
|
+
(`spec/canon/yaml_engine_parity_spec.rb`).
|
|
987
|
+
|
|
960
988
|
=== SAX Parser
|
|
961
989
|
|
|
962
990
|
Canon includes a SAX-based XML parser (`Canon::Xml::SaxBuilder`) that provides
|
|
963
|
-
significantly faster XML parsing by avoiding intermediate
|
|
991
|
+
significantly faster XML parsing by avoiding intermediate engine DOM trees.
|
|
964
992
|
|
|
965
993
|
[source,ruby]
|
|
966
994
|
----
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Regenerating expected heredocs
|
|
3
|
+
parent: Features
|
|
4
|
+
nav_order: 50
|
|
5
|
+
---
|
|
6
|
+
= Regenerating expected heredocs
|
|
7
|
+
:toc:
|
|
8
|
+
:toclevels: 3
|
|
9
|
+
|
|
10
|
+
== Purpose
|
|
11
|
+
|
|
12
|
+
When a Canon matcher (`be_xml_equivalent_to`, `be_html_equivalent_to`, etc.)
|
|
13
|
+
asserts equivalence against a heredoc-literal `expected`, and the
|
|
14
|
+
upstream code legitimately changes its output, the fixture heredoc
|
|
15
|
+
needs to be updated to the new value. The default workflow is:
|
|
16
|
+
|
|
17
|
+
1. Run `bundle exec rspec` — see N failing assertions.
|
|
18
|
+
2. Copy the prettyprinted received from canon's diff display.
|
|
19
|
+
3. Paste into the `<<~XML ... XML` heredoc in the spec file.
|
|
20
|
+
4. Re-run, iterate.
|
|
21
|
+
|
|
22
|
+
For large fixture suites (e.g. the metanorma stack — 200+ heredocs per
|
|
23
|
+
rebaseline event) this is slow and error-prone (indent drift, partial
|
|
24
|
+
copies, missed trailing whitespace).
|
|
25
|
+
|
|
26
|
+
The `CANON_REGENERATE_EXPECTED=true` env var collapses steps 2-3 into
|
|
27
|
+
an automated rewrite.
|
|
28
|
+
|
|
29
|
+
== Workflow
|
|
30
|
+
|
|
31
|
+
[source,sh]
|
|
32
|
+
----
|
|
33
|
+
CANON_REGENERATE_EXPECTED=true bundle exec rspec
|
|
34
|
+
git diff # review what changed
|
|
35
|
+
git commit -am "rebaseline fixtures after upstream X change"
|
|
36
|
+
----
|
|
37
|
+
|
|
38
|
+
When the env var is set:
|
|
39
|
+
|
|
40
|
+
* Every failing `be_*_equivalent_to` assertion attempts to rewrite the
|
|
41
|
+
source heredoc that backs its `expected` argument.
|
|
42
|
+
* The rewrite uses the same `Canon::PrettyPrinter` that powers
|
|
43
|
+
`CANON_<FORMAT>_DIFF_SHOW_PRETTYPRINT_RECEIVED` — output is identical
|
|
44
|
+
to what would have appeared in the diff display.
|
|
45
|
+
* Successful rewrites mark the assertion as passing for the run, so CI
|
|
46
|
+
does not fail mid-rebaseline.
|
|
47
|
+
* Passing assertions are never touched.
|
|
48
|
+
* Negated matchers (`.not_to`) are never rewritten.
|
|
49
|
+
|
|
50
|
+
Each rewrite (or skip) emits a single-line log to stderr:
|
|
51
|
+
|
|
52
|
+
[source]
|
|
53
|
+
----
|
|
54
|
+
[canon:rebaseline] rewritten /path/to/spec.rb:123
|
|
55
|
+
[canon:rebaseline] skipped_interpolation /path/to/spec.rb:456
|
|
56
|
+
----
|
|
57
|
+
|
|
58
|
+
== Supported expected forms (v1)
|
|
59
|
+
|
|
60
|
+
The rebaseliner uses Prism to parse the caller spec file and locate the
|
|
61
|
+
heredoc backing the `expected` argument. It supports:
|
|
62
|
+
|
|
63
|
+
* `<<~XML` / `<<-XML` / `<<XML` heredoc assigned to a local variable in
|
|
64
|
+
the same `it` block.
|
|
65
|
+
* `<<~XML` / `<<-XML` / `<<XML` heredoc passed inline directly to the
|
|
66
|
+
matcher.
|
|
67
|
+
* *Multiple sequential assignments* to the same local variable within
|
|
68
|
+
one `it` block (the common metanorma pattern of reassigning `output`
|
|
69
|
+
before each format-specific expect). The rebaseliner walks backward
|
|
70
|
+
from the failing `expect` line to the *most-recent* assignment and
|
|
71
|
+
rewrites that one.
|
|
72
|
+
* Substitution chains on the actual side (e.g.
|
|
73
|
+
`expect(strip_guid(actual).gsub(...))`) — since the matcher receives
|
|
74
|
+
the post-substitution value, idempotency holds on the next run.
|
|
75
|
+
|
|
76
|
+
== Skipped with a warning (v1)
|
|
77
|
+
|
|
78
|
+
* Heredoc with `#{}` interpolation. Mechanical rewriting would lose
|
|
79
|
+
the interpolation; v2 (token preservation) is planned.
|
|
80
|
+
* Expected value from a method call (`load_fixture(...)`). The
|
|
81
|
+
expected is computed at runtime; the rebaseliner cannot identify
|
|
82
|
+
which on-disk fixture to rewrite.
|
|
83
|
+
* Expected value from a `let` or `shared_context` in a different
|
|
84
|
+
spec/support file. v1 does not cross file boundaries.
|
|
85
|
+
* Inline string literal expected (no heredoc). There is nothing to
|
|
86
|
+
re-indent into.
|
|
87
|
+
|
|
88
|
+
In each skipped case the file is left unchanged and the assertion
|
|
89
|
+
fails normally with the standard Canon diff display.
|
|
90
|
+
|
|
91
|
+
== Tracking line shifts across multiple rewrites
|
|
92
|
+
|
|
93
|
+
After a rewrite, the file's on-disk line numbering can shift if the new
|
|
94
|
+
heredoc body has a different line count from the old. Ruby's
|
|
95
|
+
`caller_locations` continues to report the *original* line numbers
|
|
96
|
+
during the same process run (the in-memory source is unchanged), so
|
|
97
|
+
the rebaseliner maintains a per-file cumulative line-shift tracker and
|
|
98
|
+
translates subsequent caller-reported lines accordingly. Multiple
|
|
99
|
+
rewrites within a single `it` block, or across multiple `it` blocks
|
|
100
|
+
in the same spec file, work transparently.
|
|
101
|
+
|
|
102
|
+
== Limitations and v2 roadmap
|
|
103
|
+
|
|
104
|
+
The following are intentionally out of v1 scope:
|
|
105
|
+
|
|
106
|
+
* `#{}` interpolation in heredoc bodies — v2 adds token-preserving
|
|
107
|
+
rewrites that re-anchor `#{var}` fragments in the prettyprinted
|
|
108
|
+
actual.
|
|
109
|
+
* JSON and YAML format prettyprinter wiring (v1 covers XML + HTML).
|
|
110
|
+
* A `canon regenerate SPEC_GLOB` Thor subcommand with a summary
|
|
111
|
+
report.
|
|
112
|
+
* File-locking for `parallel_rspec` safety. v1 documents "do not run
|
|
113
|
+
rebaseline mode in parallel."
|
|
114
|
+
* Optional `rubocop -A` post-rewrite formatter.
|
|
115
|
+
|
|
116
|
+
== Architecture
|
|
117
|
+
|
|
118
|
+
The rebaseliner is a self-contained module under
|
|
119
|
+
`lib/canon/rebaseliner/`:
|
|
120
|
+
|
|
121
|
+
`Canon::Rebaseliner`::
|
|
122
|
+
Top-level orchestrator. `enabled?` reads the env var.
|
|
123
|
+
`rewrite!` is the entry called by the matcher hook on failure.
|
|
124
|
+
|
|
125
|
+
`CallSiteResolver`::
|
|
126
|
+
Parses the spec file with Prism and locates the matcher invocation
|
|
127
|
+
on the failing line. Returns the AST node passed as `expected` and
|
|
128
|
+
the enclosing `it`/`example` block.
|
|
129
|
+
|
|
130
|
+
`HeredocLocator`::
|
|
131
|
+
Classifies the `expected` AST node. For local-variable references,
|
|
132
|
+
walks the enclosing block backward to find the most-recent
|
|
133
|
+
assignment. Returns a `HeredocSpec` describing the byte range to
|
|
134
|
+
rewrite, or a skip reason.
|
|
135
|
+
|
|
136
|
+
`HeredocRewriter`::
|
|
137
|
+
Re-indents the new body for `<<~` (squiggly) heredocs and writes the
|
|
138
|
+
new source via `AtomicWriter`.
|
|
139
|
+
|
|
140
|
+
`AtomicWriter`::
|
|
141
|
+
Tempfile-then-rename to avoid half-written files on Ctrl-C.
|
|
142
|
+
|
|
143
|
+
`Logger`::
|
|
144
|
+
Single-line stderr writes with the `[canon:rebaseline]` prefix.
|
|
145
|
+
|
|
146
|
+
The matcher hook lives in `lib/canon/rspec_matchers.rb`. It captures
|
|
147
|
+
`caller_locations` only when the env var is set, so passing
|
|
148
|
+
assertions pay no overhead.
|
|
149
|
+
|
|
150
|
+
== Testing the rebaseliner
|
|
151
|
+
|
|
152
|
+
`spec/canon/rebaseliner_spec.rb` runs each fixture under
|
|
153
|
+
`spec/fixtures/rebaseliner/` in a subprocess with the env var set,
|
|
154
|
+
then asserts the file content was (or was not) modified appropriately
|
|
155
|
+
and that the stderr log line is present. Add new fixture cases by
|
|
156
|
+
dropping `*_input.rb` into `spec/fixtures/rebaseliner/` and a new
|
|
157
|
+
example into `rebaseliner_spec.rb`.
|
|
@@ -179,6 +179,15 @@ module Canon
|
|
|
179
179
|
return "whitespace: #{describe_whitespace(text1)} vs #{describe_whitespace(text2)}"
|
|
180
180
|
end
|
|
181
181
|
|
|
182
|
+
# Identical content modulo whitespace: two full visualized
|
|
183
|
+
# copies read as materially different text when the only
|
|
184
|
+
# delta is trailing/invisible whitespace (#94) — the compact
|
|
185
|
+
# character-count description says what actually differs.
|
|
186
|
+
if text1.strip == text2.strip
|
|
187
|
+
return "whitespace-only: #{describe_whitespace(text1)} vs " \
|
|
188
|
+
"#{describe_whitespace(text2)}"
|
|
189
|
+
end
|
|
190
|
+
|
|
182
191
|
"Text: \"#{visualize_whitespace(text1)}\" vs \"#{visualize_whitespace(text2)}\""
|
|
183
192
|
end
|
|
184
193
|
|
|
@@ -34,14 +34,24 @@ module Canon
|
|
|
34
34
|
# @param filtered [Hash] Output hash to populate
|
|
35
35
|
def self.filter_array_attributes(attributes, opts, match_opts, filtered)
|
|
36
36
|
attributes.each do |attr|
|
|
37
|
-
name
|
|
37
|
+
# Expanded name (XML Namespaces 1.0 §5.2/§5.3): an
|
|
38
|
+
# unprefixed attribute is in NO namespace — {no-ns}srsName
|
|
39
|
+
# and {uri}srsName are different attributes, and two
|
|
40
|
+
# prefixes bound to the same URI are the same attribute.
|
|
41
|
+
# Local-name keys conflated qualified with unqualified
|
|
42
|
+
# attributes whenever the prefix matched the element's
|
|
43
|
+
# (issue #155).
|
|
44
|
+
name = expanded_attribute_name(attr)
|
|
38
45
|
value = attr.value
|
|
39
46
|
|
|
40
47
|
# Skip namespace declarations - they're handled separately
|
|
41
|
-
next if namespace_declaration?(name)
|
|
48
|
+
next if namespace_declaration?(attr.name)
|
|
42
49
|
|
|
43
|
-
# Skip if attribute name should be ignored
|
|
44
|
-
|
|
50
|
+
# Skip if attribute name should be ignored — by local name
|
|
51
|
+
# (user-facing list) or expanded key, so ignore lists keep
|
|
52
|
+
# working against both forms.
|
|
53
|
+
next if ignore_by_name?(attr.name, opts) ||
|
|
54
|
+
ignore_by_name?(name, opts)
|
|
45
55
|
|
|
46
56
|
# Skip if attribute content should be ignored
|
|
47
57
|
next if ignore_by_content?(value, opts)
|
|
@@ -54,6 +64,26 @@ module Canon
|
|
|
54
64
|
end
|
|
55
65
|
end
|
|
56
66
|
|
|
67
|
+
# Expanded attribute key: "{namespace-uri}local-name", or the
|
|
68
|
+
# bare local name when the attribute is in no namespace.
|
|
69
|
+
# Prefixed-but-unresolvable attributes (namespace-invalid
|
|
70
|
+
# documents — an undeclared prefix has no expanded name) fall
|
|
71
|
+
# back to the local name: recovery comparison cannot do better.
|
|
72
|
+
def self.expanded_attribute_name(attr)
|
|
73
|
+
case attr
|
|
74
|
+
when Canon::Xml::Nodes::AttributeNode
|
|
75
|
+
uri = attr.namespace_uri
|
|
76
|
+
attr.prefix
|
|
77
|
+
when defined?(Nokogiri) && Nokogiri::XML::Attr
|
|
78
|
+
uri = attr.namespace&.href
|
|
79
|
+
attr.namespace&.prefix
|
|
80
|
+
else
|
|
81
|
+
uri = attr.namespace_uri
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
uri && !uri.empty? ? "{#{uri}}#{attr.name}" : attr.name
|
|
85
|
+
end
|
|
86
|
+
|
|
57
87
|
# Filter hash-format attributes (Nokogiri/Moxml)
|
|
58
88
|
#
|
|
59
89
|
# @param attributes [Hash] Hash-like attributes
|
|
@@ -21,6 +21,12 @@ module Canon
|
|
|
21
21
|
|
|
22
22
|
FORMAT_ATTRIBUTE_TYPES = {
|
|
23
23
|
preprocessing: :string,
|
|
24
|
+
|
|
25
|
+
# Rebaseliner: opt-in mode that rewrites failing heredoc
|
|
26
|
+
# expectations in spec files with the prettyprinted received
|
|
27
|
+
# value. Read directly by Canon::Rebaseliner.enabled?; schema
|
|
28
|
+
# entry exists for --env-help discoverability.
|
|
29
|
+
regenerate_expected: :boolean,
|
|
24
30
|
}.freeze
|
|
25
31
|
|
|
26
32
|
class << self
|
|
@@ -33,82 +33,11 @@ module Canon
|
|
|
33
33
|
# @return [String] Formatted diff
|
|
34
34
|
def format(doc1, doc2)
|
|
35
35
|
compute_line_num_width(doc1, doc2)
|
|
36
|
-
|
|
37
|
-
if @differences&.any?(Canon::Diff::DiffNode)
|
|
38
|
-
# Check if we should skip based on show_diffs setting
|
|
39
|
-
if should_skip_diff_display?
|
|
40
|
-
return ""
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Use new pipeline when DiffNodes available
|
|
44
|
-
return format_with_pipeline(doc1, doc2)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# LEGACY: Fall back to old DOM-based behavior
|
|
48
|
-
# Check if we should show any diffs based on differences array
|
|
49
|
-
if should_skip_diff_display?
|
|
50
|
-
return ""
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
output = []
|
|
54
|
-
|
|
55
|
-
begin
|
|
56
|
-
# Parse to DOM using HTML parser
|
|
57
|
-
root1 = Canon::Html::DataModel.from_html(doc1,
|
|
58
|
-
version: @html_version)
|
|
59
|
-
root2 = Canon::Html::DataModel.from_html(doc2,
|
|
60
|
-
version: @html_version)
|
|
61
|
-
|
|
62
|
-
# Match elements semantically
|
|
63
|
-
matcher = Canon::Xml::ElementMatcher.new
|
|
64
|
-
matches = matcher.match_trees(root1, root2)
|
|
65
|
-
|
|
66
|
-
# Pretty-print HTML for line mapping
|
|
67
|
-
pretty_printer = Canon::PrettyPrinter::Html.new(indent: 2)
|
|
68
|
-
pretty1 = pretty_printer.format(doc1)
|
|
69
|
-
pretty2 = pretty_printer.format(doc2)
|
|
70
|
-
|
|
71
|
-
# Build line range maps using pretty-printed documents
|
|
72
|
-
mapper1 = Canon::Xml::LineRangeMapper.new(indent: 2)
|
|
73
|
-
mapper2 = Canon::Xml::LineRangeMapper.new(indent: 2)
|
|
74
|
-
map1 = mapper1.build_map(root1, pretty1)
|
|
75
|
-
map2 = mapper2.build_map(root2, pretty2)
|
|
76
|
-
|
|
77
|
-
# Use pretty-printed document lines for display
|
|
78
|
-
lines1 = pretty1.split("\n")
|
|
79
|
-
lines2 = pretty2.split("\n")
|
|
80
|
-
|
|
81
|
-
# Display diffs based on element matches
|
|
82
|
-
result = format_element_matches(matches, map1, map2, lines1, lines2)
|
|
83
|
-
output << result
|
|
84
|
-
rescue StandardError => e
|
|
85
|
-
# Fall back to simple diff on error
|
|
86
|
-
output << colorize("Warning: DOM parsing failed, using simple diff",
|
|
87
|
-
:yellow)
|
|
88
|
-
output << colorize("Error: #{e.class}: #{e.message}", :red)
|
|
36
|
+
return "" if should_skip_diff_display?
|
|
89
37
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
end.take(3)
|
|
94
|
-
unless relevant_trace.empty?
|
|
95
|
-
output << colorize("Backtrace:", :yellow)
|
|
96
|
-
relevant_trace.each do |line|
|
|
97
|
-
output << colorize(" #{line}", :yellow)
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
output << ""
|
|
102
|
-
simple = SimpleFormatter.new(
|
|
103
|
-
use_color: @use_color,
|
|
104
|
-
context_lines: @context_lines,
|
|
105
|
-
diff_grouping_lines: @diff_grouping_lines,
|
|
106
|
-
visualization_map: @visualization_map,
|
|
107
|
-
)
|
|
108
|
-
output << simple.format(doc1, doc2)
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
output.join("\n")
|
|
38
|
+
# The DiffNode pipeline is the only renderer: without
|
|
39
|
+
# DiffNodes there is nothing to report (#84).
|
|
40
|
+
format_with_pipeline(doc1, doc2)
|
|
112
41
|
end
|
|
113
42
|
|
|
114
43
|
# Format using new DiffReportBuilder pipeline
|
|
@@ -260,227 +189,6 @@ module Canon
|
|
|
260
189
|
|
|
261
190
|
output.join("\n")
|
|
262
191
|
end
|
|
263
|
-
|
|
264
|
-
private
|
|
265
|
-
|
|
266
|
-
# Format element matches for display
|
|
267
|
-
def format_element_matches(matches, map1, map2, lines1, lines2)
|
|
268
|
-
output = []
|
|
269
|
-
|
|
270
|
-
# Detect non-ASCII characters in the diff
|
|
271
|
-
all_text = (lines1 + lines2).join
|
|
272
|
-
non_ascii = Legend.detect_non_ascii(all_text, @visualization_map)
|
|
273
|
-
|
|
274
|
-
# Add Unicode legend if any non-ASCII characters detected
|
|
275
|
-
unless non_ascii.empty?
|
|
276
|
-
output << Legend.build_legend(non_ascii, use_color: @use_color)
|
|
277
|
-
output << ""
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
# Build a set of elements to skip (children of parents showing diffs)
|
|
281
|
-
elements_to_skip = build_skip_set(matches, map1, map2, lines1,
|
|
282
|
-
lines2)
|
|
283
|
-
|
|
284
|
-
# Build a set of children of matched parents
|
|
285
|
-
children_of_matched_parents = build_children_set(matches)
|
|
286
|
-
|
|
287
|
-
# Collect diff sections with metadata
|
|
288
|
-
diff_sections = collect_diff_sections(matches, map1, map2, lines1,
|
|
289
|
-
lines2, elements_to_skip,
|
|
290
|
-
children_of_matched_parents)
|
|
291
|
-
|
|
292
|
-
# Sort by line number
|
|
293
|
-
diff_sections.sort_by! do |section|
|
|
294
|
-
section[:start_line1] || section[:start_line2] || 0
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
# Group diffs by proximity if diff_grouping_lines is set
|
|
298
|
-
formatted_diffs = if @diff_grouping_lines
|
|
299
|
-
groups = group_diff_sections(diff_sections,
|
|
300
|
-
@diff_grouping_lines)
|
|
301
|
-
format_diff_groups(groups)
|
|
302
|
-
else
|
|
303
|
-
diff_sections.filter_map do |s|
|
|
304
|
-
s[:formatted]
|
|
305
|
-
end.join("\n\n")
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
output << formatted_diffs
|
|
309
|
-
output.join("\n")
|
|
310
|
-
end
|
|
311
|
-
|
|
312
|
-
# Build set of elements to skip (children with parents showing diffs)
|
|
313
|
-
def build_skip_set(matches, map1, map2, lines1, lines2)
|
|
314
|
-
elements_to_skip = Set.new
|
|
315
|
-
elements_with_diffs = Set.new
|
|
316
|
-
|
|
317
|
-
# Build set of element pairs that have semantic diffs
|
|
318
|
-
build_elements_with_semantic_diffs_set
|
|
319
|
-
|
|
320
|
-
# First pass: identify elements with line differences
|
|
321
|
-
# (semantic filtering happens in collect_diff_sections)
|
|
322
|
-
matches.each do |match|
|
|
323
|
-
next unless match.status == :matched
|
|
324
|
-
|
|
325
|
-
range1 = map1[match.elem1]
|
|
326
|
-
range2 = map2[match.elem2]
|
|
327
|
-
next unless range1 && range2
|
|
328
|
-
|
|
329
|
-
elem_lines1 = lines1[range1.start_line..range1.end_line]
|
|
330
|
-
elem_lines2 = lines2[range2.start_line..range2.end_line]
|
|
331
|
-
|
|
332
|
-
# Add if there are line diffs
|
|
333
|
-
# Semantic filtering is done in collect_diff_sections
|
|
334
|
-
if elem_lines1 != elem_lines2
|
|
335
|
-
elements_with_diffs.add(match.elem1)
|
|
336
|
-
end
|
|
337
|
-
end
|
|
338
|
-
|
|
339
|
-
# Second pass: skip children of elements with diffs
|
|
340
|
-
elements_with_diffs.each do |elem|
|
|
341
|
-
current = Canon::Comparison::NodeInspector.parent(elem)
|
|
342
|
-
while current
|
|
343
|
-
if Canon::Comparison::NodeInspector.element_node?(current) &&
|
|
344
|
-
elements_with_diffs.include?(current)
|
|
345
|
-
elements_to_skip.add(elem)
|
|
346
|
-
break
|
|
347
|
-
end
|
|
348
|
-
current = Canon::Comparison::NodeInspector.parent(current)
|
|
349
|
-
end
|
|
350
|
-
end
|
|
351
|
-
|
|
352
|
-
elements_to_skip
|
|
353
|
-
end
|
|
354
|
-
|
|
355
|
-
# Collect diff sections with metadata
|
|
356
|
-
def collect_diff_sections(matches, map1, map2, lines1, lines2,
|
|
357
|
-
elements_to_skip, _children_of_matched_parents)
|
|
358
|
-
diff_sections = []
|
|
359
|
-
no_range_count = 0
|
|
360
|
-
no_diff_count = 0
|
|
361
|
-
|
|
362
|
-
# If there are NO semantic diffs, don't show any matched elements
|
|
363
|
-
# (all text diffs were normalized away)
|
|
364
|
-
elements_with_semantic_diffs = build_elements_with_semantic_diffs_set
|
|
365
|
-
|
|
366
|
-
matches.each do |match|
|
|
367
|
-
case match.status
|
|
368
|
-
when :matched
|
|
369
|
-
next if elements_to_skip.include?(match.elem1)
|
|
370
|
-
|
|
371
|
-
# Only apply semantic filtering if we have DiffNode objects
|
|
372
|
-
# (when called standalone or without DiffNodes, show all diffs)
|
|
373
|
-
if !@differences.nil? && !@differences.empty? && @differences.any?(Canon::Diff::DiffNode)
|
|
374
|
-
# Skip if no semantic diffs exist (all diffs were normalized)
|
|
375
|
-
next if elements_with_semantic_diffs.empty?
|
|
376
|
-
|
|
377
|
-
# Skip if this element has no semantic diffs in its subtree
|
|
378
|
-
next unless has_semantic_diff_in_subtree?(match.elem1,
|
|
379
|
-
elements_with_semantic_diffs)
|
|
380
|
-
end
|
|
381
|
-
|
|
382
|
-
range1 = map1[match.elem1]
|
|
383
|
-
range2 = map2[match.elem2]
|
|
384
|
-
if !range1 || !range2
|
|
385
|
-
no_range_count += 1
|
|
386
|
-
end
|
|
387
|
-
|
|
388
|
-
section = format_matched_element_with_metadata(match, map1,
|
|
389
|
-
map2, lines1,
|
|
390
|
-
lines2)
|
|
391
|
-
if range1 && range2 && !section
|
|
392
|
-
no_diff_count += 1
|
|
393
|
-
end
|
|
394
|
-
diff_sections << section if section
|
|
395
|
-
when :deleted
|
|
396
|
-
# Don't skip deleted elements - they should always be shown
|
|
397
|
-
section = format_deleted_element_with_metadata(match, map1,
|
|
398
|
-
lines1)
|
|
399
|
-
diff_sections << section if section
|
|
400
|
-
when :inserted
|
|
401
|
-
# Don't skip inserted elements - they should always be shown
|
|
402
|
-
section = format_inserted_element_with_metadata(match, map2,
|
|
403
|
-
lines2)
|
|
404
|
-
diff_sections << section if section
|
|
405
|
-
end
|
|
406
|
-
end
|
|
407
|
-
|
|
408
|
-
diff_sections
|
|
409
|
-
end
|
|
410
|
-
|
|
411
|
-
# Format a matched element showing differences
|
|
412
|
-
def format_matched_element(match, map1, map2, lines1, lines2)
|
|
413
|
-
range1 = map1[match.elem1]
|
|
414
|
-
range2 = map2[match.elem2]
|
|
415
|
-
return nil unless range1 && range2
|
|
416
|
-
|
|
417
|
-
# Extract line ranges
|
|
418
|
-
elem_lines1 = lines1[range1.start_line..range1.end_line]
|
|
419
|
-
elem_lines2 = lines2[range2.start_line..range2.end_line]
|
|
420
|
-
|
|
421
|
-
# Skip if identical
|
|
422
|
-
return nil if elem_lines1 == elem_lines2
|
|
423
|
-
|
|
424
|
-
# Run line diff
|
|
425
|
-
diffs = ::Diff::LCS.sdiff(elem_lines1, elem_lines2)
|
|
426
|
-
|
|
427
|
-
# Identify diff blocks
|
|
428
|
-
diff_blocks = identify_diff_blocks(diffs)
|
|
429
|
-
return nil if diff_blocks.empty?
|
|
430
|
-
|
|
431
|
-
# Group into contexts
|
|
432
|
-
contexts = group_diff_blocks_into_contexts(diff_blocks,
|
|
433
|
-
@diff_grouping_lines || 0)
|
|
434
|
-
|
|
435
|
-
# Expand with context lines
|
|
436
|
-
expanded_contexts = expand_contexts_with_context_lines(contexts,
|
|
437
|
-
@context_lines,
|
|
438
|
-
diffs.length)
|
|
439
|
-
|
|
440
|
-
# Format contexts
|
|
441
|
-
output = []
|
|
442
|
-
expanded_contexts.each_with_index do |context, idx|
|
|
443
|
-
output << "" if idx.positive?
|
|
444
|
-
output << format_context(context, diffs, range1.start_line,
|
|
445
|
-
range2.start_line)
|
|
446
|
-
end
|
|
447
|
-
|
|
448
|
-
output.join("\n")
|
|
449
|
-
end
|
|
450
|
-
|
|
451
|
-
# Format a deleted element
|
|
452
|
-
def format_deleted_element(match, map1, lines1)
|
|
453
|
-
range1 = map1[match.elem1]
|
|
454
|
-
return nil unless range1
|
|
455
|
-
|
|
456
|
-
output = []
|
|
457
|
-
path_str = match.path.join("/")
|
|
458
|
-
output << colorize("Element: #{path_str} [DELETED]", :red, :bold)
|
|
459
|
-
|
|
460
|
-
# Show all lines as deleted
|
|
461
|
-
(range1.start_line..range1.end_line).each do |i|
|
|
462
|
-
output << format_unified_line(i + 1, nil, "-", lines1[i], :red)
|
|
463
|
-
end
|
|
464
|
-
|
|
465
|
-
output.join("\n")
|
|
466
|
-
end
|
|
467
|
-
|
|
468
|
-
# Format an inserted element
|
|
469
|
-
def format_inserted_element(match, map2, lines2)
|
|
470
|
-
range2 = map2[match.elem2]
|
|
471
|
-
return nil unless range2
|
|
472
|
-
|
|
473
|
-
output = []
|
|
474
|
-
path_str = match.path.join("/")
|
|
475
|
-
output << colorize("Element: #{path_str} [INSERTED]", :green, :bold)
|
|
476
|
-
|
|
477
|
-
# Show all lines as inserted
|
|
478
|
-
(range2.start_line..range2.end_line).each do |i|
|
|
479
|
-
output << format_unified_line(nil, i + 1, "+", lines2[i], :green)
|
|
480
|
-
end
|
|
481
|
-
|
|
482
|
-
output.join("\n")
|
|
483
|
-
end
|
|
484
192
|
end
|
|
485
193
|
end
|
|
486
194
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Canon
|
|
7
|
+
module Rebaseliner
|
|
8
|
+
# Write a file atomically by writing to a same-directory tempfile and
|
|
9
|
+
# renaming over the target. Preserves the original file's mode. Avoids
|
|
10
|
+
# half-written files on Ctrl-C.
|
|
11
|
+
module AtomicWriter
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param path [String] absolute path to write
|
|
15
|
+
# @param contents [String] new file contents
|
|
16
|
+
# @return [void]
|
|
17
|
+
def write(path, contents)
|
|
18
|
+
original_mode = File.stat(path).mode
|
|
19
|
+
dir = File.dirname(path)
|
|
20
|
+
Tempfile.create(["canon-rebaseline", ".tmp"], dir) do |tmp|
|
|
21
|
+
tmp.binmode
|
|
22
|
+
tmp.write(contents)
|
|
23
|
+
tmp.flush
|
|
24
|
+
tmp.fsync
|
|
25
|
+
tmp.close
|
|
26
|
+
File.chmod(original_mode, tmp.path)
|
|
27
|
+
File.rename(tmp.path, path)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|