canon 0.3.33 → 0.3.35

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: 6094f49ebe4d3b32a5396a0db4d62dbc502156d277ed12115029d50eb36f37ff
4
- data.tar.gz: 8cf4e6d0b0e1feaaddaed2b996d98f9fd36adb4b250c3460ac2ae20a5263689a
3
+ metadata.gz: 746a0b0b72cc9111986fb3aab9b8c5de994d71b30a011983665b5a24130b092c
4
+ data.tar.gz: e9e5a262b99c0e44c2f11385e911faaf5216b52f32bc027d1a0ae82f0bae985e
5
5
  SHA512:
6
- metadata.gz: 9b53fdc9296da8e354cbb99cc194f6dab11544a52c1cda09370a01f413ce57dc5b04d47d99d496d9a3dcb9558f934dd75a5124be10406de74106e6750b86f51a
7
- data.tar.gz: b8cf46fd5d9380584db39732281d18e7186c33927518ac46e5a42fec09d7d3cc5aad167ca1a85c879f087a54a934e8a329b879c43b4cc3aade63512503d0fe80
6
+ metadata.gz: 9edbbe4c9eb6adb6218b2d282f1ac030fa4642ece72cedae34b54ea6cbac987acfd46bebbf1277da017b4bfcf511be98f41c64951fee9d7034d1410f8414a38b
7
+ data.tar.gz: 23d088e8fb394d5f36553b64f9e05955f788ca85615147878d6116552ed2ac3b297ebdc739604de009c754a5a8600a57cad97668c6c325a018635d6c6010df04
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
3
4
  require "nokogiri" unless RUBY_ENGINE == "opal"
4
5
 
5
6
  module Canon
@@ -596,6 +597,12 @@ compare_profile = nil)
596
597
  # Use default list from WhitespaceSensitivity (single source of truth)
597
598
  WhitespaceSensitivity.format_default_preserve_elements(match_opts).map(&:to_s)
598
599
  end
600
+ preserve_set = preserve_whitespace.to_set
601
+ # Element names arrive lowercased from the HTML parser but the
602
+ # ancestor walk would still allocate a downcase copy per visit;
603
+ # memoize per unique name instead (bounded by the tag vocabulary).
604
+ name_cache = {}
605
+ whitespace_result_cache = {}
599
606
 
600
607
  # Walk all text nodes
601
608
  doc.xpath(".//text()").each do |text_node|
@@ -603,11 +610,20 @@ compare_profile = nil)
603
610
  # Check all ancestors, not just immediate parent
604
611
  # Whitespace preservation happens REGARDLESS of text_content setting
605
612
  parent = text_node.parent
606
- next if ancestor_preserves_whitespace?(parent, preserve_whitespace)
613
+ next if ancestor_preserves_whitespace?(parent, preserve_set,
614
+ name_cache,
615
+ whitespace_result_cache)
607
616
 
608
617
  # Collapse whitespace sequences (spaces, tabs, newlines) to single
609
618
  # space - use tr/squeeze to avoid ReDoS vulnerability from gsub(/\s+/)
610
- normalized = text_node.content.tr("\t\n\r\f\v", " ").squeeze(" ")
619
+ # Content with no tab-class char and no space run is already
620
+ # collapsed — skip the copies entirely.
621
+ content = text_node.content
622
+ normalized = if content.match?(/[\t\n\r\f\v]/) || content.include?(" ")
623
+ content.tr("\t\n\r\f\v", " ").squeeze(" ")
624
+ else
625
+ content
626
+ end
611
627
 
612
628
  # Trim leading/trailing whitespace if appropriate
613
629
  normalized = normalized.strip if should_trim_text_node?(text_node)
@@ -616,31 +632,36 @@ compare_profile = nil)
616
632
  end
617
633
  end
618
634
 
619
- # Check if any ancestor of the given node preserves whitespace
620
- def ancestor_preserves_whitespace?(node, preserve_list)
621
- current = node
622
- while current.is_a?(Canon::Xml::Node) || Canon::XmlParsing.xml_node?(current)
623
- return true if preserve_list.include?(current.name.downcase)
624
-
625
- break if Canon::XmlParsing.document?(current)
626
-
627
- current = current.parent
628
- end
629
- false
635
+ # Check if any ancestor of the given node preserves whitespace.
636
+ # +name_cache+ memoizes downcased names per unique element name
637
+ # (one downcase per tag vocabulary entry), and +result_cache+
638
+ # memoizes the verdict per element — engine node names allocate
639
+ # a fresh String per call, so sibling text nodes must not
640
+ # re-walk shared ancestors.
641
+ def ancestor_preserves_whitespace?(node, preserve_set, name_cache,
642
+ result_cache)
643
+ return false unless node.is_a?(Canon::Xml::Node) ||
644
+ Canon::XmlParsing.xml_node?(node)
645
+
646
+ cached = result_cache[node]
647
+ return cached unless cached.nil?
648
+
649
+ name = node.name
650
+ name = (name_cache[name] ||= name.downcase)
651
+ result_cache[node] = preserve_set.include?(name) ||
652
+ ancestor_preserves_whitespace?(
653
+ node.parent, preserve_set, name_cache,
654
+ result_cache
655
+ )
630
656
  end
631
657
 
632
658
  # Determine if a text node should have leading/trailing whitespace
633
659
  # trimmed Text nodes at the start or end of their parent element should
634
- # be trimmed
660
+ # be trimmed. Only/first/last child ⟺ a missing sibling pointer —
661
+ # checked without allocating the parent's children NodeSet.
662
+ # (The walk runs on Nokogiri fragments only — see the callers.)
635
663
  def should_trim_text_node?(text_node)
636
- parent = text_node.parent
637
- siblings = parent.children
638
-
639
- # Trim if text is the only child
640
- return true if siblings.length == 1
641
-
642
- # Trim if text is at the start or end of parent
643
- text_node == siblings.first || text_node == siblings.last
664
+ text_node.previous_sibling.nil? || text_node.next_sibling.nil?
644
665
  end
645
666
 
646
667
  # Remove whitespace-only text nodes from the document
@@ -654,12 +675,16 @@ compare_profile = nil)
654
675
  def remove_whitespace_only_text_nodes(doc)
655
676
  # Elements where whitespace is significant - don't remove whitespace-only nodes
656
677
  # SINGLE SOURCE OF TRUTH: WhitespaceSensitivity.format_default_preserve_elements
657
- preserve_whitespace = WhitespaceSensitivity.format_default_preserve_elements(format: :html).map(&:to_s)
678
+ preserve_whitespace = WhitespaceSensitivity.format_default_preserve_elements(format: :html).to_set(&:to_s)
679
+ name_cache = {}
680
+ whitespace_result_cache = {}
658
681
 
659
682
  doc.xpath(".//text()").each do |text_node|
660
683
  # CRITICAL: Skip if this text node is inside a whitespace-preserving element
661
684
  parent = text_node.parent
662
- next if ancestor_preserves_whitespace?(parent, preserve_whitespace)
685
+ next if ancestor_preserves_whitespace?(parent, preserve_whitespace,
686
+ name_cache,
687
+ whitespace_result_cache)
663
688
 
664
689
  content = text_node.content
665
690
 
@@ -40,7 +40,14 @@ module Canon
40
40
 
41
41
  # HTML comments are parsed as TEXT nodes by Nokogiri
42
42
  if node.text?
43
- text_stripped = text_content(node).to_s.strip.gsub("\\", "")
43
+ raw = text_content(node).to_s
44
+ # A comment-shaped text must start (after whitespace and
45
+ # backslash removal) with '<' — anything else is a cheap
46
+ # negative with no strip/gsub copies. ('\' must reach the
47
+ # slow path: backslash removal happens before the check.)
48
+ return false unless raw.match?(/\A[ \t\r\n\f]*[<\\]/)
49
+
50
+ text_stripped = raw.strip.gsub("\\", "")
44
51
  return true if text_stripped.start_with?("<!--") && text_stripped.end_with?("-->")
45
52
  end
46
53
  false
data/lib/canon/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Canon
4
- VERSION = "0.3.33"
4
+ VERSION = "0.3.35"
5
5
  end
@@ -447,7 +447,16 @@ module Canon
447
447
  map = {}
448
448
 
449
449
  elements.each do |elem|
450
- class_attr = elem.attribute_nodes.find { |a| a.name == "class" }
450
+ class_attr = nil
451
+ attrs = elem.attribute_nodes
452
+ j = 0
453
+ while j < attrs.length
454
+ if attrs[j].name == "class"
455
+ class_attr = attrs[j]
456
+ break
457
+ end
458
+ j += 1
459
+ end
451
460
  next unless class_attr
452
461
 
453
462
  # Use element name + class as key to handle multiple element types
@@ -458,11 +467,24 @@ module Canon
458
467
  map
459
468
  end
460
469
 
461
- # Extract identity from element attributes
470
+ # Extract identity from element attributes. Index loops, not
471
+ # find/detect blocks — Enumerable#find allocates ~3 objects per
472
+ # call on CRuby 3.4 and this runs per element (both sides) on
473
+ # every comparison level.
462
474
  def extract_identity(elem)
463
- @identity_attrs.each do |attr_name|
464
- attr = elem.attribute_nodes.find { |a| a.name == attr_name }
465
- return attr.value if attr
475
+ attrs = elem.attribute_nodes
476
+ i = 0
477
+ identity_count = @identity_attrs.length
478
+ while i < identity_count
479
+ attr_name = @identity_attrs[i]
480
+ j = 0
481
+ attr_count = attrs.length
482
+ while j < attr_count
483
+ return attrs[j].value if attrs[j].name == attr_name
484
+
485
+ j += 1
486
+ end
487
+ i += 1
466
488
  end
467
489
  nil
468
490
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: canon
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.33
4
+ version: 0.3.35
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-12 00:00:00.000000000 Z
11
+ date: 2026-09-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs