defmodule XMAVLink.Parser do
@moduledoc """
Parse a mavlink xml file into an idiomatic Elixir representation:
%{
version: 2,
dialect: 0,
enums: [
%{
name: :mav_autopilot,
description: "Micro air vehicle...",
entries: [
%{
value: 0,
name: :mav_autopilot_generic, (use atoms for identifiers)
description: "Generic autopilot..."
params: [ (only used by commands)
%{
index: 0,
description: ""
},
... more entry params
]
},
... more enum entries
]
},
... more enums
],
messages: [
%{
id: 0,
name: "optical_flow",
description: "Optical flow...",
fields: [
%{
type: "uint16_t",
ordinality: 1,
name: "flow_x",
units: "dpixels", (note: string not atom)
description: "Flow in pixels..."
},
... more message fields
]
},
... more messages
]
}
"""
import Enum, only: [empty?: 1, reduce: 3, reverse: 1, map: 2, sort_by: 2, into: 3, filter: 2]
import List, only: [first: 1]
import Record, only: [defrecord: 2, extract: 2]
import Regex, only: [replace: 3]
import String, only: [to_integer: 1, downcase: 1, to_atom: 1, split: 3]
@xmerl_header "xmerl/include/xmerl.hrl"
defrecord :xmlElement, extract(:xmlElement, from_lib: @xmerl_header)
defrecord :xmlAttribute, extract(:xmlAttribute, from_lib: @xmerl_header)
defrecord :xmlText, extract(:xmlText, from_lib: @xmerl_header)
@spec parse_mavlink_xml(String.t) :: %{version: integer, dialect: integer, enums: [ enum_description ], messages: [ message_description ]} | {:error, :enoent}
def parse_mavlink_xml(path) do
parse_mavlink_xml(path, %{}) |> Map.values |> combine_definitions
end
def parse_mavlink_xml(path, paths) do
case Map.has_key?(paths, path) do
true ->
paths # Don't include a file twice
false ->
case :xmerl_scan.file(path) do
{defs, []} ->
# Recursively add new includes to paths
paths = reduce(
:xmerl_xpath.string('/mavlink/include/text()', defs) |> map(&extract_text/1),
paths,
fn (next_include, acc) ->
include_path = Path.dirname(path) <> "/" <> next_include
parse_mavlink_xml(include_path, acc)
end
)
# And add ourselves to paths if we're not already there through a circular dependency
version = :xmerl_xpath.string('/mavlink/version/text()', defs) |> extract_text |> nil_to_zero_string
Map.put_new(paths, path, %{
version: version,
dialect: :xmerl_xpath.string('/mavlink/dialect/text()', defs) |> extract_text |> nil_to_zero_string,
enums: (for enum <- :xmerl_xpath.string('/mavlink/enums/enum', defs), do: parse_enum(enum)),
messages: (for msg <- :xmerl_xpath.string('/mavlink/messages/message', defs), do: parse_message(msg, version))
})
{:error, :enoent} ->
Map.put(paths, path, {:error, "File '#{path}' does not exist"})
end
end
end
# See https://mavlink.io/en/guide/xml_schema.html, mavparse.py merge_enums() and
# check_duplicates() for proper validation. If making changes to definitions test
# first with mavgen for now.
# TODO Handle missing includes without borking
def combine_definitions([single_def]) do
single_def
end
def combine_definitions([
%{
version: v1,
dialect: d1,
enums: e1,
messages: m1
},
%{
version: v2,
dialect: d2,
enums: e2,
messages: m2
} | more_definitions]) do
combine_definitions([
%{
version: max(v1, v2), # strings > nil
dialect: max(d1, d2),
enums: merge_enums(e1, e2),
messages: sort_by(m1 ++ m2, & &1.id)
} | more_definitions])
end
def merge_enums(as, bs) do
a_index = into(as, %{}, fn (enum) -> {enum.name, enum} end)
b_index = into(bs, %{}, fn (enum) -> {enum.name, enum} end)
only_in_a = for name <- filter(Map.keys(a_index), & !Map.has_key?(b_index, &1)), do: a_index[name]
only_in_b = for name <- filter(Map.keys(b_index), & !Map.has_key?(a_index, &1)), do: b_index[name]
in_a_and_b = for name <- filter(Map.keys(a_index), & Map.has_key?(b_index, &1)) do
%{a_index[name] | entries: sort_by(a_index[name].entries ++ b_index[name].entries, & &1.value)}
end
sort_by(only_in_a ++ in_a_and_b ++ only_in_b, & &1.name)
end
@type enum_description :: %{
name: atom,
description: String.t,
entries: [ entry_description ]
}
@spec parse_enum(tuple) :: enum_description
defp parse_enum(element) do
%{
name: :xmerl_xpath.string('@name', element) |> extract_text |> downcase |> to_atom,
description: :xmerl_xpath.string('/enum/description/text()', element) |> extract_text |> nil_to_empty_string,
entries: (for entry <- :xmerl_xpath.string('/enum/entry', element), do: parse_entry(entry))
}
end
@type entry_description :: %{
value: integer | nil,
name: atom,
description: String.t,
params: [ param_description ]
}
@spec parse_entry(tuple) :: entry_description
defp parse_entry(element) do
value_attr = :xmerl_xpath.string('@value', element) # Apparently optional in common.xml?
%{
value: (if not empty?(value_attr), do: extract_text(value_attr) |> to_integer, else: nil),
name: :xmerl_xpath.string('@name', element) |> extract_text |> downcase |> to_atom,
description: :xmerl_xpath.string('/entry/description/text()', element) |> extract_text |> nil_to_empty_string,
params: (for param <- :xmerl_xpath.string('/entry/param', element), do: parse_param(param))
}
end
@type param_description :: %{
index: integer,
description: String.t
}
@spec parse_param(tuple) :: param_description
defp parse_param(element) do
%{
index: :xmerl_xpath.string('@index', element) |> extract_text |> to_integer,
description: :xmerl_xpath.string('/param/text()', element) |> extract_text,
}
end
@type message_description :: %{
id: integer,
name: String.t,
description: String.t,
has_ext_fields: boolean,
fields: [ field_description ]
}
@spec parse_message(tuple, String.t) :: message_description
defp parse_message(element, version) do
message_description = reduce(
xmlElement(element, :content),
%{
id: :xmerl_xpath.string('@id', element) |> extract_text |> to_integer,
name: :xmerl_xpath.string('@name', element) |> extract_text,
description: :xmerl_xpath.string('/message/description/text()', element) |> extract_text,
has_ext_fields: false,
fields: []
},
fn (next_child, acc) ->
case xmlElement(next_child, :name) do
:field ->
%{acc | fields: [parse_field(next_child, version, acc.has_ext_fields) | acc.fields]}
:extensions ->
%{acc | has_ext_fields: true}
_ ->
acc
end
end)
%{message_description | fields: reverse(message_description.fields)}
end
@type field_description :: %{
type: String.t,
ordinality: integer,
omit_arg: boolean,
is_extension: boolean,
constant_val: String.t | nil,
name: String.t,
enum: String.t,
display: :bitmask | nil,
print_format: String.t | nil,
units: atom | nil,
description: String.t
}
@spec parse_field(tuple, binary(), boolean) :: field_description
defp parse_field(element, version, is_extension_field) do
{type, ordinality, omit_arg, constant_val} =
:xmerl_xpath.string('@type', element)
|> extract_text
|> parse_type_ordinality_omit_arg_constant_val(version)
%{
type: type,
ordinality: ordinality,
omit_arg: omit_arg,
is_extension: is_extension_field,
constant_val: constant_val,
name: :xmerl_xpath.string('@name', element) |> extract_text, # You can't downcase this, wrecks crc_extra calc for POWER_STATUS
enum: :xmerl_xpath.string('@enum', element) |> extract_text |> nil_to_empty_string |> downcase,
display: :xmerl_xpath.string('@display', element) |> extract_text |> to_atom_or_nil,
print_format: :xmerl_xpath.string('@print_format', element) |> extract_text,
units: :xmerl_xpath.string('@units', element) |> extract_text |> to_atom_or_nil,
description: :xmerl_xpath.string('/field/text()', element) |> extract_text |> nil_to_empty_string
}
end
@spec parse_type_ordinality_omit_arg_constant_val(String.t, String.t) :: {String.t, integer, boolean, String.t | nil}
defp parse_type_ordinality_omit_arg_constant_val(type_string, version) do
[type | ordinality] = type_string
|> split(["[", "]"], trim: true)
case type do
"uint8_t_mavlink_version" ->
{"uint8_t", 1, true, version}
_ ->
{
type,
cond do
ordinality |> empty? ->
1
true ->
ordinality |> first |> to_integer
end,
false,
nil
}
end
end
# TODO Can't spec this without causing dialyzer "nil can't match binary" - Erlang types?
defp extract_text([xml]), do: extract_text(xml)
defp extract_text(xmlText(value: value)), do: clean_string(value)
defp extract_text(xmlAttribute(value: value)), do: clean_string(value)
defp extract_text(_), do: nil
@spec clean_string([ char ] | binary) :: String.t
defp clean_string(s) do
trimmed = s |> List.to_string |> String.trim |> String.replace("\"", "'")
replace(~r/\s+/, trimmed, " ")
end
@spec nil_to_empty_string(String.t | nil) :: String.t
defp nil_to_empty_string(nil), do: ""
defp nil_to_empty_string(value) when is_binary(value), do: value
@spec nil_to_zero_string(String.t | nil) :: String.t
defp nil_to_zero_string(nil), do: "0"
defp nil_to_zero_string(value) when is_binary(value), do: value
@spec to_atom_or_nil(String.t | nil) :: atom | nil
defp to_atom_or_nil(nil), do: nil
defp to_atom_or_nil(""), do: nil
defp to_atom_or_nil(value) when is_binary(value), do: to_atom(value)
end