From 4324da146775e69c21f909e5bec351c6faa2a906 Mon Sep 17 00:00:00 2001 From: Hans Dijkema Date: Thu, 20 Aug 2026 23:48:49 +0200 Subject: [PATCH] Aded extended commands and editor --- CHANGELOG | 23 + README.md | 51 +- help | 6 +- info.rkt | 4 +- main.rkt | 2 + private/aliases.rkt | 6 +- private/commands.rkt | 5 +- private/editor.rkt | 66 +++ private/extended.rkt | 974 +++++++++++++++++++++++++++++++------ scrbl/rash-coreutils.scrbl | 655 +++++++++++++++++++++---- tests/coreutils.rkt | 67 +++ tests/rash-smoke.rkt | 18 +- 12 files changed, 1616 insertions(+), 261 deletions(-) create mode 100644 private/editor.rkt diff --git a/CHANGELOG b/CHANGELOG index 28dfbd3..4615ca1 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,26 @@ +0.2.14 + +- Add `rackedit` as a runtime dependency. +- Add the Rash `edit` command, implemented by `coreutils-edit`. +- Use RackEdit's `rkdt` as the default editor and map `edit --wait FILE` to `#:wait? #t`. +- Add `current-coreutils-editor` so callers can dynamically select another editor procedure. +- Rework the Scribble manual around the commands available in Rash, with each command documenting its internal Racket implementation. +- Add non-GUI regression tests for editor dispatch and `--wait` translation. + +0.2.12 + +- Add a portable `time` command for rash-coreutils commands and executables on PATH. +- Report real, combined CPU, and Racket GC time to stderr so command stdout remains pipeline-safe. +- Include completed subprocess CPU time in the reported CPU measurement. +- Add package-index-safe regression coverage for command timing and stderr separation. + +0.2.11 + +- Make head and tail scan fixed-size byte blocks instead of materializing complete lines. +- Make tail scan seekable files backwards so memory use is independent of file and line size. +- Spool tail input from stdin or multiple files to a temporary file instead of retaining lines in memory. +- Add regression coverage for very long lines in head and tail. + 0.2.9 - Make package-build tests independent of the directory from which raco test is invoked. diff --git a/README.md b/README.md index 8b18f95..11d0c96 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,63 @@ # rash-coreutils -Portable Unix-style core utilities for Rash, implemented in Racket. +Portable Unix-style commands for Rash, implemented in Racket. -Version 0.2.9 includes text/pipeline tools (`head`, `tail`, `wc`, `sort`, `uniq`, -`cut`, `tee`, `tr`), path and filesystem helpers (`basename`, `dirname`, -`realpath`, `readlink`, `stat`, `du`, `df`, `mktemp`), environment commands -(`printenv`, `env`), and `date` backed by Gregor. +Version 0.2.14 adds the `edit` command backed by the `rackedit` package and +reworks the manual around the commands that are available directly in Rash. +The manual now describes, per command, which internal `coreutils-*` Racket +procedure implements it. Rash remains responsible for shell syntax such as globbing, pipelines and redirection. `rash-coreutils` additionally accepts Racket regular expressions as path selectors. +## Editing + +`edit` opens one file using the editor configured by `current-coreutils-editor`. +The default editor is RackEdit's `rkdt` procedure. + +```text +edit notes.rkt +edit --wait notes.rkt +``` + +`--wait` is passed to RackEdit as `#:wait? #t`. In ordinary Racket code a +different editor can be selected dynamically by parameterizing +`current-coreutils-editor` with a procedure that accepts a file path and the +`#:wait?` keyword. + +## Command timing + +`time` executes one rash-coreutils command or executable and writes timing data +to standard error. Command output therefore remains suitable for pipelines and +redirection. The portable measurements are elapsed (`real`) time, combined CPU +time (`cpu`) for Racket and completed subprocesses, and Racket garbage-collection +time (`gc`). + +```text +time head -n 100 large.txt +time raco test tests/coreutils.rkt +``` ## Racket tooling -`rash-coreutils` also exposes the `raco` command from the active Racket installation, without requiring `raco` to be on PATH. +`rash-coreutils` also exposes the `raco` command from the active Racket +installation, without requiring `raco` to be on PATH. ```text raco setup rash-coreutils raco pkg show ``` -In Racket expression mode, use `coreutils-raco`, for example `(coreutils-raco '(setup rash-coreutils))`. The name `raco` is reserved for the Rash command binding. +In Racket expression mode, use `coreutils-raco`, for example +`(coreutils-raco '(setup rash-coreutils))`. The name `raco` is reserved for the +Rash command binding. ## Windows paths -Commands that print paths use forward slashes on Windows. This keeps their textual output directly reusable in Rash line mode, where a backslash is an escape character. Internally, Racket path values remain native paths. +Commands that print paths use forward slashes on Windows. This keeps their +textual output directly reusable in Rash line mode, where a backslash is an +escape character. Internally, Racket path values remain native paths. ```text pwd @@ -35,4 +67,5 @@ mktemp C:/Users/hans/AppData/Local/Temp/tmp123 ``` -A native Racket path value can still be passed directly, for example `ls (values (find-system-path 'temp-dir))`. +A native Racket path value can still be passed directly, for example +`ls (values (find-system-path 'temp-dir))`. diff --git a/help b/help index b02f5c9..c3fccbd 100644 --- a/help +++ b/help @@ -1,7 +1,7 @@ #lang info (define pkg-authors '(hnmdijkema)) -(define version "0.1.6") +(define version "0.2.14") (define license 'MIT) (define collection "rash-coreutils") (define pkg-desc @@ -11,10 +11,10 @@ '(("scrbl/rash-coreutils.scrbl" () (library 0)))) (define deps - '("base" "rash")) + '("base" "rash" "gregor-lib" "rackedit")) (define build-deps '("racket-doc" "rackunit-lib" "scribble-lib" - "rash")) + "linea")) \ No newline at end of file diff --git a/info.rkt b/info.rkt index c442194..c3fccbd 100644 --- a/info.rkt +++ b/info.rkt @@ -1,7 +1,7 @@ #lang info (define pkg-authors '(hnmdijkema)) -(define version "0.2.9") +(define version "0.2.14") (define license 'MIT) (define collection "rash-coreutils") (define pkg-desc @@ -11,7 +11,7 @@ '(("scrbl/rash-coreutils.scrbl" () (library 0)))) (define deps - '("base" "rash" "gregor-lib")) + '("base" "rash" "gregor-lib" "rackedit")) (define build-deps '("racket-doc" diff --git a/main.rkt b/main.rkt index 62c1ecd..a9dbb07 100644 --- a/main.rkt +++ b/main.rkt @@ -4,6 +4,7 @@ "private/extended.rkt" "private/commands.rkt" "private/racket-tools.rkt" + "private/editor.rkt" "private/dispatcher.rkt" "private/help.rkt" "private/aliases.rkt") @@ -12,6 +13,7 @@ (all-from-out "private/extended.rkt") (all-from-out "private/commands.rkt") (all-from-out "private/racket-tools.rkt") + (all-from-out "private/editor.rkt") (all-from-out "private/dispatcher.rkt") (all-from-out "private/help.rkt") (all-from-out "private/aliases.rkt")) diff --git a/private/aliases.rkt b/private/aliases.rkt index cbc5cd6..08b8191 100644 --- a/private/aliases.rkt +++ b/private/aliases.rkt @@ -9,7 +9,7 @@ (provide pwd ls mkdir rmdir rm cp mv cat touch echo which head tail wc sort uniq cut tee tr basename dirname realpath readlink - stat du df mktemp printenv env date help raco) + stat du df mktemp printenv env date time edit help raco) (define (make-command-procedure name) (λ args @@ -45,6 +45,8 @@ (define printenv-command (make-command-procedure 'printenv)) (define env-command (make-command-procedure 'env)) (define date-command (make-command-procedure 'date)) +(define time-command (make-command-procedure 'time)) +(define edit-command (make-command-procedure 'edit)) (define raco-command coreutils-raco) (define (help-command . args) @@ -131,5 +133,7 @@ (define-pipeline-alias printenv (make-coreutils-alias #'printenv-command)) (define-pipeline-alias env (make-env-alias #'env-command)) (define-pipeline-alias date (make-coreutils-alias #'date-command)) +(define-pipeline-alias time (make-coreutils-alias #'time-command)) +(define-pipeline-alias edit (make-coreutils-alias #'edit-command)) (define-pipeline-alias help (make-coreutils-alias #'help-command)) (define-pipeline-alias raco (make-coreutils-alias #'raco-command)) diff --git a/private/commands.rkt b/private/commands.rkt index b1775ea..fad5a3d 100644 --- a/private/commands.rkt +++ b/private/commands.rkt @@ -2,7 +2,8 @@ (require "coreutils.rkt" "extended.rkt" - "racket-tools.rkt") + "racket-tools.rkt" + "editor.rkt") (provide (struct-out coreutils-command) coreutils-commands @@ -42,6 +43,8 @@ (coreutils-command 'printenv coreutils-printenv 'rash-coreutils-printenv) (coreutils-command 'env coreutils-env 'rash-coreutils-env) (coreutils-command 'date coreutils-date 'rash-coreutils-date) + (coreutils-command 'time coreutils-time 'rash-coreutils-time) + (coreutils-command 'edit coreutils-edit 'rash-coreutils-edit) (coreutils-command 'raco coreutils-raco 'rash-coreutils-raco))) (define (find-coreutils-command name) diff --git a/private/editor.rkt b/private/editor.rkt new file mode 100644 index 0000000..e500351 --- /dev/null +++ b/private/editor.rkt @@ -0,0 +1,66 @@ +#lang racket/base + +(require racket/format + racket/path + racket/string + rackedit) + +(provide coreutils-edit + current-coreutils-editor) + +(define current-coreutils-editor + (make-parameter + rkdt + (λ (editor) + (unless (procedure? editor) + (raise-argument-error 'current-coreutils-editor "procedure?" editor)) + editor))) + +(define (arg->path arg) + (cond + [(path? arg) arg] + [(string? arg) (string->path arg)] + [(symbol? arg) (string->path (symbol->string arg))] + [else + (raise-argument-error + 'edit + "path, string, or symbol" + arg)])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Open one file in the editor configured for rash-coreutils. +; pre : args contains exactly one filename and optionally --wait. +; post : The configured editor has been invoked once. With --wait, the +; command does not return until the editor procedure returns. +; result : (void). +; internals: +; current-coreutils-editor defaults to rackedit's rkdt procedure. +; The --wait flag is translated to rkdt's #:wait? keyword. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (coreutils-edit . args) + (define wait? #f) + (define reversed-paths '()) + + (for ([arg (in-list args)]) + (define text (~a arg)) + (cond + [(string=? text "--wait") + (set! wait? #t)] + [(string-prefix? text "-") + (raise-arguments-error 'edit + "unsupported option" + "option" text)] + [else + (set! reversed-paths (cons (arg->path arg) reversed-paths))])) + + (define paths (reverse reversed-paths)) + (unless (or (= (length paths) 1) (= (length paths) 0)) + (raise-arguments-error 'edit + "expected exactly zero or one file" + "arguments" args)) + + (if (null? paths) + ((current-coreutils-editor) #:wait? wait?) + ((current-coreutils-editor) (car paths) #:wait? wait?)) + + (void)) diff --git a/private/extended.rkt b/private/extended.rkt index e57a274..27a0272 100644 --- a/private/extended.rkt +++ b/private/extended.rkt @@ -1,5 +1,8 @@ #lang racket/base +;; rash-coreutils 0.2.14 +;; Streaming implementation of the extended core utilities. + (require gregor racket/file racket/format @@ -30,7 +33,8 @@ coreutils-mktemp coreutils-printenv coreutils-env - coreutils-date) + coreutils-date + coreutils-time) (define (arg->string arg) (cond @@ -42,13 +46,48 @@ (define (arg->path arg) (string->path (arg->string arg))) -(define (read-lines-from-arguments args) - (if (null? args) - (port->lines (current-input-port)) - (append-map - (λ (arg) - (call-with-input-file* (arg->path arg) port->lines)) - args))) +;; Calls proc for each input port. Proc returns #t to continue with the +;; next file and #f to stop processing input early. +(define (for-each-input-port/while args proc) + (cond + [(null? args) + (proc (current-input-port))] + [else + (let loop ([rest args]) + (cond + [(null? rest) #t] + [else + (define continue? + (call-with-input-file* (arg->path (car rest)) proc)) + (and continue? + (loop (cdr rest)))]))])) + +(define (for-each-input-port args proc) + (for-each-input-port/while + args + (λ (in) + (proc in) + #t)) + (void)) + +(define (for-each-input-line/while args proc) + (for-each-input-port/while + args + (λ (in) + (let loop () + (define line (read-line in 'any)) + (cond + [(eof-object? line) #t] + [(proc line) (loop)] + [else #f]))))) + +(define (for-each-input-line args proc) + (for-each-input-line/while + args + (λ (line) + (proc line) + #t)) + (void)) (define (parse-n-option who args default) (cond @@ -64,31 +103,193 @@ [else (values default args)])) +(define line-stream-buffer-size (* 64 1024)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Write the first requested number of input lines. +; pre : args contains an optional -n/--lines non-negative integer and +; readable file arguments; without files, current-input-port is used. +; post : File input ports opened by this procedure are closed. Input after +; the requested number of LF-terminated lines is not read. +; result : (void); at most n lines are written to current-output-port. +; internals: +; Input is scanned in fixed 64 KiB byte blocks. Bytes are written as +; soon as they are read and only LF bytes are counted. A complete +; input line is never materialized, so memory use is independent of +; both the complete input size and the length of an individual line. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (coreutils-head . args) (define-values (n files) (parse-n-option 'head args 10)) - (define lines (read-lines-from-arguments files)) - (for ([line (in-list (take lines (min n (length lines))))]) - (displayln line))) + (unless (zero? n) + (let ([remaining n]) + (for-each-input-port/while + files + (λ (in) + (define buffer (make-bytes line-stream-buffer-size)) + (let loop () + (define count (read-bytes-avail! buffer in)) + (cond + [(eof-object? count) #t] + [else + (define stop-position #f) + (let scan ([position 0]) + (when (and (< position count) + (eq? stop-position #f)) + (when (= (bytes-ref buffer position) 10) + (set! remaining (sub1 remaining)) + (when (zero? remaining) + (set! stop-position (add1 position)))) + (scan (add1 position)))) + (cond + [stop-position + (write-bytes buffer + (current-output-port) + 0 + stop-position) + #f] + [else + (write-bytes buffer + (current-output-port) + 0 + count) + (loop)])])))))) + (void)) +;; Return the byte position at which the final n lines start. The input port +;; must be a seekable file port positioned anywhere in the file. +(define (tail-start-position in size n) + (cond + [(or (zero? n) (zero? size)) size] + [else + (file-position in (sub1 size)) + (define ends-with-newline? + (= (read-byte in) 10)) + (define newlines-needed + (if ends-with-newline? + (add1 n) + n)) + (define buffer (make-bytes line-stream-buffer-size)) + (let block-loop ([block-end size] + [remaining newlines-needed]) + (cond + [(zero? block-end) 0] + [else + (define block-start + (max 0 (- block-end line-stream-buffer-size))) + (define count (- block-end block-start)) + (file-position in block-start) + (define read-count (read-bytes! buffer in 0 count)) + (unless (and (exact-integer? read-count) + (>= read-count 0) + (= read-count count)) + (error 'tail "could not read requested file block")) + (let scan ([position (sub1 count)] + [needed remaining]) + (cond + [(negative? position) + (block-loop block-start needed)] + [(= (bytes-ref buffer position) 10) + (define next-needed (sub1 needed)) + (if (zero? next-needed) + (+ block-start position 1) + (scan (sub1 position) next-needed))] + [else + (scan (sub1 position) needed)]))]))])) + +;; Write the final n lines from a seekable file without reading a complete +;; line into memory. +(define (write-tail-file path n) + (call-with-input-file* + path + (λ (in) + (define size (file-size path)) + (define start (tail-start-position in size n)) + (file-position in start) + (copy-port in (current-output-port))))) + +;; Tail cannot know which bytes from a non-seekable stream are final until EOF. +;; Spool such input to disk so memory stays bounded. +(define (write-tail-stream in n) + (define temp-path (make-temporary-file "rash-coreutils-tail-~a")) + (dynamic-wind + void + (λ () + (call-with-output-file + temp-path + (λ (out) + (copy-port in out)) + #:exists 'truncate/replace) + (write-tail-file temp-path n)) + (λ () + (when (file-exists? temp-path) + (delete-file temp-path))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Write the last requested number of input lines. +; pre : args contains an optional -n/--lines non-negative integer and +; readable file arguments; without files, current-input-port is used. +; post : All required input has been consumed and every temporary file or +; file input port opened by this procedure is closed and removed. +; result : (void); at most the last n LF-delimited lines are written to +; current-output-port. +; internals: +; A single named file is scanned backwards in fixed 64 KiB byte +; blocks and then copied from the calculated start position. stdin +; and multiple file arguments are first spooled to one temporary +; file and handled in the same way. No complete input line is ever +; materialized, so even an individual line larger than available +; memory does not cause the tail buffer itself to grow. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (coreutils-tail . args) (define-values (n files) (parse-n-option 'tail args 10)) - (define lines (read-lines-from-arguments files)) - (define count (length lines)) - (for ([line (in-list (drop lines (max 0 (- count n))))]) - (displayln line))) - -(define (bytes-for-arguments args) - (if (null? args) - (port->bytes (current-input-port)) - (apply bytes-append - (for/list ([arg (in-list args)]) - (file->bytes (arg->path arg)))))) + (unless (zero? n) + (cond + [(null? files) + (write-tail-stream (current-input-port) n)] + [(null? (cdr files)) + (write-tail-file (arg->path (car files)) n)] + [else + ;; Keep the existing no-header behavior for multiple file arguments. + ;; Their bytes are concatenated on disk before the backwards scan. + (define temp-path (make-temporary-file "rash-coreutils-tail-~a")) + (dynamic-wind + void + (λ () + (call-with-output-file + temp-path + (λ (out) + (for ([file (in-list files)]) + (call-with-input-file* + (arg->path file) + (λ (in) + (copy-port in out))))) + #:exists 'truncate/replace) + (write-tail-file temp-path n)) + (λ () + (when (file-exists? temp-path) + (delete-file temp-path))))])) + (void)) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Count lines, words and/or bytes in input without materializing the +; complete input. +; pre : args contains only -l, -w, -c/--bytes and readable file arguments; +; without files, current-input-port is used. +; post : All input has been consumed and file input ports opened by this +; procedure are closed. +; result : (void); the selected counts are written to current-output-port. +; With no count options, line, word and byte counts are written. +; internals: +; Input is processed in a fixed 64 KiB byte buffer. Newlines and +; ASCII whitespace are recognized directly from bytes. Only counters +; and the current word state are retained; a file boundary ends a +; word for the combined result. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (coreutils-wc . args) (define lines? #f) (define words? #f) (define bytes? #f) - (define files '()) + (define reversed-files '()) (for ([arg (in-list args)]) (define text (arg->string arg)) (cond @@ -97,68 +298,305 @@ [(or (string=? text "-c") (string=? text "--bytes")) (set! bytes? #t)] [(string-prefix? text "-") (raise-arguments-error 'wc "unsupported option" "option" text)] - [else (set! files (append files (list arg)))])) + [else + (set! reversed-files (cons arg reversed-files))])) (when (not (or lines? words? bytes?)) (set! lines? #t) (set! words? #t) (set! bytes? #t)) - (define bs (bytes-for-arguments files)) - (define text (bytes->string/utf-8 bs #\?)) + (define files (reverse reversed-files)) + + (define line-count 0) + (define word-count 0) + (define byte-count 0) + (define inside-word? #f) + + ;; The previous implementation used #px"\\s" after UTF-8 decoding. + ;; Pregexp whitespace is ASCII whitespace, so word boundaries can be + ;; counted directly from bytes. UTF-8 continuation bytes can never equal + ;; one of these ASCII bytes, which makes this both exact and fast. + (define (word-whitespace-byte? byte) + (or (= byte 32) ; space + (= byte 9) ; tab + (= byte 10) ; line feed + (= byte 11) ; vertical tab + (= byte 12) ; form feed + (= byte 13))) ; carriage return + + (define (process-port in) + (define buffer (make-bytes (* 64 1024))) + (let loop () + (define count (read-bytes-avail! buffer in)) + (unless (eof-object? count) + (when bytes? + (set! byte-count (+ byte-count count))) + (when (or lines? words?) + (for ([i (in-range count)]) + (define byte (bytes-ref buffer i)) + (when (and lines? (= byte 10)) + (set! line-count (add1 line-count))) + (when words? + (cond + [(word-whitespace-byte? byte) + (set! inside-word? #f)] + [(not inside-word?) + (set! word-count (add1 word-count)) + (set! inside-word? #t)])))) + (loop))) + ;; A file boundary also ends a word. This prevents the last word of one + ;; file and the first word of the next file from being counted as one. + (when words? + (set! inside-word? #f))) + + (for-each-input-port files process-port) + (define results (filter (λ (x) x) - (list (if lines? (number->string (length (regexp-match* #rx"\n" text))) #f) - (if words? (number->string (length (regexp-match* #px"\\S+" text))) #f) - (if bytes? (number->string (bytes-length bs)) #f)))) + (list (if lines? (number->string line-count) #f) + (if words? (number->string word-count) #f) + (if bytes? (number->string byte-count) #f)))) (displayln (string-join results " "))) +(define sort-run-memory-limit (* 16 1024 1024)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Sort input lines lexically or numerically without keeping the +; complete input in memory. +; pre : args contains only supported sort options (-r/--reverse and +; -n/--numeric-sort) followed by readable file arguments; when no +; files are supplied, current-input-port provides the input. +; post : All temporary run files and ports created by this procedure are +; closed and removed, including when sorting or merging raises an +; exception. +; result : (void); sorted lines are written to current-output-port. +; internals: +; Input is collected in approximately 16 MiB runs. Each run is +; sorted in memory and written to a temporary file. Runs are then +; merged pairwise until one run remains. During merging only one +; current line from each of two runs is kept in memory. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (coreutils-sort . args) (define reverse? #f) (define numeric? #f) - (define files '()) + (define reversed-files '()) + (for ([arg (in-list args)]) (define text (arg->string arg)) (cond - [(or (string=? text "-r") (string=? text "--reverse")) (set! reverse? #t)] - [(or (string=? text "-n") (string=? text "--numeric-sort")) (set! numeric? #t)] + [(or (string=? text "-r") (string=? text "--reverse")) + (set! reverse? #t)] + [(or (string=? text "-n") (string=? text "--numeric-sort")) + (set! numeric? #t)] [(string-prefix? text "-") (raise-arguments-error 'sort "unsupported option" "option" text)] - [else (set! files (append files (list arg)))])) - (define (less? a b) - (if numeric? - (< (or (string->number a) +inf.0) - (or (string->number b) +inf.0)) - (stringnumber line)]) + (if (real? value) + value + +inf.0)) + line)) + + (define (key a b) + (< a b))] + [else + (if reverse? + (string>? a b) + (string= run-size sort-run-memory-limit) + (flush-run!)))) + + (flush-run!) + (set! run-paths (reverse run-paths)) + + (let reduce ([runs run-paths]) + (cond + [(null? runs) + (void)] + [(null? (cdr runs)) + (call-with-input-file + (car runs) + (λ (in) + (copy-port in (current-output-port))))] + [else + (reduce (merge-pass runs))]))) + (λ () + (for ([path (in-list temp-paths)]) + (delete-sort-temp-file path))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Remove adjacent duplicate input lines, optionally writing the run +; count for each distinct line. +; pre : args contains only -c/--count and readable file arguments; without +; files, current-input-port is used. Input should already be grouped +; when duplicates that are not adjacent must also be removed. +; post : All input has been consumed and file input ports opened by this +; procedure are closed. +; result : (void); one output line is written for each adjacent run. +; internals: +; Only the current line and its count are retained. A completed run +; is written as soon as a different line is read. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (coreutils-uniq . args) (define count? #f) - (define files '()) + (define reversed-files '()) (for ([arg (in-list args)]) (define text (arg->string arg)) (cond - [(or (string=? text "-c") (string=? text "--count")) (set! count? #t)] + [(or (string=? text "-c") (string=? text "--count")) + (set! count? #t)] [(string-prefix? text "-") (raise-arguments-error 'uniq "unsupported option" "option" text)] - [else (set! files (append files (list arg)))])) - (for ([entry (in-list (run-lengths (read-lines-from-arguments files)))]) - (if count? - (printf "~a ~a\n" (~a (cdr entry) #:min-width 7 #:align 'right) (car entry)) - (displayln (car entry))))) + [else + (set! reversed-files (cons arg reversed-files))])) + (define files (reverse reversed-files)) + (define current-line #f) + (define current-count 0) + + (define (write-current!) + (when current-line + (if count? + (printf "~a ~a\n" + (~a current-count #:min-width 7 #:align 'right) + current-line) + (displayln current-line)))) + + (for-each-input-line + files + (λ (line) + (cond + [(and current-line (string=? current-line line)) + (set! current-count (add1 current-count))] + [else + (write-current!) + (set! current-line line) + (set! current-count 1)]))) + (write-current!)) (define (parse-field-spec text) (define n (string->number text)) @@ -167,10 +605,23 @@ "field" text)) n) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Write one selected delimited field from every input line. +; pre : args contains -f/--fields with one positive field number, an +; optional -d/--delimiter, and readable file arguments; without +; files, current-input-port is used. +; post : All input has been consumed and file input ports opened by this +; procedure are closed. Lines without the selected field produce no +; output. +; result : (void); selected field values are written to current-output-port. +; internals: +; Input is read one line at a time. Only the current line is split +; into fields, so memory use is bounded by the size of one line. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (coreutils-cut . args) (define delimiter "\t") (define field #f) - (define files '()) + (define reversed-files '()) (let loop ([rest args]) (cond [(null? rest) (void)] @@ -187,39 +638,69 @@ [(string-prefix? (arg->string (car rest)) "-") (raise-arguments-error 'cut "unsupported option" "option" (car rest))] [else - (set! files (append files (list (car rest)))) + (set! reversed-files (cons (car rest) reversed-files)) (loop (cdr rest))])) (unless field (raise-arguments-error 'cut "expected -f FIELD" "arguments" args)) - (for ([line (in-list (read-lines-from-arguments files))]) - (define parts (string-split line delimiter #:trim? #f #:repeat? #f)) - (when (<= field (length parts)) - (displayln (list-ref parts (sub1 field)))))) + (define files (reverse reversed-files)) + (for-each-input-line + files + (λ (line) + (define parts (string-split line delimiter #:trim? #f #:repeat? #f)) + (when (<= field (length parts)) + (displayln (list-ref parts (sub1 field))))))) +(define (open-output-files paths append?) + (let loop ([rest paths] [opened '()]) + (cond + [(null? rest) (reverse opened)] + [else + (with-handlers + ([exn? + (λ (e) + (for ([out (in-list opened)]) + (close-output-port out)) + (raise e))]) + (loop (cdr rest) + (cons (open-output-file + (car rest) + #:exists (if append? 'append 'truncate/replace)) + opened)))]))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Copy standard input to standard output and to each requested file. +; pre : args contains only -a/--append and writable file paths. +; post : Every output file opened by this procedure is closed, including +; when copying raises an exception. Existing files are replaced +; unless append mode was requested. +; result : (void); the complete input has been copied to every output. +; internals: +; Racket copy-port performs the streaming copy directly. Files are +; opened before copying and dynamic-wind guarantees their cleanup. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (coreutils-tee . args) (define append? #f) - (define files '()) + (define reversed-files '()) (for ([arg (in-list args)]) (define text (arg->string arg)) (cond - [(or (string=? text "-a") (string=? text "--append")) (set! append? #t)] + [(or (string=? text "-a") (string=? text "--append")) + (set! append? #t)] [(string-prefix? text "-") (raise-arguments-error 'tee "unsupported option" "option" text)] - [else (set! files (append files (list (arg->path arg))))])) - (define outputs - (for/list ([path (in-list files)]) - (open-output-file path #:exists (if append? 'append 'truncate/replace)))) + [else + (set! reversed-files (cons (arg->path arg) reversed-files))])) + (define outputs (open-output-files (reverse reversed-files) append?)) (dynamic-wind void (λ () - (let loop () - (define bs (read-bytes 4096 (current-input-port))) - (unless (eof-object? bs) - (write-bytes bs (current-output-port)) - (for ([out (in-list outputs)]) (write-bytes bs out)) - (loop)))) + (apply copy-port + (current-input-port) + (current-output-port) + outputs)) (λ () - (for ([out (in-list outputs)]) (close-output-port out))))) + (for ([out (in-list outputs)]) + (close-output-port out))))) (define (expand-character-set text) (define chars (string->list text)) @@ -233,49 +714,60 @@ (char<=? (car rest) (caddr rest))) (define start (char->integer (car rest))) (define end (char->integer (caddr rest))) - (define expanded - (for/list ([code (in-range start (add1 end))]) - (integer->char code))) - (loop (cdddr rest) (append (reverse expanded) result))] + (define expanded-result + (for/fold ([expanded result]) ([code (in-range start (add1 end))]) + (cons (integer->char code) expanded))) + (loop (cdddr rest) expanded-result)] [else (loop (cdr rest) (cons (car rest) result))]))) -(define (squeeze-characters text chars) - (define squeeze? (list->seteq chars)) - (list->string - (let loop ([rest (string->list text)] [previous #f] [result '()]) - (cond - [(null? rest) (reverse result)] - [else - (define ch (car rest)) - (if (and previous - (char=? ch previous) - (set-member? squeeze? ch)) - (loop (cdr rest) previous result) - (loop (cdr rest) ch (cons ch result)))])))) - -(define (translate-characters text from to) +(define (make-translation-mapping from to) (when (null? to) (raise-arguments-error 'tr "SET2 must not be empty" "SET2" to)) - (define last-to (last to)) - (define mapping - (for/hash ([ch (in-list from)] [i (in-naturals)]) - (values ch (if (< i (length to)) (list-ref to i) last-to)))) - (list->string - (for/list ([ch (in-string text)]) - (hash-ref mapping ch ch)))) + (define to-vector (list->vector to)) + (define to-count (vector-length to-vector)) + (define last-to (vector-ref to-vector (sub1 to-count))) + (for/hash ([ch (in-list from)] [i (in-naturals)]) + (values ch + (if (< i to-count) + (vector-ref to-vector i) + last-to)))) -(define (delete-characters text chars) - (define delete-set (list->seteq chars)) - (list->string - (for/list ([ch (in-string text)] - #:unless (set-member? delete-set ch)) - ch))) +(define (stream-tr delete-set mapping squeeze-set) + (define previous #f) + (let loop () + (define ch (read-char (current-input-port))) + (unless (eof-object? ch) + (unless (and delete-set (set-member? delete-set ch)) + (let ([translated + (if mapping + (hash-ref mapping ch ch) + ch)]) + (unless (and previous + squeeze-set + (char=? previous translated) + (set-member? squeeze-set translated)) + (write-char translated) + (set! previous translated)))) + (loop)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Translate, delete and/or squeeze characters from current input. +; pre : args contains a supported combination of -d/--delete, +; -s/--squeeze-repeats and the required one or two character sets. +; Character ranges use the simple a-z form supported by this module. +; post : current-input-port has been consumed to end-of-file. +; result : (void); transformed characters are written to current-output-port. +; internals: +; Character sets are expanded once into sets or a translation hash. +; stream-tr then reads and writes one character at a time and keeps +; only the previous emitted character for squeeze processing. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (coreutils-tr . args) (define delete? #f) (define squeeze? #f) - (define sets '()) + (define reversed-sets '()) (for ([arg (in-list args)]) (define text (arg->string arg)) (cond @@ -289,43 +781,62 @@ [(string-prefix? text "-") (raise-arguments-error 'tr "unsupported option" "option" text)] [else - (set! sets (append sets (list text)))])) - (define text (port->string (current-input-port))) + (set! reversed-sets (cons text reversed-sets))])) + (define sets (reverse reversed-sets)) + (cond [(and delete? squeeze?) (unless (= (length sets) 2) (raise-arguments-error 'tr "expected SET1 SET2 with -ds" "arguments" args)) - (define deleted - (delete-characters text (expand-character-set (first sets)))) - (display - (squeeze-characters deleted (expand-character-set (second sets))))] + (define delete-set + (list->seteq (expand-character-set (first sets)))) + (define squeeze-set + (list->seteq (expand-character-set (second sets)))) + (stream-tr delete-set #f squeeze-set)] [delete? (unless (= (length sets) 1) (raise-arguments-error 'tr "expected one character set with -d" "arguments" args)) - (display - (delete-characters text (expand-character-set (first sets))))] + (define delete-set + (list->seteq (expand-character-set (first sets)))) + (stream-tr delete-set #f #f)] [(and squeeze? (= (length sets) 1)) - (display - (squeeze-characters text (expand-character-set (first sets))))] + (define squeeze-set + (list->seteq (expand-character-set (first sets)))) + (stream-tr #f #f squeeze-set)] [else (unless (= (length sets) 2) (raise-arguments-error 'tr "expected SET1 SET2" "arguments" args)) + (define from (expand-character-set (first sets))) (define to (expand-character-set (second sets))) - (define translated - (translate-characters text - (expand-character-set (first sets)) - to)) - (display - (if squeeze? - (squeeze-characters translated to) - translated))])) + (define mapping (make-translation-mapping from to)) + (define squeeze-set (if squeeze? (list->seteq to) #f)) + (stream-tr #f mapping squeeze-set)])) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Write the final path component of one path. +; pre : Exactly one path argument is supplied. +; post : No filesystem state is changed. +; result : (void); the final path component, or an empty string when there is +; none, is written to current-output-port. +; internals: +; file-name-from-path extracts the final component without reading +; the filesystem. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (coreutils-basename . args) (unless (= (length args) 1) (raise-arguments-error 'basename "expected one path" "arguments" args)) (define name (file-name-from-path (arg->path (car args)))) (displayln (if name (path->string name) ""))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Write the directory component of one path. +; pre : Exactly one path argument is supplied. +; post : No filesystem state is changed. +; result : (void); the directory part is written to current-output-port. +; internals: +; split-path is used so platform-specific path syntax is handled by +; Racket; a relative path without a directory is reported as '.'. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (coreutils-dirname . args) (unless (= (length args) 1) (raise-arguments-error 'dirname "expected one path" "arguments" args)) @@ -336,11 +847,29 @@ [(eq? base 'relative) "."] [else (path->rash-string (arg->path (car args)))]))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Write a simplified absolute path with filesystem links resolved. +; pre : Exactly one path argument is supplied and path resolution can be +; performed by the current filesystem. +; post : No filesystem state is changed. +; result : (void); the resolved path is written to current-output-port. +; internals: +; path->complete-path makes the path absolute and simplify-path with +; filesystem resolution enabled resolves path components and links. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (coreutils-realpath . args) (unless (= (length args) 1) (raise-arguments-error 'realpath "expected one path" "arguments" args)) (displayln (path->rash-string (simplify-path (path->complete-path (arg->path (car args))) #t)))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Write the target of one symbolic link. +; pre : Exactly one path argument is supplied and it names a symbolic link. +; post : No filesystem state is changed. +; result : (void); the resolved link target is written to current-output-port. +; internals: +; link-exists? validates the path before resolve-path is used. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (coreutils-readlink . args) (unless (= (length args) 1) (raise-arguments-error 'readlink "expected one path" "arguments" args)) @@ -349,6 +878,18 @@ (raise-arguments-error 'readlink "path is not a symbolic link" "path" path)) (displayln (path->rash-string (resolve-path path)))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Write basic type, size and modification information for paths. +; pre : At least one existing file, directory or symbolic-link path is +; supplied. +; post : No filesystem state is changed. +; result : (void); one information block per path is written to +; current-output-port. +; internals: +; Racket filesystem predicates determine the type; file-size is used +; only for files and file-or-directory-modify-seconds supplies the +; modification timestamp. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (coreutils-stat . args) (when (null? args) (raise-arguments-error 'stat "expected at least one path" "arguments" args)) @@ -365,11 +906,22 @@ (printf "Modified: ~a\n" (file-or-directory-modify-seconds path)))) (define (path-size path) - (cond - [(file-exists? path) (file-size path)] - [(directory-exists? path) - (for/sum ([entry (in-list (directory-list path #:build? #t))]) - (path-size entry))] + (case (file-or-directory-type path #t) + [(file) + (file-size path)] + [(directory) + ;; Do not follow symbolic links. Apart from avoiding link cycles, this + ;; also keeps traversal memory proportional to traversal depth instead + ;; of materializing the complete directory tree. + (fold-files + (λ (entry type total) + (if (eq? type 'file) + (+ total (file-size entry)) + total)) + 0 + path + #f)] + [(link directory-link) 0] [else 0])) (define (human-size n) @@ -379,21 +931,48 @@ [(>= n 1024) (format "~aK" (~r (/ n 1024.0) #:precision '(= 1)))] [else (format "~aB" n)])) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Write the accumulated file size for each requested path. +; pre : args contains only -h/--human-readable and filesystem paths; when +; no paths are supplied, current-directory is used. +; post : No filesystem state is changed and symbolic links are not followed. +; result : (void); one size and path line is written for every requested path. +; internals: +; path-size uses fold-files for directories and adds file sizes while +; explicitly avoiding link traversal. This prevents link cycles and +; avoids materializing a complete directory tree. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (coreutils-du . args) (define human? #f) - (define paths '()) + (define reversed-paths '()) (for ([arg (in-list args)]) (define text (arg->string arg)) (cond - [(or (string=? text "-h") (string=? text "--human-readable")) (set! human? #t)] + [(or (string=? text "-h") (string=? text "--human-readable")) + (set! human? #t)] [(string-prefix? text "-") (raise-arguments-error 'du "unsupported option" "option" text)] - [else (set! paths (append paths (list (arg->path arg))))])) - (when (null? paths) (set! paths (list (current-directory)))) + [else + (set! reversed-paths (cons (arg->path arg) reversed-paths))])) + (define paths + (if (null? reversed-paths) + (list (current-directory)) + (reverse reversed-paths))) (for ([path (in-list paths)]) (define size (path-size path)) - (printf "~a\t~a\n" (if human? (human-size size) size) (path->rash-string path)))) + (printf "~a\t~a\n" + (if human? (human-size size) size) + (path->rash-string path)))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : List filesystem roots visible to Racket. +; pre : No arguments are supplied. +; post : No filesystem state is changed. +; result : (void); a header and the filesystem roots are written to +; current-output-port. +; internals: +; filesystem-root-list supplies the platform-specific root paths. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (coreutils-df . args) (unless (null? args) (raise-arguments-error 'df "this portable implementation does not accept arguments yet" @@ -402,6 +981,16 @@ (for ([root (in-list (filesystem-root-list))]) (displayln (path->rash-string root)))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Create a unique temporary file or directory and write its path. +; pre : args contains only -d/--directory and at most a supported temporary +; filename template. +; post : One new temporary file or directory exists on success. +; result : (void); the created path is written to current-output-port. +; internals: +; make-temporary-file performs unique-name creation atomically using +; Racket's temporary-file facilities. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (coreutils-mktemp . args) (define directory? #f) (define template "tmp~a") @@ -431,6 +1020,17 @@ (when value (printf "~a=~a\n" (environment-name->string name) (environment-value->string value))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Write the complete current environment or selected variable values. +; pre : Each optional argument can be converted to an environment-variable +; name in the current locale. +; post : The current process environment is not modified. Missing selected +; variables produce no output. +; result : (void); environment values are written to current-output-port. +; internals: +; With no names, write-environment sorts and writes all variables; +; otherwise each requested name is looked up directly. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (coreutils-printenv . args) (define env (current-environment-variables)) (if (null? args) @@ -483,6 +1083,23 @@ (raise-arguments-error 'env "command returned a non-zero exit status" "command" (car command)))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Apply temporary environment assignments and optionally execute a +; command with that environment. +; pre : Leading arguments are valid environment assignments; any remaining +; command is either handled by current-coreutils-command-runner or +; names an executable available on PATH. +; post : The caller's current environment is unchanged after the procedure +; returns. The copied environment is used only for output or command +; execution. +; result : (void); without a command the modified environment is written; with +; a command, that command is executed and non-zero external status is +; reported as an error. +; internals: +; Environment assignments are applied to a copy. parameterize scopes +; that copy around command execution, first trying the coreutils +; runner and then an external executable. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (coreutils-env . args) (define-values (assignments command) (split-environment-arguments args)) @@ -503,6 +1120,83 @@ (unless handled? (run-external-environment-command command))))) + +(define (run-external-time-command command) + (define executable + (find-executable-path (arg->string (car command)))) + (unless executable + (raise-arguments-error 'time "command was not found on PATH" + "command" (car command))) + (define ok? + (apply system* executable (map arg->string (cdr command)))) + (unless ok? + (raise-arguments-error 'time "command returned a non-zero exit status" + "command" (car command)))) + +(define (write-time-result real-ms cpu-ms gc-ms) + (define err (current-error-port)) + (fprintf err "real\t~as\n" (~r (/ real-ms 1000.0) #:precision '(= 3))) + (fprintf err "cpu\t~as\n" (~r (/ cpu-ms 1000.0) #:precision '(= 3))) + (fprintf err "gc\t~as\n" (~r (/ gc-ms 1000.0) #:precision '(= 3)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Execute one command and report its elapsed, CPU, and GC time. +; pre : At least one command argument is supplied. The command is either a +; registered rash-coreutils command or an executable available on PATH. +; post : The command has completed or raised an error. Timing information is +; written to current-error-port and command output remains untouched. +; result : (void) when the command succeeds; command lookup and execution errors +; are propagated after timing information has been written. +; internals: +; Monotonic elapsed time is combined with Racket process CPU time and +; CPU time accumulated for completed subprocesses. GC time is the part +; spent by the Racket runtime. This provides portable process timing +; without pretending that separate user/system CPU values are available. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (coreutils-time . command) + (when (null? command) + (raise-arguments-error 'time "expected a command" "arguments" command)) + + (define start-real (current-inexact-monotonic-milliseconds)) + (define start-cpu (current-process-milliseconds)) + (define start-subprocess-cpu (current-process-milliseconds 'subprocesses)) + (define start-gc (current-gc-milliseconds)) + + (define (write-result) + (define real-ms + (- (current-inexact-monotonic-milliseconds) start-real)) + (define cpu-ms + (+ (- (current-process-milliseconds) start-cpu) + (- (current-process-milliseconds 'subprocesses) + start-subprocess-cpu))) + (define gc-ms + (- (current-gc-milliseconds) start-gc)) + (write-time-result real-ms cpu-ms gc-ms)) + + (dynamic-wind + void + (λ () + (define runner (current-coreutils-command-runner)) + (define handled? + (and runner (runner command))) + (unless handled? + (run-external-time-command command))) + write-result) + + (void)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Write the current date and time in the requested representation. +; pre : args contains only -u/--utc, -I/--iso/--iso-8601, --tz ZONE and/or +; --format CLDR-PATTERN with values accepted by gregor. +; post : No external state is changed. +; result : (void); one formatted current date/time line is written to +; current-output-port. +; internals: +; gregor creates the current moment in UTC, a requested timezone or +; the local timezone; output is then ISO-8601, CLDR formatted or the +; module's default display format. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (coreutils-date . args) (define utc? #f) (define iso? #f) diff --git a/scrbl/rash-coreutils.scrbl b/scrbl/rash-coreutils.scrbl index 920fedc..30989a3 100644 --- a/scrbl/rash-coreutils.scrbl +++ b/scrbl/rash-coreutils.scrbl @@ -1,6 +1,7 @@ #lang scribble/manual @(require (for-label racket/base + (only-in rackedit rkdt) (only-in rash-coreutils coreutils-pwd coreutils-ls coreutils-mkdir coreutils-rmdir coreutils-rm coreutils-cp coreutils-mv coreutils-cat @@ -10,140 +11,506 @@ coreutils-dirname coreutils-realpath coreutils-readlink coreutils-stat coreutils-du coreutils-df coreutils-mktemp coreutils-printenv coreutils-env coreutils-date - raco - coreutils-raco))) + coreutils-time coreutils-edit current-coreutils-editor + coreutils-help dispatch-coreutils-command + raco coreutils-raco))) @title{rash-coreutils} @author["Hans Dijkema / hans@dijkewijk.nl"] @defmodule[rash-coreutils] -@bold{rash-coreutils} provides platform-independent Unix-style shell commands -for Rash. The commands are implemented in Racket, so common shell utilities do -not depend on @tt{cmd.exe}, PowerShell, or Unix executables being installed. +@bold{rash-coreutils} provides platform-independent Unix-style commands for +Rash. The user-facing API is the set of commands that can be written directly +in Rash line mode. Most commands are implemented by ordinary Racket procedures, +so they do not depend on @tt{cmd.exe}, PowerShell, or Unix utility executables. Rash remains responsible for shell syntax such as pipelines, globbing, tilde -expansion, variable expansion, and redirection. @bold{rash-coreutils} supplies -the commands themselves plus Racket-specific additions such as regular-expression -path selectors. +expansion, variable expansion, and redirection. The command aliases provided by +this package preserve those Rash semantics and then dispatch to the internal +Racket implementation. -@section{Commands} +@section{How a Rash command is executed} -The package provides @tt{pwd}, @tt{ls}, @tt{mkdir}, @tt{rmdir}, @tt{rm}, -@tt{cp}, @tt{mv}, @tt{cat}, @tt{touch}, @tt{echo}, @tt{which}, @tt{head}, -@tt{tail}, @tt{wc}, @tt{sort}, @tt{uniq}, @tt{cut}, @tt{tee}, @tt{tr}, -@tt{basename}, @tt{dirname}, @tt{realpath}, @tt{readlink}, @tt{stat}, @tt{du}, -@tt{df}, @tt{mktemp}, @tt{printenv}, @tt{env}, @tt{date}, @tt{raco}, and @tt{help}. +For a normal command such as + +@verbatim{ +ls -l *.rkt +} + +Rash first processes its line syntax and pipeline semantics. The @tt{ls} alias +then calls @racket[dispatch-coreutils-command], which looks up the registered +command and invokes @racket[coreutils-ls]. Arguments that are Racket regular +expressions are expanded by the rash-coreutils dispatcher before the internal +procedure is called. + +This means the shell command is the primary interface, while procedures such as +@racket[coreutils-ls] are the implementation API and are also available to +ordinary Racket code. The option set is deliberately smaller than GNU coreutils. Unsupported options -raise an error instead of silently approximating another command's behavior. +raise an error instead of silently approximating behavior that is not +implemented. -@section{Racket procedures} - -Each shell command has an explicitly named Racket procedure. All procedures -write to the normal current ports so Rash pipeline and redirection semantics -continue to work. +@section{Filesystem commands} @index["rash-coreutils-pwd"] -@defproc[(coreutils-pwd) void?]{Writes the current directory.} +@subsection{@tt{pwd}} + +@verbatim{ +pwd +} + +Writes the current directory. The command accepts no arguments. + +Internally, @tt{pwd} dispatches to @racket[coreutils-pwd]. + +@defproc[(coreutils-pwd) void?]{Writes @racket[current-directory] using a Rash-friendly textual path.} @index["rash-coreutils-ls"] -@defproc[(coreutils-ls [arg any/c] ...) void?]{Lists paths. Supports @tt{-a}, @tt{-l}, and their long forms.} +@subsection{@tt{ls}} + +@verbatim{ +ls +ls -l +ls -a +ls -la +ls *.rkt +ls #px"^info[0-9]+[.]rkt$" +} + +Lists files and directories. @tt{-a}/@tt{--all} includes hidden names and +@tt{-l}/@tt{--long} writes the portable long form used by this package. Rash +performs normal glob expansion. A Racket regular-expression value is an +additional rash-coreutils path selector and is matched against names in the +current directory. + +Internally, @tt{ls} dispatches to @racket[coreutils-ls]. + +@defproc[(coreutils-ls [arg any/c] ...) void?]{Implements the listing after Rash and dispatcher argument handling.} @index["rash-coreutils-mkdir"] -@defproc[(coreutils-mkdir [arg any/c] ...) void?]{Creates directories. Supports @tt{-p}.} +@subsection{@tt{mkdir}} + +@verbatim{ +mkdir work +mkdir -p build/output +} + +Creates one or more directories. @tt{-p}/@tt{--parents} creates missing parent +directories. + +Internally, @tt{mkdir} dispatches to @racket[coreutils-mkdir]. + +@defproc[(coreutils-mkdir [arg any/c] ...) void?]{Creates the requested directories.} @index["rash-coreutils-rmdir"] +@subsection{@tt{rmdir}} + +@verbatim{ +rmdir empty-directory +} + +Removes one or more empty directories. No options are currently supported. + +Internally, @tt{rmdir} dispatches to @racket[coreutils-rmdir]. + @defproc[(coreutils-rmdir [arg any/c] ...) void?]{Removes empty directories.} @index["rash-coreutils-rm"] -@defproc[(coreutils-rm [arg any/c] ...) void?]{Removes paths. Supports recursive and force options.} +@subsection{@tt{rm}} + +@verbatim{ +rm file.txt +rm -r directory +rm -rf generated +} + +Removes files or links. Directories require @tt{-r}, @tt{-R}, or +@tt{--recursive}. @tt{-f}/@tt{--force} suppresses an error for a missing path. +The combined forms @tt{-rf}, @tt{-fr}, @tt{-Rf}, and @tt{-fR} are supported. + +Internally, @tt{rm} dispatches to @racket[coreutils-rm]. + +@defproc[(coreutils-rm [arg any/c] ...) void?]{Removes the requested paths.} @index["rash-coreutils-cp"] -@defproc[(coreutils-cp [arg any/c] ...) void?]{Copies a source to a destination. Directory copies require @tt{-r}.} +@subsection{@tt{cp}} + +@verbatim{ +cp source.txt copy.txt +cp -r source-directory copy-directory +} + +Copies exactly one source to one destination. Directory copies require +@tt{-r}, @tt{-R}, or @tt{--recursive}. + +Internally, @tt{cp} dispatches to @racket[coreutils-cp]. + +@defproc[(coreutils-cp [arg any/c] ...) void?]{Copies a file or, in recursive mode, a directory tree.} @index["rash-coreutils-mv"] +@subsection{@tt{mv}} + +@verbatim{ +mv old-name.txt new-name.txt +} + +Moves or renames exactly one source path to one destination path. + +Internally, @tt{mv} dispatches to @racket[coreutils-mv]. + @defproc[(coreutils-mv [source any/c] [destination any/c]) void?]{Moves or renames a path.} -@index["rash-coreutils-cat"] -@defproc[(coreutils-cat [arg any/c] ...) void?]{Copies files, or standard input, to standard output.} - @index["rash-coreutils-touch"] -@defproc[(coreutils-touch [arg any/c] ...) void?]{Updates modification times or creates empty files.} +@subsection{@tt{touch}} -@index["rash-coreutils-echo"] -@defproc[(coreutils-echo [arg any/c] ...) void?]{Writes arguments separated by spaces.} +@verbatim{ +touch notes.txt +} -@index["rash-coreutils-which"] -@defproc[(coreutils-which [arg any/c] ...) void?]{Finds executables using Racket's executable search.} +Creates missing files and updates the modification time of existing files. No +options are currently supported. -@index["rash-coreutils-head"] -@defproc[(coreutils-head [arg any/c] ...) void?]{Writes the first lines of input. Supports @tt{-n}.} +Internally, @tt{touch} dispatches to @racket[coreutils-touch]. -@index["rash-coreutils-tail"] -@defproc[(coreutils-tail [arg any/c] ...) void?]{Writes the last lines of input. Supports @tt{-n}.} - -@index["rash-coreutils-wc"] -@defproc[(coreutils-wc [arg any/c] ...) void?]{Counts lines, words, and bytes. Supports @tt{-l}, @tt{-w}, and @tt{-c}.} - -@index["rash-coreutils-sort"] -@defproc[(coreutils-sort [arg any/c] ...) void?]{Sorts lines. Supports @tt{-r} and @tt{-n}.} - -@index["rash-coreutils-uniq"] -@defproc[(coreutils-uniq [arg any/c] ...) void?]{Collapses adjacent duplicate lines. Supports @tt{-c}.} - -@index["rash-coreutils-cut"] -@defproc[(coreutils-cut [arg any/c] ...) void?]{Selects one delimited field with @tt{-d} and @tt{-f}.} - -@index["rash-coreutils-tee"] -@defproc[(coreutils-tee [arg any/c] ...) void?]{Copies standard input to standard output and files. Supports @tt{-a}.} - -@index["rash-coreutils-tr"] -@defproc[(coreutils-tr [arg any/c] ...) void?]{Translates characters from standard input. Character ranges such as @tt{a-z}, @tt{A-Z}, and @tt{0-9} are expanded. @tt{-d} deletes characters and @tt{-s} squeezes repeated characters.} +@defproc[(coreutils-touch [arg any/c] ...) void?]{Touches one or more files.} @index["rash-coreutils-basename"] +@subsection{@tt{basename}} + +@verbatim{ +basename a/b/file.txt +} + +Writes the final component of one path. + +Internally, @tt{basename} dispatches to @racket[coreutils-basename]. + @defproc[(coreutils-basename [path any/c]) void?]{Writes the final path component.} @index["rash-coreutils-dirname"] +@subsection{@tt{dirname}} + +@verbatim{ +dirname a/b/file.txt +} + +Writes the directory portion of one path. + +Internally, @tt{dirname} dispatches to @racket[coreutils-dirname]. + @defproc[(coreutils-dirname [path any/c]) void?]{Writes the directory portion of a path.} @index["rash-coreutils-realpath"] -@defproc[(coreutils-realpath [path any/c]) void?]{Writes a complete simplified path.} +@subsection{@tt{realpath}} + +@verbatim{ +realpath relative/path +} + +Writes a complete simplified path with filesystem links resolved where the +current filesystem supports that resolution. + +Internally, @tt{realpath} dispatches to @racket[coreutils-realpath]. + +@defproc[(coreutils-realpath [path any/c]) void?]{Writes the complete simplified path.} @index["rash-coreutils-readlink"] -@defproc[(coreutils-readlink [path any/c]) void?]{Resolves a symbolic link.} +@subsection{@tt{readlink}} + +@verbatim{ +readlink link-name +} + +Writes the target of one symbolic link and reports an error when the supplied +path is not a link. + +Internally, @tt{readlink} dispatches to @racket[coreutils-readlink]. + +@defproc[(coreutils-readlink [path any/c]) void?]{Writes the resolved symbolic-link target.} @index["rash-coreutils-stat"] +@subsection{@tt{stat}} + +@verbatim{ +stat file.txt +stat file.txt directory +} + +Writes portable type, size, and modification information for one or more paths. + +Internally, @tt{stat} dispatches to @racket[coreutils-stat]. + @defproc[(coreutils-stat [path any/c] ...) void?]{Writes basic portable path metadata.} @index["rash-coreutils-du"] -@defproc[(coreutils-du [arg any/c] ...) void?]{Calculates recursive file size. Supports @tt{-h}.} - -@index["rash-coreutils-df"] -@defproc[(coreutils-df) void?]{Lists filesystem roots. Racket has no portable API for total and free filesystem capacity, so this initial implementation deliberately does not invent platform-specific subprocess fallbacks.} - -@index["rash-coreutils-mktemp"] -@defproc[(coreutils-mktemp [arg any/c] ...) void?]{Creates a temporary file, or a directory with @tt{-d}, and writes its path.} - -@index["rash-coreutils-printenv"] -@defproc[(coreutils-printenv [name any/c] ...) void?]{Writes environment variables.} - -@index["rash-coreutils-env"] -@defproc[(coreutils-env [arg any/c] ...) void?]{Creates a copied environment, applies leading @tt{NAME=value} assignments, and either writes that environment or runs the remaining command in it. Racket values are converted to environment strings.} - -The Rash form can mix shell-style assignment names with Racket expressions: +@subsection{@tt{du}} @verbatim{ +du +du -h directory +} + +Writes the accumulated file size of each requested path. With no path, the +current directory is used. @tt{-h}/@tt{--human-readable} formats the size using +binary K, M, and G units. Symbolic links are not followed. + +Internally, @tt{du} dispatches to @racket[coreutils-du]. + +@defproc[(coreutils-du [arg any/c] ...) void?]{Calculates recursive file size.} + +@index["rash-coreutils-df"] +@subsection{@tt{df}} + +@verbatim{ +df +} + +Lists filesystem roots visible to Racket. This portable implementation does not +invent platform-specific subprocess fallbacks for total and free capacity. + +Internally, @tt{df} dispatches to @racket[coreutils-df]. + +@defproc[(coreutils-df) void?]{Lists filesystem roots.} + +@index["rash-coreutils-mktemp"] +@subsection{@tt{mktemp}} + +@verbatim{ +mktemp +mktemp -d +mktemp "rash-coreutils-~a" +} + +Creates a unique temporary file and writes its path. @tt{-d}/@tt{--directory} +creates a directory instead. An optional non-option argument is used as the +Racket temporary-file template. + +Internally, @tt{mktemp} dispatches to @racket[coreutils-mktemp]. + +@defproc[(coreutils-mktemp [arg any/c] ...) void?]{Creates a temporary file or directory and writes its path.} + +@section{Text and pipeline commands} + +@index["rash-coreutils-cat"] +@subsection{@tt{cat}} + +@verbatim{ +cat file.txt +cat first.txt second.txt +producer | cat +} + +Copies files to standard output. With no file arguments, it copies standard +input. This makes the implementation usable in Rash pipelines without special +pipeline code. + +Internally, @tt{cat} dispatches to @racket[coreutils-cat]. + +@defproc[(coreutils-cat [arg any/c] ...) void?]{Copies file or standard-input bytes to the current output port.} + +@index["rash-coreutils-echo"] +@subsection{@tt{echo}} + +@verbatim{ +echo hello world +} + +Writes its arguments separated by spaces and terminates the result with a +newline. + +Internally, @tt{echo} dispatches to @racket[coreutils-echo]. + +@defproc[(coreutils-echo [arg any/c] ...) void?]{Writes the textual representation of the arguments.} + +@index["rash-coreutils-head"] +@subsection{@tt{head}} + +@verbatim{ +head file.txt +head -n 20 file.txt +} + +Writes the first lines of files or standard input. @tt{-n}/@tt{--lines} +selects a non-negative line count; the default is 10. Input is scanned in fixed +size byte blocks so memory use does not grow with file size or an individual +line length. + +Internally, @tt{head} dispatches to @racket[coreutils-head]. + +@defproc[(coreutils-head [arg any/c] ...) void?]{Writes the requested first lines.} + +@index["rash-coreutils-tail"] +@subsection{@tt{tail}} + +@verbatim{ +tail file.txt +tail -n 20 file.txt +} + +Writes the last lines of files or standard input. @tt{-n}/@tt{--lines} selects +a non-negative line count; the default is 10. Seekable files are scanned +backwards in fixed-size byte blocks. Input that cannot be scanned backwards is +spooled to a temporary file, keeping memory use independent of input size and +line length. + +Internally, @tt{tail} dispatches to @racket[coreutils-tail]. + +@defproc[(coreutils-tail [arg any/c] ...) void?]{Writes the requested last lines.} + +@index["rash-coreutils-wc"] +@subsection{@tt{wc}} + +@verbatim{ +wc file.txt +wc -l file.txt +wc -w file.txt +wc -c file.txt +} + +Counts lines, words, and bytes. @tt{-l} selects lines, @tt{-w} words, and +@tt{-c}/@tt{--bytes} bytes. With no count options all three values are written. + +Internally, @tt{wc} dispatches to @racket[coreutils-wc]. + +@defproc[(coreutils-wc [arg any/c] ...) void?]{Counts input using bounded streaming buffers.} + +@index["rash-coreutils-sort"] +@subsection{@tt{sort}} + +@verbatim{ +sort file.txt +sort -r file.txt +sort -n numbers.txt +} + +Sorts input lines. @tt{-r}/@tt{--reverse} reverses the ordering and +@tt{-n}/@tt{--numeric-sort} uses numeric keys. Large input is divided into +bounded in-memory runs and merged through temporary files. + +Internally, @tt{sort} dispatches to @racket[coreutils-sort]. + +@defproc[(coreutils-sort [arg any/c] ...) void?]{Sorts lines without retaining the complete input in memory.} + +@index["rash-coreutils-uniq"] +@subsection{@tt{uniq}} + +@verbatim{ +uniq sorted.txt +uniq -c sorted.txt +} + +Collapses adjacent duplicate lines. @tt{-c}/@tt{--count} prefixes each output +line with its run count. As with the Unix command, input should already be +grouped when non-adjacent equal lines must also be collapsed. + +Internally, @tt{uniq} dispatches to @racket[coreutils-uniq]. + +@defproc[(coreutils-uniq [arg any/c] ...) void?]{Collapses adjacent duplicate lines.} + +@index["rash-coreutils-cut"] +@subsection{@tt{cut}} + +@verbatim{ +cut -f 2 data.txt +cut -d ";" -f 3 data.txt +} + +Writes one delimited field from each input line. @tt{-f}/@tt{--fields} is +required and currently accepts one positive field number. @tt{-d}/@tt{--delimiter} +changes the delimiter from the default tab. + +Internally, @tt{cut} dispatches to @racket[coreutils-cut]. + +@defproc[(coreutils-cut [arg any/c] ...) void?]{Selects one field from each input line.} + +@index["rash-coreutils-tee"] +@subsection{@tt{tee}} + +@verbatim{ +producer | tee output.txt +producer | tee -a output.txt +} + +Copies standard input to standard output and to each named file. +@tt{-a}/@tt{--append} appends instead of replacing existing file content. + +Internally, @tt{tee} dispatches to @racket[coreutils-tee]. + +@defproc[(coreutils-tee [arg any/c] ...) void?]{Copies the current input stream to the current output and files.} + +@index["rash-coreutils-tr"] +@subsection{@tt{tr}} + +@verbatim{ +echo abc | tr a-z A-Z +echo a1b2c3 | tr -d 0-9 +echo "a b" | tr -s " " +} + +Translates characters from standard input. Simple ranges such as @tt{a-z}, +@tt{A-Z}, and @tt{0-9} are expanded. @tt{-d}/@tt{--delete} deletes selected +characters and @tt{-s}/@tt{--squeeze-repeats} squeezes repeated selected +characters. @tt{-ds} and @tt{-sd} are also accepted. + +Internally, @tt{tr} dispatches to @racket[coreutils-tr]. + +@defproc[(coreutils-tr [arg any/c] ...) void?]{Translates, deletes, or squeezes characters from the current input port.} + +@section{Environment, time, and executable commands} + +@index["rash-coreutils-which"] +@subsection{@tt{which}} + +@verbatim{ +which git +which racket +} + +Finds commands using Racket's executable search and writes the resolved path. + +Internally, @tt{which} dispatches to @racket[coreutils-which]. + +@defproc[(coreutils-which [arg any/c] ...) void?]{Finds one or more executables on @tt{PATH}.} + +@index["rash-coreutils-printenv"] +@subsection{@tt{printenv}} + +@verbatim{ +printenv +printenv PATH +} + +Writes the complete current environment or the values of selected variables. +A missing selected variable produces no output. + +Internally, @tt{printenv} dispatches to @racket[coreutils-printenv]. + +@defproc[(coreutils-printenv [name any/c] ...) void?]{Writes environment-variable values from @racket[current-environment-variables].} + +@index["rash-coreutils-env"] +@subsection{@tt{env}} + +@verbatim{ +env NAME=value printenv NAME (define x 42) env ANSWER=(values x) printenv ANSWER } -A parenthesized expression in Rash line mode is ordinary Racket code. Therefore -the value after an empty @tt{NAME=} token can be a number, symbol, path, string, -or another printable Racket value. +Creates a copy of the current environment, applies leading @tt{NAME=value} +assignments, and either writes the resulting environment or runs the remaining +command in it. A parenthesized expression in Rash line mode is ordinary Racket +code, so the value following an empty @tt{NAME=} token can be a Racket value. +Registered rash-coreutils commands are tried before an executable on @tt{PATH}. + +Internally, the @tt{env} Rash alias first preserves Racket expressions in +assignment values and then dispatches to @racket[coreutils-env]. + +@defproc[(coreutils-env [arg any/c] ...) void?]{Applies temporary environment assignments and optionally runs a command.} @index["rash-coreutils-date"] -@defproc[(coreutils-date [arg any/c] ...) void?]{Writes the current date and time using Gregor. Supports @tt{-u}/@tt{--utc}, @tt{-I}/@tt{--iso}, @tt{--tz ZONE}, and @tt{--format CLDR-PATTERN}. The format pattern follows Gregor's CLDR syntax rather than pretending to implement every GNU @tt{date} format escape.} +@subsection{@tt{date}} @verbatim{ date @@ -152,47 +519,90 @@ date --utc --iso date --tz Europe/Amsterdam --format "yyyy-MM-dd HH:mm:ss" } -@index["rash-coreutils-raco"] -@defproc[(raco [arg any/c] ...) void?]{Runs @tt{raco} from the current Racket installation. The executable is resolved from the active Racket installation before PATH is considered, so it also works on Windows when @tt{raco.exe} is not on PATH. Arguments may be strings, symbols, paths, numbers, or nested lists. With no arguments, the underlying @tt{raco} program is invoked without a subcommand.} +Writes the current date and time using Gregor. @tt{-u}/@tt{--utc} selects UTC, +@tt{-I}/@tt{--iso}/@tt{--iso-8601} selects ISO output, @tt{--tz ZONE} selects a +time zone, and @tt{--format CLDR-PATTERN} uses Gregor's CLDR formatting syntax. -@defproc[(coreutils-raco [arg any/c] ...) void?]{Explicit implementation name for @racket[raco].} +Internally, @tt{date} dispatches to @racket[coreutils-date]. + +@defproc[(coreutils-date [arg any/c] ...) void?]{Writes the current date and time in the requested representation.} + +@index["rash-coreutils-time"] +@subsection{@tt{time}} + +@verbatim{ +time head -n 100 large.txt +time raco test tests/coreutils.rkt +} + +Executes one registered rash-coreutils command or an executable on @tt{PATH} and +writes timing data to standard error. The portable measurements are elapsed +@tt{real} time, combined @tt{cpu} time for Racket and completed subprocesses, +and Racket @tt{gc} time. Standard output from the timed command remains usable +in pipelines and redirections. + +Internally, @tt{time} dispatches to @racket[coreutils-time]. + +@defproc[(coreutils-time [command any/c] [arg any/c] ...) void?]{Runs and measures a command.} + +@index["rash-coreutils-raco"] +@subsection{@tt{raco}} @verbatim{ raco setup rash-coreutils raco pkg show } -The same @racket[raco] binding is a normal Racket procedure, so expression-mode code can use @racket[(raco '(setup rash-coreutils))]. The explicit implementation name @racket[coreutils-raco] is also exported. +Runs @tt{raco} from the active Racket installation. The executable is resolved +from that installation before @tt{PATH} is considered, which avoids accidentally +using @tt{raco} from another Racket installation and also works on Windows when +@tt{raco.exe} is not on @tt{PATH}. -@section{Path selection} +The Rash command calls @racket[coreutils-raco]. The exported @racket[raco] +binding is the Rash-facing command binding; ordinary Racket code can call +@racket[coreutils-raco] explicitly. -Shell-style argument expansion remains Rash's responsibility. The aliases expand -back into Rash's @tt{=unix-pipe=} operator, preserving Rash globbing, tilde -expansion, and @tt{$} expansion. +@defproc[(coreutils-raco [arg any/c] ...) void?]{Runs the active installation's @tt{raco} executable.} + +@section{Editor command} + +@index["rash-coreutils-edit"] +@subsection{@tt{edit}} @verbatim{ -ls *.rkt -ls -l info* -cat info* +edit notes.rkt +edit --wait notes.rkt } -Regular-expression values are an additional @bold{rash-coreutils} selector. A -@racket[#rx""] or @racket[#px""] value is matched against entry names in the -current directory. +Opens exactly one file in the editor configured for rash-coreutils. By default, +the editor is RackEdit's @racket[rkdt] procedure. Without @tt{--wait}, RackEdit +opens the editor and the Rash command can continue. With @tt{--wait}, the flag +is translated to @racket[#:wait?] @racket[#t], so the command returns only after +the editor procedure returns. + +Internally, @tt{edit} dispatches to @racket[coreutils-edit]. The implementation +calls the procedure stored in @racket[current-coreutils-editor] with the file +path and a @racket[#:wait?] keyword. + +@defproc[(coreutils-edit [arg any/c] ...) void?]{Parses @tt{--wait}, validates that exactly one file was supplied, and invokes @racket[current-coreutils-editor].} + +@defthing[current-coreutils-editor parameter?]{ +A parameter whose default value is @racket[rkdt]. The configured procedure must +accept one file path and the optional keyword @racket[#:wait?]. A wrapper can be +used to select a different editor for a dynamic extent. +} @verbatim{ -ls #px"^info[0-9]+[.]rkt$" +(parameterize + ([current-coreutils-editor + (λ (filename #:wait? [wait? #f]) + ...)]) + ...) } +@section{Help command} -@section{Windows paths} - -On Windows, commands that write path names use forward slashes in their textual output. Rash treats backslashes as escape characters in line mode, so a printed path such as @tt{C:/Users/name/AppData/Local/Temp/file} can be copied directly into another Rash command. Racket path values themselves remain native and can be passed directly to a command through a Racket expression. - -@section{Help} - -@tt{help} uses Racket's documentation search through @tt{raco docs}. Registered -commands use package-specific index terms such as @tt{rash-coreutils-ls}. +@subsection{@tt{help}} @verbatim{ help @@ -201,10 +611,49 @@ ls --help help directory-list } +With no argument, @tt{help} writes the registered rash-coreutils command names. +For a registered command, it searches Racket documentation using the command's +package-specific index term, for example @tt{rash-coreutils-ls}. Other terms are +passed directly to Racket's documentation search. Supplying @tt{--help} to a +registered command uses the same mechanism. + +The @tt{help} alias calls @racket[coreutils-help] directly. The normal command +dispatcher intercepts @tt{--help} before invoking the command implementation. + +@defproc[(coreutils-help [arg any/c] ...) void?]{Lists commands or opens Racket documentation for one search term.} + +@section{Path selection and Rash expansion} + +Normal shell-style argument expansion remains Rash's responsibility. The aliases +expand back into Rash's pipeline machinery, preserving globbing, tilde expansion, +variable expansion, pipelines, and redirection. + +@verbatim{ +ls *.rkt +ls -l info* +cat info* | wc -l +} + +Racket regular-expression values are an additional rash-coreutils selector. A +@racket[#rx""] or @racket[#px""] value is matched against entry names in the +current directory before the internal command procedure is called. + +@verbatim{ +ls #px"^info[0-9]+[.]rkt$" +} + +@section{Windows paths} + +On Windows, commands that write path names use forward slashes in their textual +output. Rash treats backslashes as escape characters in line mode, so a printed +path such as @tt{C:/Users/name/AppData/Local/Temp/file} can be copied directly +into another Rash command. Racket path values themselves remain native and can +be passed directly to a command through a Racket expression. + @section{Capturing and redirecting output} -Commands use normal ports. Rash can therefore capture or redirect them without -special support in this package. +The internal Racket procedures use normal current ports. Rash can therefore +capture, pipe, or redirect command output without command-specific support. @verbatim{ (define git-path { which git |> read-line }) @@ -216,5 +665,5 @@ cat info.rkt &>! copy.rkt The command names and common options follow Unix conventions, but this package does not claim complete GNU coreutils compatibility. Platform-independent -behavior, predictable Rash scripting, and useful integration with Racket values -are the primary goals. +behavior, predictable Rash scripting, and integration with Racket values are +the primary goals. diff --git a/tests/coreutils.rkt b/tests/coreutils.rkt index 8e791d9..f0f3210 100644 --- a/tests/coreutils.rkt +++ b/tests/coreutils.rkt @@ -9,6 +9,7 @@ "../private/dispatcher.rkt" "../private/help.rkt" "../private/racket-tools.rkt" + "../private/editor.rkt" "../private/env-support.rkt") (define (capture-output procedure . args) @@ -95,8 +96,36 @@ (check-equal? (coreutils-help-search-term 'directory-list) "directory-list") (check-equal? (coreutils-help-search-term 'raco) "rash-coreutils-raco") + (check-equal? (coreutils-help-search-term 'edit) "rash-coreutils-edit") (check-true (path? (find-raco-executable))) (check-true (procedure? coreutils-raco)) + (check-true (procedure? (current-coreutils-editor))) + + (define edit-call #f) + (parameterize + ([current-coreutils-editor + (λ (filename #:wait? [wait? #f]) + (set! edit-call (list filename wait?)))]) + (dispatch-coreutils-command 'edit '--wait "one.txt")) + (check-equal? (path->string (first edit-call)) "one.txt") + (check-true (second edit-call)) + + (set! edit-call #f) + (parameterize + ([current-coreutils-editor + (λ (filename #:wait? [wait? #f]) + (set! edit-call (list filename wait?)))]) + (dispatch-coreutils-command 'edit "one.txt")) + (check-equal? (path->string (first edit-call)) "one.txt") + (check-false (second edit-call)) + + (check-exn + exn:fail? + (λ () + (parameterize + ([current-coreutils-editor + (λ (filename #:wait? [wait? #f]) (void))]) + (dispatch-coreutils-command 'edit "one.txt" "two.txt")))) (call-with-output-file "lines.txt" #:exists 'truncate @@ -106,6 +135,27 @@ "one\ntwo\n") (check-equal? (capture-output coreutils-tail "-n" "1" "lines.txt") "three\n") + + ;; A long physical line must not force head or tail to materialize that + ;; line in memory. The 64 KiB internal block size is crossed many times. + (define long-lines-path (build-path test-root "long-lines.txt")) + (define long-chunk (make-bytes (* 64 1024) 120)) + (call-with-output-file long-lines-path + #:exists 'truncate + (λ (out) + (display "first\n" out) + (for ([i (in-range 64)]) + (write-bytes long-chunk out)) + (display "\nlast-one\nlast-two\n" out))) + (check-equal? (capture-output coreutils-head "-n" "1" long-lines-path) + "first\n") + (check-equal? (capture-output coreutils-tail "-n" "2" long-lines-path) + "last-one\nlast-two\n") + (call-with-input-file long-lines-path + (λ (in) + (parameterize ([current-input-port in]) + (check-equal? (capture-output coreutils-tail "-n" "2") + "last-one\nlast-two\n")))) (check-equal? (capture-output coreutils-wc "-l" "lines.txt") "3\n") (check-equal? (capture-output coreutils-basename "a/b/c.txt") @@ -146,6 +196,23 @@ (check-true (regexp-match? #px"[0-9]{4}-[0-9]{2}-[0-9]{2}T" (capture-output coreutils-date "--iso"))) + + ;; time writes measurements to stderr without contaminating command output. + (define time-error (open-output-string)) + (define timed-output + (parameterize ([current-error-port time-error]) + (capture-output + dispatch-coreutils-command + 'time + 'echo + 'timed))) + (check-equal? timed-output "timed\n") + (define time-output (get-output-string time-error)) + (check-true + (regexp-match? + #px"^real\\t[0-9]+[.][0-9]{3}s\ncpu\\t[0-9]+[.][0-9]{3}s\ngc\\t[0-9]+[.][0-9]{3}s\n$" + time-output)) + (check-exn exn:fail? (λ () diff --git a/tests/rash-smoke.rkt b/tests/rash-smoke.rkt index 861b5aa..69ddf6b 100644 --- a/tests/rash-smoke.rkt +++ b/tests/rash-smoke.rkt @@ -2,6 +2,7 @@ (require rackunit racket/file + racket/path racket/port rash-coreutils) @@ -11,13 +12,17 @@ (open-output-string)) (define errors (open-output-string)) +(define edited #f) (dynamic-wind void (λ () (parameterize ([current-directory test-root] [current-output-port output] - [current-error-port errors]) + [current-error-port errors] + [current-coreutils-editor + (λ (filename #:wait? [wait? #f]) + (set! edited (list filename wait?)))]) { (call-with-output-file "info.rkt" #:exists 'truncate @@ -30,21 +35,30 @@ ls -l rash-coreutils-smoke ls *.rkt ls #px"^info[.]rkt$" + edit --wait info.rkt (define x 42) env RASH_COREUTILS_VALUE=(values x) printenv RASH_COREUTILS_VALUE echo one two three | wc -w + time echo timed date --iso rm -r rash-coreutils-smoke (check-false (directory-exists? "rash-coreutils-smoke")) }) - (check-equal? (get-output-string errors) "") + (define error-text (get-output-string errors)) + (check-true + (regexp-match? + #px"^real\\t[0-9]+[.][0-9]{3}s\ncpu\\t[0-9]+[.][0-9]{3}s\ngc\\t[0-9]+[.][0-9]{3}s\n$" + error-text)) (define text (get-output-string output)) (check-true (regexp-match? #rx"hello from rash-coreutils" text)) (check-true (regexp-match? #rx"info[.]rkt" text)) (check-true (regexp-match? #rx"42" text)) + (check-true (regexp-match? #rx"timed" text)) + (check-equal? (path->string (first edited)) "info.rkt") + (check-true (second edited)) (check-true (regexp-match? #rx"\n3\n" text)) (check-true (regexp-match? #px"[0-9]{4}-[0-9]{2}-[0-9]{2}T" text))) (λ ()