Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 8 additions & 23 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,49 +11,34 @@ env:

jobs:
test:
name: Test (Elixir ${{ matrix.elixir }} / OTP ${{ matrix.otp }})
name: Test (Elixir 1.19 / OTP 28)
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
include:
- elixir: "1.15"
otp: "25"
- elixir: "1.16"
otp: "26"
- elixir: "1.17"
otp: "27"
- elixir: "1.18"
otp: "27"
- elixir: "1.19"
otp: "28"

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Elixir
uses: erlef/setup-beam@v1
with:
elixir-version: ${{ matrix.elixir }}
otp-version: ${{ matrix.otp }}
elixir-version: "1.19"
otp-version: "28"

- name: Cache deps
uses: actions/cache@v4
with:
path: deps
key: ${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('**/mix.lock') }}
key: ${{ runner.os }}-mix-test-${{ hashFiles('**/mix.lock') }}
restore-keys: |
${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}-
${{ runner.os }}-mix-test-

- name: Cache _build
uses: actions/cache@v4
with:
path: _build
key: ${{ runner.os }}-build-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('**/mix.lock') }}
key: ${{ runner.os }}-build-test-${{ hashFiles('**/mix.lock') }}
restore-keys: |
${{ runner.os }}-build-${{ matrix.elixir }}-${{ matrix.otp }}-
${{ runner.os }}-build-test-

- name: Install dependencies
run: mix deps.get
Expand All @@ -62,7 +47,7 @@ jobs:
run: mix compile --warnings-as-errors

- name: Run tests
run: mix test --exclude bash_comparison
run: mix test

lint:
name: Lint
Expand Down
35 changes: 27 additions & 8 deletions lib/just_bash/commands/mv.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,33 @@ defmodule JustBash.Commands.Mv do
src_resolved = InMemoryFs.resolve_path(bash.cwd, src)
dest_resolved = InMemoryFs.resolve_path(bash.cwd, dest)

case InMemoryFs.read_file(bash.fs, src_resolved) do
{:ok, content} ->
{:ok, new_fs} = InMemoryFs.write_file(bash.fs, dest_resolved, content)
{:ok, new_fs} = InMemoryFs.rm(new_fs, src_resolved)
{Command.ok(), %{bash | fs: new_fs}}

{:error, _} ->
{Command.error("mv: cannot stat '#{src}': No such file or directory\n"), bash}
dest_final =
case InMemoryFs.stat(bash.fs, dest_resolved) do
{:ok, %{is_directory: true}} ->
InMemoryFs.normalize_path(dest_resolved <> "/" <> InMemoryFs.basename(src_resolved))

_ ->
dest_resolved
end

if InMemoryFs.normalize_path(src_resolved) == InMemoryFs.normalize_path(dest_final) do
{Command.result("", "mv: '#{src_resolved}' and '#{dest_final}' are the same file\n", 1),
bash}
else
case InMemoryFs.mv(bash.fs, src_resolved, dest_final) do
{:ok, new_fs} ->
{Command.ok(), %{bash | fs: new_fs}}

{:error, :enoent} ->
{Command.error("mv: cannot stat '#{src}': No such file or directory\n"), bash}

{:error, :eisdir} ->
{Command.error("mv: cannot overwrite directory '#{dest}' with non-directory\n"),
bash}

{:error, :enotdir} ->
{Command.error("mv: cannot move '#{src}' to '#{dest}': Not a directory\n"), bash}
end
end

_ ->
Expand Down
46 changes: 2 additions & 44 deletions lib/just_bash/commands/sort.ex
Original file line number Diff line number Diff line change
Expand Up @@ -69,50 +69,8 @@ defmodule JustBash.Commands.Sort do
Enum.sort_by(lines, &String.downcase/1, sort_direction(flags.r))
end

defp sort_lines(lines, %{r: true}), do: Enum.sort(lines, &locale_compare_desc/2)
defp sort_lines(lines, _flags), do: Enum.sort(lines, &locale_compare_asc/2)

# Locale-aware comparison (similar to en_US.UTF-8 collation)
# Case-insensitive primary sort, lowercase before uppercase as tiebreaker
defp locale_compare_asc(a, b) do
a_down = String.downcase(a)
b_down = String.downcase(b)

cond do
a_down < b_down -> true
a_down > b_down -> false
true -> lowercase_first?(a, b)
end
end

defp locale_compare_desc(a, b), do: locale_compare_asc(b, a)

defp lowercase_first?(a, b) do
a_chars = String.graphemes(a)
b_chars = String.graphemes(b)
compare_chars(a_chars, b_chars)
end

defp compare_chars([], []), do: true
defp compare_chars([], _), do: true
defp compare_chars(_, []), do: false

defp compare_chars([a_char | a_rest], [b_char | b_rest]) do
a_down = String.downcase(a_char)
b_down = String.downcase(b_char)

cond do
a_down != b_down -> a_down <= b_down
a_char == b_char -> compare_chars(a_rest, b_rest)
lowercase?(a_char) and not lowercase?(b_char) -> true
not lowercase?(a_char) and lowercase?(b_char) -> false
true -> compare_chars(a_rest, b_rest)
end
end

defp lowercase?(char) do
String.downcase(char) == char and String.upcase(char) != char
end
defp sort_lines(lines, %{r: true}), do: Enum.sort(lines, &>=/2)
defp sort_lines(lines, _flags), do: Enum.sort(lines, &<=/2)

defp parse_key_spec(spec) when is_integer(spec), do: {spec, nil}

Expand Down
3 changes: 3 additions & 0 deletions lib/just_bash/commands/uniq.ex
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ defmodule JustBash.Commands.Uniq do
"#{count} #{hd(chunk)}"
end)

flags.d and flags.u ->
""

flags.d ->
# Only print duplicate lines (lines that appear more than once)
lines
Expand Down
38 changes: 25 additions & 13 deletions lib/just_bash/commands/wc.ex
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,14 @@ defmodule JustBash.Commands.Wc do

defp format_output(content, file, flags) do
counts = count_content(content)
suffix = if file, do: " #{file}\n", else: "\n"
format_counts(counts, flags) <> suffix

formatted = format_counts(counts, flags, file != nil)

if file do
formatted <> " " <> file <> "\n"
else
formatted <> "\n"
end
end

defp count_content(content) do
Expand All @@ -49,20 +55,26 @@ defmodule JustBash.Commands.Wc do
}
end

defp format_counts(counts, %{l: true, w: false, c: false}), do: pad_count(counts.lines)
defp format_counts(counts, %{l: false, w: true, c: false}), do: pad_count(counts.words)
defp format_counts(counts, %{l: false, w: false, c: true}), do: pad_count(counts.bytes)
defp format_counts(counts, %{l: true, w: false, c: false}, has_file?),
do: format_single_count(counts.lines, has_file?)

# Default output: all three counts with consistent spacing
# Real wc uses 8-character fields for each count (total 24 chars)
defp format_counts(counts, _flags) do
String.pad_leading(Integer.to_string(counts.lines), 8) <>
String.pad_leading(Integer.to_string(counts.words), 8) <>
String.pad_leading(Integer.to_string(counts.bytes), 8)
defp format_counts(counts, %{l: false, w: true, c: false}, has_file?),
do: format_single_count(counts.words, has_file?)

defp format_counts(counts, %{l: false, w: false, c: true}, has_file?),
do: format_single_count(counts.bytes, has_file?)

defp format_counts(counts, _flags, _has_file?) do
# Match GNU wc formatting on Linux:
# counts are printed as right-aligned 7-char fields, separated by a space.
# Example: " 1 1 6"
pad_field(counts.lines) <> " " <> pad_field(counts.words) <> " " <> pad_field(counts.bytes)
end

# Single count padding to 8 characters (standard wc format)
defp pad_count(n), do: String.pad_leading(Integer.to_string(n), 8)
defp format_single_count(n, false), do: Integer.to_string(n)
defp format_single_count(n, true), do: pad_field(n)

defp pad_field(n), do: String.pad_leading(Integer.to_string(n), 7)

defp parse_flags(args), do: parse_flags(args, %{l: false, w: false, c: false}, [])

Expand Down
34 changes: 27 additions & 7 deletions lib/just_bash/fs/in_memory_fs.ex
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,8 @@ defmodule JustBash.Fs.InMemoryFs do
- `{:error, :enoent}` if source doesn't exist
- `{:error, :eisdir}` if source is directory and not recursive
"""
@spec cp(t(), String.t(), String.t(), cp_opts()) :: {:ok, t()} | {:error, :enoent | :eisdir}
@spec cp(t(), String.t(), String.t(), cp_opts()) ::
{:ok, t()} | {:error, :enoent | :eisdir | :enotdir}
def cp(%__MODULE__{data: data} = fs, src, dest, opts \\ []) do
src_norm = normalize_path(src)
dest_norm = normalize_path(dest)
Expand All @@ -563,12 +564,24 @@ defmodule JustBash.Fs.InMemoryFs do
fs = ensure_parent_dirs(fs, dest_norm)
{:ok, %{fs | data: Map.put(fs.data, dest_norm, entry)}}

%{type: :symlink} = entry ->
fs = ensure_parent_dirs(fs, dest_norm)
{:ok, %{fs | data: Map.put(fs.data, dest_norm, entry)}}

%{type: :directory} when not recursive ->
{:error, :eisdir}

%{type: :directory} ->
{:ok, fs} = mkdir(fs, dest_norm, recursive: true)
cp_children(fs, src_norm, dest_norm, opts)
case mkdir(fs, dest_norm, recursive: true) do
{:ok, fs} ->
cp_children(fs, src_norm, dest_norm, opts)

{:error, :eexist} ->
{:error, :enotdir}

{:error, _} = err ->
err
end
end
end

Expand All @@ -588,11 +601,18 @@ defmodule JustBash.Fs.InMemoryFs do
@doc """
Move/rename a file or directory.
"""
@spec mv(t(), String.t(), String.t()) :: {:ok, t()} | {:error, :enoent}
@spec mv(t(), String.t(), String.t()) :: {:ok, t()} | {:error, :enoent | :eisdir | :enotdir}
def mv(%__MODULE__{} = fs, src, dest) do
case cp(fs, src, dest, recursive: true) do
{:ok, fs} -> rm(fs, src, recursive: true)
error -> error
src_norm = normalize_path(src)
dest_norm = normalize_path(dest)

if src_norm == dest_norm do
{:ok, fs}
else
case cp(fs, src_norm, dest_norm, recursive: true) do
{:ok, fs} -> rm(fs, src_norm, recursive: true)
error -> error
end
end
end

Expand Down
6 changes: 5 additions & 1 deletion test/bash_comparison/find_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,11 @@ defmodule JustBash.BashComparison.FindTest do
end

test "find nonexistent path" do
compare_bash("find /tmp/nonexistent_dir_12345 2>&1", ignore_exit: true)
{real_out, _real_exit} = run_real_bash("find /tmp/nonexistent_dir_12345 2>&1")
{just_out, _just_exit} = run_just_bash("find /tmp/nonexistent_dir_12345 2>&1")

assert real_out =~ "No such file or directory"
assert just_out =~ "No such file or directory"
end

test "find with print0 flag" do
Expand Down
44 changes: 44 additions & 0 deletions test/bash_comparison/mv_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
defmodule JustBash.BashComparison.MvTest do
use ExUnit.Case, async: false
import JustBash.BashComparison.Support

@moduletag :bash_comparison

describe "mv comparison" do
test "moves a file and removes the source" do
compare_bash(
"D=/tmp/jb_mv_$$; rm -rf $D; mkdir $D; echo hi > $D/src; mv $D/src $D/dst; cat $D/dst; [ -f $D/src ] && echo bad || echo ok; rm -rf $D"
)
end

test "moves a file into an existing directory" do
compare_bash(
"D=/tmp/jb_mv_$$; rm -rf $D; mkdir $D; mkdir $D/dir; echo hi > $D/src; mv $D/src $D/dir; cat $D/dir/src; rm -rf $D"
)
end

test "moves a directory into an existing directory" do
compare_bash(
"D=/tmp/jb_mv_$$; rm -rf $D; mkdir $D; mkdir $D/srcdir; mkdir $D/dstdir; echo hi > $D/srcdir/file; mv $D/srcdir $D/dstdir; cat $D/dstdir/srcdir/file; rm -rf $D"
)
end

test "mv to the same path is a no-op" do
compare_bash(
"D=/tmp/jb_mv_$$; rm -rf $D; mkdir $D; echo hi > $D/file; mv $D/file $D/file 2>/dev/null; cat $D/file; rm -rf $D"
)
end

test "overwrites an existing destination file" do
compare_bash(
"D=/tmp/jb_mv_$$; rm -rf $D; mkdir $D; echo old > $D/dest; echo new > $D/src; mv $D/src $D/dest; cat $D/dest; rm -rf $D"
)
end

test "moves a symlink without dereferencing it" do
compare_bash(
"D=/tmp/jb_mv_$$; rm -rf $D; mkdir $D; cd $D; echo hi > target; ln -s target link; mv link moved; readlink moved; cat moved; rm -rf $D"
)
end
end
end
Loading