diff --git a/src/cli.toit b/src/cli.toit index 19753fb..d846278 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 @@ -33,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. @@ -1086,6 +1092,289 @@ 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_ --option-name=name + result := InFile.from-path_ str --option-name=name + if check-exists and not for-help-example: + result.check + return result + +/** +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 --option-name=name + return OutFile.from-path_ str --create-directories=create-directories --option-name=name + +/** +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 + + /** + The option name, used in error messages. + */ + option-name/string + + constructor.from-path_ .path/string --.option-name: + is-stdin = false + + 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. + + 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 + reader := pipe.stdin.in + try: + return reader.read-all + finally: + reader.close + +/** +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 + + /** + 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 --.option-name: + 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 + + /** + 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.write data + finally: + writer.close + /** A Uuid option. */ diff --git a/tests/options_test.toit b/tests/options_test.toit index f24731b..de22ea8 100644 --- a/tests/options_test.toit +++ b/tests/options_test.toit @@ -3,7 +3,10 @@ // 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 import uuid show Uuid main: @@ -14,6 +17,8 @@ main: test-uuid test-flag test-path + test-in-file + test-out-file test-bad-combos test-string: @@ -216,3 +221,153 @@ 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/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 "-" + 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 "-" + 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 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 "-" + expect in-file.is-stdin + + // Test check-exists is skipped for help examples. + in-file = option.parse "/nonexistent/file.txt" --for-help-example + expect-equals "/nonexistent/file.txt" in-file.path + + // Test reading from a real file. + 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 + 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 + contents := in-file.read-contents + expect-equals "hello world" contents.to-string + + // 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/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 "-" + 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 "-" + expect-equals "-" out-file.path + expect-not out-file.is-stdout + + // Test writing to a real file. + with-tmp-directory: | tmpdir | + test-path := "$tmpdir/output.txt" + option = cli.OptionOutFile "output" + out-file = option.parse test-path + + // 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 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 + + out-file.do: | writer | + writer.write "nested" + + 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 + expect-throw "FILE_NOT_FOUND: \"$missing-path\"": + out-file.open + + // Test bad combos. + expect-throw "Multi option can't have default value.": + cli.OptionOutFile "foo" --default="bar" --multi