fix: close ten agent-corpus defects in sed, awk, date, find and the fakes
sed -i writes for every command, not only s and d; multi-file output is concatenated raw; an escaped delimiter inside an address regex no longer cuts the address short, and \%re% custom delimiters parse. awk refuses constructs it cannot run (exit 2, "unsupported construct") instead of echoing their source; simple assignment and OFS now execute and unset names render empty. date -d parses ISO, @epoch and the gnulib relative grammar in both languages (TS printed NaN with exit 0), invalid input is exit 1 with GNU wording; TS strftime learns %F %c %C %g %G %V %U %W %h %k %l %n %P %q %r %R %t %x %X, and Python expands %q ahead of strftime. find -printf is implemented (%p %P %f %h %d %s %y %Y %m %M %T escapes), registered on the CommandSpec so the format is not read as a path, and stats through the dispatcher when no overlay stat is wired. Fakes: the GitHub server strips the internal files list from list-commits, accepts refs/heads/x on /contents and /commits, and reports files as objects on one commit; the GWS Drive server evaluates fullText contains and answers 400 for an unknown query field; the Notion server honors after on block append with the live API's response and validation shape. Integ: new unix cases for each command fix, awk_ofs golden corrected, and cli-gh/cli-gws/cli-ntn cases that drive every fake fix through the CLIs; ntn conformance re-pinned against the real binary.
This commit is contained in:
+54
-2
@@ -178,7 +178,7 @@
|
||||
"command": "gh repo fork integ/repo-cli --fork-name forked",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "✓ Created fork integ-user/forked\n",
|
||||
"stdout": "\u2713 Created fork integ-user/forked\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
@@ -191,7 +191,7 @@
|
||||
"command": "gh repo rename renamed -R integ-user/forked",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "✓ Renamed repository integ-user/renamed\n",
|
||||
"stdout": "\u2713 Renamed repository integ-user/renamed\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
@@ -389,6 +389,58 @@
|
||||
"stdout": "jq fail\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_api_contents_accepts_the_qualified_ref_spelling",
|
||||
"command": "gh api -X GET repos/integ/repo-cli/contents/README.md -F ref=refs/heads/main --jq .path",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "README.md\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"seq": 569031,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gh_api_commits_list_carries_no_files_key",
|
||||
"command": "gh api repos/integ/repo-cli/commits --jq 'map(has(\"files\")) | unique'",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "[false]\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"seq": 569032,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gh_api_one_commit_reports_files_as_objects",
|
||||
"command": "gh api repos/integ/repo-cli/commits/$(gh api repos/integ/repo-cli/commits --jq '.[0].sha') --jq '.files[0] | keys'",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "[\"filename\",\"status\"]\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"seq": 569033,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gh_api_commits_by_qualified_ref_is_the_branch_head",
|
||||
"command": "test \"$(gh api repos/integ/repo-cli/commits/refs/heads/main --jq .sha)\" = \"$(gh api repos/integ/repo-cli/commits --jq '.[0].sha')\" && echo same",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "same\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"seq": 569034,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -910,6 +910,45 @@
|
||||
"stdout": "[[\"a\"],[\"b\"]]\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gw_drive_fulltext_finds_a_doc_by_body_text",
|
||||
"command": "gws drive files list --params \"{\\\"q\\\": \\\"fullText contains 'facet'\\\"}\" | jq -r '.files[].id'",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "doc0001\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"seq": 563073,
|
||||
"targets": [
|
||||
"cli-gws"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gw_drive_fulltext_with_mimetype_is_the_search_spreadsheets_query",
|
||||
"command": "gws drive files list --params \"{\\\"q\\\": \\\"fullText contains 'metrics' and mimeType = 'application/vnd.google-apps.spreadsheet'\\\"}\" | jq -r '.files[].id'",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "sheet0001\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"seq": 563074,
|
||||
"targets": [
|
||||
"cli-gws"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gw_drive_unknown_query_field_is_a_400_not_a_500",
|
||||
"command": "gws drive files list --params \"{\\\"q\\\": \\\"sharedWithMe = true\\\"}\" 2>&1 | grep -c 'unsupported query field: sharedWithMe'",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "1\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"seq": 563075,
|
||||
"targets": [
|
||||
"cli-gws"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+41
-2
@@ -152,7 +152,7 @@
|
||||
"command": "ntn datasources query d5000000-2222-3333-4444-555566667777",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "ffff1111-2222-3333-4444-555566667777\t✓\t2026-02-01\thttps://example.com/spec\tWrite spec\tneeds review\t2\tReview\tinfra, docs\nffff2222-3333-4444-5555-666677778888\t\t\t\tShip beta\t\t1\t\t\n",
|
||||
"stdout": "ffff1111-2222-3333-4444-555566667777\t\u2713\t2026-02-01\thttps://example.com/spec\tWrite spec\tneeds review\t2\tReview\tinfra, docs\nffff2222-3333-4444-5555-666677778888\t\t\t\tShip beta\t\t1\t\t\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
@@ -400,7 +400,7 @@
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "",
|
||||
"stderr": "✔ Page trashed\n"
|
||||
"stderr": "\u2714 Page trashed\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -948,6 +948,45 @@
|
||||
"stdout": "block\nchild_page\ntrue\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ntn_api_append_after_answers_inserted_then_trailing_siblings",
|
||||
"command": "A=$(ntn api v1/blocks/aaaa1111-2222-3333-4444-555566667777/children page_size==1 | jq -r '.results[0].id') && ntn api v1/blocks/aaaa1111-2222-3333-4444-555566667777/children -X PATCH -d \"{\\\"children\\\": [{\\\"object\\\": \\\"block\\\", \\\"type\\\": \\\"paragraph\\\", \\\"paragraph\\\": {\\\"rich_text\\\": [{\\\"type\\\": \\\"text\\\", \\\"text\\\": {\\\"content\\\": \\\"after probe\\\"}}]}}], \\\"after\\\": \\\"$A\\\"}\" | jq -r '.results[0].paragraph.rich_text[0].plain_text, (.results | length > 1)'",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "after probe\ntrue\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"seq": 566073,
|
||||
"targets": [
|
||||
"cli-ntn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ntn_api_append_after_lands_behind_the_anchor",
|
||||
"command": "ntn api v1/blocks/aaaa1111-2222-3333-4444-555566667777/children page_size==2 | jq -r '.results[1].paragraph.rich_text[0].plain_text'",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "after probe\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"seq": 566074,
|
||||
"targets": [
|
||||
"cli-ntn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ntn_api_append_after_unknown_anchor_is_refused",
|
||||
"command": "ntn api v1/blocks/aaaa1111-2222-3333-4444-555566667777/children -X PATCH -d '{\"children\": [{\"object\": \"block\", \"type\": \"paragraph\", \"paragraph\": {\"rich_text\": [{\"type\": \"text\", \"text\": {\"content\": \"never\"}}]}}], \"after\": \"11111111-1111-1111-1111-111111111111\"}'",
|
||||
"expect": {
|
||||
"exit": 5,
|
||||
"stdout": "",
|
||||
"stderr": "error: Public API request failed (400 Bad Request validation_error): body failed validation: body.position.after_block.id should be a valid uuid, instead was `\"11111111-1111-1111-1111-111111111111\"`.\n"
|
||||
},
|
||||
"seq": 566075,
|
||||
"targets": [
|
||||
"cli-ntn"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -147,6 +147,24 @@ def _commit_files(paths: list[str], status: str = "added") -> list[dict]:
|
||||
return [{"filename": path, "status": status} for path in paths]
|
||||
|
||||
|
||||
def _commit_json(entry: dict) -> dict:
|
||||
"""One stored commit as the list endpoints report it.
|
||||
|
||||
The store keeps a raw `files` list of paths for the single-commit
|
||||
endpoint to expand; GitHub's own commit-list and write-response
|
||||
shapes carry no `files` key at all, so serving the stored dict raw
|
||||
handed clients a list of bare strings where the API contract has
|
||||
objects -- which broke history enumeration after the first write.
|
||||
|
||||
Args:
|
||||
entry (dict): the stored commit.
|
||||
|
||||
Returns:
|
||||
dict: the commit without internal state.
|
||||
"""
|
||||
return {k: v for k, v in entry.items() if k != "files"}
|
||||
|
||||
|
||||
def _commit_list(repo: "FakeRepo", branch: str = "") -> list[dict]:
|
||||
"""One branch's commits, newest first, with a synthetic root.
|
||||
|
||||
@@ -395,8 +413,16 @@ class FakeRepo:
|
||||
"""
|
||||
if not ref or ref == "HEAD":
|
||||
return self.default_branch
|
||||
if ref in self.trees_by_branch:
|
||||
return ref
|
||||
# A fully qualified spelling names the same branch: tool schemas
|
||||
# advertise `refs/heads/main` and the live API accepts it on
|
||||
# every ref-taking parameter, so the fake must too.
|
||||
name = ref
|
||||
for qualifier in ("refs/heads/", "heads/"):
|
||||
if name.startswith(qualifier):
|
||||
name = name[len(qualifier):]
|
||||
break
|
||||
if name in self.trees_by_branch:
|
||||
return name
|
||||
for branch in self.trees_by_branch:
|
||||
if any(c["sha"] == ref for c in _commit_list(self, branch)):
|
||||
return branch
|
||||
@@ -837,11 +863,11 @@ class GitHubServer:
|
||||
repo = self._lookup(request)
|
||||
if repo is None:
|
||||
return _error(404, "Not Found")
|
||||
return web.json_response(
|
||||
_commit_list(
|
||||
repo,
|
||||
repo.branch_for(request.query.get("sha", ""))
|
||||
or repo.default_branch))
|
||||
history = _commit_list(
|
||||
repo,
|
||||
repo.branch_for(request.query.get("sha", ""))
|
||||
or repo.default_branch)
|
||||
return web.json_response([_commit_json(entry) for entry in history])
|
||||
|
||||
async def commit(self, request: web.Request) -> web.Response:
|
||||
"""One commit by sha or by ref, with the paths it touched.
|
||||
@@ -870,11 +896,13 @@ class GitHubServer:
|
||||
history = [{
|
||||
**entry, "files": _commit_files(entry["files"])
|
||||
} for entry in history]
|
||||
if ref in (*repo.trees_by_branch, "HEAD"):
|
||||
return web.json_response(history[0])
|
||||
for entry in history:
|
||||
if entry["sha"] == ref:
|
||||
return web.json_response(entry)
|
||||
# Any spelling branch_for resolves (bare, HEAD, refs/heads/...)
|
||||
# names the branch head.
|
||||
if branch is not None:
|
||||
return web.json_response(history[0])
|
||||
return _error(404, "Not Found")
|
||||
|
||||
async def contents(self, request: web.Request) -> web.Response:
|
||||
@@ -955,7 +983,7 @@ class GitHubServer:
|
||||
return web.json_response(
|
||||
{
|
||||
"content": _content_json(repo, path, files),
|
||||
"commit": commit,
|
||||
"commit": _commit_json(commit),
|
||||
},
|
||||
status=201 if created else 200)
|
||||
|
||||
@@ -1255,7 +1283,10 @@ class GitHubServer:
|
||||
commit = _record_commit(repo,
|
||||
str(body.get("message") or f"Delete {path}"),
|
||||
[path], branch)
|
||||
return web.json_response({"content": None, "commit": commit})
|
||||
return web.json_response({
|
||||
"content": None,
|
||||
"commit": _commit_json(commit)
|
||||
})
|
||||
|
||||
async def list_issues(self, request: web.Request) -> web.Response:
|
||||
"""List issues, newest first, filtered by state.
|
||||
@@ -1632,7 +1663,7 @@ def _add_routes(app: web.Application, server: "GitHubServer",
|
||||
server.branch)
|
||||
app.router.add_get(f"{prefix}/repos/{{owner}}/{{repo}}/commits",
|
||||
server.commits)
|
||||
app.router.add_get(f"{prefix}/repos/{{owner}}/{{repo}}/commits/{{ref}}",
|
||||
app.router.add_get(f"{prefix}/repos/{{owner}}/{{repo}}/commits/{{ref:.+}}",
|
||||
server.commit)
|
||||
# Both spellings of the repository root: GitHub serves `/contents` as
|
||||
# well as `/contents/`, and a caller listing the root picks either.
|
||||
|
||||
@@ -459,6 +459,21 @@ function unescapeQ(value: string): string {
|
||||
return out
|
||||
}
|
||||
|
||||
// Everything the live index searches for `fullText`: the display name, a
|
||||
// Doc's flat text, a Sheet's cell values, and an uploaded file's bytes.
|
||||
// Case-insensitive, the way the real search index answers.
|
||||
function fullTextOf(item: DriveItem): string {
|
||||
const parts: string[] = [item.name]
|
||||
const doc = state.docs.get(item.id)
|
||||
if (doc !== undefined) parts.push(doc.text)
|
||||
const sheet = state.sheets.get(item.id)
|
||||
if (sheet !== undefined) {
|
||||
for (const tab of sheet.tabs) parts.push([...tab.cells.values()].join(' '))
|
||||
}
|
||||
if (item.content.length > 0) parts.push(item.content.toString('utf8'))
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
function matchClause(item: DriveItem, clause: QueryClause): boolean {
|
||||
switch (clause.field) {
|
||||
case 'parents':
|
||||
@@ -471,6 +486,14 @@ function matchClause(item: DriveItem, clause: QueryClause): boolean {
|
||||
if (clause.op === 'contains') return item.mimeType.includes(clause.value)
|
||||
if (clause.op === '!=') return item.mimeType !== clause.value
|
||||
return item.mimeType === clause.value
|
||||
case 'fullText':
|
||||
// The live API defines only `contains` for fullText; any other
|
||||
// operator is an invalid query, reported as the 400 the caller
|
||||
// catches below.
|
||||
if (clause.op !== 'contains') {
|
||||
throw new Error(`unsupported operator for fullText: ${clause.op}`)
|
||||
}
|
||||
return fullTextOf(item).toLowerCase().includes(clause.value.toLowerCase())
|
||||
case 'trashed':
|
||||
return item.trashed === (clause.value === 'true')
|
||||
case 'modifiedTime': {
|
||||
@@ -500,13 +523,15 @@ function listFiles(query: URLSearchParams): [number, object] {
|
||||
items = items.filter((item) => item.driveId === undefined)
|
||||
}
|
||||
if (q !== null && q.trim() !== '') {
|
||||
let clauses: QueryClause[]
|
||||
// Matching sits inside the guard too: an unknown field surfaces from
|
||||
// matchClause, and the live API answers a query it cannot interpret
|
||||
// with 400 invalid-query, never a 500.
|
||||
try {
|
||||
clauses = parseDriveQuery(q)
|
||||
const clauses = parseDriveQuery(q)
|
||||
items = items.filter((item) => clauses.every((c) => matchClause(item, c)))
|
||||
} catch (err) {
|
||||
return googleError(400, err instanceof Error ? err.message : String(err), 'INVALID_ARGUMENT')
|
||||
}
|
||||
items = items.filter((item) => clauses.every((c) => matchClause(item, c)))
|
||||
} else {
|
||||
items = items.filter((item) => !item.trashed)
|
||||
}
|
||||
|
||||
@@ -1139,6 +1139,28 @@ async function appendChildren(
|
||||
const parentBlock = await db.notionBlock.findFirst({ where: { workspaceId, id: parentId } })
|
||||
if (parentPage === null && parentBlock === null) return notFound('block', parentId)
|
||||
let at = await db.notionBlock.count({ where: { workspaceId, parentId } })
|
||||
let anchorPos: number | null = null
|
||||
const after = typeof body.after === 'string' ? body.after : null
|
||||
if (after !== null) {
|
||||
// `after` inserts the new blocks directly behind an existing child;
|
||||
// later siblings shift down. The live API reports a malformed id and
|
||||
// a well-formed one that is not a child of this parent with the SAME
|
||||
// validation message (probed on 2025-09-03).
|
||||
const anchor = await db.notionBlock.findFirst({ where: { workspaceId, id: after } })
|
||||
if (anchor === null || anchor.parentId !== parentId) {
|
||||
return apiError(
|
||||
400,
|
||||
'validation_error',
|
||||
`body failed validation: body.position.after_block.id should be a valid uuid, instead was \`"${after}"\`.`,
|
||||
)
|
||||
}
|
||||
await db.notionBlock.updateMany({
|
||||
where: { workspaceId, parentId, position: { gt: anchor.position } },
|
||||
data: { position: { increment: children.length } },
|
||||
})
|
||||
anchorPos = anchor.position
|
||||
at = anchor.position + 1
|
||||
}
|
||||
const created: Json[] = []
|
||||
for (const child of children) {
|
||||
const spec = asObject(child)
|
||||
@@ -1171,9 +1193,20 @@ async function appendChildren(
|
||||
data: { hasChildren: true },
|
||||
})
|
||||
}
|
||||
// With `after`, the live API answers with the inserted blocks AND every
|
||||
// sibling behind them, in order (probed); a plain append returns just
|
||||
// the inserted blocks.
|
||||
let results = created
|
||||
if (anchorPos !== null) {
|
||||
const fromAnchor = await db.notionBlock.findMany({
|
||||
where: { workspaceId, parentId, position: { gt: anchorPos } },
|
||||
orderBy: [{ position: 'asc' }, { id: 'asc' }],
|
||||
})
|
||||
results = fromAnchor.map((row) => blockJson(row as BlockRow))
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
json: { object: 'list', results: created, has_more: false, next_cursor: null },
|
||||
json: { object: 'list', results, has_more: false, next_cursor: null },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
"command": "awk -F , 'BEGIN{OFS=\":\"} {print $1, $2}' /data/csv.csv",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "name age\nalice 30\nbob 25\n",
|
||||
"stdout": "name:age\nalice:30\nbob:25\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "awk_simple_assignment_executes",
|
||||
"seq": 962340,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "printf 'line\\n' | awk '{x = 1; print x}'",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "1\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "awk_rejects_function_call",
|
||||
"seq": 962341,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "printf 'line\\n' | awk '{print toupper($1)}'",
|
||||
"expect": {
|
||||
"exit": 2,
|
||||
"stdout": "",
|
||||
"stderr": "awk: unsupported construct: 'print toupper($1)'\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "awk_rejects_arithmetic_assignment",
|
||||
"seq": 962342,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "printf 'line\\n' | awk '{x = y + 1; print x}'",
|
||||
"expect": {
|
||||
"exit": 2,
|
||||
"stdout": "",
|
||||
"stderr": "awk: unsupported construct: 'x = y + 1'\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "date_d_relative_from_iso_base",
|
||||
"seq": 962330,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "date -u -d '2026-08-16 12:00:00 24 hours ago' '+%F %T'",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "2026-08-15 12:00:00\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "date_d_epoch",
|
||||
"seq": 962331,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "date -u -d '@1755300000' '+%F %T'",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "2025-08-15 23:20:00\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "date_d_month_normalization",
|
||||
"seq": 962332,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "date -u -d '2026-01-31 1 month' '+%F'",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "2026-03-03\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "date_d_invalid_fails_loud",
|
||||
"seq": 962333,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "date -d 'not a date'",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "date: invalid date 'not a date'\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "date_gnu_format_specifiers",
|
||||
"seq": 962334,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "date -u -d '2026-08-16T13:45:30Z' '+%F|%T|%q|%C|%R|%r'",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "2026-08-16|13:45:30|3|20|13:45|01:45:30 PM\n",
|
||||
"stderr": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "find_printf_paths_and_types",
|
||||
"seq": 962300,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/fpx1/sub && printf 'hello\\n' > /data/fpx1/a.txt && printf 'hi\\n' > /data/fpx1/sub/b.txt && find /data/fpx1 -printf '%p %y %d\\n' && rm -r /data/fpx1",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/data/fpx1 d 0\n/data/fpx1/a.txt f 1\n/data/fpx1/sub d 1\n/data/fpx1/sub/b.txt f 2\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "find_printf_name_size_escapes",
|
||||
"seq": 962301,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/fpx2 && printf 'hello\\n' > /data/fpx2/a.txt && find /data/fpx2 -name a.txt -printf '%f\\t%s\\n' && find /data/fpx2 -name a.txt -printf '%P|%h\\n' && rm -r /data/fpx2",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "a.txt\t6\na.txt|/data/fpx2\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "find_printf_unknown_directive_warns",
|
||||
"seq": 962302,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/fpx3 && printf 'x' > /data/fpx3/a.txt && find /data/fpx3 -name a.txt -printf '%Q\\n' && rm -r /data/fpx3",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "%Q\n",
|
||||
"stderr": "find: warning: unrecognized format directive '%Q'\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "sed_address_escaped_delimiter",
|
||||
"seq": 962320,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/sa && printf 'x\\na/b\\ny\\n' > /data/sa/f && sed '/a\\/b/d' /data/sa/f && rm -r /data/sa",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "x\ny\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sed_address_custom_delimiter",
|
||||
"seq": 962321,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/sa2 && printf 'a/b\\nz\\n' > /data/sa2/f && sed '\\%a/b%d' /data/sa2/f && rm -r /data/sa2",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "z\n",
|
||||
"stderr": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "sed_inplace_change",
|
||||
"seq": 962310,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/si && printf 'one\\ntwo\\n' > /data/si/f && sed -i 'c chg' /data/si/f && cat /data/si/f && rm -r /data/si",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "chg\nchg\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sed_inplace_insert",
|
||||
"seq": 962311,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/si2 && printf 'one\\ntwo\\nthree\\n' > /data/si2/f && sed -i '2i inserted' /data/si2/f && cat /data/si2/f && rm -r /data/si2",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "one\ninserted\ntwo\nthree\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sed_inplace_print_doubles",
|
||||
"seq": 962312,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/si3 && printf 'one\\ntwo\\n' > /data/si3/f && sed -i p /data/si3/f && cat /data/si3/f && rm -r /data/si3",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "one\none\ntwo\ntwo\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sed_inplace_quit_truncates",
|
||||
"seq": 962313,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"databricks",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"databricks-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"sharepoint-prefix",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/si4 && printf 'one\\ntwo\\nthree\\n' > /data/si4/f && sed -i 2q /data/si4/f && cat /data/si4/f && rm -r /data/si4",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "one\ntwo\n",
|
||||
"stderr": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -336,6 +336,7 @@ class FindArgs:
|
||||
or_names: list[str] | None = None
|
||||
empty: bool = False
|
||||
tree: PredNode | None = None
|
||||
printf: str | None = None
|
||||
|
||||
|
||||
def args_to_tree(args: FindArgs) -> PredNode:
|
||||
|
||||
@@ -13,8 +13,13 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from stat import S_IFLNK, filemode
|
||||
|
||||
from mirage.commands.errors import FindParseError
|
||||
from mirage.types import FileStat, FileType, PathSpec
|
||||
from mirage.utils.dates import iso_timestamp
|
||||
from mirage.utils.stat_view import DIR_MODE, FILE_MODE
|
||||
|
||||
|
||||
def _parse_depth(value: str, flag: str) -> int:
|
||||
@@ -65,3 +70,178 @@ def _parse_mtime(spec: str) -> tuple[float | None, float | None]:
|
||||
if spec.startswith("-"):
|
||||
return now - n * day, None
|
||||
return now - (n + 1) * day, now - n * day
|
||||
|
||||
|
||||
_PRINTF_ESCAPES = {
|
||||
"n": "\n",
|
||||
"t": "\t",
|
||||
"r": "\r",
|
||||
"0": "\0",
|
||||
"\\": "\\",
|
||||
"a": "\a",
|
||||
"b": "\b",
|
||||
"f": "\f",
|
||||
"v": "\v",
|
||||
}
|
||||
_STAT_DIRECTIVES = frozenset("syYmMT")
|
||||
_TYPE_LETTER = {FileType.DIRECTORY: "d", FileType.SYMLINK: "l"}
|
||||
# One mode per kind, spelled from the same constants every stat
|
||||
# translator uses (utils/stat_view.py); links are 777 the way ls draws
|
||||
# them.
|
||||
_KIND_MODE = {"d": DIR_MODE, "l": S_IFLNK | 0o777, "f": FILE_MODE}
|
||||
|
||||
|
||||
def printf_needs_stat(fmt: str) -> bool:
|
||||
"""Whether a -printf format reads anything off the entry's stat.
|
||||
|
||||
Args:
|
||||
fmt (str): the format string as typed.
|
||||
"""
|
||||
i = 0
|
||||
while i < len(fmt) - 1:
|
||||
if fmt[i] == "%" and fmt[i + 1] in _STAT_DIRECTIVES:
|
||||
return True
|
||||
if fmt[i] in ("%", "\\"):
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
return False
|
||||
|
||||
|
||||
def _relative_part(row: str, search: PathSpec) -> str:
|
||||
base = search.raw_path or search.virtual
|
||||
if row == base:
|
||||
return ""
|
||||
stem = base if base.endswith("/") else base + "/"
|
||||
if row.startswith(stem):
|
||||
return row[len(stem):]
|
||||
return row
|
||||
|
||||
|
||||
def unrespell_raw(row: str, virtual: str, raw: str) -> str:
|
||||
"""Map one respelled display row back to its virtual path.
|
||||
|
||||
The inverse of ``respell_one``: rows were rewritten to carry the
|
||||
operand as typed, and the stat probe needs the resolved spelling
|
||||
back.
|
||||
|
||||
Args:
|
||||
row (str): the display row.
|
||||
virtual (str): the operand's resolved absolute path.
|
||||
raw (str): the operand as typed.
|
||||
"""
|
||||
if not raw or raw == virtual:
|
||||
return row
|
||||
if row == raw:
|
||||
return virtual
|
||||
stem = raw if raw.endswith("/") else raw + "/"
|
||||
if row.startswith(stem):
|
||||
return (virtual.rstrip("/") or "") + "/" + row[len(stem):]
|
||||
return row
|
||||
|
||||
|
||||
def _mtime_epoch(st: FileStat | None) -> float:
|
||||
if st is None or st.modified is None:
|
||||
return 0.0
|
||||
return iso_timestamp(st.modified) or 0.0
|
||||
|
||||
|
||||
def _expand_time(letter: str, ts: float, directive_src: str,
|
||||
warnings: list[str]) -> str:
|
||||
if letter == "@":
|
||||
return f"{ts:.10f}"
|
||||
dt = datetime.fromtimestamp(ts, timezone.utc)
|
||||
if letter == "+":
|
||||
frac = f"{ts:.10f}".split(".")[1]
|
||||
return dt.strftime("%Y-%m-%d+%H:%M:%S") + "." + frac
|
||||
try:
|
||||
return dt.strftime(f"%{letter}")
|
||||
except ValueError:
|
||||
_warn_unrecognized(directive_src, warnings)
|
||||
return directive_src
|
||||
|
||||
|
||||
def _warn_unrecognized(src: str, warnings: list[str]) -> None:
|
||||
kind = "escape" if src.startswith("\\") else "format directive"
|
||||
line = f"find: warning: unrecognized {kind} '{src}'"
|
||||
if line not in warnings:
|
||||
warnings.append(line)
|
||||
|
||||
|
||||
def expand_printf(fmt: str, row: str, search: PathSpec, st: FileStat | None,
|
||||
warnings: list[str]) -> str:
|
||||
"""Expand one -printf format against one result row.
|
||||
|
||||
Directives cover what GNU's find agents actually use: the path family
|
||||
(%p %P %f %h %d), the stat family (%s %y %Y %m %M), %T times, and the
|
||||
backslash escapes. An unrecognized directive or escape renders
|
||||
literally and adds GNU's warning line once, exit code untouched --
|
||||
which is GNU's own behavior. Times render in UTC (mirage timestamps
|
||||
are zone-carrying ISO strings; GNU renders the local zone).
|
||||
|
||||
Args:
|
||||
fmt (str): the format string as typed.
|
||||
row (str): the display row (operand-respelled).
|
||||
search (PathSpec): the start point the row came from.
|
||||
st (FileStat | None): the row's stat, when the format needs one.
|
||||
warnings (list[str]): sink for GNU's warning lines, deduplicated.
|
||||
"""
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
n = len(fmt)
|
||||
kind = ("f" if st is None or st.type is None else _TYPE_LETTER.get(
|
||||
st.type, "f"))
|
||||
while i < n:
|
||||
ch = fmt[i]
|
||||
if ch == "\\" and i + 1 < n:
|
||||
nxt = fmt[i + 1]
|
||||
if nxt in _PRINTF_ESCAPES:
|
||||
out.append(_PRINTF_ESCAPES[nxt])
|
||||
else:
|
||||
_warn_unrecognized(f"\\{nxt}", warnings)
|
||||
out.append(fmt[i:i + 2])
|
||||
i += 2
|
||||
continue
|
||||
if ch != "%" or i + 1 >= n:
|
||||
out.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
code = fmt[i + 1]
|
||||
i += 2
|
||||
if code == "%":
|
||||
out.append("%")
|
||||
elif code == "p":
|
||||
out.append(row)
|
||||
elif code == "P":
|
||||
out.append(_relative_part(row, search))
|
||||
elif code == "f":
|
||||
trimmed = row.rstrip("/")
|
||||
out.append(trimmed.rsplit("/", 1)[-1] if trimmed else "/")
|
||||
elif code == "h":
|
||||
trimmed = row.rstrip("/")
|
||||
if "/" not in trimmed:
|
||||
out.append("." if trimmed else "/")
|
||||
else:
|
||||
head = trimmed.rsplit("/", 1)[0]
|
||||
out.append(head if head else "/")
|
||||
elif code == "d":
|
||||
rel = _relative_part(row, search)
|
||||
out.append("0" if not rel else str(rel.count("/") + 1))
|
||||
elif code == "s":
|
||||
out.append(str((st.size if st is not None else 0) or 0))
|
||||
elif code in ("y", "Y"):
|
||||
out.append("U" if st is None else kind)
|
||||
elif code == "m":
|
||||
out.append(format(_KIND_MODE[kind] & 0o7777, "o"))
|
||||
elif code == "M":
|
||||
out.append(filemode(_KIND_MODE[kind]))
|
||||
elif code == "T" and i < n:
|
||||
letter = fmt[i]
|
||||
i += 1
|
||||
out.append(
|
||||
_expand_time(letter, _mtime_epoch(st), f"%T{letter}",
|
||||
warnings))
|
||||
else:
|
||||
_warn_unrecognized(f"%{code}", warnings)
|
||||
out.append(f"%{code}")
|
||||
return "".join(out)
|
||||
|
||||
@@ -28,6 +28,7 @@ _VALUE_PREDICATES = frozenset({
|
||||
"-mtime",
|
||||
"-maxdepth",
|
||||
"-mindepth",
|
||||
"-printf",
|
||||
})
|
||||
|
||||
_BARE_PREDICATES = frozenset({
|
||||
@@ -67,6 +68,7 @@ class FindExpr:
|
||||
mtime_min: float | None = None
|
||||
mtime_max: float | None = None
|
||||
uses_empty: bool = False
|
||||
printf: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -159,6 +161,14 @@ def _parse_primary(state: _State) -> PredNode:
|
||||
return Path(value)
|
||||
if tok == "-type":
|
||||
return _type_node(value)
|
||||
if tok == "-printf":
|
||||
# An action, not a test: it always matches, replaces the
|
||||
# default -print rendering, and one format applies to every
|
||||
# row (GNU evaluates actions per expression position, which
|
||||
# the flat window cannot express; a single trailing -printf,
|
||||
# the way agents write it, renders identically).
|
||||
state.expr.printf = value
|
||||
return TrueNode()
|
||||
if tok == "-maxdepth":
|
||||
state.expr.maxdepth = _int_arg(value, "-maxdepth")
|
||||
return TrueNode()
|
||||
|
||||
@@ -24,6 +24,35 @@ from mirage.commands.spec.types import CommandName, FlagView
|
||||
from mirage.commands.spec.usage import extra_operand_error
|
||||
from mirage.io.types import ByteSource, IOResult
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.dates import parse_date_expr
|
||||
|
||||
|
||||
def _expand_gnu_only(fmt: str, dt: datetime) -> str:
|
||||
"""Expand the directives GNU date implements itself, ahead of strftime.
|
||||
|
||||
``%q`` (quarter) exists in no C library strftime, so passing it
|
||||
through prints a mangled literal; GNU expands it before formatting
|
||||
and so does this. ``%%`` pairs are stepped over, keeping ``%%q``
|
||||
literal.
|
||||
|
||||
Args:
|
||||
fmt (str): the + format as typed.
|
||||
dt (datetime): the moment being rendered.
|
||||
"""
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i < len(fmt):
|
||||
if fmt[i] == "%" and i + 1 < len(fmt):
|
||||
nxt = fmt[i + 1]
|
||||
if nxt == "q":
|
||||
out.append(str((dt.month - 1) // 3 + 1))
|
||||
else:
|
||||
out.append(fmt[i:i + 2])
|
||||
i += 2
|
||||
continue
|
||||
out.append(fmt[i])
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
@command("date", resource=None, spec=SPECS["date"], provision=pure_provision)
|
||||
@@ -39,9 +68,13 @@ async def date(
|
||||
if len(texts) > 1:
|
||||
raise extra_operand_error(CommandName.DATE, texts[1])
|
||||
if d is not None:
|
||||
dt = datetime.fromisoformat(d)
|
||||
if u and dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
parsed_d = parse_date_expr(d, utc=u)
|
||||
if parsed_d is None:
|
||||
# GNU's refusal, exit 1: a wrong answer with exit 0 poisons
|
||||
# whatever consumed it (the NaN-timestamp corpus failure).
|
||||
return None, IOResult(
|
||||
exit_code=1, stderr=f"date: invalid date '{d}'\n".encode())
|
||||
dt = parsed_d
|
||||
elif u:
|
||||
dt = datetime.now(timezone.utc)
|
||||
else:
|
||||
@@ -56,7 +89,7 @@ async def date(
|
||||
elif fl.as_bool("R"):
|
||||
result = email.utils.format_datetime(dt)
|
||||
elif fmt is not None:
|
||||
result = dt.strftime(fmt)
|
||||
result = dt.strftime(_expand_gnu_only(fmt, dt))
|
||||
else:
|
||||
result = dt.strftime("%a %b %d %H:%M:%S %Z %Y") if u else dt.strftime(
|
||||
"%a %b %d %H:%M:%S %Y")
|
||||
|
||||
@@ -48,14 +48,152 @@ def _parse_program(program: str) -> tuple[str, str]:
|
||||
return program, ""
|
||||
|
||||
|
||||
_IDENT_RE = re.compile(r"[A-Za-z_]\w*\Z")
|
||||
_NUMBER_RE = re.compile(r"-?(?:\d+\.?\d*|\.\d+)\Z")
|
||||
|
||||
|
||||
def _is_simple_operand(tok: str) -> bool:
|
||||
"""Whether the scraper can evaluate this token as a value.
|
||||
|
||||
The supported grammar is deliberately small: a double-quoted string
|
||||
with no embedded quote, a numeric literal, a plain identifier, or a
|
||||
``$`` field naming a number or an identifier. Anything else (function
|
||||
calls, arithmetic, concatenation) has no evaluator here and must be
|
||||
refused rather than echoed as its own source text.
|
||||
|
||||
Args:
|
||||
tok (str): the token as written in the program.
|
||||
"""
|
||||
if not tok:
|
||||
return False
|
||||
if len(tok) >= 2 and tok.startswith('"') and tok.endswith('"'):
|
||||
return '"' not in tok[1:-1]
|
||||
if tok.startswith(FIELD_PREFIX):
|
||||
inner = tok[1:]
|
||||
return inner.isdigit() or bool(_IDENT_RE.match(inner))
|
||||
return bool(_IDENT_RE.match(tok) or _NUMBER_RE.match(tok))
|
||||
|
||||
|
||||
def _reject(construct: str) -> None:
|
||||
raise UsageError(f"awk: unsupported construct: '{construct}'")
|
||||
|
||||
|
||||
def _validate_print_args(args: str, stmt: str) -> None:
|
||||
for tok in re.split(r",\s*", args):
|
||||
if not _is_simple_operand(tok.strip()):
|
||||
_reject(stmt)
|
||||
|
||||
|
||||
def _validate_action(action: str) -> None:
|
||||
"""Refuse any statement the streamer would silently drop or mangle.
|
||||
|
||||
``_eval_statements`` executes ``print``, ``var = value`` and
|
||||
``var += value``; every other statement used to vanish (and
|
||||
``printf`` ran as a mangled ``print``), so an agent's script exited 0
|
||||
having done nothing. Mirrors the statement split the evaluator uses.
|
||||
|
||||
Args:
|
||||
action (str): the action block's source text.
|
||||
"""
|
||||
for stmt in action.split(";"):
|
||||
stmt = stmt.strip()
|
||||
if not stmt:
|
||||
continue
|
||||
m = re.match(r"\w+\s*\+=\s*(.+)\Z", stmt)
|
||||
if m:
|
||||
if not _is_simple_operand(m.group(1).strip()):
|
||||
_reject(stmt)
|
||||
continue
|
||||
if not re.match(rf"{PRINT_STMT}\b", stmt):
|
||||
m_set = _ASSIGN_RE.match(stmt)
|
||||
if m_set:
|
||||
if not _is_simple_operand(m_set.group(2).strip()):
|
||||
_reject(stmt)
|
||||
continue
|
||||
if stmt == PRINT_STMT:
|
||||
continue
|
||||
if re.match(rf"{PRINT_STMT}\b", stmt):
|
||||
args = stmt[len(PRINT_STMT):].strip()
|
||||
if args:
|
||||
_validate_print_args(args, stmt)
|
||||
continue
|
||||
_reject(stmt)
|
||||
|
||||
|
||||
def _validate_simple(expr: str) -> None:
|
||||
expr = expr.strip()
|
||||
m = re.match(rf"(.+?)\s*({CMP_OP_PATTERN})\s*(.+)", expr)
|
||||
if not m:
|
||||
if len(expr) >= 2 and expr.startswith("/") and expr.endswith("/"):
|
||||
return
|
||||
if not _is_simple_operand(expr):
|
||||
_reject(expr)
|
||||
return
|
||||
lhs = m.group(1).strip()
|
||||
rhs = m.group(3).strip()
|
||||
if not _is_simple_operand(lhs):
|
||||
_reject(expr)
|
||||
if rhs.startswith('"') or rhs.startswith(FIELD_PREFIX):
|
||||
if not _is_simple_operand(rhs):
|
||||
_reject(expr)
|
||||
return
|
||||
# A bare right-hand side compares as a literal in this dialect, so any
|
||||
# word is fine; structural characters mean an expression nothing here
|
||||
# evaluates (`length(x)`, `a[1]`).
|
||||
if any(ch in rhs for ch in "(){}["):
|
||||
_reject(expr)
|
||||
|
||||
|
||||
def _validate_condition(condition: str) -> None:
|
||||
"""Refuse any pattern ``_eval_condition`` cannot actually decide.
|
||||
|
||||
Mirrors its decomposition exactly (``||`` first, then ``&&``, then one
|
||||
simple comparison / regex / truthiness probe), so everything the
|
||||
evaluator runs is accepted and everything it would misread (`~`,
|
||||
arithmetic, parenthesized groups) is refused up front.
|
||||
|
||||
Args:
|
||||
condition (str): the pattern's source text.
|
||||
"""
|
||||
condition = condition.strip()
|
||||
if not condition or condition in (AwkBlock.BEGIN, AwkBlock.END):
|
||||
return
|
||||
if AwkBoolOp.OR in condition:
|
||||
for part in condition.split(AwkBoolOp.OR):
|
||||
_validate_condition(part)
|
||||
return
|
||||
if AwkBoolOp.AND in condition:
|
||||
for part in condition.split(AwkBoolOp.AND):
|
||||
_validate_condition(part)
|
||||
return
|
||||
_validate_simple(condition)
|
||||
|
||||
|
||||
def _validate_program(program: str) -> None:
|
||||
begin, main, end = _parse_blocks(program)
|
||||
condition, action = _parse_program(main) if main else ("", "")
|
||||
if begin:
|
||||
_validate_action(begin)
|
||||
if end:
|
||||
_validate_action(end)
|
||||
_validate_condition(condition)
|
||||
if action:
|
||||
_validate_action(action)
|
||||
|
||||
|
||||
def _resolve_token(tok: str, field_map: Mapping[str, str]) -> str:
|
||||
if tok.startswith(FIELD_PREFIX):
|
||||
inner = tok[1:]
|
||||
if inner in field_map:
|
||||
ref = field_map[inner]
|
||||
return field_map.get(f"{FIELD_PREFIX}{ref}", "")
|
||||
return field_map.get(tok, tok)
|
||||
return field_map.get(tok, tok)
|
||||
# An out-of-range field is empty in awk, never its own spelling.
|
||||
return field_map.get(tok, "")
|
||||
if tok in field_map:
|
||||
return field_map[tok]
|
||||
# An unset variable reads as the empty string, not its own name; a
|
||||
# numeric literal is its own value.
|
||||
return "" if _IDENT_RE.match(tok) else tok
|
||||
|
||||
|
||||
def _eval_simple(expr: str, field_map: Mapping[str, str]) -> bool:
|
||||
@@ -108,15 +246,54 @@ def _eval_condition(condition: str, field_map: Mapping[str, str]) -> bool:
|
||||
return _eval_simple(condition, field_map)
|
||||
|
||||
|
||||
def _eval_action(action: str, field_map: Mapping[str, str]) -> str | None:
|
||||
_ASSIGN_RE = re.compile(r"([A-Za-z_]\w*)\s*=(?!=)\s*(.+)\Z")
|
||||
|
||||
|
||||
def _eval_statements(action: str, field_map: dict[str, str],
|
||||
accum: dict[str, float],
|
||||
variables: dict[str, str]) -> str | None:
|
||||
"""Run an action's statements in written order.
|
||||
|
||||
Three statement forms exist in this dialect: `var += value`
|
||||
accumulates, `var = value` assigns (persisting across records via
|
||||
``variables``, which is how ``BEGIN {OFS=":"}`` reaches every print),
|
||||
and `print` emits its arguments joined with OFS. One sequential pass,
|
||||
so `x = 1; print x` sees the assignment.
|
||||
|
||||
Args:
|
||||
action (str): the action block's source text.
|
||||
field_map (dict[str, str]): the record's fields and variables.
|
||||
accum (dict[str, float]): running `+=` totals.
|
||||
variables (dict[str, str]): the program's global variables.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
printed = False
|
||||
for stmt in action.split(";"):
|
||||
stmt = stmt.strip()
|
||||
if not stmt:
|
||||
continue
|
||||
m_add = re.match(r"(\w+)\s*\+=\s*(.+)", stmt)
|
||||
if m_add:
|
||||
var, expr = m_add.group(1), m_add.group(2).strip()
|
||||
val = field_map.get(expr, expr)
|
||||
accum[var] = accum.get(var, 0.0) + to_number(val)
|
||||
continue
|
||||
if not stmt.startswith(PRINT_STMT):
|
||||
m_set = _ASSIGN_RE.match(stmt)
|
||||
if m_set:
|
||||
var, raw = m_set.group(1), m_set.group(2).strip()
|
||||
if raw.startswith('"') and raw.endswith('"') and len(raw) >= 2:
|
||||
val = raw[1:-1]
|
||||
else:
|
||||
val = _resolve_token(raw, field_map)
|
||||
variables[var] = val
|
||||
field_map[var] = val
|
||||
continue
|
||||
if not stmt.startswith(PRINT_STMT):
|
||||
continue
|
||||
printed = True
|
||||
args = stmt[len(PRINT_STMT):].strip()
|
||||
ofs = field_map.get("OFS", " ")
|
||||
if not args:
|
||||
parts.append(field_map.get(AwkBuiltin.REC, ""))
|
||||
continue
|
||||
@@ -128,7 +305,7 @@ def _eval_action(action: str, field_map: Mapping[str, str]) -> str | None:
|
||||
vals.append(tok[1:-1])
|
||||
else:
|
||||
vals.append(_resolve_token(tok, field_map))
|
||||
parts.append(" ".join(vals))
|
||||
parts.append(ofs.join(vals))
|
||||
return "\n".join(parts) if printed else None
|
||||
|
||||
|
||||
@@ -174,16 +351,6 @@ def _parse_blocks(program: str) -> tuple[str, str, str]:
|
||||
return begin, main, end
|
||||
|
||||
|
||||
def _eval_accumulator(action: str, field_map: Mapping[str, str],
|
||||
accum: dict[str, float]) -> None:
|
||||
for stmt in action.split(";"):
|
||||
m = re.match(r"(\w+)\s*\+=\s*(.+)", stmt.strip())
|
||||
if m:
|
||||
var, expr = m.group(1), m.group(2).strip()
|
||||
val = field_map.get(expr, expr)
|
||||
accum[var] = accum.get(var, 0.0) + to_number(val)
|
||||
|
||||
|
||||
async def _awk_stream(
|
||||
sources: Sequence[AsyncIterator[bytes]],
|
||||
program: str,
|
||||
@@ -201,7 +368,7 @@ async def _awk_stream(
|
||||
AwkBuiltin.NR: "0",
|
||||
AwkBuiltin.NF: "0",
|
||||
} | variables
|
||||
result = _eval_action(begin, begin_map)
|
||||
result = _eval_statements(begin, begin_map, accum, variables)
|
||||
if result is not None:
|
||||
yield (result + "\n").encode()
|
||||
|
||||
@@ -214,8 +381,8 @@ async def _awk_stream(
|
||||
field_map = _build_field_map(line, fs, nr, variables)
|
||||
if condition and not _eval_condition(condition, field_map):
|
||||
continue
|
||||
_eval_accumulator(action, field_map, accum)
|
||||
result = _eval_action(action, field_map) if action else line
|
||||
result = (_eval_statements(action, field_map, accum, variables)
|
||||
if action else line)
|
||||
if result is not None:
|
||||
yield (result + "\n").encode()
|
||||
|
||||
@@ -227,7 +394,7 @@ async def _awk_stream(
|
||||
} | variables
|
||||
for k, v in accum.items():
|
||||
end_map[k] = format_number(v)
|
||||
result = _eval_action(end, end_map)
|
||||
result = _eval_statements(end, end_map, accum, variables)
|
||||
if result is not None:
|
||||
yield (result + "\n").encode()
|
||||
|
||||
@@ -281,6 +448,8 @@ async def awk(
|
||||
else:
|
||||
raise UsageError(USAGE)
|
||||
|
||||
_validate_program(program)
|
||||
|
||||
variables: dict[str, str] = {}
|
||||
for assignment in f.assignments:
|
||||
if "=" in assignment:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
|
||||
from mirage.cache.index import IndexCacheStore
|
||||
from mirage.commands.builtin.find_eval import (FindArgs, FindEntry, PredNode,
|
||||
@@ -8,7 +9,9 @@ from mirage.commands.builtin.find_eval import (FindArgs, FindEntry, PredNode,
|
||||
prefix_path_nodes,
|
||||
start_basename, tree_has_empty)
|
||||
from mirage.commands.builtin.find_helper import (_parse_depth, _parse_mtime,
|
||||
_parse_size)
|
||||
_parse_size, expand_printf,
|
||||
printf_needs_stat,
|
||||
unrespell_raw)
|
||||
from mirage.commands.builtin.find_parse import parse_find_expression
|
||||
from mirage.commands.builtin.utils.output import format_records
|
||||
from mirage.commands.config import CommandOpts
|
||||
@@ -47,6 +50,7 @@ def parse_find_args(
|
||||
mindepth=expr.mindepth,
|
||||
empty=expr.uses_empty,
|
||||
tree=expr.tree,
|
||||
printf=expr.printf,
|
||||
)
|
||||
ftype: FindType | str | None = type
|
||||
if type in (FindType.DIRECTORY.value, FindType.FILE.value):
|
||||
@@ -105,6 +109,81 @@ async def apply_mtime_filter(
|
||||
return filtered
|
||||
|
||||
|
||||
async def _printf_stat(
|
||||
row: str,
|
||||
search: PathSpec,
|
||||
stat: Callable[[PathSpec], Awaitable[FileStat]] | None,
|
||||
stat_path: StatPath | None,
|
||||
links: LinkView | None,
|
||||
) -> FileStat | None:
|
||||
virtual = unrespell_raw(row, search.virtual, search.raw_path
|
||||
or search.virtual)
|
||||
if links is not None:
|
||||
link_row = links.stat_at(virtual)
|
||||
if link_row is not None:
|
||||
return link_row
|
||||
if stat is not None:
|
||||
prefix = mount_prefix_of(search.virtual, search.resource_path)
|
||||
spec = PathSpec(virtual=virtual,
|
||||
directory=virtual,
|
||||
resolved=False,
|
||||
resource_path=mount_key(virtual, prefix))
|
||||
try:
|
||||
return await stat(spec)
|
||||
except (FileNotFoundError, NotADirectoryError, ValueError):
|
||||
return None
|
||||
if stat_path is not None:
|
||||
# The dispatcher probe answers for every backend, including the
|
||||
# ones that wire no cheap local stat (an object store); it is the
|
||||
# same channel resolve_start classifies start points on.
|
||||
return await stat_path(virtual)
|
||||
return None
|
||||
|
||||
|
||||
async def _stat_with_index(stat: Callable[..., Awaitable[FileStat]],
|
||||
index: IndexCacheStore | None,
|
||||
spec: PathSpec) -> FileStat:
|
||||
return await stat(spec, index)
|
||||
|
||||
|
||||
async def render_printf_rows(
|
||||
pairs: list[tuple[str, PathSpec]],
|
||||
fmt: str,
|
||||
stat: Callable[[PathSpec], Awaitable[FileStat]] | None,
|
||||
stat_path: StatPath | None,
|
||||
links: LinkView | None,
|
||||
missing: list[str],
|
||||
) -> tuple[ByteSource | None, IOResult]:
|
||||
"""Render matched rows through a -printf format.
|
||||
|
||||
Stats are fetched per row only when the format reads one (%s %y %m
|
||||
%M %T), through the same overlay-aware channel the -mtime filter
|
||||
uses, with namespace links answered first since a link row has no
|
||||
backend inode. Warning lines (unrecognized directives) ride stderr
|
||||
without touching the exit code, GNU's behavior; missing start points
|
||||
keep forcing exit 1.
|
||||
|
||||
Args:
|
||||
pairs (list[tuple[str, PathSpec]]): display rows with the start
|
||||
point each came from.
|
||||
fmt (str): the -printf format as typed.
|
||||
stat (Callable | None): bound overlay-aware stat, when wired.
|
||||
links (LinkView | None): the namespace's symlink facts.
|
||||
missing (list[str]): diagnostics for start points not walked.
|
||||
"""
|
||||
warnings: list[str] = []
|
||||
needs = printf_needs_stat(fmt)
|
||||
parts: list[str] = []
|
||||
for row, search in pairs:
|
||||
st = (await _printf_stat(row, search, stat, stat_path, links)
|
||||
if needs else None)
|
||||
parts.append(expand_printf(fmt, row, search, st, warnings))
|
||||
err = missing + warnings
|
||||
io = IOResult(stderr=("\n".join(err) + "\n").encode() if err else None,
|
||||
exit_code=1 if missing else 0)
|
||||
return "".join(parts).encode(), io
|
||||
|
||||
|
||||
def apply_mount_prefix(results: list[str], mount_prefix: str) -> list[str]:
|
||||
if not mount_prefix:
|
||||
return results
|
||||
@@ -382,6 +461,7 @@ async def find(
|
||||
# exits 1; the rows already found still print.
|
||||
results: list[str] = []
|
||||
missing: list[str] = []
|
||||
printf_pairs: list[tuple[str, PathSpec]] = []
|
||||
for search_path in searches:
|
||||
rows, detail = await _find_root(search_path,
|
||||
args,
|
||||
@@ -395,6 +475,11 @@ async def find(
|
||||
missing.append(missing_start_line(search_path, detail))
|
||||
continue
|
||||
results.extend(rows)
|
||||
if args.printf is not None:
|
||||
printf_pairs.extend((row, search_path) for row in rows)
|
||||
if args.printf is not None:
|
||||
return await render_printf_rows(printf_pairs, args.printf, stat,
|
||||
stat_path, links, missing)
|
||||
if missing:
|
||||
return format_records(results), IOResult(stderr=("\n".join(missing) +
|
||||
"\n").encode(),
|
||||
@@ -918,6 +1003,7 @@ async def find_walk_generic(
|
||||
empty=parsed.empty)
|
||||
results: list[str] = []
|
||||
missing: list[str] = []
|
||||
printf_pairs: list[tuple[str, PathSpec]] = []
|
||||
for search in searches:
|
||||
# Same start-point rule as the native-op path, so what `find` does
|
||||
# with a file or a missing operand does not depend on whether the
|
||||
@@ -930,16 +1016,24 @@ async def find_walk_generic(
|
||||
missing.append(missing_start_line(search, start.detail))
|
||||
continue
|
||||
if not start.walk:
|
||||
results.extend(start.results)
|
||||
continue
|
||||
walked = await walk_find(search,
|
||||
readdir=readdir,
|
||||
stat=stat,
|
||||
index=opts.index,
|
||||
args=args,
|
||||
links=links,
|
||||
follow=parsed.follow)
|
||||
results.extend(respell_raw(walked, search.virtual, search.raw_path))
|
||||
rows = start.results
|
||||
else:
|
||||
walked = await walk_find(search,
|
||||
readdir=readdir,
|
||||
stat=stat,
|
||||
index=opts.index,
|
||||
args=args,
|
||||
links=links,
|
||||
follow=parsed.follow)
|
||||
rows = respell_raw(walked, search.virtual, search.raw_path)
|
||||
results.extend(rows)
|
||||
if args.printf is not None:
|
||||
printf_pairs.extend((row, search) for row in rows)
|
||||
if args.printf is not None:
|
||||
return await render_printf_rows(
|
||||
printf_pairs, args.printf,
|
||||
partial(_stat_with_index, stat, opts.index), stat_path, links,
|
||||
missing)
|
||||
if missing:
|
||||
return format_records(results), IOResult(stderr=("\n".join(missing) +
|
||||
"\n").encode(),
|
||||
|
||||
@@ -95,7 +95,11 @@ async def sed(
|
||||
stderr=err or None)
|
||||
|
||||
if paths:
|
||||
modifying = in_place and any(c["cmd"] in ("s", "d") for c in commands)
|
||||
# GNU -i redirects the whole output stream to the file whatever the
|
||||
# script ran: `p` doubles lines in place, `q` truncates, `a`/`i`/`c`
|
||||
# land their text. Gating on the command set left every non-s/d
|
||||
# script printing to stdout while reporting success (#326 corpus).
|
||||
modifying = in_place
|
||||
all_outputs: list[str] = []
|
||||
writes = {}
|
||||
err = b""
|
||||
@@ -127,7 +131,9 @@ async def sed(
|
||||
cache=[p.mount_path for p in edited],
|
||||
exit_code=1 if err else 0,
|
||||
stderr=err or None)
|
||||
return "\n".join(all_outputs).encode(), IOResult(
|
||||
# GNU concatenates per-file output with no separator (each file's
|
||||
# output already carries its own newlines).
|
||||
return "".join(all_outputs).encode(), IOResult(
|
||||
exit_code=1 if err else 0, stderr=err or None)
|
||||
|
||||
raw = await _read_stdin_async(stdin)
|
||||
|
||||
@@ -67,9 +67,6 @@ def _apply_repl(m: "re.Match[str]", repl: str) -> str:
|
||||
def _parse_address(addr: str) -> tuple[str, str] | None:
|
||||
if not addr:
|
||||
return None
|
||||
if addr[0] == "/":
|
||||
end = addr.index("/", 1)
|
||||
return ("regex", addr[1:end])
|
||||
if addr.isascii() and addr.isdigit():
|
||||
return ("line", addr)
|
||||
if addr == "$":
|
||||
@@ -77,13 +74,48 @@ def _parse_address(addr: str) -> tuple[str, str] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _scan_regex_field(rest: str, start: int, delim: str) -> tuple[str, int]:
|
||||
"""Collect an address regex up to its unescaped closing delimiter.
|
||||
|
||||
A backslash escapes the next character (so ``\\/`` inside ``/re/`` is a
|
||||
literal slash) and the pair is kept verbatim: BRE escapes like ``\\+``
|
||||
must survive for the regex translator, and both engines accept a
|
||||
redundant ``\\/``.
|
||||
|
||||
Args:
|
||||
rest (str): the script text, positioned at the address.
|
||||
delim (str): the delimiter character to stop at.
|
||||
start (int): index of the first regex character.
|
||||
|
||||
Returns:
|
||||
tuple[str, int]: the regex and the index after the delimiter.
|
||||
"""
|
||||
out: list[str] = []
|
||||
i = start
|
||||
while i < len(rest):
|
||||
ch = rest[i]
|
||||
if ch == "\\" and i + 1 < len(rest):
|
||||
out.append(rest[i:i + 2])
|
||||
i += 2
|
||||
continue
|
||||
if ch == delim:
|
||||
return "".join(out), i + 1
|
||||
out.append(ch)
|
||||
i += 1
|
||||
raise ValueError("sed: unterminated address regex")
|
||||
|
||||
|
||||
def _consume_address(rest: str) -> tuple[tuple[str, str] | None, str]:
|
||||
if not rest:
|
||||
return None, rest
|
||||
if rest[0] == "/":
|
||||
end = rest.index("/", 1)
|
||||
addr = ("regex", rest[1:end])
|
||||
return addr, rest[end + 1:]
|
||||
pattern, nxt = _scan_regex_field(rest, 1, "/")
|
||||
return ("regex", pattern), rest[nxt:]
|
||||
if rest[0] == "\\" and len(rest) > 1:
|
||||
# GNU's \cREc form: the character after the backslash delimits the
|
||||
# regex in place of `/`.
|
||||
pattern, nxt = _scan_regex_field(rest, 2, rest[1])
|
||||
return ("regex", pattern), rest[nxt:]
|
||||
if rest[0] in "0123456789" or rest[0] == "$":
|
||||
num = ""
|
||||
while rest and (rest[0] in "0123456789" or rest[0] == "$"):
|
||||
|
||||
@@ -63,6 +63,7 @@ SPECS: dict[str, CommandSpec] = {
|
||||
Option(short="-iname", type="str", multiple=True),
|
||||
Option(short="-path", type="str", multiple=True),
|
||||
Option(short="-mindepth", type="str", multiple=True),
|
||||
Option(short="-printf", type="str", multiple=True),
|
||||
# GNU find's link policy: -P (no follow) is the default, -H
|
||||
# follows only the start point, -L follows everything.
|
||||
Option(short="-P"),
|
||||
|
||||
@@ -12,7 +12,185 @@
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
from calendar import monthrange
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
_UNIT_SECONDS = {
|
||||
"sec": 1,
|
||||
"second": 1,
|
||||
"min": 60,
|
||||
"minute": 60,
|
||||
"hour": 3600,
|
||||
"day": 86400,
|
||||
"week": 604800,
|
||||
}
|
||||
_CALENDAR_UNITS = ("month", "year")
|
||||
_NUMBER_UNIT_RE = re.compile(r"([+-]?\d+)([a-z]+)\Z")
|
||||
_NUMBER_RE = re.compile(r"[+-]?\d+\Z")
|
||||
|
||||
|
||||
def _date_unit(word: str) -> str | None:
|
||||
unit = word.removesuffix("s") if word != "s" else word
|
||||
if unit in _UNIT_SECONDS or unit in _CALENDAR_UNITS:
|
||||
return unit
|
||||
return None
|
||||
|
||||
|
||||
def _add_months(dt: datetime, count: int) -> datetime:
|
||||
total = dt.month - 1 + count
|
||||
year = dt.year + total // 12
|
||||
month = total % 12 + 1
|
||||
# GNU normalizes an overflowing day-of-month through mktime rather
|
||||
# than clamping: Jan 31 + 1 month is Mar 3, not Feb 28.
|
||||
days = monthrange(year, month)[1]
|
||||
day = dt.day
|
||||
if day > days:
|
||||
day -= days
|
||||
month += 1
|
||||
if month == 13:
|
||||
month = 1
|
||||
year += 1
|
||||
return dt.replace(year=year, month=month, day=day)
|
||||
|
||||
|
||||
def _shift(dt: datetime, unit: str, count: int) -> datetime:
|
||||
if unit == "month":
|
||||
return _add_months(dt, count)
|
||||
if unit == "year":
|
||||
return _add_months(dt, 12 * count)
|
||||
return dt + timedelta(seconds=_UNIT_SECONDS[unit] * count)
|
||||
|
||||
|
||||
def _localize(dt: datetime, utc: bool) -> datetime:
|
||||
if dt.tzinfo is not None:
|
||||
return dt.astimezone(timezone.utc) if utc else dt.astimezone()
|
||||
return dt.replace(tzinfo=timezone.utc) if utc else dt
|
||||
|
||||
|
||||
def _apply_relative(base: datetime, words: list[str]) -> datetime | None:
|
||||
result = base
|
||||
# What `ago` would negate: the state before the last displacement plus
|
||||
# that displacement. Re-applying from the checkpoint (rather than
|
||||
# subtracting twice) keeps month normalization exact.
|
||||
checkpoint: tuple[datetime, str, int] | None = None
|
||||
i = 0
|
||||
while i < len(words):
|
||||
word = words[i].lower()
|
||||
if word in ("now", "today"):
|
||||
checkpoint = None
|
||||
i += 1
|
||||
continue
|
||||
if word in ("yesterday", "tomorrow"):
|
||||
days = -1 if word == "yesterday" else 1
|
||||
checkpoint = (result, "day", days)
|
||||
result = _shift(result, "day", days)
|
||||
i += 1
|
||||
continue
|
||||
if word in ("last", "next"):
|
||||
if i + 1 >= len(words):
|
||||
return None
|
||||
unit = _date_unit(words[i + 1].lower())
|
||||
if unit is None:
|
||||
return None
|
||||
count = -1 if word == "last" else 1
|
||||
checkpoint = (result, unit, count)
|
||||
result = _shift(result, unit, count)
|
||||
i += 2
|
||||
continue
|
||||
if word == "ago":
|
||||
if checkpoint is None:
|
||||
return None
|
||||
before, unit, count = checkpoint
|
||||
result = _shift(before, unit, -count)
|
||||
checkpoint = None
|
||||
i += 1
|
||||
continue
|
||||
sign = 1
|
||||
if word in ("+", "-"):
|
||||
sign = -1 if word == "-" else 1
|
||||
i += 1
|
||||
if i >= len(words):
|
||||
return None
|
||||
word = words[i].lower()
|
||||
combined = _NUMBER_UNIT_RE.match(word)
|
||||
if combined:
|
||||
unit = _date_unit(combined.group(2))
|
||||
if unit is None:
|
||||
return None
|
||||
count = int(combined.group(1)) * sign
|
||||
checkpoint = (result, unit, count)
|
||||
result = _shift(result, unit, count)
|
||||
i += 1
|
||||
continue
|
||||
if _NUMBER_RE.match(word):
|
||||
if i + 1 >= len(words):
|
||||
return None
|
||||
unit = _date_unit(words[i + 1].lower())
|
||||
if unit is None:
|
||||
return None
|
||||
count = int(word) * sign
|
||||
checkpoint = (result, unit, count)
|
||||
result = _shift(result, unit, count)
|
||||
i += 2
|
||||
continue
|
||||
unit = _date_unit(word)
|
||||
if unit is not None:
|
||||
checkpoint = (result, unit, sign)
|
||||
result = _shift(result, unit, sign)
|
||||
i += 1
|
||||
continue
|
||||
return None
|
||||
return result
|
||||
|
||||
|
||||
def parse_date_expr(text: str,
|
||||
*,
|
||||
utc: bool = False,
|
||||
now: datetime | None = None) -> datetime | None:
|
||||
"""Parse a GNU `date -d` expression, or None when it is invalid.
|
||||
|
||||
Covers the forms agents actually type: ISO 8601 dates and datetimes
|
||||
(with or without zone), `@epoch`, and gnulib's relative grammar
|
||||
(`24 hours ago`, `yesterday`, `next month`, `-2 weeks`, an ISO date
|
||||
followed by displacements). A None return is the caller's cue for
|
||||
GNU's `date: invalid date '...'` refusal, never a silent fallback.
|
||||
|
||||
Args:
|
||||
text (str): the -d argument as typed.
|
||||
utc (bool): whether -u pinned the timeline to UTC.
|
||||
now (datetime | None): the current moment, injectable for tests.
|
||||
"""
|
||||
raw = text.strip()
|
||||
if not raw:
|
||||
return None
|
||||
if raw.startswith("@"):
|
||||
try:
|
||||
epoch = float(raw[1:])
|
||||
except ValueError:
|
||||
return None
|
||||
return datetime.fromtimestamp(epoch, tz=timezone.utc if utc else None)
|
||||
try:
|
||||
return _localize(datetime.fromisoformat(raw), utc)
|
||||
except ValueError:
|
||||
pass
|
||||
words = raw.split()
|
||||
if now is None:
|
||||
now = datetime.now(timezone.utc) if utc else datetime.now()
|
||||
base = now
|
||||
index = 0
|
||||
for take in (2, 1):
|
||||
if len(words) < take:
|
||||
continue
|
||||
try:
|
||||
prefix = _localize(datetime.fromisoformat(" ".join(words[:take])),
|
||||
utc)
|
||||
except ValueError:
|
||||
continue
|
||||
base = prefix
|
||||
index = take
|
||||
break
|
||||
return _apply_relative(base, words[index:])
|
||||
|
||||
|
||||
def utc_date_folder(ts: float | None = None) -> str:
|
||||
|
||||
@@ -421,3 +421,171 @@ async def test_awk_repeated_program_files_concatenate():
|
||||
read_stream=rs,
|
||||
)
|
||||
assert (await _drain(output)).decode() == "6\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awk_simple_assignment_executes():
|
||||
rb, rs = _make_backend({})
|
||||
output, _ = await awk(
|
||||
[],
|
||||
("{x = 1; print x}", ),
|
||||
None,
|
||||
read_bytes=rb,
|
||||
read_stream=rs,
|
||||
stdin=b"line\n",
|
||||
)
|
||||
assert (await _drain(output)).decode() == "1\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awk_assignment_from_field():
|
||||
rb, rs = _make_backend({})
|
||||
output, _ = await awk(
|
||||
[],
|
||||
("{x = $2; print x}", ),
|
||||
None,
|
||||
read_bytes=rb,
|
||||
read_stream=rs,
|
||||
stdin=b"a b\n",
|
||||
)
|
||||
assert (await _drain(output)).decode() == "b\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awk_ofs_joins_print_arguments():
|
||||
rb, rs = _make_backend({})
|
||||
output, _ = await awk(
|
||||
[],
|
||||
('BEGIN{OFS=":"} {print $1, $2}', ),
|
||||
None,
|
||||
read_bytes=rb,
|
||||
read_stream=rs,
|
||||
stdin=b"name age\nalice 30\n",
|
||||
)
|
||||
assert (await _drain(output)).decode() == "name:age\nalice:30\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awk_rejects_arithmetic_assignment():
|
||||
rb, rs = _make_backend({})
|
||||
with pytest.raises(UsageError, match="unsupported construct"):
|
||||
await awk(
|
||||
[],
|
||||
("{x = y + 1; print x}", ),
|
||||
None,
|
||||
read_bytes=rb,
|
||||
read_stream=rs,
|
||||
stdin=b"line\n",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awk_rejects_function_call_in_print():
|
||||
rb, rs = _make_backend({})
|
||||
with pytest.raises(UsageError, match=r"unsupported construct.*toupper"):
|
||||
await awk(
|
||||
[],
|
||||
("{print toupper($1)}", ),
|
||||
None,
|
||||
read_bytes=rb,
|
||||
read_stream=rs,
|
||||
stdin=b"line\n",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awk_rejects_printf():
|
||||
rb, rs = _make_backend({})
|
||||
with pytest.raises(UsageError, match="unsupported construct"):
|
||||
await awk(
|
||||
[],
|
||||
('{printf "%s\\n", $1}', ),
|
||||
None,
|
||||
read_bytes=rb,
|
||||
read_stream=rs,
|
||||
stdin=b"line\n",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awk_rejects_if_statement():
|
||||
rb, rs = _make_backend({})
|
||||
with pytest.raises(UsageError, match="unsupported construct"):
|
||||
await awk(
|
||||
[],
|
||||
("{if ($1) print $1}", ),
|
||||
None,
|
||||
read_bytes=rb,
|
||||
read_stream=rs,
|
||||
stdin=b"line\n",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awk_rejects_tilde_match_condition():
|
||||
rb, rs = _make_backend({})
|
||||
with pytest.raises(UsageError, match="unsupported construct"):
|
||||
await awk(
|
||||
[],
|
||||
("$1 ~ /x/ {print}", ),
|
||||
None,
|
||||
read_bytes=rb,
|
||||
read_stream=rs,
|
||||
stdin=b"x\n",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awk_rejects_arithmetic_in_condition():
|
||||
rb, rs = _make_backend({})
|
||||
with pytest.raises(UsageError, match="unsupported construct"):
|
||||
await awk(
|
||||
[],
|
||||
("NR % 2 == 0 {print}", ),
|
||||
None,
|
||||
read_bytes=rb,
|
||||
read_stream=rs,
|
||||
stdin=b"a\nb\n",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awk_rejects_program_file_with_unsupported_statement():
|
||||
rb, rs = _make_backend({"/p.awk": b'{gsub(/a/, "b"); print}\n'})
|
||||
with pytest.raises(UsageError, match="unsupported construct"):
|
||||
await awk(
|
||||
[],
|
||||
(),
|
||||
{"f": [_spec("/p.awk")]},
|
||||
read_bytes=rb,
|
||||
read_stream=rs,
|
||||
stdin=b"line\n",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awk_unset_variable_prints_empty():
|
||||
rb, rs = _make_backend({})
|
||||
output, _ = await awk(
|
||||
[],
|
||||
("{print foo}", ),
|
||||
None,
|
||||
read_bytes=rb,
|
||||
read_stream=rs,
|
||||
stdin=b"line\n",
|
||||
)
|
||||
assert (await _drain(output)).decode() == "\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awk_out_of_range_field_prints_empty():
|
||||
rb, rs = _make_backend({})
|
||||
output, _ = await awk(
|
||||
[],
|
||||
("{print $5}", ),
|
||||
None,
|
||||
read_bytes=rb,
|
||||
read_stream=rs,
|
||||
stdin=b"one two\n",
|
||||
)
|
||||
assert (await _drain(output)).decode() == "\n"
|
||||
|
||||
@@ -424,3 +424,174 @@ async def test_sed_zero_count_rejected():
|
||||
rb, wb, _ = _make_backend({})
|
||||
with pytest.raises(ValueError, match="may not be zero"):
|
||||
await sed([], "s/o/O/0", read_bytes=rb, write_bytes=wb, stdin=b"oo\n")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sed_inplace_change_writes_file():
|
||||
rb, wb, store = _make_backend({"/a.txt": b"one\ntwo\n"})
|
||||
output, io = await sed(
|
||||
[_spec("/a.txt")],
|
||||
"c chg",
|
||||
read_bytes=rb,
|
||||
write_bytes=wb,
|
||||
in_place=True,
|
||||
)
|
||||
assert output is None
|
||||
assert store["/a.txt"] == b"chg\nchg\n"
|
||||
assert io.writes == {"/a.txt": b"chg\nchg\n"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sed_inplace_insert_writes_file():
|
||||
rb, wb, store = _make_backend({"/a.txt": b"one\ntwo\nthree\n"})
|
||||
output, _ = await sed(
|
||||
[_spec("/a.txt")],
|
||||
"2i inserted",
|
||||
read_bytes=rb,
|
||||
write_bytes=wb,
|
||||
in_place=True,
|
||||
)
|
||||
assert output is None
|
||||
assert store["/a.txt"] == b"one\ninserted\ntwo\nthree\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sed_inplace_append_writes_file():
|
||||
rb, wb, store = _make_backend({"/a.txt": b"one\ntwo\n"})
|
||||
output, _ = await sed(
|
||||
[_spec("/a.txt")],
|
||||
"a app",
|
||||
read_bytes=rb,
|
||||
write_bytes=wb,
|
||||
in_place=True,
|
||||
)
|
||||
assert output is None
|
||||
assert store["/a.txt"] == b"one\napp\ntwo\napp\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sed_inplace_transliterate_writes_file():
|
||||
rb, wb, store = _make_backend({"/a.txt": b"one\ntwo\n"})
|
||||
output, _ = await sed(
|
||||
[_spec("/a.txt")],
|
||||
"y/o/0/",
|
||||
read_bytes=rb,
|
||||
write_bytes=wb,
|
||||
in_place=True,
|
||||
)
|
||||
assert output is None
|
||||
assert store["/a.txt"] == b"0ne\ntw0\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sed_inplace_print_doubles_lines_in_file():
|
||||
rb, wb, store = _make_backend({"/a.txt": b"one\ntwo\n"})
|
||||
output, _ = await sed(
|
||||
[_spec("/a.txt")],
|
||||
"p",
|
||||
read_bytes=rb,
|
||||
write_bytes=wb,
|
||||
in_place=True,
|
||||
)
|
||||
assert output is None
|
||||
assert store["/a.txt"] == b"one\none\ntwo\ntwo\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sed_inplace_suppress_print_rewrites_same_content():
|
||||
rb, wb, store = _make_backend({"/a.txt": b"one\ntwo\n"})
|
||||
output, _ = await sed(
|
||||
[_spec("/a.txt")],
|
||||
"p",
|
||||
read_bytes=rb,
|
||||
write_bytes=wb,
|
||||
in_place=True,
|
||||
suppress=True,
|
||||
)
|
||||
assert output is None
|
||||
assert store["/a.txt"] == b"one\ntwo\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sed_inplace_quit_truncates_file():
|
||||
rb, wb, store = _make_backend({"/a.txt": b"one\ntwo\nthree\n"})
|
||||
output, _ = await sed(
|
||||
[_spec("/a.txt")],
|
||||
"2q",
|
||||
read_bytes=rb,
|
||||
write_bytes=wb,
|
||||
in_place=True,
|
||||
)
|
||||
assert output is None
|
||||
assert store["/a.txt"] == b"one\ntwo\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sed_address_escaped_delimiter():
|
||||
rb, wb, _ = _make_backend({})
|
||||
output, _ = await sed(
|
||||
[],
|
||||
r"/a\/b/d",
|
||||
read_bytes=rb,
|
||||
write_bytes=wb,
|
||||
stdin=b"x\na/b\ny\n",
|
||||
)
|
||||
assert output == b"x\ny\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sed_address_custom_delimiter():
|
||||
rb, wb, _ = _make_backend({})
|
||||
output, _ = await sed(
|
||||
[],
|
||||
r"\%a/b%d",
|
||||
read_bytes=rb,
|
||||
write_bytes=wb,
|
||||
stdin=b"a/b\nz\n",
|
||||
)
|
||||
assert output == b"z\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sed_address_keeps_bre_escapes():
|
||||
rb, wb, _ = _make_backend({})
|
||||
output, _ = await sed(
|
||||
[],
|
||||
r"/a\+b/d",
|
||||
read_bytes=rb,
|
||||
write_bytes=wb,
|
||||
stdin=b"x\na+b\naab\ny\n",
|
||||
)
|
||||
assert output == b"x\na+b\ny\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sed_address_range_with_escaped_delimiters():
|
||||
rb, wb, _ = _make_backend({})
|
||||
output, _ = await sed(
|
||||
[],
|
||||
r"/a\/b/,/c\/d/d",
|
||||
read_bytes=rb,
|
||||
write_bytes=wb,
|
||||
stdin=b"x\na/b\nmid\nc/d\ny\n",
|
||||
)
|
||||
assert output == b"x\ny\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sed_unterminated_address_raises():
|
||||
rb, wb, _ = _make_backend({})
|
||||
with pytest.raises(ValueError, match="unterminated address regex"):
|
||||
await sed([], "/a\\/b", read_bytes=rb, write_bytes=wb, stdin=b"x\n")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sed_multi_file_output_concatenates_without_separator():
|
||||
rb, wb, _ = _make_backend({"/a.txt": b"A\n", "/b.txt": b"B\n"})
|
||||
output, _ = await sed(
|
||||
[_spec("/a.txt"), _spec("/b.txt")],
|
||||
"p",
|
||||
read_bytes=rb,
|
||||
write_bytes=wb,
|
||||
)
|
||||
assert output == b"A\nA\nB\nB\n"
|
||||
|
||||
@@ -50,3 +50,31 @@ def test_date_utc_format():
|
||||
year = _bytes(stdout).strip().decode()
|
||||
assert len(year) == 4
|
||||
assert year.isdigit()
|
||||
|
||||
|
||||
def test_date_relative_from_iso_base():
|
||||
ws, _ = _ws()
|
||||
stdout, io = _run_raw(
|
||||
ws, "date -u -d '2026-08-16 12:00:00 24 hours ago' '+%F %T'")
|
||||
assert _bytes(stdout).decode() == "2026-08-15 12:00:00\n"
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
def test_date_epoch_input():
|
||||
ws, _ = _ws()
|
||||
stdout, _ = _run_raw(ws, "date -u -d '@1755300000' '+%F %T'")
|
||||
assert _bytes(stdout).decode() == "2025-08-15 23:20:00\n"
|
||||
|
||||
|
||||
def test_date_month_addition_normalizes():
|
||||
ws, _ = _ws()
|
||||
stdout, _ = _run_raw(ws, "date -u -d '2026-01-31 1 month' '+%F'")
|
||||
assert _bytes(stdout).decode() == "2026-03-03\n"
|
||||
|
||||
|
||||
def test_date_invalid_date_fails_loud():
|
||||
ws, _ = _ws()
|
||||
stdout, io = _run_raw(ws, "date -d 'not a date'")
|
||||
assert io.exit_code == 1
|
||||
assert b"date: invalid date 'not a date'" in io.stderr
|
||||
assert _bytes(stdout) == b""
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
from mirage.commands.builtin.find_helper import (expand_printf,
|
||||
printf_needs_stat,
|
||||
unrespell_raw)
|
||||
from mirage.types import FileStat, FileType, PathSpec
|
||||
|
||||
|
||||
def _spec(virtual: str, raw: str | None = None) -> PathSpec:
|
||||
return PathSpec(resource_path=virtual.strip("/"),
|
||||
virtual=virtual,
|
||||
directory=virtual,
|
||||
resolved=True,
|
||||
raw_path=raw if raw is not None else virtual)
|
||||
|
||||
|
||||
def _stat(size: int = 6, file_type: FileType = FileType.TEXT) -> FileStat:
|
||||
return FileStat(name="a",
|
||||
size=size,
|
||||
type=file_type,
|
||||
modified="2026-08-16T13:45:30+00:00")
|
||||
|
||||
|
||||
def test_printf_needs_stat():
|
||||
assert printf_needs_stat("%s\n")
|
||||
assert printf_needs_stat("%TY\n")
|
||||
assert not printf_needs_stat("%p %f %h %P %d\n")
|
||||
assert not printf_needs_stat("100%%score\n")
|
||||
|
||||
|
||||
def test_unrespell_raw_round_trip():
|
||||
assert unrespell_raw("./sub/x", "/data", ".") == "/data/sub/x"
|
||||
assert unrespell_raw(".", "/data", ".") == "/data"
|
||||
assert unrespell_raw("/data/x", "/data", "/data") == "/data/x"
|
||||
|
||||
|
||||
def test_expand_path_directives():
|
||||
warnings: list[str] = []
|
||||
search = _spec("/data")
|
||||
row = "/data/sub/b.txt"
|
||||
assert expand_printf(
|
||||
"%p|%P|%f|%h|%d\n", row, search, None,
|
||||
warnings) == "/data/sub/b.txt|sub/b.txt|b.txt|/data/sub|2\n"
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_expand_stat_directives():
|
||||
warnings: list[str] = []
|
||||
search = _spec("/data")
|
||||
out = expand_printf("%s %y %m %M\n", "/data/a.txt", search, _stat(),
|
||||
warnings)
|
||||
assert out == "6 f 644 -rw-r--r--\n"
|
||||
dir_out = expand_printf("%y %m\n", "/data/sub", search,
|
||||
_stat(0, FileType.DIRECTORY), warnings)
|
||||
assert dir_out == "d 755\n"
|
||||
|
||||
|
||||
def test_expand_time_directives():
|
||||
warnings: list[str] = []
|
||||
search = _spec("/data")
|
||||
assert expand_printf("%TY-%Tm-%Td\n", "/data/a.txt", search, _stat(),
|
||||
warnings) == "2026-08-16\n"
|
||||
epoch = expand_printf("%T@\n", "/data/a.txt", search, _stat(), warnings)
|
||||
assert epoch == "1786887930.0000000000\n"
|
||||
assert len(epoch.strip().split(".")[1]) == 10
|
||||
|
||||
|
||||
def test_expand_escapes_and_unknown():
|
||||
warnings: list[str] = []
|
||||
search = _spec("/data")
|
||||
assert expand_printf("A\\tB\\n", "/data/a.txt", search, None,
|
||||
warnings) == "A\tB\n"
|
||||
assert expand_printf("%Q\n", "/data/a.txt", search, None,
|
||||
warnings) == "%Q\n"
|
||||
assert warnings == ["find: warning: unrecognized format directive '%Q'"]
|
||||
expand_printf("%Q %Q\n", "/data/a.txt", search, None, warnings)
|
||||
assert len(warnings) == 1
|
||||
|
||||
|
||||
def test_expand_root_row():
|
||||
warnings: list[str] = []
|
||||
search = _spec("/data")
|
||||
assert expand_printf("%P|%d|%f\n", "/data", search, None,
|
||||
warnings) == "|0|data\n"
|
||||
@@ -201,3 +201,18 @@ def test_operator_closed_by_paren_names_both(tokens, op):
|
||||
FindParseError,
|
||||
match=f"^find: expected an expression between '{op}' and '\\)'$"):
|
||||
parse_find_expression(tokens)
|
||||
|
||||
|
||||
def test_printf_stores_format():
|
||||
expr = parse_find_expression(["-printf", "%p\\n"])
|
||||
assert expr.printf == "%p\\n"
|
||||
|
||||
|
||||
def test_printf_missing_argument():
|
||||
with pytest.raises(FindParseError, match="missing argument to '-printf'"):
|
||||
parse_find_expression(["-printf"])
|
||||
|
||||
|
||||
def test_printf_combines_with_tests():
|
||||
expr = parse_find_expression(["-name", "*.txt", "-printf", "%f\\n"])
|
||||
assert expr.printf == "%f\\n"
|
||||
|
||||
@@ -70,3 +70,44 @@ def test_find_mtime(env):
|
||||
env.create_file("f.txt", b"hello")
|
||||
result = env.mirage("find /data -mtime -1 -type f")
|
||||
assert "f.txt" in result
|
||||
|
||||
|
||||
def test_find_printf_paths(env):
|
||||
env.create_file("a.txt", b"hello\n")
|
||||
env.create_file("sub/b.txt", b"hi\n")
|
||||
result = env.mirage("find /data -printf '%p\\n'")
|
||||
assert result == "/data\n/data/a.txt\n/data/sub\n/data/sub/b.txt\n"
|
||||
|
||||
|
||||
def test_find_printf_stat_directives(env):
|
||||
env.create_file("a.txt", b"hello\n")
|
||||
result = env.mirage("find /data -name a.txt -printf '%f %s %y %d\\n'")
|
||||
assert result == "a.txt 6 f 1\n"
|
||||
|
||||
|
||||
def test_find_printf_relative_and_dirname(env):
|
||||
env.create_file("sub/b.txt", b"hi\n")
|
||||
result = env.mirage("find /data -name b.txt -printf '%h|%P|%m|%M\\n'")
|
||||
assert result == "/data/sub|sub/b.txt|644|-rw-r--r--\n"
|
||||
|
||||
|
||||
def test_find_printf_escapes(env):
|
||||
env.create_file("a.txt", b"x")
|
||||
result = env.mirage("find /data -name a.txt -printf 'A\\tB\\n'")
|
||||
assert result == "A\tB\n"
|
||||
|
||||
|
||||
def test_find_printf_no_trailing_newline(env):
|
||||
env.create_file("a.txt", b"x")
|
||||
result = env.mirage("find /data -name a.txt -printf '%f'")
|
||||
assert result == "a.txt"
|
||||
|
||||
|
||||
def test_find_printf_time_directives(env):
|
||||
env.create_file("a.txt", b"x")
|
||||
result = env.mirage("find /data -name a.txt -printf '%TY\\n'")
|
||||
assert len(result.strip()) == 4
|
||||
assert result.strip().isdigit()
|
||||
epoch = env.mirage("find /data -name a.txt -printf '%T@\\n'").strip()
|
||||
assert "." in epoch
|
||||
assert len(epoch.split(".")[1]) == 10
|
||||
|
||||
@@ -61,7 +61,7 @@ UNMIRRORED_DIRS = {
|
||||
# would count 816 today. What the ratchet buys is narrower than it looks:
|
||||
# a module whose name appears nowhere in the suite cannot be added
|
||||
# silently.
|
||||
MIRROR_BASELINE = 194
|
||||
MIRROR_BASELINE = 192
|
||||
|
||||
|
||||
def _test_dirs() -> list[pathlib.Path]:
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from mirage.utils.dates import parse_date_expr
|
||||
|
||||
NOW = datetime(2026, 8, 16, 13, 45, 30)
|
||||
|
||||
|
||||
def test_relative_hours_ago():
|
||||
assert parse_date_expr("24 hours ago",
|
||||
now=NOW) == datetime(2026, 8, 15, 13, 45, 30)
|
||||
|
||||
|
||||
def test_relative_days_and_weeks():
|
||||
assert parse_date_expr("3 days",
|
||||
now=NOW) == datetime(2026, 8, 19, 13, 45, 30)
|
||||
assert parse_date_expr("-2 weeks",
|
||||
now=NOW) == datetime(2026, 8, 2, 13, 45, 30)
|
||||
|
||||
|
||||
def test_relative_words():
|
||||
assert parse_date_expr("yesterday",
|
||||
now=NOW) == datetime(2026, 8, 15, 13, 45, 30)
|
||||
assert parse_date_expr("tomorrow",
|
||||
now=NOW) == datetime(2026, 8, 17, 13, 45, 30)
|
||||
assert parse_date_expr("now", now=NOW) == NOW
|
||||
assert parse_date_expr("last year",
|
||||
now=NOW) == datetime(2025, 8, 16, 13, 45, 30)
|
||||
assert parse_date_expr("next month",
|
||||
now=NOW) == datetime(2026, 9, 16, 13, 45, 30)
|
||||
|
||||
|
||||
def test_month_overflow_normalizes_like_gnu():
|
||||
assert parse_date_expr("2026-01-31 1 month",
|
||||
now=NOW) == datetime(2026, 3, 3)
|
||||
|
||||
|
||||
def test_iso_base_with_relative_tail():
|
||||
assert parse_date_expr("2026-08-16 12:00:00 24 hours ago",
|
||||
now=NOW) == datetime(2026, 8, 15, 12, 0, 0)
|
||||
|
||||
|
||||
def test_epoch():
|
||||
parsed = parse_date_expr("@1755300000", utc=True)
|
||||
assert parsed == datetime(2025, 8, 15, 23, 20, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_iso_datetime_with_offset_converts_under_utc():
|
||||
parsed = parse_date_expr("2026-08-16T10:00:00+02:00", utc=True)
|
||||
assert parsed is not None
|
||||
assert parsed.hour == 8
|
||||
assert parsed.tzinfo == timezone.utc
|
||||
|
||||
|
||||
def test_invalid_returns_none():
|
||||
assert parse_date_expr("not a date", now=NOW) is None
|
||||
assert parse_date_expr("24 hours agoo", now=NOW) is None
|
||||
assert parse_date_expr("", now=NOW) is None
|
||||
assert parse_date_expr("@abc", now=NOW) is None
|
||||
|
||||
|
||||
def test_number_attached_to_unit():
|
||||
assert parse_date_expr("2days",
|
||||
now=NOW) == datetime(2026, 8, 18, 13, 45, 30)
|
||||
@@ -307,6 +307,11 @@
|
||||
"short": "-mindepth",
|
||||
"type": "str"
|
||||
},
|
||||
{
|
||||
"multiple": true,
|
||||
"short": "-printf",
|
||||
"type": "str"
|
||||
},
|
||||
{
|
||||
"short": "-P",
|
||||
"type": "bool"
|
||||
|
||||
@@ -265,6 +265,11 @@
|
||||
"short": "-mindepth",
|
||||
"type": "str"
|
||||
},
|
||||
{
|
||||
"multiple": true,
|
||||
"short": "-printf",
|
||||
"type": "str"
|
||||
},
|
||||
{
|
||||
"short": "-P",
|
||||
"type": "bool"
|
||||
|
||||
@@ -328,6 +328,11 @@
|
||||
"short": "-mindepth",
|
||||
"type": "str"
|
||||
},
|
||||
{
|
||||
"multiple": true,
|
||||
"short": "-printf",
|
||||
"type": "str"
|
||||
},
|
||||
{
|
||||
"short": "-P",
|
||||
"type": "bool"
|
||||
|
||||
@@ -17,12 +17,15 @@ import {
|
||||
buildTree,
|
||||
computeNonemptyDirs,
|
||||
evalPredicate,
|
||||
expandPrintf,
|
||||
type FindEntry,
|
||||
keep,
|
||||
printfNeedsStat,
|
||||
treeHasType,
|
||||
displayPath,
|
||||
emitStartPath,
|
||||
prefixPathNodes,
|
||||
unrespellRaw,
|
||||
} from './findEval.ts'
|
||||
|
||||
function entry(over: Partial<FindEntry> = {}): FindEntry {
|
||||
@@ -218,3 +221,71 @@ describe('emitStartPath size on directories', () => {
|
||||
expect(results).toEqual(['/data'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('expandPrintf', () => {
|
||||
const stat = { size: 6, kind: 'f' as const, mtimeEpoch: 1786887930 }
|
||||
|
||||
it('expands the path family', () => {
|
||||
const warnings: string[] = []
|
||||
expect(expandPrintf('%p|%P|%f|%h|%d\n', '/data/sub/b.txt', '/data', null, warnings)).toBe(
|
||||
'/data/sub/b.txt|sub/b.txt|b.txt|/data/sub|2\n',
|
||||
)
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('expands the stat family', () => {
|
||||
const warnings: string[] = []
|
||||
expect(expandPrintf('%s %y %m %M\n', '/data/a.txt', '/data', stat, warnings)).toBe(
|
||||
'6 f 644 -rw-r--r--\n',
|
||||
)
|
||||
expect(
|
||||
expandPrintf(
|
||||
'%y %m\n',
|
||||
'/data/sub',
|
||||
'/data',
|
||||
{ size: 0, kind: 'd', mtimeEpoch: 0 },
|
||||
warnings,
|
||||
),
|
||||
).toBe('d 755\n')
|
||||
})
|
||||
|
||||
it('expands time directives in UTC', () => {
|
||||
const warnings: string[] = []
|
||||
expect(expandPrintf('%TY-%Tm-%Td\n', '/data/a.txt', '/data', stat, warnings)).toBe(
|
||||
'2026-08-16\n',
|
||||
)
|
||||
expect(expandPrintf('%T@\n', '/data/a.txt', '/data', stat, warnings)).toBe(
|
||||
'1786887930.0000000000\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('handles escapes and warns once per unknown directive', () => {
|
||||
const warnings: string[] = []
|
||||
expect(expandPrintf('A\\tB\\n', '/data/a.txt', '/data', null, warnings)).toBe('A\tB\n')
|
||||
expect(expandPrintf('%Q\n', '/data/a.txt', '/data', null, warnings)).toBe('%Q\n')
|
||||
expect(expandPrintf('%Q\n', '/data/a.txt', '/data', null, warnings)).toBe('%Q\n')
|
||||
expect(warnings).toEqual(["find: warning: unrecognized format directive '%Q'"])
|
||||
})
|
||||
|
||||
it('renders the start row at depth 0', () => {
|
||||
const warnings: string[] = []
|
||||
expect(expandPrintf('%P|%d|%f\n', '/data', '/data', null, warnings)).toBe('|0|data\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('unrespellRaw', () => {
|
||||
it('inverts respelling', () => {
|
||||
expect(unrespellRaw('./sub/x', '/data', '.')).toBe('/data/sub/x')
|
||||
expect(unrespellRaw('.', '/data', '.')).toBe('/data')
|
||||
expect(unrespellRaw('/data/x', '/data', '/data')).toBe('/data/x')
|
||||
})
|
||||
})
|
||||
|
||||
describe('printfNeedsStat', () => {
|
||||
it('detects stat directives', () => {
|
||||
expect(printfNeedsStat('%s\n')).toBe(true)
|
||||
expect(printfNeedsStat('%TY\n')).toBe(true)
|
||||
expect(printfNeedsStat('%p %f %h %P %d\n')).toBe(false)
|
||||
expect(printfNeedsStat('100%%score\n')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -260,3 +260,215 @@ export function computeNonemptyDirs(keys: string[]): Set<string> {
|
||||
}
|
||||
return nonempty
|
||||
}
|
||||
|
||||
const PRINTF_ESCAPES: Record<string, string> = {
|
||||
n: '\n',
|
||||
t: '\t',
|
||||
r: '\r',
|
||||
'0': '\0',
|
||||
'\\': '\\',
|
||||
a: '\x07',
|
||||
b: '\b',
|
||||
f: '\f',
|
||||
v: '\v',
|
||||
}
|
||||
const STAT_DIRECTIVES = new Set(['s', 'y', 'Y', 'm', 'M', 'T'])
|
||||
// One mode per kind, spelled from the same constants every stat
|
||||
// translator uses (utils/stat_view.ts); links are 777 the way ls draws
|
||||
// them.
|
||||
const KIND_OCTAL: Record<string, string> = { d: '755', l: '777', f: '644' }
|
||||
const KIND_SYMBOLIC: Record<string, string> = {
|
||||
d: 'drwxr-xr-x',
|
||||
l: 'lrwxrwxrwx',
|
||||
f: '-rw-r--r--',
|
||||
}
|
||||
const DAY_ABBR = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
const MONTH_ABBR = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
]
|
||||
|
||||
// Whether a -printf format reads anything off the entry's stat.
|
||||
export function printfNeedsStat(fmt: string): boolean {
|
||||
let i = 0
|
||||
while (i < fmt.length - 1) {
|
||||
const ch = fmt.charAt(i)
|
||||
if (ch === '%' && STAT_DIRECTIVES.has(fmt.charAt(i + 1))) return true
|
||||
if (ch === '%' || ch === '\\') {
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Map one respelled display row back to its virtual path: the inverse of
|
||||
// respellRaw, for the stat probe.
|
||||
export function unrespellRaw(row: string, virtual: string, raw: string): string {
|
||||
if (raw === '' || raw === virtual) return row
|
||||
if (row === raw) return virtual
|
||||
const stem = raw.endsWith('/') ? raw : raw + '/'
|
||||
if (row.startsWith(stem)) {
|
||||
const base = virtual.replace(/\/+$/, '')
|
||||
return base + '/' + row.slice(stem.length)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
function relativePart(row: string, base: string): string {
|
||||
if (row === base) return ''
|
||||
const stem = base.endsWith('/') ? base : base + '/'
|
||||
if (row.startsWith(stem)) return row.slice(stem.length)
|
||||
return row
|
||||
}
|
||||
|
||||
function pad(n: number, width: number, fill = '0'): string {
|
||||
return String(n).padStart(width, fill)
|
||||
}
|
||||
|
||||
function timeDirective(letter: string, ts: number): string | null {
|
||||
if (letter === '@') return ts.toFixed(10)
|
||||
const dt = new Date(ts * 1000)
|
||||
const frac = ts.toFixed(10).split('.')[1] ?? '0000000000'
|
||||
switch (letter) {
|
||||
case '+':
|
||||
return `${pad(dt.getUTCFullYear(), 4)}-${pad(dt.getUTCMonth() + 1, 2)}-${pad(dt.getUTCDate(), 2)}+${pad(dt.getUTCHours(), 2)}:${pad(dt.getUTCMinutes(), 2)}:${pad(dt.getUTCSeconds(), 2)}.${frac}`
|
||||
case 'Y':
|
||||
return pad(dt.getUTCFullYear(), 4)
|
||||
case 'y':
|
||||
return pad(dt.getUTCFullYear() % 100, 2)
|
||||
case 'm':
|
||||
return pad(dt.getUTCMonth() + 1, 2)
|
||||
case 'd':
|
||||
return pad(dt.getUTCDate(), 2)
|
||||
case 'e':
|
||||
return String(dt.getUTCDate()).padStart(2, ' ')
|
||||
case 'H':
|
||||
return pad(dt.getUTCHours(), 2)
|
||||
case 'M':
|
||||
return pad(dt.getUTCMinutes(), 2)
|
||||
case 'S':
|
||||
return pad(dt.getUTCSeconds(), 2)
|
||||
case 'j': {
|
||||
const start = Date.UTC(dt.getUTCFullYear(), 0, 0)
|
||||
return pad(Math.floor((dt.getTime() - start) / 86_400_000), 3)
|
||||
}
|
||||
case 'a':
|
||||
return DAY_ABBR[dt.getUTCDay()] ?? ''
|
||||
case 'b':
|
||||
case 'h':
|
||||
return MONTH_ABBR[dt.getUTCMonth()] ?? ''
|
||||
case 'p':
|
||||
return dt.getUTCHours() < 12 ? 'AM' : 'PM'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export interface PrintfStatFacts {
|
||||
size: number
|
||||
kind: 'f' | 'd' | 'l'
|
||||
mtimeEpoch: number
|
||||
}
|
||||
|
||||
function warnUnrecognized(src: string, warnings: string[]): void {
|
||||
const kind = src.startsWith('\\') ? 'escape' : 'format directive'
|
||||
const line = `find: warning: unrecognized ${kind} '${src}'`
|
||||
if (!warnings.includes(line)) warnings.push(line)
|
||||
}
|
||||
|
||||
// Expand one -printf format against one result row. Directives cover what
|
||||
// GNU's find agents actually use: the path family (%p %P %f %h %d), the
|
||||
// stat family (%s %y %Y %m %M), %T times, and the backslash escapes. An
|
||||
// unrecognized directive or escape renders literally and adds GNU's
|
||||
// warning line once, exit code untouched -- which is GNU's own behavior.
|
||||
// Times render in UTC (mirage timestamps are zone-carrying ISO strings;
|
||||
// GNU renders the local zone). Mirrors the Python expand_printf.
|
||||
export function expandPrintf(
|
||||
fmt: string,
|
||||
row: string,
|
||||
startBase: string,
|
||||
st: PrintfStatFacts | null,
|
||||
warnings: string[],
|
||||
): string {
|
||||
const out: string[] = []
|
||||
let i = 0
|
||||
const n = fmt.length
|
||||
const kind = st === null ? 'f' : st.kind
|
||||
while (i < n) {
|
||||
const ch = fmt.charAt(i)
|
||||
if (ch === '\\' && i + 1 < n) {
|
||||
const nxt = fmt.charAt(i + 1)
|
||||
const mapped = PRINTF_ESCAPES[nxt]
|
||||
if (mapped !== undefined) {
|
||||
out.push(mapped)
|
||||
} else {
|
||||
warnUnrecognized(`\\${nxt}`, warnings)
|
||||
out.push(fmt.slice(i, i + 2))
|
||||
}
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if (ch !== '%' || i + 1 >= n) {
|
||||
out.push(ch)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
const code = fmt.charAt(i + 1)
|
||||
i += 2
|
||||
if (code === '%') {
|
||||
out.push('%')
|
||||
} else if (code === 'p') {
|
||||
out.push(row)
|
||||
} else if (code === 'P') {
|
||||
out.push(relativePart(row, startBase))
|
||||
} else if (code === 'f') {
|
||||
const trimmed = row.replace(/\/+$/, '')
|
||||
out.push(trimmed === '' ? '/' : (trimmed.split('/').pop() ?? trimmed))
|
||||
} else if (code === 'h') {
|
||||
const trimmed = row.replace(/\/+$/, '')
|
||||
if (!trimmed.includes('/')) {
|
||||
out.push(trimmed === '' ? '/' : '.')
|
||||
} else {
|
||||
const head = trimmed.slice(0, trimmed.lastIndexOf('/'))
|
||||
out.push(head === '' ? '/' : head)
|
||||
}
|
||||
} else if (code === 'd') {
|
||||
const rel = relativePart(row, startBase)
|
||||
out.push(rel === '' ? '0' : String(rel.split('/').length))
|
||||
} else if (code === 's') {
|
||||
out.push(String(st === null ? 0 : st.size))
|
||||
} else if (code === 'y' || code === 'Y') {
|
||||
out.push(st === null ? 'U' : kind)
|
||||
} else if (code === 'm') {
|
||||
out.push(KIND_OCTAL[kind] ?? '644')
|
||||
} else if (code === 'M') {
|
||||
out.push(KIND_SYMBOLIC[kind] ?? '-rw-r--r--')
|
||||
} else if (code === 'T' && i < n) {
|
||||
const letter = fmt.charAt(i)
|
||||
i += 1
|
||||
const rendered = timeDirective(letter, st === null ? 0 : st.mtimeEpoch)
|
||||
if (rendered === null) {
|
||||
warnUnrecognized(`%T${letter}`, warnings)
|
||||
out.push(`%T${letter}`)
|
||||
} else {
|
||||
out.push(rendered)
|
||||
}
|
||||
} else {
|
||||
warnUnrecognized(`%${code}`, warnings)
|
||||
out.push(`%${code}`)
|
||||
}
|
||||
}
|
||||
return out.join('')
|
||||
}
|
||||
|
||||
@@ -196,3 +196,19 @@ describe('parseFindExpression', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('find -printf parsing', () => {
|
||||
it('stores the format on the expression', () => {
|
||||
const expr = parseFindExpression(['-printf', '%p\\n'])
|
||||
expect(expr.printf).toBe('%p\\n')
|
||||
})
|
||||
|
||||
it('refuses a missing argument', () => {
|
||||
expect(() => parseFindExpression(['-printf'])).toThrow("missing argument to '-printf'")
|
||||
})
|
||||
|
||||
it('combines with tests', () => {
|
||||
const expr = parseFindExpression(['-name', '*.txt', '-printf', '%f\\n'])
|
||||
expect(expr.printf).toBe('%f\\n')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ const VALUE_PREDICATES = new Set([
|
||||
'-mtime',
|
||||
'-maxdepth',
|
||||
'-mindepth',
|
||||
'-printf',
|
||||
])
|
||||
|
||||
const BARE_PREDICATES = new Set(['-empty', '-print', '-print0', '-delete', '-ls', '-depth'])
|
||||
@@ -48,6 +49,7 @@ export interface FindExpr {
|
||||
mtimeMin: number | null
|
||||
mtimeMax: number | null
|
||||
usesEmpty: boolean
|
||||
printf: string | null
|
||||
}
|
||||
|
||||
// GNU rounds the file size up to whole units before comparing, and
|
||||
@@ -130,6 +132,7 @@ export function parseFindExpression(tokens: string[]): FindExpr {
|
||||
mtimeMin: null as number | null,
|
||||
mtimeMax: null as number | null,
|
||||
usesEmpty: false,
|
||||
printf: null as string | null,
|
||||
}
|
||||
let pos = 0
|
||||
let depth = 0
|
||||
@@ -162,6 +165,15 @@ export function parseFindExpression(tokens: string[]): FindExpr {
|
||||
if (tok === '-iname') return { op: 'name', pattern: value, icase: true }
|
||||
if (tok === '-path') return { op: 'path', pattern: value }
|
||||
if (tok === '-type') return typeNode(value)
|
||||
if (tok === '-printf') {
|
||||
// An action, not a test: it always matches, replaces the default
|
||||
// -print rendering, and one format applies to every row (GNU
|
||||
// evaluates actions per expression position, which the flat
|
||||
// window cannot express; a single trailing -printf, the way
|
||||
// agents write it, renders identically).
|
||||
g.printf = value
|
||||
return { op: 'true' }
|
||||
}
|
||||
if (tok === '-maxdepth') {
|
||||
g.maxDepth = intArg(value, '-maxdepth')
|
||||
return { op: 'true' }
|
||||
|
||||
@@ -73,3 +73,88 @@ describe('date', () => {
|
||||
expect(out.trim()).toBe(String(Math.floor(Date.UTC(2026, 3, 21) / 1000)))
|
||||
})
|
||||
})
|
||||
|
||||
async function runDateIo(
|
||||
texts: string[] = [],
|
||||
flags: Record<string, string | boolean | number | string[]> = {},
|
||||
): Promise<[string, string, number]> {
|
||||
const resource = new RAMResource()
|
||||
const cmd = GENERAL_DATE[0]
|
||||
if (cmd === undefined) throw new Error('date not registered')
|
||||
const result = await cmd.fn((resource as { accessor?: unknown }).accessor as never, [], texts, {
|
||||
stdin: null,
|
||||
flags,
|
||||
filetypeFns: null,
|
||||
cwd: '/',
|
||||
})
|
||||
if (result === null) return ['', '', 0]
|
||||
const [out, io] = result
|
||||
const buf =
|
||||
out === null
|
||||
? new Uint8Array()
|
||||
: out instanceof Uint8Array
|
||||
? out
|
||||
: await materialize(out as AsyncIterable<Uint8Array>)
|
||||
const errRaw =
|
||||
io.stderr === null
|
||||
? new Uint8Array()
|
||||
: io.stderr instanceof Uint8Array
|
||||
? io.stderr
|
||||
: await materialize(io.stderr as AsyncIterable<Uint8Array>)
|
||||
return [DEC.decode(buf), DEC.decode(errRaw), io.exitCode]
|
||||
}
|
||||
|
||||
describe('date GNU format specifiers', () => {
|
||||
const AT = '2026-08-16T13:45:30Z'
|
||||
|
||||
it('+%F renders the ISO date, not the literal', async () => {
|
||||
expect(await runDate(['+%F %T'], { d: AT, u: true })).toBe('2026-08-16 13:45:30\n')
|
||||
})
|
||||
|
||||
it('renders 12-hour, quarter, century, and padded-hour forms', async () => {
|
||||
expect(await runDate(['+%r|%q|%C|%h|%k|%l|%P|%R'], { d: AT, u: true })).toBe(
|
||||
'01:45:30 PM|3|20|Aug|13| 1|pm|13:45\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders week numbers and the ISO week-based year', async () => {
|
||||
expect(await runDate(['+%V|%U|%W|%G|%g'], { d: AT, u: true })).toBe('33|33|32|2026|26\n')
|
||||
})
|
||||
|
||||
it('renders C-locale %c, %x, %X and the %n/%t escapes', async () => {
|
||||
expect(await runDate(['+%c|%x|%X|%n|%t'], { d: AT, u: true })).toBe(
|
||||
'Sun Aug 16 13:45:30 2026|08/16/26|13:45:30|\n|\t\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('passes an unknown directive through literally, as GNU does', async () => {
|
||||
expect(await runDate(['+%v'], { d: AT, u: true })).toBe('%v\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('date -d expressions', () => {
|
||||
it('handles a relative displacement from an ISO base', async () => {
|
||||
const out = await runDate(['+%F %T'], { d: '2026-08-16 12:00:00 24 hours ago', u: true })
|
||||
expect(out).toBe('2026-08-15 12:00:00\n')
|
||||
})
|
||||
|
||||
it('handles @epoch input', async () => {
|
||||
expect(await runDate(['+%F %T'], { d: '@1755300000', u: true })).toBe('2025-08-15 23:20:00\n')
|
||||
})
|
||||
|
||||
it('normalizes month overflow the way GNU does', async () => {
|
||||
expect(await runDate(['+%F'], { d: '2026-01-31 1 month', u: true })).toBe('2026-03-03\n')
|
||||
})
|
||||
|
||||
it('produces a date, never NaN, for a bare relative expression', async () => {
|
||||
const out = await runDate(['+%F'], { d: '24 hours ago', u: true })
|
||||
expect(out).toMatch(/^\d{4}-\d{2}-\d{2}\n$/)
|
||||
})
|
||||
|
||||
it('refuses an invalid date with GNU wording and exit 1', async () => {
|
||||
const [out, stderr, code] = await runDateIo([], { d: 'not a date' })
|
||||
expect(out).toBe('')
|
||||
expect(stderr).toBe("date: invalid date 'not a date'\n")
|
||||
expect(code).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import type { PathSpec } from '../../../types.ts'
|
||||
import type { Accessor } from '../../../accessor/base.ts'
|
||||
import { IOResult } from '../../../io/types.ts'
|
||||
import { parseDateExpr } from '../../../utils/dates.ts'
|
||||
import { command, type CommandFnResult, type CommandOpts } from '../../config.ts'
|
||||
import { specOf } from '../../spec/builtins.ts'
|
||||
import { pureProvision } from '../generic_bind/provision.ts'
|
||||
@@ -47,6 +48,21 @@ function pad4(n: number): string {
|
||||
return String(n).padStart(4, '0')
|
||||
}
|
||||
|
||||
function dayOfYear(year: number, month: number, day: number): number {
|
||||
return Math.floor((Date.UTC(year, month, day) - Date.UTC(year, 0, 0)) / 86_400_000)
|
||||
}
|
||||
|
||||
// ISO 8601 week-based year and week number (%G/%g/%V): the week belongs to
|
||||
// the year holding its Thursday.
|
||||
function isoWeekParts(year: number, month: number, day: number): [number, number] {
|
||||
const dow = new Date(Date.UTC(year, month, day)).getUTCDay()
|
||||
const isoDow = dow === 0 ? 7 : dow
|
||||
const thursday = new Date(Date.UTC(year, month, day + 4 - isoDow))
|
||||
const ty = thursday.getUTCFullYear()
|
||||
const yday = dayOfYear(ty, thursday.getUTCMonth(), thursday.getUTCDate())
|
||||
return [ty, Math.floor((yday - 1) / 7) + 1]
|
||||
}
|
||||
|
||||
function strftime(dt: Date, fmt: string, utc: boolean): string {
|
||||
const year = utc ? dt.getUTCFullYear() : dt.getFullYear()
|
||||
const month = utc ? dt.getUTCMonth() : dt.getMonth()
|
||||
@@ -55,7 +71,7 @@ function strftime(dt: Date, fmt: string, utc: boolean): string {
|
||||
const hour = utc ? dt.getUTCHours() : dt.getHours()
|
||||
const minute = utc ? dt.getUTCMinutes() : dt.getMinutes()
|
||||
const second = utc ? dt.getUTCSeconds() : dt.getSeconds()
|
||||
return fmt.replace(/%([aAbBdDHIMmYypSszZjewuT%])/g, (_m, code: string) => {
|
||||
return fmt.replace(/%([aAbBcCdDeFgGhHIjklMmnpPqrRsStTuUVwWxXYyzZ%])/g, (_m, code: string) => {
|
||||
switch (code) {
|
||||
case 'a':
|
||||
return DAY_NAMES[dow] ?? ''
|
||||
@@ -82,12 +98,58 @@ function strftime(dt: Date, fmt: string, utc: boolean): string {
|
||||
]
|
||||
return full[month] ?? ''
|
||||
}
|
||||
case 'c':
|
||||
// C-locale %c (%a %b %e %H:%M:%S %Y), what glibc renders and what
|
||||
// Python's strftime produces under LC_ALL=C.
|
||||
return `${DAY_NAMES[dow] ?? ''} ${MONTH_NAMES[month] ?? ''} ${String(day).padStart(2, ' ')} ${pad2(hour)}:${pad2(minute)}:${pad2(second)} ${pad4(year)}`
|
||||
case 'C':
|
||||
return pad2(Math.floor(year / 100))
|
||||
case 'd':
|
||||
return pad2(day)
|
||||
case 'D':
|
||||
return `${pad2(month + 1)}/${pad2(day)}/${pad2(year % 100)}`
|
||||
case 'F':
|
||||
return `${pad4(year)}-${pad2(month + 1)}-${pad2(day)}`
|
||||
case 'g':
|
||||
return pad2(isoWeekParts(year, month, day)[0] % 100)
|
||||
case 'G':
|
||||
return pad4(isoWeekParts(year, month, day)[0])
|
||||
case 'h':
|
||||
return MONTH_NAMES[month] ?? ''
|
||||
case 'H':
|
||||
return pad2(hour)
|
||||
case 'k':
|
||||
return String(hour).padStart(2, ' ')
|
||||
case 'l': {
|
||||
const h12l = hour % 12 === 0 ? 12 : hour % 12
|
||||
return String(h12l).padStart(2, ' ')
|
||||
}
|
||||
case 'n':
|
||||
return '\n'
|
||||
case 'P':
|
||||
return hour < 12 ? 'am' : 'pm'
|
||||
case 'q':
|
||||
return String(Math.floor(month / 3) + 1)
|
||||
case 'r': {
|
||||
const h12r = hour % 12 === 0 ? 12 : hour % 12
|
||||
return `${pad2(h12r)}:${pad2(minute)}:${pad2(second)} ${hour < 12 ? 'AM' : 'PM'}`
|
||||
}
|
||||
case 'R':
|
||||
return `${pad2(hour)}:${pad2(minute)}`
|
||||
case 't':
|
||||
return '\t'
|
||||
case 'U':
|
||||
// Week of year, Sunday-first, week 00 before the first Sunday.
|
||||
return pad2(Math.floor((dayOfYear(year, month, day) + 6 - dow) / 7))
|
||||
case 'V':
|
||||
return pad2(isoWeekParts(year, month, day)[1])
|
||||
case 'W':
|
||||
// Week of year, Monday-first.
|
||||
return pad2(Math.floor((dayOfYear(year, month, day) + 6 - ((dow + 6) % 7)) / 7))
|
||||
case 'x':
|
||||
return `${pad2(month + 1)}/${pad2(day)}/${pad2(year % 100)}`
|
||||
case 'X':
|
||||
return `${pad2(hour)}:${pad2(minute)}:${pad2(second)}`
|
||||
case 'I': {
|
||||
const h12 = hour % 12 === 0 ? 12 : hour % 12
|
||||
return pad2(h12)
|
||||
@@ -165,7 +227,21 @@ function dateCommand(
|
||||
// (`AMBIGUOUS_NAMES`); a plain `I` key is one the parser never emits.
|
||||
const argsI = fl.asBool('args_I')
|
||||
const R = fl.asBool('R')
|
||||
const dt = d !== null ? new Date(d) : new Date()
|
||||
let dt: Date
|
||||
if (d !== null) {
|
||||
const parsed = parseDateExpr(d, u)
|
||||
if (parsed === null) {
|
||||
// GNU's refusal, exit 1: a NaN render with exit 0 poisons whatever
|
||||
// consumed it (the 0NaN-NaN-NaN corpus failure).
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: 1, stderr: ENC.encode(`date: invalid date '${d}'\n`) }),
|
||||
]
|
||||
}
|
||||
dt = parsed
|
||||
} else {
|
||||
dt = new Date()
|
||||
}
|
||||
let fmt: string | null = null
|
||||
for (const t of texts) {
|
||||
if (t.startsWith('+')) {
|
||||
|
||||
@@ -271,3 +271,74 @@ describe('awkGeneric', () => {
|
||||
expect(out).toBe('}\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('awk unsupported constructs fail loud', () => {
|
||||
it('rejects an arithmetic assignment', async () => {
|
||||
await expect(run([], ['{x = y + 1; print x}'], opts({}, ENC.encode('line\n')))).rejects.toThrow(
|
||||
'unsupported construct',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a function call in print', async () => {
|
||||
await expect(run([], ['{print toupper($1)}'], opts({}, ENC.encode('line\n')))).rejects.toThrow(
|
||||
"unsupported construct: 'print toupper($1)'",
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects printf', async () => {
|
||||
await expect(run([], ['{printf "%s\\n", $1}'], opts({}, ENC.encode('line\n')))).rejects.toThrow(
|
||||
'unsupported construct',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an if statement', async () => {
|
||||
await expect(run([], ['{if ($1) print $1}'], opts({}, ENC.encode('line\n')))).rejects.toThrow(
|
||||
'unsupported construct',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a tilde match condition', async () => {
|
||||
await expect(run([], ['$1 ~ /x/ {print}'], opts({}, ENC.encode('x\n')))).rejects.toThrow(
|
||||
'unsupported construct',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects arithmetic in a condition', async () => {
|
||||
await expect(run([], ['NR % 2 == 0 {print}'], opts({}, ENC.encode('a\nb\n')))).rejects.toThrow(
|
||||
'unsupported construct',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('awk assignments and OFS', () => {
|
||||
it('executes a simple assignment', async () => {
|
||||
const [out] = await run([], ['{x = 1; print x}'], opts({}, ENC.encode('line\n')))
|
||||
expect(out).toBe('1\n')
|
||||
})
|
||||
|
||||
it('assigns from a field', async () => {
|
||||
const [out] = await run([], ['{x = $2; print x}'], opts({}, ENC.encode('a b\n')))
|
||||
expect(out).toBe('b\n')
|
||||
})
|
||||
|
||||
it('joins print arguments with OFS', async () => {
|
||||
const [out] = await run(
|
||||
[],
|
||||
['BEGIN{OFS=":"} {print $1, $2}'],
|
||||
opts({}, ENC.encode('name age\nalice 30\n')),
|
||||
)
|
||||
expect(out).toBe('name:age\nalice:30\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('awk unset values', () => {
|
||||
it('prints empty for an unset variable', async () => {
|
||||
const [out] = await run([], ['{print foo}'], opts({}, ENC.encode('line\n')))
|
||||
expect(out).toBe('\n')
|
||||
})
|
||||
|
||||
it('prints empty for an out-of-range field', async () => {
|
||||
const [out] = await run([], ['{print $5}'], opts({}, ENC.encode('one two\n')))
|
||||
expect(out).toBe('\n')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ import { mountKey, mountPrefixOf } from '../../../utils/key_prefix.ts'
|
||||
import { IOResult, materialize } from '../../../io/types.ts'
|
||||
import { PathSpec } from '../../../types.ts'
|
||||
import type { CommandFnResult, CommandOpts } from '../../config.ts'
|
||||
import { awkStream } from './awk_helper.ts'
|
||||
import { awkStream, validateAwkProgram } from './awk_helper.ts'
|
||||
import { USAGE, type AwkFlags } from './awk_types.ts'
|
||||
import { isMissingPath } from '../../../utils/errors.ts'
|
||||
import { resolvePath } from '../../../utils/path.ts'
|
||||
@@ -78,6 +78,8 @@ export async function awkGeneric(
|
||||
return [null, new IOResult({ exitCode: 2, stderr: ENC.encode(`${USAGE}\n`) })]
|
||||
}
|
||||
|
||||
validateAwkProgram(program)
|
||||
|
||||
const variables: Record<string, string> = {}
|
||||
for (const assignment of f.assignments) {
|
||||
const eq = assignment.indexOf('=')
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import { AsyncLineIterator } from '../../../io/async_line_iterator.ts'
|
||||
import { UsageError } from '../../errors.ts'
|
||||
import { toNumber } from '../utils/formatting.ts'
|
||||
import {
|
||||
AwkBlock,
|
||||
@@ -56,6 +57,119 @@ function parseProgram(program: string): [string, string] {
|
||||
return [trimmed, '']
|
||||
}
|
||||
|
||||
const IDENT_RE = /^[A-Za-z_]\w*$/
|
||||
const NUMBER_RE = /^-?(?:\d+\.?\d*|\.\d+)$/
|
||||
|
||||
// Whether the scraper can evaluate this token as a value. The supported
|
||||
// grammar is deliberately small: a double-quoted string with no embedded
|
||||
// quote, a numeric literal, a plain identifier, or a `$` field naming a
|
||||
// number or an identifier. Anything else (function calls, arithmetic,
|
||||
// concatenation) has no evaluator here and must be refused rather than
|
||||
// echoed as its own source text.
|
||||
function isSimpleOperand(tok: string): boolean {
|
||||
if (tok === '') return false
|
||||
if (tok.length >= 2 && tok.startsWith('"') && tok.endsWith('"')) {
|
||||
return !tok.slice(1, -1).includes('"')
|
||||
}
|
||||
if (tok.startsWith(FIELD_PREFIX)) {
|
||||
const inner = tok.slice(1)
|
||||
return /^\d+$/.test(inner) || IDENT_RE.test(inner)
|
||||
}
|
||||
return IDENT_RE.test(tok) || NUMBER_RE.test(tok)
|
||||
}
|
||||
|
||||
function reject(construct: string): never {
|
||||
throw new UsageError(`awk: unsupported construct: '${construct}'`)
|
||||
}
|
||||
|
||||
function validatePrintArgs(args: string, stmt: string): void {
|
||||
for (const tok of args.split(/,\s*/)) {
|
||||
if (!isSimpleOperand(tok.trim())) reject(stmt)
|
||||
}
|
||||
}
|
||||
|
||||
const ASSIGN_RE = /^([A-Za-z_]\w*)\s*=(?!=)\s*(.+)$/
|
||||
|
||||
// Refuse any statement the streamer would silently drop or mangle.
|
||||
// `evalStatements` executes `print`, `var = value` and `var += value`;
|
||||
// every other statement used to vanish (and `printf` ran as a mangled
|
||||
// `print`), so an agent's script exited 0 having done nothing. Mirrors
|
||||
// the statement split the evaluator uses.
|
||||
function validateAction(action: string): void {
|
||||
for (const rawStmt of action.split(';')) {
|
||||
const stmt = rawStmt.trim()
|
||||
if (stmt === '') continue
|
||||
const m = /^\w+\s*\+=\s*(.+)$/.exec(stmt)
|
||||
if (m !== null) {
|
||||
if (!isSimpleOperand((m[1] ?? '').trim())) reject(stmt)
|
||||
continue
|
||||
}
|
||||
if (!new RegExp(`^${PRINT_STMT}\\b`).test(stmt)) {
|
||||
const mSet = ASSIGN_RE.exec(stmt)
|
||||
if (mSet !== null) {
|
||||
if (!isSimpleOperand((mSet[2] ?? '').trim())) reject(stmt)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (stmt === PRINT_STMT) continue
|
||||
if (new RegExp(`^${PRINT_STMT}\\b`).test(stmt)) {
|
||||
const args = stmt.slice(PRINT_STMT.length).trim()
|
||||
if (args !== '') validatePrintArgs(args, stmt)
|
||||
continue
|
||||
}
|
||||
reject(stmt)
|
||||
}
|
||||
}
|
||||
|
||||
function validateSimple(rawExpr: string): void {
|
||||
const expr = rawExpr.trim()
|
||||
const cmp = new RegExp(`(.+?)\\s*(${CMP_OP_PATTERN.source})\\s*(.+)`).exec(expr)
|
||||
if (cmp === null) {
|
||||
if (expr.length >= 2 && expr.startsWith('/') && expr.endsWith('/')) return
|
||||
if (!isSimpleOperand(expr)) reject(expr)
|
||||
return
|
||||
}
|
||||
const lhs = (cmp[1] ?? '').trim()
|
||||
const rhs = (cmp[3] ?? '').trim()
|
||||
if (!isSimpleOperand(lhs)) reject(expr)
|
||||
if (rhs.startsWith('"') || rhs.startsWith(FIELD_PREFIX)) {
|
||||
if (!isSimpleOperand(rhs)) reject(expr)
|
||||
return
|
||||
}
|
||||
// A bare right-hand side compares as a literal in this dialect, so any
|
||||
// word is fine; structural characters mean an expression nothing here
|
||||
// evaluates (`length(x)`, `a[1]`).
|
||||
if (/[(){}[]/.test(rhs)) reject(expr)
|
||||
}
|
||||
|
||||
// Refuse any pattern `evalCondition` cannot actually decide. Mirrors its
|
||||
// decomposition exactly (`||` first, then `&&`, then one simple
|
||||
// comparison / regex / truthiness probe), so everything the evaluator
|
||||
// runs is accepted and everything it would misread (`~`, arithmetic,
|
||||
// parenthesized groups) is refused up front.
|
||||
function validateCondition(condition: string): void {
|
||||
const cond = condition.trim()
|
||||
if (cond === '' || cond === AwkBlock.BEGIN || cond === AwkBlock.END) return
|
||||
if (cond.includes(AwkBoolOp.OR)) {
|
||||
for (const part of cond.split(AwkBoolOp.OR)) validateCondition(part)
|
||||
return
|
||||
}
|
||||
if (cond.includes(AwkBoolOp.AND)) {
|
||||
for (const part of cond.split(AwkBoolOp.AND)) validateCondition(part)
|
||||
return
|
||||
}
|
||||
validateSimple(cond)
|
||||
}
|
||||
|
||||
export function validateAwkProgram(program: string): void {
|
||||
const [begin, main, end] = parseBlocks(program)
|
||||
const [condition, action] = main !== '' ? parseProgram(main) : (['', ''] as [string, string])
|
||||
if (begin !== '') validateAction(begin)
|
||||
if (end !== '') validateAction(end)
|
||||
validateCondition(condition)
|
||||
if (action !== '') validateAction(action)
|
||||
}
|
||||
|
||||
function resolveToken(tok: string, fieldMap: Record<string, string>): string {
|
||||
if (tok.startsWith(FIELD_PREFIX)) {
|
||||
const inner = tok.slice(1)
|
||||
@@ -63,9 +177,13 @@ function resolveToken(tok: string, fieldMap: Record<string, string>): string {
|
||||
const ref = fieldMap[inner] ?? ''
|
||||
return fieldMap[`${FIELD_PREFIX}${ref}`] ?? ''
|
||||
}
|
||||
return fieldMap[tok] ?? tok
|
||||
// An out-of-range field is empty in awk, never its own spelling.
|
||||
return fieldMap[tok] ?? ''
|
||||
}
|
||||
return fieldMap[tok] ?? tok
|
||||
if (tok in fieldMap) return fieldMap[tok] ?? ''
|
||||
// An unset variable reads as the empty string, not its own name; a
|
||||
// numeric literal is its own value.
|
||||
return IDENT_RE.test(tok) ? '' : tok
|
||||
}
|
||||
|
||||
function evalSimple(rawExpr: string, fieldMap: Record<string, string>): boolean {
|
||||
@@ -115,14 +233,49 @@ function evalCondition(condition: string, fieldMap: Record<string, string>): boo
|
||||
return evalSimple(cond, fieldMap)
|
||||
}
|
||||
|
||||
function evalAction(action: string, fieldMap: Record<string, string>): string | null {
|
||||
// Run an action's statements in written order. Three statement forms
|
||||
// exist in this dialect: `var += value` accumulates, `var = value`
|
||||
// assigns (persisting across records via `variables`, which is how
|
||||
// `BEGIN {OFS=":"}` reaches every print), and `print` emits its
|
||||
// arguments joined with OFS. One sequential pass, so `x = 1; print x`
|
||||
// sees the assignment.
|
||||
function evalStatements(
|
||||
action: string,
|
||||
fieldMap: Record<string, string>,
|
||||
accum: Record<string, number>,
|
||||
variables: Record<string, string>,
|
||||
): string | null {
|
||||
const parts: string[] = []
|
||||
let printed = false
|
||||
for (const rawStmt of action.split(';')) {
|
||||
const stmt = rawStmt.trim()
|
||||
if (!stmt.startsWith(PRINT_STMT)) continue
|
||||
if (stmt === '') continue
|
||||
const mAdd = /^(\w+)\s*\+=\s*(.+)$/.exec(stmt)
|
||||
if (mAdd !== null) {
|
||||
const variable = mAdd[1] ?? ''
|
||||
const expr = (mAdd[2] ?? '').trim()
|
||||
const val = fieldMap[expr] ?? expr
|
||||
accum[variable] = (accum[variable] ?? 0) + toNumber(val)
|
||||
continue
|
||||
}
|
||||
if (!stmt.startsWith(PRINT_STMT)) {
|
||||
const mSet = ASSIGN_RE.exec(stmt)
|
||||
if (mSet !== null) {
|
||||
const variable = mSet[1] ?? ''
|
||||
const raw = (mSet[2] ?? '').trim()
|
||||
const val =
|
||||
raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')
|
||||
? raw.slice(1, -1)
|
||||
: resolveToken(raw, fieldMap)
|
||||
variables[variable] = val
|
||||
fieldMap[variable] = val
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
printed = true
|
||||
const args = stmt.slice(PRINT_STMT.length).trim()
|
||||
const ofs = fieldMap.OFS ?? ' '
|
||||
if (args === '') {
|
||||
parts.push(fieldMap[AwkBuiltin.REC] ?? '')
|
||||
continue
|
||||
@@ -137,7 +290,7 @@ function evalAction(action: string, fieldMap: Record<string, string>): string |
|
||||
vals.push(resolveToken(tok, fieldMap))
|
||||
}
|
||||
}
|
||||
parts.push(vals.join(' '))
|
||||
parts.push(vals.join(ofs))
|
||||
}
|
||||
return printed ? parts.join('\n') : null
|
||||
}
|
||||
@@ -179,23 +332,6 @@ function parseBlocks(program: string): [string, string, string] {
|
||||
return [begin, main, end]
|
||||
}
|
||||
|
||||
function evalAccumulator(
|
||||
action: string,
|
||||
fieldMap: Record<string, string>,
|
||||
accum: Record<string, number>,
|
||||
): void {
|
||||
for (const rawStmt of action.split(';')) {
|
||||
const stmt = rawStmt.trim()
|
||||
const m = /^(\w+)\s*\+=\s*(.+)/.exec(stmt)
|
||||
if (m !== null) {
|
||||
const variable = m[1] ?? ''
|
||||
const expr = (m[2] ?? '').trim()
|
||||
const val = fieldMap[expr] ?? expr
|
||||
accum[variable] = (accum[variable] ?? 0) + toNumber(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function* awkStream(
|
||||
sources: AsyncIterable<Uint8Array>[],
|
||||
program: string,
|
||||
@@ -214,7 +350,7 @@ export async function* awkStream(
|
||||
[AwkBuiltin.NF]: '0',
|
||||
...variables,
|
||||
}
|
||||
const result = evalAction(begin, beginMap)
|
||||
const result = evalStatements(begin, beginMap, accum, variables)
|
||||
if (result !== null) yield ENC.encode(result + '\n')
|
||||
}
|
||||
|
||||
@@ -226,8 +362,7 @@ export async function* awkStream(
|
||||
const line = DEC.decode(lineBytes)
|
||||
const fieldMap = buildFieldMap(line, fs, nr, variables)
|
||||
if (condition !== '' && !evalCondition(condition, fieldMap)) continue
|
||||
evalAccumulator(action, fieldMap, accum)
|
||||
const result = action !== '' ? evalAction(action, fieldMap) : line
|
||||
const result = action !== '' ? evalStatements(action, fieldMap, accum, variables) : line
|
||||
if (result !== null) yield ENC.encode(result + '\n')
|
||||
}
|
||||
}
|
||||
@@ -240,7 +375,7 @@ export async function* awkStream(
|
||||
...variables,
|
||||
}
|
||||
for (const [k, v] of Object.entries(accum)) endMap[k] = String(v)
|
||||
const result = evalAction(end, endMap)
|
||||
const result = evalStatements(end, endMap, accum, variables)
|
||||
if (result !== null) yield ENC.encode(result + '\n')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,13 +26,17 @@ import { respellRaw } from '../../../utils/path.ts'
|
||||
import { mountKey, mountPrefixOf } from '../../../utils/key_prefix.ts'
|
||||
import {
|
||||
emitStartPath,
|
||||
expandPrintf,
|
||||
hasLinkChildren,
|
||||
keep,
|
||||
optionsTree,
|
||||
prefixPathNodes,
|
||||
printfNeedsStat,
|
||||
startBasename,
|
||||
unrespellRaw,
|
||||
type FindEntry,
|
||||
type PredNode,
|
||||
type PrintfStatFacts,
|
||||
} from '../findEval.ts'
|
||||
import type { LinkView } from '../../../ops/types.ts'
|
||||
import { pathAllowed } from '../../../context/session_context.ts'
|
||||
@@ -372,6 +376,8 @@ export async function findGeneric(
|
||||
}
|
||||
const matches: string[] = []
|
||||
const missing: string[] = []
|
||||
const printfFmt = expr !== null ? expr.printf : null
|
||||
const printfPairs: [string, PathSpec][] = []
|
||||
for (const root of targets) {
|
||||
// `-path` matches the display path as printed; stamp the mount
|
||||
// prefix onto path nodes before the backend walks mount-relative
|
||||
@@ -426,7 +432,9 @@ export async function findGeneric(
|
||||
// path is the operand, not a key that needs rebasing.
|
||||
if (rows.length > 0) {
|
||||
const display = root.virtual === '/' ? '/' : rstripSlash(root.virtual)
|
||||
matches.push(...respellRaw([display], root.virtual, root.rawPath))
|
||||
const added = respellRaw([display], root.virtual, root.rawPath)
|
||||
matches.push(...added)
|
||||
for (const r of added) printfPairs.push([r, root])
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -503,7 +511,12 @@ export async function findGeneric(
|
||||
// the link merge, so a mount's visibility behavior cannot depend
|
||||
// on whether its backend ships a native find op.
|
||||
const visibleRows = withLinks.filter((row) => pathAllowed(row))
|
||||
matches.push(...respellRaw(visibleRows, root.virtual, root.rawPath))
|
||||
const added = respellRaw(visibleRows, root.virtual, root.rawPath)
|
||||
matches.push(...added)
|
||||
for (const r of added) printfPairs.push([r, root])
|
||||
}
|
||||
if (printfFmt !== null) {
|
||||
return renderPrintfRows(printfPairs, printfFmt, stat, opts, missing)
|
||||
}
|
||||
// Start points print in operand order (GNU); each root's rows were
|
||||
// sorted above, and a global sort here would interleave them.
|
||||
@@ -513,3 +526,74 @@ export async function findGeneric(
|
||||
}
|
||||
return [out, new IOResult()]
|
||||
}
|
||||
|
||||
async function printfStat(
|
||||
row: string,
|
||||
root: PathSpec,
|
||||
stat: ((spec: PathSpec) => Promise<FileStat>) | undefined,
|
||||
opts: CommandOpts,
|
||||
): Promise<PrintfStatFacts | null> {
|
||||
const virtual = unrespellRaw(row, root.virtual, root.rawPath !== '' ? root.rawPath : root.virtual)
|
||||
const links = opts.ns?.links ?? null
|
||||
const linkRow = links?.statAt(virtual)
|
||||
if (linkRow !== undefined && linkRow !== null) {
|
||||
return {
|
||||
size: linkRow.size ?? 0,
|
||||
kind: 'l',
|
||||
mtimeEpoch: modifiedTs(linkRow.modified ?? null) ?? 0,
|
||||
}
|
||||
}
|
||||
let st: FileStat | null = null
|
||||
if (stat !== undefined) {
|
||||
const prefix = mountPrefixOf(root.virtual, root.resourcePath)
|
||||
const spec = new PathSpec({
|
||||
virtual,
|
||||
directory: virtual,
|
||||
resolved: false,
|
||||
resourcePath: mountKey(virtual, prefix),
|
||||
})
|
||||
try {
|
||||
st = await stat(spec)
|
||||
} catch {
|
||||
st = null
|
||||
}
|
||||
} else if (opts.statPath !== undefined) {
|
||||
// The dispatcher probe answers for every backend, including the ones
|
||||
// that wire no cheap local stat (an object store); it is the same
|
||||
// channel the start-point classifier uses.
|
||||
st = await opts.statPath(virtual)
|
||||
}
|
||||
if (st === null) return null
|
||||
const kind = st.type === FileType.DIRECTORY ? 'd' : st.type === FileType.SYMLINK ? 'l' : 'f'
|
||||
return { size: st.size ?? 0, kind, mtimeEpoch: modifiedTs(st.modified ?? null) ?? 0 }
|
||||
}
|
||||
|
||||
// Render matched rows through a -printf format. Stats are fetched per row
|
||||
// only when the format reads one (%s %y %m %M %T), through the same
|
||||
// overlay-aware channel the -mtime filter uses, with namespace links
|
||||
// answered first since a link row has no backend inode. Warning lines
|
||||
// (unrecognized directives) ride stderr without touching the exit code,
|
||||
// GNU's behavior; missing start points keep forcing exit 1. Mirrors the
|
||||
// Python render_printf_rows.
|
||||
async function renderPrintfRows(
|
||||
pairs: [string, PathSpec][],
|
||||
fmt: string,
|
||||
stat: ((spec: PathSpec) => Promise<FileStat>) | undefined,
|
||||
opts: CommandOpts,
|
||||
missing: string[],
|
||||
): Promise<CommandFnResult> {
|
||||
const warnings: string[] = []
|
||||
const needs = printfNeedsStat(fmt)
|
||||
const parts: string[] = []
|
||||
for (const [row, root] of pairs) {
|
||||
const st = needs ? await printfStat(row, root, stat, opts) : null
|
||||
const base = root.rawPath !== '' ? root.rawPath : root.virtual
|
||||
parts.push(expandPrintf(fmt, row, base, st, warnings))
|
||||
}
|
||||
const err = [...missing, ...warnings]
|
||||
const io = new IOResult({
|
||||
stderr: err.length > 0 ? ENC.encode(err.join('\n') + '\n') : null,
|
||||
exitCode: missing.length > 0 ? 1 : 0,
|
||||
})
|
||||
return [ENC.encode(parts.join('')), io]
|
||||
}
|
||||
|
||||
@@ -154,7 +154,11 @@ export async function sedGeneric(
|
||||
]
|
||||
}
|
||||
|
||||
const modifying = inPlace && commands.some((c) => c.cmd === 's' || c.cmd === 'd')
|
||||
// GNU -i redirects the whole output stream to the file whatever the
|
||||
// script ran: `p` doubles lines in place, `q` truncates, `a`/`i`/`c`
|
||||
// land their text. Gating on the command set left every non-s/d script
|
||||
// printing to stdout while reporting success.
|
||||
const modifying = inPlace
|
||||
const allOutputs: string[] = []
|
||||
const writes: Record<string, Uint8Array> = {}
|
||||
const edited: string[] = []
|
||||
@@ -187,7 +191,9 @@ export async function sedGeneric(
|
||||
io.cache = edited
|
||||
return [null, io]
|
||||
}
|
||||
const out: ByteSource = ENC.encode(allOutputs.join('\n'))
|
||||
// GNU concatenates per-file output with no separator (each file's
|
||||
// output already carries its own newlines).
|
||||
const out: ByteSource = ENC.encode(allOutputs.join(''))
|
||||
return [out, io]
|
||||
}
|
||||
|
||||
|
||||
@@ -209,3 +209,32 @@ describe('find', () => {
|
||||
expect(r.lines).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('find -printf', () => {
|
||||
it('renders rows through the format with stats', async () => {
|
||||
const resource = new RAMResource()
|
||||
resource.store.dirs.add('/data')
|
||||
resource.store.dirs.add('/data/sub')
|
||||
resource.store.files.set('/data/a.txt', ENC.encode('hello\n'))
|
||||
resource.store.files.set('/data/sub/b.txt', ENC.encode('hi\n'))
|
||||
const { lines, exitCode } = await runFind(resource, [PathSpec.fromStrPath('/data')], {}, [
|
||||
'-printf',
|
||||
'%p %y %d\\n',
|
||||
])
|
||||
expect(exitCode).toBe(0)
|
||||
expect(lines).toEqual(['/data d 0', '/data/a.txt f 1', '/data/sub d 1', '/data/sub/b.txt f 2'])
|
||||
})
|
||||
|
||||
it('renders %f %s for one match', async () => {
|
||||
const resource = new RAMResource()
|
||||
resource.store.dirs.add('/data')
|
||||
resource.store.files.set('/data/a.txt', ENC.encode('hello\n'))
|
||||
const { lines } = await runFind(resource, [PathSpec.fromStrPath('/data')], {}, [
|
||||
'-name',
|
||||
'a.txt',
|
||||
'-printf',
|
||||
'%f %s\\n',
|
||||
])
|
||||
expect(lines).toEqual(['a.txt 6'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -89,3 +89,69 @@ describe('sed -f', () => {
|
||||
expect(out).toBe('HI world\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sed -i beyond s and d', () => {
|
||||
it('c writes the changed text to the file', async () => {
|
||||
const resource = new RAMResource()
|
||||
resource.store.dirs.add('/tmp')
|
||||
resource.store.files.set('/tmp/a.txt', ENC.encode('one\ntwo\n'))
|
||||
const out = await runSed(resource, ['c chg'], [PathSpec.fromStrPath('/tmp/a.txt')], { i: true })
|
||||
expect(out).toBe('')
|
||||
expect(DEC.decode(resource.store.files.get('/tmp/a.txt'))).toBe('chg\nchg\n')
|
||||
})
|
||||
|
||||
it('i writes the inserted line to the file', async () => {
|
||||
const resource = new RAMResource()
|
||||
resource.store.dirs.add('/tmp')
|
||||
resource.store.files.set('/tmp/a.txt', ENC.encode('one\ntwo\nthree\n'))
|
||||
const out = await runSed(resource, ['2i inserted'], [PathSpec.fromStrPath('/tmp/a.txt')], {
|
||||
i: true,
|
||||
})
|
||||
expect(out).toBe('')
|
||||
expect(DEC.decode(resource.store.files.get('/tmp/a.txt'))).toBe('one\ninserted\ntwo\nthree\n')
|
||||
})
|
||||
|
||||
it('p doubles every line in the file', async () => {
|
||||
const resource = new RAMResource()
|
||||
resource.store.dirs.add('/tmp')
|
||||
resource.store.files.set('/tmp/a.txt', ENC.encode('one\ntwo\n'))
|
||||
const out = await runSed(resource, ['p'], [PathSpec.fromStrPath('/tmp/a.txt')], { i: true })
|
||||
expect(out).toBe('')
|
||||
expect(DEC.decode(resource.store.files.get('/tmp/a.txt'))).toBe('one\none\ntwo\ntwo\n')
|
||||
})
|
||||
|
||||
it('q truncates the file at the quit line', async () => {
|
||||
const resource = new RAMResource()
|
||||
resource.store.dirs.add('/tmp')
|
||||
resource.store.files.set('/tmp/a.txt', ENC.encode('one\ntwo\nthree\n'))
|
||||
const out = await runSed(resource, ['2q'], [PathSpec.fromStrPath('/tmp/a.txt')], { i: true })
|
||||
expect(out).toBe('')
|
||||
expect(DEC.decode(resource.store.files.get('/tmp/a.txt'))).toBe('one\ntwo\n')
|
||||
})
|
||||
|
||||
it('y transliterates the file in place', async () => {
|
||||
const resource = new RAMResource()
|
||||
resource.store.dirs.add('/tmp')
|
||||
resource.store.files.set('/tmp/a.txt', ENC.encode('one\ntwo\n'))
|
||||
const out = await runSed(resource, ['y/o/0/'], [PathSpec.fromStrPath('/tmp/a.txt')], {
|
||||
i: true,
|
||||
})
|
||||
expect(out).toBe('')
|
||||
expect(DEC.decode(resource.store.files.get('/tmp/a.txt'))).toBe('0ne\ntw0\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sed multi-file output', () => {
|
||||
it('concatenates per-file output without a separator', async () => {
|
||||
const resource = new RAMResource()
|
||||
resource.store.dirs.add('/tmp')
|
||||
resource.store.files.set('/tmp/a.txt', ENC.encode('A\n'))
|
||||
resource.store.files.set('/tmp/b.txt', ENC.encode('B\n'))
|
||||
const out = await runSed(
|
||||
resource,
|
||||
['p'],
|
||||
[PathSpec.fromStrPath('/tmp/a.txt'), PathSpec.fromStrPath('/tmp/b.txt')],
|
||||
)
|
||||
expect(out).toBe('A\nA\nB\nB\n')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -253,3 +253,25 @@ describe('sed s/// edge cases', () => {
|
||||
expect(() => parseProgram('s/o/O/0')).toThrow(/may not be zero/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sed address delimiters', () => {
|
||||
it('escaped delimiter inside an address regex is a literal slash', () => {
|
||||
expect(sed('/a\\/b/d', 'x\na/b\ny\n')).toBe('x\ny\n')
|
||||
})
|
||||
|
||||
it('custom-delimiter address form \\cREc', () => {
|
||||
expect(sed('\\%a/b%d', 'a/b\nz\n')).toBe('z\n')
|
||||
})
|
||||
|
||||
it('BRE escapes inside an address survive to the regex', () => {
|
||||
expect(sed('/a\\+b/d', 'x\na+b\naab\ny\n')).toBe('x\na+b\ny\n')
|
||||
})
|
||||
|
||||
it('range addresses honor escaped delimiters', () => {
|
||||
expect(sed('/a\\/b/,/c\\/d/d', 'x\na/b\nmid\nc/d\ny\n')).toBe('x\ny\n')
|
||||
})
|
||||
|
||||
it('unterminated address regex throws', () => {
|
||||
expect(() => sed('/a\\/b', 'x\n')).toThrow('unterminated address regex')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,21 +42,43 @@ export interface SedCommand {
|
||||
|
||||
function parseAddress(addr: string): SedAddr | null {
|
||||
if (addr === '') return null
|
||||
if (addr.startsWith('/')) {
|
||||
const end = addr.indexOf('/', 1)
|
||||
return ['regex', addr.slice(1, end)]
|
||||
}
|
||||
if (/^\d+$/.test(addr)) return ['line', addr]
|
||||
if (addr === '$') return ['last', '']
|
||||
return null
|
||||
}
|
||||
|
||||
// Collect an address regex up to its unescaped closing delimiter. A
|
||||
// backslash escapes the next character (so `\/` inside `/re/` is a literal
|
||||
// slash) and the pair is kept verbatim: BRE escapes like `\+` must survive
|
||||
// for the regex translator, and the engine accepts a redundant `\/`.
|
||||
function scanRegexField(rest: string, start: number, delim: string): [string, number] {
|
||||
let out = ''
|
||||
let i = start
|
||||
while (i < rest.length) {
|
||||
const ch = rest.charAt(i)
|
||||
if (ch === '\\' && i + 1 < rest.length) {
|
||||
out += rest.slice(i, i + 2)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if (ch === delim) return [out, i + 1]
|
||||
out += ch
|
||||
i += 1
|
||||
}
|
||||
throw new Error('sed: unterminated address regex')
|
||||
}
|
||||
|
||||
function consumeAddress(rest: string): [SedAddr | null, string] {
|
||||
if (rest === '') return [null, rest]
|
||||
if (rest.startsWith('/')) {
|
||||
const end = rest.indexOf('/', 1)
|
||||
const addr: SedAddr = ['regex', rest.slice(1, end)]
|
||||
return [addr, rest.slice(end + 1)]
|
||||
const [pattern, next] = scanRegexField(rest, 1, '/')
|
||||
return [['regex', pattern], rest.slice(next)]
|
||||
}
|
||||
if (rest.startsWith('\\') && rest.length > 1) {
|
||||
// GNU's \cREc form: the character after the backslash delimits the
|
||||
// regex in place of `/`.
|
||||
const [pattern, next] = scanRegexField(rest, 2, rest.charAt(1))
|
||||
return [['regex', pattern], rest.slice(next)]
|
||||
}
|
||||
const first = rest[0]
|
||||
if (first !== undefined && (/\d/.test(first) || first === '$')) {
|
||||
|
||||
@@ -60,6 +60,7 @@ export const SPECS: Record<string, CommandSpec> = {
|
||||
new Option({ short: '-iname', type: 'str', multiple: true }),
|
||||
new Option({ short: '-path', type: 'str', multiple: true }),
|
||||
new Option({ short: '-mindepth', type: 'str', multiple: true }),
|
||||
new Option({ short: '-printf', type: 'str', multiple: true }),
|
||||
// GNU find's link policy: -P (no follow) is the default, -H
|
||||
// follows only the start point, -L follows everything.
|
||||
new Option({ short: '-P' }),
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { epochToIso, isoToEpoch, utcDateFolder } from './dates.ts'
|
||||
import { epochToIso, isoToEpoch, parseDateExpr, utcDateFolder } from './dates.ts'
|
||||
|
||||
describe('epochToIso', () => {
|
||||
it('formats whole seconds as second-precision ISO-Z', () => {
|
||||
@@ -47,3 +47,56 @@ describe('utcDateFolder', () => {
|
||||
expect(utcDateFolder(1609459200000)).toBe('2021-01-01')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDateExpr', () => {
|
||||
const NOW = new Date(Date.UTC(2026, 7, 16, 13, 45, 30))
|
||||
|
||||
it('parses relative displacements', () => {
|
||||
expect(parseDateExpr('24 hours ago', true, NOW)).toEqual(
|
||||
new Date(Date.UTC(2026, 7, 15, 13, 45, 30)),
|
||||
)
|
||||
expect(parseDateExpr('3 days', true, NOW)).toEqual(new Date(Date.UTC(2026, 7, 19, 13, 45, 30)))
|
||||
expect(parseDateExpr('-2 weeks', true, NOW)).toEqual(new Date(Date.UTC(2026, 7, 2, 13, 45, 30)))
|
||||
expect(parseDateExpr('2days', true, NOW)).toEqual(new Date(Date.UTC(2026, 7, 18, 13, 45, 30)))
|
||||
})
|
||||
|
||||
it('parses word displacements', () => {
|
||||
expect(parseDateExpr('yesterday', true, NOW)).toEqual(
|
||||
new Date(Date.UTC(2026, 7, 15, 13, 45, 30)),
|
||||
)
|
||||
expect(parseDateExpr('tomorrow', true, NOW)).toEqual(
|
||||
new Date(Date.UTC(2026, 7, 17, 13, 45, 30)),
|
||||
)
|
||||
expect(parseDateExpr('now', true, NOW)).toEqual(NOW)
|
||||
expect(parseDateExpr('last year', true, NOW)).toEqual(
|
||||
new Date(Date.UTC(2025, 7, 16, 13, 45, 30)),
|
||||
)
|
||||
expect(parseDateExpr('next month', true, NOW)).toEqual(
|
||||
new Date(Date.UTC(2026, 8, 16, 13, 45, 30)),
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes month overflow through the calendar like GNU', () => {
|
||||
expect(parseDateExpr('2026-01-31 1 month', true, NOW)).toEqual(new Date(Date.UTC(2026, 2, 3)))
|
||||
})
|
||||
|
||||
it('parses an ISO base with a relative tail', () => {
|
||||
expect(parseDateExpr('2026-08-16 12:00:00 24 hours ago', true, NOW)).toEqual(
|
||||
new Date(Date.UTC(2026, 7, 15, 12, 0, 0)),
|
||||
)
|
||||
})
|
||||
|
||||
it('parses @epoch and zone offsets', () => {
|
||||
expect(parseDateExpr('@1755300000', true)).toEqual(new Date(1755300000 * 1000))
|
||||
expect(parseDateExpr('2026-08-16T10:00:00+02:00', true)).toEqual(
|
||||
new Date(Date.UTC(2026, 7, 16, 8, 0, 0)),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for anything it cannot parse', () => {
|
||||
expect(parseDateExpr('not a date', true, NOW)).toBeNull()
|
||||
expect(parseDateExpr('24 hours agoo', true, NOW)).toBeNull()
|
||||
expect(parseDateExpr('', true, NOW)).toBeNull()
|
||||
expect(parseDateExpr('@abc', true, NOW)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,3 +42,211 @@ export function isoTimestamp(value: string | null | undefined): number | null {
|
||||
const ms = Date.parse(text)
|
||||
return Number.isNaN(ms) ? null : ms / 1000
|
||||
}
|
||||
|
||||
const UNIT_SECONDS: Record<string, number> = {
|
||||
sec: 1,
|
||||
second: 1,
|
||||
min: 60,
|
||||
minute: 60,
|
||||
hour: 3600,
|
||||
day: 86400,
|
||||
week: 604800,
|
||||
}
|
||||
const CALENDAR_UNITS = new Set(['month', 'year'])
|
||||
const NUMBER_UNIT_RE = /^([+-]?\d+)([a-z]+)$/
|
||||
const NUMBER_RE = /^[+-]?\d+$/
|
||||
const ISO_RE =
|
||||
/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?(Z|z|[+-]\d{2}:?\d{2})?$/
|
||||
|
||||
function dateUnit(word: string): string | null {
|
||||
const unit = word !== 's' && word.endsWith('s') ? word.slice(0, -1) : word
|
||||
if (unit in UNIT_SECONDS || CALENDAR_UNITS.has(unit)) return unit
|
||||
return null
|
||||
}
|
||||
|
||||
function daysInMonth(year: number, month: number): number {
|
||||
return new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
|
||||
}
|
||||
|
||||
interface DateParts {
|
||||
year: number
|
||||
month: number
|
||||
day: number
|
||||
hour: number
|
||||
minute: number
|
||||
second: number
|
||||
ms: number
|
||||
}
|
||||
|
||||
function partsOf(dt: Date, utc: boolean): DateParts {
|
||||
return {
|
||||
year: utc ? dt.getUTCFullYear() : dt.getFullYear(),
|
||||
month: utc ? dt.getUTCMonth() : dt.getMonth(),
|
||||
day: utc ? dt.getUTCDate() : dt.getDate(),
|
||||
hour: utc ? dt.getUTCHours() : dt.getHours(),
|
||||
minute: utc ? dt.getUTCMinutes() : dt.getMinutes(),
|
||||
second: utc ? dt.getUTCSeconds() : dt.getSeconds(),
|
||||
ms: utc ? dt.getUTCMilliseconds() : dt.getMilliseconds(),
|
||||
}
|
||||
}
|
||||
|
||||
function dateFrom(p: DateParts, utc: boolean): Date {
|
||||
if (utc) return new Date(Date.UTC(p.year, p.month, p.day, p.hour, p.minute, p.second, p.ms))
|
||||
return new Date(p.year, p.month, p.day, p.hour, p.minute, p.second, p.ms)
|
||||
}
|
||||
|
||||
function addMonthsGnu(dt: Date, count: number, utc: boolean): Date {
|
||||
const p = partsOf(dt, utc)
|
||||
const total = p.month + count
|
||||
let year = p.year + Math.floor(total / 12)
|
||||
let month = ((total % 12) + 12) % 12
|
||||
// GNU normalizes an overflowing day-of-month through mktime rather than
|
||||
// clamping: Jan 31 + 1 month is Mar 3, not Feb 28.
|
||||
let day = p.day
|
||||
const days = daysInMonth(year, month)
|
||||
if (day > days) {
|
||||
day -= days
|
||||
month += 1
|
||||
if (month === 12) {
|
||||
month = 0
|
||||
year += 1
|
||||
}
|
||||
}
|
||||
return dateFrom({ ...p, year, month, day }, utc)
|
||||
}
|
||||
|
||||
function shiftDate(dt: Date, unit: string, count: number, utc: boolean): Date {
|
||||
if (unit === 'month') return addMonthsGnu(dt, count, utc)
|
||||
if (unit === 'year') return addMonthsGnu(dt, 12 * count, utc)
|
||||
return new Date(dt.getTime() + (UNIT_SECONDS[unit] ?? 0) * count * 1000)
|
||||
}
|
||||
|
||||
function parseIsoWords(text: string, utc: boolean): Date | null {
|
||||
const m = ISO_RE.exec(text)
|
||||
if (m === null) return null
|
||||
const year = Number(m[1])
|
||||
const month = Number(m[2]) - 1
|
||||
const day = Number(m[3])
|
||||
const hour = m[4] !== undefined ? Number(m[4]) : 0
|
||||
const minute = m[5] !== undefined ? Number(m[5]) : 0
|
||||
const second = m[6] !== undefined ? Number(m[6]) : 0
|
||||
const ms = m[7] !== undefined ? Math.round(Number(`0.${m[7]}`) * 1000) : 0
|
||||
const zone = m[8]
|
||||
if (zone !== undefined) {
|
||||
let offsetMin = 0
|
||||
if (zone !== 'Z' && zone !== 'z') {
|
||||
const zm = /^([+-])(\d{2}):?(\d{2})$/.exec(zone)
|
||||
if (zm === null) return null
|
||||
offsetMin = (zm[1] === '-' ? -1 : 1) * (Number(zm[2]) * 60 + Number(zm[3]))
|
||||
}
|
||||
return new Date(Date.UTC(year, month, day, hour, minute, second, ms) - offsetMin * 60_000)
|
||||
}
|
||||
return dateFrom({ year, month, day, hour, minute, second, ms }, utc)
|
||||
}
|
||||
|
||||
function applyRelative(base: Date, words: string[], utc: boolean): Date | null {
|
||||
let result = base
|
||||
// What `ago` would negate: the state before the last displacement plus
|
||||
// that displacement. Re-applying from the checkpoint (rather than
|
||||
// subtracting twice) keeps month normalization exact.
|
||||
let checkpoint: [Date, string, number] | null = null
|
||||
let i = 0
|
||||
while (i < words.length) {
|
||||
let word = (words[i] ?? '').toLowerCase()
|
||||
if (word === 'now' || word === 'today') {
|
||||
checkpoint = null
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if (word === 'yesterday' || word === 'tomorrow') {
|
||||
const days = word === 'yesterday' ? -1 : 1
|
||||
checkpoint = [result, 'day', days]
|
||||
result = shiftDate(result, 'day', days, utc)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if (word === 'last' || word === 'next') {
|
||||
const unit = i + 1 < words.length ? dateUnit((words[i + 1] ?? '').toLowerCase()) : null
|
||||
if (unit === null) return null
|
||||
const count = word === 'last' ? -1 : 1
|
||||
checkpoint = [result, unit, count]
|
||||
result = shiftDate(result, unit, count, utc)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if (word === 'ago') {
|
||||
if (checkpoint === null) return null
|
||||
const [before, unit, count] = checkpoint
|
||||
result = shiftDate(before, unit, -count, utc)
|
||||
checkpoint = null
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
let sign = 1
|
||||
if (word === '+' || word === '-') {
|
||||
sign = word === '-' ? -1 : 1
|
||||
i += 1
|
||||
if (i >= words.length) return null
|
||||
word = (words[i] ?? '').toLowerCase()
|
||||
}
|
||||
const combined = NUMBER_UNIT_RE.exec(word)
|
||||
if (combined !== null) {
|
||||
const unit = dateUnit(combined[2] ?? '')
|
||||
if (unit === null) return null
|
||||
const count = Number(combined[1]) * sign
|
||||
checkpoint = [result, unit, count]
|
||||
result = shiftDate(result, unit, count, utc)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if (NUMBER_RE.test(word)) {
|
||||
const unit = i + 1 < words.length ? dateUnit((words[i + 1] ?? '').toLowerCase()) : null
|
||||
if (unit === null) return null
|
||||
const count = Number(word) * sign
|
||||
checkpoint = [result, unit, count]
|
||||
result = shiftDate(result, unit, count, utc)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
const unit = dateUnit(word)
|
||||
if (unit !== null) {
|
||||
checkpoint = [result, unit, sign]
|
||||
result = shiftDate(result, unit, sign, utc)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
return null
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Parse a GNU `date -d` expression, or null when it is invalid. Covers the
|
||||
// forms agents actually type: ISO 8601 dates and datetimes (with or without
|
||||
// zone), `@epoch`, and gnulib's relative grammar (`24 hours ago`,
|
||||
// `yesterday`, `next month`, `-2 weeks`, an ISO date followed by
|
||||
// displacements). A null return is the caller's cue for GNU's
|
||||
// `date: invalid date '...'` refusal, never a NaN render. Mirrors the
|
||||
// Python parse_date_expr.
|
||||
export function parseDateExpr(text: string, utc: boolean, now?: Date): Date | null {
|
||||
const raw = text.trim()
|
||||
if (raw === '') return null
|
||||
if (raw.startsWith('@')) {
|
||||
const epoch = Number(raw.slice(1))
|
||||
if (Number.isNaN(epoch)) return null
|
||||
return new Date(epoch * 1000)
|
||||
}
|
||||
const whole = parseIsoWords(raw, utc)
|
||||
if (whole !== null) return whole
|
||||
const words = raw.split(/\s+/)
|
||||
let base = now ?? new Date()
|
||||
let index = 0
|
||||
for (const take of [2, 1]) {
|
||||
if (words.length < take) continue
|
||||
const prefix = parseIsoWords(words.slice(0, take).join(' '), utc)
|
||||
if (prefix === null) continue
|
||||
base = prefix
|
||||
index = take
|
||||
break
|
||||
}
|
||||
return applyRelative(base, words.slice(index), utc)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user