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:
@@ -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": ""
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"description": null,
|
||||
"long": null,
|
||||
"numeric_shorthand": false,
|
||||
"repeatable": false,
|
||||
"repeatable": true,
|
||||
"short": "-k",
|
||||
"value_kind": "text",
|
||||
"value_optional": false
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
"description": null,
|
||||
"long": null,
|
||||
"numeric_shorthand": false,
|
||||
"repeatable": false,
|
||||
"repeatable": true,
|
||||
"short": "-k",
|
||||
"value_kind": "text",
|
||||
"value_optional": false
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
Binary file not shown.
@@ -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' }),
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user