Packages

Canvas 2D API for Elixir. Build draw operations as data, render to Phoenix LiveView, native wx windows, terminal (experimental), or your own backend.

Current section

Files

Jump to
easel lib web_idl.ex
Raw

lib/web_idl.ex

defmodule Easel.WebIDL do
@moduledoc """
A WebIDL parser for Canvas2D using NimbleParsec.
Parses interface/mixin declarations, extracting attributes and operations
with their arguments, types, optionality, and default values.
"""
import NimbleParsec
# ── Whitespace & comments ──────────────────────────────────────────
whitespace_char = ascii_char([?\s, ?\t, ?\n, ?\r])
line_comment =
string("//")
|> repeat(ascii_char(not: ?\n))
block_comment =
string("/*")
|> repeat(
lookahead_not(string("*/"))
|> ascii_char([])
)
|> string("*/")
skip =
choice([whitespace_char, line_comment, block_comment])
|> times(min: 1)
|> ignore()
optional_skip = optional(skip)
# ── Identifiers & basic tokens ─────────────────────────────────────
identifier =
ascii_char([?a..?z, ?A..?Z, ?_])
|> repeat(ascii_char([?a..?z, ?A..?Z, ?0..?9, ?_]))
|> reduce({List, :to_string, []})
# ── Types (we capture as a flat string, don't need to deeply parse) ──
# A type can be complex: `(A or B)`, `sequence<T>`, `unrestricted double`, `Type?`
# We just capture everything as a string token for our purposes.
# Balanced content inside parens or angle brackets
paren_content =
repeat(
choice([
string("(")
|> concat(parsec(:paren_inner))
|> string(")"),
string("<")
|> concat(parsec(:angle_inner))
|> string(">"),
ascii_char([{:not, ?\)}, {:not, ?\(}, {:not, ?>}, {:not, ?<}])
])
)
angle_content =
repeat(
choice([
string("(")
|> concat(parsec(:paren_inner))
|> string(")"),
string("<")
|> concat(parsec(:angle_inner))
|> string(">"),
ascii_char([{:not, ?>}, {:not, ?<}, {:not, ?\(}, {:not, ?\)}])
])
)
defcombinatorp(:paren_inner, paren_content)
defcombinatorp(:angle_inner, angle_content)
# A type expression — reads tokens until we hit an identifier that would be an arg name
# We'll handle this differently: parse type as part of argument/attribute parsing
# Type: sequence of type keywords. We'll collect chars up to a boundary.
# For arguments: type ends at the argument name (last identifier before , or = or ))
# For attributes: type ends at the attribute name (last identifier before ;)
# Simplest approach: capture a "type token" which is one or more of:
# - identifiers (including `unrestricted`, `unsigned`, `long`, etc.)
# - `?` suffix
# - `<...>` generic
# - `(... or ...)` union
type_union =
ignore(string("("))
|> concat(optional_skip)
|> concat(parsec(:type_expr))
|> repeat(
optional_skip
|> ignore(string("or"))
|> concat(optional_skip)
|> concat(parsec(:type_expr))
)
|> concat(optional_skip)
|> ignore(string(")"))
|> reduce(:join_type)
type_sequence =
string("sequence")
|> ignore(string("<"))
|> concat(optional_skip)
|> concat(parsec(:type_expr))
|> concat(optional_skip)
|> ignore(string(">"))
|> reduce(:join_type)
type_simple =
optional(string("unrestricted") |> concat(skip))
|> optional(string("unsigned") |> concat(skip))
|> concat(identifier)
|> optional(string("?"))
|> reduce(:join_type)
type_expr =
choice([
type_union,
type_sequence,
type_simple
])
defcombinatorp(:type_expr, type_expr)
defp join_type(parts) do
parts
|> List.flatten()
|> Enum.map(fn
i when is_integer(i) -> <<i>>
s -> s
end)
|> Enum.join(" ")
|> String.replace(~r/\s+/, " ")
|> String.trim()
end
# ── Default values ─────────────────────────────────────────────────
string_default =
ignore(ascii_char([?"]))
|> repeat(ascii_char(not: ?"))
|> ignore(ascii_char([?"]))
|> reduce({List, :to_string, []})
|> unwrap_and_tag(:string)
number_default =
optional(string("-"))
|> choice([
string("0x")
|> repeat(ascii_char([?0..?9, ?a..?f, ?A..?F]))
|> reduce({List, :to_string, []}),
repeat(ascii_char([?0..?9, ?.]))
|> reduce({List, :to_string, []})
])
|> reduce(:parse_number)
|> unwrap_and_tag(:number)
bool_default =
choice([
string("true") |> replace(true),
string("false") |> replace(false)
])
|> unwrap_and_tag(:boolean)
null_default = string("null") |> replace(nil) |> unwrap_and_tag(:null)
dict_default = string("{}") |> replace(%{}) |> unwrap_and_tag(:dictionary)
default_value =
choice([
string_default,
bool_default,
null_default,
dict_default,
number_default
])
defp parse_number(parts) do
str =
parts
|> List.flatten()
|> Enum.map(fn
i when is_integer(i) -> <<i>>
s -> s
end)
|> Enum.join()
cond do
String.starts_with?(str, "0x") or String.starts_with?(str, "-0x") ->
{n, ""} = Integer.parse(str, 16)
n
String.contains?(str, ".") ->
{f, ""} = Float.parse(str)
f
true ->
{n, ""} = Integer.parse(str)
n
end
end
# ── Extended attributes (we skip them) ─────────────────────────────
ext_attrs =
ignore(
string("[")
|> concat(parsec(:paren_inner_bracket))
|> string("]")
)
bracket_content =
repeat(
choice([
string("[")
|> concat(parsec(:paren_inner_bracket))
|> string("]"),
string("\"")
|> repeat(ascii_char([{:not, ?"}]))
|> string("\""),
ascii_char([{:not, ?\]}, {:not, ?\[}, {:not, ?"}])
])
)
defcombinatorp(:paren_inner_bracket, bracket_content)
# ── Arguments ──────────────────────────────────────────────────────
single_argument =
optional(ext_attrs |> concat(optional_skip))
|> optional(
string("optional")
|> concat(skip)
|> replace(true)
|> unwrap_and_tag(:optional)
)
|> concat(type_expr |> unwrap_and_tag(:type))
|> concat(skip)
|> concat(identifier |> unwrap_and_tag(:name))
|> optional(
optional_skip
|> ignore(string("="))
|> concat(optional_skip)
|> concat(default_value)
|> unwrap_and_tag(:default)
)
|> reduce(:build_argument)
argument_list =
optional(
single_argument
|> repeat(
optional_skip
|> ignore(string(","))
|> concat(optional_skip)
|> concat(single_argument)
)
)
defp build_argument(parts) do
parts = Map.new(parts)
%{
"name" => parts[:name],
"optional" => Map.get(parts, :optional, false),
"idlType" => %{"idlType" => parts[:type]},
"default" => build_default(parts[:default])
}
end
defp build_default(nil), do: nil
defp build_default({:string, v}), do: %{"type" => "string", "value" => v}
defp build_default({:boolean, v}), do: %{"type" => "boolean", "value" => v}
defp build_default({:null, _}), do: %{"type" => "null"}
defp build_default({:dictionary, _}), do: %{"type" => "dictionary"}
defp build_default({:number, v}), do: %{"type" => "number", "value" => v}
# ── Members ────────────────────────────────────────────────────────
const_member =
optional(ext_attrs |> concat(optional_skip))
|> ignore(string("const"))
|> repeat(ascii_char(not: ?;))
|> ignore(string(";"))
|> replace(:skip)
readonly_attribute =
optional(ext_attrs |> concat(optional_skip))
|> ignore(string("readonly"))
|> concat(skip)
|> ignore(string("attribute"))
|> concat(skip)
|> concat(type_expr |> unwrap_and_tag(:type))
|> concat(skip)
|> concat(identifier |> unwrap_and_tag(:name))
|> concat(optional_skip)
|> ignore(string(";"))
|> reduce(:build_readonly_attribute)
writable_attribute =
optional(ext_attrs |> concat(optional_skip))
|> ignore(string("attribute"))
|> concat(skip)
|> concat(type_expr |> unwrap_and_tag(:type))
|> concat(skip)
|> concat(identifier |> unwrap_and_tag(:name))
|> concat(optional_skip)
|> ignore(string(";"))
|> reduce(:build_writable_attribute)
operation =
optional(ext_attrs |> concat(optional_skip))
|> concat(type_expr |> unwrap_and_tag(:return_type))
|> concat(optional_skip)
|> concat(identifier |> unwrap_and_tag(:name))
|> concat(optional_skip)
|> ignore(string("("))
|> concat(optional_skip)
|> tag(argument_list, :arguments)
|> concat(optional_skip)
|> ignore(string(")"))
|> concat(optional_skip)
|> ignore(string(";"))
|> reduce(:build_operation)
defp build_readonly_attribute(parts) do
parts = Map.new(parts)
%{
"type" => "attribute",
"name" => parts[:name],
"readonly" => true,
"idlType" => %{"idlType" => parts[:type]}
}
end
defp build_writable_attribute(parts) do
parts = Map.new(parts)
%{
"type" => "attribute",
"name" => parts[:name],
"readonly" => false,
"idlType" => %{"idlType" => parts[:type]}
}
end
defp build_operation(parts) do
parts = Map.new(parts)
%{
"type" => "operation",
"name" => parts[:name],
"returnType" => %{"idlType" => parts[:return_type]},
"arguments" => parts[:arguments]
}
end
member =
choice([
const_member,
readonly_attribute,
writable_attribute,
operation
])
members =
repeat(
optional_skip
|> concat(member)
)
# ── Interface / interface mixin ────────────────────────────────────
interface_mixin =
optional(ext_attrs |> concat(optional_skip))
|> ignore(string("interface"))
|> concat(skip)
|> ignore(string("mixin"))
|> concat(skip)
|> concat(identifier |> unwrap_and_tag(:name))
|> concat(optional_skip)
|> ignore(string("{"))
|> tag(members, :members)
|> concat(optional_skip)
|> ignore(string("};"))
|> reduce(:build_interface_mixin)
interface_def =
optional(ext_attrs |> concat(optional_skip))
|> ignore(string("interface"))
|> concat(skip)
|> concat(identifier |> unwrap_and_tag(:name))
|> concat(optional_skip)
|> ignore(string("{"))
|> tag(members, :members)
|> concat(optional_skip)
|> ignore(string("};"))
|> reduce(:build_interface)
defp build_interface_mixin(parts) do
parts = Map.new(parts)
%{
"type" => "interface mixin",
"name" => parts[:name],
"members" => Enum.reject(parts[:members], &(&1 == :skip))
}
end
defp build_interface(parts) do
parts = Map.new(parts)
%{
"type" => "interface",
"name" => parts[:name],
"members" => Enum.reject(parts[:members], &(&1 == :skip))
}
end
# ── Skip non-interface top-level constructs ────────────────────────
# enum Name { ... };
skip_enum =
ignore(
string("enum")
|> repeat(ascii_char(not: ?;))
|> string(";")
)
# typedef ... Name;
skip_typedef =
ignore(
string("typedef")
|> repeat(ascii_char(not: ?;))
|> string(";")
)
# dictionary Name { ... };
skip_dictionary =
ignore(
string("dictionary")
|> repeat(
choice([
string("{")
|> repeat(ascii_char(not: ?}))
|> string("}"),
ascii_char(not: ?;)
])
)
|> string(";")
)
# Name includes Name;
skip_includes =
ignore(
identifier
|> concat(skip)
|> string("includes")
|> repeat(ascii_char(not: ?;))
|> string(";")
)
# ── Top-level document ─────────────────────────────────────────────
top_level =
choice([
interface_mixin,
interface_def,
skip_enum,
skip_typedef,
skip_dictionary,
skip_includes
])
document =
repeat(
optional_skip
|> concat(top_level)
)
|> concat(optional_skip)
|> eos()
defparsec(:parse_document, document)
# ── Public API ─────────────────────────────────────────────────────
@doc """
Parses a WebIDL string and returns a list of interface/mixin definitions.
"""
def parse(source) do
case parse_document(source) do
{:ok, results, "", _, _, _} ->
results
{:ok, _results, rest, _, _, _} ->
raise "WebIDL parse error: unparsed input starting at: #{String.slice(rest, 0, 100)}"
{:error, reason, rest, _, _, _} ->
raise "WebIDL parse error: #{reason} at: #{String.slice(rest, 0, 100)}"
end
end
@doc """
Parses a WebIDL file and returns a flat map of non-const, non-readonly
members grouped by name (preserving overloads).
"""
def members_by_name(source) do
source
|> parse()
|> Enum.filter(fn d -> d["type"] in ["interface", "interface mixin"] end)
|> Enum.flat_map(fn d -> d["members"] end)
|> Enum.reject(fn m ->
m["type"] == "const" ||
(m["type"] == "attribute" && m["readonly"] == true)
end)
|> Enum.group_by(fn m -> m["name"] end)
end
end