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) { "" }
+ 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) { "" }
+ 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) { "" }
+ 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
+
+
+
+
+ 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