feat(sort): full GNU -k KEYDEF grammar (py+ts) (#607)

Replace single-field `-k N` with the complete GNU sort key spec,
mirrored across Python and TypeScript:

- Field ranges and character offsets: -k F1[.C1][opts][,F2[.C2][opts]]
- Field-N-to-EOL default (`-k2` spans field 2 to end of line, not just
  field 2 -- the largest prior silent divergence)
- Multiple -k keys applied in order (spec `-k` is now repeatable)
- Per-key modifiers n/g/h/V/M/f/r/b; any per-key modifier (incl. -b)
  makes a key ignore all global ordering options (GNU key_init rule),
  while globals still drive the last-resort whole-line compare
- Last-resort whole-line comparison, disabled by -s
- GNU field model: leading blanks belong to the following field; -b
  skips them; -t is a clean split
- Invalid field 0 -> exit 2 with GNU-matching stderr

Pinned against GNU coreutils 9.7. New cross-language
conformance/cases/sort.json validates py/ts parity.

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
bytecii
2026-07-21 22:39:53 -07:00
committed by GitHub
parent a837c44b6f
commit a584eef138
21 changed files with 802 additions and 110 deletions
+1 -1
View File
@@ -16,7 +16,7 @@
},
"expect": {
"exit": 0,
"stdout_text": "a.txt\nb.txt\nbinary.bin\nempty.txt\nno_nl.txt\nsame_a.txt\nsame_b.txt\nsub\n",
"stdout_text": "a.txt\nb.txt\nbinary.bin\nempty.txt\nno_nl.txt\nsame_a.txt\nsame_b.txt\nsk_colon.txt\nsk_fields.txt\nsk_ties.txt\nsub\n",
"stderr_text": ""
}
},
+109
View File
@@ -0,0 +1,109 @@
{
"command": "sort",
"cases": [
{
"id": "sort_key_field_to_eol",
"cmd": "sort -k2 /data/sk_fields.txt",
"matrix": {
"python": ["ram", "disk", "redis"],
"typescript": ["ram"]
},
"expect": {
"exit": 0,
"stdout_text": "c 1 m\nb 2 a\na 2 z\n",
"stderr_text": ""
}
},
{
"id": "sort_key_bounded_range",
"cmd": "sort -k2,2 /data/sk_fields.txt",
"matrix": {
"python": ["ram", "disk", "redis"],
"typescript": ["ram"]
},
"expect": {
"exit": 0,
"stdout_text": "c 1 m\na 2 z\nb 2 a\n",
"stderr_text": ""
}
},
{
"id": "sort_multi_key_per_key_modifiers",
"cmd": "sort -k2,2n -k1,1r /data/sk_fields.txt",
"matrix": {
"python": ["ram", "disk", "redis"],
"typescript": ["ram"]
},
"expect": {
"exit": 0,
"stdout_text": "c 1 m\nb 2 a\na 2 z\n",
"stderr_text": ""
}
},
{
"id": "sort_numeric_key_last_resort",
"cmd": "sort -k2,2n /data/sk_ties.txt",
"matrix": {
"python": ["ram", "disk", "redis"],
"typescript": ["ram"]
},
"expect": {
"exit": 0,
"stdout_text": "a 2\nm 2\nz 2\n",
"stderr_text": ""
}
},
{
"id": "sort_stable_keeps_input_order",
"cmd": "sort -s -k2,2n /data/sk_ties.txt",
"matrix": {
"python": ["ram", "disk", "redis"],
"typescript": ["ram"]
},
"expect": {
"exit": 0,
"stdout_text": "z 2\nm 2\na 2\n",
"stderr_text": ""
}
},
{
"id": "sort_global_reverse_not_inherited_by_typed_key",
"cmd": "sort -rk2,2n /data/sk_ties.txt",
"matrix": {
"python": ["ram", "disk", "redis"],
"typescript": ["ram"]
},
"expect": {
"exit": 0,
"stdout_text": "z 2\nm 2\na 2\n",
"stderr_text": ""
}
},
{
"id": "sort_char_offset_with_separator",
"cmd": "sort -t: -k1.2,1.3 /data/sk_colon.txt",
"matrix": {
"python": ["ram", "disk", "redis"],
"typescript": ["ram"]
},
"expect": {
"exit": 0,
"stdout_text": "cat:100\nbee:3\napple:12\n",
"stderr_text": ""
}
},
{
"id": "sort_zero_field_number_errors",
"cmd": "sort -k0 /data/sk_fields.txt",
"matrix": {
"python": ["ram", "disk", "redis"],
"typescript": ["ram"]
},
"expect": {
"exit": 2,
"stdout_text": "",
"stderr_text": "sort: field number is zero: invalid field specification '0'\n"
}
}
]
}
+9
View File
@@ -25,5 +25,14 @@
},
"/data/sub/deep/deeper.txt": {
"text": "deep\n"
},
"/data/sk_fields.txt": {
"text": "a 2 z\nb 2 a\nc 1 m\n"
},
"/data/sk_ties.txt": {
"text": "z 2\nm 2\na 2\n"
},
"/data/sk_colon.txt": {
"text": "apple:12\nbee:3\ncat:100\n"
}
}
+22 -14
View File
@@ -1,6 +1,7 @@
from collections.abc import Awaitable, Callable
from mirage.commands.builtin.sort_helper import _sort_key, _unique_key
from mirage.commands.builtin.sort_helper import (SortKeyError, build_config,
sort_lines)
from mirage.commands.builtin.utils.lines import split_lines
from mirage.commands.builtin.utils.stream import _read_stdin_async
from mirage.io.types import ByteSource, IOResult
@@ -16,13 +17,31 @@ async def sort(
numeric: bool = False,
unique: bool = False,
fold_case: bool = False,
key_field: int | None = None,
key_defs: list[str] | None = None,
field_separator: str | None = None,
human_numeric: bool = False,
version_sort: bool = False,
month_sort: bool = False,
ignore_blanks: bool = False,
stable: bool = False,
) -> tuple[ByteSource | None, IOResult]:
try:
cfg = build_config(
key_defs=key_defs or [],
field_sep=field_separator,
reverse=reverse,
numeric=numeric,
unique=unique,
fold_case=fold_case,
human_numeric=human_numeric,
version_sort=version_sort,
month_sort=month_sort,
ignore_blanks=ignore_blanks,
stable=stable,
)
except SortKeyError as exc:
return b"", IOResult(stderr=f"sort: {exc}\n".encode(), exit_code=2)
if paths:
all_lines: list[str] = []
for p in paths:
@@ -32,18 +51,7 @@ async def sort(
raw = await _read_stdin_async(stdin)
all_lines = split_lines((raw or b"").decode(errors="replace"))
key_args = (key_field, field_separator, fold_case, numeric, human_numeric,
version_sort, month_sort, ignore_blanks)
all_lines.sort(key=lambda x: _sort_key(x, *key_args), reverse=reverse)
if unique:
seen: set[object] = set()
deduped: list[str] = []
for line in all_lines:
dk = _unique_key(_sort_key(line, *key_args))
if dk not in seen:
seen.add(dk)
deduped.append(line)
all_lines = deduped
all_lines = sort_lines(all_lines, cfg)
output = "\n".join(all_lines)
return (output + "\n").encode() if all_lines else b"", IOResult()
@@ -33,7 +33,7 @@ async def sort(
n: bool = False,
u: bool = False,
f: bool = False,
k: str | None = None,
k: str | list[str] | None = None,
t: str | None = None,
h: bool = False,
V: bool = False,
@@ -44,6 +44,12 @@ async def sort(
**kwargs,
) -> tuple[ByteSource | None, IOResult]:
paths = await resolve_or_empty(ops, accessor, paths, index)
if k is None:
key_defs: list[str] = []
elif isinstance(k, list):
key_defs = [item for item in k if isinstance(item, str)]
else:
key_defs = [k]
return await generic_sort(
paths,
read_bytes=bound_op(ops.read_bytes, accessor, index),
@@ -52,12 +58,13 @@ async def sort(
numeric=n,
unique=u,
fold_case=f,
key_field=int(k) if k is not None else None,
key_defs=key_defs,
field_separator=t,
human_numeric=h,
version_sort=V,
month_sort=M,
ignore_blanks=b,
stable=s,
)
+266 -50
View File
@@ -13,7 +13,8 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import re
from typing import Any
from dataclasses import dataclass
from functools import cmp_to_key, partial
_HUMAN_SUFFIXES = {"K": 1e3, "M": 1e6, "G": 1e9, "T": 1e12, "P": 1e15}
_VERSION_RE = re.compile(r"(\d+)|(\D+)")
@@ -31,6 +32,196 @@ _MONTHS = {
"nov": 11,
"dec": 12,
}
_KEYDEF_RE = re.compile(r"^(\d+)(?:\.(\d+))?([a-zA-Z]*)$")
# GNU key modifier letters. n/g map to numeric; h/V/M/f/r/b are honored;
# d/i/R are recognized so they still suppress global options (per GNU
# key_init) but are not yet applied as filters.
_ORDER_LETTERS = frozenset("bdfgiMnRrV")
class SortKeyError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class KeyMods:
numeric: bool = False
human: bool = False
version: bool = False
month: bool = False
fold: bool = False
reverse: bool = False
@dataclass(frozen=True, slots=True)
class Key:
start_field: int
start_char: int
start_skip: bool
end_field: int | None
end_char: int | None
end_skip: bool
mods: KeyMods
@dataclass(frozen=True, slots=True)
class SortConfig:
keys: tuple[Key, ...]
field_sep: str | None
reverse: bool
unique: bool
stable: bool
def _parse_pos(spec: str, is_end: bool) -> tuple[int, int | None, str]:
match = _KEYDEF_RE.match(spec)
if match is None:
raise SortKeyError(f"invalid field specification '{spec}'")
field = int(match.group(1))
if field == 0:
raise SortKeyError(f"field number is zero: invalid field "
f"specification '{spec}'")
char_group = match.group(2)
letters = match.group(3)
for letter in letters:
if letter not in _ORDER_LETTERS:
raise SortKeyError(f"invalid ordering option '{letter}'")
if char_group is None:
char = None if is_end else 1
else:
char = int(char_group)
if not is_end and char == 0:
char = 1
return field, char, letters
def _mods_from_letters(*letter_runs: str) -> tuple[KeyMods, bool]:
letters = "".join(letter_runs)
has_own = any(letter in _ORDER_LETTERS for letter in letters)
numeric = "n" in letters or "g" in letters
return KeyMods(
numeric=numeric,
human="h" in letters,
version="V" in letters,
month="M" in letters,
fold="f" in letters,
reverse="r" in letters,
), has_own
def parse_keydef(spec: str, global_mods: KeyMods, global_skip: bool) -> Key:
start_spec, _, end_spec = spec.partition(",")
start_field, start_char, start_letters = _parse_pos(start_spec, False)
if end_spec:
end_field, end_char, end_letters = _parse_pos(end_spec, True)
else:
end_field, end_char, end_letters = None, None, ""
own_mods, has_own = _mods_from_letters(start_letters, end_letters)
if has_own:
mods = own_mods
start_skip = "b" in start_letters
end_skip = "b" in end_letters
else:
mods = global_mods
start_skip = global_skip
end_skip = global_skip
return Key(
start_field=start_field,
start_char=start_char if start_char is not None else 1,
start_skip=start_skip,
end_field=end_field,
end_char=end_char,
end_skip=end_skip,
mods=mods,
)
def build_config(
key_defs: list[str],
field_sep: str | None,
reverse: bool,
numeric: bool,
unique: bool,
fold_case: bool,
human_numeric: bool,
version_sort: bool,
month_sort: bool,
ignore_blanks: bool,
stable: bool,
) -> SortConfig:
global_mods = KeyMods(
numeric=numeric,
human=human_numeric,
version=version_sort,
month=month_sort,
fold=fold_case,
reverse=reverse,
)
if key_defs:
keys = tuple(
parse_keydef(spec, global_mods, ignore_blanks)
for spec in key_defs)
else:
keys = (Key(1, 1, ignore_blanks, None, None, ignore_blanks,
global_mods), )
return SortConfig(
keys=keys,
field_sep=field_sep,
reverse=reverse,
unique=unique,
stable=stable,
)
def _compute_fields(line: str,
field_sep: str | None) -> list[tuple[int, int, int]]:
fields: list[tuple[int, int, int]] = []
n = len(line)
if field_sep:
pos = 0
seplen = len(field_sep)
while True:
nxt = line.find(field_sep, pos)
if nxt == -1:
fields.append((pos, pos, n))
break
fields.append((pos, pos, nxt))
pos = nxt + seplen
return fields
i = 0
while i < n:
lead_start = i
while i < n and line[i] in " \t":
i += 1
content_start = i
while i < n and line[i] not in " \t":
i += 1
fields.append((lead_start, content_start, i))
return fields
def _extract(line: str, fields: list[tuple[int, int, int]], key: Key) -> str:
n = len(line)
nf = len(fields)
if key.start_field > nf:
return ""
lead_start, content_start, _ = fields[key.start_field - 1]
base = content_start if key.start_skip else lead_start
start = min(base + (key.start_char - 1), n)
if key.end_field is None:
end = n
elif key.end_field > nf:
end = n
else:
e_lead, e_content, e_end = fields[key.end_field - 1]
if key.end_char is None or key.end_char == 0:
end = e_end
else:
e_base = e_content if key.end_skip else e_lead
end = min(e_base + key.end_char, n)
if end < start:
end = start
return line[start:end]
def _parse_human(s: str) -> float:
@@ -59,57 +250,82 @@ def _version_key(s: str) -> list[object]:
return parts
def _sort_key(
line: str,
key_field: int | None,
field_sep: str | None,
ignore_case: bool,
numeric: bool,
human_numeric: bool = False,
version: bool = False,
month: bool = False,
strip_blanks: bool = False,
) -> Any:
if key_field is not None:
sep = field_sep if field_sep else None
parts = line.split(sep)
field = parts[key_field - 1] if key_field - 1 < len(parts) else ""
# GNU -b: ignore the key field's leading blanks; the default
# whitespace separator already strips them, -t does not.
if strip_blanks:
field = field.lstrip(" \t")
else:
field = line
if ignore_case:
field_lower = field.lower()
if not numeric and not human_numeric and not version and not month:
return (field_lower, field)
field = field_lower
if month:
abbr = field.strip()[:3].lower()
return _MONTHS.get(abbr, 0)
if human_numeric:
def _leading_number(field: str) -> float:
field = field.lstrip()
num_end = 0
for ch in field:
if ch.isdigit() or (ch in ".+-" and num_end == 0):
num_end += 1
else:
break
try:
return float(field[:num_end]) if num_end else 0.0
except ValueError:
return 0.0
def _transform(field: str, mods: KeyMods) -> object:
if mods.month:
return _MONTHS.get(field.strip()[:3].lower(), 0)
if mods.human:
return _parse_human(field)
if version:
if mods.version:
return _version_key(field)
if numeric:
field = field.lstrip()
num_end = 0
for ch in field:
if ch.isdigit() or (ch in ".+-" and num_end == 0):
num_end += 1
else:
break
try:
return float(field[:num_end]) if num_end else 0.0
except ValueError:
return 0.0
if mods.numeric:
return _leading_number(field)
if mods.fold:
return field.lower()
return field
def _unique_key(key: object) -> object:
if isinstance(key, tuple):
return key[0]
if isinstance(key, list):
return tuple(key)
return key
def _cmp(a: object, b: object) -> int:
if isinstance(a, list) and isinstance(b, list):
for x, y in zip(a, b):
c = _cmp(x, y)
if c:
return c
return (len(a) > len(b)) - (len(a) < len(b))
return (a > b) - (a < b) # type: ignore[operator]
def _compare_lines(a: str, b: str, cfg: SortConfig) -> int:
fa = _compute_fields(a, cfg.field_sep)
fb = _compute_fields(b, cfg.field_sep)
for key in cfg.keys:
ka = _transform(_extract(a, fa, key), key.mods)
kb = _transform(_extract(b, fb, key), key.mods)
c = _cmp(ka, kb)
if key.mods.reverse:
c = -c
if c:
return c
if cfg.stable:
return 0
c = (a > b) - (a < b)
if cfg.reverse:
c = -c
return c
def _dedup_key(line: str, cfg: SortConfig) -> tuple[object, ...]:
fields = _compute_fields(line, cfg.field_sep)
parts: list[object] = []
for key in cfg.keys:
value = _transform(_extract(line, fields, key), key.mods)
parts.append(tuple(value) if isinstance(value, list) else value)
return tuple(parts)
def sort_lines(lines: list[str], cfg: SortConfig) -> list[str]:
compare = partial(_compare_lines, cfg=cfg)
ordered = sorted(lines, key=cmp_to_key(compare))
if not cfg.unique:
return ordered
seen: set[tuple[object, ...]] = set()
deduped: list[str] = []
for line in ordered:
dk = _dedup_key(line, cfg)
if dk not in seen:
seen.add(dk)
deduped.append(line)
return deduped
@@ -34,7 +34,7 @@ SPECS: dict[str, CommandSpec] = {
Option(short="-n"),
Option(short="-u"),
Option(short="-f"),
Option(short="-k", value_kind=OperandKind.TEXT),
Option(short="-k", value_kind=OperandKind.TEXT, repeatable=True),
Option(short="-t", value_kind=OperandKind.TEXT),
Option(short="-h"),
Option(short="-V"),
@@ -15,3 +15,15 @@ async def test_no_operand_uses_empty_standard_input():
assert await materialize(stdout) == b""
assert io.exit_code == 0
@pytest.mark.asyncio
async def test_zero_field_keydef_exits_two():
stdout, io = await sort([],
read_bytes=_unused_read_bytes,
stdin=b"a\nb\n",
key_defs=["0"])
assert await materialize(stdout) == b""
assert io.exit_code == 2
assert b"field number is zero" in await materialize(io.stderr)
@@ -83,7 +83,7 @@ class TestSortKeyField:
@pytest.mark.asyncio
async def test_key_field_numeric(self):
result = await sort_lines(b"a 10\nb 2\nc 30",
key_field=2,
key_defs=["2"],
numeric=True)
assert result == ["b 2", "a 10", "c 30"]
@@ -94,7 +94,7 @@ class TestSortFieldSep:
async def test_field_sep_with_key(self):
result = await sort_lines(b"a:10\nb:2\nc:30",
field_separator=":",
key_field=2,
key_defs=["2"],
numeric=True)
assert result == ["b:2", "a:10", "c:30"]
+135 -10
View File
@@ -1,16 +1,141 @@
from mirage.commands.builtin.sort_helper import _sort_key
import pytest
from mirage.commands.builtin.sort_helper import (KeyMods, SortKeyError,
_compute_fields, _extract,
build_config, parse_keydef,
sort_lines)
_G = KeyMods()
def test_key_field_with_explicit_sep_keeps_leading_blanks():
key = _sort_key("x: b", 2, ":", False, False)
assert key == " b"
def _cfg(key_defs=None, **kw):
defaults = dict(field_sep=None,
reverse=False,
numeric=False,
unique=False,
fold_case=False,
human_numeric=False,
version_sort=False,
month_sort=False,
ignore_blanks=False,
stable=False)
defaults.update(kw)
return build_config(key_defs or [], **defaults)
def test_strip_blanks_ignores_key_leading_blanks():
key = _sort_key("x: b", 2, ":", False, False, strip_blanks=True)
assert key == "b"
def _lines(text, key_defs=None, **kw):
return sort_lines(text.split("\n"), _cfg(key_defs, **kw))
def test_strip_blanks_without_key_field_is_noop():
assert _sort_key(" both ", None, None, False, False,
strip_blanks=True) == " both "
class TestFieldModel:
def test_default_sep_leading_blanks_belong_to_following_field(self):
fields = _compute_fields(" zeta 5 x", None)
assert [start for start, _, _ in fields] == [0, 6, 11]
assert "".join(" zeta 5 x"[c:e] for _, c, e in fields) == \
"zeta5x"
def test_explicit_sep_no_blank_collapsing(self):
fields = _compute_fields("a::b", ":")
assert len(fields) == 3
assert fields[1] == (2, 2, 2)
class TestParseKeydef:
def test_field_only_extends_to_eol(self):
key = parse_keydef("2", _G, False)
assert key.start_field == 2 and key.start_char == 1
assert key.end_field is None
def test_range_with_chars(self):
key = parse_keydef("2.3,4.5", _G, False)
assert (key.start_field, key.start_char) == (2, 3)
assert (key.end_field, key.end_char) == (4, 5)
def test_per_key_numeric_overrides_global(self):
key = parse_keydef("2,2n", KeyMods(reverse=True), False)
assert key.mods.numeric is True
assert key.mods.reverse is False
def test_blank_flag_suppresses_global_inheritance(self):
key = parse_keydef("2b", KeyMods(numeric=True), False)
assert key.mods.numeric is False
assert key.start_skip is True
def test_no_own_options_inherits_global(self):
key = parse_keydef("2", KeyMods(numeric=True, reverse=True), True)
assert key.mods.numeric is True
assert key.mods.reverse is True
assert key.start_skip is True
def test_zero_field_raises(self):
with pytest.raises(SortKeyError):
parse_keydef("0", _G, False)
def test_unknown_order_letter_raises(self):
with pytest.raises(SortKeyError):
parse_keydef("2x", _G, False)
class TestExtract:
def test_field_to_eol_includes_leading_separator(self):
line = "a 2 z"
key = parse_keydef("2", _G, False)
assert _extract(line, _compute_fields(line, None), key) == " 2 z"
def test_range_single_field_includes_leading_blank(self):
line = "a 2 z"
key = parse_keydef("2,2", _G, False)
assert _extract(line, _compute_fields(line, None), key) == " 2"
def test_char_offset_past_field_reaches_separator(self):
line = "y 5"
key = parse_keydef("1.2", _G, False)
assert _extract(line, _compute_fields(line, None), key) == " 5"
def test_missing_field_is_empty(self):
line = "x 3"
key = parse_keydef("3,3", _G, False)
assert _extract(line, _compute_fields(line, None), key) == ""
class TestSortKeydef:
def test_k2_extends_to_eol_differs_from_k2_2(self):
data = "a 2 z\nb 2 a\nc 1 m"
assert _lines(data, ["2"]) == ["c 1 m", "b 2 a", "a 2 z"]
assert _lines(data, ["2,2"]) == ["c 1 m", "a 2 z", "b 2 a"]
def test_per_key_numeric(self):
data = "apple 3\nbanana 1\ncherry 2\napple 10"
assert _lines(data, ["2,2n"]) == \
["banana 1", "cherry 2", "apple 3", "apple 10"]
def test_global_reverse_ignored_by_per_key_typed_key(self):
data = "z 2\nm 2\na 2"
assert _lines(data, ["2,2n"], reverse=True) == ["z 2", "m 2", "a 2"]
def test_stable_disables_last_resort(self):
data = "z 2\nm 2\na 2"
assert _lines(data, ["2,2n"]) == ["a 2", "m 2", "z 2"]
assert _lines(data, ["2,2n"], stable=True) == ["z 2", "m 2", "a 2"]
def test_multi_key(self):
data = "a 2 z\nb 2 a\nc 1 m"
assert _lines(data, ["2,2n", "1,1r"]) == ["c 1 m", "b 2 a", "a 2 z"]
def test_blank_only_key_sorts_as_string_under_global_numeric(self):
data = " a 30\n b 5\n c 200"
assert _lines(data, ["2b"], numeric=True) == \
[" c 200", " a 30", " b 5"]
def test_explicit_sep_char_offsets(self):
data = "apple:12\nbee:3\ncat:100"
assert _lines(data, ["1.2,1.3"], field_sep=":") == \
["cat:100", "bee:3", "apple:12"]
def test_invalid_key_leaves_lines_via_config(self):
with pytest.raises(SortKeyError):
_cfg(["0"])
+31
View File
@@ -98,3 +98,34 @@ def test_sort_f(env):
env.create_file("f.txt", b"B\na\nC\nb\n")
result = env.mirage("sort -f /data/f.txt")
assert "a" in result and "B" in result
# KEYDEF grammar cases pinned to GNU coreutils 9.7 output (BSD sort on
# macOS diverges on these, so they use hardcoded expectations rather than
# env.native).
def test_sort_multi_key_with_per_key_modifiers(env):
env.create_file("f.txt", b"a 2 z\nb 2 a\nc 1 m\n")
assert env.mirage("sort -k2,2n -k1,1r /data/f.txt") == \
"c 1 m\nb 2 a\na 2 z\n"
def test_sort_combined_global_reverse_and_key(env):
env.create_file("f.txt", b"z 2\nm 2\na 2\n")
assert env.mirage("sort -rk2,2n /data/f.txt") == "z 2\nm 2\na 2\n"
def test_sort_field_to_eol_differs_from_range(env):
env.create_file("f.txt", b"a 2 z\nb 2 a\nc 1 m\n")
assert env.mirage("sort -k2 /data/f.txt") == "c 1 m\nb 2 a\na 2 z\n"
assert env.mirage("sort -k2,2 /data/f.txt") == "c 1 m\na 2 z\nb 2 a\n"
def test_sort_char_offset_with_sep(env):
env.create_file("f.txt", b"apple:12\nbee:3\ncat:100\n")
assert env.mirage("sort -t: -k1.2,1.3 /data/f.txt") == \
"cat:100\nbee:3\napple:12\n"
def test_sort_stable_keeps_input_order_on_ties(env):
env.create_file("f.txt", b"z 2\nm 2\na 2\n")
assert env.mirage("sort -s -k2,2n /data/f.txt") == "z 2\nm 2\na 2\n"
+1 -1
View File
@@ -383,7 +383,7 @@ def test_sort_spec():
spec = SPECS["sort"]
parsed = parse_command(spec, ["-k", "2", "-t", ",", "-rn", "data.csv"],
cwd="/")
assert parsed.flag("-k") == "2"
assert parsed.flag("-k") == ["2"]
assert parsed.flag("-t") == ","
assert parsed.flag("-r") is True
assert parsed.flag("-n") is True
+1 -1
View File
@@ -84,7 +84,7 @@
"description": null,
"long": null,
"numeric_shorthand": false,
"repeatable": false,
"repeatable": true,
"short": "-k",
"value_kind": "text",
"value_optional": false
+1 -1
View File
@@ -75,7 +75,7 @@
"description": null,
"long": null,
"numeric_shorthand": false,
"repeatable": false,
"repeatable": true,
"short": "-k",
"value_kind": "text",
"value_optional": false
+1 -1
View File
@@ -83,7 +83,7 @@
"description": null,
"long": null,
"numeric_shorthand": false,
"repeatable": false,
"repeatable": true,
"short": "-k",
"value_kind": "text",
"value_optional": false
@@ -15,7 +15,7 @@
import { IOResult, materialize, type ByteSource } from '../../../io/types.ts'
import type { PathSpec } from '../../../types.ts'
import type { CommandFnResult, CommandOpts } from '../../config.ts'
import { parseKeyOptions, sortAndDedupe, splitSortLines } from '../sort_helper.ts'
import { buildConfig, SortKeyError, sortLines, splitSortLines } from '../sort_helper.ts'
import { readStdinAsync } from '../utils/stream.ts'
const ENC = new TextEncoder()
@@ -26,9 +26,18 @@ export async function sortGeneric(
opts: CommandOpts,
stream: (p: PathSpec) => AsyncIterable<Uint8Array>,
): Promise<CommandFnResult> {
const keyOpts = parseKeyOptions(opts.flags)
const reverse = opts.flags.r === true
const unique = opts.flags.u === true
let cfg
try {
cfg = buildConfig(opts.flags)
} catch (err) {
if (err instanceof SortKeyError) {
return [
new Uint8Array(0),
new IOResult({ stderr: ENC.encode(`sort: ${err.message}\n`), exitCode: 2 }),
]
}
throw err
}
let allLines: string[] = []
if (paths.length > 0) {
for (const p of paths) {
@@ -39,7 +48,7 @@ export async function sortGeneric(
const raw = await readStdinAsync(opts.stdin)
allLines = splitSortLines(DEC.decode(raw ?? new Uint8Array(0)))
}
const sorted = sortAndDedupe(allLines, keyOpts, reverse, unique)
const sorted = sortLines(allLines, cfg)
const out: ByteSource =
sorted.length === 0 ? new Uint8Array(0) : ENC.encode(sorted.join('\n') + '\n')
return [out, new IOResult()]
@@ -132,4 +132,53 @@ describe('sort', () => {
expect(r.exitCode).toBe(0)
expect(r.lines).toEqual([])
})
// KEYDEF grammar pinned to GNU coreutils 9.7 output.
it('multiple keys with per-key modifiers', async () => {
const resource = new RAMResource()
resource.store.files.set('/tmp/f.txt', ENC.encode('a 2 z\nb 2 a\nc 1 m\n'))
const r = await runSort(resource, [PathSpec.fromStrPath('/tmp/f.txt')], { k: ['2,2n', '1,1r'] })
expect(r.lines).toEqual(['c 1 m', 'b 2 a', 'a 2 z'])
})
it('global reverse combined with a per-key numeric key', async () => {
const resource = new RAMResource()
resource.store.files.set('/tmp/f.txt', ENC.encode('z 2\nm 2\na 2\n'))
const r = await runSort(resource, [PathSpec.fromStrPath('/tmp/f.txt')], { r: true, k: '2,2n' })
expect(r.lines).toEqual(['z 2', 'm 2', 'a 2'])
})
it('-k field extends to EOL, differing from a bounded range', async () => {
const resource = new RAMResource()
resource.store.files.set('/tmp/f.txt', ENC.encode('a 2 z\nb 2 a\nc 1 m\n'))
const eol = await runSort(resource, [PathSpec.fromStrPath('/tmp/f.txt')], { k: '2' })
expect(eol.lines).toEqual(['c 1 m', 'b 2 a', 'a 2 z'])
const range = await runSort(resource, [PathSpec.fromStrPath('/tmp/f.txt')], { k: '2,2' })
expect(range.lines).toEqual(['c 1 m', 'a 2 z', 'b 2 a'])
})
it('char offsets with an explicit separator', async () => {
const resource = new RAMResource()
resource.store.files.set('/tmp/f.txt', ENC.encode('apple:12\nbee:3\ncat:100\n'))
const r = await runSort(resource, [PathSpec.fromStrPath('/tmp/f.txt')], {
t: ':',
k: '1.2,1.3',
})
expect(r.lines).toEqual(['cat:100', 'bee:3', 'apple:12'])
})
it('-s keeps input order on ties', async () => {
const resource = new RAMResource()
resource.store.files.set('/tmp/f.txt', ENC.encode('z 2\nm 2\na 2\n'))
const r = await runSort(resource, [PathSpec.fromStrPath('/tmp/f.txt')], { s: true, k: '2,2n' })
expect(r.lines).toEqual(['z 2', 'm 2', 'a 2'])
})
it('zero field number exits 2', async () => {
const resource = new RAMResource()
resource.store.files.set('/tmp/f.txt', ENC.encode('a\nb\n'))
const r = await runSort(resource, [PathSpec.fromStrPath('/tmp/f.txt')], { k: '0' })
expect(r.exitCode).toBe(2)
expect(r.lines).toEqual([])
})
})
@@ -13,28 +13,141 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { describe, expect, it } from 'vitest'
import { sortKey } from './sort_helper.ts'
import {
buildConfig,
computeFields,
extract,
type KeyMods,
parseKeydef,
SortKeyError,
sortLines,
} from './sort_helper.ts'
const BASE = {
keyField: 2,
fieldSep: ':',
ignoreCase: false,
const G: KeyMods = {
numeric: false,
humanNumeric: false,
human: false,
version: false,
month: false,
fold: false,
reverse: false,
}
describe('sortKey -b (stripBlanks)', () => {
it('keeps the key leading blanks by default', () => {
expect(sortKey('x: b', BASE)).toBe(' b')
function lines(text: string, flags: Record<string, string | boolean | string[]> = {}): string[] {
return sortLines(text.split('\n'), buildConfig(flags))
}
describe('field model', () => {
it('default sep: leading blanks belong to the following field', () => {
const fields = computeFields(' zeta 5 x', null)
expect(fields.map((f) => f[0])).toEqual([0, 6, 11])
})
it('strips the key leading blanks under -b', () => {
expect(sortKey('x: b', { ...BASE, stripBlanks: true })).toBe('b')
})
it('is a no-op without a key field', () => {
expect(sortKey(' both ', { ...BASE, keyField: null, stripBlanks: true })).toBe(' both ')
it('explicit sep: no blank collapsing, empty fields kept', () => {
const fields = computeFields('a::b', ':')
expect(fields.length).toBe(3)
expect(fields[1]).toEqual([2, 2, 2])
})
})
describe('parseKeydef', () => {
it('field only extends to EOL', () => {
const key = parseKeydef('2', G, false)
expect(key.startField).toBe(2)
expect(key.startChar).toBe(1)
expect(key.endField).toBeNull()
})
it('range with char offsets', () => {
const key = parseKeydef('2.3,4.5', G, false)
expect([key.startField, key.startChar]).toEqual([2, 3])
expect([key.endField, key.endChar]).toEqual([4, 5])
})
it('per-key numeric overrides global reverse', () => {
const key = parseKeydef('2,2n', { ...G, reverse: true }, false)
expect(key.mods.numeric).toBe(true)
expect(key.mods.reverse).toBe(false)
})
it('blank flag suppresses global inheritance', () => {
const key = parseKeydef('2b', { ...G, numeric: true }, false)
expect(key.mods.numeric).toBe(false)
expect(key.startSkip).toBe(true)
})
it('no own options inherits globals', () => {
const key = parseKeydef('2', { ...G, numeric: true, reverse: true }, true)
expect(key.mods.numeric).toBe(true)
expect(key.mods.reverse).toBe(true)
expect(key.startSkip).toBe(true)
})
it('zero field throws', () => {
expect(() => parseKeydef('0', G, false)).toThrow(SortKeyError)
})
it('unknown ordering letter throws', () => {
expect(() => parseKeydef('2x', G, false)).toThrow(SortKeyError)
})
})
describe('extract', () => {
it('field-to-EOL includes leading separator', () => {
const line = 'a 2 z'
expect(extract(line, computeFields(line, null), parseKeydef('2', G, false))).toBe(' 2 z')
})
it('single-field range includes leading blank', () => {
const line = 'a 2 z'
expect(extract(line, computeFields(line, null), parseKeydef('2,2', G, false))).toBe(' 2')
})
it('char offset past field reaches separator', () => {
const line = 'y 5'
expect(extract(line, computeFields(line, null), parseKeydef('1.2', G, false))).toBe(' 5')
})
it('missing field is empty', () => {
const line = 'x 3'
expect(extract(line, computeFields(line, null), parseKeydef('3,3', G, false))).toBe('')
})
})
describe('sortLines KEYDEF', () => {
it('-k2 extends to EOL, differs from -k2,2', () => {
const data = 'a 2 z\nb 2 a\nc 1 m'
expect(lines(data, { k: '2' })).toEqual(['c 1 m', 'b 2 a', 'a 2 z'])
expect(lines(data, { k: '2,2' })).toEqual(['c 1 m', 'a 2 z', 'b 2 a'])
})
it('per-key numeric', () => {
const data = 'apple 3\nbanana 1\ncherry 2\napple 10'
expect(lines(data, { k: '2,2n' })).toEqual(['banana 1', 'cherry 2', 'apple 3', 'apple 10'])
})
it('global reverse ignored by a per-key typed key', () => {
const data = 'z 2\nm 2\na 2'
expect(lines(data, { k: '2,2n', r: true })).toEqual(['z 2', 'm 2', 'a 2'])
})
it('stable disables the last-resort compare', () => {
const data = 'z 2\nm 2\na 2'
expect(lines(data, { k: '2,2n' })).toEqual(['a 2', 'm 2', 'z 2'])
expect(lines(data, { k: '2,2n', s: true })).toEqual(['z 2', 'm 2', 'a 2'])
})
it('multiple keys applied in order', () => {
const data = 'a 2 z\nb 2 a\nc 1 m'
expect(lines(data, { k: ['2,2n', '1,1r'] })).toEqual(['c 1 m', 'b 2 a', 'a 2 z'])
})
it('blank-only key sorts as string under global numeric', () => {
const data = ' a 30\n b 5\n c 200'
expect(lines(data, { k: '2b', n: true })).toEqual([' c 200', ' a 30', ' b 5'])
})
it('char offsets with explicit separator', () => {
const data = 'apple:12\nbee:3\ncat:100'
expect(lines(data, { k: '1.2,1.3', t: ':' })).toEqual(['cat:100', 'bee:3', 'apple:12'])
})
})
@@ -262,7 +262,7 @@ export const BUILTIN_SPECS: Readonly<Record<string, CommandSpec>> = Object.freez
new Option({ short: '-n' }),
new Option({ short: '-u' }),
new Option({ short: '-f' }),
new Option({ short: '-k', valueKind: OperandKind.TEXT }),
new Option({ short: '-k', valueKind: OperandKind.TEXT, repeatable: true }),
new Option({ short: '-t', valueKind: OperandKind.TEXT }),
new Option({ short: '-h' }),
new Option({ short: '-V' }),
+9 -5
View File
@@ -307,12 +307,16 @@ export type {
AsyncStatFn,
} from './commands/builtin/utils/types.ts'
export {
compareKeys,
parseKeyOptions,
sortAndDedupe,
sortKey,
buildConfig as buildSortConfig,
computeFields as computeSortFields,
extract as extractSortKey,
type Key as SortKey,
type KeyMods as SortKeyMods,
parseKeydef as parseSortKeydef,
type SortConfig,
SortKeyError,
sortLines,
splitSortLines,
type SortKeyOptions,
} from './commands/builtin/sort_helper.ts'
export { countNewlines, parseN, tailBytes } from './commands/builtin/tail_helper.ts'
export { AsyncLineIterator } from './io/async_line_iterator.ts'