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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '05685ad043ad0a5465f4e065c26cf35ad1b453511ce347a56e60d5a5153e731f'
4
- data.tar.gz: 864587a2ddf54edd3f40d71676882b255904dfe6f46ee41a330ef8117ceddfed
3
+ metadata.gz: 6aef461e6f80457ad8fd6c227effe22086b5692f2cfcec5f4226ab0268e4956d
4
+ data.tar.gz: da2c4ae39e5c8a3483c811fe9bd19c49d69542b637fd97e4b62a2d20fda8bc08
5
5
  SHA512:
6
- metadata.gz: 6e568174aeaf605650e7eb64267afddbe30fd4399b82c032757e60177631b75663b0cf388d16e2fbaddea774ea703cb4c88f3e11ac8307ee1a75a3ea6f6530cc
7
- data.tar.gz: 5af9aded692f6b909ffd1eb50bb9e96c851b8ca98844a397ceec11f8a2eede33437f99d4df15a2870beee56954924d2fd1ec061b6cb4523d2204fa07524f0710
6
+ metadata.gz: 8cd8327564623711f977aec6b31943433ab962bdec534b2ac107f0eb7ead31cb0854551f3ef614ae1f2e2cdd059b5ac6b3655b44e7cc0dd82d34cff0a2c4b045
7
+ data.tar.gz: fcaac6df39848f1fd387710e57de61533dac6543028f412b08bd36d9615157a8252a81b13f015606ce666da31d02a5eeea0896abfadafdca6543dc92ac4977c6
data/.rubocop.yml CHANGED
@@ -17,3 +17,5 @@ plugins:
17
17
 
18
18
  AllCops:
19
19
  TargetRubyVersion: 3.0
20
+ Exclude:
21
+ - "spec/fixtures/**/*"
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 Nokogiri DOM trees.
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 = attr.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
- next if ignore_by_name?(name, opts)
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
- # If we have DiffNodes from comparison, use the new pipeline
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
- # Include relevant backtrace lines
91
- relevant_trace = e.backtrace.select do |line|
92
- line.include?("canon")
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