Skip to content

Commit 1e2ce8a

Browse files
committed
Add Beam.XML.parse backed by OTP xmerl
1 parent bc0915e commit 1e2ce8a

File tree

4 files changed

+135
-0
lines changed

4 files changed

+135
-0
lines changed

lib/quickbeam/beam_api.ex

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
defmodule QuickBEAM.BeamAPI do
22
@moduledoc false
33
import Bitwise
4+
require Record
5+
6+
Record.defrecord(:xml_element, Record.extract(:xmlElement, from_lib: "xmerl/include/xmerl.hrl"))
7+
Record.defrecord(:xml_text, Record.extract(:xmlText, from_lib: "xmerl/include/xmerl.hrl"))
8+
Record.defrecord(:xml_attribute, Record.extract(:xmlAttribute, from_lib: "xmerl/include/xmerl.hrl"))
49

510
@version Mix.Project.config()[:version]
611

@@ -191,6 +196,15 @@ defmodule QuickBEAM.BeamAPI do
191196
Kernel.inspect(value, pretty: true, width: 80)
192197
end
193198

199+
@spec xml_parse([String.t()]) :: map()
200+
def xml_parse([xml]) when is_binary(xml) do
201+
{document, _rest} = :xmerl_scan.string(String.to_charlist(xml), quiet: true)
202+
%{element_name(document) => convert_element(document)}
203+
catch
204+
:exit, reason ->
205+
raise ArgumentError, "invalid XML: #{Exception.format_exit(reason)}"
206+
end
207+
194208
@spec password_hash(list()) :: String.t()
195209
def password_hash([password, iterations])
196210
when is_binary(password) and is_integer(iterations) and iterations > 0 do
@@ -252,6 +266,78 @@ defmodule QuickBEAM.BeamAPI do
252266
end
253267
end
254268

269+
defp convert_element(element) do
270+
attributes =
271+
element
272+
|> xml_element(:attributes)
273+
|> Enum.reduce(%{}, fn attribute, acc ->
274+
Map.put(acc, "@#{attribute_name(attribute)}", attribute_value(attribute))
275+
end)
276+
277+
children =
278+
element
279+
|> xml_element(:content)
280+
|> Enum.reduce(%{text: [], elements: %{}}, &reduce_xml_content/2)
281+
282+
text =
283+
children.text
284+
|> Enum.reverse()
285+
|> Enum.join(" ")
286+
|> String.trim()
287+
288+
cond do
289+
map_size(attributes) == 0 and map_size(children.elements) == 0 ->
290+
text
291+
292+
text == "" ->
293+
Map.merge(attributes, children.elements)
294+
295+
true ->
296+
Map.merge(attributes, Map.put(children.elements, "#text", text))
297+
end
298+
end
299+
300+
defp reduce_xml_content(content, acc) do
301+
cond do
302+
match?({:xmlText, _, _, _, _, _}, content) ->
303+
case text_value(content) do
304+
"" -> acc
305+
value -> %{acc | text: [value | acc.text]}
306+
end
307+
308+
match?({:xmlElement, _, _, _, _, _, _, _, _, _, _, _}, content) ->
309+
name = element_name(content)
310+
value = convert_element(content)
311+
%{acc | elements: Map.update(acc.elements, name, value, &merge_xml_children(&1, value))}
312+
313+
true ->
314+
acc
315+
end
316+
end
317+
318+
defp merge_xml_children(existing, value) when is_list(existing), do: existing ++ [value]
319+
defp merge_xml_children(existing, value), do: [existing, value]
320+
321+
defp element_name(element) do
322+
element |> xml_element(:name) |> Atom.to_string()
323+
end
324+
325+
defp attribute_name(attribute) do
326+
attribute |> xml_attribute(:name) |> Atom.to_string()
327+
end
328+
329+
defp attribute_value(attribute) do
330+
attribute |> xml_attribute(:value) |> to_string()
331+
end
332+
333+
defp text_value(text) do
334+
text
335+
|> xml_text(:value)
336+
|> to_string()
337+
|> String.replace(~r/\s+/, " ")
338+
|> String.trim()
339+
end
340+
255341
defp escape_html_binary(<<>>, acc), do: acc
256342

257343
defp escape_html_binary(<<"&", rest::binary>>, acc),

priv/ts/beam-api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,9 @@ Beam.password = {
131131
verify: (password: string, hash: string): Promise<boolean> =>
132132
Beam.call('__beam_password_verify', password, hash) as Promise<boolean>,
133133
}
134+
135+
Beam.XML = {
136+
parse(xml: string): unknown {
137+
return Beam.callSync('__beam_xml_parse', xml)
138+
},
139+
}

priv/ts/quickbeam.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ interface BeamPassword {
5252
verify(password: string, hash: string): Promise<boolean>
5353
}
5454

55+
interface BeamXML {
56+
/** Parse XML using OTP's xmerl and return a JS-friendly object tree. */
57+
parse(xml: string): unknown
58+
}
59+
5560
interface BeamAPI {
5661
/** Call a named BEAM handler (async). Returns a Promise with the result. */
5762
call(handler: string, ...args: unknown[]): Promise<unknown>
@@ -151,6 +156,9 @@ interface BeamAPI {
151156

152157
/** Password hashing and verification via PBKDF2-SHA256. */
153158
password: BeamPassword
159+
160+
/** XML parsing backed by OTP's xmerl. */
161+
XML: BeamXML
154162
}
155163

156164
declare const Beam: BeamAPI

test/core/beam_api_test.exs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,41 @@ defmodule QuickBEAM.Core.BeamAPITest do
512512
end
513513
end
514514

515+
describe "Beam.XML" do
516+
test "parses text-only elements", %{rt: rt} do
517+
{:ok, result} = QuickBEAM.eval(rt, ~s[Beam.XML.parse("<root><name>Dan</name></root>")])
518+
assert result == %{"root" => %{"name" => "Dan"}}
519+
end
520+
521+
test "parses attributes and repeated children", %{rt: rt} do
522+
xml = ~s[<root version="1.0"><item id="1">hello</item><item id="2">world</item></root>]
523+
{:ok, result} = QuickBEAM.eval(rt, "Beam.XML.parse(#{inspect(xml)})")
524+
525+
assert result == %{
526+
"root" => %{
527+
"@version" => "1.0",
528+
"item" => [
529+
%{"@id" => "1", "#text" => "hello"},
530+
%{"@id" => "2", "#text" => "world"}
531+
]
532+
}
533+
}
534+
end
535+
536+
test "parses empty elements as empty strings", %{rt: rt} do
537+
{:ok, result} = QuickBEAM.eval(rt, ~s[Beam.XML.parse("<root><empty /></root>")])
538+
assert result == %{"root" => %{"empty" => ""}}
539+
end
540+
541+
test "rejects malformed XML", %{rt: rt} do
542+
assert {:error, %QuickBEAM.JSError{message: message}} =
543+
QuickBEAM.eval(rt, ~s[Beam.XML.parse("<root><broken></root>")])
544+
545+
546+
assert message =~ "invalid XML"
547+
end
548+
end
549+
515550
describe "Beam.inspect" do
516551
test "inspects a number", %{rt: rt} do
517552
{:ok, result} = QuickBEAM.eval(rt, "Beam.inspect(42)")

0 commit comments

Comments
 (0)