Skip to content

Commit

Permalink
Merge pull request #6 from MBXSystems/feature/skip-lines
Browse files Browse the repository at this point in the history
Updates to support skipped lines
  • Loading branch information
rgtj2 authored Jun 13, 2024
2 parents 2194f8d + 374789f commit 7085da6
Show file tree
Hide file tree
Showing 3 changed files with 190 additions and 24 deletions.
124 changes: 111 additions & 13 deletions lib/nested_lines.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,17 @@ defmodule NestedLines do
@doc """
Construct a nested line representation from a list of string values.
`nil` values in the input list do not have line numbers
nor do they affect the line numbering of subsequent lines.
## Examples
iex> NestedLines.new!(["1", "1.1", "1.2", "2", "2.1"])
%NestedLines{lines: [[1], [0, 1], [0, 1], [1], [0, 1]]}
iex> NestedLines.new!(["1", "1.1", nil, "1.2", "2"])
%NestedLines{lines: [[1], [0, 1], [], [0, 1], [1]]}
"""
@spec new!(list(String.t() | non_neg_integer())) :: t
def new!(line_input) when is_list(line_input) do
Expand All @@ -41,6 +47,8 @@ defmodule NestedLines do
|> convert_to_binary_list([])
end

defp parse_input(nil), do: []

defp parse_input(_), do: [1]

@spec convert_to_binary_list(list(String.t()), line()) :: line()
Expand All @@ -64,11 +72,17 @@ defmodule NestedLines do
@doc """
Output a string representation of the line numbers.
`nil` values in the input list are preserved in the output
and do not affect the line numbering of subsequent lines.
## 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"]
iex> NestedLines.new!(["1", "1.1", nil, "1.3", "2", "2.1"]) |> NestedLines.line_numbers()
["1", "1.1", nil, "1.2", "2", "2.1"]
"""
@spec line_numbers(t, pos_integer()) :: list(String.t())
def line_numbers(%__MODULE__{lines: lines}, starting_number \\ 1) when starting_number > 0 do
Expand All @@ -78,15 +92,24 @@ defmodule NestedLines do
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)
if current == [] do
build_line_numbers(rest, prev, output ++ [[]])
else
next =
current
|> Enum.zip(prev ++ [0])
|> Enum.map(fn {a, b} -> a + b end)

build_line_numbers(rest, next, output ++ [next])
build_line_numbers(rest, next, output ++ [next])
end
end

defp join_line_numbers(line_numbers), do: Enum.map(line_numbers, &Enum.join(&1, "."))
defp join_line_numbers(line_numbers) do
Enum.map(line_numbers, fn
[] -> nil
line -> Enum.join(line, ".")
end)
end

@doc """
Returns true if the line can be indented, false otherwise. Lines are 1-indexed.
Expand All @@ -99,16 +122,23 @@ defmodule NestedLines do
iex> NestedLines.new!(["1", "1.1", "1.2"]) |> NestedLines.can_indent?(3)
true
iex> NestedLines.new!(["1", nil, "1.1", "1.2"]) |> NestedLines.can_indent?(2)
false
iex> NestedLines.new!(["1", nil, "1.1", "1.2"]) |> NestedLines.can_indent?(4)
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)
|> previous_and_current_line(position)
|> can_indent?()
end

defp can_indent?([prev, next]) when length(prev) >= length(next), do: true
defp can_indent?([_, []]), do: false
defp can_indent?([prev, current]) when length(prev) >= length(current), do: true
defp can_indent?(_), do: false

@doc """
Expand All @@ -120,6 +150,12 @@ defmodule NestedLines do
iex> NestedLines.new!(["1", "2", "2.1", "3"]) |> NestedLines.indent!(4) |> NestedLines.line_numbers()
["1", "2", "2.1", "2.2"]
iex> NestedLines.new!(["1", "2", "2.1", nil, "2.2"]) |> NestedLines.indent!(5) |> NestedLines.line_numbers()
["1", "2", "2.1", nil, "2.1.1"]
iex> NestedLines.new!(["1", "2", "3", nil, "3.1"]) |> NestedLines.indent!(3) |> NestedLines.line_numbers()
["1", "2", "2.1", nil, "2.1.1"]
"""
@spec indent!(t, pos_integer()) :: t
def indent!(%__MODULE__{lines: lines} = nested_lines, position)
Expand Down Expand Up @@ -159,17 +195,25 @@ defmodule NestedLines do
iex> NestedLines.new!(["1", "2", "2.1", "3"]) |> NestedLines.can_outdent?(3)
true
iex> NestedLines.new!(["1", nil, "2", "2.1"]) |> NestedLines.can_outdent?(4)
true
iex> NestedLines.new!(["1", nil, "2", "2.1"]) |> NestedLines.can_outdent?(2)
false
"""
@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)
|> current_and_next_line(position)
|> can_outdent?()
end

defp can_outdent?([[1], _next]), do: false
defp can_outdent?([[1]]), do: false
defp can_outdent?([[], _next]), do: false
defp can_outdent?([[]]), do: false
defp can_outdent?(_), do: true

@doc """
Expand All @@ -181,6 +225,9 @@ defmodule NestedLines do
iex> NestedLines.new!(["1", "2", "2.1", "2.1.1"]) |> NestedLines.outdent!(3) |> NestedLines.line_numbers()
["1", "2", "3", "3.1"]
iex> NestedLines.new!(["1", "2", "2.1", nil, "2.1.1"]) |> NestedLines.outdent!(3) |> NestedLines.line_numbers()
["1", "2", "3", nil, "3.1"]
"""
def outdent!(%__MODULE__{lines: lines} = nested_lines, position)
when is_integer(position) and position > 0 do
Expand Down Expand Up @@ -208,11 +255,26 @@ defmodule NestedLines do
end

defp split_child_lines(lines, parent_length) do
Enum.split_while(lines, fn line -> length(line) > parent_length end)
Enum.split_while(lines, fn line ->
line |> Enum.count() > parent_length or line == []
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])
defp outdent_lines(lines) do
lines
|> Enum.map(fn
[0 | line] -> line
[] = line -> line
end)
end

defp indent_lines(lines) do
lines
|> Enum.map(fn
line = [] -> line
line -> [0 | line]
end)
end

@doc """
Returns a boolean indicating if the line at the given position has children
Expand All @@ -239,10 +301,12 @@ defmodule NestedLines do
def has_children?(%__MODULE__{lines: lines}, position)
when is_integer(position) and position > 0 do
lines
|> Enum.slice(position - 1, 2)
|> current_and_next_line(position)
|> has_children?()
end

defp has_children?([[], _next]), do: false
defp has_children?([[]]), do: false
defp has_children?([_, [_]]), do: false
defp has_children?([_]), do: false
defp has_children?([a, b]), do: Enum.count(a) < Enum.count(b)
Expand Down Expand Up @@ -279,6 +343,7 @@ defmodule NestedLines do
def tree(%__MODULE__{} = nested_lines) do
nested_lines
|> line_numbers()
|> Enum.filter(fn line -> line != nil end)
|> build_tree([])
end

Expand Down Expand Up @@ -324,4 +389,37 @@ defmodule NestedLines do
defp reverse_children(%{line: line, children: children}) do
%{line: line, children: Enum.reverse(children) |> Enum.map(&reverse_children/1)}
end

@spec previous_and_current_line([line()], pos_integer()) :: [line()]
defp previous_and_current_line(lines, position) do
current_line = Enum.at(lines, position - 1)

previous_line =
lines
|> Enum.take(position - 1)
|> Enum.reject(fn line -> line == [] end)
|> Enum.reverse()
|> Enum.at(0)

case previous_line do
nil -> [current_line]
_ -> [previous_line, current_line]
end
end

@spec current_and_next_line([line()], pos_integer()) :: [line()]
defp current_and_next_line(lines, position) do
non_empty_trailing_lines =
lines
|> Enum.drop(position)
|> Enum.reject(fn line -> line == [] end)

current_line = Enum.at(lines, position - 1)
next_line = Enum.at(non_empty_trailing_lines, 0)

case next_line do
nil -> [current_line]
_ -> [current_line, next_line]
end
end
end
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule NestedLines.MixProject do
def project do
[
app: :nested_lines,
version: "0.1.4",
version: "0.1.5",
elixir: "~> 1.14",
start_permanent: Mix.env() == :prod,
description: description(),
Expand Down
88 changes: 78 additions & 10 deletions test/nested_lines_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ defmodule NestedLinesTest do
doctest NestedLines

describe "parsing nil values" do
test "nil values return [[1]]" do
test "nil values return []" do
input1 = NestedLines.new!([nil])
assert %NestedLines{lines: [[1]]} = input1
assert %NestedLines{lines: [[]]} = input1

input2 = NestedLines.new!(["1", nil, "2"])
assert %NestedLines{lines: [[1], [1], [1]]} = input2
assert %NestedLines{lines: [[1], [], [1]]} = input2
end
end

Expand Down Expand Up @@ -36,14 +36,11 @@ defmodule NestedLinesTest do

describe "parsing numeric values" do
test "numeric values return [[1]]" do
input = NestedLines.new!([nil])
assert %NestedLines{lines: [[1]]} = input

input2 = NestedLines.new!(["1", 1, "2"])
assert %NestedLines{lines: [[1], [1], [1]]} = input2
input = NestedLines.new!(["1", 1, "2"])
assert %NestedLines{lines: [[1], [1], [1]]} = input

input3 = NestedLines.new!(["1", 1.1, "2", 2.1])
assert %NestedLines{lines: [[1], [0, 1], [1], [0, 1]]} = input3
input1 = NestedLines.new!(["1", 1.1, "2", 2.1])
assert %NestedLines{lines: [[1], [0, 1], [1], [0, 1]]} = input1
end
end

Expand All @@ -58,6 +55,11 @@ defmodule NestedLinesTest do
assert ["1", "1.1", "2"] = NestedLines.line_numbers(lines)
end

test "skipped line numbers" do
lines = NestedLines.new!(["1", nil, "3", "4"])
assert ["1", nil, "2", "3"] = NestedLines.line_numbers(lines)
end

test "line numbers with grandchildren" do
lines = NestedLines.new!(["1", "1.1", "2", "3", "3.1", "3.1.1", "4"])
assert ["1", "1.1", "2", "3", "3.1", "3.1.1", "4"] = NestedLines.line_numbers(lines)
Expand All @@ -68,6 +70,11 @@ defmodule NestedLinesTest do
assert ["10", "10.1", "11"] = NestedLines.line_numbers(lines, 10)
end

test "skipped nested line numbers" do
lines = NestedLines.new!(["1", "1.1", nil, "1.3", "2", "2.1"])
assert ["1", "1.1", nil, "1.2", "2", "2.1"] = NestedLines.line_numbers(lines)
end

test "fail if starting_number less than 1" do
lines = NestedLines.new!(["1", "1.1", "2"])

Expand All @@ -86,6 +93,14 @@ defmodule NestedLinesTest do
|> NestedLines.line_numbers()
end

test "indent one level with skipped line" do
lines = NestedLines.new!(["1", nil, "3"])

assert ["1", nil, "1.1"] =
NestedLines.indent!(lines, 3)
|> NestedLines.line_numbers()
end

test "indent two levels" do
lines = NestedLines.new!(["1", "1.1", "1.2"])

Expand Down Expand Up @@ -145,6 +160,28 @@ defmodule NestedLinesTest do
end
end

describe "has_children?" do
test "with children" do
has_children = NestedLines.new!(["1", "1.1", "2"]) |> NestedLines.has_children?(1)
assert has_children
end

test "with grandchildren" do
has_children = NestedLines.new!(["1", "1.1", "1.1.1"]) |> NestedLines.has_children?(2)
assert has_children
end

test "with the last element of the list" do
has_children = NestedLines.new!(["1", "1.1", "1.1.1"]) |> NestedLines.has_children?(3)
assert !has_children
end

test "with a skipped line" do
has_children = NestedLines.new!(["1", nil, "2"]) |> NestedLines.has_children?(2)
assert !has_children
end
end

describe "tree" do
test "with deeply nested children" do
line_numbers = ["1", "2", "2.1", "2.2", "2.2.1", "2.3"]
Expand Down Expand Up @@ -176,5 +213,36 @@ defmodule NestedLinesTest do
}
] = NestedLines.tree(lines)
end

test "with skipped lines" do
line_numbers = ["1", "2", "2.1", nil, "2.2", "2.2.1", "2.3", nil]
lines = NestedLines.new!(line_numbers)

assert [
%{line: "1", children: []},
%{
line: "2",
children: [
%{
line: "2.1",
children: []
},
%{
line: "2.2",
children: [
%{
line: "2.2.1",
children: []
}
]
},
%{
line: "2.3",
children: []
}
]
}
] = NestedLines.tree(lines)
end
end
end

0 comments on commit 7085da6

Please sign in to comment.