diff --git a/.gitignore b/.gitignore index adb590ed..261448f4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ /tmp/ .byebug_history Gemfile.lock +/vendor/bundle +/.bundle # rspec failure tracking .rspec_status diff --git a/lib/moxml/adapter/libxml.rb b/lib/moxml/adapter/libxml.rb index b96f0da2..4cf5b991 100644 --- a/lib/moxml/adapter/libxml.rb +++ b/lib/moxml/adapter/libxml.rb @@ -346,6 +346,19 @@ def root(document) native_doc&.root end + def path(node) + native_node = unpatch_node(node) + return "/" unless native_node + + # LibXML has a built-in path method + native_node.respond_to?(:path) ? native_node.path : build_xpath_for_node(native_node) + end + + def line_number(node) + native_node = unpatch_node(node) + native_node.respond_to?(:line_num) ? native_node.line_num : nil + end + def attributes(element) native_elem = unpatch_node(element) return [] unless native_elem @@ -1157,6 +1170,39 @@ def prepare_for_new_document(node, target_doc) private + def build_xpath_for_node(node) + # Build XPath by traversing up to root + path_parts = [] + current = node + + while current && !current.is_a?(::LibXML::XML::Document) + if current.is_a?(::LibXML::XML::Node) && current.element? + # Get element name + name = current.name + + # Find position among siblings with same name + parent = current.parent + if parent && !parent.is_a?(::LibXML::XML::Document) + siblings = [] + parent.each_child do |child| + siblings << child if child.element? && child.name == name + end + if siblings.size > 1 + position = siblings.index(current) + 1 + path_parts.unshift("#{name}[#{position}]") + else + path_parts.unshift(name) + end + else + path_parts.unshift(name) + end + end + current = current.parent + end + + "/" + path_parts.join("/") + end + def serialize_element(elem) output = "<#{elem.name}" diff --git a/lib/moxml/adapter/nokogiri.rb b/lib/moxml/adapter/nokogiri.rb index bafeaf40..fecdafe4 100644 --- a/lib/moxml/adapter/nokogiri.rb +++ b/lib/moxml/adapter/nokogiri.rb @@ -194,6 +194,14 @@ def root(document) document.respond_to?(:root) ? document.root : document.children.first end + def path(node) + node.path + end + + def line_number(node) + node.respond_to?(:line) ? node.line : nil + end + def attribute_element(attr) attr.parent end diff --git a/lib/moxml/adapter/oga.rb b/lib/moxml/adapter/oga.rb index c15e5e8e..b39f03cf 100644 --- a/lib/moxml/adapter/oga.rb +++ b/lib/moxml/adapter/oga.rb @@ -201,6 +201,16 @@ def root(document) document.children.find { |node| node.is_a?(::Oga::XML::Element) } end + def path(node) + # Oga doesn't have a built-in path method, build it manually + build_xpath_for_node(node) + end + + def line_number(node) + # Oga doesn't track line numbers + nil + end + def attribute_element(attr) attr.element end @@ -452,6 +462,38 @@ def serialize(node, options = {}) # Default: use XmlGenerator ::Moxml::Adapter::CustomizedOga::XmlGenerator.new(node).to_xml end + + private + + def build_xpath_for_node(node) + # Build XPath by traversing up to root + path_parts = [] + current = node + + while current && !current.is_a?(::Oga::XML::Document) + if current.is_a?(::Oga::XML::Element) + # Get element name + name = current.name + + # Find position among siblings with same name + parent = current.parent + if parent && !parent.is_a?(::Oga::XML::Document) + siblings = parent.children.select { |e| e.is_a?(::Oga::XML::Element) && e.name == name } + if siblings.size > 1 + position = siblings.index(current) + 1 + path_parts.unshift("#{name}[#{position}]") + else + path_parts.unshift(name) + end + else + path_parts.unshift(name) + end + end + current = current.parent + end + + "/" + path_parts.join("/") + end end end diff --git a/lib/moxml/adapter/ox.rb b/lib/moxml/adapter/ox.rb index f3411113..cd74e822 100644 --- a/lib/moxml/adapter/ox.rb +++ b/lib/moxml/adapter/ox.rb @@ -270,6 +270,16 @@ def root(document) document.nodes&.find { |node| node.is_a?(::Ox::Element) } end + def path(node) + # Ox doesn't have a built-in path method, build it manually + build_xpath_for_node(node) + end + + def line_number(node) + # Ox doesn't track line numbers + nil + end + def attributes(element) unless element.respond_to?(:attributes) && element.attributes return [] @@ -608,6 +618,41 @@ def serialize(node, options = {}) private + def build_xpath_for_node(node) + # Build XPath by traversing up to root + path_parts = [] + current = node + + while current && !current.is_a?(::Ox::Document) + if current.is_a?(::Ox::Element) + # Get element name + name = current.value + + # Find position among siblings with same name + parent = get_ox_parent(current) + if parent && !parent.is_a?(::Ox::Document) + siblings = parent.nodes.select { |n| n.is_a?(::Ox::Element) && n.value == name } + if siblings.size > 1 + position = siblings.index(current) + 1 + path_parts.unshift("#{name}[#{position}]") + else + path_parts.unshift(name) + end + else + path_parts.unshift(name) + end + end + current = get_ox_parent(current) + end + + "/" + path_parts.join("/") + end + + def get_ox_parent(node) + # Ox stores parent in @moxml_parent instance variable + node.instance_variable_get(:@moxml_parent) + end + # Translate a subset of XPath to Ox locate() syntax # Supports: //element, /path/to/element, .//element, element[@attr] # Note: Ox locate() doesn't support namespace prefixes in the path diff --git a/lib/moxml/adapter/rexml.rb b/lib/moxml/adapter/rexml.rb index c139eb90..c5e43f08 100644 --- a/lib/moxml/adapter/rexml.rb +++ b/lib/moxml/adapter/rexml.rb @@ -213,6 +213,16 @@ def root(document) document.root end + def path(node) + # REXML uses xpath to generate path + build_xpath_for_node(node) + end + + def line_number(node) + # REXML doesn't track line numbers + nil + end + def attributes(element) return [] unless element.respond_to?(:attributes) @@ -524,6 +534,36 @@ def serialize(node, options = {}) private + def build_xpath_for_node(node) + # Build XPath by traversing up to root + path_parts = [] + current = node + + while current && !current.is_a?(::REXML::Document) + if current.is_a?(::REXML::Element) + # Get element name + name = current.name + + # Find position among siblings with same name + parent = current.parent + if parent && !parent.is_a?(::REXML::Document) + siblings = parent.elements.select { |e| e.name == name } + if siblings.size > 1 + position = siblings.index(current) + 1 + path_parts.unshift("#{name}[#{position}]") + else + path_parts.unshift(name) + end + else + path_parts.unshift(name) + end + end + current = current.parent + end + + "/" + path_parts.join("/") + end + def write_with_formatter(node, output, indent = 2) formatter = ::Moxml::Adapter::CustomizedRexml::Formatter.new( indentation: indent, self_close_empty: false, diff --git a/lib/moxml/node.rb b/lib/moxml/node.rb index e0ca547c..0fd2e175 100644 --- a/lib/moxml/node.rb +++ b/lib/moxml/node.rb @@ -170,6 +170,42 @@ def each_node(&block) end end + # Returns all ancestor nodes from parent up to document + # @return [NodeSet] collection of ancestor nodes + def ancestors + result = [] + current = parent + while current + result << current.native + # Stop at document node (documents don't have parents) + break if current.is_a?(Document) + + current = current.respond_to?(:parent) ? current.parent : nil + end + NodeSet.new(result, context) + end + + # Returns all descendant nodes (children, grandchildren, etc.) + # Does not include the current node itself + # @return [NodeSet] collection of descendant nodes + def descendants + result = [] + each_node { |node| result << node.native } + NodeSet.new(result, context) + end + + # Returns the XPath expression to locate this node + # @return [String] XPath path to this node + def path + adapter.path(@native) + end + + # Returns the line number where this node appears in the source XML + # @return [Integer, nil] line number or nil if not available + def line_number + adapter.line_number(@native) + end + # Clone the node (deep copy) def clone Node.wrap(adapter.dup(@native), context) diff --git a/spec/moxml/node_spec.rb b/spec/moxml/node_spec.rb index 0cc40289..df107236 100644 --- a/spec/moxml/node_spec.rb +++ b/spec/moxml/node_spec.rb @@ -34,4 +34,84 @@ expect(node.children).to be_empty end end + + describe "#ancestors" do + let(:xml) { "
Book 1
" } + let(:doc) { context.parse(xml) } + let(:title) { doc.at_xpath("//title") } + + it "returns all ancestor nodes" do + ancestors = title.ancestors + expect(ancestors).to be_a(Moxml::NodeSet) + expect(ancestors.size).to eq(4) # book, section, library, document + end + + it "returns ancestors in order from parent to root" do + ancestors = title.ancestors.to_a + expect(ancestors[0]).to be_a(Moxml::Element) + expect(ancestors[0].name).to eq("book") + expect(ancestors[1].name).to eq("section") + expect(ancestors[2].name).to eq("library") + expect(ancestors[3]).to be_a(Moxml::Document) + end + end + + describe "#descendants" do + let(:xml) { "
Book 1
" } + let(:doc) { context.parse(xml) } + let(:section) { doc.at_xpath("//section") } + + it "returns all descendant nodes" do + descendants = section.descendants + expect(descendants).to be_a(Moxml::NodeSet) + # Should include book, title, and text nodes + expect(descendants.size).to be >= 2 + end + + it "includes all nested elements and text nodes" do + descendants = section.descendants.to_a + element_names = descendants.select { |d| d.is_a?(Moxml::Element) }.map(&:name) + expect(element_names).to include("book", "title") + end + end + + describe "#path" do + let(:xml) { "
Book 1
" } + let(:doc) { context.parse(xml) } + let(:title) { doc.at_xpath("//title") } + let(:book) { doc.at_xpath("//book") } + + it "returns XPath to the node" do + expect(title.path).to be_a(String) + expect(title.path).to eq("/library/section/book/title") + end + + it "returns correct path for intermediate nodes" do + expect(book.path).to eq("/library/section/book") + end + end + + describe "#line_number" do + let(:xml) do + <<~XML + + +
+ + Book 1 + +
+
+ XML + end + let(:doc) { context.parse(xml) } + let(:title) { doc.at_xpath("//title") } + + it "returns line number for supported adapters" do + # Nokogiri and LibXML support line numbers + # Other adapters may return nil + line_num = title.line_number + expect(line_num).to be_a(Integer).or(be_nil) + end + end end