Repository: comonicon/Comonicon.jl
Pull request #192 (2022-03-05 01:35:46)

support bash completion

codecov[bot]: # [Codecov](https://codecov.io/gh/comonicon/Comonicon.jl/pull/192?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
> Merging [#192](https://codecov.io/gh/comonicon/Comonicon.jl/pull/192?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (41eeba7) into [master](https://codecov.io/gh/comonicon/Comonicon.jl/commit/3f822e29461c12fa990312bc44785853ed19f39d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (3f822e2) will **decrease** coverage by `81.44%`.
> The diff coverage is `0.00%`.

> :exclamation: Current head 41eeba7 differs from pull request most recent head 94f1f55. Consider uploading reports for the commit 94f1f55 to get more accurate results

[![Impacted file tree graph](https://codecov.io/gh/comonicon/Comonicon.jl/pull/192/graphs/tree.svg?width=650&height=150&src=pr&token=zZjCxCiFTY&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)](https://codecov.io/gh/comonicon/Comonicon.jl/pull/192?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)

```diff
@@            Coverage Diff             @@
##           master    #192       +/-   ##
==========================================
- Coverage   82.41%   0.97%   -81.45%     
==========================================
  Files          20      20               
  Lines        1308    1336       +28     
==========================================
- Hits         1078      13     -1065     
- Misses        230    1323     +1093     
```


| [Impacted Files](https://codecov.io/gh/comonicon/Comonicon.jl/pull/192?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [src/argtypes.jl](https://codecov.io/gh/comonicon/Comonicon.jl/pull/192/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2FyZ3R5cGVzLmps) | `0.00% <0.00%> (-78.95%)` | :arrow_down: |
| [src/codegen/bash.jl](https://codecov.io/gh/comonicon/Comonicon.jl/pull/192/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2NvZGVnZW4vYmFzaC5qbA==) | `0.00% <0.00%> (ø)` | |
| [src/compat.jl](https://codecov.io/gh/comonicon/Comonicon.jl/pull/192/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2NvbXBhdC5qbA==) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [src/exceptions.jl](https://codecov.io/gh/comonicon/Comonicon.jl/pull/192/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2V4Y2VwdGlvbnMuamw=) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [src/builder/app.jl](https://codecov.io/gh/comonicon/Comonicon.jl/pull/192/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2J1aWxkZXIvYXBwLmps) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [src/frontend/markdown.jl](https://codecov.io/gh/comonicon/Comonicon.jl/pull/192/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2Zyb250ZW5kL21hcmtkb3duLmps) | `0.00% <0.00%> (-94.12%)` | :arrow_down: |
| [src/codegen/zsh.jl](https://codecov.io/gh/comonicon/Comonicon.jl/pull/192/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2NvZGVnZW4venNoLmps) | `0.00% <0.00%> (-91.08%)` | :arrow_down: |
| [src/builder/install.jl](https://codecov.io/gh/comonicon/Comonicon.jl/pull/192/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2J1aWxkZXIvaW5zdGFsbC5qbA==) | `0.00% <0.00%> (-89.86%)` | :arrow_down: |
| [src/builder/cli.jl](https://codecov.io/gh/comonicon/Comonicon.jl/pull/192/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2J1aWxkZXIvY2xpLmps) | `0.00% <0.00%> (-89.71%)` | :arrow_down: |
| [src/ast/printing.jl](https://codecov.io/gh/comonicon/Comonicon.jl/pull/192/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2FzdC9wcmludGluZy5qbA==) | `0.00% <0.00%> (-89.10%)` | :arrow_down: |
| ... and [10 more](https://codecov.io/gh/comonicon/Comonicon.jl/pull/192/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/comonicon/Comonicon.jl/pull/192?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/comonicon/Comonicon.jl/pull/192?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None). Last update [3f822e2...94f1f55](https://codecov.io/gh/comonicon/Comonicon.jl/pull/192?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).


Patch:
diff --git a/src/Comonicon.jl b/src/Comonicon.jl
index 4f6599d..539769e 100644
--- a/src/Comonicon.jl
+++ b/src/Comonicon.jl
@@ -19,6 +19,7 @@ include("argtypes.jl")
 include("ast/ast.jl")
 include("codegen/julia.jl")
 include("codegen/zsh.jl")
+include("codegen/bash.jl")
 include("tools.jl")
 
 include("frontend/frontend.jl")
diff --git a/src/argtypes.jl b/src/argtypes.jl
index 4c62f4a..8cbaf49 100644
--- a/src/argtypes.jl
+++ b/src/argtypes.jl
@@ -1,8 +1,6 @@
 module Arg
 
-export FileName, DirName,
-    UserName, Path, Prefix, Suffix,
-    @Prefix_str, @Suffix_str
+export FileName, DirName, UserName, Path, Prefix, Suffix, @Prefix_str, @Suffix_str
 
 abstract type ArgType end
 
@@ -38,8 +36,8 @@ macro Suffix_str(s::String)
     return Suffix{Symbol(s)}
 end
 
-Base.show(io::IO, x::Prefix{name}) where name = print(io, "Prefix\"", name, "\"")
-Base.show(io::IO, x::Suffix{name}) where name = print(io, "Suffix\"", name, "\"")
+Base.show(io::IO, x::Prefix{name}) where {name} = print(io, "Prefix\"", name, "\"")
+Base.show(io::IO, x::Suffix{name}) where {name} = print(io, "Suffix\"", name, "\"")
 
 # use rust-like enum instead?
 # @renum ArgType begin
@@ -51,7 +49,7 @@ Base.show(io::IO, x::Suffix{name}) where name = print(io, "Suffix\"", name, "\""
 #     AnyType(name::String)
 # end
 
-function Base.tryparse(::Type{T}, s::AbstractString) where {T <: ArgType}
+function Base.tryparse(::Type{T}, s::AbstractString) where {T<:ArgType}
     return T(s)
 end
 
diff --git a/src/ast/ast.jl b/src/ast/ast.jl
index 2f72323..0c3d9a0 100644
--- a/src/ast/ast.jl
+++ b/src/ast/ast.jl
@@ -1,9 +1,6 @@
 module AST
 
-export ComoniconExpr,
-    Description,
-    LeafCommand, NodeCommand,
-    Entry, Argument, Option, Flag, print_cmd
+export ComoniconExpr, Description, LeafCommand, NodeCommand, Entry, Argument, Option, Flag, print_cmd
 
 include("types.jl")
 include("printing.jl")
diff --git a/src/builder/builder.jl b/src/builder/builder.jl
index f34a390..542cc98 100644
--- a/src/builder/builder.jl
+++ b/src/builder/builder.jl
@@ -2,6 +2,7 @@ module Builder
 
 using ..Configs: Configs
 using ..ZSHCompletions
+using ..BashCompletions
 using ..Comonicon: get_version, Tools
 using PackageCompiler
 using Logging
diff --git a/src/builder/install.jl b/src/builder/install.jl
index 1df38b5..2ae8021 100644
--- a/src/builder/install.jl
+++ b/src/builder/install.jl
@@ -63,8 +63,12 @@ end
 
 function install_completion(m::Module, options::Configs.Comonicon, shell::String)
     completions_dir = install_path(options, "completions")
-    completion_file = joinpath(completions_dir, "_" * options.name)
-    shell in ["zsh"] || return # only emit supported shell
+    shell in ["zsh", "bash"] || return
+    if shell == "zsh"
+        completion_file = joinpath(completions_dir, "_" * options.name)
+    elseif shell == "bash"
+        completion_file = joinpath(completions_dir, options.name * "-" * "completion.bash")
+    end
     open(completion_file, "w+") do io
         print(io, completion_script(m, options, shell))
     end
@@ -78,6 +82,8 @@ function completion_script(m::Module, options::Configs.Comonicon, shell::String)
 
     if shell == "zsh"
         return ZSHCompletions.emit(main)
+    elseif shell == "bash"
+        return BashCompletions.emit(main)
     else
         error(
             "$shell autocompletion is not supported, " *
diff --git a/src/builder/rcfile.jl b/src/builder/rcfile.jl
index 2d71c98..48f81fc 100644
--- a/src/builder/rcfile.jl
+++ b/src/builder/rcfile.jl
@@ -14,9 +14,28 @@ function install_env_path(
         write_path(rcfile, install_path)
     end
 
-    msg = "cannot detect $(options.install.path)/completions in FPATH, do you want to add it in FPATH?"
-    if !contains_fpath(rcfile, install_path, env) && Tools.prompt(msg, yes)
-        write_fpath(rcfile, install_path)
+    if shell == "bash"
+        completions_script = install_path(options, "completions",
+                     options.name * "-" * "completion.bash")
+        println("please add the following line in your $rcfile to enable shell autocompletion")
+        println(" source $completions_script")
+        println()
+        # msg = "cannot detect bash completions, do you want to add it in $rcfile?"
+        # if Tools.prompt(msg, yes)
+        #     open(rcfile, "a") do io
+        #         completions_script = install_path(options, "completions",
+        #             options.name * "-" * "completion.bash")
+        #         script = "source $completions_script"
+        #         write(io, "\n" * script * "\n")
+        #     end
+        # end
+    end
+
+    if shell == "zsh"
+        msg = "cannot detect $(options.install.path)/completions in FPATH, do you want to add it in FPATH?"
+        if !contains_fpath(rcfile, install_path, env) && Tools.prompt(msg, yes)
+            write_fpath(rcfile, install_path)
+        end
     end
 
     @info "open a new terminal, or source $rcfile to enable the new PATH."
diff --git a/src/codegen/bash.jl b/src/codegen/bash.jl
new file mode 100644
index 0000000..edb494d
--- /dev/null
+++ b/src/codegen/bash.jl
@@ -0,0 +1,184 @@
+module BashCompletions
+
+using ..AST
+using ..Arg
+
+tab(n::Int) = " "^n
+
+indent(s::String, n::Int=1) = tab(4n) * s
+function indent(lines::Vector, n::Int=1)
+    indent.(lines, n)
+end
+
+function bash_function_name(tokens::Vector{String})
+    return string("_", join(tokens, "_"), "_completion")
+end
+
+function emit(cmd::Entry)
+    name = cmd.root.name
+    comp_func = bash_function_name([name])
+    return """
+    $(emit(cmd.root))
+
+    _$(name)_completion_entry() {
+        $comp_func 1
+    }
+
+    complete -o nospace -F _$(name)_completion_entry $(name)
+    """
+end
+
+function emit(cmd::NodeCommand, prefix::Vector{String}=String[])
+    subcmd_cases = ["case \"\$curr_word\" in"]
+    for name in keys(cmd.subcmds)
+        subcmd_func = bash_function_name([prefix..., cmd.name, name])
+        push!(subcmd_cases, indent("$name) $subcmd_func \$((curr_word_idx+1));;"))
+    end
+    push!(subcmd_cases, indent("*)     ;;"))
+    push!(subcmd_cases, "esac")
+
+    # NOTE: NodeCommand always complete based on the last word
+    available_cmds = join(keys(cmd.subcmds), " ")
+    script = """
+    $(bash_function_name([prefix..., cmd.name]))()
+    {
+        local curr_word_idx=\${1:-1}
+        local curr_word="\${COMP_WORDS[curr_word_idx]}"
+        if [[ "\$curr_word_idx" -eq "\$COMP_CWORD" ]]
+        then
+            COMPREPLY=(\$(compgen -W "$(available_cmds)" -- "\$curr_word"))
+            return # return early if we're still completing the 'current' command
+        fi
+
+    $(join(indent(indent(subcmd_cases)), '\n'))
+    }
+    """
+
+    for subcmd in values(cmd.subcmds)
+        script *= "\n\n"
+        script *= emit(subcmd, [prefix..., cmd.name])
+    end
+    return script
+end
+
+function emit(cmd::LeafCommand, prefix::Vector{String}=String[])
+    # last word doesn't match anything
+    # specifically, but start with -
+    # list all possible inputs
+    dash_comp = String[]
+    for flag in values(cmd.flags)
+        push!(dash_comp, string("--", flag.name))
+        flag.short && push!(dash_comp, string("-", flag.name))
+    end
+    push!(dash_comp, "-h", "--help")
+
+    # last word matches an option
+    # complete the option argument
+    option_comp = String[]
+    skip_option = String[]
+    for option in values(cmd.options)
+        long_name = string("--", option.name)
+        push!(option_comp, string(long_name, ")"))
+        push!(option_comp, indent(emit_compgen(option.type, "last_word")))
+        push!(option_comp, indent("return"))
+        push!(option_comp, indent(";;"))
+        push!(dash_comp, string(long_name, "="))
+
+        push!(skip_option, string(long_name, ")"))
+        push!(skip_option, indent("if [[ \"\${COMP_WORDS[i+1]}\" == \"=\" ]]"))
+        push!(skip_option, indent("then"))
+        push!(skip_option, indent("(( i+=2 ))", 2))
+        push!(skip_option, indent("else"))
+        push!(skip_option, indent("(( i+=1 ))", 2))
+        push!(skip_option, indent("fi"))
+        push!(skip_option, indent(";;"))
+
+        if option.short
+            short_name = string("-", first(option.name))
+            push!(option_comp, string(short_name, ")"))
+            push!(option_comp, indent(emit_compgen(option.type, "last_word")))
+            push!(option_comp, indent("return"))
+            push!(option_comp, indent(";;"))
+            push!(dash_comp, short_name)
+        end
+    end
+
+    # complete positional arguments
+    args_comp = String[]
+    for (k, arg) in enumerate(cmd.args)
+        push!(args_comp, "$k)")
+        push!(args_comp, indent(emit_compgen(arg.type, "last_word")))
+        push!(args_comp, indent(";;"))
+    end
+
+    vararg_comp = String[]
+    if isnothing(cmd.vararg)
+        push!(vararg_comp, "*)")
+        push!(vararg_comp, indent("COMPREPLY=()"))
+        push!(vararg_comp, indent(";; # too much arguments just ignore"))
+    else
+        push!(vararg_comp, "*)")
+        push!(vararg_comp, indent(emit_compgen(cmd.vararg.type, "last_word")))
+        push!(vararg_comp, indent(";;"))
+    end
+
+    """
+    $(bash_function_name([prefix..., cmd.name]))()
+    {
+        local curr_word_idx=\${1:-1}
+        local last_word="\${COMP_WORDS[COMP_CWORD]}"
+        local prev_word="\${COMP_WORDS[COMP_CWORD-1]}"
+
+        if [[ "\$prev_word" == "=" ]]
+        then
+            prev_word="\${COMP_WORDS[COMP_CWORD-2]}"
+        fi
+
+        if [[ "\$last_word" == "=" ]]
+        then
+            last_word=""
+        fi
+
+        case "\$prev_word" in
+    $(join(indent(option_comp, 2), '\n'))
+        esac
+
+        case "\$last_word" in
+            -*) # complete flag or option name
+                COMPREPLY=(\$(compgen -o nospace -W "$(join(dash_comp, " "))" -- "\$last_word"))
+                ;;
+            *) # complete arguments
+                # count which argument to complete
+                local narguments=0 i=\$curr_word_idx
+                while [[ "\$i" -lt "\$COMP_CWORD" ]]; do
+                    s="\${COMP_WORDS[i]}"
+                    case "\$s" in
+    $(join(indent(skip_option, 5), '\n'))
+                        -*)
+                            ;;
+                        *)  (( narguments++ ))
+                            ;;
+                    esac
+                    (( i++ ))
+                done
+                (( narguments++ ))
+                case "\$narguments" in
+    $(join(indent(args_comp, 4), '\n'))
+    $(join(indent(vararg_comp, 4), '\n'))
+                esac
+                ;;
+        esac
+    }
+    """
+end
+
+function emit_compgen(::Type{Arg.DirName}, word::String)
+    "COMPREPLY=(\$(compgen -d -- \"\$$(word)\"))"
+end
+
+# if don't know how to complete, just go with default
+function emit_compgen(::Type, word::String)
+    "COMPREPLY=(\$(compgen -o default -- \"\$$(word)\"))"
+end
+
+end
\ No newline at end of file


Test patch:
diff --git a/test/argtype.jl b/test/argtype.jl
index 8d08bb8..6fe01b7 100644
--- a/test/argtype.jl
+++ b/test/argtype.jl
@@ -4,5 +4,5 @@ using Comonicon: Arg
 @testset "tryparse(::ArgType, s)" begin
     @test tryparse(Arg.Path, "random-string").content == "random-string"
     @test tryparse(Arg.Prefix"file-", "file-content").content == "content"
-    @test tryparse(Arg.Suffix".py", "script.py").content == "script"    
+    @test tryparse(Arg.Suffix".py", "script.py").content == "script"
 end
diff --git a/test/codegen/bash.jl b/test/codegen/bash.jl
new file mode 100644
index 0000000..18ea21b
--- /dev/null
+++ b/test/codegen/bash.jl
@@ -0,0 +1,65 @@
+using Test
+using Comonicon
+using Comonicon.Arg
+using Comonicon.AST
+using Comonicon.BashCompletions
+
+# TODO: find a way to actually test the completions
+
+called(x) = x
+
+cmd = Entry(;
+    version = v"1.2.0",
+    root = NodeCommand(;
+        name = "node",
+        subcmds = Dict(
+            "cmd1" => LeafCommand(; fn = called, name = "cmd1"),
+            "cmd2" => LeafCommand(; fn = called, name = "cmd2"),
+        ),
+    ),
+)
+
+BashCompletions.emit(cmd)|>print
+
+cmd = Entry(;
+    version = v"1.2.0",
+    root = NodeCommand(;
+        name = "node",
+        subcmds = Dict(
+            "cmd1" => NodeCommand(;
+                name = "cmd1",
+                    subcmds = Dict(
+                        "cmd11" => LeafCommand(; fn = called, name = "cmd11")
+                    )
+            ),
+            "cmd2" => LeafCommand(; fn = called, name = "cmd2"),
+        ),
+    ),
+)
+
+BashCompletions.emit(cmd)|>print
+
+foo(x) = x
+
+cmd = Entry(;
+    version = v"1.1.0",
+    root = LeafCommand(;
+        fn = foo,
+        name = "leaf",
+        args = [
+            Argument(; name = "path", type = Arg.Path),
+            Argument(; name = "dir", type = Arg.DirName, require = false),
+        ],
+        vararg = Argument(; name = "c", type = Int, vararg = true),
+        options = Dict(
+            "option-a" => Option(; sym = :option_a, hint = "int", type = Int, short = true),
+            "option-b" => Option(; sym = :option_b, hint = "float64", type = Float64),
+        ),
+        flags = Dict(
+            "flag-a" => Flag(; sym = :flag_a, short = true),
+            "flag-b" => Flag(; sym = :flag_b),
+        ),
+    ),
+)
+
+BashCompletions.emit(cmd)|>print
\ No newline at end of file
diff --git a/test/codegen/codegen.jl b/test/codegen/codegen.jl
index 7f3861f..d4ee104 100644
--- a/test/codegen/codegen.jl
+++ b/test/codegen/codegen.jl
@@ -14,3 +14,7 @@ end
 @testset "ZSHCompletions.emit" begin
     include("zsh/zsh.jl")
 end
+
+@testset "BashCompletions.emit" begin
+    include("bash.jl")
+end