diff --git a/rhombus-lib/rhombus/private/amalgam/core-derived.rkt b/rhombus-lib/rhombus/private/amalgam/core-derived.rkt index aac1421ee..572f755a9 100644 --- a/rhombus-lib/rhombus/private/amalgam/core-derived.rkt +++ b/rhombus-lib/rhombus/private/amalgam/core-derived.rkt @@ -6,6 +6,7 @@ "check.rhm" "maybe.rhm" "string.rhm" + "path-object.rhm" "when_unless.rhm" "where.rhm" "described_as.rhm" diff --git a/rhombus-lib/rhombus/private/amalgam/path-object.rhm b/rhombus-lib/rhombus/private/amalgam/path-object.rhm new file mode 100644 index 000000000..b503caa08 --- /dev/null +++ b/rhombus-lib/rhombus/private/amalgam/path-object.rhm @@ -0,0 +1,33 @@ +#lang rhombus/private/amalgam/core + +import: + "core-meta.rkt" open + +use_static + +export: + PathString + +namespace PathString: + export: + to_path + to_complete_path + + fun to_path(p :: PathString) :~ Path: + Path(p) + + fun + | to_complete_path(p :: PathString): + Path.to_complete_path(p) + | to_complete_path(p :: PathString, ~relative_to: base_path :: PathString): + Path.to_complete_path(p, ~relative_to: base_path) + + annot.macro 'to_path': + 'converting(fun (p :: PathString) :~ Path: to_path(p))' + + annot.macro + | 'to_complete_path(~relative_to: $base_path)': + 'converting(fun (p :: PathString) :~ Path: + to_complete_path(p, ~relative_to: $base_path))' + | 'to_complete_path': + 'converting(fun (p :: PathString) :~ Path: to_complete_path(p))' diff --git a/rhombus-lib/rhombus/private/amalgam/path-object.rkt b/rhombus-lib/rhombus/private/amalgam/path-object.rkt index 6bd480552..ba4241336 100644 --- a/rhombus-lib/rhombus/private/amalgam/path-object.rkt +++ b/rhombus-lib/rhombus/private/amalgam/path-object.rkt @@ -5,15 +5,23 @@ "realm.rkt" "call-result-key.rkt" "define-arity.rkt" + "append-key.rkt" "compare-key.rkt" - (submod "string.rkt" static-infos) - (submod "bytes.rkt" static-infos)) + "index-result-key.rkt" + "static-info.rkt" + (submod "annotation.rkt" for-class) + (submod "bytes.rkt" static-infos) + (submod "function.rkt" for-info) + (submod "list.rkt" for-listable) + (submod "string.rkt" static-infos)) (provide (for-spaces (rhombus/namespace #f rhombus/bind rhombus/annot) - Path)) + Path) + (for-space rhombus/annot + PathString)) (module+ for-builtin (provide path-method-table)) @@ -36,7 +44,8 @@ (define-primitive-class Path path #:lift-declaration #:no-constructor-static-info - #:instance-static-info ((#%compare ((< path path>?) (>= path>=?) @@ -46,12 +55,19 @@ #:translucent #:fields ([bytes Path.bytes #,(get-bytes-static-infos)]) + #:namespace-fields + ([current_directory current-directory]) #:properties () #:methods (bytes + extend + is_absolute + parts + read_with string - )) + to_complete_path + write_with)) (define/arity #:name Path (path c) #:static-infos ((#%call-result #,(get-path-static-infos))) @@ -62,12 +78,46 @@ [else (raise-argument-error* who rhombus-realm "String || Bytes || Path" c)])) +(define-static-info-syntax current-directory + (#%function-arity 3) + (#%call-result #,(get-path-static-infos)) + . #,(get-function-static-infos)) + (define/method (Path.bytes s) #:primitive (path->bytes) #:static-infos ((#%call-result #,(get-bytes-static-infos))) (bytes->immutable-bytes (path->bytes s))) +(define/method (Path.extend p . ss) + #:primitive (build-path) + #:static-infos ((#%call-result #,(get-path-static-infos))) + (apply build-path p ss)) + +(define/method (Path.is_absolute p) + #:primitive (absolute-path?) + (absolute-path? p)) + +(define/method (Path.parts p) + #:primitive (explode-path) + #:static-infos ((#%call-result #,(get-treelist-static-infos))) + (to-treelist #f (explode-path p))) + +(define/method (Path.read_with p f) + #:primitive (call-with-input-file) + (call-with-input-file p f)) + (define/method (Path.string s) #:primitive (path->string) #:static-infos ((#%call-result #,(get-string-static-infos))) (string->immutable-string (path->string s))) + +(define/method (Path.to_complete_path p #:relative_to [base-path (current-directory)]) + #:primitive (path->complete-path) + #:static-infos ((#%call-result #,(get-path-static-infos))) + (path->complete-path p base-path)) + +(define/method (Path.write_with p f #:exists [exists 'error]) + #:primitive (call-with-output-file) + (call-with-output-file p f #:exists exists)) + +(define-annotation-syntax PathString (identifier-annotation path-string? ())) diff --git a/rhombus-lib/rhombus/private/amalgam/port.rkt b/rhombus-lib/rhombus/private/amalgam/port.rkt index 1f11ae05a..2c86e2046 100644 --- a/rhombus-lib/rhombus/private/amalgam/port.rkt +++ b/rhombus-lib/rhombus/private/amalgam/port.rkt @@ -71,7 +71,8 @@ [open_bytes Port.Output.open_bytes] [open_string Port.Output.open_string] [get_bytes Port.Output.get_bytes] - [get_string Port.Output.get_string]) + [get_string Port.Output.get_string] + ExistsFlag) #:properties () #:methods ([flush Port.Output.flush] @@ -124,6 +125,16 @@ any [any_one any-one]) +(define-simple-symbol-enum ExistsFlag + error + replace + truncate + [must_truncate must-truncate] + [truncate_replace truncate/replace] + update + [can_update can-update] + append) + (define (check-input-port who ip) (unless (input-port? ip) (raise-argument-error* who rhombus-realm "Port.Input" ip))) @@ -139,6 +150,11 @@ [(bstr) (open-input-bytes bstr)] [(bstr name) (open-input-bytes bstr name)])) +(define/arity (Port.Input.open_file path) + #:primitive (open-input-file) + #:static-infos ((#%call-result #,(get-input-port-static-infos))) + (open-input-file path)) + (define/arity Port.Input.open_string #:primitive (open-input-string) #:static-infos ((#%call-result #,(get-input-port-static-infos))) @@ -153,6 +169,11 @@ [() (open-output-bytes)] [(name) (open-output-bytes name)])) +(define/arity (Port.Output.open_file path #:exists [exists 'error]) + #:primitive (open-output-file) + #:static-infos ((#%call-result #,(get-output-port-static-infos))) + (open-output-file path #:exists (->ExistsFlag exists))) + (define/arity Port.Output.open_string #:primitive (open-output-string) #:static-infos ((#%call-result #,(get-output-string-port-static-infos))) diff --git a/rhombus-lib/rhombus/private/amalgam/print.rkt b/rhombus-lib/rhombus/private/amalgam/print.rkt index 0611255c3..4ff819dad 100644 --- a/rhombus-lib/rhombus/private/amalgam/print.rkt +++ b/rhombus-lib/rhombus/private/amalgam/print.rkt @@ -147,7 +147,10 @@ [(display?) (display v op)] [else - (write v op)])] + (concat + (display "Path(" op) + (write (path->string v) op) + (display ")" op))])] [(and (procedure? v) (not (printer-ref v #f))) (define name (object-name v)) diff --git a/rhombus/rhombus/scribblings/reference/appendable.scrbl b/rhombus/rhombus/scribblings/reference/appendable.scrbl index 91049d850..854866831 100644 --- a/rhombus/rhombus/scribblings/reference/appendable.scrbl +++ b/rhombus/rhombus/scribblings/reference/appendable.scrbl @@ -29,6 +29,7 @@ An @deftech{appendable} value is one that supports @rhombus(++). Maps, operator ((v1 :: ReadableString) ++ (v2 :: ReadableString)) :: String operator ((v1 :: Bytes) ++ (v2 :: Bytes)) :: MutableBytes + operator ((v1 :: Path) ++ (v2 :: PathString)) :: Path operator ((v1 :: Appendable) ++ (v2 :: Appendable)) :: Any ){ diff --git a/rhombus/rhombus/scribblings/reference/path.scrbl b/rhombus/rhombus/scribblings/reference/path.scrbl index 5c9a99179..2934248de 100644 --- a/rhombus/rhombus/scribblings/reference/path.scrbl +++ b/rhombus/rhombus/scribblings/reference/path.scrbl @@ -11,7 +11,12 @@ A @deftech{path} value represents a filesystem path. "path" Path path.bytes() + path.extend(part, ...) + path.is_absolute() + path.parts() + path.read_with(proc) path.string() + path.write_with(proc, ...) ) Paths are @tech{comparable}, which means that generic operations like @@ -19,13 +24,20 @@ Paths are @tech{comparable}, which means that generic operations like @doc( annot.macro 'Path' + annot.macro 'PathString' + annot.macro 'PathString.to_path' ){ - Matches a path value. + Matches a path value. The @rhombus(PathString, ~annot) annotation allows + @rhombus(ReadableString, ~annot) as well as @rhombus(Path, ~annot) values. + The @rhombus(PathString.to_path, ~annot) + @tech(~doc: guide_doc){converter annotation} allows + @rhombus(PathString, ~annot) values, but converts + @rhombus(ReadableString, ~annot) values to @rhombus(Path) values. } @doc( - fun Path(path :: Bytes || String || Path) :: Path + fun Path(path :: Bytes || ReadableString || Path) :: Path ){ Constructs a path given a byte string, string, or existing path. When a @@ -69,6 +81,93 @@ Paths are @tech{comparable}, which means that generic operations like } +@doc( + fun Path.extend(path :: Path, + part :: PathString | #'up | #'same, ...) :: Path +){ + + Creates a path given a base path and any number of sub-path + extensions. See also @rhombus(++). If @rhombus(path) is an absolute path, + the result is an absolute path, otherwise the result is a relative path. + + The @rhombus(path) and each @rhombus(part) must be either a relative + path, the symbol @rhombus(#'up) (indicating the relative parent + directory), or the symbol @rhombus(#'same) (indicating the + relative current directory). For Windows paths, if @rhombus(path) is a + drive specification (with or without a trailing slash) the first + @rhombus(part) can be an absolute (driveless) path. For all platforms, + the last @rhombus(part) can be a filename. + + The @rhombus(path) and @rhombus(part) arguments can be paths for + any platform. The platform for the resulting path is inferred from the + @rhombus(path) and @rhombus(part) arguments, where string arguments imply + a path for the current platform. If different arguments are for + different platforms, the @rhombus(Exn.Fail.Contract, ~class) exception + is thrown. If no argument implies a platform (i.e., all are @rhombus(#'up) + or @rhombus(#'same)), the generated path is for the current platform. + + Each @rhombus(part) and @rhombus(path) can optionally end in a directory + separator. If the last @rhombus(part) ends in a separator, it is + included in the resulting path. + + The @rhombus(build-path) procedure builds a path @italic{without} + checking the validity of the path or accessing the filesystem. + +@examples( + def p = Path("/home/rhombus") + Path.extend(p, "shape.txt") + p.extend("shape.txt") + p ++ "shape.txt" +) + +} + +@doc(fun Path.is_absolute(path :: Path)){ + + Returns @rhombus(#true) if @rhombus(path) is an absolute path, @rhombus(#false) + otherwise. This procedure does not access the filesystem. +} + +@doc( + fun Path.parts(path :: Path) :: List.of(Path || #'up || #'same) +){ + + Returns a list of path elements that constitute @rhombus(path). + + The @rhombus(Path.parts) function computes its result in time + proportional to the length of @rhombus(path). + +@examples( + def p = Path("/home/rhombus/shape.txt") + Path.parts(p) + p.parts() +) + +} + +@doc( + fun Path.read_with(path :: Path, read_proc :: Function.of_arity(1)) +){ + + Opens @rhombus(path) for reading and calls @rhombus(read_proc) with the + @tech{input port}. The result of @rhombus(read_proc) is the result of + @rhombus(Path.read_with). + +} + +@doc( + fun Path.write_with(path :: Path, + proc :: Function.of_arity(1), + ~exists: exists_flag + :: Port.Output.ExistsFlag = #'error) +){ + + Opens @rhombus(path) for writing and calls @rhombus(write_proc) with the + @tech{output port}. The result of @rhombus(write_proc) is the result of + @rhombus(Path.write_with). + +} + @doc( fun Path.string(path :: Path) :: String ){ diff --git a/rhombus/rhombus/scribblings/reference/port.scrbl b/rhombus/rhombus/scribblings/reference/port.scrbl index f38c387dc..5c53a5285 100644 --- a/rhombus/rhombus/scribblings/reference/port.scrbl +++ b/rhombus/rhombus/scribblings/reference/port.scrbl @@ -112,6 +112,14 @@ output. Moreover, an @deftech{input string port} or an } +@doc( + fun Port.Input.open_file(file :: PathString) :: Port.Input +){ + + Creates an @tech{input port} that reads from the @tech{path} @rhombus(file). + +} + @doc( fun Port.Input.open_string(str :: ReadableString, name :: Symbol = #'string) @@ -314,6 +322,51 @@ output. Moreover, an @deftech{input string port} or an } +@doc( + fun Port.Output.open_file(path :: PathString, + ~exists: exists_flag + :: Port.Output.ExistsFlag = #'error) + :: Port.Output +){ + + Creates an @tech{output port} that writes to the @tech{path} @rhombus(file). + The @rhombus(exists_flag) argument specifies how to handle/require + files that already exist: + + @itemlist( + + @item{@rhombus(#'error) --- throws @rhombus(Exn.Fail.Filesystem.Exists) + if the file exists.} + + @item{@rhombus(#'replace) --- remove the old file, if it + exists, and write a new one.} + + @item{@rhombus(#'truncate) --- remove all old data, if the file + exists.} + + @item{@rhombus(#'must_truncate) --- remove all old data in an + existing file; if the file does not exist, the + @rhombus(Exn.Fail.Filesystem) exception is thrown.} + + @item{@rhombus(#'truncate_replace) --- try @rhombus(#'truncate); + if it fails (perhaps due to file permissions), try + @rhombus(#'replace).} + + @item{@rhombus(#'update) --- open an existing file without + truncating it; if the file does not exist, the + @rhombus(Exn.Fail.Filesystem) exception is thrown.} + + @item{@rhombus(#'can_update) --- open an existing file without + truncating it, or create the file if it does not exist.} + + @item{@rhombus(#'append) --- append to the end of the file, + whether it already exists or not; on Windows, + @rhombus(#'append) is equivalent to @rhombus(#'update), except that + the file is not required to exist, and the file position is + immediately set to the end of the file after opening it.} + +)} + @doc( fun Port.Output.open_bytes(name :: Symbol = #'string) :: Port.Output.String @@ -357,3 +410,19 @@ output. Moreover, an @deftech{input string port} or an Flushes the content of @rhombus(out)'s buffer. } + +@doc( + enum Port.Output.ExistsFlag: + error + append + update + can_update + replace + truncate + must_truncate + truncate_replace +){ + + Flags for handling existing files when opening @tech{output ports}. + +} diff --git a/rhombus/rhombus/tests/path.rhm b/rhombus/rhombus/tests/path.rhm index 6cc40bce1..6fab86596 100644 --- a/rhombus/rhombus/tests/path.rhm +++ b/rhombus/rhombus/tests/path.rhm @@ -59,3 +59,35 @@ block: check p == q ~is #false check p != Path("/") ~is #false check p != q ~is #true + +block: + def s = "/etc" + def p1 = Path("/etc") + def p2 = (s :: PathString.to_path) + check p1 compares_equal p2 ~is #true + version_guard.at_least "8.13.0.1": + check p1 == p2 ~is #true + +block: + def p1 = Path("/etc/passwd") + check p1.parts() ~is [Path("/"), Path("etc"), Path("passwd")] + def p2 = Path("C:/windows") + check p2.parts() ~is [Path("C:"), Path("windows")] + def p3 = Path("../a/b/./c") + check p3.parts() ~is [#'up, Path("a"), Path("b"), #'same, Path("c")] + +block: + check Path("/").extend("etc", Path("passwd")) ~is Path("/etc/passwd") + check Path.extend("C:", "win32", "sys") ~is Path("C:/win32/sys") + check Path("/") ++ Path("etc") ++ "passwd" ~is Path("/etc/passwd") + check Path("..") ++ "a" ++ "b" ++ #'same ++ "c" ~is Path("../a/b/./c") + +block: + def p1 = Path("/etc") + def p2 = Path("etc") + check Path.is_absolute(p1) ~is #true + check Path.is_absolute("/etc") ~is #true + check p1.is_absolute() ~is #true + check Path.is_absolute(p2) ~is #false + check Path.is_absolute("etc") ~is #false + check p2.is_absolute() ~is #false