Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
/tmp/
.byebug_history
Gemfile.lock
/vendor/bundle
/.bundle

# rspec failure tracking
.rspec_status
Expand Down
46 changes: 46 additions & 0 deletions lib/moxml/adapter/libxml.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}"

Expand Down
8 changes: 8 additions & 0 deletions lib/moxml/adapter/nokogiri.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions lib/moxml/adapter/oga.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
45 changes: 45 additions & 0 deletions lib/moxml/adapter/ox.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions lib/moxml/adapter/rexml.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions lib/moxml/node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
80 changes: 80 additions & 0 deletions spec/moxml/node_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,84 @@
expect(node.children).to be_empty
end
end

describe "#ancestors" do
let(:xml) { "<library><section><book><title>Book 1</title></book></section></library>" }
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) { "<section><book><title>Book 1</title></book></section>" }
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) { "<library><section><book><title>Book 1</title></book></section></library>" }
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 version="1.0"?>
<library>
<section>
<book>
<title>Book 1</title>
</book>
</section>
</library>
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
Loading