defmodule Expath do @moduledoc """ Lightning-fast XML parsing and XPath querying for Elixir, powered by Rust NIFs. Expath provides blazing-fast XML processing through Rust's battle-tested `sxd-document` and `sxd-xpath` libraries, delivering 2-10x performance improvements and up to 195,000x memory efficiency compared to existing Elixir XML libraries. ## Key Features - **🚀 Blazing Fast**: 2-10x faster than SweetXml with Rust-powered NIFs - **🔄 Parse-Once, Query-Many**: Efficient document reuse for multiple XPath queries - **🛡️ Battle-Tested**: Built on proven Rust XML libraries - **🎯 Simple API**: Clean, intuitive interface with comprehensive error handling - **⚡ Thread-Safe**: Safe concurrent access to parsed documents ## Quick Start For single XPath queries: iex> xml = "1984" iex> {:ok, titles} = Expath.select(xml, "//title/text()") iex> titles ["1984"] For multiple queries on the same document (more efficient): iex> xml = "1984Orwell" iex> {:ok, doc} = Expath.new(xml) iex> {:ok, titles} = Expath.query(doc, "//title/text()") iex> {:ok, authors} = Expath.query(doc, "//author/text()") iex> {titles, authors} {["1984"], ["Orwell"]} ## Performance Benchmark results comparing Expath vs SweetXml: | Document Size | Speed | |---------------|-------| | Small (644B) | 2-3x faster | | Medium (5.6KB)| 2.3x faster | | Large (904KB) | 8-10x faster | ## XPath Support Expath supports the full XPath 1.0 specification: # Node selection Expath.select(xml, "//book") # All book elements Expath.select(xml, "//book[@id='1']") # Specific book # Text extraction Expath.select(xml, "//title/text()") # All title text Expath.select(xml, "//book/@id") # All id attributes # Functions Expath.select(xml, "count(//book)") # Count elements Expath.select(xml, "//book[position()=1]") # First element # Complex expressions Expath.select(xml, "//book[price > 10]/title/text()") # Conditional ## Error Handling All functions return `{:ok, result}` or `{:error, reason}` tuples: {:error, :invalid_xml} # XML parsing failed {:error, :invalid_xpath} # XPath expression invalid {:error, :xpath_error} # XPath evaluation failed ## When to Use Parse-Once vs Single Query **Use `select/2` for**: - One-off XML processing - Small documents - Simple scripts **Use `new/1` + `query/2` for**: - Multiple queries on the same document - Large documents (>1KB) - Performance-critical applications - Concurrent processing scenarios """ # use Rustler, otp_app: :expath, crate: "expath" version = Mix.Project.config()[:version] use RustlerPrecompiled, otp_app: :expath, crate: "expath", base_url: "https://github.com/wearecococo/expath/releases/download/v#{version}", force_build: System.get_env("EXPATH_FORCE_BUILD") in ["1", "true"], version: version @doc """ Parse XML from binary data (low-level function). This function validates that the provided XML binary is well-formed. For most use cases, prefer `select/2` or `new/1` which provide more functionality. ## Parameters - `xml_binary` - XML content as binary data ## Returns - `{:ok, "parsed"}` - XML is valid - `{:error, :invalid_xml}` - XML parsing failed ## Examples iex> Expath.parse_xml("value") {:ok, "parsed"} iex> Expath.parse_xml("") {:error, :invalid_xml} """ def parse_xml(_xml_binary), do: :erlang.nif_error(:nif_not_loaded) @doc """ Select nodes from XML using XPath expressions (low-level function). This is a low-level function that parses XML and executes an XPath query in one operation. For most use cases, prefer the higher-level `select/2` function which provides better error handling and API consistency. ## Parameters - `xml_binary` - XML content as binary data - `xpath_str` - XPath expression as string ## Returns - `{:ok, results}` - List of matching values as strings - `{:error, :invalid_xml}` - XML parsing failed - `{:error, :invalid_xpath}` - XPath expression is invalid - `{:error, :xpath_error}` - XPath evaluation failed ## Examples iex> xml = "value1value2" iex> {:ok, results} = Expath.xpath_select(xml, "//item/text()") iex> Enum.sort(results) ["value1", "value2"] iex> Expath.xpath_select("", "count(//*)") {:ok, ["1"]} """ def xpath_select(_xml_binary, _xpath_str), do: :erlang.nif_error(:nif_not_loaded) @doc """ Parse XML and return a Document resource for efficient reuse (low-level function). This is a low-level function that creates an `Expath.Document` resource. For most use cases, prefer the higher-level `new/1` function. ## Parameters - `xml_binary` - XML content as binary data ## Returns - `{:ok, %Expath.Document{}}` - Successfully parsed document - `{:error, :invalid_xml}` - XML parsing failed ## Examples iex> {:ok, doc} = Expath.parse_document("value") iex> is_struct(doc, Expath.Document) true iex> Expath.parse_document("") {:error, :invalid_xml} """ def parse_document(_xml_binary), do: :erlang.nif_error(:nif_not_loaded) @doc """ Query a parsed Document resource with XPath (low-level function). This is a low-level function that queries an `Expath.Document` resource. For most use cases, prefer the higher-level `query/2` function. ## Parameters - `document` - An `%Expath.Document{}` resource - `xpath_str` - XPath expression as string ## Returns - `{:ok, results}` - List of matching values as strings - `{:error, :invalid_xml}` - Document is invalid - `{:error, :invalid_xpath}` - XPath expression is invalid - `{:error, :xpath_error}` - XPath evaluation failed ## Examples iex> {:ok, doc} = Expath.parse_document("value1value2") iex> {:ok, results} = Expath.query_document(doc, "//item/text()") iex> Enum.sort(results) ["value1", "value2"] iex> {:ok, doc} = Expath.parse_document("") iex> Expath.query_document(doc, "count(//book)") {:ok, ["2"]} """ def query_document(_document, _xpath_str), do: :erlang.nif_error(:nif_not_loaded) @doc """ Select nodes from an XML string using XPath. This is the recommended function for single XPath queries. It parses the XML and executes the XPath query in one operation with clean error handling. For multiple queries on the same XML document, consider using `new/1` followed by multiple `query/2` calls for better performance. ## Parameters - `xml_string` - XML content as a binary string - `xpath` - XPath expression as a binary string ## Returns - `{:ok, results}` - List of matching values as strings - `{:error, :invalid_xml}` - XML parsing failed - `{:error, :invalid_xpath}` - XPath expression is invalid - `{:error, :xpath_error}` - XPath evaluation failed ## Examples iex> xml = "1984" iex> {:ok, titles} = Expath.select(xml, "//title/text()") iex> titles ["1984"] iex> xml = "value1value2" iex> {:ok, results} = Expath.select(xml, "//item/text()") iex> Enum.sort(results) ["value1", "value2"] iex> xml = "" iex> {:ok, [count]} = Expath.select(xml, "count(//book)") iex> count "2" iex> Expath.select("", "//title") {:error, :invalid_xml} iex> Expath.select("", "//[invalid") {:error, :invalid_xpath} ## Performance Tips - For single queries: use `select/2` - For multiple queries on the same document: use `new/1` + `query/2` - Prefer specific XPath expressions over broad searches - Use the included benchmark suite to test your specific use case """ def select(xml_string, xpath) when is_binary(xml_string) and is_binary(xpath) do xpath_select(xml_string, xpath) end @doc """ Parse XML from a string and return a Document resource. This is the recommended function for parse-once, query-many scenarios. The parsed document can be reused for multiple XPath queries without re-parsing the XML, providing significant performance benefits for applications that need to run multiple queries on the same document. The Document resource is automatically cleaned up by Erlang's garbage collector when it goes out of scope. ## Parameters - `xml_string` - XML content as a binary string ## Returns - `{:ok, %Expath.Document{}}` - Successfully parsed document resource - `{:error, :invalid_xml}` - XML parsing failed ## Examples iex> xml = "1984Orwell" iex> {:ok, doc} = Expath.new(xml) iex> is_struct(doc, Expath.Document) true iex> Expath.new("") {:error, :invalid_xml} ## Usage Pattern # Parse once {:ok, doc} = Expath.new(large_xml_string) # Query multiple times efficiently {:ok, titles} = Expath.query(doc, "//title/text()") {:ok, authors} = Expath.query(doc, "//author/text()") {:ok, [count]} = Expath.query(doc, "count(//book)") # Document automatically cleaned up when `doc` goes out of scope ## Performance Benefits - **Memory**: Document stored efficiently in Rust - **Speed**: No XML re-parsing for subsequent queries - **Concurrency**: Document can be safely shared across processes - **Large Documents**: Particularly beneficial for documents >1KB """ def new(xml_string) when is_binary(xml_string) do parse_document(xml_string) end @doc """ Query a Document resource with XPath. This function executes XPath queries on a previously parsed Document resource. It's designed for high-performance scenarios where multiple queries need to be run on the same XML document without re-parsing. ## Parameters - `document` - An `%Expath.Document{}` resource from `new/1` - `xpath` - XPath expression as a binary string ## Returns - `{:ok, results}` - List of matching values as strings - `{:error, :invalid_xml}` - Document resource is invalid - `{:error, :invalid_xpath}` - XPath expression is invalid - `{:error, :xpath_error}` - XPath evaluation failed ## Examples iex> xml = "1984Orwell" iex> {:ok, doc} = Expath.new(xml) iex> {:ok, titles} = Expath.query(doc, "//title/text()") iex> titles ["1984"] iex> {:ok, doc} = Expath.new("value1value2") iex> {:ok, results} = Expath.query(doc, "//item/text()") iex> Enum.sort(results) ["value1", "value2"] iex> {:ok, doc} = Expath.new("") iex> {:ok, [count]} = Expath.query(doc, "count(//book)") iex> count "2" ## Typical Usage # Parse large document once {:ok, doc} = Expath.new(large_xml_content) # Run multiple queries efficiently {:ok, products} = Expath.query(doc, "//product/@id") {:ok, prices} = Expath.query(doc, "//price/text()") {:ok, categories} = Expath.query(doc, "//category/text()") {:ok, [total]} = Expath.query(doc, "count(//product)") ## Concurrent Usage # Document can be safely shared across processes {:ok, doc} = Expath.new(xml_content) tasks = for xpath <- xpath_list do Task.async(fn -> Expath.query(doc, xpath) end) end results = Task.await_many(tasks) ## Performance This approach is particularly beneficial when: - Running 2+ queries on the same document - Working with large documents (>1KB) - Processing documents in tight loops - Sharing documents across multiple processes """ def query(%Expath.Document{} = document, xpath) when is_binary(xpath) do query_document(document, xpath) end end