From aa56f0627be26c26d1c8999c1d6d71cd4509bb3e Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Thu, 2 Jul 2026 11:49:01 +0300 Subject: [PATCH 1/4] - Refactor to a tree walk pattern --- lib/completely/commands/init.rb | 2 +- lib/completely/completions.rb | 57 +++++---- lib/completely/pattern_config.rb | 87 ++++++++++---- .../templates/pattern-config/template.erb | 111 ++++++++++-------- spec/approvals/completions/script-pattern | 92 ++++++++------- .../script-pattern-complete-options | 70 ++++++----- .../completely/pattern_config_integration.yml | 31 +++++ spec/completely/pattern_config_spec.rb | 100 ++++++++++++---- .../fixtures/pattern-config/tree-options.yaml | 17 +++ 9 files changed, 380 insertions(+), 187 deletions(-) create mode 100644 spec/fixtures/pattern-config/tree-options.yaml diff --git a/lib/completely/commands/init.rb b/lib/completely/commands/init.rb index d891b9c..2ecb7b5 100644 --- a/lib/completely/commands/init.rb +++ b/lib/completely/commands/init.rb @@ -32,7 +32,7 @@ def format def sample_path @sample_path ||= begin - raise Error, "Invalid format: #{format}" unless sample_filenames.key? format + raise Error, "Invalid format: #{format}" unless sample_filenames.has_key? format File.expand_path "../templates/#{sample_filename}", __dir__ end diff --git a/lib/completely/completions.rb b/lib/completely/completions.rb index b2bad21..622b189 100644 --- a/lib/completely/completions.rb +++ b/lib/completely/completions.rb @@ -101,48 +101,61 @@ def pattern_config? config.is_a? PatternConfig end - def pattern_routes - config.model[:routes] + def pattern_tree + config.model[:tree] end - def pattern_programs - pattern_routes.map { |route| route.dig(:words, 0, :name) } + def pattern_nodes + @pattern_nodes ||= flatten_pattern_tree pattern_tree end - def pattern_root_words - pattern_routes.flat_map do |route| - word = route[:words][1] - word ? [word[:name], *word[:aliases]] : [] - end.uniq + def pattern_programs + config.model[:programs] end - def pattern_route_id(route) - pattern_routes.index route + def pattern_node_id(node) + pattern_nodes.index { |entry| entry[:node].equal? node } 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(' || ') + def pattern_node_options(node) + node[:option_groups].flat_map do |name| + config.model[:options][name] || [] end end - def pattern_route_word_count(route) - route[:words].size - 1 + def pattern_node_depth(node) + pattern_nodes.dig(pattern_node_id(node), :depth) end - def pattern_route_options(route) - route[:option_groups].flat_map do |name| - config.model[:options][name] || [] + def pattern_child_transitions(node) + node[:children].flat_map do |child| + pattern_word_names(child[:word]).map do |name| + { name: name, node: child } + end end end + def pattern_node_child_words(node) + node[:children].flat_map { |child| pattern_word_names child[:word] }.uniq + end + def pattern_has_unique_options? - pattern_routes.any? do |route| - pattern_route_options(route).any? { |option| !option[:repeatable] } + pattern_nodes.any? do |entry| + pattern_node_options(entry[:node]).any? { |option| !option[:repeatable] } end end + def pattern_word_names(word) + [word[:name], *word[:aliases]] + end + + def flatten_pattern_tree(node, depth = 0) + [ + { node: node, depth: depth }, + *node[:children].flat_map { |child| flatten_pattern_tree child, depth + 1 }, + ] + end + def pattern_source_empty?(source) source[:items].empty? end diff --git a/lib/completely/pattern_config.rb b/lib/completely/pattern_config.rb index d525c0e..aad0a19 100644 --- a/lib/completely/pattern_config.rb +++ b/lib/completely/pattern_config.rb @@ -11,10 +11,11 @@ def model validate! @model ||= { - program: program, - routes: routes, - options: parsed_options, - tokens: tokens, + program: program, + programs: programs, + tree: tree, + options: parsed_options, + tokens: tokens, } end @@ -37,11 +38,63 @@ def token_sources end def program - routes.first.dig(:words, 0, :name) + programs.first end - def routes - @routes ||= patterns.map { |pattern| parse_pattern pattern } + def programs + @programs ||= patterns.filter_map do |pattern| + part = pattern_parts(pattern).find { |pattern_part| command_word? pattern_part } + parse_word(part)[:name] if part + end + end + + def tree + @tree ||= begin + root = nil + + patterns.each do |pattern| + current = nil + + pattern_parts(pattern).each do |part| + if option_group?(part) + add_option_group current, option_group_name(part) + elsif token?(part) + current[:positionals] << parse_token(part) + else + word = parse_word part + current = current ? find_or_create_child(current, word) : (root ||= build_tree_node(word)) + merge_word! current[:word], word + end + end + end + + root + end + end + + def build_tree_node(word) + { word: word, option_groups: [], positionals: [], children: [] } + end + + def add_option_group(node, name) + node[:option_groups] << name unless node[:option_groups].include? name + end + + def find_or_create_child(node, word) + node[:children].find { |child| same_word? child[:word], word } || + node[:children].tap { |children| children << build_tree_node(word) }.last + end + + def same_word?(left, right) + word_names(left).intersect? word_names(right) + end + + def word_names(word) + [word[:name], *word[:aliases]] + end + + def merge_word!(target, source) + target[:aliases] = (word_names(target) | word_names(source)) - [target[:name]] end def parsed_options @@ -99,22 +152,6 @@ def option_tokens 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..] || [] } @@ -182,6 +219,10 @@ def option_group?(part) part.start_with?('[') && part.end_with?(']') end + def command_word?(part) + !option_group?(part) && !token?(part) + end + def option_group_name(part) part[1..-2].sub(/\s+options\z/, '') end diff --git a/lib/completely/templates/pattern-config/template.erb b/lib/completely/templates/pattern-config/template.erb index 5e3496a..d2073a7 100644 --- a/lib/completely/templates/pattern-config/template.erb +++ b/lib/completely/templates/pattern-config/template.erb @@ -4,11 +4,12 @@ # completely (https://github.com/bashly-framework/completely) # Modifying it manually is not recommended -<%= function_name %>_route_flag_expects_value() { +<%= function_name %>_node_flag_expects_value() { case "$1:$2" 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}:") %>) return 0 ;; +% pattern_nodes.each do |entry| +% node = entry[:node] +% pattern_node_options(node).select { |option| option[:value] }.each do |option| + <%= pattern_node_id node %>:<%= option[:names].map { |name| bash_escape name }.join("|#{pattern_node_id node}:") %>) return 0 ;; % end % end esac @@ -29,26 +30,30 @@ } % end -<%= function_name %>_resolve_route() { - route_id= - route_word_count=-1 - route_has_positionals=0 +<%= function_name %>_resolve_node() { + node_id=0 + node_word_count=0 positional_index=0 -% pattern_routes.each do |route| -% conditions = pattern_route_conditions(route) - 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 + local word + for word in "${non_options[@]}"; do + case "$node_id:$word" in +% pattern_nodes.each do |entry| +% node = entry[:node] +% pattern_child_transitions(node).each do |transition| + <%= pattern_node_id node %>:<%= bash_escape transition[:name] %>) + node_id=<%= pattern_node_id transition[:node] %> + node_word_count=<%= pattern_node_depth transition[:node] %> + ;; % end +% end + *) + break + ;; + esac + done + + positional_index=$((${#non_options[@]} - node_word_count)) } <%= function_name %>() { @@ -65,11 +70,10 @@ local non_options=() local completed_options=() - local route_id= - local route_word_count=-1 - local route_has_positionals=0 + local node_id= + local node_word_count=-1 local positional_index=0 - <%= function_name %>_resolve_route + <%= function_name %>_resolve_node local skip_next=0 for word in "${completed[@]}"; do @@ -80,27 +84,23 @@ if [[ "${word:0:1}" == "-" ]]; then completed_options+=("$word") - if <%= function_name %>_route_flag_expects_value "$route_id" "$word"; then + if <%= function_name %>_node_flag_expects_value "$node_id" "$word"; then skip_next=1 fi continue fi non_options+=("$word") - <%= function_name %>_resolve_route + <%= function_name %>_resolve_node done COMPREPLY=() - 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 - - 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}:") %>) + case "$node_id:$prev" in +% pattern_nodes.each do |entry| +% node = entry[:node] +% pattern_node_options(node).select { |option| option[:value] }.each do |option| + <%= pattern_node_id node %>:<%= option[:names].map { |name| bash_escape name }.join("|#{pattern_node_id node}:") %>) % if pattern_source_empty? option[:value][:source] return % else @@ -112,12 +112,27 @@ % end esac + if [[ "${cur:0:1}" != "-" ]] && (( positional_index == 0 )); then + case "$node_id" in +% pattern_nodes.each do |entry| +% node = entry[:node] +% child_words = pattern_node_child_words(node) +% next if child_words.empty? + <%= pattern_node_id node %>) + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "<%= bash_double_quote_escape child_words.join(' ') %>" -- "$cur") + return + ;; +% end + esac + fi + if [[ "${cur:0:1}" == "-" ]]; then - case "$route_id" in -% pattern_routes.each do |route| - <%= pattern_route_id route %>) + case "$node_id" in +% pattern_nodes.each do |entry| +% node = entry[:node] + <%= pattern_node_id node %>) local words=() -% pattern_route_options(route).each do |option| +% pattern_node_options(node).each do |option| % if option[:repeatable] words+=(<%= option[:names].map { |name| %["#{bash_escape name}"] }.join(' ') %>) % else @@ -131,10 +146,11 @@ esac fi -% pattern_routes.each do |route| -% route[:positionals].each_with_index do |positional, index| +% pattern_nodes.each do |entry| +% node = entry[:node] +% node[:positionals].each_with_index do |positional, index| % next unless positional[:repeatable] - if [[ "$route_id" == "<%= pattern_route_id route %>" ]] && (( positional_index >= <%= index %> )); then + if [[ "$node_id" == "<%= pattern_node_id node %>" ]] && (( positional_index >= <%= index %> )); then % if pattern_source_empty? positional[:source] return % else @@ -145,11 +161,12 @@ % end % end - case "$route_id:$positional_index" in -% pattern_routes.each do |route| -% route[:positionals].each_with_index do |positional, index| + case "$node_id:$positional_index" in +% pattern_nodes.each do |entry| +% node = entry[:node] +% node[:positionals].each_with_index do |positional, index| % next if positional[:repeatable] - <%= pattern_route_id route %>:<%= index %>) + <%= pattern_node_id node %>:<%= index %>) % if pattern_source_empty? positional[:source] return % else diff --git a/spec/approvals/completions/script-pattern b/spec/approvals/completions/script-pattern index 1e99bda..8fb2302 100644 --- a/spec/approvals/completions/script-pattern +++ b/spec/approvals/completions/script-pattern @@ -4,9 +4,9 @@ # completely (https://github.com/bashly-framework/completely) # Modifying it manually is not recommended -_mygit_completions_route_flag_expects_value() { +_mygit_completions_node_flag_expects_value() { case "$1:$2" in - 1:--branch|1:-b) return 0 ;; + 2:--branch|2:-b) return 0 ;; esac return 1 @@ -23,31 +23,33 @@ _mygit_completions_option_seen() { return 1 } -_mygit_completions_resolve_route() { - route_id= - route_word_count=-1 - route_has_positionals=0 +_mygit_completions_resolve_node() { + node_id=0 + node_word_count=0 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 + local word + for word in "${non_options[@]}"; do + case "$node_id:$word" in + 0:init) + node_id=1 + node_word_count=1 + ;; + 0:status) + node_id=2 + node_word_count=1 + ;; + 0:st) + node_id=2 + node_word_count=1 + ;; + *) + break + ;; + esac + done + positional_index=$((${#non_options[@]} - node_word_count)) } _mygit_completions() { @@ -64,11 +66,10 @@ _mygit_completions() { local non_options=() local completed_options=() - local route_id= - local route_word_count=-1 - local route_has_positionals=0 + local node_id= + local node_word_count=-1 local positional_index=0 - _mygit_completions_resolve_route + _mygit_completions_resolve_node local skip_next=0 for word in "${completed[@]}"; do @@ -79,39 +80,48 @@ _mygit_completions() { if [[ "${word:0:1}" == "-" ]]; then completed_options+=("$word") - if _mygit_completions_route_flag_expects_value "$route_id" "$word"; then + if _mygit_completions_node_flag_expects_value "$node_id" "$word"; then skip_next=1 fi continue fi non_options+=("$word") - _mygit_completions_resolve_route + _mygit_completions_resolve_node done COMPREPLY=() - 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 - - case "$route_id:$prev" in - 1:--branch|1:-b) + case "$node_id:$prev" in + 2:--branch|2:-b) while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(echo main dev)" -- "$cur") return ;; esac + if [[ "${cur:0:1}" != "-" ]] && (( positional_index == 0 )); then + case "$node_id" in + 0) + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "init status st" -- "$cur") + return + ;; + esac + fi + if [[ "${cur:0:1}" == "-" ]]; then - case "$route_id" in + case "$node_id" in 0) local words=() - _mygit_completions_option_seen "--bare" || words+=("--bare") while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "${words[*]}" -- "$cur") return ;; 1) + local words=() + _mygit_completions_option_seen "--bare" || words+=("--bare") + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "${words[*]}" -- "$cur") + return + ;; + 2) local words=() _mygit_completions_option_seen "--verbose" "-v" || words+=("--verbose" "-v") _mygit_completions_option_seen "--branch" "-b" || words+=("--branch" "-b") @@ -121,8 +131,8 @@ _mygit_completions() { esac fi - case "$route_id:$positional_index" in - 0:0) + case "$node_id:$positional_index" in + 1:0) while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -A directory -- "$cur") return ;; diff --git a/spec/approvals/completions/script-pattern-complete-options b/spec/approvals/completions/script-pattern-complete-options index 7d20c53..bf21ab5 100644 --- a/spec/approvals/completions/script-pattern-complete-options +++ b/spec/approvals/completions/script-pattern-complete-options @@ -4,28 +4,32 @@ # completely (https://github.com/bashly-framework/completely) # Modifying it manually is not recommended -_cli_completions_route_flag_expects_value() { +_cli_completions_node_flag_expects_value() { case "$1:$2" in esac return 1 } -_cli_completions_resolve_route() { - route_id= - route_word_count=-1 - route_has_positionals=0 +_cli_completions_resolve_node() { + node_id=0 + node_word_count=0 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 + local word + for word in "${non_options[@]}"; do + case "$node_id:$word" in + 0:set) + node_id=1 + node_word_count=1 + ;; + *) + break + ;; + esac + done + + positional_index=$((${#non_options[@]} - node_word_count)) } _cli_completions() { @@ -42,11 +46,10 @@ _cli_completions() { local non_options=() local completed_options=() - local route_id= - local route_word_count=-1 - local route_has_positionals=0 + local node_id= + local node_word_count=-1 local positional_index=0 - _cli_completions_resolve_route + _cli_completions_resolve_node local skip_next=0 for word in "${completed[@]}"; do @@ -57,38 +60,47 @@ _cli_completions() { if [[ "${word:0:1}" == "-" ]]; then completed_options+=("$word") - if _cli_completions_route_flag_expects_value "$route_id" "$word"; then + if _cli_completions_node_flag_expects_value "$node_id" "$word"; then skip_next=1 fi continue fi non_options+=("$word") - _cli_completions_resolve_route + _cli_completions_resolve_node done COMPREPLY=() - 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 - - case "$route_id:$prev" in + case "$node_id:$prev" in esac + if [[ "${cur:0:1}" != "-" ]] && (( positional_index == 0 )); then + case "$node_id" in + 0) + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "set" -- "$cur") + return + ;; + esac + fi + if [[ "${cur:0:1}" == "-" ]]; then - case "$route_id" in + case "$node_id" in 0) local words=() while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "${words[*]}" -- "$cur") return ;; + 1) + local words=() + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "${words[*]}" -- "$cur") + return + ;; esac fi - case "$route_id:$positional_index" in - 0:0) + case "$node_id:$positional_index" in + 1:0) while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "0 10 20 30 40 50 60 70 80 90 100" -- "$cur") return ;; diff --git a/spec/completely/pattern_config_integration.yml b/spec/completely/pattern_config_integration.yml index 68b5cc1..9c5ad02 100644 --- a/spec/completely/pattern_config_integration.yml +++ b/spec/completely/pattern_config_integration.yml @@ -168,3 +168,34 @@ repeatable-positionals: - compline: "cli copy source1 target1 " expected: [target1, target2] + +tree-options: +- compline: "docker " + expected: [container] + +- compline: "docker -" + expected: [--config, --quiet] + +- compline: "docker --config " + expected: [config-a, config-b] + +- compline: "docker --config config-a container " + expected: [cp] + +- compline: "docker container " + expected: [cp] + +- compline: "docker container -" + expected: [--latest] + +- compline: "docker container cp -" + expected: [--archive, -a] + +- compline: "docker container cp --" + expected: [--archive] + +- compline: "docker container cp " + expected: [container:/app, local.txt] + +- compline: "docker container cp local.txt " + expected: [./out, container:/tmp] diff --git a/spec/completely/pattern_config_spec.rb b/spec/completely/pattern_config_spec.rb index ca7d353..db60917 100644 --- a/spec/completely/pattern_config_spec.rb +++ b/spec/completely/pattern_config_spec.rb @@ -6,28 +6,8 @@ expect(config.model[:program]).to eq 'mygit' end - 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 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: { items: [{ type: :builtin, value: 'directory' }] } }], - [], - ] + it 'returns program names from all patterns' do + expect(config.model[:programs]).to eq %w[mygit mygit] end it 'returns init options' do @@ -58,6 +38,34 @@ 'branch' => { items: [{ type: :value, value: '$(echo main dev)' }] } ) end + + it 'returns the command tree root' do + tree = config.model[:tree] + + expect(tree[:word]).to eq(name: 'mygit', aliases: []) + expect(tree[:option_groups]).to eq [] + expect(tree[:positionals]).to eq [] + end + + it 'returns child command nodes' do + children = config.model[:tree][:children] + + expect(children.map { |child| child[:word] }).to eq [ + { name: 'init', aliases: [] }, + { name: 'status', aliases: ['st'] }, + ] + end + + it 'returns child node option groups and positionals' do + init, status = config.model[:tree][:children] + + expect(init[:option_groups]).to eq ['init'] + expect(init[:positionals]).to eq [ + { name: 'directory', source: { items: [{ type: :builtin, value: 'directory' }] } }, + ] + expect(status[:option_groups]).to eq ['status'] + expect(status[:positionals]).to eq [] + end end describe '#flat_config' do @@ -107,7 +115,7 @@ end it 'uses the empty source for positionals' do - expect(config.model[:routes].first[:positionals].first).to eq( + expect(config.model[:tree][:children].first[:positionals].first).to eq( name: 'source', source: { items: [] } ) @@ -171,7 +179,7 @@ end it 'marks repeatable positionals' do - expect(config.model[:routes].first[:positionals]).to eq [ + expect(config.model[:tree][:children].first[:positionals]).to eq [ { name: 'file', repeatable: true, @@ -199,4 +207,48 @@ expect { config.model }.to raise_error Completely::ParseError, 'Unknown option metadata: (hidden)' end end + + context 'with option groups between command words' do + subject(:config) do + Config.parse <<~YAML + patterns: + - docker [global options] container [container options] + - docker [global options] container push [push options] + + options: + global: + - --config + container: + - --context + push: + - --all-tags + + tokens: + file: +file + context: [default, remote] + container: [app, worker] + YAML + end + + it 'attaches option groups to the command node where they appear' do + tree = config.model[:tree] + container = tree[:children].first + push = container[:children].first + + expect(tree[:option_groups]).to eq ['global'] + expect(container[:option_groups]).to eq ['container'] + expect(push[:option_groups]).to eq ['push'] + end + + it 'keeps positionals on the command node where they appear' do + push = config.model[:tree][:children].first[:children].first + + expect(push[:positionals]).to eq [ + { + name: 'container', + source: { items: [{ type: :value, value: 'app' }, { type: :value, value: 'worker' }] }, + }, + ] + end + end end diff --git a/spec/fixtures/pattern-config/tree-options.yaml b/spec/fixtures/pattern-config/tree-options.yaml new file mode 100644 index 0000000..a422b3c --- /dev/null +++ b/spec/fixtures/pattern-config/tree-options.yaml @@ -0,0 +1,17 @@ +patterns: + - docker [global options] container [container options] + - docker [global options] container cp [cp options] + +options: + global: + - --quiet + - --config + container: + - --latest + cp: + - -a|--archive + +tokens: + file: [config-a, config-b] + source: [container:/app, local.txt] + dest: [container:/tmp, ./out] From a1b693670a8a6a82197aeb92bc823a36e7676648 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Thu, 2 Jul 2026 12:07:20 +0300 Subject: [PATCH 2/4] - Stop offering suggestions on invalid command line --- .../templates/pattern-config/template.erb | 20 +++++++++++++++++++ spec/approvals/completions/script-pattern | 17 ++++++++++++++++ .../script-pattern-complete-options | 14 +++++++++++++ .../completely/pattern_config_integration.yml | 12 +++++++++++ 4 files changed, 63 insertions(+) diff --git a/lib/completely/templates/pattern-config/template.erb b/lib/completely/templates/pattern-config/template.erb index d2073a7..96f29b6 100644 --- a/lib/completely/templates/pattern-config/template.erb +++ b/lib/completely/templates/pattern-config/template.erb @@ -17,6 +17,19 @@ return 1 } +<%= function_name %>_node_has_flag() { + case "$1:$2" in +% pattern_nodes.each do |entry| +% node = entry[:node] +% pattern_node_options(node).each do |option| + <%= pattern_node_id node %>:<%= option[:names].map { |name| bash_escape name }.join("|#{pattern_node_id node}:") %>) return 0 ;; +% end +% end + esac + + return 1 +} + % if pattern_has_unique_options? <%= function_name %>_option_seen() { local completed_option option_name @@ -73,6 +86,7 @@ local node_id= local node_word_count=-1 local positional_index=0 + local invalid_completion=0 <%= function_name %>_resolve_node local skip_next=0 @@ -83,6 +97,11 @@ fi if [[ "${word:0:1}" == "-" ]]; then + if ! <%= function_name %>_node_has_flag "$node_id" "$word"; then + invalid_completion=1 + break + fi + completed_options+=("$word") if <%= function_name %>_node_flag_expects_value "$node_id" "$word"; then skip_next=1 @@ -95,6 +114,7 @@ done COMPREPLY=() + (( invalid_completion )) && return case "$node_id:$prev" in % pattern_nodes.each do |entry| diff --git a/spec/approvals/completions/script-pattern b/spec/approvals/completions/script-pattern index 8fb2302..d324330 100644 --- a/spec/approvals/completions/script-pattern +++ b/spec/approvals/completions/script-pattern @@ -12,6 +12,16 @@ _mygit_completions_node_flag_expects_value() { return 1 } +_mygit_completions_node_has_flag() { + case "$1:$2" in + 1:--bare) return 0 ;; + 2:--verbose|2:-v) return 0 ;; + 2:--branch|2:-b) return 0 ;; + esac + + return 1 +} + _mygit_completions_option_seen() { local completed_option option_name for completed_option in "${completed_options[@]}"; do @@ -69,6 +79,7 @@ _mygit_completions() { local node_id= local node_word_count=-1 local positional_index=0 + local invalid_completion=0 _mygit_completions_resolve_node local skip_next=0 @@ -79,6 +90,11 @@ _mygit_completions() { fi if [[ "${word:0:1}" == "-" ]]; then + if ! _mygit_completions_node_has_flag "$node_id" "$word"; then + invalid_completion=1 + break + fi + completed_options+=("$word") if _mygit_completions_node_flag_expects_value "$node_id" "$word"; then skip_next=1 @@ -91,6 +107,7 @@ _mygit_completions() { done COMPREPLY=() + (( invalid_completion )) && return case "$node_id:$prev" in 2:--branch|2:-b) diff --git a/spec/approvals/completions/script-pattern-complete-options b/spec/approvals/completions/script-pattern-complete-options index bf21ab5..3a09b49 100644 --- a/spec/approvals/completions/script-pattern-complete-options +++ b/spec/approvals/completions/script-pattern-complete-options @@ -11,6 +11,13 @@ _cli_completions_node_flag_expects_value() { return 1 } +_cli_completions_node_has_flag() { + case "$1:$2" in + esac + + return 1 +} + _cli_completions_resolve_node() { node_id=0 node_word_count=0 @@ -49,6 +56,7 @@ _cli_completions() { local node_id= local node_word_count=-1 local positional_index=0 + local invalid_completion=0 _cli_completions_resolve_node local skip_next=0 @@ -59,6 +67,11 @@ _cli_completions() { fi if [[ "${word:0:1}" == "-" ]]; then + if ! _cli_completions_node_has_flag "$node_id" "$word"; then + invalid_completion=1 + break + fi + completed_options+=("$word") if _cli_completions_node_flag_expects_value "$node_id" "$word"; then skip_next=1 @@ -71,6 +84,7 @@ _cli_completions() { done COMPREPLY=() + (( invalid_completion )) && return case "$node_id:$prev" in esac diff --git a/spec/completely/pattern_config_integration.yml b/spec/completely/pattern_config_integration.yml index 9c5ad02..82fc3f0 100644 --- a/spec/completely/pattern_config_integration.yml +++ b/spec/completely/pattern_config_integration.yml @@ -55,6 +55,9 @@ route-specificity: expected: [--remote] - compline: "cli repo --root remote -" + expected: [] + +- compline: "cli --root repo remote -" expected: [--remote] - compline: "cli repo --user admin remote -" @@ -182,12 +185,18 @@ tree-options: - compline: "docker --config config-a container " expected: [cp] +- compline: "docker --bad container " + expected: [] + - compline: "docker container " expected: [cp] - compline: "docker container -" expected: [--latest] +- compline: "docker container --bad " + expected: [] + - compline: "docker container cp -" expected: [--archive, -a] @@ -199,3 +208,6 @@ tree-options: - compline: "docker container cp local.txt " expected: [./out, container:/tmp] + +- compline: "docker container cp --bad local.txt " + expected: [] From 0c0183ec3d59e9aa02c85950c0534efcc36ba7fa Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Thu, 2 Jul 2026 12:12:21 +0300 Subject: [PATCH 3/4] update docs --- README.md | 27 +++++++++++++++++++ .../templates/pattern-config/sample.yaml | 8 ++++++ schemas/completely.json | 1 + 3 files changed, 36 insertions(+) diff --git a/README.md b/README.md index 5955cd9..00faf89 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,33 @@ The `patterns` section describes valid command shapes: - `` references `tokens.token`. - `...` marks the final positional as repeatable. +Pattern config is compiled as a command tree. Option group placement is +therefore meaningful: an option group belongs to the command word immediately +before it. In the example above, `root` options belong to `mygit`, `init` +options belong to `mygit init`, and `status` options belong to `mygit status`. + +This is useful for commands that have global options and command-specific +options: + +```yaml +patterns: + - docker [global options] container [container options] + - docker [global options] container cp [cp options] + +options: + global: + - --config + container: + - --latest + cp: + - -a|--archive + +tokens: + file: +file + source: [container:/app, local.txt] + dest: [container:/tmp, ./out] +``` + The `options` section defines option groups: ```yaml diff --git a/lib/completely/templates/pattern-config/sample.yaml b/lib/completely/templates/pattern-config/sample.yaml index c307c2b..a8f7c8d 100644 --- a/lib/completely/templates/pattern-config/sample.yaml +++ b/lib/completely/templates/pattern-config/sample.yaml @@ -1,5 +1,6 @@ patterns: - mygit [root options] + - mygit [root options] clone [clone options] - mygit init [init options] - mygit status [status options] @@ -7,6 +8,9 @@ options: root: - -h|--help - -v|--version + - --config + clone: + - --depth init: - --bare status: @@ -16,6 +20,10 @@ options: - --verbose (repeatable) tokens: + file: +file + source: $(git branch --format='%(refname:short)' 2>/dev/null) + dest: +directory + depth: [1, 10, 100] directory: +directory branch: $(git branch --format='%(refname:short)' 2>/dev/null) format: [short, long] diff --git a/schemas/completely.json b/schemas/completely.json index 1c63049..414b038 100644 --- a/schemas/completely.json +++ b/schemas/completely.json @@ -28,6 +28,7 @@ ], "examples": [ "mygit [root options]", + "mygit [root options] clone [clone options] ", "mygit init [init options] ", "mygit upload ...", "mygit status|st [status options]" From 9c2fda47e634392f5370e1d83a21784efe860fac Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Thu, 2 Jul 2026 12:27:37 +0300 Subject: [PATCH 4/4] optimization --- README.md | 3 +++ .../templates/pattern-config/template.erb | 23 +++++++------------ spec/approvals/completions/script-pattern | 19 ++++++--------- .../script-pattern-complete-options | 16 +++++-------- 4 files changed, 24 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 00faf89..178fee3 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,9 @@ tokens: dest: [container:/tmp, ./out] ``` +If a completed command line contains an option that is not valid at the current +node, Completely stops offering suggestions for that command line. + The `options` section defines option groups: ```yaml diff --git a/lib/completely/templates/pattern-config/template.erb b/lib/completely/templates/pattern-config/template.erb index 96f29b6..2687470 100644 --- a/lib/completely/templates/pattern-config/template.erb +++ b/lib/completely/templates/pattern-config/template.erb @@ -4,24 +4,14 @@ # completely (https://github.com/bashly-framework/completely) # Modifying it manually is not recommended -<%= function_name %>_node_flag_expects_value() { +<%= function_name %>_node_flag_state() { case "$1:$2" in % pattern_nodes.each do |entry| % node = entry[:node] % pattern_node_options(node).select { |option| option[:value] }.each do |option| - <%= pattern_node_id node %>:<%= option[:names].map { |name| bash_escape name }.join("|#{pattern_node_id node}:") %>) return 0 ;; -% end + <%= pattern_node_id node %>:<%= option[:names].map { |name| bash_escape name }.join("|#{pattern_node_id node}:") %>) return 2 ;; % end - esac - - return 1 -} - -<%= function_name %>_node_has_flag() { - case "$1:$2" in -% pattern_nodes.each do |entry| -% node = entry[:node] -% pattern_node_options(node).each do |option| +% pattern_node_options(node).reject { |option| option[:value] }.each do |option| <%= pattern_node_id node %>:<%= option[:names].map { |name| bash_escape name }.join("|#{pattern_node_id node}:") %>) return 0 ;; % end % end @@ -87,6 +77,7 @@ local node_word_count=-1 local positional_index=0 local invalid_completion=0 + local flag_state=0 <%= function_name %>_resolve_node local skip_next=0 @@ -97,13 +88,15 @@ fi if [[ "${word:0:1}" == "-" ]]; then - if ! <%= function_name %>_node_has_flag "$node_id" "$word"; then + <%= function_name %>_node_flag_state "$node_id" "$word" + flag_state=$? + if (( flag_state == 1 )); then invalid_completion=1 break fi completed_options+=("$word") - if <%= function_name %>_node_flag_expects_value "$node_id" "$word"; then + if (( flag_state == 2 )); then skip_next=1 fi continue diff --git a/spec/approvals/completions/script-pattern b/spec/approvals/completions/script-pattern index d324330..1b97236 100644 --- a/spec/approvals/completions/script-pattern +++ b/spec/approvals/completions/script-pattern @@ -4,19 +4,11 @@ # completely (https://github.com/bashly-framework/completely) # Modifying it manually is not recommended -_mygit_completions_node_flag_expects_value() { - case "$1:$2" in - 2:--branch|2:-b) return 0 ;; - esac - - return 1 -} - -_mygit_completions_node_has_flag() { +_mygit_completions_node_flag_state() { case "$1:$2" in 1:--bare) return 0 ;; + 2:--branch|2:-b) return 2 ;; 2:--verbose|2:-v) return 0 ;; - 2:--branch|2:-b) return 0 ;; esac return 1 @@ -80,6 +72,7 @@ _mygit_completions() { local node_word_count=-1 local positional_index=0 local invalid_completion=0 + local flag_state=0 _mygit_completions_resolve_node local skip_next=0 @@ -90,13 +83,15 @@ _mygit_completions() { fi if [[ "${word:0:1}" == "-" ]]; then - if ! _mygit_completions_node_has_flag "$node_id" "$word"; then + _mygit_completions_node_flag_state "$node_id" "$word" + flag_state=$? + if (( flag_state == 1 )); then invalid_completion=1 break fi completed_options+=("$word") - if _mygit_completions_node_flag_expects_value "$node_id" "$word"; then + if (( flag_state == 2 )); then skip_next=1 fi continue diff --git a/spec/approvals/completions/script-pattern-complete-options b/spec/approvals/completions/script-pattern-complete-options index 3a09b49..eef4249 100644 --- a/spec/approvals/completions/script-pattern-complete-options +++ b/spec/approvals/completions/script-pattern-complete-options @@ -4,14 +4,7 @@ # completely (https://github.com/bashly-framework/completely) # Modifying it manually is not recommended -_cli_completions_node_flag_expects_value() { - case "$1:$2" in - esac - - return 1 -} - -_cli_completions_node_has_flag() { +_cli_completions_node_flag_state() { case "$1:$2" in esac @@ -57,6 +50,7 @@ _cli_completions() { local node_word_count=-1 local positional_index=0 local invalid_completion=0 + local flag_state=0 _cli_completions_resolve_node local skip_next=0 @@ -67,13 +61,15 @@ _cli_completions() { fi if [[ "${word:0:1}" == "-" ]]; then - if ! _cli_completions_node_has_flag "$node_id" "$word"; then + _cli_completions_node_flag_state "$node_id" "$word" + flag_state=$? + if (( flag_state == 1 )); then invalid_completion=1 break fi completed_options+=("$word") - if _cli_completions_node_flag_expects_value "$node_id" "$word"; then + if (( flag_state == 2 )); then skip_next=1 fi continue