Current section
Files
Jump to
Current section
Files
lib/ex_yarn.ex
defmodule ExYarn do
@moduledoc """
The library's main module
This module should be used as the public entrypoint of the library. It exposes
a single function, `parse/2`, which is used to parse a lockfile's contents.
**Note on performance:** This library was built in part as a learning exercise and therefore does not necessarily
apply the best possible practices and tools when it comes to code quality and performance. If performance is important
to you, I recommend using Dorgan's library ([hex.pm](https://hex.pm/packages/yarn_parser),
[Github](https://github.com/doorgan/yarn_parser)), which uses
[NimbleParsec](https://hexdocs.pm/nimble_parsec/NimbleParsec.html) for better performance.
## Example
iex> input = ~s(
...>foo:
...> "bar" true
...> foo 10
...> foobar: barfoo
...>)
...> ExYarn.parse(input)
{:ok, :success, %{"foo" => %{"bar" => true, "foo" => 10, "foobar" => "barfoo"}}}
"""
alias ExYarn.{ParseError, Parser}
@merge_conflict_ancestor "|||||||"
@merge_conflict_end ">>>>>>>"
@merge_conflict_sep "======="
@merge_conflict_start "<<<<<<<"
@typedoc """
The different types of errors that can be returned by `parse/2`
Types details:
- `{:error, ParseError}`: There was an error while parsing the file as a Yarn lockfile.
- `{:error, YamlElixir.ParsingError}`: There was an error while parsing the file as a YAML file.
- `{:error, FunctionClauseError}`: The lockfile contained a merge conflict which could not be parsed successfully.
"""
@type parsingError ::
{:error, ParseError}
| {:error, YamlElixir.ParsingError}
| {:error, FunctionClauseError}
@doc """
Receives the lockfile's contents, and optionnally the lockfile's name as inputs
and returns the parsed result
The lockfile's name is used to determine whether to parse the lockfile as an
official yarn lockfile (i.e. with yarn's custom format) or as a regular YAML
file. The function defaults to parsing the file as a yarn lockfile.
"""
@spec parse(any, any) ::
parsingError()
| {:ok, :merge | :success, map}
def parse(str, file_loc \\ "lockfile") do
{type, result} = parse!(str, file_loc)
{:ok, type, result}
rescue
e in ParseError -> {:error, e}
e in YamlElixir.ParsingError -> {:error, e}
e in FunctionClauseError -> {:error, e}
end
@doc """
Receives the lockfile's contents, and optionnally the lockfile's name as inputs
and returns the parsed result
Similar to `parse/2` except it will raise in case of errors.
"""
@spec parse!(String.t(), String.t()) :: {:merge | :success, map()}
def parse!(str, file_loc \\ "lockfile") do
str = String.replace_prefix(str, "\uFEFF", "")
if has_merge_conflict?(str) do
parse_with_conflict(str, file_loc)
else
parse_file(str, file_loc)
end
end
defp extract_conflict_variants(str) do
{variant1, variant2} =
String.split(str, ~r/\r?\n/)
|> extract_conflict_variants([], [])
{Enum.join(variant1, "\n"), Enum.join(variant2, "\n")}
end
defp extract_conflict_variants([line | lines], variants1, variants2) do
if String.starts_with?(line, @merge_conflict_start) do
{variant1, lines} = get_first_variant(lines, variants1, false)
{variant2, common_end} = get_second_variant(lines, variants2)
{variant1 ++ common_end, variant2 ++ common_end}
else
variants1 = [line | variants1]
variants2 = [line | variants2]
extract_conflict_variants(lines, variants1, variants2)
end
end
defp get_first_variant([line | lines], variants1, skip) do
cond do
line == @merge_conflict_sep -> {Enum.reverse(variants1), lines}
skip or String.starts_with?(line, @merge_conflict_ancestor) -> get_first_variant(lines, variants1, true)
true -> get_first_variant(lines, [line | variants1], skip)
end
end
defp get_second_variant([line | lines], variants2) do
if String.starts_with?(line, @merge_conflict_end) do
{Enum.reverse(variants2), lines}
else
get_second_variant(lines, [line | variants2])
end
end
defp has_merge_conflict?(str) do
String.contains?(str, @merge_conflict_start) and
String.contains?(str, @merge_conflict_sep) and
String.contains?(str, @merge_conflict_end)
end
@spec parse_file(String.t(), String.t()) :: {:success, map()}
defp parse_file(str, file_loc) do
if String.ends_with?(file_loc, ".yml") do
parse_file_yaml(str)
else
parse_file_yarn(str)
end
end
defp parse_file_yaml(str) do
result = YamlElixir.read_from_string!(str)
{:success, result}
end
defp parse_file_yarn(str) do
{:ok, result} = Parser.parse(str)
{:success, result}
rescue
e in ParseError ->
try do
parse_file_yaml(str)
rescue
_ in YamlElixir.ParsingError -> reraise e, __STACKTRACE__
end
end
@spec parse_with_conflict(String.t(), String.t()) :: {:merge | :success, map()}
defp parse_with_conflict(str, file_loc) do
{variant1, variant2} = extract_conflict_variants(str)
{_, parse_result_1} = parse!(variant1, file_loc)
{_, parse_result_2} = parse!(variant2, file_loc)
merge_conflict_results(parse_result_1, parse_result_2)
end
defp merge_conflict_results(parse_result_1, parse_result_2) do
{:merge, Map.merge(parse_result_1, parse_result_2)}
end
end