ea3046f635
Co-authored-by: Cursor <cursoragent@cursor.com>
112 lines
3.6 KiB
Python
112 lines
3.6 KiB
Python
"""Parse GNU `ls -la` permission strings and listing lines."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
|
|
|
|
def modified_from_ls_parts(parts: list[str]) -> str:
|
|
"""Build display timestamp from ``ls`` fields (without filename)."""
|
|
if len(parts) < 6:
|
|
return ""
|
|
if len(parts) >= 7 and _ISO_DATE.match(parts[5]):
|
|
time_part = parts[6].split(".", 1)[0] if parts[6] else ""
|
|
return f"{parts[5]} {time_part}".strip()
|
|
if len(parts) >= 9:
|
|
return " ".join(parts[5:8])
|
|
return " ".join(parts[5:])
|
|
|
|
|
|
def parse_ls_la_line(line: str) -> dict | None:
|
|
"""Parse one ``ls -la`` line; returns None for total/skip lines.
|
|
|
|
Handles two timestamp formats produced by different ``ls`` implementations:
|
|
- Standard (3-token date): ``perms nlinks owner group size Mon DD HH:MM name`` → 9+ fields
|
|
- GNU long-iso (2-token): ``perms nlinks owner group size YYYY-MM-DD HH:MM name`` → 8+ fields
|
|
|
|
With ``--time-style=long-iso`` the date collapses from 3 tokens to 2, so
|
|
the total token count drops from 9 to 8. Directories and regular files were
|
|
silently dropped (only symlinks survived because their names contain
|
|
``" -> target"`` which pushed the count back to 9).
|
|
"""
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith("total "):
|
|
return None
|
|
|
|
# Split into at most 9 pieces so filenames with spaces are kept intact.
|
|
parts = stripped.split(None, 8)
|
|
if len(parts) < 8:
|
|
return None
|
|
|
|
perms = parts[0]
|
|
is_dir = perms.startswith("d")
|
|
is_link = perms.startswith("l")
|
|
|
|
# Detect format by checking whether field[5] is an ISO date (YYYY-MM-DD).
|
|
# long-iso: [perms nlinks owner group size DATE TIME name] → name at index 7
|
|
# standard: [perms nlinks owner group size MON DAY TIME name] → name at index 8
|
|
if _ISO_DATE.match(parts[5]):
|
|
# long-iso format — name is at index 7 (or rest of string)
|
|
if len(parts) < 8:
|
|
return None
|
|
name = parts[7]
|
|
modified = f"{parts[5]} {parts[6].split('.', 1)[0]}"
|
|
else:
|
|
# standard format — need at least 9 tokens for the name at index 8
|
|
if len(parts) < 9:
|
|
return None
|
|
name = parts[8]
|
|
modified = modified_from_ls_parts(parts)
|
|
|
|
link_target = None
|
|
if is_link and " -> " in name:
|
|
name, link_target = name.split(" -> ", 1)
|
|
if name in (".", ".."):
|
|
return None
|
|
|
|
return {
|
|
"name": name,
|
|
"is_dir": is_dir,
|
|
"is_link": is_link,
|
|
"link_target": link_target,
|
|
"size": parts[4],
|
|
"perms": perms,
|
|
"owner": parts[2],
|
|
"group": parts[3] if len(parts) > 3 else "",
|
|
"modified": modified,
|
|
}
|
|
|
|
|
|
def perms_to_mode_octal(perms: str) -> str | None:
|
|
"""Convert ``drwxr-xr-x`` / ``-rw-r--r--`` to ``755`` / ``644``."""
|
|
if not perms or len(perms) < 10:
|
|
return None
|
|
bits = perms[1:10]
|
|
if len(bits) != 9:
|
|
return None
|
|
|
|
def _tri(chunk: str) -> int:
|
|
value = 0
|
|
if chunk[0] == "r":
|
|
value += 4
|
|
if chunk[1] == "w":
|
|
value += 2
|
|
if chunk[2] in "xXsStT":
|
|
value += 1
|
|
return value
|
|
|
|
return f"{_tri(bits[0:3])}{_tri(bits[3:6])}{_tri(bits[6:9])}"
|
|
|
|
|
|
def format_owner_label(owner: str, group: str | None = None) -> str:
|
|
"""Primary list label: ``www`` or ``www:nogroup`` when group differs."""
|
|
o = (owner or "").strip()
|
|
g = (group or "").strip()
|
|
if not o:
|
|
return "—"
|
|
if not g or g == o:
|
|
return o
|
|
return f"{o}:{g}"
|