From 764fb36c3e69b01c8e47c033050d32a41310a279 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Fri, 12 Sep 2025 16:14:18 +0000 Subject: [PATCH 1/6] - Add support for reading config from stdin and writing to stdout --- lib/completely/commands/base.rb | 14 ++++++++++++-- lib/completely/commands/generate.rb | 23 ++++++++++++++++++----- lib/completely/completions.rb | 9 ++++++--- lib/completely/config.rb | 17 +++++++---------- lib/completely/exceptions.rb | 1 + spec/approvals/cli/commands | 2 +- spec/approvals/cli/generate/help | 10 ++++++++-- 7 files changed, 53 insertions(+), 23 deletions(-) diff --git a/lib/completely/commands/base.rb b/lib/completely/commands/base.rb index d3bf9ca..20d00d8 100644 --- a/lib/completely/commands/base.rb +++ b/lib/completely/commands/base.rb @@ -35,7 +35,13 @@ def script end def completions - @completions ||= Completions.load(config_path, function_name: args['--function']) + @completions ||= if config_path == '-' + raise Error, "Nothing is piped on stdin" if $stdin.tty? + + Completions.read $stdin, function_name: args['--function'] + else + Completions.load config_path, function_name: args['--function'] + end end def config_path @@ -43,7 +49,11 @@ def config_path end def output_path - @output_path ||= args['OUTPUT_PATH'] || ENV['COMPLETELY_OUTPUT_PATH'] || "#{config_basename}.bash" + @output_path ||= args['OUTPUT_PATH'] || ENV['COMPLETELY_OUTPUT_PATH'] || stdout || "#{config_basename}.bash" + end + + def stdout + @stdout ||= config_path == '-' ? '-' : nil end def config_basename diff --git a/lib/completely/commands/generate.rb b/lib/completely/commands/generate.rb index b42d54a..0415b0f 100644 --- a/lib/completely/commands/generate.rb +++ b/lib/completely/commands/generate.rb @@ -3,7 +3,7 @@ module Completely module Commands class Generate < Base - help 'Generate the bash completion script to a file' + help 'Generate the bash completion script' usage 'completely generate [CONFIG_PATH OUTPUT_PATH --function NAME --wrap NAME]' usage 'completely generate (-h|--help)' @@ -12,10 +12,19 @@ class Generate < Base option '-w --wrap NAME', 'Wrap the completion script inside a function that echos the ' \ 'script. This is useful if you wish to embed it directly in your script.' - param_config_path + param 'CONFIG_PATH', <<~USAGE + Path to the YAML configuration file [default: completely.yaml]. + Use '-' to read from stdin. + + Can also be set by an environment variable. + USAGE + param 'OUTPUT_PATH', <<~USAGE Path to the output bash script. - When not provided, the name of the input file will be used with a .bash extension. + Use '-' for stdout. + + When not provided, the name of the input file will be used with a .bash extension, unless the input is stdin - in this case the default will be to output to stdout. + Can also be set by an environment variable. USAGE @@ -26,8 +35,12 @@ class Generate < Base def run wrap = args['--wrap'] output = wrap ? wrapper_function(wrap) : script - File.write output_path, output - say "Saved m`#{output_path}`" + if output_path == '-' + puts output + else + File.write output_path, output + say "Saved m`#{output_path}`" + end syntax_warning unless completions.valid? end diff --git a/lib/completely/completions.rb b/lib/completely/completions.rb index 9f0a013..3de2294 100644 --- a/lib/completely/completions.rb +++ b/lib/completely/completions.rb @@ -6,9 +6,12 @@ class Completions attr_reader :config class << self - def load(config_path, function_name: nil) - config = Config.load config_path - new config, function_name: function_name + def load(path, function_name: nil) + new Config.load(path), function_name: function_name + end + + def read(io, function_name: nil) + new Config.read(io), function_name: function_name end end diff --git a/lib/completely/config.rb b/lib/completely/config.rb index fdf4d6e..643904c 100644 --- a/lib/completely/config.rb +++ b/lib/completely/config.rb @@ -3,17 +3,14 @@ class Config attr_reader :config, :options class << self - def load(config_path) - begin - config = YAML.load_file config_path, aliases: true - rescue ArgumentError - # :nocov: - config = YAML.load_file config_path - # :nocov: - end - - new config + def parse(str) + new YAML.load(str, aliases: true) + rescue Psych::Exception => e + raise ParseError, "Invalid YAML: #{e.message}" end + + def load(path) = parse(File.read(path)) + def read(io) = parse(io.read) end def initialize(config) diff --git a/lib/completely/exceptions.rb b/lib/completely/exceptions.rb index 1ebd7a9..cd02a1f 100644 --- a/lib/completely/exceptions.rb +++ b/lib/completely/exceptions.rb @@ -1,4 +1,5 @@ module Completely class Error < StandardError; end class InstallError < Error; end + class ParseError < Error; end end diff --git a/spec/approvals/cli/commands b/spec/approvals/cli/commands index 84a8522..ce901b8 100644 --- a/spec/approvals/cli/commands +++ b/spec/approvals/cli/commands @@ -3,7 +3,7 @@ Completely - Bash Completions Generator Commands: init Create a new sample YAML configuration file preview Generate the bash completion script to STDOUT - generate Generate the bash completion script to a file + generate Generate the bash completion script test Test completions install Install a bash completion script uninstall Uninstall a bash completion script diff --git a/spec/approvals/cli/generate/help b/spec/approvals/cli/generate/help index e262f90..d1619dd 100644 --- a/spec/approvals/cli/generate/help +++ b/spec/approvals/cli/generate/help @@ -1,4 +1,4 @@ -Generate the bash completion script to a file +Generate the bash completion script Usage: completely generate [CONFIG_PATH OUTPUT_PATH --function NAME --wrap NAME] @@ -18,12 +18,18 @@ Options: Parameters: CONFIG_PATH Path to the YAML configuration file [default: completely.yaml]. + Use '-' to read from stdin. + Can also be set by an environment variable. OUTPUT_PATH Path to the output bash script. + Use '-' for stdout. + When not provided, the name of the input file will be used with a .bash - extension. + extension, unless the input is stdin - in this case the default will be to + output to stdout. + Can also be set by an environment variable. Environment Variables: From fcf017222060d037087919ead11f9f3511660214 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Fri, 12 Sep 2025 17:59:26 +0000 Subject: [PATCH 2/6] add specs --- lib/completely/commands/generate.rb | 2 +- lib/completely/commands/preview.rb | 2 +- spec/approvals/cli/commands | 4 +-- spec/approvals/cli/generate/custom-path-stdin | 1 + spec/approvals/cli/generate/help | 2 +- spec/approvals/cli/preview/help | 2 +- spec/completely/commands/generate_spec.rb | 32 +++++++++++++++++-- 7 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 spec/approvals/cli/generate/custom-path-stdin diff --git a/lib/completely/commands/generate.rb b/lib/completely/commands/generate.rb index 0415b0f..9a5c19a 100644 --- a/lib/completely/commands/generate.rb +++ b/lib/completely/commands/generate.rb @@ -3,7 +3,7 @@ module Completely module Commands class Generate < Base - help 'Generate the bash completion script' + help 'Generate the bash completion script to file or stdout' usage 'completely generate [CONFIG_PATH OUTPUT_PATH --function NAME --wrap NAME]' usage 'completely generate (-h|--help)' diff --git a/lib/completely/commands/preview.rb b/lib/completely/commands/preview.rb index 82f7121..e411ed0 100644 --- a/lib/completely/commands/preview.rb +++ b/lib/completely/commands/preview.rb @@ -3,7 +3,7 @@ module Completely module Commands class Preview < Base - help 'Generate the bash completion script to STDOUT' + help 'Generate the bash completion script to stdout' usage 'completely preview [CONFIG_PATH --function NAME]' usage 'completely preview (-h|--help)' diff --git a/spec/approvals/cli/commands b/spec/approvals/cli/commands index ce901b8..639d394 100644 --- a/spec/approvals/cli/commands +++ b/spec/approvals/cli/commands @@ -2,8 +2,8 @@ Completely - Bash Completions Generator Commands: init Create a new sample YAML configuration file - preview Generate the bash completion script to STDOUT - generate Generate the bash completion script + preview Generate the bash completion script to stdout + generate Generate the bash completion script to file or stdout test Test completions install Install a bash completion script uninstall Uninstall a bash completion script diff --git a/spec/approvals/cli/generate/custom-path-stdin b/spec/approvals/cli/generate/custom-path-stdin new file mode 100644 index 0000000..1c973a8 --- /dev/null +++ b/spec/approvals/cli/generate/custom-path-stdin @@ -0,0 +1 @@ +Saved spec/tmp/stdin-to-file.bash diff --git a/spec/approvals/cli/generate/help b/spec/approvals/cli/generate/help index d1619dd..5bbb67f 100644 --- a/spec/approvals/cli/generate/help +++ b/spec/approvals/cli/generate/help @@ -1,4 +1,4 @@ -Generate the bash completion script +Generate the bash completion script to file or stdout Usage: completely generate [CONFIG_PATH OUTPUT_PATH --function NAME --wrap NAME] diff --git a/spec/approvals/cli/preview/help b/spec/approvals/cli/preview/help index 6546285..fb122af 100644 --- a/spec/approvals/cli/preview/help +++ b/spec/approvals/cli/preview/help @@ -1,4 +1,4 @@ -Generate the bash completion script to STDOUT +Generate the bash completion script to stdout Usage: completely preview [CONFIG_PATH --function NAME] diff --git a/spec/completely/commands/generate_spec.rb b/spec/completely/commands/generate_spec.rb index a962902..8c031fb 100644 --- a/spec/completely/commands/generate_spec.rb +++ b/spec/completely/commands/generate_spec.rb @@ -1,8 +1,14 @@ describe Commands::Generate do subject { described_class.new } - before { system 'cp lib/completely/templates/sample.yaml completely.yaml' } - after { system 'rm -f completely.yaml' } + before do + reset_tmp_dir + system 'cp lib/completely/templates/sample.yaml completely.yaml' + end + + after do + system 'rm -f completely.yaml' + end context 'with --help' do it 'shows long usage' do @@ -81,6 +87,28 @@ end end + context 'with stdin and stdout' do + it 'reads config from stdin and writes to stdout' do + allow($stdin).to receive(:tty?).and_return false + allow($stdin).to receive(:read).and_return File.read('completely.yaml') + + expect { subject.execute %w[generate -] } + .to output_approval('cli/generated-script') + end + end + + context 'with stdin and output path' do + let(:outfile) { 'spec/tmp/stdin-to-file.bash' } + + it 'reads config from stdin and writes to file' do + allow($stdin).to receive(:tty?).and_return false + allow($stdin).to receive(:read).and_return File.read('completely.yaml') + + expect { subject.execute %W[generate - #{outfile}] }.to output_approval('cli/generate/custom-path-stdin') + expect(File.read outfile).to match_approval('cli/generated-script') + end + end + context 'with --function NAME' do after { system 'rm -f completely.bash' } From fce3aa62464f4fad48939817fb783a2639eb4533 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Fri, 12 Sep 2025 18:01:39 +0000 Subject: [PATCH 3/6] rubocop --- lib/completely/commands/base.rb | 2 +- lib/completely/commands/generate.rb | 6 +++--- lib/completely/completions.rb | 2 +- spec/completely/commands/generate_spec.rb | 6 ++---- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/lib/completely/commands/base.rb b/lib/completely/commands/base.rb index 20d00d8..3107280 100644 --- a/lib/completely/commands/base.rb +++ b/lib/completely/commands/base.rb @@ -36,7 +36,7 @@ def script def completions @completions ||= if config_path == '-' - raise Error, "Nothing is piped on stdin" if $stdin.tty? + raise Error, 'Nothing is piped on stdin' if $stdin.tty? Completions.read $stdin, function_name: args['--function'] else diff --git a/lib/completely/commands/generate.rb b/lib/completely/commands/generate.rb index 9a5c19a..6fea310 100644 --- a/lib/completely/commands/generate.rb +++ b/lib/completely/commands/generate.rb @@ -15,16 +15,16 @@ class Generate < Base param 'CONFIG_PATH', <<~USAGE Path to the YAML configuration file [default: completely.yaml]. Use '-' to read from stdin. - + Can also be set by an environment variable. USAGE param 'OUTPUT_PATH', <<~USAGE Path to the output bash script. Use '-' for stdout. - + When not provided, the name of the input file will be used with a .bash extension, unless the input is stdin - in this case the default will be to output to stdout. - + Can also be set by an environment variable. USAGE diff --git a/lib/completely/completions.rb b/lib/completely/completions.rb index 3de2294..955a4f1 100644 --- a/lib/completely/completions.rb +++ b/lib/completely/completions.rb @@ -29,7 +29,7 @@ def patterns end def valid? - pattern_prefixes.uniq.count == 1 + pattern_prefixes.uniq.one? end def script diff --git a/spec/completely/commands/generate_spec.rb b/spec/completely/commands/generate_spec.rb index 8c031fb..748c037 100644 --- a/spec/completely/commands/generate_spec.rb +++ b/spec/completely/commands/generate_spec.rb @@ -89,8 +89,7 @@ context 'with stdin and stdout' do it 'reads config from stdin and writes to stdout' do - allow($stdin).to receive(:tty?).and_return false - allow($stdin).to receive(:read).and_return File.read('completely.yaml') + allow($stdin).to receive_messages(tty?: false, read: File.read('completely.yaml')) expect { subject.execute %w[generate -] } .to output_approval('cli/generated-script') @@ -101,8 +100,7 @@ let(:outfile) { 'spec/tmp/stdin-to-file.bash' } it 'reads config from stdin and writes to file' do - allow($stdin).to receive(:tty?).and_return false - allow($stdin).to receive(:read).and_return File.read('completely.yaml') + allow($stdin).to receive_messages(tty?: false, read: File.read('completely.yaml')) expect { subject.execute %W[generate - #{outfile}] }.to output_approval('cli/generate/custom-path-stdin') expect(File.read outfile).to match_approval('cli/generated-script') From b70ac2b5fde2c4fe6caa7daf009256419d5c5fb6 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Sat, 13 Sep 2025 04:23:27 +0000 Subject: [PATCH 4/6] add specs --- spec/completely/completions_spec.rb | 7 +++++++ spec/completely/config_spec.rb | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/spec/completely/completions_spec.rb b/spec/completely/completions_spec.rb index 6f95d75..0f29946 100644 --- a/spec/completely/completions_spec.rb +++ b/spec/completely/completions_spec.rb @@ -4,6 +4,13 @@ let(:path) { "spec/fixtures/#{file}.yaml" } let(:file) { 'basic' } + describe '::read' do + it 'reads from io' do + io = double :io, read: "cli: [--help, --version]" + expect(described_class.read(io).config.config).to eq({ 'cli' => %w[--help --version] }) + end + end + describe '#valid?' do context 'when all patterns start with the same word' do it 'returns true' do diff --git a/spec/completely/config_spec.rb b/spec/completely/config_spec.rb index f3b9f50..abca739 100644 --- a/spec/completely/config_spec.rb +++ b/spec/completely/config_spec.rb @@ -3,6 +3,27 @@ let(:path) { "spec/fixtures/#{file}.yaml" } let(:file) { 'nested' } + let(:config_string) { "cli: [--help, --version]" } + let(:config_hash) { { 'cli' => %w[--help --version] } } + + describe '::parse' do + it 'loads config from string' do + expect(described_class.parse(config_string).config).to eq config_hash + end + + context 'when the string is not a valid YAML' do + it 'raises ParseError' do + expect { described_class.parse("not: a: yaml") }.to raise_error(Completely::ParseError) + end + end + end + + describe '::read' do + it 'loads config from io' do + io = double :io, read: config_string + expect(described_class.read(io).config).to eq config_hash + end + end describe '#flat_config' do it 'returns a flat pattern => completions hash' do From 7969ad3a6b0d318a5f56cd87e4a05dbc39a8fbef Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Sat, 13 Sep 2025 10:27:39 +0000 Subject: [PATCH 5/6] memoize InstallerCommand#installer and #script_path --- lib/completely/commands/install.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/completely/commands/install.rb b/lib/completely/commands/install.rb index fa300eb..a649fc7 100644 --- a/lib/completely/commands/install.rb +++ b/lib/completely/commands/install.rb @@ -35,11 +35,11 @@ def run private def installer - Installer.new program: args['PROGRAM'], script_path: script_path + @installer ||= Installer.new(program: args['PROGRAM'], script_path: script_path) end def script_path - args['SCRIPT_PATH'] || 'completely.bash' + @script_path ||= args['SCRIPT_PATH'] || 'completely.bash' end end end From 0109f76527f0b134ff8886ab8701360797ee1958 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Sat, 13 Sep 2025 12:36:55 +0000 Subject: [PATCH 6/6] - Add stdin support for installer command --- lib/completely/commands/install.rb | 17 ++++++++++- lib/completely/installer.rb | 12 ++++---- spec/approvals/cli/install/help | 1 + spec/approvals/cli/install/stdin-dry | 1 + spec/approvals/cli/install/stdin-install | 2 ++ spec/completely/commands/install_spec.rb | 37 ++++++++++++++++++++++++ spec/completely/installer_spec.rb | 10 +++++++ 7 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 spec/approvals/cli/install/stdin-dry create mode 100644 spec/approvals/cli/install/stdin-install diff --git a/lib/completely/commands/install.rb b/lib/completely/commands/install.rb index a649fc7..4834a5a 100644 --- a/lib/completely/commands/install.rb +++ b/lib/completely/commands/install.rb @@ -1,3 +1,4 @@ +require 'tempfile' require 'completely/commands/base' module Completely @@ -17,9 +18,19 @@ class Install < Base option '-d --dry', 'Show the installation command but do not run it' param 'PROGRAM', 'Name of the program the completions are for.' - param 'SCRIPT_PATH', 'Path to the source bash script [default: completely.bash].' + param 'SCRIPT_PATH', <<~USAGE + Path to the source bash script [default: completely.bash]. + Use '-' to provide the script via stdin. + USAGE def run + if script_path == '-' + raise InstallError, "Nothing is piped on stdin" if $stdin.tty? + + @script_path = tempfile.path + File.write script_path, $stdin.read + end + if args['--dry'] puts installer.install_command_string return @@ -32,6 +43,10 @@ def run say 'You may need to restart your session to test it' end + def tempfile + @tempfile ||= Tempfile.new('stdin-completely-') + end + private def installer diff --git a/lib/completely/installer.rb b/lib/completely/installer.rb index e877d58..eb5b2c4 100644 --- a/lib/completely/installer.rb +++ b/lib/completely/installer.rb @@ -73,15 +73,13 @@ def root_user? end def completions_path - @completions_path ||= completions_path! - end + @completions_path ||= begin + target_directories.each do |target| + return target if Dir.exist? target + end - def completions_path! - target_directories.each do |target| - return target if Dir.exist? target + nil end - - nil end end end diff --git a/spec/approvals/cli/install/help b/spec/approvals/cli/install/help index 2435df1..181822f 100644 --- a/spec/approvals/cli/install/help +++ b/spec/approvals/cli/install/help @@ -25,3 +25,4 @@ Parameters: SCRIPT_PATH Path to the source bash script [default: completely.bash]. + Use '-' to provide the script via stdin. diff --git a/spec/approvals/cli/install/stdin-dry b/spec/approvals/cli/install/stdin-dry new file mode 100644 index 0000000..47144da --- /dev/null +++ b/spec/approvals/cli/install/stdin-dry @@ -0,0 +1 @@ +sudo cp /usr/share/bash-completion/completions/completely-test diff --git a/spec/approvals/cli/install/stdin-install b/spec/approvals/cli/install/stdin-install new file mode 100644 index 0000000..7c7f085 --- /dev/null +++ b/spec/approvals/cli/install/stdin-install @@ -0,0 +1,2 @@ +Saved some-target-path +You may need to restart your session to test it diff --git a/spec/completely/commands/install_spec.rb b/spec/completely/commands/install_spec.rb index 3208920..4e09242 100644 --- a/spec/completely/commands/install_spec.rb +++ b/spec/completely/commands/install_spec.rb @@ -34,6 +34,20 @@ end end + context 'with PROGRAM - (stdin)' do + it 'invokes the Installer using a temp file' do + allow(subject).to receive(:installer).and_return(mock_installer) + allow($stdin).to receive_messages(tty?: false, read: 'dummy data') + + expect(mock_installer).to receive(:install) + + expect { subject.execute %w[install completely-test -] } + .to output_approval('cli/install/stdin-install') + + expect(File.read subject.tempfile.path).to eq 'dummy data' + end + end + context 'with PROGRAM --dry' do it 'shows the command and does not install anything' do expect(mock_installer).not_to receive(:install) @@ -43,6 +57,29 @@ end end + context 'with PROGRAM - --dry (stdin)' do + it 'shows the command and does not install anything' do + allow($stdin).to receive_messages(tty?: false, read: 'dummy data') + + expect(mock_installer).not_to receive(:install) + + expect { subject.execute %w[install completely-test - --dry] } + .to output_approval('cli/install/stdin-dry') + .except(/[^\s]*stdin-completely-[^\s]*/, '') + end + + context 'when stdin is empty' do + it 'raises InstallError' do + allow($stdin).to receive_messages(tty?: true, read: nil) + expect(mock_installer).not_to receive(:install) + + expect { subject.execute %w[install completely-test - --dry] } + .to raise_error(InstallError, 'Nothing is piped on stdin') + end + end + end + + context 'when the installer fails' do it 'raises an error' do allow(subject).to receive(:installer).and_return(mock_installer) diff --git a/spec/completely/installer_spec.rb b/spec/completely/installer_spec.rb index 2972015..ed8e9f7 100644 --- a/spec/completely/installer_spec.rb +++ b/spec/completely/installer_spec.rb @@ -25,6 +25,16 @@ expect(subject.target_path) .to eq '/usr/share/bash-completion/completions/completely-test' end + + # This method will not be called if there is no completions path + # The test is here to cover the nil fallback + context 'when no paths found' do + it 'returns nil as the base path' do + allow(subject).to receive(:target_directories).and_return([]) + + expect(subject.target_path).to eq '/completely-test' + end + end end describe '#install_command' do