Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,36 @@ The `patterns` section describes valid command shapes:
- `<token>` references `tokens.token`.
- `<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] <source> <dest>

options:
global:
- --config <file>
container:
- --latest
cp:
- -a|--archive

tokens:
file: +file
source: [container:/app, local.txt]
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
Expand Down
2 changes: 1 addition & 1 deletion lib/completely/commands/init.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 35 additions & 22 deletions lib/completely/completions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 64 additions & 23 deletions lib/completely/pattern_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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..] || [] }
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions lib/completely/templates/pattern-config/sample.yaml
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
patterns:
- mygit [root options]
- mygit [root options] clone [clone options] <source> <dest>
- mygit init [init options] <directory>
- mygit status [status options]

options:
root:
- -h|--help
- -v|--version
- --config <file>
clone:
- --depth <depth>
init:
- --bare
status:
Expand All @@ -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]
Loading
Loading