defmodule StructuredIO do
@moduledoc """
A process for performing I/O of structured data, such as markup or
binary-encoded data.
## Encoding
The process operates in either **binary mode** or **Unicode mode** (see
`#{inspect __MODULE__}.start/2` and `#{inspect __MODULE__}.start_link/2`).
When in binary mode, the result of a read operation is a binary, regardless of
whether the data read is `String.valid?/1`. In Unicode mode, the result of a
read operation is an `t:error/0` if the data read is not properly encoded
Unicode data.
"""
defmodule State do
@moduledoc false
defstruct data: [], mode: nil
@typedoc false
@type t :: %__MODULE__{data: iodata | IO.chardata | String.Chars.t,
mode: StructuredIO.mode}
end
use GenServer
alias StructuredIO.{Collector,Deprecated,Enumerator,Scanner}
@typedoc """
An error result.
"""
@type error :: {:error, atom | binary}
@typedoc """
A binary value which marks the beginning of an enclosed data element.
See `#{inspect __MODULE__}.read_across/3` and
`#{inspect __MODULE__}.read_between/3`.
"""
@type left :: Scanner.left
@typedoc """
The portion of a binary value matched in a read operation.
See `#{inspect __MODULE__}.read_across/3`,
`#{inspect __MODULE__}.read_between/3`,
`#{inspect __MODULE__}.read_through/2`, and `#{inspect __MODULE__}.read_to/2`.
"""
@type match :: Scanner.match
@typedoc """
A mode of operation for the process: either binary or Unicode.
See `#{inspect __MODULE__}.start/2` and `#{inspect __MODULE__}.start_link/2`.
"""
@type mode :: :binary | :unicode
@valid_modes [:binary, :unicode]
@typedoc """
A binary value which marks the end of an enclosed or terminated data element.
See `#{inspect __MODULE__}.read_across/3`,
`#{inspect __MODULE__}.read_between/3`,
`#{inspect __MODULE__}.read_through/2`, and `#{inspect __MODULE__}.read_to/2`.
"""
@type right :: Scanner.right
@doc false
defdelegate binread_across(structured_io,
left,
right,
timeout \\ 5000), to: Deprecated
@doc false
defdelegate binread_between(structured_io,
left,
right,
timeout \\ 5000), to: Deprecated
@doc false
defdelegate binread_through(structured_io,
right,
timeout \\ 5000), to: Deprecated
@doc false
defdelegate binread_to(structured_io, to, timeout \\ 5000), to: Deprecated
@doc false
defdelegate binwrite(structured_io, iodata), to: Deprecated
@doc """
Returns a value that can be passed to `Enum.into/2` or `Enum.into/3` for
writing data to the specified `structured_io`.
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:unicode)
iex> StructuredIO.read_between structured_io,
...> "",
...> ""
""
iex> collector = StructuredIO.collect(structured_io)
iex> ["foo",
...> "bar",
...> "baz"]
iex> |> Enum.into(collector)
iex> StructuredIO.read_between structured_io,
...> "",
...> ""
"foo"
iex> StructuredIO.read_between structured_io,
...> "",
...> ""
"bar"
iex> StructuredIO.read_between structured_io,
...> "",
...> ""
"baz"
iex> StructuredIO.read_between structured_io,
...> "",
...> ""
""
"""
@spec collect(GenServer.server) :: Collector.t
def collect(structured_io) do
{:ok, collector} = Collector.new(%{process: structured_io,
function: :write})
collector
end
@doc """
Returns a value that can be passed to functions such as `Enum.map/2` for
reading data elements from the specified `structured_io`, using the specified
`#{inspect __MODULE__}` `function`, and the specified `left` and/or `right`.
Note that enumeration is not a purely functional operation; it consumes data
elements from the underlying `#{inspect __MODULE__}` process.
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:unicode)
iex> StructuredIO.write structured_io,
...> "foo"
:ok
iex> StructuredIO.write structured_io,
...> "bar"
:ok
iex> StructuredIO.write structured_io,
...> "baz"
:ok
iex> structured_io
...> |> StructuredIO.enumerate_with(:read_between,
...> "",
...> "")
...> |> Enum.map(&String.upcase/1)
["FOO",
"BAR",
"BAZ"]
iex> StructuredIO.read_between structured_io,
...> "",
...> ""
""
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:unicode)
iex> StructuredIO.write structured_io,
...> "foo
"
:ok
iex> StructuredIO.write structured_io,
...> "bar
"
:ok
iex> StructuredIO.write structured_io,
...> "baz
"
:ok
iex> structured_io
...> |> StructuredIO.enumerate_with(:read_through,
...> "
")
...> |> Enum.map(&String.upcase/1)
["FOO
",
"BAR
",
"BAZ
"]
iex> StructuredIO.read_between structured_io,
...> "",
...> ""
""
"""
@spec enumerate_with(GenServer.server,
:read_across | :read_between,
left,
right) :: Enumerator.t
def enumerate_with(structured_io,
function,
left,
right) when function in [:read_across, :read_between] do
{:ok, enumerator} = Enumerator.new(%{process: structured_io,
function: function,
additional_arguments: [left, right]})
enumerator
end
@spec enumerate_with(GenServer.server,
:read_through | :read_to,
right) :: Enumerator.t
def enumerate_with(structured_io,
function,
right) when function in [:read_through, :read_to] do
{:ok, enumerator} = Enumerator.new(%{process: structured_io,
function: function,
additional_arguments: [right]})
enumerator
end
@doc """
Gets the mode of the specified `structured_io`.
## Examples
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:binary)
iex> StructuredIO.mode structured_io
:binary
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:unicode)
iex> StructuredIO.mode structured_io
:unicode
"""
@spec mode(GenServer.server) :: mode
def mode(structured_io) do
request = :mode
GenServer.call structured_io, request
end
@doc """
Reads data from the specified `structured_io` beginning with the specified
`left` and ending with the specified `right`, inclusive, using the specified
`timeout` (defaults to 5,000 milliseconds).
If the data read does not begin with `left`, the result is an empty binary
(`""`). Likewise, if `right` is not encountered, the result is an empty
binary.
## Examples
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:binary)
iex> StructuredIO.write structured_io,
...> <<0, 0, 0, 1, 2, 3, 255, 255>>
:ok
iex> StructuredIO.read_across structured_io,
...> <<0, 0, 0>>,
...> <<255, 255, 255>>
""
iex> StructuredIO.write structured_io,
...> <<255, 0, 0, 0, 4, 5, 6, 255, 255, 255>>
:ok
iex> StructuredIO.read_across structured_io,
...> <<0, 0, 0>>,
...> <<255, 255, 255>>
<<0, 0, 0, 1, 2, 3, 255, 255, 255>>
iex> StructuredIO.read_across structured_io,
...> <<0, 0, 0>>,
...> <<255, 255, 255>>
<<0, 0, 0, 4, 5, 6, 255, 255, 255>>
iex> StructuredIO.read_across structured_io,
...> <<0, 0, 0>>,
...> <<255, 255, 255>>
""
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:unicode)
iex> StructuredIO.write structured_io,
...> "foo StructuredIO.read_across structured_io,
...> "",
...> ""
""
iex> StructuredIO.write structured_io,
...> ">bar"
:ok
iex> StructuredIO.read_across structured_io,
...> "",
...> ""
"foo"
iex> StructuredIO.read_across structured_io,
...> "",
...> ""
"bar"
iex> StructuredIO.read_across structured_io,
...> "",
...> ""
""
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:binary)
iex> StructuredIO.write structured_io,
...> ""
:ok
iex> <> = "π"
iex> StructuredIO.write structured_io,
...> fragment1
:ok
iex> StructuredIO.read_across structured_io,
...> "",
...> ""
""
iex> StructuredIO.write structured_io,
...> fragment2
:ok
iex> StructuredIO.write structured_io,
...> ""
:ok
iex> StructuredIO.read_across structured_io,
...> "",
...> ""
"π"
iex> StructuredIO.read_across structured_io,
...> "",
...> ""
""
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:unicode)
iex> StructuredIO.write structured_io,
...> ""
:ok
iex> <> = "π"
iex> StructuredIO.write structured_io,
...> fragment1
:ok
iex> StructuredIO.read_across structured_io,
...> "",
...> ""
{:error,
"UnicodeConversionError: incomplete encoding starting at \#{inspect fragment1}"}
iex> StructuredIO.write structured_io,
...> fragment2
:ok
iex> StructuredIO.write structured_io,
...> ""
:ok
iex> StructuredIO.read_across structured_io,
...> "",
...> ""
"π"
iex> StructuredIO.read_across structured_io,
...> "",
...> ""
""
"""
@spec read_across(GenServer.server, left, right) :: match | error
@spec read_across(GenServer.server, left, right, timeout) :: match | error
def read_across(structured_io, left, right, timeout \\ 5000) do
request = {:read_across, left, right}
structured_io
|> GenServer.call(request, timeout)
|> convert_if_error
end
@doc """
Reads data from the specified `structured_io` beginning with the specified
`left` and ending with the specified `right`, exclusive, using the specified
`timeout` (defaults to 5,000 milliseconds).
If the data read does not begin with `left`, the result is an empty binary
(`""`). Likewise, if `right` is not encountered, the result is an empty
binary.
## Examples
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:binary)
iex> StructuredIO.write structured_io,
...> <<0, 0, 0, 1, 2, 3, 255, 255>>
:ok
iex> StructuredIO.read_between structured_io,
...> <<0, 0, 0>>,
...> <<255, 255, 255>>
""
iex> StructuredIO.write structured_io,
...> <<255, 0, 0, 0, 4, 5, 6, 255, 255, 255>>
:ok
iex> StructuredIO.read_between structured_io,
...> <<0, 0, 0>>,
...> <<255, 255, 255>>
<<1, 2, 3>>
iex> StructuredIO.read_between structured_io,
...> <<0, 0, 0>>,
...> <<255, 255, 255>>
<<4, 5, 6>>
iex> StructuredIO.read_between structured_io,
...> <<0, 0, 0>>,
...> <<255, 255, 255>>
""
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:unicode)
iex> StructuredIO.write structured_io,
...> "foo StructuredIO.read_between structured_io,
...> "",
...> ""
""
iex> StructuredIO.write structured_io,
...> ">bar"
:ok
iex> StructuredIO.read_between structured_io,
...> "",
...> ""
"foo"
iex> StructuredIO.read_between structured_io,
...> "",
...> ""
"bar"
iex> StructuredIO.read_between structured_io,
...> "",
...> ""
""
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:binary)
iex> StructuredIO.write structured_io,
...> ""
:ok
iex> <> = "π"
iex> StructuredIO.write structured_io,
...> fragment1
:ok
iex> StructuredIO.read_between structured_io,
...> "",
...> ""
""
iex> StructuredIO.write structured_io,
...> fragment2
:ok
iex> StructuredIO.write structured_io,
...> ""
:ok
iex> StructuredIO.read_between structured_io,
...> "",
...> ""
"π"
iex> StructuredIO.read_between structured_io,
...> "",
...> ""
""
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:unicode)
iex> StructuredIO.write structured_io,
...> ""
:ok
iex> <> = "π"
iex> StructuredIO.write structured_io,
...> fragment1
:ok
iex> StructuredIO.read_between structured_io,
...> "",
...> ""
{:error,
"UnicodeConversionError: incomplete encoding starting at \#{inspect fragment1}"}
iex> StructuredIO.write structured_io,
...> fragment2
:ok
iex> StructuredIO.write structured_io,
...> ""
:ok
iex> StructuredIO.read_between structured_io,
...> "",
...> ""
"π"
iex> StructuredIO.read_between structured_io,
...> "",
...> ""
""
"""
@spec read_between(GenServer.server, left, right) :: match | error
@spec read_between(GenServer.server, left, right, timeout) :: match | error
def read_between(structured_io, left, right, timeout \\ 5000) do
request = {:read_between, left, right}
structured_io
|> GenServer.call(request, timeout)
|> convert_if_error
end
@doc """
Reads data from the specified `structured_io` if and until the specified
`right` is encountered, including `right`, using the specified `timeout`
(defaults to 5,000 milliseconds).
If `right` is not encountered, the result is an empty binary (`""`).
## Examples
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:binary)
iex> StructuredIO.write structured_io,
...> <<1, 2, 3, 255, 255>>
:ok
iex> StructuredIO.read_through structured_io,
...> <<255, 255, 255>>
""
iex> StructuredIO.write structured_io,
...> <<255, 4, 5, 6, 255, 255, 255>>
:ok
iex> StructuredIO.read_through structured_io,
...> <<255, 255, 255>>
<<1, 2, 3, 255, 255, 255>>
iex> StructuredIO.read_through structured_io,
...> <<255, 255, 255>>
<<4, 5, 6, 255, 255, 255>>
iex> StructuredIO.read_through structured_io,
...> <<255, 255, 255>>
""
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:unicode)
iex> StructuredIO.write structured_io,
...> "foo
StructuredIO.read_through structured_io,
...> "
"
""
iex> StructuredIO.write structured_io,
...> ">bar
"
:ok
iex> StructuredIO.read_through structured_io,
...> "
"
"foo
"
iex> StructuredIO.read_through structured_io,
...> "
"
"bar
"
iex> StructuredIO.read_through structured_io,
...> "
"
""
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:binary)
iex> <> = "π"
iex> StructuredIO.write structured_io,
...> fragment1
:ok
iex> StructuredIO.read_through structured_io,
...> "
"
""
iex> StructuredIO.write structured_io,
...> fragment2
:ok
iex> StructuredIO.write structured_io,
...> "
"
:ok
iex> StructuredIO.read_through structured_io,
...> "
"
"π
"
iex> StructuredIO.read_through structured_io,
...> "
"
""
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:unicode)
iex> <> = "π"
iex> StructuredIO.write structured_io,
...> fragment1
:ok
iex> StructuredIO.read_through structured_io,
...> "
"
{:error,
"UnicodeConversionError: incomplete encoding starting at \#{inspect fragment1}"}
iex> StructuredIO.write structured_io,
...> fragment2
:ok
iex> StructuredIO.write structured_io,
...> "
"
:ok
iex> StructuredIO.read_through structured_io,
...> "
"
"π
"
iex> StructuredIO.read_through structured_io,
...> "
"
""
"""
@spec read_through(GenServer.server, right) :: match | error
@spec read_through(GenServer.server, right, timeout) :: match | error
def read_through(structured_io, right, timeout \\ 5000) do
request = {:read_through, right}
structured_io
|> GenServer.call(request, timeout)
|> convert_if_error
end
@doc """
Reads data from the specified `structured_io` if and until the specified
`right` is encountered, excluding `right`, using the specified `timeout`
(defaults to 5,000 milliseconds).
If `right` is not encountered, the result is an empty binary (`""`).
## Examples
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:binary)
iex> StructuredIO.write structured_io,
...> <<1, 2, 3, 255, 255>>
:ok
iex> StructuredIO.read_to structured_io,
...> <<255, 255, 255>>
""
iex> StructuredIO.write structured_io,
...> <<255, 4, 5, 6, 255, 255, 255>>
:ok
iex> StructuredIO.read_to structured_io,
...> <<255, 255, 255>>
<<1, 2, 3>>
iex> StructuredIO.read_through structured_io,
...> <<255, 255, 255>>
<<255, 255, 255>>
iex> StructuredIO.read_to structured_io,
...> <<255, 255, 255>>
<<4, 5, 6>>
iex> StructuredIO.read_to structured_io,
...> <<255, 255, 255>>
""
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:unicode)
iex> StructuredIO.write structured_io,
...> "foo
StructuredIO.read_to structured_io,
...> "
"
""
iex> StructuredIO.write structured_io,
...> ">bar
"
:ok
iex> StructuredIO.read_to structured_io,
...> "
"
"foo"
iex> StructuredIO.read_through structured_io,
...> "
"
"
"
iex> StructuredIO.read_to structured_io,
...> "
"
"bar"
iex> StructuredIO.read_to structured_io,
...> "
"
""
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:binary)
iex> <> = "π"
iex> StructuredIO.write structured_io,
...> fragment1
:ok
iex> StructuredIO.read_to structured_io,
...> "
"
""
iex> StructuredIO.write structured_io,
...> fragment2
:ok
iex> StructuredIO.write structured_io,
...> "
"
:ok
iex> StructuredIO.read_to structured_io,
...> "
"
"π"
iex> StructuredIO.read_to structured_io,
...> "
"
""
iex> {:ok,
...> structured_io} = StructuredIO.start_link(:unicode)
iex> <> = "π"
iex> StructuredIO.write structured_io,
...> fragment1
:ok
iex> StructuredIO.read_to structured_io,
...> "
"
{:error,
"UnicodeConversionError: incomplete encoding starting at \#{inspect fragment1}"}
iex> StructuredIO.write structured_io,
...> fragment2
:ok
iex> StructuredIO.write structured_io,
...> "
"
:ok
iex> StructuredIO.read_to structured_io,
...> "
"
"π"
iex> StructuredIO.read_to structured_io,
...> "
"
""
"""
@spec read_to(GenServer.server, right) :: match | error
@spec read_to(GenServer.server, right, timeout) :: match | error
def read_to(structured_io, right, timeout \\ 5000) do
request = {:read_to, right}
structured_io
|> GenServer.call(request, timeout)
|> convert_if_error
end
@doc false
defdelegate start, to: Deprecated
@doc """
Starts a `#{inspect __MODULE__}` process without links (outside a supervision
tree) with the specified `mode` and `options`.
## Examples
iex> StructuredIO.start :super_pursuit_mode
{:error,
"invalid mode :super_pursuit_mode"}
See `#{inspect __MODULE__}.start_link/2`.
"""
@spec start(mode) :: GenServer.on_start
@spec start(mode, GenServer.options) :: GenServer.on_start
def start(mode, options \\ []) do
with {:ok, mode} <- compute_mode(mode, :start) do
GenServer.start __MODULE__, %State{mode: mode}, options
end
end
@doc false
defdelegate start_link, to: Deprecated
@doc """
Starts a `#{inspect __MODULE__}` process linked to the current process with
the specified `mode` and `options`.
## Examples
iex> StructuredIO.start_link :super_pursuit_mode
{:error,
"invalid mode :super_pursuit_mode"}
See `#{inspect __MODULE__}.mode/1`, `#{inspect __MODULE__}.read_across/3`,
`#{inspect __MODULE__}.read_between/3`,
`#{inspect __MODULE__}.read_through/2`, and `#{inspect __MODULE__}.read_to/2`
for more examples.
"""
@spec start_link(mode) :: GenServer.on_start
@spec start_link(mode, GenServer.options) :: GenServer.on_start
def start_link(mode, options \\ []) do
with {:ok, mode} <- compute_mode(mode, :start_link) do
GenServer.start_link __MODULE__, %State{mode: mode}, options
end
end
@doc """
Synchronously stops the specified `structured_io` process with the specified
`reason` (defaults to `:normal`) and `timeout` (defaults to infinity).
"""
@spec stop(GenServer.server) :: :ok
@spec stop(GenServer.server, term) :: :ok
@spec stop(GenServer.server, term, timeout) :: :ok
def stop(structured_io, reason \\ :normal, timeout \\ :infinity) do
GenServer.stop structured_io, reason, timeout
end
@doc """
Asynchronously writes the specified `data` as a binary to the specified
`structured_io`.
See `#{inspect __MODULE__}.read_across/3`,
`#{inspect __MODULE__}.read_between/3`,
`#{inspect __MODULE__}.read_through/2`, and `#{inspect __MODULE__}.read_to/2`
for examples.
"""
@spec write(GenServer.server,
iodata | IO.chardata | String.Chars.t) :: :ok | error
def write(structured_io, data) do
request = {:deprecated_write, data}
GenServer.cast structured_io, request
end
# Callbacks
def handle_call(:mode, _from, %{mode: mode}=state), do: {:reply, mode, state}
def handle_call({:read_across, left, right}, _from, state) do
case binary_data(state) do
{:error, _}=error ->
{:reply, error, state}
{:ok, binary} ->
binary
|> Scanner.scan_across(left, right)
|> read_reply(state)
end
end
def handle_call({:read_between, left, right}, _from, state) do
case binary_data(state) do
{:error, _}=error ->
{:reply, error, state}
{:ok, binary} ->
binary
|> Scanner.scan_between(left, right)
|> read_reply(state)
end
end
def handle_call({:read_through, right}, _from, state) do
case binary_data(state) do
{:error, _}=error ->
{:reply, error, state}
{:ok, binary} ->
binary
|> Scanner.scan_through(right)
|> read_reply(state)
end
end
def handle_call({:read_to, read_to}, _from, state) do
case binary_data(state) do
{:error, _}=error ->
{:reply, error, state}
{:ok, binary} ->
binary
|> Scanner.scan_to(read_to)
|> read_reply(state)
end
end
@doc false
defdelegate handle_call(request, from, state), to: Deprecated
# # TODO: Add a handler for the :write message when the :deprecated_write message is eliminated
# def handle_cast({:write, new_data}, %{data: data}=state) do
# new_state = %{state | data: [data, new_data]}
# {:noreply, new_state}
# end
@doc false
defdelegate handle_cast(request, state), to: Deprecated
def init(args), do: {:ok, args}
@spec binary_data(State.t) :: {:ok, binary} | error
# # TODO: Donβt handle `nil` :mode when deprecated `.start*` usage is eliminated
defp binary_data(%{data: iodata, mode: nil}) do
{:ok, IO.iodata_to_binary(iodata)}
end
defp binary_data(%{data: iodata, mode: :binary}) do
{:ok, IO.iodata_to_binary(iodata)}
end
defp binary_data(%{data: chardata, mode: :unicode}) do
try do
IO.chardata_to_string chardata
rescue
e in UnicodeConversionError -> {:error, e}
else
string ->
{:ok, string}
end
end
@spec compute_mode(mode, atom) :: {:ok, mode} | error
defp compute_mode(mode, _) when mode in @valid_modes, do: {:ok, mode}
defp compute_mode(mode, _), do: {:error, "invalid mode #{inspect mode}"}
@spec convert_if_error(any) :: error | any
defp convert_if_error({:error, error}) do
if Exception.exception?(error) do
type = error
|> Map.fetch!(:__struct__)
|> inspect
{:error, "#{type}: #{error.message}"}
else
if is_atom(error) do
{:error, error}
else
{:error, to_string(error)}
end
end
end
defp convert_if_error(other), do: other
@spec read_reply(nil | {Scanner.match, Scanner.remainder},
State.t) :: {:reply, Scanner.match, State.t}
defp read_reply(nil, state), do: {:reply, "", state}
defp read_reply({match, remainder}, state) do
new_state = %{state | data: remainder}
{:reply, match, new_state}
end
end