From 94160fcd348349d92e6e8161259105957ea87931 Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 2 Apr 2026 20:06:25 +0200 Subject: [PATCH 1/3] Add OptionInFile and OptionOutFile with lazy wrapper objects. Add convenience option types that return InFile/OutFile wrappers supporting lazy opening, "-" for stdin/stdout, and optional file existence checking at parse time. --- src/cli.toit | 253 ++++++++++++++++++++++++++++++++++++++++ tests/options_test.toit | 142 ++++++++++++++++++++++ 2 files changed, 395 insertions(+) diff --git a/src/cli.toit b/src/cli.toit index 19753fb..4bdc971 100644 --- a/src/cli.toit +++ b/src/cli.toit @@ -2,6 +2,11 @@ // Use of this source code is governed by an MIT-style license that can be // found in the package's LICENSE file. +import fs +import host.directory +import host.file +import host.pipe +import io import log import uuid show Uuid import system @@ -1086,6 +1091,254 @@ class OptionPath extends Option: parse str/string --for-help-example/bool=false -> string: return str +/** +An input file option. + +The parsed value is an $InFile, which can be opened lazily. + +If $allow-dash is true (the default), the value "-" is interpreted as + stdin. + +If $check-exists is true (the default), the file is checked for + existence at parse time. This check is skipped for "-" (stdin). +*/ +class OptionInFile extends Option: + default/string? + type/string + + /** + Whether "-" is interpreted as stdin. + */ + allow-dash/bool + + /** + Whether the file is checked for existence at parse time. + */ + check-exists/bool + + /** + Creates a new input file option. + + The $default value is null. + The $type defaults to "file". + + If $allow-dash is true (the default), the value "-" is interpreted + as stdin. + + If $check-exists is true (the default), the file must exist at parse + time. This check is skipped for "-" (stdin) and for help examples. + + See $Option.constructor for the other parameters. + */ + constructor name/string + --.default=null + --.type="file" + --.allow-dash=true + --.check-exists=true + --short-name/string?=null + --help/string?=null + --required/bool=false + --hidden/bool=false + --multi/bool=false + --split-commas/bool=false + --completion/Lambda?=null: + if multi and default: throw "Multi option can't have default value." + if required and default: throw "Option can't have default value and be required." + super.from-subclass name --short-name=short-name --help=help \ + --required=required --hidden=hidden --multi=multi \ + --split-commas=split-commas --completion=completion + + is-flag: return false + + options-for-completion -> List: return [] + + completion-directive -> int?: return DIRECTIVE-FILE-COMPLETION_ + + parse str/string --for-help-example/bool=false -> any: + if allow-dash and str == "-": + return InFile.stdin_ + if check-exists and not for-help-example: + if not file.is-file str: + throw "File not found for option '$name': '$str'." + return InFile.from-path_ str + +/** +An output file option. + +The parsed value is an $OutFile, which can be opened lazily. + +If $allow-dash is true (the default), the value "-" is interpreted as + stdout. + +If $create-directories is true, parent directories are created + automatically when opening the file for writing. +*/ +class OptionOutFile extends Option: + default/string? + type/string + + /** + Whether "-" is interpreted as stdout. + */ + allow-dash/bool + + /** + Whether parent directories are created when opening for writing. + */ + create-directories/bool + + /** + Creates a new output file option. + + The $default value is null. + The $type defaults to "file". + + If $allow-dash is true (the default), the value "-" is interpreted + as stdout. + + If $create-directories is true, parent directories are created + automatically when opening the file for writing. Defaults to false. + + See $Option.constructor for the other parameters. + */ + constructor name/string + --.default=null + --.type="file" + --.allow-dash=true + --.create-directories=false + --short-name/string?=null + --help/string?=null + --required/bool=false + --hidden/bool=false + --multi/bool=false + --split-commas/bool=false + --completion/Lambda?=null: + if multi and default: throw "Multi option can't have default value." + if required and default: throw "Option can't have default value and be required." + super.from-subclass name --short-name=short-name --help=help \ + --required=required --hidden=hidden --multi=multi \ + --split-commas=split-commas --completion=completion + + is-flag: return false + + options-for-completion -> List: return [] + + completion-directive -> int?: return DIRECTIVE-FILE-COMPLETION_ + + parse str/string --for-help-example/bool=false -> any: + if allow-dash and str == "-": + return OutFile.stdout_ --create-directories=create-directories + return OutFile.from-path_ str --create-directories=create-directories + +/** +A wrapper around an input file or stdin. + +Returned by $OptionInFile when parsing command-line arguments. + +Use $open or $do to read from the file or stdin. +*/ +class InFile: + /** The file path, or null if this represents stdin. */ + path/string? + + /** + Whether this $InFile represents stdin. + */ + is-stdin/bool + + constructor.from-path_ .path/string: + is-stdin = false + + constructor.stdin_: + path = null + is-stdin = true + + /** + Opens the file (or stdin) for reading. + + The caller is responsible for closing the returned reader. + */ + open -> io.CloseableReader: + if is-stdin: return pipe.stdin.in + return (file.Stream.for-read path).in + + /** + Opens the file (or stdin) for reading, calls the given $block + with the reader, and closes the reader afterwards. + */ + do [block] -> none: + reader := open + try: + block.call reader + finally: + reader.close + + /** + Reads the entire content of the file or stdin. + */ + read-contents -> ByteArray: + if not is-stdin: return file.read-contents path + buffer := io.Buffer + reader := pipe.stdin.in + try: + while chunk := reader.read: + buffer.write chunk + finally: + reader.close + return buffer.bytes + +/** +A wrapper around an output file or stdout. + +Returned by $OptionOutFile when parsing command-line arguments. + +Use $open or $do to write to the file or stdout. +*/ +class OutFile: + /** The file path, or null if this represents stdout. */ + path/string? + + create-directories_/bool + + /** + Whether this $OutFile represents stdout. + */ + is-stdout/bool + + constructor.from-path_ .path/string --create-directories/bool: + create-directories_ = create-directories + is-stdout = false + + constructor.stdout_ --create-directories/bool: + path = null + create-directories_ = create-directories + is-stdout = true + + /** + Opens the file (or stdout) for writing. + + If $OptionOutFile.create-directories was set, parent directories are + created automatically. + + The caller is responsible for closing the returned writer. + */ + open -> io.CloseableWriter: + if is-stdout: return pipe.stdout.out + if create-directories_: + directory.mkdir --recursive (fs.dirname path) + return (file.Stream.for-write path).out + + /** + Opens the file (or stdout) for writing, calls the given $block + with the writer, and closes the writer afterwards. + */ + do [block] -> none: + writer := open + try: + block.call writer + finally: + writer.close + /** A Uuid option. */ diff --git a/tests/options_test.toit b/tests/options_test.toit index f24731b..ac86945 100644 --- a/tests/options_test.toit +++ b/tests/options_test.toit @@ -4,6 +4,8 @@ import cli import expect show * +import host.directory +import host.file import uuid show Uuid main: @@ -14,6 +16,8 @@ main: test-uuid test-flag test-path + test-in-file + test-out-file test-bad-combos test-string: @@ -216,3 +220,141 @@ test-path: expect-throw "Multi option can't have default value.": cli.OptionPath "foo" --default="bar" --multi + +test-in-file: + option := cli.OptionInFile "input" --help="Input file." + expect-equals "input" option.name + expect-null option.default + expect-equals "file" option.type + expect-not option.is-flag + expect option.allow-dash + expect option.check-exists + + // Test with custom options. + option = cli.OptionInFile "input" --no-allow-dash --no-check-exists + expect-not option.allow-dash + expect-not option.check-exists + + // Test parse returns InFile for a path. + in-file := (option.parse "/some/path") as cli.InFile + expect-equals "/some/path" in-file.path + expect-not in-file.is-stdin + + // Test parse returns InFile for "-" when allow-dash is true. + option = cli.OptionInFile "input" + in-file = (option.parse "-") as cli.InFile + expect-null in-file.path + expect in-file.is-stdin + + // Test "-" is treated as literal when allow-dash is false. + option = cli.OptionInFile "input" --no-allow-dash --no-check-exists + in-file = (option.parse "-") as cli.InFile + expect-equals "-" in-file.path + expect-not in-file.is-stdin + + // Test check-exists fails for missing files. + option = cli.OptionInFile "input" --check-exists + expect-throw "File not found for option 'input': '/nonexistent/file.txt'.": + option.parse "/nonexistent/file.txt" + + // Test check-exists is skipped for "-". + in-file = (option.parse "-") as cli.InFile + expect in-file.is-stdin + + // Test check-exists is skipped for help examples. + in-file = (option.parse "/nonexistent/file.txt" --for-help-example) as cli.InFile + expect-equals "/nonexistent/file.txt" in-file.path + + // Test reading from a real file. + tmpdir := directory.mkdtemp "/tmp/cli-test-" + try: + test-path := "$tmpdir/test.txt" + file.write-contents --path=test-path "hello world" + option = cli.OptionInFile "input" + in-file = (option.parse test-path) as cli.InFile + expect-equals test-path in-file.path + + // Test do [block]. + in-file.do: | reader | + data := reader.read + expect-equals "hello world" data.to-string + + // Test read-contents. + in-file = (option.parse test-path) as cli.InFile + contents := in-file.read-contents + expect-equals "hello world" contents.to-string + finally: + directory.rmdir --recursive --force tmpdir + + // Test bad combos. + expect-throw "Multi option can't have default value.": + cli.OptionInFile "foo" --default="bar" --multi + +test-out-file: + option := cli.OptionOutFile "output" --help="Output file." + expect-equals "output" option.name + expect-null option.default + expect-equals "file" option.type + expect-not option.is-flag + expect option.allow-dash + expect-not option.create-directories + + // Test with custom options. + option = cli.OptionOutFile "output" --no-allow-dash --create-directories + expect-not option.allow-dash + expect option.create-directories + + // Test parse returns OutFile for a path. + out-file := (option.parse "/some/path") as cli.OutFile + expect-equals "/some/path" out-file.path + expect-not out-file.is-stdout + + // Test parse returns OutFile for "-" when allow-dash is true. + option = cli.OptionOutFile "output" + out-file = (option.parse "-") as cli.OutFile + expect-null out-file.path + expect out-file.is-stdout + + // Test "-" is treated as literal when allow-dash is false. + option = cli.OptionOutFile "output" --no-allow-dash + out-file = (option.parse "-") as cli.OutFile + expect-equals "-" out-file.path + expect-not out-file.is-stdout + + // Test writing to a real file. + tmpdir := directory.mkdtemp "/tmp/cli-test-" + try: + test-path := "$tmpdir/output.txt" + option = cli.OptionOutFile "output" + out-file = (option.parse test-path) as cli.OutFile + + // Test do [block]. + out-file.do: | writer | + writer.write "hello output" + + contents := file.read-contents test-path + expect-equals "hello output" contents.to-string + + // Test create-directories. + nested-path := "$tmpdir/a/b/c/output.txt" + option = cli.OptionOutFile "output" --create-directories + out-file = (option.parse nested-path) as cli.OutFile + + out-file.do: | writer | + writer.write "nested" + + contents = file.read-contents nested-path + expect-equals "nested" contents.to-string + + // Test create-directories=false fails for missing parent dirs. + missing-path := "$tmpdir/x/y/z/output.txt" + option = cli.OptionOutFile "output" + out-file = (option.parse missing-path) as cli.OutFile + expect-throw "FILE_NOT_FOUND: \"$missing-path\"": + out-file.open + finally: + directory.rmdir --recursive --force tmpdir + + // Test bad combos. + expect-throw "Multi option can't have default value.": + cli.OptionOutFile "foo" --default="bar" --multi From 006fa9222a5fd790682497ab607f25d1e994edd4 Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Fri, 3 Apr 2026 12:34:09 +0200 Subject: [PATCH 2/3] Address PR review feedback for OptionInFile/OptionOutFile. - Move file-exists check to InFile.check method. - Add option-name field to InFile and OutFile. - Add write-contents convenience method to OutFile. - Use typed declarations instead of 'as' casts in tests. - Use with-tmp-directory in tests. --- src/cli.toit | 58 ++++++++++++++++++++++++++++++++++------- tests/options_test.toit | 55 +++++++++++++++++++++++--------------- 2 files changed, 82 insertions(+), 31 deletions(-) diff --git a/src/cli.toit b/src/cli.toit index 4bdc971..22a4517 100644 --- a/src/cli.toit +++ b/src/cli.toit @@ -1156,11 +1156,11 @@ class OptionInFile extends Option: parse str/string --for-help-example/bool=false -> any: if allow-dash and str == "-": - return InFile.stdin_ + return InFile.stdin_ --option-name=name + result := InFile.from-path_ str --option-name=name if check-exists and not for-help-example: - if not file.is-file str: - throw "File not found for option '$name': '$str'." - return InFile.from-path_ str + result.check + return result /** An output file option. @@ -1227,8 +1227,8 @@ class OptionOutFile extends Option: parse str/string --for-help-example/bool=false -> any: if allow-dash and str == "-": - return OutFile.stdout_ --create-directories=create-directories - return OutFile.from-path_ str --create-directories=create-directories + return OutFile.stdout_ --create-directories=create-directories --option-name=name + return OutFile.from-path_ str --create-directories=create-directories --option-name=name /** A wrapper around an input file or stdin. @@ -1246,13 +1246,28 @@ class InFile: */ is-stdin/bool - constructor.from-path_ .path/string: + /** + The option name, used in error messages. + */ + option-name/string + + constructor.from-path_ .path/string --.option-name: is-stdin = false - constructor.stdin_: + constructor.stdin_ --.option-name: path = null is-stdin = true + /** + Checks that the file exists. + + Throws if the file does not exist. Does nothing for stdin. + */ + check -> none: + if is-stdin: return + if not file.is-file path: + throw "File not found for option '$option-name': '$path'." + /** Opens the file (or stdin) for reading. @@ -1305,11 +1320,16 @@ class OutFile: */ is-stdout/bool - constructor.from-path_ .path/string --create-directories/bool: + /** + The option name, used in error messages. + */ + option-name/string + + constructor.from-path_ .path/string --create-directories/bool --.option-name: create-directories_ = create-directories is-stdout = false - constructor.stdout_ --create-directories/bool: + constructor.stdout_ --create-directories/bool --.option-name: path = null create-directories_ = create-directories is-stdout = true @@ -1339,6 +1359,24 @@ class OutFile: finally: writer.close + /** + Writes the given $data to the file or stdout. + + If $OptionOutFile.create-directories was set, parent directories are + created automatically. + */ + write-contents data/io.Data -> none: + if not is-stdout: + if create-directories_: + directory.mkdir --recursive (fs.dirname path) + file.write-contents --path=path data + return + writer := pipe.stdout.out + try: + (writer as io.Writer).write data + finally: + writer.close + /** A Uuid option. */ diff --git a/tests/options_test.toit b/tests/options_test.toit index ac86945..de22ea8 100644 --- a/tests/options_test.toit +++ b/tests/options_test.toit @@ -3,6 +3,7 @@ // be found in the tests/LICENSE file. import cli +import cli.utils_ show with-tmp-directory import expect show * import host.directory import host.file @@ -236,19 +237,19 @@ test-in-file: expect-not option.check-exists // Test parse returns InFile for a path. - in-file := (option.parse "/some/path") as cli.InFile + in-file/cli.InFile := option.parse "/some/path" expect-equals "/some/path" in-file.path expect-not in-file.is-stdin // Test parse returns InFile for "-" when allow-dash is true. option = cli.OptionInFile "input" - in-file = (option.parse "-") as cli.InFile + in-file = option.parse "-" expect-null in-file.path expect in-file.is-stdin // Test "-" is treated as literal when allow-dash is false. option = cli.OptionInFile "input" --no-allow-dash --no-check-exists - in-file = (option.parse "-") as cli.InFile + in-file = option.parse "-" expect-equals "-" in-file.path expect-not in-file.is-stdin @@ -257,21 +258,26 @@ test-in-file: expect-throw "File not found for option 'input': '/nonexistent/file.txt'.": option.parse "/nonexistent/file.txt" + // Test check on InFile directly. + option = cli.OptionInFile "input" --no-check-exists + in-file = option.parse "/nonexistent/file.txt" + expect-throw "File not found for option 'input': '/nonexistent/file.txt'.": + in-file.check + // Test check-exists is skipped for "-". - in-file = (option.parse "-") as cli.InFile + in-file = option.parse "-" expect in-file.is-stdin // Test check-exists is skipped for help examples. - in-file = (option.parse "/nonexistent/file.txt" --for-help-example) as cli.InFile + in-file = option.parse "/nonexistent/file.txt" --for-help-example expect-equals "/nonexistent/file.txt" in-file.path // Test reading from a real file. - tmpdir := directory.mkdtemp "/tmp/cli-test-" - try: + with-tmp-directory: | tmpdir | test-path := "$tmpdir/test.txt" file.write-contents --path=test-path "hello world" option = cli.OptionInFile "input" - in-file = (option.parse test-path) as cli.InFile + in-file = option.parse test-path expect-equals test-path in-file.path // Test do [block]. @@ -280,11 +286,9 @@ test-in-file: expect-equals "hello world" data.to-string // Test read-contents. - in-file = (option.parse test-path) as cli.InFile + in-file = option.parse test-path contents := in-file.read-contents expect-equals "hello world" contents.to-string - finally: - directory.rmdir --recursive --force tmpdir // Test bad combos. expect-throw "Multi option can't have default value.": @@ -305,28 +309,27 @@ test-out-file: expect option.create-directories // Test parse returns OutFile for a path. - out-file := (option.parse "/some/path") as cli.OutFile + out-file/cli.OutFile := option.parse "/some/path" expect-equals "/some/path" out-file.path expect-not out-file.is-stdout // Test parse returns OutFile for "-" when allow-dash is true. option = cli.OptionOutFile "output" - out-file = (option.parse "-") as cli.OutFile + out-file = option.parse "-" expect-null out-file.path expect out-file.is-stdout // Test "-" is treated as literal when allow-dash is false. option = cli.OptionOutFile "output" --no-allow-dash - out-file = (option.parse "-") as cli.OutFile + out-file = option.parse "-" expect-equals "-" out-file.path expect-not out-file.is-stdout // Test writing to a real file. - tmpdir := directory.mkdtemp "/tmp/cli-test-" - try: + with-tmp-directory: | tmpdir | test-path := "$tmpdir/output.txt" option = cli.OptionOutFile "output" - out-file = (option.parse test-path) as cli.OutFile + out-file = option.parse test-path // Test do [block]. out-file.do: | writer | @@ -335,10 +338,15 @@ test-out-file: contents := file.read-contents test-path expect-equals "hello output" contents.to-string + // Test write-contents. + out-file.write-contents "written directly" + contents = file.read-contents test-path + expect-equals "written directly" contents.to-string + // Test create-directories. nested-path := "$tmpdir/a/b/c/output.txt" option = cli.OptionOutFile "output" --create-directories - out-file = (option.parse nested-path) as cli.OutFile + out-file = option.parse nested-path out-file.do: | writer | writer.write "nested" @@ -346,14 +354,19 @@ test-out-file: contents = file.read-contents nested-path expect-equals "nested" contents.to-string + // Test write-contents with create-directories. + nested-path2 := "$tmpdir/d/e/f/output.txt" + out-file = option.parse nested-path2 + out-file.write-contents "nested-written" + contents = file.read-contents nested-path2 + expect-equals "nested-written" contents.to-string + // Test create-directories=false fails for missing parent dirs. missing-path := "$tmpdir/x/y/z/output.txt" option = cli.OptionOutFile "output" - out-file = (option.parse missing-path) as cli.OutFile + out-file = option.parse missing-path expect-throw "FILE_NOT_FOUND: \"$missing-path\"": out-file.open - finally: - directory.rmdir --recursive --force tmpdir // Test bad combos. expect-throw "Multi option can't have default value.": From 9e043038eae9f415f583636bacd4a610ac41ceab Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Fri, 3 Apr 2026 13:45:01 +0200 Subject: [PATCH 3/3] Minor cleanups. --- src/cli.toit | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/cli.toit b/src/cli.toit index 22a4517..d846278 100644 --- a/src/cli.toit +++ b/src/cli.toit @@ -38,6 +38,7 @@ interface Cli: --config/Config?=null: if not ui: ui = Ui.human return Cli_ name --ui=ui --cache=cache --config=config + /** The name of the application. @@ -1293,14 +1294,11 @@ class InFile: */ read-contents -> ByteArray: if not is-stdin: return file.read-contents path - buffer := io.Buffer reader := pipe.stdin.in try: - while chunk := reader.read: - buffer.write chunk + return reader.read-all finally: reader.close - return buffer.bytes /** A wrapper around an output file or stdout. @@ -1373,7 +1371,7 @@ class OutFile: return writer := pipe.stdout.out try: - (writer as io.Writer).write data + writer.write data finally: writer.close