Current section
Files
Jump to
Current section
Files
lib/nested_lines.ex
defmodule NestedLines do
@moduledoc """
Documentation for `NestedLines`.
"""
defstruct lines: []
@type line_number :: 0..1
@type line :: [line_number()]
@type t :: %__MODULE__{
lines: [line()]
}
@doc """
Construct a nested line representation from a list of string values.
## Examples
iex> NestedLines.new!(["1", "1.1", "1.2", "2", "2.1"])
%NestedLines{lines: [[1], [0, 1], [0, 1], [1], [0, 1]]}
"""
@spec new!(list(String.t() | non_neg_integer())) :: t
def new!(line_input) when is_list(line_input) do
lines = Enum.map(line_input, &parse_input/1)
%__MODULE__{lines: lines}
end
def new!(_), do: raise(ArgumentError, "cannot build NestedLines, invalid input")
@spec parse_input(any()) :: line()
defp parse_input(line) when is_number(line), do: to_string(line) |> parse_input()
defp parse_input(line) when is_binary(line) do
line
|> String.split(".", trim: true)
|> convert_to_binary_list([])
end
defp parse_input(_), do: [1]
@spec convert_to_binary_list(list(String.t()), line()) :: line()
defp convert_to_binary_list([], list), do: list
defp convert_to_binary_list([_head | [] = tail], list) do
convert_to_binary_list(tail, list ++ [1])
end
defp convert_to_binary_list([_head | tail], list) do
convert_to_binary_list(tail, [0 | list])
end
@doc """
Output a string representation of the line numbers.
## Examples
iex> NestedLines.new!(["1", "1.1", "1.2", "2", "2.1", "2.1.1"]) |> NestedLines.line_numbers()
["1", "1.1", "1.2", "2", "2.1", "2.1.1"]
"""
@spec line_numbers(t, pos_integer()) :: list(String.t())
def line_numbers(%__MODULE__{lines: lines}, starting_number \\ 1) when starting_number > 0 do
build_line_numbers(lines, [starting_number - 1], [])
end
defp build_line_numbers([], _prev, output), do: join_line_numbers(output)
defp build_line_numbers([current | rest], prev, output) do
next =
current
|> Enum.zip(prev ++ [0])
|> Enum.map(fn {a, b} -> a + b end)
build_line_numbers(rest, next, output ++ [next])
end
defp join_line_numbers(line_numbers), do: Enum.map(line_numbers, &Enum.join(&1, "."))
@doc """
Returns true if the line can be indented, false otherwise. Lines are 1-indexed.
## Examples
iex> NestedLines.new!(["1", "1.1", "1.2"]) |> NestedLines.can_indent?(1)
false
iex> NestedLines.new!(["1", "1.1", "1.2"]) |> NestedLines.can_indent?(3)
true
"""
@spec can_indent?(t, pos_integer()) :: boolean()
def can_indent?(%__MODULE__{lines: lines}, position)
when is_integer(position) and position > 0 do
lines
|> Enum.slice(position - 2, 2)
|> can_indent?()
end
defp can_indent?([prev, next]) when length(prev) >= length(next), do: true
defp can_indent?(_), do: false
@doc """
Indents a line based on its index, raises if the line cannot be indented.
Child lines are also indented by one position. Lines are 1-indexed.
## Examples
iex> NestedLines.new!(["1", "2", "2.1", "3"]) |> NestedLines.indent!(4) |> NestedLines.line_numbers()
["1", "2", "2.1", "2.2"]
"""
@spec indent!(t, pos_integer()) :: t
def indent!(%__MODULE__{lines: lines} = nested_lines, position)
when is_integer(position) and position > 0 do
case can_indent?(nested_lines, position) do
true -> do_indent(lines, position)
false -> raise(ArgumentError, "cannot indent line at #{position}")
end
end
defp do_indent(lines, position) do
# split the lines into three parts: front, back, and the line to be indented
{front, [line_item | rest]} = Enum.split(lines, position - 1)
{child_lines, back} = split_child_lines(rest, length(line_item))
updated_lines =
[line_item | child_lines]
|> indent_lines()
|> then(fn indented_lines -> [front, indented_lines, back] end)
|> Enum.concat()
%__MODULE__{lines: updated_lines}
end
@doc """
Returns true if the line can be outdented, false otherwise. Lines are 1-indexed.
## Examples
iex> NestedLines.new!(["1", "1.1", "1.1.1"]) |> NestedLines.can_outdent?(2)
true
iex> NestedLines.new!(["1", "1.1", "2"]) |> NestedLines.can_outdent?(3)
false
iex> NestedLines.new!(["1", "2", "2.1", "3"]) |> NestedLines.can_outdent?(3)
true
"""
@spec can_outdent?(t, pos_integer()) :: boolean()
def can_outdent?(%__MODULE__{lines: lines}, position)
when is_integer(position) and position > 0 do
lines
|> Enum.slice(position - 1, 2)
|> can_outdent?()
end
defp can_outdent?([[1], _next]), do: false
defp can_outdent?([[1]]), do: false
defp can_outdent?(_), do: true
@doc """
Outdents a line based on its index, raises if the line cannot be outdented.
Child lines are also outdented by one position. Lines are 1-indexed.
## Examples
iex> NestedLines.new!(["1", "2", "2.1", "2.1.1"]) |> NestedLines.outdent!(3) |> NestedLines.line_numbers()
["1", "2", "3", "3.1"]
"""
def outdent!(%__MODULE__{lines: lines} = nested_lines, position)
when is_integer(position) and position > 0 do
case can_outdent?(nested_lines, position) do
true -> do_outdent(lines, position)
false -> raise(ArgumentError, "cannot outdent line at #{position}")
end
end
defp do_outdent(lines, position) do
# split the lines into three parts: front, back, and the line to be outdented
{front, [line_item | rest]} = Enum.split(lines, position - 1)
{child_lines, back} = split_child_lines(rest, length(line_item))
updated_lines =
[line_item | child_lines]
|> outdent_lines()
|> then(fn outdented_lines ->
[front, outdented_lines, back]
end)
|> Enum.concat()
%__MODULE__{lines: updated_lines}
end
defp split_child_lines(lines, parent_length) do
Enum.split_while(lines, fn line -> length(line) > parent_length end)
end
defp outdent_lines(lines), do: Enum.map(lines, fn [0 | line] -> line end)
defp indent_lines(lines), do: Enum.map(lines, &[0 | &1])
@doc """
Returns a boolean indicating if the line at the given position has children
Examples:
iex> NestedLines.new!(["1", "1.1", "1.1.1"]) |> NestedLines.has_children?(1)
true
iex> NestedLines.new!(["1", "1.1", "1.1.1"]) |> NestedLines.has_children?(2)
true
iex> NestedLines.new!(["1", "1.1", "1.1.1"]) |> NestedLines.has_children?(3)
false
iex> NestedLines.new!(["1", "2", "2.1"]) |> NestedLines.has_children?(1)
false
"""
@spec has_children?(t, pos_integer()) :: boolean()
def has_children?(%__MODULE__{lines: lines}, position)
when is_integer(position) and position > 0 do
lines
|> Enum.slice(position - 1, 2)
|> has_children?()
end
defp has_children?([_, [_]]), do: false
defp has_children?([_]), do: false
defp has_children?([_, _]), do: true
@doc """
Returns a tree representation of the input lines.
## Examples
iex> NestedLines.new!(["1", "1.1", "2", "2.1", "2.1.1", "2.2", "2.2.1"]) |> NestedLines.tree()
[
%{
line: "1",
children: [
%{line: "1.1", children: []}
]
},
%{
line: "2",
children: [
%{line: "2.1", children: [
%{line: "2.1.1", children: []}
]},
%{line: "2.2", children: [
%{line: "2.2.1", children: []}
]
}
]
}
]
"""
@spec tree(t) :: list(map())
def tree(%__MODULE__{} = nested_lines) do
nested_lines
|> line_numbers()
|> build_tree([])
end
@spec build_tree(list(String.t()), list(map())) :: list(map())
defp build_tree([], tree), do: Enum.reverse(tree) |> Enum.map(&reverse_children/1)
defp build_tree([line | rest] = lines, tree)
when is_list(lines) and
is_list(tree) and is_binary(line) do
levels = String.split(line, ".")
case levels do
[_] ->
build_tree(rest, [%{line: line, children: []} | tree])
_ ->
updated_tree = add_to_parent(tree, line)
build_tree(rest, updated_tree)
end
end
@spec add_to_parent(list(map()), String.t()) :: list(map())
defp add_to_parent([], line) when is_binary(line), do: [%{line: line, children: []}]
defp add_to_parent([%{line: _} = latest_tree_line | rest], line)
when is_binary(line) do
line_levels = String.split(line, ".")
line_parent_levels = Enum.join(Enum.take(line_levels, Enum.count(line_levels) - 1), ".")
if latest_tree_line.line == line_parent_levels do
updated_line =
Map.update!(latest_tree_line, :children, fn children ->
[%{line: line, children: []} | children]
end)
[updated_line | rest]
else
updated_tree_line = Map.update!(latest_tree_line, :children, &add_to_parent(&1, line))
[updated_tree_line | rest]
end
end
defp reverse_children(%{line: line, children: children}) do
%{line: line, children: Enum.reverse(children) |> Enum.map(&reverse_children/1)}
end
end