Skip to content

Commit 5af516a

Browse files
committed
add an endpoint to receive the chunk of the file
add chunk.sh shell script that returns cut of the file based on starting line, number of lines to read and direction (up or down) add ChunkExtractor module that sanitizes params and handles response of the chunk.sh script add a json endpoint to be able to request chunks
1 parent d751327 commit 5af516a

File tree

10 files changed

+388
-4
lines changed

10 files changed

+388
-4
lines changed

lib/diff/hex/chunk_extractor.ex

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
defmodule Diff.Hex.ChunkExtractor do
2+
@enforce_keys [:file_path, :from_line, :lines_to_read, :direction]
3+
defstruct Enum.map(@enforce_keys, &{&1, nil}) ++
4+
[
5+
errors: [],
6+
raw: nil,
7+
parsed: nil
8+
]
9+
10+
def run(params) do
11+
__MODULE__
12+
|> struct!(params)
13+
|> parse_integers([:from_line, :lines_to_read])
14+
|> validate_direction()
15+
|> system_read_raw_chunk()
16+
|> parse_chunk()
17+
|> remove_trailing_newline()
18+
end
19+
20+
defp parse_integers(chunk, fields) do
21+
Enum.reduce(fields, chunk, &parse_integer/2)
22+
end
23+
24+
defp parse_integer(field, chunk) do
25+
value = chunk |> Map.get(field) |> parse_value()
26+
27+
case value do
28+
:error -> %{chunk | errors: {:parse_integer, "#{field} must be a number"}}
29+
integer -> Map.put(chunk, field, integer)
30+
end
31+
end
32+
33+
defp parse_value(number) when is_integer(number), do: number
34+
35+
defp parse_value(number) when is_binary(number) do
36+
with {int, _} <- Integer.parse(number), do: int
37+
end
38+
39+
defp validate_direction(%{direction: direction} = chunk) when direction in ["up", "down"] do
40+
chunk
41+
end
42+
43+
defp validate_direction(chunk) do
44+
error = {:direction, "direction must be either \"up\" or \"down\""}
45+
%{chunk | errors: [error | chunk.errors]}
46+
end
47+
48+
defp system_read_raw_chunk(%{errors: [_ | _]} = chunk), do: chunk
49+
50+
defp system_read_raw_chunk(chunk) do
51+
chunk_sh = Application.app_dir(:diff, ["priv", "chunk.sh"])
52+
53+
path = chunk.file_path
54+
from_line = to_string(chunk.from_line)
55+
lines_to_read = to_string(chunk.lines_to_read)
56+
direction = chunk.direction
57+
58+
case System.cmd(chunk_sh, [path, from_line, lines_to_read, direction], stderr_to_stdout: true) do
59+
{raw_chunk, 0} ->
60+
%{chunk | raw: raw_chunk}
61+
62+
{error, code} ->
63+
error = {:system, "System command exited with a non-zero status #{code}: #{error}"}
64+
%{chunk | errors: [error | chunk.errors]}
65+
end
66+
end
67+
68+
defp parse_chunk(%{errors: [_ | _]} = chunk), do: chunk
69+
70+
defp parse_chunk(chunk) do
71+
parsed =
72+
chunk.raw
73+
|> String.split("\n")
74+
|> Enum.map(fn line -> %{line_text: line} end)
75+
76+
set_line_numbers(%{chunk | parsed: parsed})
77+
end
78+
79+
defp set_line_numbers(%{direction: "down"} = chunk) do
80+
%{chunk | parsed: parsed_with_line_numbers(chunk.parsed, chunk.from_line)}
81+
end
82+
83+
defp set_line_numbers(%{direction: "up"} = chunk) do
84+
offset = chunk.from_line - length(chunk.parsed) + 1
85+
86+
%{chunk | parsed: parsed_with_line_numbers(chunk.parsed, offset)}
87+
end
88+
89+
defp parsed_with_line_numbers(parsed_chunk, starting_number) when is_binary(starting_number) do
90+
parsed_with_line_numbers(parsed_chunk, starting_number)
91+
end
92+
93+
defp parsed_with_line_numbers(parsed_chunk, starting_number) do
94+
parsed_chunk
95+
|> Enum.with_index(starting_number)
96+
|> Enum.map(fn {line, line_number} -> Map.put_new(line, :line_number, line_number) end)
97+
end
98+
99+
defp remove_trailing_newline(%{errors: [_ | _]} = chunk), do: {:error, chunk}
100+
101+
defp remove_trailing_newline(chunk) do
102+
[trailing_line | reversed_tail] = Enum.reverse(chunk.parsed)
103+
104+
chunk =
105+
case trailing_line do
106+
%{line_text: ""} -> %{chunk | parsed: Enum.reverse(reversed_tail)}
107+
%{line_text: _text} -> chunk
108+
end
109+
110+
{:ok, chunk}
111+
end
112+
end

lib/diff/hex/hex.ex

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,11 @@ defmodule Diff.Hex do
3838
end
3939
end
4040

41-
def unpack_tarball(tarball, path) when is_binary(path) do
41+
def unpack_tarball(tarball, file_list \\ [], path) when is_binary(path) do
4242
path = to_charlist(path)
43+
file_list = Enum.map(file_list, &to_charlist/1)
4344

44-
with {:ok, _} <- :hex_tarball.unpack(tarball, path) do
45+
with {:ok, _} <- :hex_tarball.unpack(tarball, file_list, path) do
4546
:ok
4647
end
4748
end
@@ -100,6 +101,35 @@ defmodule Diff.Hex do
100101
end
101102
end
102103

104+
def get_chunk(package, version, file_name, from_line, direction) do
105+
path = tmp_path("package-#{package}-#{version}-")
106+
107+
chunk_extractor_params = %{
108+
file_path: Path.join(path, file_name),
109+
from_line: from_line,
110+
direction: direction,
111+
lines_to_read: 20
112+
}
113+
114+
try do
115+
with {:ok, tarball} <- get_tarball(package, version),
116+
:ok <- unpack_tarball(tarball, [file_name], path),
117+
{:ok, %{parsed: parsed_chunk}} <- Diff.Hex.ChunkExtractor.run(chunk_extractor_params) do
118+
{:ok, parsed_chunk}
119+
else
120+
{:error, %Diff.Hex.ChunkExtractor{errors: errors} = chunk} ->
121+
Logger.error(inspect(errors))
122+
{:error, chunk}
123+
124+
error ->
125+
Logger.error(inspect(error))
126+
{:error, error}
127+
end
128+
after
129+
File.rm_rf(path)
130+
end
131+
end
132+
103133
defp git_diff(path_from, path_to, path_out) do
104134
case System.cmd("git", [
105135
"-c",

lib/diff_web/controllers/page_controller.ex

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,32 @@ defmodule DiffWeb.PageController do
2828
end
2929
end
3030

31+
def expand_context(conn, %{"file_name" => file_name} = params) do
32+
%{
33+
"version" => version,
34+
"package" => package,
35+
"from_line" => from_line,
36+
"direction" => direction
37+
} = params
38+
39+
case parse_version(version) do
40+
{:ok, version} ->
41+
version = to_string(version)
42+
do_expand_context(conn, package, version, file_name, from_line, direction)
43+
44+
:error ->
45+
conn
46+
|> put_status(400)
47+
|> json(%{error: "Bad Request"})
48+
end
49+
end
50+
51+
def expand_context(conn, _params) do
52+
conn
53+
|> put_status(400)
54+
|> json(%{error: "missing query parameter: file_name"})
55+
end
56+
3157
defp maybe_cached_diff(conn, _package, version, version) do
3258
render_error(conn, 400)
3359
end
@@ -70,6 +96,18 @@ defmodule DiffWeb.PageController do
7096
end
7197
end
7298

99+
defp do_expand_context(conn, package, version, file_name, from_line, direction) do
100+
case Diff.Hex.get_chunk(package, version, file_name, from_line, direction) do
101+
{:ok, chunk} ->
102+
json(conn, %{chunk: chunk})
103+
104+
{:error, %{errors: errors}} ->
105+
conn
106+
|> put_status(400)
107+
|> json(%{errors: Enum.into(errors, %{})})
108+
end
109+
end
110+
73111
defp do_diff(conn, package, from, to) do
74112
case Diff.Hex.diff(package, from, to) do
75113
{:ok, stream} ->

lib/diff_web/router.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ defmodule DiffWeb.Router do
1515
pipe_through :browser
1616

1717
live "/", SearchLiveView
18+
get "/diff/:package/:version/expand/:from_line/:direction", PageController, :expand_context
1819
get "/diff/:package/:versions", PageController, :diff
1920
get "/diffs", PageController, :diffs
2021
end

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ defmodule Diff.MixProject do
4343
{:jason, "~> 1.0"},
4444
{:plug_cowboy, "~> 2.0"},
4545
{:phoenix_live_view, "~> 0.6"},
46-
{:hex_core, "~> 0.6.1"},
46+
{:hex_core, github: "RudolfMan/hex_core", branch: "unpack-list-of-files"},
4747
{:rollbax, "~> 0.11.0"},
4848
{:logster, "~> 1.0"},
4949
{:git_diff, github: "hexpm/git_diff", branch: "emj/stream"},

mix.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"git_diff": {:git, "https://github.com/hexpm/git_diff.git", "453ed1933e87e47c2debef1b5b77a0dc4a1c3fb7", [branch: "emj/stream"]},
1010
"goth": {:hex, :goth, "1.2.0", "92d6d926065a72a7e0da8818cc3a133229b56edf378022c00d9886c4125ce769", [:mix], [{:httpoison, "~> 0.11 or ~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.0", [hex: :joken, repo: "hexpm", optional: false]}], "hexpm", "4974932ab3b782c99a6fdeb0b968ddd61436ef14de5862bd6bb0227386c63b26"},
1111
"hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"},
12-
"hex_core": {:hex, :hex_core, "0.6.6", "a253f0abd41e10bc33cb73cdb1261a33a710559243ef3d6ca4d7ade0462a3f2c", [:rebar3], [], "hexpm", "f1aa2bf3a27520055d94e7c03880314f77c46ab19e1b280e8b8e46981ae92286"},
12+
"hex_core": {:git, "https://github.com/RudolfMan/hex_core.git", "8295d4b67f7d461a991fa34507b0e2c74a3201ea", [branch: "unpack-list-of-files"]},
1313
"html_entities": {:hex, :html_entities, "0.5.0", "40f5c5b9cbe23073b48a4e69c67b6c11974f623a76165e2b92d098c0e88ccb1d", [:mix], [], "hexpm", "8e9186e1873bea1067895f6a542b59df6c9fcf3b516ba272eeff3ea0c7b755cd"},
1414
"httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "aa2c74bd271af34239a3948779612f87df2422c2fdcfdbcec28d9c105f0773fe"},
1515
"idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"},

priv/chunk.sh

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/bin/sh
2+
3+
file=$1
4+
from_line=$2
5+
lines_to_read=$3
6+
direction=$4
7+
8+
if [ $direction = "down" ]
9+
then
10+
TAIL="$(tail -n+$from_line $file)"
11+
TMP_STATUS=$?
12+
printf '%s' "$TAIL" | head -n$lines_to_read
13+
FINAL_STATUS=$?
14+
else
15+
HEAD="$(head -n$from_line $file)"
16+
TMP_STATUS=$?
17+
printf '%s' "$HEAD" | tail -n$lines_to_read
18+
FINAL_STATUS=$?
19+
fi
20+
21+
[ $TMP_STATUS -gt 0 ] && exit $TMP_STATUS || exit $FINAL_STATUS
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
defmodule Diff.Hex.ChunkExtractorTest do
2+
use ExUnit.Case, async: true
3+
4+
alias Diff.Hex.ChunkExtractor
5+
6+
setup do
7+
content = """
8+
foo 1
9+
bar 2
10+
baz 3
11+
baf 4
12+
"""
13+
14+
path = System.tmp_dir!() |> Path.join("test_file")
15+
File.write!(path, content)
16+
17+
on_exit(fn -> File.rm!(path) end)
18+
19+
# some deafult params
20+
%{params: %{file_path: path, lines_to_read: 2, from_line: 1, direction: "down"}}
21+
end
22+
23+
describe "validates direction" do
24+
test "down", %{params: params} do
25+
{:ok, %{errors: errors}} = ChunkExtractor.run(%{params | direction: "down"})
26+
assert [] = errors
27+
end
28+
29+
test "up", %{params: params} do
30+
{:ok, %{errors: errors}} = ChunkExtractor.run(%{params | direction: "up"})
31+
assert [] = errors
32+
end
33+
34+
test "error when direction is neither up nor down", %{params: params} do
35+
{:error, %{errors: errors}} = ChunkExtractor.run(%{params | direction: "left"})
36+
assert "direction must be either \"up\" or \"down\"" = Keyword.get(errors, :direction)
37+
end
38+
end
39+
40+
describe "reads raw chunk from the file_path" do
41+
test "reads first 2 lines down", %{params: params} do
42+
{:ok, %{raw: raw}} = ChunkExtractor.run(%{params | direction: "down"})
43+
assert "foo 1\nbar 2\n" = raw
44+
end
45+
46+
test "reads first 2 lines up", %{params: params} do
47+
{:ok, %{raw: raw}} = ChunkExtractor.run(%{params | direction: "up", from_line: 2})
48+
assert "foo 1\nbar 2" = raw
49+
end
50+
51+
test "error when file doesn't exist", %{params: params} do
52+
{:error, %{errors: errors}} = ChunkExtractor.run(%{params | file_path: "non_existent"})
53+
assert Keyword.get(errors, :system) =~ ~r/non_existent: No such file/
54+
end
55+
56+
test "error when arguments are not valid", %{params: params} do
57+
{:error, %{errors: errors}} = ChunkExtractor.run(%{params | from_line: -1})
58+
assert Keyword.get(errors, :system) =~ ~r/illegal offset/
59+
end
60+
61+
test "reads 2 lines up from the middle", %{params: params} do
62+
{:ok, %{raw: raw}} = ChunkExtractor.run(%{params | direction: "up", from_line: 3})
63+
assert "bar 2\nbaz 3" = raw
64+
end
65+
66+
test "reads 2 lines down from the middle", %{params: params} do
67+
{:ok, %{raw: raw}} = ChunkExtractor.run(%{params | direction: "down", from_line: 2})
68+
assert "bar 2\nbaz 3\n" = raw
69+
end
70+
end
71+
72+
describe "parse_chunk" do
73+
test "parses raw chunk into list of structs", %{params: params} do
74+
{:ok, %{parsed: actual}} = ChunkExtractor.run(params)
75+
assert [%{line_text: "foo 1"}, %{line_text: "bar 2"}] = actual
76+
end
77+
78+
test "sets line_numbers when direction is down", %{params: params} do
79+
{:ok, %{parsed: actual}} = ChunkExtractor.run(%{params | direction: "down", from_line: 2})
80+
assert [%{line_number: 2}, %{line_number: 3}] = actual
81+
end
82+
83+
test "sets line_numbers when direction is up", %{params: params} do
84+
{:ok, %{parsed: actual}} = ChunkExtractor.run(%{params | direction: "up", from_line: 3})
85+
assert [%{line_number: 2}, %{line_number: 3}] = actual
86+
end
87+
end
88+
end

0 commit comments

Comments
 (0)