add format detector module
This commit is contained in:
1 parent
6913749434
commit
cf34ebbfff
1 file changed
+152
@@ -0,0 +1,152 @@
|
||||
defmodule FeedReader.FormatDetector do
|
||||
@moduledoc "Internal bounded detection and replay for `FeedReader`."
|
||||
|
||||
alias FeedReader.FormatRegistry
|
||||
|
||||
@default_max_detection_bytes 65_536
|
||||
|
||||
@doc "Returns the detected format id, or `:unknown`, from a bounded prefix."
|
||||
@spec detect(binary(), keyword()) :: FeedReader.format_id()
|
||||
def detect(content, opts) when is_binary(content) do
|
||||
{format, _prefix, _replayable} = detect_and_replay(content, opts)
|
||||
format
|
||||
end
|
||||
|
||||
def detect(_content, _opts), do: :unknown
|
||||
|
||||
@doc "Returns the detected id, inspected prefix, and replayable input stream."
|
||||
@spec detect_and_replay(binary() | Enumerable.t(), keyword()) ::
|
||||
{FeedReader.format_id(), binary(), Enumerable.t()}
|
||||
def detect_and_replay(content, opts) when is_binary(content) do
|
||||
max_bytes = max_detection_bytes(opts)
|
||||
prefix_size = min(byte_size(content), max_bytes)
|
||||
prefix = binary_part(content, 0, prefix_size)
|
||||
remainder = binary_part(content, prefix_size, byte_size(content) - prefix_size)
|
||||
|
||||
{detect_prefix(prefix, opts), prefix, Stream.concat([prefix, remainder])}
|
||||
end
|
||||
|
||||
def detect_and_replay(content, opts) do
|
||||
max_bytes = max_detection_bytes(opts)
|
||||
|
||||
if max_bytes == 0 do
|
||||
{:unknown, "", content}
|
||||
else
|
||||
state = %{mode: :detect, chunks: [], size: 0, remainder: ""}
|
||||
reduce_and_replay(content, state, max_bytes, opts)
|
||||
end
|
||||
end
|
||||
|
||||
defp reduce_and_replay(content, state, max_bytes, opts) do
|
||||
case Enumerable.reduce(content, {:cont, state}, fn chunk, state ->
|
||||
consume_chunk(chunk, state, max_bytes)
|
||||
end) do
|
||||
{:suspended, state, continuation} ->
|
||||
prefix = prefix(state)
|
||||
rest = Stream.concat([state.remainder], continuation_stream(continuation, state))
|
||||
{detect_prefix(prefix, opts), prefix, Stream.concat([prefix], rest)}
|
||||
|
||||
{:done, state} ->
|
||||
prefix = prefix(state)
|
||||
{detect_prefix(prefix, opts), prefix, [prefix]}
|
||||
|
||||
{:halted, state} ->
|
||||
prefix = prefix(state)
|
||||
{:unknown, prefix, [prefix]}
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Checks whether the bounded prefix matches the explicitly selected format."
|
||||
@spec validate(binary(), atom() | module(), keyword()) :: boolean()
|
||||
def validate(content, format, opts) when is_binary(content) do
|
||||
{_detected, prefix, _replayable} = detect_and_replay(content, opts)
|
||||
|
||||
case resolve_format(format, formats(opts)) do
|
||||
{:ok, formatter} -> matches?(formatter, prefix)
|
||||
:error -> false
|
||||
end
|
||||
end
|
||||
|
||||
def validate(_content, _format, _opts), do: false
|
||||
|
||||
defp max_detection_bytes(opts) do
|
||||
case Keyword.get(opts, :max_detection_bytes, @default_max_detection_bytes) do
|
||||
max_bytes when is_integer(max_bytes) and max_bytes > 0 -> max_bytes
|
||||
_ -> 0
|
||||
end
|
||||
end
|
||||
|
||||
defp formats(opts), do: FormatRegistry.formats(Keyword.get(opts, :formats, []))
|
||||
|
||||
defp detect_prefix(prefix, opts) do
|
||||
opts
|
||||
|> formats()
|
||||
|> Enum.find(&matches?(&1, prefix))
|
||||
|> format_id()
|
||||
end
|
||||
|
||||
defp consume_chunk(chunk, %{mode: :detect} = state, max_bytes) when is_binary(chunk) do
|
||||
remaining = max_bytes - state.size
|
||||
length = min(byte_size(chunk), remaining)
|
||||
prefix_chunk = chunk |> binary_part(0, length) |> :binary.copy()
|
||||
state = %{state | chunks: [prefix_chunk | state.chunks], size: state.size + length}
|
||||
remainder = binary_part(chunk, length, byte_size(chunk) - length)
|
||||
state = %{state | remainder: remainder}
|
||||
|
||||
if state.size == max_bytes, do: {:suspend, state}, else: {:cont, state}
|
||||
end
|
||||
|
||||
defp consume_chunk(chunk, %{mode: :replay} = state, _max_bytes) do
|
||||
{:suspend, Map.put(state, :replay_chunk, chunk)}
|
||||
end
|
||||
|
||||
defp consume_chunk(_chunk, state, _max_bytes), do: {:halt, state}
|
||||
|
||||
defp continuation_stream(continuation, state) do
|
||||
Stream.resource(
|
||||
fn -> {continuation, %{state | mode: :replay}} end,
|
||||
fn {continuation, state} ->
|
||||
case continuation.({:cont, state}) do
|
||||
{:suspended, next_state, next_continuation} ->
|
||||
chunk = Map.fetch!(next_state, :replay_chunk)
|
||||
{[chunk], {next_continuation, Map.delete(next_state, :replay_chunk)}}
|
||||
|
||||
{:done, _state} ->
|
||||
{:halt, nil}
|
||||
|
||||
{:halted, _state} ->
|
||||
{:halt, nil}
|
||||
end
|
||||
end,
|
||||
fn _state -> :ok end
|
||||
)
|
||||
end
|
||||
|
||||
defp prefix(state), do: state.chunks |> Enum.reverse() |> IO.iodata_to_binary()
|
||||
|
||||
defp matches?(format, prefix) do
|
||||
Code.ensure_loaded?(format) and function_exported?(format, :matches_prefix?, 1) and
|
||||
format.matches_prefix?(prefix)
|
||||
end
|
||||
|
||||
defp format_id(nil), do: :unknown
|
||||
|
||||
defp format_id(format) do
|
||||
if function_exported?(format, :id, 0), do: format.id(), else: :unknown
|
||||
end
|
||||
|
||||
defp resolve_format(format, formats) when is_atom(format) do
|
||||
if Code.ensure_loaded?(format) and function_exported?(format, :matches_prefix?, 1) do
|
||||
{:ok, format}
|
||||
else
|
||||
Enum.find_value(formats, :error, &resolve_id(&1, format))
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_format(_format, _formats), do: :error
|
||||
|
||||
defp resolve_id(module, id) do
|
||||
if Code.ensure_loaded?(module) and function_exported?(module, :id, 0) and module.id() == id,
|
||||
do: {:ok, module}
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user