From 6eb5c85d52ca5bc35ea0f381e854e5c2f238a2dd Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Tue, 30 Jun 2026 13:46:12 +0300 Subject: [PATCH 01/18] add initial plumbing for dual config formats --- lib/completely.rb | 2 + lib/completely/completions.rb | 2 +- lib/completely/config.rb | 59 ++------- lib/completely/flat_config.rb | 56 ++++++++ lib/completely/pattern_config.rb | 120 ++++++++++++++++++ spec/completely/commands/generate_spec.rb | 2 +- spec/completely/commands/preview_spec.rb | 2 +- spec/completely/completions_spec.rb | 2 +- spec/completely/config_spec.rb | 15 ++- spec/completely/pattern_config_spec.rb | 66 ++++++++++ spec/completely/zsh_spec.rb | 2 +- spec/fixtures/{ => flat-config}/basic.yaml | 0 spec/fixtures/{ => flat-config}/broken.yaml | 0 .../{ => flat-config}/complete_options.yaml | 0 spec/fixtures/{ => flat-config}/nested.yaml | 0 .../{ => flat-config}/only-spaces.yaml | 0 spec/fixtures/pattern-config/basic.yaml | 14 ++ 17 files changed, 286 insertions(+), 56 deletions(-) create mode 100644 lib/completely/flat_config.rb create mode 100644 lib/completely/pattern_config.rb create mode 100644 spec/completely/pattern_config_spec.rb rename spec/fixtures/{ => flat-config}/basic.yaml (100%) rename spec/fixtures/{ => flat-config}/broken.yaml (100%) rename spec/fixtures/{ => flat-config}/complete_options.yaml (100%) rename spec/fixtures/{ => flat-config}/nested.yaml (100%) rename spec/fixtures/{ => flat-config}/only-spaces.yaml (100%) create mode 100644 spec/fixtures/pattern-config/basic.yaml diff --git a/lib/completely.rb b/lib/completely.rb index 54a3d95..0cf4b02 100644 --- a/lib/completely.rb +++ b/lib/completely.rb @@ -1,4 +1,6 @@ require 'completely/exceptions' +require 'completely/flat_config' +require 'completely/pattern_config' require 'completely/config' require 'completely/pattern' require 'completely/completions' diff --git a/lib/completely/completions.rb b/lib/completely/completions.rb index 955a4f1..acee7da 100644 --- a/lib/completely/completions.rb +++ b/lib/completely/completions.rb @@ -16,7 +16,7 @@ def read(io, function_name: nil) end def initialize(config, function_name: nil) - @config = config.is_a?(Config) ? config : Config.new(config) + @config = config.respond_to?(:flat_config) ? config : Config.build(config) @function_name = function_name end diff --git a/lib/completely/config.rb b/lib/completely/config.rb index 643904c..e9bfa1d 100644 --- a/lib/completely/config.rb +++ b/lib/completely/config.rb @@ -1,67 +1,26 @@ module Completely class Config - attr_reader :config, :options - class << self def parse(str) - new YAML.load(str, aliases: true) + build 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) - @options = config.delete('completely_options')&.transform_keys(&:to_sym) || {} - @config = config - end - - def flat_config - result = {} - - config.each do |root_key, root_list| - result.merge! process_key(root_key, root_list) - end - - result - end - private - - def process_key(prefix, list) - result = {} - result[prefix] = collect_immediate_children list - result.merge! process_nested_items(prefix, list) - result - end - - def collect_immediate_children(list) - list.map do |item| - x = item.is_a?(Hash) ? item.keys.first : item - x.gsub(/^[*+]/, '') + def build(config) + if pattern_config? config + PatternConfig.new config + else + FlatConfig.new config + end end - end - - def process_nested_items(prefix, list) - result = {} - list.each do |item| - next unless item.is_a? Hash - - nested_prefix = generate_nested_prefix(prefix, item) - nested_list = item.values.first - result.merge!(process_key(nested_prefix, nested_list)) + def pattern_config?(config) + config.is_a?(Hash) && config.key?('patterns') end - - result - end - - def generate_nested_prefix(prefix, item) - appended_prefix = item.keys.first.gsub(/^\+/, '*') - appended_prefix = " #{appended_prefix}" unless appended_prefix.start_with? '*' - "#{prefix}#{appended_prefix}" end end end diff --git a/lib/completely/flat_config.rb b/lib/completely/flat_config.rb new file mode 100644 index 0000000..49ac6ad --- /dev/null +++ b/lib/completely/flat_config.rb @@ -0,0 +1,56 @@ +module Completely + class FlatConfig + attr_reader :config, :options + + def initialize(config) + @options = config.delete('completely_options')&.transform_keys(&:to_sym) || {} + @config = config + end + + def flat_config + result = {} + + config.each do |root_key, root_list| + result.merge! process_key(root_key, root_list) + end + + result + end + + private + + def process_key(prefix, list) + result = {} + result[prefix] = collect_immediate_children list + result.merge! process_nested_items(prefix, list) + result + end + + def collect_immediate_children(list) + list.map do |item| + x = item.is_a?(Hash) ? item.keys.first : item + x.gsub(/^[*+]/, '') + end + end + + def process_nested_items(prefix, list) + result = {} + + list.each do |item| + next unless item.is_a? Hash + + nested_prefix = generate_nested_prefix(prefix, item) + nested_list = item.values.first + result.merge!(process_key(nested_prefix, nested_list)) + end + + result + end + + def generate_nested_prefix(prefix, item) + appended_prefix = item.keys.first.gsub(/^\+/, '*') + appended_prefix = " #{appended_prefix}" unless appended_prefix.start_with? '*' + "#{prefix}#{appended_prefix}" + end + end +end diff --git a/lib/completely/pattern_config.rb b/lib/completely/pattern_config.rb new file mode 100644 index 0000000..fd15a6a --- /dev/null +++ b/lib/completely/pattern_config.rb @@ -0,0 +1,120 @@ +module Completely + class PatternConfig + attr_reader :config + + def initialize(config) + @config = config + end + + def model + @model ||= { + program: program, + routes: routes, + options: options, + tokens: tokens, + } + end + + def flat_config + raise Error, 'Pattern config completion generation is not implemented yet' + end + + private + + def patterns + @patterns ||= Array config['patterns'] + end + + def option_groups + @option_groups ||= config['options'] || {} + end + + def token_sources + @token_sources ||= config['tokens'] || {} + end + + def program + routes.first.dig(:words, 0, :name) + end + + def routes + @routes ||= patterns.map { |pattern| parse_pattern pattern } + end + + def options + @options ||= option_groups.to_h do |name, entries| + [name, Array(entries).map { |entry| parse_option entry }] + end + end + + def tokens + @tokens ||= token_sources.to_h do |name, source| + [name, parse_source(name, source)] + end + end + + def parse_pattern(pattern) + result = { words: [], option_groups: [], positionals: [] } + + pattern_parts(pattern).each do |part| + if option_group?(part) + result[:option_groups] << option_group_name(part) + elsif token?(part) + result[:positionals] << parse_token(part) + else + result[:words] << parse_word(part) + end + end + + result + end + + def parse_word(part) + names = part.split('|') + { name: names.first, aliases: names[1..] || [] } + end + + def pattern_parts(pattern) + pattern.scan(/\[[^\]]+\]|<[^>]+>|\S+/) + end + + def parse_option(entry) + flag_part, value_part = entry.split + names = flag_part.split('|') + + result = { names: names } + result[:value] = parse_token(value_part) if value_part + result + end + + def parse_token(part) + name = part[/\A<(.+)>\z/, 1] + { name: name, source: parse_source(name, token_sources[name]) } + end + + def parse_source(name, source) + case source + when Array + { type: :values, value: source } + when /^\$\(.*\)$/ + { type: :command, value: source } + when String + { type: :builtin, value: source } + else + { type: :builtin, value: name } + end + end + + def option_group?(part) + part.start_with?('[') && part.end_with?(']') + end + + def option_group_name(part) + part[1..-2].sub(/\s+options\z/, '') + end + + def token?(part) + part.start_with?('<') && part.end_with?('>') + end + end +end diff --git a/spec/completely/commands/generate_spec.rb b/spec/completely/commands/generate_spec.rb index 9d152ea..0e3f20b 100644 --- a/spec/completely/commands/generate_spec.rb +++ b/spec/completely/commands/generate_spec.rb @@ -148,7 +148,7 @@ context 'with an invalid configuration' do it 'outputs a warning to STDERR' do - expect { subject.execute %w[generate spec/fixtures/broken.yaml spec/tmp/out.bash] } + expect { subject.execute %w[generate spec/fixtures/flat-config/broken.yaml spec/tmp/out.bash] } .to output_approval('cli/warning').to_stderr end end diff --git a/spec/completely/commands/preview_spec.rb b/spec/completely/commands/preview_spec.rb index d531a6d..5359127 100644 --- a/spec/completely/commands/preview_spec.rb +++ b/spec/completely/commands/preview_spec.rb @@ -42,7 +42,7 @@ context 'with an invalid configuration' do it 'outputs a warning to STDERR' do - expect { subject.execute %w[preview spec/fixtures/broken.yaml] } + expect { subject.execute %w[preview spec/fixtures/flat-config/broken.yaml] } .to output_approval('cli/warning').to_stderr end end diff --git a/spec/completely/completions_spec.rb b/spec/completely/completions_spec.rb index 7e9cf4f..81d89ae 100644 --- a/spec/completely/completions_spec.rb +++ b/spec/completely/completions_spec.rb @@ -1,7 +1,7 @@ describe Completions do subject { described_class.load path } - let(:path) { "spec/fixtures/#{file}.yaml" } + let(:path) { "spec/fixtures/flat-config/#{file}.yaml" } let(:file) { 'basic' } describe '::read' do diff --git a/spec/completely/config_spec.rb b/spec/completely/config_spec.rb index 6d16e59..46d6a09 100644 --- a/spec/completely/config_spec.rb +++ b/spec/completely/config_spec.rb @@ -1,7 +1,7 @@ describe Config do subject { described_class.load path } - let(:path) { "spec/fixtures/#{file}.yaml" } + let(:path) { "spec/fixtures/flat-config/#{file}.yaml" } let(:file) { 'nested' } let(:config_string) { 'cli: [--help, --version]' } let(:config_hash) { { 'cli' => %w[--help --version] } } @@ -11,6 +11,19 @@ expect(described_class.parse(config_string).config).to eq config_hash end + it 'returns a flat config for the existing configuration format' do + expect(described_class.parse(config_string)).to be_a FlatConfig + end + + it 'returns a pattern config for the pattern configuration format' do + config = described_class.parse <<~YAML + patterns: + - cli [options] + YAML + + expect(config).to be_a PatternConfig + 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) diff --git a/spec/completely/pattern_config_spec.rb b/spec/completely/pattern_config_spec.rb new file mode 100644 index 0000000..9cd9b39 --- /dev/null +++ b/spec/completely/pattern_config_spec.rb @@ -0,0 +1,66 @@ +describe PatternConfig do + subject(:config) { Config.load 'spec/fixtures/pattern-config/basic.yaml' } + + describe '#model' do + it 'returns the command name' do + expect(config.model[:program]).to eq 'mygit' + end + + it 'returns routes from the completion patterns' do + expect(config.model[:routes]).to eq [ + { + words: [ + { name: 'mygit', aliases: [] }, + { name: 'init', aliases: [] }, + ], + option_groups: ['init'], + positionals: [ + { + name: 'directory', + source: { type: :builtin, value: 'directory' }, + }, + ], + }, + { + words: [ + { name: 'mygit', aliases: [] }, + { name: 'status', aliases: ['st'] }, + ], + option_groups: ['status'], + positionals: [], + }, + ] + end + + it 'returns option groups' do + expect(config.model[:options]).to eq( + 'init' => [ + { names: ['--bare'] }, + ], + 'status' => [ + { names: ['--verbose', '-v'] }, + { + names: ['--branch', '-b'], + value: { + name: 'branch', + source: { + type: :command, + value: "$(git branch --format='%(refname:short)' 2>/dev/null)", + }, + }, + }, + ] + ) + end + + it 'returns token sources' do + expect(config.model[:tokens]).to eq( + 'directory' => { type: :builtin, value: 'directory' }, + 'branch' => { + type: :command, + value: "$(git branch --format='%(refname:short)' 2>/dev/null)", + } + ) + end + end +end diff --git a/spec/completely/zsh_spec.rb b/spec/completely/zsh_spec.rb index ef90968..8971a65 100644 --- a/spec/completely/zsh_spec.rb +++ b/spec/completely/zsh_spec.rb @@ -5,7 +5,7 @@ end end - let(:completions) { Completely::Completions.load 'spec/fixtures/basic.yaml' } + let(:completions) { Completely::Completions.load 'spec/fixtures/flat-config/basic.yaml' } let(:words) { 'completely generate ' } let(:tester_script) { completions.tester.tester_script words } let(:shell) { 'zsh' } diff --git a/spec/fixtures/basic.yaml b/spec/fixtures/flat-config/basic.yaml similarity index 100% rename from spec/fixtures/basic.yaml rename to spec/fixtures/flat-config/basic.yaml diff --git a/spec/fixtures/broken.yaml b/spec/fixtures/flat-config/broken.yaml similarity index 100% rename from spec/fixtures/broken.yaml rename to spec/fixtures/flat-config/broken.yaml diff --git a/spec/fixtures/complete_options.yaml b/spec/fixtures/flat-config/complete_options.yaml similarity index 100% rename from spec/fixtures/complete_options.yaml rename to spec/fixtures/flat-config/complete_options.yaml diff --git a/spec/fixtures/nested.yaml b/spec/fixtures/flat-config/nested.yaml similarity index 100% rename from spec/fixtures/nested.yaml rename to spec/fixtures/flat-config/nested.yaml diff --git a/spec/fixtures/only-spaces.yaml b/spec/fixtures/flat-config/only-spaces.yaml similarity index 100% rename from spec/fixtures/only-spaces.yaml rename to spec/fixtures/flat-config/only-spaces.yaml diff --git a/spec/fixtures/pattern-config/basic.yaml b/spec/fixtures/pattern-config/basic.yaml new file mode 100644 index 0000000..c0e3137 --- /dev/null +++ b/spec/fixtures/pattern-config/basic.yaml @@ -0,0 +1,14 @@ +patterns: + - mygit init [init options] + - mygit status|st [status options] + +options: + init: + - --bare + status: + - --verbose|-v + - --branch|-b + +tokens: + directory: directory + branch: $(git branch --format='%(refname:short)' 2>/dev/null) From 93cbeacc10f011b978c6b3a9d647f31084f5ecc9 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Tue, 30 Jun 2026 14:02:25 +0300 Subject: [PATCH 02/18] organize templates by config format --- lib/completely/commands/init.rb | 2 +- lib/completely/completions.rb | 2 +- .../templates/{ => flat-config}/sample-nested.yaml | 0 lib/completely/templates/{ => flat-config}/sample.yaml | 0 lib/completely/templates/{ => flat-config}/template.erb | 0 .../templates/{tester-template.erb => tester.bash.erb} | 0 lib/completely/tester.rb | 2 +- spec/completely/commands/generate_spec.rb | 4 ++-- spec/completely/commands/init_spec.rb | 6 +++--- spec/completely/commands/preview_spec.rb | 4 ++-- spec/completely/commands/test_spec.rb | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) rename lib/completely/templates/{ => flat-config}/sample-nested.yaml (100%) rename lib/completely/templates/{ => flat-config}/sample.yaml (100%) rename lib/completely/templates/{ => flat-config}/template.erb (100%) rename lib/completely/templates/{tester-template.erb => tester.bash.erb} (100%) diff --git a/lib/completely/commands/init.rb b/lib/completely/commands/init.rb index a9d9d1a..c7dbae3 100644 --- a/lib/completely/commands/init.rb +++ b/lib/completely/commands/init.rb @@ -33,7 +33,7 @@ def nested? def sample_path @sample_path ||= begin sample_name = nested? ? 'sample-nested' : 'sample' - File.expand_path "../templates/#{sample_name}.yaml", __dir__ + File.expand_path "../templates/flat-config/#{sample_name}.yaml", __dir__ end end end diff --git a/lib/completely/completions.rb b/lib/completely/completions.rb index acee7da..bad80c9 100644 --- a/lib/completely/completions.rb +++ b/lib/completely/completions.rb @@ -62,7 +62,7 @@ def patterns! end def template_path - @template_path ||= File.expand_path('templates/template.erb', __dir__) + @template_path ||= File.expand_path('templates/flat-config/template.erb', __dir__) end def template diff --git a/lib/completely/templates/sample-nested.yaml b/lib/completely/templates/flat-config/sample-nested.yaml similarity index 100% rename from lib/completely/templates/sample-nested.yaml rename to lib/completely/templates/flat-config/sample-nested.yaml diff --git a/lib/completely/templates/sample.yaml b/lib/completely/templates/flat-config/sample.yaml similarity index 100% rename from lib/completely/templates/sample.yaml rename to lib/completely/templates/flat-config/sample.yaml diff --git a/lib/completely/templates/template.erb b/lib/completely/templates/flat-config/template.erb similarity index 100% rename from lib/completely/templates/template.erb rename to lib/completely/templates/flat-config/template.erb diff --git a/lib/completely/templates/tester-template.erb b/lib/completely/templates/tester.bash.erb similarity index 100% rename from lib/completely/templates/tester-template.erb rename to lib/completely/templates/tester.bash.erb diff --git a/lib/completely/tester.rb b/lib/completely/tester.rb index 48d095f..2cbbe8e 100644 --- a/lib/completely/tester.rb +++ b/lib/completely/tester.rb @@ -37,7 +37,7 @@ def absolute_script_path end def template_path - @template_path ||= File.expand_path 'templates/tester-template.erb', __dir__ + @template_path ||= File.expand_path 'templates/tester.bash.erb', __dir__ end def template diff --git a/spec/completely/commands/generate_spec.rb b/spec/completely/commands/generate_spec.rb index 0e3f20b..a7f44c8 100644 --- a/spec/completely/commands/generate_spec.rb +++ b/spec/completely/commands/generate_spec.rb @@ -3,7 +3,7 @@ before do reset_tmp_dir - system 'cp lib/completely/templates/sample.yaml completely.yaml' + system 'cp lib/completely/templates/flat-config/sample.yaml completely.yaml' end after do @@ -43,7 +43,7 @@ context 'with COMPLETELY_CONFIG_PATH env var' do before do reset_tmp_dir - system 'cp lib/completely/templates/sample.yaml spec/tmp/hello.yml' + system 'cp lib/completely/templates/flat-config/sample.yaml spec/tmp/hello.yml' system 'rm -f completely.yaml' ENV['COMPLETELY_CONFIG_PATH'] = 'spec/tmp/hello.yml' end diff --git a/spec/completely/commands/init_spec.rb b/spec/completely/commands/init_spec.rb index 65a5cc4..b187b63 100644 --- a/spec/completely/commands/init_spec.rb +++ b/spec/completely/commands/init_spec.rb @@ -4,8 +4,8 @@ before { system 'rm -f completely.yaml' } after { system 'rm -f completely.yaml' } - let(:sample) { File.read 'lib/completely/templates/sample.yaml' } - let(:sample_nested) { File.read 'lib/completely/templates/sample-nested.yaml' } + let(:sample) { File.read 'lib/completely/templates/flat-config/sample.yaml' } + let(:sample_nested) { File.read 'lib/completely/templates/flat-config/sample-nested.yaml' } context 'with --help' do it 'shows long usage' do @@ -53,7 +53,7 @@ end context 'when the config file already exists' do - before { system 'cp lib/completely/templates/sample.yaml completely.yaml' } + before { system 'cp lib/completely/templates/flat-config/sample.yaml completely.yaml' } after { system 'rm -f completely.yaml' } it 'raises an error' do diff --git a/spec/completely/commands/preview_spec.rb b/spec/completely/commands/preview_spec.rb index 5359127..6b8685f 100644 --- a/spec/completely/commands/preview_spec.rb +++ b/spec/completely/commands/preview_spec.rb @@ -1,7 +1,7 @@ describe Commands::Preview do subject { described_class.new } - before { system 'cp lib/completely/templates/sample.yaml completely.yaml' } + before { system 'cp lib/completely/templates/flat-config/sample.yaml completely.yaml' } after { system 'rm -f completely.yaml' } context 'with --help' do @@ -27,7 +27,7 @@ context 'with COMPLETELY_CONFIG_PATH env var' do before do reset_tmp_dir - system 'cp lib/completely/templates/sample.yaml spec/tmp/hello.yml' + system 'cp lib/completely/templates/flat-config/sample.yaml spec/tmp/hello.yml' system 'rm -f completely.yaml' ENV['COMPLETELY_CONFIG_PATH'] = 'spec/tmp/hello.yml' end diff --git a/spec/completely/commands/test_spec.rb b/spec/completely/commands/test_spec.rb index 04a7787..740084d 100644 --- a/spec/completely/commands/test_spec.rb +++ b/spec/completely/commands/test_spec.rb @@ -2,7 +2,7 @@ subject { described_class.new } before do - system 'cp lib/completely/templates/sample.yaml completely.yaml' + system 'cp lib/completely/templates/flat-config/sample.yaml completely.yaml' ENV['COMPLETELY_CONFIG_PATH'] = nil end From 89a6257bbda1b83aba87c44b0b741022e37b4ef9 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Tue, 30 Jun 2026 15:00:59 +0300 Subject: [PATCH 03/18] add pattern confir completion generation --- lib/completely/completions.rb | 76 ++++++++++++- lib/completely/pattern_config.rb | 2 +- .../templates/pattern-config/template.erb | 105 ++++++++++++++++++ spec/completely/completions_spec.rb | 16 +++ .../completely/pattern_config_integration.yml | 35 ++++++ .../pattern_config_integration_spec.rb | 27 +++++ spec/completely/pattern_config_spec.rb | 11 +- spec/fixtures/pattern-config/basic.yaml | 2 +- .../pattern-config/implicit-builtin.yaml | 2 + .../pattern-config/invalid-programs.yaml | 3 + .../pattern-config/static-values.yaml | 8 ++ 11 files changed, 281 insertions(+), 6 deletions(-) create mode 100644 lib/completely/templates/pattern-config/template.erb create mode 100644 spec/completely/pattern_config_integration.yml create mode 100644 spec/completely/pattern_config_integration_spec.rb create mode 100644 spec/fixtures/pattern-config/implicit-builtin.yaml create mode 100644 spec/fixtures/pattern-config/invalid-programs.yaml create mode 100644 spec/fixtures/pattern-config/static-values.yaml diff --git a/lib/completely/completions.rb b/lib/completely/completions.rb index bad80c9..d5bf83d 100644 --- a/lib/completely/completions.rb +++ b/lib/completely/completions.rb @@ -29,6 +29,8 @@ def patterns end def valid? + return pattern_programs.uniq.one? if pattern_config? + pattern_prefixes.uniq.one? end @@ -62,7 +64,10 @@ def patterns! end def template_path - @template_path ||= File.expand_path('templates/flat-config/template.erb', __dir__) + @template_path ||= begin + template = pattern_config? ? 'pattern-config/template.erb' : 'flat-config/template.erb' + File.expand_path("templates/#{template}", __dir__) + end end def template @@ -70,7 +75,7 @@ def template end def command - @command ||= flat_config.keys.first.split.first + @command ||= pattern_config? ? config.model[:program] : flat_config.keys.first.split.first end def function_name @@ -91,5 +96,72 @@ def complete_options_line "#{options} " end + + def pattern_config? + config.is_a? PatternConfig + end + + def pattern_routes + config.model[:routes] + end + + def pattern_programs + pattern_routes.map { |route| route.dig(:words, 0, :name) } + end + + def pattern_root_words + pattern_routes.flat_map do |route| + word = route[:words][1] + word ? [word[:name], *word[:aliases]] : [] + end.uniq + end + + def pattern_route_id(route) + pattern_routes.index route + end + + def pattern_route_conditions(route) + route[:words][1..].map.with_index do |word, index| + names = [word[:name], *word[:aliases]] + names.map { |name| %["${non_options[#{index}]}" == "#{bash_escape name}"] }.join(' || ') + end + end + + def pattern_route_word_count(route) + route[:words].size - 1 + end + + def pattern_route_options(route) + route[:option_groups].flat_map do |name| + config.model[:options][name] || [] + end + end + + def pattern_route_option_words(route) + pattern_route_options(route).flat_map { |option| option[:names] }.uniq + end + + def pattern_options_with_values + config.model[:options].values.flatten.select { |option| option[:value] } + end + + def pattern_source_compgen(source) + case source[:type] + when :builtin + "-A #{bash_escape source[:value]}" + when :command + %[-W "#{bash_double_quote_escape source[:value]}"] + when :values + %[-W "#{bash_double_quote_escape source[:value].join ' '}"] + end + end + + def bash_escape(value) + value.to_s.gsub('\\', '\\\\\\').gsub('"', '\\"') + end + + def bash_double_quote_escape(value) + value.to_s.gsub('\\', '\\\\\\').gsub('"', '\\"') + end end end diff --git a/lib/completely/pattern_config.rb b/lib/completely/pattern_config.rb index fd15a6a..dd41470 100644 --- a/lib/completely/pattern_config.rb +++ b/lib/completely/pattern_config.rb @@ -16,7 +16,7 @@ def model end def flat_config - raise Error, 'Pattern config completion generation is not implemented yet' + raise Error, 'Pattern config cannot be converted to flat config' end private diff --git a/lib/completely/templates/pattern-config/template.erb b/lib/completely/templates/pattern-config/template.erb new file mode 100644 index 0000000..1f68ff3 --- /dev/null +++ b/lib/completely/templates/pattern-config/template.erb @@ -0,0 +1,105 @@ +# <%= "#{command} completion".ljust 56 %> -*- shell-script -*- + +# This bash completions script was generated by +# completely (https://github.com/bashly-framework/completely) +# Modifying it manually is not recommended + +<%= function_name %>_flag_expects_value() { + case "$1" in +% pattern_options_with_values.each do |option| + <%= option[:names].map { |name| bash_escape name }.join('|') %>) return 0 ;; +% end + esac + + return 1 +} + +<%= function_name %>() { + local cur=${COMP_WORDS[COMP_CWORD]} + local prev= + if ((COMP_CWORD > 0)); then + prev=${COMP_WORDS[$((COMP_CWORD - 1))]} + fi + + local completed=() + if ((COMP_CWORD > 1)); then + completed=("${COMP_WORDS[@]:1:$((COMP_CWORD - 1))}") + fi + + local non_options=() + local skip_next=0 + for word in "${completed[@]}"; do + if ((skip_next)); then + skip_next=0 + continue + fi + + if [[ "${word:0:1}" == "-" ]]; then + if <%= function_name %>_flag_expects_value "$word"; then + skip_next=1 + fi + continue + fi + + non_options+=("$word") + done + + local route_id= + local positional_index=0 +% pattern_routes.each do |route| +% conditions = pattern_route_conditions(route) +% next if conditions.empty? + if (( ${#non_options[@]} >= <%= pattern_route_word_count route %> )) && +% conditions.each_with_index do |condition, index| + [[ <%= condition %> ]]<%= index == conditions.size - 1 ? '' : ' &&' %> +% end + then + route_id=<%= pattern_route_id route %> + positional_index=$((${#non_options[@]} - <%= pattern_route_word_count route %>)) + fi + +% end + COMPREPLY=() + + if [[ -z "$route_id" ]]; then + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "<%= bash_double_quote_escape pattern_root_words.join(' ') %>" -- "$cur") + return + fi + + case "$route_id:$prev" in +% pattern_routes.each do |route| +% pattern_route_options(route).select { |option| option[:value] }.each do |option| + <%= pattern_route_id route %>:<%= option[:names].map { |name| bash_escape name }.join("|#{pattern_route_id route}:") %>) + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen <%= pattern_source_compgen option[:value][:source] %> -- "$cur") + return + ;; +% end +% end + esac + + case "$route_id" in +% pattern_routes.each do |route| + <%= pattern_route_id route %>) + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "<%= bash_double_quote_escape pattern_route_option_words(route).join(' ') %>" -- "$cur") + ;; +% end + esac + + if [[ "${cur:0:1}" == "-" ]]; then + return + fi + + case "$route_id:$positional_index" in +% pattern_routes.each do |route| +% route[:positionals].each_with_index do |positional, index| + <%= pattern_route_id route %>:<%= index %>) + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen <%= pattern_source_compgen positional[:source] %> -- "$cur") + return + ;; +% end +% end + esac +} && + complete -F <%= function_name %> <%= command %> + +# ex: filetype=sh diff --git a/spec/completely/completions_spec.rb b/spec/completely/completions_spec.rb index 81d89ae..9732963 100644 --- a/spec/completely/completions_spec.rb +++ b/spec/completely/completions_spec.rb @@ -25,6 +25,22 @@ expect(subject).not_to be_valid end end + + context 'with pattern config' do + let(:path) { 'spec/fixtures/pattern-config/basic.yaml' } + + it 'returns true when all patterns use the same program' do + expect(subject).to be_valid + end + end + + context 'with pattern config using different programs' do + let(:path) { 'spec/fixtures/pattern-config/invalid-programs.yaml' } + + it 'returns false' do + expect(subject).not_to be_valid + end + end end describe '#patterns' do diff --git a/spec/completely/pattern_config_integration.yml b/spec/completely/pattern_config_integration.yml new file mode 100644 index 0000000..43fda04 --- /dev/null +++ b/spec/completely/pattern_config_integration.yml @@ -0,0 +1,35 @@ +basic: +- compline: "mygit " + expected: [init, st, status] + +- compline: "mygit stat" + expected: [status] + +- compline: "mygit status -" + expected: [--branch, --verbose, -b, -v] + +- compline: "mygit init " + expected: [--bare, another-dir, dir with spaces, dummy-dir] + +- compline: "mygit st -" + expected: [--branch, --verbose, -b, -v] + +- compline: "mygit status --branch " + expected: [dev, main] + +- compline: "mygit status -b m" + expected: [main] + +- compline: "mygit init d" + expected: [dir with spaces, dummy-dir] + +- compline: "mygit init dummy-dir -" + expected: [--bare] + +static-values: +- compline: "mygit deploy st" + expected: [staging] + +implicit-builtin: +- compline: "mygit init d" + expected: [dir with spaces, dummy-dir] diff --git a/spec/completely/pattern_config_integration_spec.rb b/spec/completely/pattern_config_integration_spec.rb new file mode 100644 index 0000000..4e76631 --- /dev/null +++ b/spec/completely/pattern_config_integration_spec.rb @@ -0,0 +1,27 @@ +describe 'generated pattern config script' do + subject { Completions.load config_path } + + let(:config_path) { File.expand_path("../fixtures/pattern-config/#{fixture}.yaml", __dir__) } + + let(:response) do + Dir.chdir 'spec/fixtures/integration' do + subject.tester.test(compline).sort + end + end + + config = YAML.load_file 'spec/completely/pattern_config_integration.yml' + + config.each do |fixture, use_cases| + use_cases.each do |use_case| + describe "#{fixture} ▶ '#{use_case['compline']}'" do + let(:fixture) { fixture } + let(:compline) { use_case['compline'] } + let(:expected) { use_case['expected'] } + + it "returns #{use_case['expected'].join ' '}" do + expect(response).to eq expected + end + end + end + end +end diff --git a/spec/completely/pattern_config_spec.rb b/spec/completely/pattern_config_spec.rb index 9cd9b39..1907b43 100644 --- a/spec/completely/pattern_config_spec.rb +++ b/spec/completely/pattern_config_spec.rb @@ -45,7 +45,7 @@ name: 'branch', source: { type: :command, - value: "$(git branch --format='%(refname:short)' 2>/dev/null)", + value: '$(echo main dev)', }, }, }, @@ -58,9 +58,16 @@ 'directory' => { type: :builtin, value: 'directory' }, 'branch' => { type: :command, - value: "$(git branch --format='%(refname:short)' 2>/dev/null)", + value: '$(echo main dev)', } ) end end + + describe '#flat_config' do + it 'does not convert pattern config to flat config' do + expect { config.flat_config } + .to raise_error Completely::Error, 'Pattern config cannot be converted to flat config' + end + end end diff --git a/spec/fixtures/pattern-config/basic.yaml b/spec/fixtures/pattern-config/basic.yaml index c0e3137..7c8c645 100644 --- a/spec/fixtures/pattern-config/basic.yaml +++ b/spec/fixtures/pattern-config/basic.yaml @@ -11,4 +11,4 @@ options: tokens: directory: directory - branch: $(git branch --format='%(refname:short)' 2>/dev/null) + branch: $(echo main dev) diff --git a/spec/fixtures/pattern-config/implicit-builtin.yaml b/spec/fixtures/pattern-config/implicit-builtin.yaml new file mode 100644 index 0000000..9ef7472 --- /dev/null +++ b/spec/fixtures/pattern-config/implicit-builtin.yaml @@ -0,0 +1,2 @@ +patterns: + - mygit init diff --git a/spec/fixtures/pattern-config/invalid-programs.yaml b/spec/fixtures/pattern-config/invalid-programs.yaml new file mode 100644 index 0000000..675cf76 --- /dev/null +++ b/spec/fixtures/pattern-config/invalid-programs.yaml @@ -0,0 +1,3 @@ +patterns: + - mygit init + - other status diff --git a/spec/fixtures/pattern-config/static-values.yaml b/spec/fixtures/pattern-config/static-values.yaml new file mode 100644 index 0000000..53589b7 --- /dev/null +++ b/spec/fixtures/pattern-config/static-values.yaml @@ -0,0 +1,8 @@ +patterns: + - mygit deploy + +tokens: + environment: + - dev + - staging + - production From 977ca5948f0a541d98578155539ec1260071b109 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Tue, 30 Jun 2026 15:19:33 +0300 Subject: [PATCH 04/18] improve error handling --- lib/completely/pattern_config.rb | 43 +++++++++++++++++-- .../completely/pattern_config_integration.yml | 21 +++++++-- spec/completely/pattern_config_spec.rb | 16 +++++++ spec/fixtures/pattern-config/interleaved.yaml | 12 ++++++ .../pattern-config/missing-option.yaml | 2 + ...plicit-builtin.yaml => missing-token.yaml} | 0 6 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 spec/fixtures/pattern-config/interleaved.yaml create mode 100644 spec/fixtures/pattern-config/missing-option.yaml rename spec/fixtures/pattern-config/{implicit-builtin.yaml => missing-token.yaml} (100%) diff --git a/lib/completely/pattern_config.rb b/lib/completely/pattern_config.rb index dd41470..2c92d61 100644 --- a/lib/completely/pattern_config.rb +++ b/lib/completely/pattern_config.rb @@ -7,6 +7,8 @@ def initialize(config) end def model + validate! + @model ||= { program: program, routes: routes, @@ -53,6 +55,39 @@ def tokens end end + def validate! + missing_options = referenced_options - option_groups.keys + missing_tokens = referenced_tokens - token_sources.keys + + errors = [] + errors << "Unknown option group: #{missing_options.join ', '}" if missing_options.any? + errors << "Unknown token: #{missing_tokens.join ', '}" if missing_tokens.any? + raise ParseError, errors.join("\n") if errors.any? + end + + def referenced_options + patterns.flat_map do |pattern| + pattern_parts(pattern).filter_map { |part| option_group_name(part) if option_group?(part) } + end.uniq + end + + def referenced_tokens + pattern_tokens + option_tokens + end + + def pattern_tokens + patterns.flat_map do |pattern| + pattern_parts(pattern).filter_map { |part| token_name(part) if token?(part) } + end + end + + def option_tokens + option_groups.values.flatten.filter_map do |entry| + _flag_part, value_part = entry.split + token_name(value_part) if value_part + end + end + def parse_pattern(pattern) result = { words: [], option_groups: [], positionals: [] } @@ -88,7 +123,7 @@ def parse_option(entry) end def parse_token(part) - name = part[/\A<(.+)>\z/, 1] + name = token_name part { name: name, source: parse_source(name, token_sources[name]) } end @@ -100,8 +135,6 @@ def parse_source(name, source) { type: :command, value: source } when String { type: :builtin, value: source } - else - { type: :builtin, value: name } end end @@ -116,5 +149,9 @@ def option_group_name(part) def token?(part) part.start_with?('<') && part.end_with?('>') end + + def token_name(part) + part[/\A<(.+)>\z/, 1] + end end end diff --git a/spec/completely/pattern_config_integration.yml b/spec/completely/pattern_config_integration.yml index 43fda04..afc37a7 100644 --- a/spec/completely/pattern_config_integration.yml +++ b/spec/completely/pattern_config_integration.yml @@ -30,6 +30,21 @@ static-values: - compline: "mygit deploy st" expected: [staging] -implicit-builtin: -- compline: "mygit init d" - expected: [dir with spaces, dummy-dir] +interleaved: +- compline: "cli " + expected: [clone] + +- compline: "cli clone " + expected: [--protocol, --verbose, -p, -v, source1, source2] + +- compline: "cli clone -v " + expected: [--protocol, --verbose, -p, -v, source1, source2] + +- compline: "cli clone -v source1 " + expected: [--protocol, --verbose, -p, -v, dest1, dest2] + +- compline: "cli clone -v source1 -p " + expected: [https, ssh] + +- compline: "cli clone -v source1 -p ssh " + expected: [--protocol, --verbose, -p, -v, dest1, dest2] diff --git a/spec/completely/pattern_config_spec.rb b/spec/completely/pattern_config_spec.rb index 1907b43..3a8c9e2 100644 --- a/spec/completely/pattern_config_spec.rb +++ b/spec/completely/pattern_config_spec.rb @@ -70,4 +70,20 @@ .to raise_error Completely::Error, 'Pattern config cannot be converted to flat config' end end + + context 'with a missing option group' do + subject(:config) { Config.load 'spec/fixtures/pattern-config/missing-option.yaml' } + + it 'raises ParseError' do + expect { config.model }.to raise_error Completely::ParseError, 'Unknown option group: missing' + end + end + + context 'with a missing token' do + subject(:config) { Config.load 'spec/fixtures/pattern-config/missing-token.yaml' } + + it 'raises ParseError' do + expect { config.model }.to raise_error Completely::ParseError, 'Unknown token: directory' + end + end end diff --git a/spec/fixtures/pattern-config/interleaved.yaml b/spec/fixtures/pattern-config/interleaved.yaml new file mode 100644 index 0000000..3ae45f7 --- /dev/null +++ b/spec/fixtures/pattern-config/interleaved.yaml @@ -0,0 +1,12 @@ +patterns: + - cli clone [download] + +options: + download: + - -p|--protocol + - -v|--verbose + +tokens: + source: [source1, source2] + dest: [dest1, dest2] + protocol: [ssh, https] diff --git a/spec/fixtures/pattern-config/missing-option.yaml b/spec/fixtures/pattern-config/missing-option.yaml new file mode 100644 index 0000000..b72dc28 --- /dev/null +++ b/spec/fixtures/pattern-config/missing-option.yaml @@ -0,0 +1,2 @@ +patterns: + - mygit init [missing] diff --git a/spec/fixtures/pattern-config/implicit-builtin.yaml b/spec/fixtures/pattern-config/missing-token.yaml similarity index 100% rename from spec/fixtures/pattern-config/implicit-builtin.yaml rename to spec/fixtures/pattern-config/missing-token.yaml From c9732ff8d7820b0fc2ff38c4831f8bd642bfb86d Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Tue, 30 Jun 2026 15:22:14 +0300 Subject: [PATCH 05/18] rubocop --- lib/completely/config.rb | 2 +- lib/completely/pattern_config.rb | 6 +- spec/completely/pattern_config_spec.rb | 81 ++++++++++++-------------- 3 files changed, 42 insertions(+), 47 deletions(-) diff --git a/lib/completely/config.rb b/lib/completely/config.rb index e9bfa1d..1f94fb1 100644 --- a/lib/completely/config.rb +++ b/lib/completely/config.rb @@ -19,7 +19,7 @@ def build(config) end def pattern_config?(config) - config.is_a?(Hash) && config.key?('patterns') + config.is_a?(Hash) && config.has_key?('patterns') end end end diff --git a/lib/completely/pattern_config.rb b/lib/completely/pattern_config.rb index 2c92d61..d4a373c 100644 --- a/lib/completely/pattern_config.rb +++ b/lib/completely/pattern_config.rb @@ -11,9 +11,9 @@ def model @model ||= { program: program, - routes: routes, + routes: routes, options: options, - tokens: tokens, + tokens: tokens, } end @@ -127,7 +127,7 @@ def parse_token(part) { name: name, source: parse_source(name, token_sources[name]) } end - def parse_source(name, source) + def parse_source(_name, source) case source when Array { type: :values, value: source } diff --git a/spec/completely/pattern_config_spec.rb b/spec/completely/pattern_config_spec.rb index 3a8c9e2..7275494 100644 --- a/spec/completely/pattern_config_spec.rb +++ b/spec/completely/pattern_config_spec.rb @@ -6,58 +6,53 @@ expect(config.model[:program]).to eq 'mygit' end - it 'returns routes from the completion patterns' do - expect(config.model[:routes]).to eq [ - { - words: [ - { name: 'mygit', aliases: [] }, - { name: 'init', aliases: [] }, - ], - option_groups: ['init'], - positionals: [ - { - name: 'directory', - source: { type: :builtin, value: 'directory' }, - }, - ], - }, - { - words: [ - { name: 'mygit', aliases: [] }, - { name: 'status', aliases: ['st'] }, - ], - option_groups: ['status'], - positionals: [], - }, + it 'returns route words' do + words = config.model[:routes].map { |route| route[:words] } + + expect(words).to eq [ + [{ name: 'mygit', aliases: [] }, { name: 'init', aliases: [] }], + [{ name: 'mygit', aliases: [] }, { name: 'status', aliases: ['st'] }], ] end - it 'returns option groups' do - expect(config.model[:options]).to eq( - 'init' => [ - { names: ['--bare'] }, - ], - 'status' => [ - { names: ['--verbose', '-v'] }, - { - names: ['--branch', '-b'], - value: { - name: 'branch', - source: { - type: :command, - value: '$(echo main dev)', - }, - }, - }, - ] + it 'returns route option groups' do + option_groups = config.model[:routes].map { |route| route[:option_groups] } + + expect(option_groups).to eq [['init'], ['status']] + end + + it 'returns route positionals' do + positionals = config.model[:routes].map { |route| route[:positionals] } + + expect(positionals).to eq [ + [{ name: 'directory', source: { type: :builtin, value: 'directory' } }], + [], + ] + end + + it 'returns init options' do + expect(config.model[:options]['init']).to eq [{ names: ['--bare'] }] + end + + it 'returns status flag options' do + expect(config.model[:options]['status'].first).to eq({ names: ['--verbose', '-v'] }) + end + + it 'returns status options with values' do + expect(config.model[:options]['status'].last).to eq( + names: ['--branch', '-b'], + value: { + name: 'branch', + source: { type: :command, value: '$(echo main dev)' }, + } ) end it 'returns token sources' do expect(config.model[:tokens]).to eq( 'directory' => { type: :builtin, value: 'directory' }, - 'branch' => { - type: :command, + 'branch' => { + type: :command, value: '$(echo main dev)', } ) From 833e26c2a2c57be7ed38da01d1e14778a8b3ae80 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Tue, 30 Jun 2026 16:04:04 +0300 Subject: [PATCH 06/18] add pattern init format and validation --- lib/completely/commands/init.rb | 21 ++++++++++---- lib/completely/pattern_config.rb | 8 +++-- .../templates/pattern-config/sample.yaml | 21 ++++++++++++++ spec/approvals/cli/init/flat | 1 + spec/approvals/cli/init/help | 6 ++-- spec/approvals/cli/init/invalid-format | 1 + spec/approvals/cli/init/pattern | 1 + spec/completely/commands/init_spec.rb | 29 ++++++++++++++++--- .../completely/pattern_config_integration.yml | 4 +++ .../fixtures/pattern-config/spaced-token.yaml | 9 ++++++ 10 files changed, 86 insertions(+), 15 deletions(-) create mode 100644 lib/completely/templates/pattern-config/sample.yaml create mode 100644 spec/approvals/cli/init/flat create mode 100644 spec/approvals/cli/init/invalid-format create mode 100644 spec/approvals/cli/init/pattern create mode 100644 spec/fixtures/pattern-config/spaced-token.yaml diff --git a/lib/completely/commands/init.rb b/lib/completely/commands/init.rb index c7dbae3..3c5d922 100644 --- a/lib/completely/commands/init.rb +++ b/lib/completely/commands/init.rb @@ -5,10 +5,10 @@ module Commands class Init < Base help 'Create a new sample YAML configuration file' - usage 'completely init [--nested] [CONFIG_PATH]' + usage 'completely init [--format FORMAT] [CONFIG_PATH]' usage 'completely init (-h|--help)' - option '-n --nested', 'Generate a nested configuration' + option '-f --format FORMAT', 'Configuration format: pattern, flat, or nested [default: pattern]' param_config_path environment_config_path @@ -26,16 +26,25 @@ def sample @sample ||= File.read sample_path end - def nested? - args['--nested'] + def format + @format ||= args['--format'] || 'pattern' end def sample_path @sample_path ||= begin - sample_name = nested? ? 'sample-nested' : 'sample' - File.expand_path "../templates/flat-config/#{sample_name}.yaml", __dir__ + raise Error, "Invalid format: #{format}" unless %w[flat nested pattern].include? format + + File.expand_path "../templates/#{sample_filename}", __dir__ end end + + def sample_filename + { + 'flat' => 'flat-config/sample.yaml', + 'nested' => 'flat-config/sample-nested.yaml', + 'pattern' => 'pattern-config/sample.yaml', + }[format] + end end end end diff --git a/lib/completely/pattern_config.rb b/lib/completely/pattern_config.rb index d4a373c..455612d 100644 --- a/lib/completely/pattern_config.rb +++ b/lib/completely/pattern_config.rb @@ -83,7 +83,7 @@ def pattern_tokens def option_tokens option_groups.values.flatten.filter_map do |entry| - _flag_part, value_part = entry.split + _flag_part, value_part = option_parts entry token_name(value_part) if value_part end end @@ -114,7 +114,7 @@ def pattern_parts(pattern) end def parse_option(entry) - flag_part, value_part = entry.split + flag_part, value_part = option_parts entry names = flag_part.split('|') result = { names: names } @@ -122,6 +122,10 @@ def parse_option(entry) result end + def option_parts(entry) + entry.scan(/<[^>]+>|\S+/) + end + def parse_token(part) name = token_name part { name: name, source: parse_source(name, token_sources[name]) } diff --git a/lib/completely/templates/pattern-config/sample.yaml b/lib/completely/templates/pattern-config/sample.yaml new file mode 100644 index 0000000..138ddaf --- /dev/null +++ b/lib/completely/templates/pattern-config/sample.yaml @@ -0,0 +1,21 @@ +patterns: + - mygit [root options] + - mygit init [init options] + - mygit status [status options] + +options: + root: + - -h|--help + - -v|--version + init: + - --bare + status: + - --help + - --branch|-b + - --format + - --verbose + +tokens: + directory: directory + branch: $(git branch --format='%(refname:short)' 2>/dev/null) + format: [short, long] diff --git a/spec/approvals/cli/init/flat b/spec/approvals/cli/init/flat new file mode 100644 index 0000000..665ba20 --- /dev/null +++ b/spec/approvals/cli/init/flat @@ -0,0 +1 @@ +Saved completely.yaml diff --git a/spec/approvals/cli/init/help b/spec/approvals/cli/init/help index 69f3d38..2702671 100644 --- a/spec/approvals/cli/init/help +++ b/spec/approvals/cli/init/help @@ -1,12 +1,12 @@ Create a new sample YAML configuration file Usage: - completely init [--nested] [CONFIG_PATH] + completely init [--format FORMAT] [CONFIG_PATH] completely init (-h|--help) Options: - -n --nested - Generate a nested configuration + -f --format FORMAT + Configuration format: pattern, flat, or nested [default: pattern] -h --help Show this help diff --git a/spec/approvals/cli/init/invalid-format b/spec/approvals/cli/init/invalid-format new file mode 100644 index 0000000..cb0130e --- /dev/null +++ b/spec/approvals/cli/init/invalid-format @@ -0,0 +1 @@ +# \ No newline at end of file diff --git a/spec/approvals/cli/init/pattern b/spec/approvals/cli/init/pattern new file mode 100644 index 0000000..665ba20 --- /dev/null +++ b/spec/approvals/cli/init/pattern @@ -0,0 +1 @@ +Saved completely.yaml diff --git a/spec/completely/commands/init_spec.rb b/spec/completely/commands/init_spec.rb index b187b63..01d5a30 100644 --- a/spec/completely/commands/init_spec.rb +++ b/spec/completely/commands/init_spec.rb @@ -6,6 +6,7 @@ let(:sample) { File.read 'lib/completely/templates/flat-config/sample.yaml' } let(:sample_nested) { File.read 'lib/completely/templates/flat-config/sample-nested.yaml' } + let(:sample_pattern) { File.read 'lib/completely/templates/pattern-config/sample.yaml' } context 'with --help' do it 'shows long usage' do @@ -16,24 +17,44 @@ context 'without arguments' do it 'creates a new sample file named completely.yaml' do expect { subject.execute %w[init] }.to output_approval('cli/init/no-args') + expect(File.read 'completely.yaml').to eq sample_pattern + end + end + + context 'with --format flat' do + it 'creates a sample using the flat configuration' do + expect { subject.execute %w[init --format flat] }.to output_approval('cli/init/flat') expect(File.read 'completely.yaml').to eq sample end end - context 'with --nested' do + context 'with --format nested' do it 'creates a sample using the nested configuration' do - expect { subject.execute %w[init --nested] }.to output_approval('cli/init/nested') + expect { subject.execute %w[init --format nested] }.to output_approval('cli/init/nested') expect(File.read 'completely.yaml').to eq sample_nested end end + context 'with --format pattern' do + it 'creates a sample using the pattern configuration' do + expect { subject.execute %w[init --format pattern] }.to output_approval('cli/init/pattern') + expect(File.read 'completely.yaml').to eq sample_pattern + end + end + + context 'with an invalid format' do + it 'raises an error' do + expect { subject.execute %w[init --format invalid] }.to raise_approval('cli/init/invalid-format') + end + end + context 'with CONFIG_PATH' do before { reset_tmp_dir } it 'creates a new sample file with the requested name' do expect { subject.execute %w[init spec/tmp/in.yaml] } .to output_approval('cli/init/custom-path') - expect(File.read 'spec/tmp/in.yaml').to eq sample + expect(File.read 'spec/tmp/in.yaml').to eq sample_pattern end end @@ -48,7 +69,7 @@ it 'creates a new sample file with the requested name' do expect { subject.execute %w[init] } .to output_approval('cli/init/custom-path-env') - expect(File.read 'spec/tmp/hello.yml').to eq sample + expect(File.read 'spec/tmp/hello.yml').to eq sample_pattern end end diff --git a/spec/completely/pattern_config_integration.yml b/spec/completely/pattern_config_integration.yml index afc37a7..73da509 100644 --- a/spec/completely/pattern_config_integration.yml +++ b/spec/completely/pattern_config_integration.yml @@ -30,6 +30,10 @@ static-values: - compline: "mygit deploy st" expected: [staging] +spaced-token: +- compline: "mygit status --format l" + expected: [long] + interleaved: - compline: "cli " expected: [clone] diff --git a/spec/fixtures/pattern-config/spaced-token.yaml b/spec/fixtures/pattern-config/spaced-token.yaml new file mode 100644 index 0000000..b1ea106 --- /dev/null +++ b/spec/fixtures/pattern-config/spaced-token.yaml @@ -0,0 +1,9 @@ +patterns: + - mygit status [status options] + +options: + status: + - --format + +tokens: + status format: [short, long] From 26b01b87cbfb9f46f67d3de26115a754e8c9cc8b Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Tue, 30 Jun 2026 16:27:04 +0300 Subject: [PATCH 07/18] update readme and fix flag completion restrictor --- README.md | 303 ++++++++++-------- .../templates/pattern-config/template.erb | 15 +- .../completely/pattern_config_integration.yml | 10 +- 3 files changed, 176 insertions(+), 152 deletions(-) diff --git a/README.md b/README.md index 6203e99..7b557af 100644 --- a/README.md +++ b/README.md @@ -39,20 +39,15 @@ $ alias completely='docker run --rm -it --user $(id -u):$(id -g) --volume "$PWD: ## Configuration syntax -Completely works with a simple YAML configuration file as input, and generates -a bash completions script as output. +Completely works with a YAML configuration file as input, and generates a bash +completions script as output. -The configuration file is built of blocks that look like this: +There are three configuration formats: -```yaml -pattern: -- --argument -- --param -- command -``` - -Each pattern contains an array of words (or functions) that will be suggested -for the auto complete process. +- **Pattern config**: Recommended for new projects. It describes command shapes, + option groups, and value sources explicitly. +- **Flat config**: The original simple pattern-to-suggestions format. +- **Nested config**: A nested spelling of the flat config format. You can save a sample YAML file by running: @@ -60,165 +55,138 @@ You can save a sample YAML file by running: $ completely init ``` -This will generate a file named `completely.yaml` with this content: - -```yaml -mygit: -- -h -- -v -- --help -- --version -- init -- status - -mygit init: -- --bare -- - -mygit status: -- --help -- --verbose -- --branch -- -b - -mygit status*--branch: &branches -- $(git branch --format='%(refname:short)' 2>/dev/null) +This creates a `completely.yaml` file using the recommended pattern config +format. You can also choose a format explicitly: -mygit status*-b: *branches +```bash +$ completely init --format pattern +$ completely init --format flat +$ completely init --format nested ``` -Each pattern in this configuration file will be checked against the user's -input, and if the input matches the pattern, the list that follows it will be -suggested as completions. - -Note that the suggested completions will not show flags (strings that start with -a hyphen `-`) unless the input ends with a hyphen. - -To generate the bash script, simply run: +To generate the bash script, run: ```bash $ completely generate -# or, to just preview it without saving: +# or, to preview it without saving: $ completely preview ``` -For more options (like setting input/output path), run: +For more options, run: ```bash $ completely --help ``` -### Suggesting files, directories and other bash built-ins +### Pattern config -In addition to specifying a simple array of completion words, you may use -the special syntax `<..>` to suggest more advanced functions. +Pattern config is the recommended format for new completion files. ```yaml -pattern: -- -- +patterns: + - mygit [root options] + - mygit init [init options] + - mygit status [status options] + +options: + root: + - -h|--help + - -v|--version + init: + - --bare + status: + - --help + - --branch|-b + - --format + - --verbose + +tokens: + directory: directory + branch: $(git branch --format='%(refname:short)' 2>/dev/null) + format: [short, long] ``` -These suggestions will add the list of files and directories -(when `` is used) or just directories (when `` is used) to -the list of suggestions. +The `patterns` section describes valid command shapes: -You may use any of the below keywords to add additional suggestions: +- Plain words are command words, for example `mygit`, `init`, and `status`. +- Command aliases can be written with `|`, for example `status|st`. +- `[name options]` references `options.name`. `[name]` is also accepted. +- `` references `tokens.token`. -| Keyword | Meaning -|---------------|--------------------- -| `` | Alias names -| `` | Array variable names -| `` | Readline key binding names -| `` | Names of shell builtin commands -| `` | Command names -| `` | Directory names -| `` | Names of disabled shell builtins -| `` | Names of enabled shell builtins -| `` | Names of exported shell variables -| `` | File names -| `` | Names of shell functions -| `` | Group names -| `` | Help topics as accepted by the help builtin -| `` | Hostnames, as taken from the file specified by the HOSTFILE shell variable -| `` | Job names -| `` | Shell reserved words -| `` | Names of running jobs -| `` | Service names -| `` | Signal names -| `` | Names of stopped jobs -| `` | User names -| `` | Names of all shell variables - -For those interested in the technical details, any word between `<...>` will -simply be added using the [`compgen -A action`][compgen] function, so you can -in fact use any of its supported arguments. - -### Suggesting custom dynamic suggestions - -You can also use any command that outputs a whitespace-delimited list as a -suggestions list, by wrapping it in `$(..)`. For example, in order to add git -branches to your suggestions, use the following: +The `options` section defines option groups: ```yaml -mygit: -- $(git branch --format='%(refname:short)' 2>/dev/null) +options: + status: + - --help + - --branch|-b + - --verbose ``` -The `2> /dev/null` is used so that if the command is executed in a directory -without a git repository, it will still behave as expected. +An option can be a plain flag, aliases separated with `|`, or a flag that +expects a value token. -### Completion scope and limitations +The `tokens` section defines completion sources. Each token value can be one of +these forms: -- Completion words are treated as whitespace-delimited tokens. -- Literal completion phrases that contain spaces are not supported as a single completion item. -- Quotes and other special shell characters in literal completion words are not escaped automatically. -- Dynamic `$(...)` completion commands should output plain whitespace-delimited words. +```yaml +tokens: + directory: directory + branch: $(git branch --format='%(refname:short)' 2>/dev/null) + format: [short, long] +``` -### Suggesting flag arguments +- A plain string such as `directory` uses a bash built-in completion action. +- A `$(...)` string runs a command and uses its whitespace-delimited output. +- An array provides a fixed list of completion words. -Adding a `*` wildcard in the middle of a pattern can be useful for suggesting -arguments for flags. For example: +Every `[name]` option group and every `` used by patterns or options must +be defined. This keeps typos from generating broken completion scripts. -```yaml -mygit checkout: -- --branch -- -b +### Flat config -mygit checkout*--branch: -- $(git branch --format='%(refname:short)' 2>/dev/null) +Flat config is the original Completely format. It is simpler, and remains +supported. -mygit checkout*-b: -- $(git branch --format='%(refname:short)' 2>/dev/null) -``` +```yaml +mygit: +- -h +- -v +- --help +- --version +- init +- status -The above will suggest git branches for commands that end with `-b` or `--branch`. -To avoid code duplication, you may use YAML aliases, so the above can also be -written like this: +mygit init: +- --bare +- -```yaml -mygit checkout: +mygit status: +- --help +- --verbose - --branch - -b -mygit checkout*--branch: &branches +mygit status*--branch: &branches - $(git branch --format='%(refname:short)' 2>/dev/null) -mygit checkout*-b: *branches +mygit status*-b: *branches ``` -### Alternative nested syntax +Each pattern is checked against the user's input. If the input matches the +pattern, the list that follows it is suggested as completions. -Completely also supports an alternative nested syntax. You can generate this -example by running: +Suggested completions do not show flags (strings that start with a hyphen `-`) +unless the input ends with a hyphen. -```bash -$ completely init --nested -``` +Adding a `*` wildcard in the middle of a pattern can be used for suggesting flag +arguments. In the example above, branches are suggested after `--branch` or `-b`. + +### Nested config -The example configuration below will generate the exact same script as the one -shown at the beginning of this document. +Nested config is an alternate spelling of flat config. It generates the same +completion behavior as the flat example above. ```yaml mygit: @@ -237,18 +205,74 @@ mygit: - +-b: *branches ``` -The rules here are as follows: +The rules are: + +- Each pattern can have a mixed array of strings and hashes. +- Strings and hash keys are used as completion strings for that pattern. +- Hashes can contain a nested mixed array of the same structure. +- Hash keys are appended to the parent prefix. In the example above, the `init` + hash creates the pattern `mygit init`. +- To provide a wildcard such as `mygit status*--branch`, prefix the hash key with + `+` or `*`, for example `+--branch` or `"*--branch"`. When using `*`, quote the + key because asterisks have special meaning in YAML. + +### Completion sources -- Each pattern (e.g., `mygit`) can have a mixed array of strings and hashes. -- Strings and hash keys (e.e., `--help` and `init` respectively) will be used - as completion strings for that pattern. -- Hashes may contain a nested mixed array of the same structure. -- When a hash is provided, the hash key will be appended to the parent prefix. - In the example above, the `init` hash will create the pattern `mygit init`. -- In order to provide a wildcard (like `mygit status*--branch` in the standard - configuration syntax), you can provide either a `*` or a `+` prefix to the - hash key (e.g., `+--branch` or `"*--branch"`). Note that when using a `*`, - the hash key must be quoted since asterisks have special meaning in YAML. +Pattern config and the original flat/nested formats use the same underlying bash +completion sources, but they spell built-ins differently. + +Pattern config uses named tokens: + +```yaml +tokens: + file: file + directory: directory + branch: $(git branch --format='%(refname:short)' 2>/dev/null) + format: [short, long] +``` + +Flat and nested configs use completion words directly: + +```yaml +mygit init: +- +- +- $(git branch --format='%(refname:short)' 2>/dev/null) +``` + +The built-in names map to `compgen -A` actions: + +| Built-in | Meaning +|---------------|--------------------- +| `alias` | Alias names +| `arrayvar` | Array variable names +| `binding` | Readline key binding names +| `builtin` | Names of shell builtin commands +| `command` | Command names +| `directory` | Directory names +| `disabled` | Names of disabled shell builtins +| `enabled` | Names of enabled shell builtins +| `export` | Names of exported shell variables +| `file` | File names +| `function` | Names of shell functions +| `group` | Group names +| `helptopic` | Help topics as accepted by the help builtin +| `hostname` | Hostnames, as taken from the file specified by the HOSTFILE shell variable +| `job` | Job names +| `keyword` | Shell reserved words +| `running` | Names of running jobs +| `service` | Service names +| `signal` | Signal names +| `stopped` | Names of stopped jobs +| `user` | User names +| `variable` | Names of all shell variables + +### Completion scope and limitations + +- Completion words are treated as whitespace-delimited tokens. +- Literal completion phrases that contain spaces are not supported as a single completion item. +- Quotes and other special shell characters in literal completion words are not escaped automatically. +- Dynamic `$(...)` completion commands should output plain whitespace-delimited words. ## Using the generated completion scripts @@ -327,8 +351,9 @@ autoload -Uz +X bashcompinit && bashcompinit ## Customizing the `complete` command In case you wish to customize the `complete` command call in the generated -script, you can do so by adding any additional flags to the `completely.yaml` -configuration file using the special `completely_options` key. For example: +script for flat or nested config, you can do so by adding any additional flags +to the `completely.yaml` configuration file using the special +`completely_options` key. For example: ```yaml completely_options: diff --git a/lib/completely/templates/pattern-config/template.erb b/lib/completely/templates/pattern-config/template.erb index 1f68ff3..6b93e30 100644 --- a/lib/completely/templates/pattern-config/template.erb +++ b/lib/completely/templates/pattern-config/template.erb @@ -77,16 +77,15 @@ % end esac - case "$route_id" in + if [[ "${cur:0:1}" == "-" ]]; then + case "$route_id" in % pattern_routes.each do |route| - <%= pattern_route_id route %>) - while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "<%= bash_double_quote_escape pattern_route_option_words(route).join(' ') %>" -- "$cur") - ;; + <%= pattern_route_id route %>) + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "<%= bash_double_quote_escape pattern_route_option_words(route).join(' ') %>" -- "$cur") + return + ;; % end - esac - - if [[ "${cur:0:1}" == "-" ]]; then - return + esac fi case "$route_id:$positional_index" in diff --git a/spec/completely/pattern_config_integration.yml b/spec/completely/pattern_config_integration.yml index 73da509..e31a323 100644 --- a/spec/completely/pattern_config_integration.yml +++ b/spec/completely/pattern_config_integration.yml @@ -9,7 +9,7 @@ basic: expected: [--branch, --verbose, -b, -v] - compline: "mygit init " - expected: [--bare, another-dir, dir with spaces, dummy-dir] + expected: [another-dir, dir with spaces, dummy-dir] - compline: "mygit st -" expected: [--branch, --verbose, -b, -v] @@ -39,16 +39,16 @@ interleaved: expected: [clone] - compline: "cli clone " - expected: [--protocol, --verbose, -p, -v, source1, source2] + expected: [source1, source2] - compline: "cli clone -v " - expected: [--protocol, --verbose, -p, -v, source1, source2] + expected: [source1, source2] - compline: "cli clone -v source1 " - expected: [--protocol, --verbose, -p, -v, dest1, dest2] + expected: [dest1, dest2] - compline: "cli clone -v source1 -p " expected: [https, ssh] - compline: "cli clone -v source1 -p ssh " - expected: [--protocol, --verbose, -p, -v, dest1, dest2] + expected: [dest1, dest2] From 394c503d019edab1467046b58f2762aa097f9d45 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Tue, 30 Jun 2026 17:02:51 +0300 Subject: [PATCH 08/18] add completely_options support for pattern config --- README.md | 7 +- lib/completely/pattern_config.rb | 9 ++- .../templates/pattern-config/template.erb | 2 +- .../script-pattern-complete-options | 81 +++++++++++++++++++ spec/completely/completions_spec.rb | 8 ++ .../completely/pattern_config_integration.yml | 4 + spec/completely/pattern_config_spec.rb | 16 ++++ .../pattern-config/complete_options.yaml | 8 ++ 8 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 spec/approvals/completions/script-pattern-complete-options create mode 100644 spec/fixtures/pattern-config/complete_options.yaml diff --git a/README.md b/README.md index 7b557af..052837b 100644 --- a/README.md +++ b/README.md @@ -351,9 +351,10 @@ autoload -Uz +X bashcompinit && bashcompinit ## Customizing the `complete` command In case you wish to customize the `complete` command call in the generated -script for flat or nested config, you can do so by adding any additional flags -to the `completely.yaml` configuration file using the special -`completely_options` key. For example: +script, you can do so by adding any additional flags to the +`completely.yaml` configuration file using the special `completely_options` +key. Completely passes these options to Bash's `complete` command as is. For +example: ```yaml completely_options: diff --git a/lib/completely/pattern_config.rb b/lib/completely/pattern_config.rb index 455612d..8b56249 100644 --- a/lib/completely/pattern_config.rb +++ b/lib/completely/pattern_config.rb @@ -1,8 +1,9 @@ module Completely class PatternConfig - attr_reader :config + attr_reader :config, :options def initialize(config) + @options = config.delete('completely_options')&.transform_keys(&:to_sym) || {} @config = config end @@ -12,7 +13,7 @@ def model @model ||= { program: program, routes: routes, - options: options, + options: parsed_options, tokens: tokens, } end @@ -43,8 +44,8 @@ def routes @routes ||= patterns.map { |pattern| parse_pattern pattern } end - def options - @options ||= option_groups.to_h do |name, entries| + def parsed_options + @parsed_options ||= option_groups.to_h do |name, entries| [name, Array(entries).map { |entry| parse_option entry }] end end diff --git a/lib/completely/templates/pattern-config/template.erb b/lib/completely/templates/pattern-config/template.erb index 6b93e30..877338a 100644 --- a/lib/completely/templates/pattern-config/template.erb +++ b/lib/completely/templates/pattern-config/template.erb @@ -99,6 +99,6 @@ % end esac } && - complete -F <%= function_name %> <%= command %> + complete <%= complete_options_line %>-F <%= function_name %> <%= command %> # ex: filetype=sh diff --git a/spec/approvals/completions/script-pattern-complete-options b/spec/approvals/completions/script-pattern-complete-options new file mode 100644 index 0000000..5c6ddf9 --- /dev/null +++ b/spec/approvals/completions/script-pattern-complete-options @@ -0,0 +1,81 @@ +# cli completion -*- shell-script -*- + +# This bash completions script was generated by +# completely (https://github.com/bashly-framework/completely) +# Modifying it manually is not recommended + +_cli_completions_flag_expects_value() { + case "$1" in + esac + + return 1 +} + +_cli_completions() { + local cur=${COMP_WORDS[COMP_CWORD]} + local prev= + if ((COMP_CWORD > 0)); then + prev=${COMP_WORDS[$((COMP_CWORD - 1))]} + fi + + local completed=() + if ((COMP_CWORD > 1)); then + completed=("${COMP_WORDS[@]:1:$((COMP_CWORD - 1))}") + fi + + local non_options=() + local skip_next=0 + for word in "${completed[@]}"; do + if ((skip_next)); then + skip_next=0 + continue + fi + + if [[ "${word:0:1}" == "-" ]]; then + if _cli_completions_flag_expects_value "$word"; then + skip_next=1 + fi + continue + fi + + non_options+=("$word") + done + + local route_id= + local positional_index=0 + if (( ${#non_options[@]} >= 1 )) && + [[ "${non_options[0]}" == "set" ]] + then + route_id=0 + positional_index=$((${#non_options[@]} - 1)) + fi + + COMPREPLY=() + + if [[ -z "$route_id" ]]; then + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "set" -- "$cur") + return + fi + + case "$route_id:$prev" in + esac + + if [[ "${cur:0:1}" == "-" ]]; then + case "$route_id" in + 0) + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "" -- "$cur") + return + ;; + esac + fi + + case "$route_id:$positional_index" in + 0:0) + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "0 10 20 30 40 50 60 70 80 90 100" -- "$cur") + return + ;; + esac +} && + complete -o nosort -F _cli_completions cli + +# ex: filetype=sh diff --git a/spec/completely/completions_spec.rb b/spec/completely/completions_spec.rb index 9732963..9e9f402 100644 --- a/spec/completely/completions_spec.rb +++ b/spec/completely/completions_spec.rb @@ -80,6 +80,14 @@ expect(subject.script).to match_approval 'completions/script-complete-options' end end + + context 'with a pattern configuration file that includes complete_options' do + let(:path) { 'spec/fixtures/pattern-config/complete_options.yaml' } + + it 'adds the complete_options to the complete command' do + expect(subject.script).to match_approval 'completions/script-pattern-complete-options' + end + end end describe '#wrapper_function' do diff --git a/spec/completely/pattern_config_integration.yml b/spec/completely/pattern_config_integration.yml index e31a323..e066ca6 100644 --- a/spec/completely/pattern_config_integration.yml +++ b/spec/completely/pattern_config_integration.yml @@ -34,6 +34,10 @@ spaced-token: - compline: "mygit status --format l" expected: [long] +complete_options: +- compline: "cli set " + expected: ['0', '10', '100', '20', '30', '40', '50', '60', '70', '80', '90'] + interleaved: - compline: "cli " expected: [clone] diff --git a/spec/completely/pattern_config_spec.rb b/spec/completely/pattern_config_spec.rb index 7275494..a2a6b4d 100644 --- a/spec/completely/pattern_config_spec.rb +++ b/spec/completely/pattern_config_spec.rb @@ -66,6 +66,22 @@ end end + context 'when complete_options is defined' do + subject(:config) { Config.load 'spec/fixtures/pattern-config/complete_options.yaml' } + + describe 'config' do + it 'ignores the completely_options YAML key' do + expect(config.config.keys).to eq %w[patterns tokens] + end + end + + describe 'options' do + it 'returns the completely_options hash from the YAML file' do + expect(config.options[:complete_options]).to eq '-o nosort' + end + end + end + context 'with a missing option group' do subject(:config) { Config.load 'spec/fixtures/pattern-config/missing-option.yaml' } diff --git a/spec/fixtures/pattern-config/complete_options.yaml b/spec/fixtures/pattern-config/complete_options.yaml new file mode 100644 index 0000000..025cf7e --- /dev/null +++ b/spec/fixtures/pattern-config/complete_options.yaml @@ -0,0 +1,8 @@ +completely_options: + complete_options: -o nosort + +patterns: + - cli set + +tokens: + level: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100] From e0837c44b93a3ee16da0122a5fdec132377e2a8c Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Tue, 30 Jun 2026 17:18:44 +0300 Subject: [PATCH 09/18] update api section in readme --- README.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 052837b..9c2a10e 100644 --- a/README.md +++ b/README.md @@ -317,9 +317,26 @@ require 'completely' # Load from file completions = Completely::Completions.load "input.yaml" -# Or, from a hash +# Or, from a pattern config hash input = { - "mygit" => %w[--help --version status init commit], + "patterns" => [ + "mygit init [init options] ", + "mygit status|st [status options]" + ], + "options" => { + "init" => ["--bare"], + "status" => ["--verbose|-v", "--branch|-b "] + }, + "tokens" => { + "directory" => "directory", + "branch" => "$(git branch --format='%(refname:short)' 2>/dev/null)" + } +} +completions = Completely::Completions.new input + +# Flat and nested config hashes are also supported by the same API. +input = { + "mygit" => %w[--help --version status init], "mygit status" => %w[--help --verbose --branch] } completions = Completely::Completions.new input From 6615be1ddc5997ee8eb9e92f6902e81422b5fd8a Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Tue, 30 Jun 2026 17:50:58 +0300 Subject: [PATCH 10/18] add golden snapshot test for pattern config --- spec/approvals/completions/script-pattern | 97 +++++++++++++++++++++++ spec/completely/completions_spec.rb | 8 ++ 2 files changed, 105 insertions(+) create mode 100644 spec/approvals/completions/script-pattern diff --git a/spec/approvals/completions/script-pattern b/spec/approvals/completions/script-pattern new file mode 100644 index 0000000..bc8c2d4 --- /dev/null +++ b/spec/approvals/completions/script-pattern @@ -0,0 +1,97 @@ +# mygit completion -*- shell-script -*- + +# This bash completions script was generated by +# completely (https://github.com/bashly-framework/completely) +# Modifying it manually is not recommended + +_mygit_completions_flag_expects_value() { + case "$1" in + --branch|-b) return 0 ;; + esac + + return 1 +} + +_mygit_completions() { + local cur=${COMP_WORDS[COMP_CWORD]} + local prev= + if ((COMP_CWORD > 0)); then + prev=${COMP_WORDS[$((COMP_CWORD - 1))]} + fi + + local completed=() + if ((COMP_CWORD > 1)); then + completed=("${COMP_WORDS[@]:1:$((COMP_CWORD - 1))}") + fi + + local non_options=() + local skip_next=0 + for word in "${completed[@]}"; do + if ((skip_next)); then + skip_next=0 + continue + fi + + if [[ "${word:0:1}" == "-" ]]; then + if _mygit_completions_flag_expects_value "$word"; then + skip_next=1 + fi + continue + fi + + non_options+=("$word") + done + + local route_id= + local positional_index=0 + if (( ${#non_options[@]} >= 1 )) && + [[ "${non_options[0]}" == "init" ]] + then + route_id=0 + positional_index=$((${#non_options[@]} - 1)) + fi + + if (( ${#non_options[@]} >= 1 )) && + [[ "${non_options[0]}" == "status" || "${non_options[0]}" == "st" ]] + then + route_id=1 + positional_index=$((${#non_options[@]} - 1)) + fi + + COMPREPLY=() + + if [[ -z "$route_id" ]]; then + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "init status st" -- "$cur") + return + fi + + case "$route_id:$prev" in + 1:--branch|1:-b) + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(echo main dev)" -- "$cur") + return + ;; + esac + + if [[ "${cur:0:1}" == "-" ]]; then + case "$route_id" in + 0) + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "--bare" -- "$cur") + return + ;; + 1) + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "--verbose -v --branch -b" -- "$cur") + return + ;; + esac + fi + + case "$route_id:$positional_index" in + 0:0) + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -A directory -- "$cur") + return + ;; + esac +} && + complete -F _mygit_completions mygit + +# ex: filetype=sh diff --git a/spec/completely/completions_spec.rb b/spec/completely/completions_spec.rb index 9e9f402..bb1602e 100644 --- a/spec/completely/completions_spec.rb +++ b/spec/completely/completions_spec.rb @@ -55,6 +55,14 @@ expect(subject.script).to match_approval 'completions/script' end + context 'with a pattern configuration file' do + let(:path) { 'spec/fixtures/pattern-config/basic.yaml' } + + it 'returns a bash completions script' do + expect(subject.script).to match_approval 'completions/script-pattern' + end + end + context 'with a configuration file that only includes patterns with spaces' do let(:file) { 'only-spaces' } From fb70123f2573e1a7b7030a83d5520ffa3499e03f Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Tue, 30 Jun 2026 18:04:48 +0300 Subject: [PATCH 11/18] polish help text --- lib/completely/commands/base.rb | 6 +++--- lib/completely/commands/generate.rb | 2 +- lib/completely/commands/init.rb | 4 ++-- lib/completely/commands/test.rb | 4 ++-- spec/approvals/cli/commands | 2 +- spec/approvals/cli/generate/help | 5 +++-- spec/approvals/cli/init/help | 9 +++++---- spec/approvals/cli/preview/help | 5 +++-- spec/approvals/cli/test/help | 6 +++--- spec/approvals/cli/warning | 2 +- spec/completely/commands/test_spec.rb | 5 ++++- 11 files changed, 28 insertions(+), 22 deletions(-) diff --git a/lib/completely/commands/base.rb b/lib/completely/commands/base.rb index 3107280..dd6d5f4 100644 --- a/lib/completely/commands/base.rb +++ b/lib/completely/commands/base.rb @@ -6,7 +6,7 @@ class Base < MisterBin::Command class << self def param_config_path param 'CONFIG_PATH', <<~USAGE - Path to the YAML configuration file [default: completely.yaml]. + Path to the Completely YAML configuration file (pattern, flat, or nested) [default: completely.yaml]. Can also be set by an environment variable. USAGE end @@ -18,7 +18,7 @@ def option_function def environment_config_path environment 'COMPLETELY_CONFIG_PATH', - 'Path to a completely configuration file [default: completely.yaml].' + 'Path to a Completely YAML configuration file [default: completely.yaml].' end def environment_debug @@ -62,7 +62,7 @@ def config_basename def syntax_warning say! "\nr`WARNING:`\nr`Your configuration is invalid.`" - say! 'r`All patterns must start with the same word.`' + say! 'r`All completion patterns must use the same command name.`' end end end diff --git a/lib/completely/commands/generate.rb b/lib/completely/commands/generate.rb index fb013ff..4329572 100644 --- a/lib/completely/commands/generate.rb +++ b/lib/completely/commands/generate.rb @@ -16,7 +16,7 @@ class Generate < Base option '-i --install PROGRAM', 'Install the generated script as completions for PROGRAM.' param 'CONFIG_PATH', <<~USAGE - Path to the YAML configuration file [default: completely.yaml]. + Path to the Completely YAML configuration file (pattern, flat, or nested) [default: completely.yaml]. Use '-' to read from stdin. Can also be set by an environment variable. diff --git a/lib/completely/commands/init.rb b/lib/completely/commands/init.rb index 3c5d922..7bbb0ba 100644 --- a/lib/completely/commands/init.rb +++ b/lib/completely/commands/init.rb @@ -3,12 +3,12 @@ module Completely module Commands class Init < Base - help 'Create a new sample YAML configuration file' + help 'Create a new sample Completely YAML configuration file' usage 'completely init [--format FORMAT] [CONFIG_PATH]' usage 'completely init (-h|--help)' - option '-f --format FORMAT', 'Configuration format: pattern, flat, or nested [default: pattern]' + option '-f --format FORMAT', 'Sample format: pattern, flat, or nested [default: pattern]' param_config_path environment_config_path diff --git a/lib/completely/commands/test.rb b/lib/completely/commands/test.rb index 359749f..07bacfb 100644 --- a/lib/completely/commands/test.rb +++ b/lib/completely/commands/test.rb @@ -6,8 +6,8 @@ class Test < Base summary 'Test completions' help 'This command can be used to test that your completions script responds with ' \ - 'the right completions. It works by reading your completely.yaml file, generating ' \ - 'a completions script, and generating a temporary testing script.' + 'the right completions. It works by reading a Completely YAML configuration file, ' \ + 'generating a completions script, and generating a temporary testing script.' usage 'completely test [--keep] COMPLINE...' usage 'completely test (-h|--help)' diff --git a/spec/approvals/cli/commands b/spec/approvals/cli/commands index 639d394..77fad2b 100644 --- a/spec/approvals/cli/commands +++ b/spec/approvals/cli/commands @@ -1,7 +1,7 @@ Completely - Bash Completions Generator Commands: - init Create a new sample YAML configuration file + init Create a new sample Completely YAML configuration file preview Generate the bash completion script to stdout generate Generate the bash completion script to file or stdout test Test completions diff --git a/spec/approvals/cli/generate/help b/spec/approvals/cli/generate/help index 0cf96a1..d757eda 100644 --- a/spec/approvals/cli/generate/help +++ b/spec/approvals/cli/generate/help @@ -21,7 +21,8 @@ Options: Parameters: CONFIG_PATH - Path to the YAML configuration file [default: completely.yaml]. + Path to the Completely YAML configuration file (pattern, flat, or nested) + [default: completely.yaml]. Use '-' to read from stdin. Can also be set by an environment variable. @@ -38,7 +39,7 @@ Parameters: Environment Variables: COMPLETELY_CONFIG_PATH - Path to a completely configuration file [default: completely.yaml]. + Path to a Completely YAML configuration file [default: completely.yaml]. COMPLETELY_OUTPUT_PATH Path to the output bash script. diff --git a/spec/approvals/cli/init/help b/spec/approvals/cli/init/help index 2702671..66984df 100644 --- a/spec/approvals/cli/init/help +++ b/spec/approvals/cli/init/help @@ -1,4 +1,4 @@ -Create a new sample YAML configuration file +Create a new sample Completely YAML configuration file Usage: completely init [--format FORMAT] [CONFIG_PATH] @@ -6,16 +6,17 @@ Usage: Options: -f --format FORMAT - Configuration format: pattern, flat, or nested [default: pattern] + Sample format: pattern, flat, or nested [default: pattern] -h --help Show this help Parameters: CONFIG_PATH - Path to the YAML configuration file [default: completely.yaml]. + Path to the Completely YAML configuration file (pattern, flat, or nested) + [default: completely.yaml]. Can also be set by an environment variable. Environment Variables: COMPLETELY_CONFIG_PATH - Path to a completely configuration file [default: completely.yaml]. + Path to a Completely YAML configuration file [default: completely.yaml]. diff --git a/spec/approvals/cli/preview/help b/spec/approvals/cli/preview/help index fb122af..162be93 100644 --- a/spec/approvals/cli/preview/help +++ b/spec/approvals/cli/preview/help @@ -13,12 +13,13 @@ Options: Parameters: CONFIG_PATH - Path to the YAML configuration file [default: completely.yaml]. + Path to the Completely YAML configuration file (pattern, flat, or nested) + [default: completely.yaml]. Can also be set by an environment variable. Environment Variables: COMPLETELY_CONFIG_PATH - Path to a completely configuration file [default: completely.yaml]. + Path to a Completely YAML configuration file [default: completely.yaml]. COMPLETELY_DEBUG If not empty, the generated script will include an additional debugging diff --git a/spec/approvals/cli/test/help b/spec/approvals/cli/test/help index 8918a91..1e55834 100644 --- a/spec/approvals/cli/test/help +++ b/spec/approvals/cli/test/help @@ -1,8 +1,8 @@ Test completions This command can be used to test that your completions script responds with the -right completions. It works by reading your completely.yaml file, generating a -completions script, and generating a temporary testing script. +right completions. It works by reading a Completely YAML configuration file, +generating a completions script, and generating a temporary testing script. Usage: completely test [--keep] COMPLINE... @@ -24,7 +24,7 @@ Parameters: Environment Variables: COMPLETELY_CONFIG_PATH - Path to a completely configuration file [default: completely.yaml]. + Path to a Completely YAML configuration file [default: completely.yaml]. COMPLETELY_DEBUG If not empty, the generated script will include an additional debugging diff --git a/spec/approvals/cli/warning b/spec/approvals/cli/warning index 533ce47..56d30e6 100644 --- a/spec/approvals/cli/warning +++ b/spec/approvals/cli/warning @@ -1,4 +1,4 @@ WARNING: Your configuration is invalid. -All patterns must start with the same word. +All completion patterns must use the same command name. diff --git a/spec/completely/commands/test_spec.rb b/spec/completely/commands/test_spec.rb index 740084d..3f554d2 100644 --- a/spec/completely/commands/test_spec.rb +++ b/spec/completely/commands/test_spec.rb @@ -6,7 +6,10 @@ ENV['COMPLETELY_CONFIG_PATH'] = nil end - after { system 'rm -f completely.yaml' } + after do + system 'rm -f completely.yaml' + ENV['COMPLETELY_CONFIG_PATH'] = nil + end context 'with --help' do it 'shows long usage' do From bde4fcc4d2b9e71a77f76bd0d21919f933fc70e5 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Tue, 30 Jun 2026 18:35:37 +0300 Subject: [PATCH 12/18] update json schema --- Runfile | 17 ++++-- schemas/completely.json | 122 ++++++++++++++++++++++++++++++++++------ 2 files changed, 117 insertions(+), 22 deletions(-) diff --git a/Runfile b/Runfile index 70349b0..cb03b17 100644 --- a/Runfile +++ b/Runfile @@ -11,17 +11,26 @@ import 'debug' help 'Test the completely JSON schema' action :schema do files = %w[ - lib/completely/templates/sample.yaml - lib/completely/templates/sample-nested.yaml - spec/fixtures/complete_options.yaml + lib/completely/templates/pattern-config/sample.yaml + lib/completely/templates/flat-config/sample.yaml + lib/completely/templates/flat-config/sample-nested.yaml + spec/fixtures/flat-config/complete_options.yaml + spec/fixtures/pattern-config/basic.yaml + spec/fixtures/pattern-config/complete_options.yaml + spec/fixtures/pattern-config/spaced-token.yaml ] files.each do |file| command = "check-jsonschema --schemafile schemas/completely.json #{file}" say "\n$ check-jsonschema bb`#{file}`" success = system command - exit 1 unless success + unless success + say 'r`FAILED`' + exit 1 + end end + + say 'g`ALL PASS`' end help 'Run preconfigured shfmt on any script' diff --git a/schemas/completely.json b/schemas/completely.json index 507639e..15a06bf 100644 --- a/schemas/completely.json +++ b/schemas/completely.json @@ -1,6 +1,101 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Completely configuration", + "description": "Configuration for generating bash completions with Completely.", + "oneOf": [ + { "$ref": "#/definitions/patternConfig" }, + { "$ref": "#/definitions/flatOrNestedConfig" } + ], "definitions": { + "completelyOptions": { + "type": "object", + "properties": { + "complete_options": { + "type": "string", + "examples": ["-o nosort"] + } + }, + "required": ["complete_options"], + "additionalProperties": false + }, + "nonEmptyString": { + "type": "string", + "minLength": 1 + }, + "pattern": { + "allOf": [ + { "$ref": "#/definitions/nonEmptyString" } + ], + "examples": [ + "mygit [root options]", + "mygit init [init options] ", + "mygit status|st [status options]" + ] + }, + "option": { + "allOf": [ + { "$ref": "#/definitions/nonEmptyString" } + ], + "examples": [ + "--help", + "--version", + "-h|--help", + "--branch|-b " + ] + }, + "tokenSource": { + "oneOf": [ + { + "allOf": [ + { "$ref": "#/definitions/nonEmptyString" } + ], + "examples": [ + "directory", + "file", + "$(git branch --format='%(refname:short)' 2>/dev/null)" + ] + }, + { + "type": "array", + "items": { "$ref": "#/definitions/tokenValue" }, + "examples": [ + ["short", "long"], + [0, 10, 100] + ] + } + ] + }, + "tokenValue": { + "oneOf": [ + { "$ref": "#/definitions/nonEmptyString" }, + { "type": "number" }, + { "type": "boolean" } + ] + }, + "patternConfig": { + "type": "object", + "required": ["patterns"], + "properties": { + "completely_options": { "$ref": "#/definitions/completelyOptions" }, + "patterns": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/pattern" } + }, + "options": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "$ref": "#/definitions/option" } + } + }, + "tokens": { + "type": "object", + "additionalProperties": { "$ref": "#/definitions/tokenSource" } + } + }, + "additionalProperties": false + }, "word": { "type": "string", "minLength": 1, @@ -38,34 +133,25 @@ { "$ref": "#/definitions/word" }, { "type": "object", - "additionalProperties": { - "type": "array", - "items": { "$ref": "#/definitions/wordOrMapping" } - } + "additionalProperties": { "$ref": "#/definitions/entryArray" } } ] }, "entryArray": { "type": "array", "items": { "$ref": "#/definitions/wordOrMapping" } - } - }, - "type": "object", - "properties": { - "completely_options": { + }, + "flatOrNestedConfig": { "type": "object", "properties": { - "complete_options": { - "type": "string", - "examples": ["-o nosort"] + "completely_options": { "$ref": "#/definitions/completelyOptions" } + }, + "patternProperties": { + "^(?!(completely_options|patterns|options|tokens)$).*": { + "$ref": "#/definitions/entryArray" } }, - "required": ["complete_options"], "additionalProperties": false } - }, - "patternProperties": { - "^(?!completely_options$).*": { "$ref": "#/definitions/entryArray" } - }, - "additionalProperties": false + } } From c9c0a21023df0b52b90faa9e8bccecd736813b84 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Tue, 30 Jun 2026 19:19:34 +0300 Subject: [PATCH 13/18] make pattern options unique by default with repeatable support --- README.md | 13 +++++-- Runfile | 1 + lib/completely/completions.rb | 4 --- lib/completely/pattern_config.rb | 27 +++++++++++--- .../templates/pattern-config/sample.yaml | 2 +- .../templates/pattern-config/template.erb | 20 ++++++++++- schemas/completely.json | 8 ++--- spec/approvals/completions/script-pattern | 35 +++++++++++++++++-- .../script-pattern-complete-options | 5 ++- .../completely/pattern_config_integration.yml | 25 +++++++++++++ spec/completely/pattern_config_spec.rb | 35 ++++++++++++++++--- spec/fixtures/pattern-config/repeatable.yaml | 14 ++++++++ .../pattern-config/unknown-metadata.yaml | 6 ++++ 13 files changed, 172 insertions(+), 23 deletions(-) create mode 100644 spec/fixtures/pattern-config/repeatable.yaml create mode 100644 spec/fixtures/pattern-config/unknown-metadata.yaml diff --git a/README.md b/README.md index 9c2a10e..b48aa44 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ options: - --help - --branch|-b - --format - - --verbose + - --verbose (repeatable) tokens: directory: directory @@ -121,12 +121,21 @@ options: status: - --help - --branch|-b - - --verbose + - --verbose (repeatable) ``` An option can be a plain flag, aliases separated with `|`, or a flag that expects a value token. +Options are unique by default. If an option should be suggested again after it +was already used, add `(repeatable)`: + +```yaml +options: + status: + - --tag (repeatable) +``` + The `tokens` section defines completion sources. Each token value can be one of these forms: diff --git a/Runfile b/Runfile index cb03b17..6b94a9e 100644 --- a/Runfile +++ b/Runfile @@ -17,6 +17,7 @@ action :schema do spec/fixtures/flat-config/complete_options.yaml spec/fixtures/pattern-config/basic.yaml spec/fixtures/pattern-config/complete_options.yaml + spec/fixtures/pattern-config/repeatable.yaml spec/fixtures/pattern-config/spaced-token.yaml ] diff --git a/lib/completely/completions.rb b/lib/completely/completions.rb index d5bf83d..e0745d9 100644 --- a/lib/completely/completions.rb +++ b/lib/completely/completions.rb @@ -137,10 +137,6 @@ def pattern_route_options(route) end end - def pattern_route_option_words(route) - pattern_route_options(route).flat_map { |option| option[:names] }.uniq - end - def pattern_options_with_values config.model[:options].values.flatten.select { |option| option[:value] } end diff --git a/lib/completely/pattern_config.rb b/lib/completely/pattern_config.rb index 8b56249..34fbdde 100644 --- a/lib/completely/pattern_config.rb +++ b/lib/completely/pattern_config.rb @@ -84,7 +84,7 @@ def pattern_tokens def option_tokens option_groups.values.flatten.filter_map do |entry| - _flag_part, value_part = option_parts entry + value_part = option_parts(entry).find { |part| token? part } token_name(value_part) if value_part end end @@ -115,16 +115,35 @@ def pattern_parts(pattern) end def parse_option(entry) - flag_part, value_part = option_parts entry + flag_part, *parts = option_parts entry + value_part = parts.find { |part| token? part } + metadata_parts = parts.select { |part| metadata? part } + unknown_parts = parts - [value_part] - metadata_parts + raise ParseError, "Invalid option syntax: #{entry}" if unknown_parts.any? + names = flag_part.split('|') - result = { names: names } + result = { names: names, repeatable: false } result[:value] = parse_token(value_part) if value_part + metadata_parts.each { |part| apply_option_metadata result, part } result end def option_parts(entry) - entry.scan(/<[^>]+>|\S+/) + entry.scan(/<[^>]+>|\([^)]+\)|\S+/) + end + + def apply_option_metadata(result, part) + case part + when '(repeatable)' + result[:repeatable] = true + else + raise ParseError, "Unknown option metadata: #{part}" + end + end + + def metadata?(part) + part.start_with?('(') && part.end_with?(')') end def parse_token(part) diff --git a/lib/completely/templates/pattern-config/sample.yaml b/lib/completely/templates/pattern-config/sample.yaml index 138ddaf..7789d31 100644 --- a/lib/completely/templates/pattern-config/sample.yaml +++ b/lib/completely/templates/pattern-config/sample.yaml @@ -13,7 +13,7 @@ options: - --help - --branch|-b - --format - - --verbose + - --verbose (repeatable) tokens: directory: directory diff --git a/lib/completely/templates/pattern-config/template.erb b/lib/completely/templates/pattern-config/template.erb index 877338a..7c7eb38 100644 --- a/lib/completely/templates/pattern-config/template.erb +++ b/lib/completely/templates/pattern-config/template.erb @@ -27,6 +27,7 @@ fi local non_options=() + local completed_options=() local skip_next=0 for word in "${completed[@]}"; do if ((skip_next)); then @@ -35,6 +36,7 @@ fi if [[ "${word:0:1}" == "-" ]]; then + completed_options+=("$word") if <%= function_name %>_flag_expects_value "$word"; then skip_next=1 fi @@ -81,7 +83,23 @@ case "$route_id" in % pattern_routes.each do |route| <%= pattern_route_id route %>) - while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "<%= bash_double_quote_escape pattern_route_option_words(route).join(' ') %>" -- "$cur") + local words=() +% pattern_route_options(route).each do |option| +% if option[:repeatable] + words+=(<%= option[:names].map { |name| %["#{bash_escape name}"] }.join(' ') %>) +% else + local option_seen=0 + for completed_option in "${completed_options[@]}"; do + case "$completed_option" in + <%= option[:names].map { |name| bash_escape name }.join('|') %>) option_seen=1 ;; + esac + done + if ((!option_seen)); then + words+=(<%= option[:names].map { |name| %["#{bash_escape name}"] }.join(' ') %>) + fi +% end +% end + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "${words[*]}" -- "$cur") return ;; % end diff --git a/schemas/completely.json b/schemas/completely.json index 15a06bf..07bb5b3 100644 --- a/schemas/completely.json +++ b/schemas/completely.json @@ -33,14 +33,14 @@ ] }, "option": { - "allOf": [ - { "$ref": "#/definitions/nonEmptyString" } - ], + "type": "string", + "pattern": "^\\S+(?:\\s+<[^>]+>)?(?:\\s+\\(repeatable\\))?$", "examples": [ "--help", "--version", "-h|--help", - "--branch|-b " + "--branch|-b ", + "--tag (repeatable)" ] }, "tokenSource": { diff --git a/spec/approvals/completions/script-pattern b/spec/approvals/completions/script-pattern index bc8c2d4..f4ec915 100644 --- a/spec/approvals/completions/script-pattern +++ b/spec/approvals/completions/script-pattern @@ -25,6 +25,7 @@ _mygit_completions() { fi local non_options=() + local completed_options=() local skip_next=0 for word in "${completed[@]}"; do if ((skip_next)); then @@ -33,6 +34,7 @@ _mygit_completions() { fi if [[ "${word:0:1}" == "-" ]]; then + completed_options+=("$word") if _mygit_completions_flag_expects_value "$word"; then skip_next=1 fi @@ -75,11 +77,40 @@ _mygit_completions() { if [[ "${cur:0:1}" == "-" ]]; then case "$route_id" in 0) - while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "--bare" -- "$cur") + local words=() + local option_seen=0 + for completed_option in "${completed_options[@]}"; do + case "$completed_option" in + --bare) option_seen=1 ;; + esac + done + if ((!option_seen)); then + words+=("--bare") + fi + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "${words[*]}" -- "$cur") return ;; 1) - while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "--verbose -v --branch -b" -- "$cur") + local words=() + local option_seen=0 + for completed_option in "${completed_options[@]}"; do + case "$completed_option" in + --verbose|-v) option_seen=1 ;; + esac + done + if ((!option_seen)); then + words+=("--verbose" "-v") + fi + local option_seen=0 + for completed_option in "${completed_options[@]}"; do + case "$completed_option" in + --branch|-b) option_seen=1 ;; + esac + done + if ((!option_seen)); then + words+=("--branch" "-b") + fi + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "${words[*]}" -- "$cur") return ;; esac diff --git a/spec/approvals/completions/script-pattern-complete-options b/spec/approvals/completions/script-pattern-complete-options index 5c6ddf9..36e0971 100644 --- a/spec/approvals/completions/script-pattern-complete-options +++ b/spec/approvals/completions/script-pattern-complete-options @@ -24,6 +24,7 @@ _cli_completions() { fi local non_options=() + local completed_options=() local skip_next=0 for word in "${completed[@]}"; do if ((skip_next)); then @@ -32,6 +33,7 @@ _cli_completions() { fi if [[ "${word:0:1}" == "-" ]]; then + completed_options+=("$word") if _cli_completions_flag_expects_value "$word"; then skip_next=1 fi @@ -63,7 +65,8 @@ _cli_completions() { if [[ "${cur:0:1}" == "-" ]]; then case "$route_id" in 0) - while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "" -- "$cur") + local words=() + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "${words[*]}" -- "$cur") return ;; esac diff --git a/spec/completely/pattern_config_integration.yml b/spec/completely/pattern_config_integration.yml index e066ca6..737178e 100644 --- a/spec/completely/pattern_config_integration.yml +++ b/spec/completely/pattern_config_integration.yml @@ -38,6 +38,31 @@ complete_options: - compline: "cli set " expected: ['0', '10', '100', '20', '30', '40', '50', '60', '70', '80', '90'] +repeatable: +- compline: "cli download -" + expected: [--help, --protocol, --user, --verbose, --version, -h, -p, -u, -v] + +- compline: "cli download --version -" + expected: [--help, --protocol, --user, --verbose, -h, -p, -u, -v] + +- compline: "cli download -h -" + expected: [--protocol, --user, --verbose, --version, -p, -u, -v] + +- compline: "cli download --help -" + expected: [--protocol, --user, --verbose, --version, -p, -u, -v] + +- compline: "cli download -v -" + expected: [--help, --protocol, --user, --verbose, --version, -h, -p, -u, -v] + +- compline: "cli download -u " + expected: [alice, bob] + +- compline: "cli download -u alice -" + expected: [--help, --protocol, --user, --verbose, --version, -h, -p, -u, -v] + +- compline: "cli download -p ssh -" + expected: [--help, --user, --verbose, --version, -h, -u, -v] + interleaved: - compline: "cli " expected: [clone] diff --git a/spec/completely/pattern_config_spec.rb b/spec/completely/pattern_config_spec.rb index a2a6b4d..cdf02c2 100644 --- a/spec/completely/pattern_config_spec.rb +++ b/spec/completely/pattern_config_spec.rb @@ -31,17 +31,21 @@ end it 'returns init options' do - expect(config.model[:options]['init']).to eq [{ names: ['--bare'] }] + expect(config.model[:options]['init']).to eq [{ names: ['--bare'], repeatable: false }] end it 'returns status flag options' do - expect(config.model[:options]['status'].first).to eq({ names: ['--verbose', '-v'] }) + expect(config.model[:options]['status'].first).to eq( + names: ['--verbose', '-v'], + repeatable: false + ) end it 'returns status options with values' do expect(config.model[:options]['status'].last).to eq( - names: ['--branch', '-b'], - value: { + names: ['--branch', '-b'], + repeatable: false, + value: { name: 'branch', source: { type: :command, value: '$(echo main dev)' }, } @@ -97,4 +101,27 @@ expect { config.model }.to raise_error Completely::ParseError, 'Unknown token: directory' end end + + context 'with a repeatable option' do + subject(:config) { Config.load 'spec/fixtures/pattern-config/repeatable.yaml' } + + it 'marks repeatable options' do + expect(config.model[:options]['download'].last).to eq( + names: ['-u', '--user'], + repeatable: true, + value: { + name: 'name', + source: { type: :values, value: %w[alice bob] }, + } + ) + end + end + + context 'with unknown option metadata' do + subject(:config) { Config.load 'spec/fixtures/pattern-config/unknown-metadata.yaml' } + + it 'raises ParseError' do + expect { config.model }.to raise_error Completely::ParseError, 'Unknown option metadata: (hidden)' + end + end end diff --git a/spec/fixtures/pattern-config/repeatable.yaml b/spec/fixtures/pattern-config/repeatable.yaml new file mode 100644 index 0000000..f6e71c9 --- /dev/null +++ b/spec/fixtures/pattern-config/repeatable.yaml @@ -0,0 +1,14 @@ +patterns: + - cli download [download options] + +options: + download: + - -p|--protocol + - -h|--help + - --version + - -v|--verbose (repeatable) + - -u|--user (repeatable) + +tokens: + protocol: [http, ssh] + name: [alice, bob] diff --git a/spec/fixtures/pattern-config/unknown-metadata.yaml b/spec/fixtures/pattern-config/unknown-metadata.yaml new file mode 100644 index 0000000..59b8e5f --- /dev/null +++ b/spec/fixtures/pattern-config/unknown-metadata.yaml @@ -0,0 +1,6 @@ +patterns: + - cli download [download options] + +options: + download: + - --debug (hidden) From 9ff3c806d680ee63486d0f68e977f78721fafedd Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Tue, 30 Jun 2026 21:10:37 +0300 Subject: [PATCH 14/18] add support for repeatable positional arg --- README.md | 10 +++++++ Runfile | 1 + lib/completely/pattern_config.rb | 28 +++++++++++++++---- .../templates/pattern-config/template.erb | 11 ++++++++ schemas/completely.json | 1 + .../completely/pattern_config_integration.yml | 22 +++++++++++++++ spec/completely/pattern_config_spec.rb | 25 +++++++++++++++++ .../repeatable-positionals-invalid.yaml | 6 ++++ .../repeatable-positionals.yaml | 8 ++++++ 9 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 spec/fixtures/pattern-config/repeatable-positionals-invalid.yaml create mode 100644 spec/fixtures/pattern-config/repeatable-positionals.yaml diff --git a/README.md b/README.md index b48aa44..350615a 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ The `patterns` section describes valid command shapes: - Command aliases can be written with `|`, for example `status|st`. - `[name options]` references `options.name`. `[name]` is also accepted. - `` references `tokens.token`. +- `...` marks the final positional as repeatable. The `options` section defines option groups: @@ -136,6 +137,15 @@ options: - --tag (repeatable) ``` +The final positional in a pattern can be repeatable: + +```yaml +patterns: + - mygit upload ... +``` + +Only the final positional may be repeatable. + The `tokens` section defines completion sources. Each token value can be one of these forms: diff --git a/Runfile b/Runfile index 6b94a9e..8582bff 100644 --- a/Runfile +++ b/Runfile @@ -18,6 +18,7 @@ action :schema do spec/fixtures/pattern-config/basic.yaml spec/fixtures/pattern-config/complete_options.yaml spec/fixtures/pattern-config/repeatable.yaml + spec/fixtures/pattern-config/repeatable-positionals.yaml spec/fixtures/pattern-config/spaced-token.yaml ] diff --git a/lib/completely/pattern_config.rb b/lib/completely/pattern_config.rb index 34fbdde..8a078ea 100644 --- a/lib/completely/pattern_config.rb +++ b/lib/completely/pattern_config.rb @@ -63,9 +63,19 @@ def validate! errors = [] errors << "Unknown option group: #{missing_options.join ', '}" if missing_options.any? errors << "Unknown token: #{missing_tokens.join ', '}" if missing_tokens.any? + errors.concat repeatable_positional_errors raise ParseError, errors.join("\n") if errors.any? end + def repeatable_positional_errors + patterns.filter_map do |pattern| + positionals = pattern_parts(pattern).select { |part| token? part } + next unless positionals[0...-1].any? { |part| repeatable_token? part } + + "Repeatable positional must be the last positional in pattern: #{pattern}" + end + end + def referenced_options patterns.flat_map do |pattern| pattern_parts(pattern).filter_map { |part| option_group_name(part) if option_group?(part) } @@ -111,7 +121,7 @@ def parse_word(part) end def pattern_parts(pattern) - pattern.scan(/\[[^\]]+\]|<[^>]+>|\S+/) + pattern.scan(/\[[^\]]+\]|<[^>]+>\.\.\.|<[^>]+>|\S+/) end def parse_option(entry) @@ -147,8 +157,12 @@ def metadata?(part) end def parse_token(part) - name = token_name part - { name: name, source: parse_source(name, token_sources[name]) } + repeatable = repeatable_token? part + token_part = repeatable ? part.delete_suffix('...') : part + name = token_name token_part + result = { name: name, source: parse_source(name, token_sources[name]) } + result[:repeatable] = true if repeatable + result end def parse_source(_name, source) @@ -171,11 +185,15 @@ def option_group_name(part) end def token?(part) - part.start_with?('<') && part.end_with?('>') + part.match?(/\A<[^>]+>(?:\.\.\.)?\z/) + end + + def repeatable_token?(part) + part.end_with? '...' end def token_name(part) - part[/\A<(.+)>\z/, 1] + part.delete_suffix('...')[/\A<(.+)>\z/, 1] end end end diff --git a/lib/completely/templates/pattern-config/template.erb b/lib/completely/templates/pattern-config/template.erb index 7c7eb38..e61afed 100644 --- a/lib/completely/templates/pattern-config/template.erb +++ b/lib/completely/templates/pattern-config/template.erb @@ -106,9 +106,20 @@ esac fi +% pattern_routes.each do |route| +% route[:positionals].each_with_index do |positional, index| +% next unless positional[:repeatable] + if [[ "$route_id" == "<%= pattern_route_id route %>" ]] && (( positional_index >= <%= index %> )); then + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen <%= pattern_source_compgen positional[:source] %> -- "$cur") + return + fi + +% end +% end case "$route_id:$positional_index" in % pattern_routes.each do |route| % route[:positionals].each_with_index do |positional, index| +% next if positional[:repeatable] <%= pattern_route_id route %>:<%= index %>) while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen <%= pattern_source_compgen positional[:source] %> -- "$cur") return diff --git a/schemas/completely.json b/schemas/completely.json index 07bb5b3..af1a533 100644 --- a/schemas/completely.json +++ b/schemas/completely.json @@ -29,6 +29,7 @@ "examples": [ "mygit [root options]", "mygit init [init options] ", + "mygit upload ...", "mygit status|st [status options]" ] }, diff --git a/spec/completely/pattern_config_integration.yml b/spec/completely/pattern_config_integration.yml index 737178e..8a2c143 100644 --- a/spec/completely/pattern_config_integration.yml +++ b/spec/completely/pattern_config_integration.yml @@ -81,3 +81,25 @@ interleaved: - compline: "cli clone -v source1 -p ssh " expected: [dest1, dest2] + +repeatable-positionals: +- compline: "cli " + expected: [copy, upload] + +- compline: "cli upload " + expected: [file1, file2] + +- compline: "cli upload file1 " + expected: [file1, file2] + +- compline: "cli upload file1 file2 " + expected: [file1, file2] + +- compline: "cli copy " + expected: [source1, source2] + +- compline: "cli copy source1 " + expected: [target1, target2] + +- compline: "cli copy source1 target1 " + expected: [target1, target2] diff --git a/spec/completely/pattern_config_spec.rb b/spec/completely/pattern_config_spec.rb index cdf02c2..5d2f77a 100644 --- a/spec/completely/pattern_config_spec.rb +++ b/spec/completely/pattern_config_spec.rb @@ -117,6 +117,31 @@ end end + context 'with repeatable positionals' do + subject(:config) { Config.load 'spec/fixtures/pattern-config/repeatable-positionals.yaml' } + + it 'marks repeatable positionals' do + expect(config.model[:routes].first[:positionals]).to eq [ + { + name: 'file', + repeatable: true, + source: { type: :values, value: %w[file1 file2] }, + }, + ] + end + end + + context 'with a non-final repeatable positional' do + subject(:config) { Config.load 'spec/fixtures/pattern-config/repeatable-positionals-invalid.yaml' } + + it 'raises ParseError' do + expect { config.model }.to raise_error( + Completely::ParseError, + 'Repeatable positional must be the last positional in pattern: cli copy ... ' + ) + end + end + context 'with unknown option metadata' do subject(:config) { Config.load 'spec/fixtures/pattern-config/unknown-metadata.yaml' } diff --git a/spec/fixtures/pattern-config/repeatable-positionals-invalid.yaml b/spec/fixtures/pattern-config/repeatable-positionals-invalid.yaml new file mode 100644 index 0000000..6419c3d --- /dev/null +++ b/spec/fixtures/pattern-config/repeatable-positionals-invalid.yaml @@ -0,0 +1,6 @@ +patterns: + - cli copy ... + +tokens: + source: [source1, source2] + target: [target1, target2] diff --git a/spec/fixtures/pattern-config/repeatable-positionals.yaml b/spec/fixtures/pattern-config/repeatable-positionals.yaml new file mode 100644 index 0000000..8ffc6a6 --- /dev/null +++ b/spec/fixtures/pattern-config/repeatable-positionals.yaml @@ -0,0 +1,8 @@ +patterns: + - cli upload ... + - cli copy ... + +tokens: + file: [file1, file2] + source: [source1, source2] + target: [target1, target2] From 9b277d0907b60e1b92275c59ef030b3deedee724 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Tue, 30 Jun 2026 22:50:48 +0300 Subject: [PATCH 15/18] add support for nil token (placeholder) --- README.md | 2 ++ Runfile | 1 + lib/completely/completions.rb | 4 ++++ lib/completely/pattern_config.rb | 2 ++ .../templates/pattern-config/template.erb | 12 ++++++++++++ schemas/completely.json | 4 ++++ spec/completely/pattern_config_integration.yml | 10 ++++++++++ spec/completely/pattern_config_spec.rb | 15 +++++++++++++++ spec/fixtures/pattern-config/nil-source.yaml | 6 ++++++ 9 files changed, 56 insertions(+) create mode 100644 spec/fixtures/pattern-config/nil-source.yaml diff --git a/README.md b/README.md index 350615a..16970dd 100644 --- a/README.md +++ b/README.md @@ -151,11 +151,13 @@ these forms: ```yaml tokens: + source: ~ directory: directory branch: $(git branch --format='%(refname:short)' 2>/dev/null) format: [short, long] ``` +- A null value such as `~` defines a token without completion suggestions. - A plain string such as `directory` uses a bash built-in completion action. - A `$(...)` string runs a command and uses its whitespace-delimited output. - An array provides a fixed list of completion words. diff --git a/Runfile b/Runfile index 8582bff..28bd927 100644 --- a/Runfile +++ b/Runfile @@ -17,6 +17,7 @@ action :schema do spec/fixtures/flat-config/complete_options.yaml spec/fixtures/pattern-config/basic.yaml spec/fixtures/pattern-config/complete_options.yaml + spec/fixtures/pattern-config/nil-source.yaml spec/fixtures/pattern-config/repeatable.yaml spec/fixtures/pattern-config/repeatable-positionals.yaml spec/fixtures/pattern-config/spaced-token.yaml diff --git a/lib/completely/completions.rb b/lib/completely/completions.rb index e0745d9..52c34bb 100644 --- a/lib/completely/completions.rb +++ b/lib/completely/completions.rb @@ -141,6 +141,10 @@ def pattern_options_with_values config.model[:options].values.flatten.select { |option| option[:value] } end + def pattern_source_none?(source) + source[:type] == :none + end + def pattern_source_compgen(source) case source[:type] when :builtin diff --git a/lib/completely/pattern_config.rb b/lib/completely/pattern_config.rb index 8a078ea..f6f114a 100644 --- a/lib/completely/pattern_config.rb +++ b/lib/completely/pattern_config.rb @@ -167,6 +167,8 @@ def parse_token(part) def parse_source(_name, source) case source + when nil + { type: :none } when Array { type: :values, value: source } when /^\$\(.*\)$/ diff --git a/lib/completely/templates/pattern-config/template.erb b/lib/completely/templates/pattern-config/template.erb index e61afed..bd3c13c 100644 --- a/lib/completely/templates/pattern-config/template.erb +++ b/lib/completely/templates/pattern-config/template.erb @@ -72,8 +72,12 @@ % pattern_routes.each do |route| % pattern_route_options(route).select { |option| option[:value] }.each do |option| <%= pattern_route_id route %>:<%= option[:names].map { |name| bash_escape name }.join("|#{pattern_route_id route}:") %>) +% if pattern_source_none? option[:value][:source] + return +% else while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen <%= pattern_source_compgen option[:value][:source] %> -- "$cur") return +% end ;; % end % end @@ -110,8 +114,12 @@ % route[:positionals].each_with_index do |positional, index| % next unless positional[:repeatable] if [[ "$route_id" == "<%= pattern_route_id route %>" ]] && (( positional_index >= <%= index %> )); then +% if pattern_source_none? positional[:source] + return +% else while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen <%= pattern_source_compgen positional[:source] %> -- "$cur") return +% end fi % end @@ -121,8 +129,12 @@ % route[:positionals].each_with_index do |positional, index| % next if positional[:repeatable] <%= pattern_route_id route %>:<%= index %>) +% if pattern_source_none? positional[:source] + return +% else while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen <%= pattern_source_compgen positional[:source] %> -- "$cur") return +% end ;; % end % end diff --git a/schemas/completely.json b/schemas/completely.json index af1a533..aced2ee 100644 --- a/schemas/completely.json +++ b/schemas/completely.json @@ -63,6 +63,10 @@ ["short", "long"], [0, 10, 100] ] + }, + { + "type": "null", + "examples": [null] } ] }, diff --git a/spec/completely/pattern_config_integration.yml b/spec/completely/pattern_config_integration.yml index 8a2c143..22d4291 100644 --- a/spec/completely/pattern_config_integration.yml +++ b/spec/completely/pattern_config_integration.yml @@ -82,6 +82,16 @@ interleaved: - compline: "cli clone -v source1 -p ssh " expected: [dest1, dest2] +nil-source: +- compline: "cli copy " + expected: [] + +- compline: "cli copy source1 " + expected: [target1, target2] + +- compline: "cli copy source1 t" + expected: [target1, target2] + repeatable-positionals: - compline: "cli " expected: [copy, upload] diff --git a/spec/completely/pattern_config_spec.rb b/spec/completely/pattern_config_spec.rb index 5d2f77a..2ef1648 100644 --- a/spec/completely/pattern_config_spec.rb +++ b/spec/completely/pattern_config_spec.rb @@ -102,6 +102,21 @@ end end + context 'with a nil token source' do + subject(:config) { Config.load 'spec/fixtures/pattern-config/nil-source.yaml' } + + it 'returns a none source' do + expect(config.model[:tokens]['source']).to eq(type: :none) + end + + it 'uses the none source for positionals' do + expect(config.model[:routes].first[:positionals].first).to eq( + name: 'source', + source: { type: :none } + ) + end + end + context 'with a repeatable option' do subject(:config) { Config.load 'spec/fixtures/pattern-config/repeatable.yaml' } diff --git a/spec/fixtures/pattern-config/nil-source.yaml b/spec/fixtures/pattern-config/nil-source.yaml new file mode 100644 index 0000000..a2e12e5 --- /dev/null +++ b/spec/fixtures/pattern-config/nil-source.yaml @@ -0,0 +1,6 @@ +patterns: + - cli copy + +tokens: + source: ~ + target: [target1, target2] From 7afda26f2c5b4caedeeb37ea6a805f0f764ce75d Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Wed, 1 Jul 2026 00:26:40 +0300 Subject: [PATCH 16/18] allow multiple builtins --- README.md | 17 +++--- Runfile | 1 + lib/completely/completions.rb | 25 +++++--- lib/completely/pattern_config.rb | 20 +++--- .../templates/pattern-config/sample.yaml | 2 +- .../templates/pattern-config/template.erb | 6 +- schemas/completely.json | 10 ++- .../completely/pattern_config_integration.yml | 10 +++ spec/completely/pattern_config_spec.rb | 61 +++++++++++++++---- spec/fixtures/pattern-config/basic.yaml | 2 +- .../fixtures/pattern-config/mixed-source.yaml | 7 +++ 11 files changed, 113 insertions(+), 48 deletions(-) create mode 100644 spec/fixtures/pattern-config/mixed-source.yaml diff --git a/README.md b/README.md index 16970dd..bdeca38 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ options: - --verbose (repeatable) tokens: - directory: directory + directory: +directory branch: $(git branch --format='%(refname:short)' 2>/dev/null) format: [short, long] ``` @@ -152,15 +152,18 @@ these forms: ```yaml tokens: source: ~ - directory: directory + directory: +directory branch: $(git branch --format='%(refname:short)' 2>/dev/null) format: [short, long] + target: [+file, +directory, README.md, $(git branch --format='%(refname:short)' 2>/dev/null)] + literal: ++file ``` - A null value such as `~` defines a token without completion suggestions. -- A plain string such as `directory` uses a bash built-in completion action. -- A `$(...)` string runs a command and uses its whitespace-delimited output. -- An array provides a fixed list of completion words. +- A value starting with `+`, such as `+directory`, uses a bash built-in completion action. +- A value starting with `++`, such as `++file`, provides the literal completion word `+file`. +- Plain strings, including `$(...)` command substitutions, are added to the completion word list. +- An array combines multiple source items. Every `[name]` option group and every `` used by patterns or options must be defined. This keeps typos from generating broken completion scripts. @@ -246,8 +249,8 @@ Pattern config uses named tokens: ```yaml tokens: - file: file - directory: directory + file: +file + directory: +directory branch: $(git branch --format='%(refname:short)' 2>/dev/null) format: [short, long] ``` diff --git a/Runfile b/Runfile index 28bd927..a5c8c1d 100644 --- a/Runfile +++ b/Runfile @@ -17,6 +17,7 @@ action :schema do spec/fixtures/flat-config/complete_options.yaml spec/fixtures/pattern-config/basic.yaml spec/fixtures/pattern-config/complete_options.yaml + spec/fixtures/pattern-config/mixed-source.yaml spec/fixtures/pattern-config/nil-source.yaml spec/fixtures/pattern-config/repeatable.yaml spec/fixtures/pattern-config/repeatable-positionals.yaml diff --git a/lib/completely/completions.rb b/lib/completely/completions.rb index 52c34bb..e74be88 100644 --- a/lib/completely/completions.rb +++ b/lib/completely/completions.rb @@ -141,19 +141,24 @@ def pattern_options_with_values config.model[:options].values.flatten.select { |option| option[:value] } end - def pattern_source_none?(source) - source[:type] == :none + def pattern_source_empty?(source) + source[:items].empty? end def pattern_source_compgen(source) - case source[:type] - when :builtin - "-A #{bash_escape source[:value]}" - when :command - %[-W "#{bash_double_quote_escape source[:value]}"] - when :values - %[-W "#{bash_double_quote_escape source[:value].join ' '}"] - end + wordlist = source[:items] + .select { |item| item[:type] == :value } + .map { |item| item[:value] } + .join(' ') + + builtins = source[:items] + .select { |item| item[:type] == :builtin } + .map { |item| "-A #{bash_escape item[:value]}" } + + parts = [] + parts << %[-W "#{bash_double_quote_escape wordlist}"] unless wordlist.empty? + parts.concat builtins + parts.join(' ') end def bash_escape(value) diff --git a/lib/completely/pattern_config.rb b/lib/completely/pattern_config.rb index f6f114a..d525c0e 100644 --- a/lib/completely/pattern_config.rb +++ b/lib/completely/pattern_config.rb @@ -166,16 +166,16 @@ def parse_token(part) end def parse_source(_name, source) - case source - when nil - { type: :none } - when Array - { type: :values, value: source } - when /^\$\(.*\)$/ - { type: :command, value: source } - when String - { type: :builtin, value: source } - end + source_items = source.is_a?(Array) ? source : [source] + items = source_items.compact.map { |item| parse_source_item item } + { items: items } + end + + def parse_source_item(item) + return { type: :value, value: item.to_s[1..] } if item.to_s.start_with? '++' + return { type: :builtin, value: item.to_s[1..] } if item.to_s.start_with? '+' + + { type: :value, value: item.to_s } end def option_group?(part) diff --git a/lib/completely/templates/pattern-config/sample.yaml b/lib/completely/templates/pattern-config/sample.yaml index 7789d31..c307c2b 100644 --- a/lib/completely/templates/pattern-config/sample.yaml +++ b/lib/completely/templates/pattern-config/sample.yaml @@ -16,6 +16,6 @@ options: - --verbose (repeatable) tokens: - directory: directory + directory: +directory branch: $(git branch --format='%(refname:short)' 2>/dev/null) format: [short, long] diff --git a/lib/completely/templates/pattern-config/template.erb b/lib/completely/templates/pattern-config/template.erb index bd3c13c..39c6137 100644 --- a/lib/completely/templates/pattern-config/template.erb +++ b/lib/completely/templates/pattern-config/template.erb @@ -72,7 +72,7 @@ % pattern_routes.each do |route| % pattern_route_options(route).select { |option| option[:value] }.each do |option| <%= pattern_route_id route %>:<%= option[:names].map { |name| bash_escape name }.join("|#{pattern_route_id route}:") %>) -% if pattern_source_none? option[:value][:source] +% if pattern_source_empty? option[:value][:source] return % else while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen <%= pattern_source_compgen option[:value][:source] %> -- "$cur") @@ -114,7 +114,7 @@ % route[:positionals].each_with_index do |positional, index| % next unless positional[:repeatable] if [[ "$route_id" == "<%= pattern_route_id route %>" ]] && (( positional_index >= <%= index %> )); then -% if pattern_source_none? positional[:source] +% if pattern_source_empty? positional[:source] return % else while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen <%= pattern_source_compgen positional[:source] %> -- "$cur") @@ -129,7 +129,7 @@ % route[:positionals].each_with_index do |positional, index| % next if positional[:repeatable] <%= pattern_route_id route %>:<%= index %>) -% if pattern_source_none? positional[:source] +% if pattern_source_empty? positional[:source] return % else while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen <%= pattern_source_compgen positional[:source] %> -- "$cur") diff --git a/schemas/completely.json b/schemas/completely.json index aced2ee..1c63049 100644 --- a/schemas/completely.json +++ b/schemas/completely.json @@ -51,8 +51,9 @@ { "$ref": "#/definitions/nonEmptyString" } ], "examples": [ - "directory", - "file", + "+directory", + "+file", + "README.md", "$(git branch --format='%(refname:short)' 2>/dev/null)" ] }, @@ -61,7 +62,9 @@ "items": { "$ref": "#/definitions/tokenValue" }, "examples": [ ["short", "long"], - [0, 10, 100] + [0, 10, 100], + ["+file", "+directory", "README.md"], + [null, "+file"] ] }, { @@ -73,6 +76,7 @@ "tokenValue": { "oneOf": [ { "$ref": "#/definitions/nonEmptyString" }, + { "type": "null" }, { "type": "number" }, { "type": "boolean" } ] diff --git a/spec/completely/pattern_config_integration.yml b/spec/completely/pattern_config_integration.yml index 22d4291..a2b555a 100644 --- a/spec/completely/pattern_config_integration.yml +++ b/spec/completely/pattern_config_integration.yml @@ -92,6 +92,16 @@ nil-source: - compline: "cli copy source1 t" expected: [target1, target2] +mixed-source: +- compline: "cli copy " + expected: [another-dir, dir with spaces, dummy-dir, target1, target2] + +- compline: "cli copy t" + expected: [target1, target2] + +- compline: "cli literal " + expected: ["+file"] + repeatable-positionals: - compline: "cli " expected: [copy, upload] diff --git a/spec/completely/pattern_config_spec.rb b/spec/completely/pattern_config_spec.rb index 2ef1648..ca7d353 100644 --- a/spec/completely/pattern_config_spec.rb +++ b/spec/completely/pattern_config_spec.rb @@ -25,7 +25,7 @@ positionals = config.model[:routes].map { |route| route[:positionals] } expect(positionals).to eq [ - [{ name: 'directory', source: { type: :builtin, value: 'directory' } }], + [{ name: 'directory', source: { items: [{ type: :builtin, value: 'directory' }] } }], [], ] end @@ -47,18 +47,15 @@ repeatable: false, value: { name: 'branch', - source: { type: :command, value: '$(echo main dev)' }, + source: { items: [{ type: :value, value: '$(echo main dev)' }] }, } ) end it 'returns token sources' do expect(config.model[:tokens]).to eq( - 'directory' => { type: :builtin, value: 'directory' }, - 'branch' => { - type: :command, - value: '$(echo main dev)', - } + 'directory' => { items: [{ type: :builtin, value: 'directory' }] }, + 'branch' => { items: [{ type: :value, value: '$(echo main dev)' }] } ) end end @@ -105,14 +102,34 @@ context 'with a nil token source' do subject(:config) { Config.load 'spec/fixtures/pattern-config/nil-source.yaml' } - it 'returns a none source' do - expect(config.model[:tokens]['source']).to eq(type: :none) + it 'returns an empty source' do + expect(config.model[:tokens]['source']).to eq(items: []) end - it 'uses the none source for positionals' do + it 'uses the empty source for positionals' do expect(config.model[:routes].first[:positionals].first).to eq( name: 'source', - source: { type: :none } + source: { items: [] } + ) + end + end + + context 'with a mixed token source' do + subject(:config) { Config.load 'spec/fixtures/pattern-config/mixed-source.yaml' } + + it 'returns builtin and value items' do + expect(config.model[:tokens]['target']).to eq( + items: [ + { type: :builtin, value: 'directory' }, + { type: :value, value: 'target1' }, + { type: :value, value: '$(echo target2)' }, + ] + ) + end + + it 'escapes literal values that start with +' do + expect(config.model[:tokens]['value']).to eq( + items: [{ type: :value, value: '+file' }] ) end end @@ -120,13 +137,22 @@ context 'with a repeatable option' do subject(:config) { Config.load 'spec/fixtures/pattern-config/repeatable.yaml' } + let(:name_source) do + { + items: [ + { type: :value, value: 'alice' }, + { type: :value, value: 'bob' }, + ], + } + end + it 'marks repeatable options' do expect(config.model[:options]['download'].last).to eq( names: ['-u', '--user'], repeatable: true, value: { name: 'name', - source: { type: :values, value: %w[alice bob] }, + source: name_source, } ) end @@ -135,12 +161,21 @@ context 'with repeatable positionals' do subject(:config) { Config.load 'spec/fixtures/pattern-config/repeatable-positionals.yaml' } + let(:file_source) do + { + items: [ + { type: :value, value: 'file1' }, + { type: :value, value: 'file2' }, + ], + } + end + it 'marks repeatable positionals' do expect(config.model[:routes].first[:positionals]).to eq [ { name: 'file', repeatable: true, - source: { type: :values, value: %w[file1 file2] }, + source: file_source, }, ] end diff --git a/spec/fixtures/pattern-config/basic.yaml b/spec/fixtures/pattern-config/basic.yaml index 7c8c645..009c367 100644 --- a/spec/fixtures/pattern-config/basic.yaml +++ b/spec/fixtures/pattern-config/basic.yaml @@ -10,5 +10,5 @@ options: - --branch|-b tokens: - directory: directory + directory: +directory branch: $(echo main dev) diff --git a/spec/fixtures/pattern-config/mixed-source.yaml b/spec/fixtures/pattern-config/mixed-source.yaml new file mode 100644 index 0000000..6f058d5 --- /dev/null +++ b/spec/fixtures/pattern-config/mixed-source.yaml @@ -0,0 +1,7 @@ +patterns: + - cli copy + - cli literal + +tokens: + target: [~, +directory, target1, $(echo target2)] + value: [++file] From 0fe66f6b7b261d91ce1051bec245379f8d0fd7d6 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Wed, 1 Jul 2026 10:27:06 +0300 Subject: [PATCH 17/18] fix root options --- .../templates/pattern-config/template.erb | 8 +++-- spec/approvals/completions/script-pattern | 10 +++++- .../script-pattern-complete-options | 7 +++- .../completely/pattern_config_integration.yml | 32 +++++++++++++++++++ .../route-specificity-reversed.yaml | 13 ++++++++ .../pattern-config/route-specificity.yaml | 17 ++++++++++ 6 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 spec/fixtures/pattern-config/route-specificity-reversed.yaml create mode 100644 spec/fixtures/pattern-config/route-specificity.yaml diff --git a/lib/completely/templates/pattern-config/template.erb b/lib/completely/templates/pattern-config/template.erb index 39c6137..718d0b2 100644 --- a/lib/completely/templates/pattern-config/template.erb +++ b/lib/completely/templates/pattern-config/template.erb @@ -47,23 +47,27 @@ done local route_id= + local route_word_count=-1 + local route_has_positionals=0 local positional_index=0 % pattern_routes.each do |route| % conditions = pattern_route_conditions(route) -% next if conditions.empty? if (( ${#non_options[@]} >= <%= pattern_route_word_count route %> )) && + (( <%= pattern_route_word_count route %> > route_word_count ))<%= conditions.empty? ? '' : ' &&' %> % conditions.each_with_index do |condition, index| [[ <%= condition %> ]]<%= index == conditions.size - 1 ? '' : ' &&' %> % end then route_id=<%= pattern_route_id route %> + route_word_count=<%= pattern_route_word_count route %> + route_has_positionals=<%= route[:positionals].empty? ? 0 : 1 %> positional_index=$((${#non_options[@]} - <%= pattern_route_word_count route %>)) fi % end COMPREPLY=() - if [[ -z "$route_id" ]]; then + if [[ -z "$route_id" ]] || { (( route_word_count == 0 )) && (( !route_has_positionals )) && [[ "${cur:0:1}" != "-" ]]; }; then while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "<%= bash_double_quote_escape pattern_root_words.join(' ') %>" -- "$cur") return fi diff --git a/spec/approvals/completions/script-pattern b/spec/approvals/completions/script-pattern index f4ec915..4b8788d 100644 --- a/spec/approvals/completions/script-pattern +++ b/spec/approvals/completions/script-pattern @@ -45,24 +45,32 @@ _mygit_completions() { done local route_id= + local route_word_count=-1 + local route_has_positionals=0 local positional_index=0 if (( ${#non_options[@]} >= 1 )) && + (( 1 > route_word_count )) && [[ "${non_options[0]}" == "init" ]] then route_id=0 + route_word_count=1 + route_has_positionals=1 positional_index=$((${#non_options[@]} - 1)) fi if (( ${#non_options[@]} >= 1 )) && + (( 1 > route_word_count )) && [[ "${non_options[0]}" == "status" || "${non_options[0]}" == "st" ]] then route_id=1 + route_word_count=1 + route_has_positionals=0 positional_index=$((${#non_options[@]} - 1)) fi COMPREPLY=() - if [[ -z "$route_id" ]]; then + if [[ -z "$route_id" ]] || { (( route_word_count == 0 )) && (( !route_has_positionals )) && [[ "${cur:0:1}" != "-" ]]; }; then while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "init status st" -- "$cur") return fi diff --git a/spec/approvals/completions/script-pattern-complete-options b/spec/approvals/completions/script-pattern-complete-options index 36e0971..a1977db 100644 --- a/spec/approvals/completions/script-pattern-complete-options +++ b/spec/approvals/completions/script-pattern-complete-options @@ -44,17 +44,22 @@ _cli_completions() { done local route_id= + local route_word_count=-1 + local route_has_positionals=0 local positional_index=0 if (( ${#non_options[@]} >= 1 )) && + (( 1 > route_word_count )) && [[ "${non_options[0]}" == "set" ]] then route_id=0 + route_word_count=1 + route_has_positionals=1 positional_index=$((${#non_options[@]} - 1)) fi COMPREPLY=() - if [[ -z "$route_id" ]]; then + if [[ -z "$route_id" ]] || { (( route_word_count == 0 )) && (( !route_has_positionals )) && [[ "${cur:0:1}" != "-" ]]; }; then while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "set" -- "$cur") return fi diff --git a/spec/completely/pattern_config_integration.yml b/spec/completely/pattern_config_integration.yml index a2b555a..f21054a 100644 --- a/spec/completely/pattern_config_integration.yml +++ b/spec/completely/pattern_config_integration.yml @@ -38,6 +38,38 @@ complete_options: - compline: "cli set " expected: ['0', '10', '100', '20', '30', '40', '50', '60', '70', '80', '90'] +route-specificity: +- compline: "cli " + expected: [r, repo] + +- compline: "cli -" + expected: [--root, --user] + +- compline: "cli repo -" + expected: [--repo, --user] + +- compline: "cli repo remote -" + expected: [--remote] + +- compline: "cli r rm -" + expected: [--remote] + +- compline: "cli repo --root remote -" + expected: [--remote] + +- compline: "cli repo --user admin remote -" + expected: [--remote] + +route-specificity-reversed: +- compline: "cli repo remote -" + expected: [--remote] + +- compline: "cli repo -" + expected: [--repo] + +- compline: "cli -" + expected: [--root] + repeatable: - compline: "cli download -" expected: [--help, --protocol, --user, --verbose, --version, -h, -p, -u, -v] diff --git a/spec/fixtures/pattern-config/route-specificity-reversed.yaml b/spec/fixtures/pattern-config/route-specificity-reversed.yaml new file mode 100644 index 0000000..dd39aac --- /dev/null +++ b/spec/fixtures/pattern-config/route-specificity-reversed.yaml @@ -0,0 +1,13 @@ +patterns: + - cli repo|r remote|rm [remote options] + - cli repo|r [repo options] + - cli [root options] + +options: + root: + - --root + repo: + - --repo + remote: + - --remote + diff --git a/spec/fixtures/pattern-config/route-specificity.yaml b/spec/fixtures/pattern-config/route-specificity.yaml new file mode 100644 index 0000000..525c85a --- /dev/null +++ b/spec/fixtures/pattern-config/route-specificity.yaml @@ -0,0 +1,17 @@ +patterns: + - cli [root options] + - cli repo|r [repo options] + - cli repo|r remote|rm [remote options] + +options: + root: + - --root + - --user + repo: + - --repo + - --user + remote: + - --remote + +tokens: + user: [admin, guest] From 2366a500d4dd5e72ee656c4f7e88a789649fda37 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Wed, 1 Jul 2026 12:15:51 +0300 Subject: [PATCH 18/18] code review --- lib/completely/commands/init.rb | 10 +++++++--- lib/completely/completions.rb | 11 ++++++++++- spec/completely/completions_spec.rb | 16 ++++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/lib/completely/commands/init.rb b/lib/completely/commands/init.rb index 7bbb0ba..d891b9c 100644 --- a/lib/completely/commands/init.rb +++ b/lib/completely/commands/init.rb @@ -32,18 +32,22 @@ def format def sample_path @sample_path ||= begin - raise Error, "Invalid format: #{format}" unless %w[flat nested pattern].include? format + raise Error, "Invalid format: #{format}" unless sample_filenames.key? format File.expand_path "../templates/#{sample_filename}", __dir__ end end def sample_filename - { + sample_filenames.fetch format + end + + def sample_filenames + @sample_filenames ||= { 'flat' => 'flat-config/sample.yaml', 'nested' => 'flat-config/sample-nested.yaml', 'pattern' => 'pattern-config/sample.yaml', - }[format] + } end end end diff --git a/lib/completely/completions.rb b/lib/completely/completions.rb index e74be88..90b3c21 100644 --- a/lib/completely/completions.rb +++ b/lib/completely/completions.rb @@ -16,7 +16,7 @@ def read(io, function_name: nil) end def initialize(config, function_name: nil) - @config = config.respond_to?(:flat_config) ? config : Config.build(config) + @config = normalize_config config @function_name = function_name end @@ -168,5 +168,14 @@ def bash_escape(value) def bash_double_quote_escape(value) value.to_s.gsub('\\', '\\\\\\').gsub('"', '\\"') end + + def normalize_config(config) + case config + when FlatConfig, PatternConfig + config + else + Config.build config + end + end end end diff --git a/spec/completely/completions_spec.rb b/spec/completely/completions_spec.rb index bb1602e..04c39e2 100644 --- a/spec/completely/completions_spec.rb +++ b/spec/completely/completions_spec.rb @@ -11,6 +11,22 @@ end end + describe '#initialize' do + it 'builds a config from a hash' do + completions = described_class.new({ 'cli' => %w[--help --version] }) + + expect(completions.config).to be_a FlatConfig + expect(completions.config.config).to eq({ 'cli' => %w[--help --version] }) + end + + it 'accepts a built config object' do + config = FlatConfig.new({ 'cli' => %w[--help --version] }) + completions = described_class.new config + + expect(completions.config).to be config + end + end + describe '#valid?' do context 'when all patterns start with the same word' do it 'returns true' do