Repository: comonicon/Comonicon.jl
Pull request #222 (2022-08-21 23:54:04)

support option type
This implements the following dot syntax for long options when the corresponding type
is an option type defined by Configurations, e.g

```julia
using Test
using Comonicon
using Configurations

@option struct OptionA
    a::Int
    b::Int
end

@option struct OptionB
    option::OptionA
    c::Int
end

"""
# Options

- `-c, --config <path/to/option/or/specific field>`: config.
"""
@main function run(;config::OptionB)
    @test config == OptionB(OptionA(1, 1), 1)
end
```

can be used with the following syntax

```sh
command --config.c=1 --config.option.a=1 --config.option.b=1
command --config=config.toml
command -c config.toml
```


codecov[bot]: # [Codecov](https://codecov.io/gh/comonicon/Comonicon.jl/pull/222?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
Base: **83.24**% // Head: **0.86**% // Decreases project coverage by **`-82.37%`** :warning:
> Coverage data is based on head [(`9d2ea81`)](https://codecov.io/gh/comonicon/Comonicon.jl/pull/222?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) compared to base [(`ba45d81`)](https://codecov.io/gh/comonicon/Comonicon.jl/commit/ba45d811a5d675c63c8082cec2c2c850f67c8ff4?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).
> Patch coverage: 0.00% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #222       +/-   ##
==========================================
- Coverage   83.24%   0.86%   -82.38%     
==========================================
  Files          21      20        -1     
  Lines        1540    1496       -44     
==========================================
- Hits         1282      13     -1269     
- Misses        258    1483     +1225     
```


| [Impacted Files](https://codecov.io/gh/comonicon/Comonicon.jl/pull/222?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [src/codegen/julia.jl](https://codecov.io/gh/comonicon/Comonicon.jl/pull/222/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2NvZGVnZW4vanVsaWEuamw=) | `0.00% <0.00%> (-87.37%)` | :arrow_down: |
| [src/compat.jl](https://codecov.io/gh/comonicon/Comonicon.jl/pull/222/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/222/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/222/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/222/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.49%)` | :arrow_down: |
| [src/builder/install.jl](https://codecov.io/gh/comonicon/Comonicon.jl/pull/222/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%> (-93.25%)` | :arrow_down: |
| [src/codegen/zsh.jl](https://codecov.io/gh/comonicon/Comonicon.jl/pull/222/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%> (-92.60%)` | :arrow_down: |
| [src/codegen/bash.jl](https://codecov.io/gh/comonicon/Comonicon.jl/pull/222/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%> (-91.02%)` | :arrow_down: |
| [src/builder/cli.jl](https://codecov.io/gh/comonicon/Comonicon.jl/pull/222/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/utils.jl](https://codecov.io/gh/comonicon/Comonicon.jl/pull/222/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2FzdC91dGlscy5qbA==) | `0.00% <0.00%> (-88.89%)` | :arrow_down: |
| ... and [12 more](https://codecov.io/gh/comonicon/Comonicon.jl/pull/222/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/comonicon/Comonicon.jl/pull/222?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).


Patch:
diff --git a/.gitignore b/.gitignore
index c8cef0b..882f6cf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,11 +13,13 @@ Manifest.toml
 
 /node_modules/
 package-lock.json
+config.toml
 
 *.cmd
 /bin/
 /test/bin/
 /test/completions/
+/test/config.toml
 
 build
 *.tar.gz
diff --git a/src/codegen/julia.jl b/src/codegen/julia.jl
index 00a45d9..d2d457b 100644
--- a/src/codegen/julia.jl
+++ b/src/codegen/julia.jl
@@ -3,6 +3,7 @@ module JuliaExpr
 using ..AST
 using ..Comonicon: CommandException, CommandExit, _sprint
 using ExproniconLite
+using Configurations: Configurations
 
 function help_str(x; color = true, width::Int)
     _sprint(print_cmd, x; color, displaysize = (24, width))
@@ -170,7 +171,7 @@ function emit_dash_body(cmd::LeafCommand, ctx::EmitContext, idx::Symbol, ptr::In
     @gensym token token_ptr args kwargs
 
     quote # parse option/flag
-        $kwargs = []
+        $kwargs = Dict{Symbol, Any}()
         $token_ptr = $ptr
         while $token_ptr ≤ $idx - 1
             $token = ARGS[$token_ptr]
@@ -195,7 +196,7 @@ function emit_norm_body(cmd::LeafCommand, ctx::EmitContext, ptr::Int = 1)
 
     quote
         $args = []
-        $kwargs = []
+        $kwargs = Dict{Symbol, Any}()
         sizehint!($args, $(cmd.nrequire))
         $token_ptr = $ptr
         while $token_ptr ≤ length(ARGS)
@@ -245,8 +246,8 @@ function emit_leaf_call(cmd::LeafCommand, ctx::EmitContext, args::Symbol, kwargs
         )
     end
 
-    # check if all required options are filled
     if !isempty(cmd.options)
+        # check if all required options are filled
         for each in values(cmd.options)
             each.require || continue
             err_hint = "--" * each.name
@@ -259,12 +260,23 @@ function emit_leaf_call(cmd::LeafCommand, ctx::EmitContext, args::Symbol, kwargs
 
             @gensym idx
             push!(ret.args, quote
-                $idx = findfirst(x -> x.first === $(QuoteNode(each.sym)), $kwargs)
-                if $idx === nothing
+                if !($(QuoteNode(each.sym)) in Base.keys($kwargs))
                     $(emit_error(cmd, ctx, err_hint))
                 end
             end)
         end
+
+        # convert dict to option types
+        # NOTE: since the types must be an option type
+        # thus the corresponding program depends on Configurations
+        # already, we are safe to depend on it in the generated code
+        for option in values(cmd.options)
+            Configurations.is_option(option.type) || continue
+            push!(ret.args, quote
+                $kwargs[$(QuoteNode(option.sym))] =
+                    $Configurations.from_dict($(option.type), $kwargs[$(QuoteNode(option.sym))])
+            end)
+        end
     end
 
     call = Expr(:call, cmd.fn)
@@ -356,8 +368,9 @@ function emit_kwarg(cmd::LeafCommand, ctx::EmitContext, token::Symbol, kwargs::S
     ifelse = JLIfElse()
     # long option/flag
     ifelse[:(startswith($token, "--"))] = quote
-        $key = lstrip(split($token, '=')[1], '-')
-        $(emit_long_option_or_flag(cmd, ctx, token, sym, key, value, token_ptr))
+        $key = lstrip(split($token, '='; limit=2)[1], '-')
+        $key = split($key, '.'; limit=2)[1]
+        $(emit_long_option_or_flag(cmd, ctx, token, kwargs, sym, key, value, token_ptr))
     end
 
     # short option
@@ -368,7 +381,7 @@ function emit_kwarg(cmd::LeafCommand, ctx::EmitContext, token::Symbol, kwargs::S
 
     return quote
         $(codegen_ast(ifelse))
-        push!($kwargs, $sym => $value)
+        $kwargs[$sym] = $value
     end
 end
 
@@ -410,7 +423,7 @@ function emit_short_option(
             name = option.name[1:1]
             ifelse[:($key == $name)] = quote
                 if occursin('=', $token)
-                    _, $value = split($token, '=')
+                    _, $value = split($token, '='; limit=2)
                 elseif length($token) == 2 # read next
                     $token_ptr += 1
                     if $token_ptr > length(ARGS)
@@ -435,11 +448,13 @@ function emit_long_option_or_flag(
     cmd::LeafCommand,
     ctx::EmitContext,
     token::Symbol,
+    kwargs::Symbol,
     sym::Symbol,
     key::Symbol,
     value::Symbol,
     token_ptr,
 )
+
     ifelse = JLIfElse()
     for (name, flag) in cmd.flags
         ifelse[:($key == $name)] = quote
@@ -451,7 +466,7 @@ function emit_long_option_or_flag(
     for (name, option) in cmd.options
         ifelse[:($key == $name)] = quote
             $sym = $(QuoteNode(option.sym))
-            $(emit_option(option, ctx, token, value, token_ptr))
+            $(emit_option(option, ctx, token, kwargs, value, token_ptr))
         end
     end
 
@@ -464,6 +479,10 @@ function emit_parse_value(cmd, ctx::EmitContext, type, value)
         return value
     elseif type === String # we need to convert SubString to String
         return :(String($value))
+    elseif Configurations.is_option(type)
+        return quote
+            $Configurations.from_toml($type, $value)
+        end
     else
         @gensym ret
         return quote
@@ -480,12 +499,17 @@ function emit_option(
     option::Option,
     ctx::EmitContext,
     token::Symbol,
+    kwargs::Symbol,
     value::Symbol,
     token_ptr::Symbol,
 )
+    if Configurations.is_option(option.type)
+        return emit_option_with_option_type(option, ctx, token, kwargs, value, token_ptr)
+    end
+
     return quote
         if occursin('=', $token)
-            _, $value = split($token, '=')
+            _, $value = split($token, '='; limit=2)
         else # read next token
             $token_ptr += 1
             if $token_ptr > length(ARGS)
@@ -497,4 +521,77 @@ function emit_option(
     end
 end
 
+function emit_option_with_option_type(
+    option::Option,
+    ctx::EmitContext,
+    token::Symbol,
+    kwargs::Symbol,
+    value::Symbol,
+    token_ptr::Symbol,
+)
+
+    @gensym key field_value fields
+    assign_field_to_dict = foreach_leaf_field(option.type, value, fields) do dict_ex, type
+        quote
+            $field_value = $(emit_parse_value(option, ctx, type, field_value))
+            $dict_ex = $field_value 
+        end
+    end
+    assign_field_to_dict.otherwise = emit_error(option, ctx, :("cannot find field $($fields)"))
+
+    return quote
+        if occursin('=', $token)
+            $key, $field_value = split($token, '='; limit=2)
+            if occursin('.', $key)
+                _, $fields = split($key, '.'; limit=2)
+            else
+                $fields = nothing
+            end
+        else # read next token
+            if occursin('.', $token)
+                _, $fields = split($token, '.'; limit=2)
+            else
+                $fields = nothing
+            end
+
+            $token_ptr += 1
+            if $token_ptr > length(ARGS)
+                $(emit_error(option, ctx, "expect a value"))
+            end
+            $field_value = ARGS[$token_ptr]
+        end
+
+        if isnothing($fields) # path to the config file
+            $value = $(emit_parse_value(option, ctx, option.type, field_value))
+        else
+            $value = get(Dict{String, Any}, $kwargs, $(QuoteNode(option.sym)))
+            $(codegen_ast(assign_field_to_dict))
+        end
+    end
 end
+
+function foreach_leaf_field!(f, jl::JLIfElse, ::Type{T}, dict, fields::Symbol, prefix::String="") where T
+    Configurations.foreach_keywords(T) do name, type
+        key = isempty(prefix) ? string(name) : join((prefix, name), '.')
+        dict_ex = :($dict[$(string(name))])
+        if Configurations.is_option(type)
+            foreach_leaf_field!(jl, type, dict_ex, fields, key) do dict_ex, type
+                quote
+                    Base.get!(Dict{String, Any}, $dict, $(string(name)))
+                    $(f(dict_ex, type))
+                end
+            end
+        else
+            jl[:($fields == $key)] = quote
+                $(f(dict_ex, type))
+            end
+        end
+    end
+    return jl
+end
+
+function foreach_leaf_field(f, ::Type{T}, dict::Symbol, fields::Symbol) where T
+    return foreach_leaf_field!(f, JLIfElse(), T, dict, fields)
+end
+
+end # JuliaExpr


Test patch:
diff --git a/test/Project.toml b/test/Project.toml
index 5c57662..1e95b33 100644
--- a/test/Project.toml
+++ b/test/Project.toml
@@ -1,5 +1,6 @@
 [deps]
 ComoniconTestUtils = "53b67e40-53a7-4335-973f-e3e901fe4865"
+Configurations = "5218b696-f38b-4ac9-8b61-a12ec717816d"
 ExproniconLite = "55351af7-c7e9-48d6-89ff-24e801d99491"
 Faker = "0efc519c-db33-5916-ab87-703215c3906f"
 FromFile = "ff7dd447-1dcb-4ce3-b8ac-22a812192de7"
diff --git a/test/codegen/codegen.jl b/test/codegen/codegen.jl
index 3fd27a1..83f9870 100644
--- a/test/codegen/codegen.jl
+++ b/test/codegen/codegen.jl
@@ -10,6 +10,7 @@ using Test
     include("julia/exception.jl")
     include("julia/plugin.jl")
     include("julia/print.jl")
+    include("julia/config.jl")
 end
 
 @testset "ZSHCompletions.emit" begin
diff --git a/test/codegen/julia/config.jl b/test/codegen/julia/config.jl
new file mode 100644
index 0000000..71457f6
--- /dev/null
+++ b/test/codegen/julia/config.jl
@@ -0,0 +1,35 @@
+module TestConfigOption
+
+using Test
+using Comonicon
+using Configurations
+
+@option struct OptionA
+    a::Int
+    b::Int
+end
+
+@option struct OptionB
+    option::OptionA
+    c::Int
+end
+
+"""
+# Options
+
+- `-c, --config <path/to/option/or/specific field>`: config.
+"""
+@main function run(;config::OptionB)
+    @test config == OptionB(OptionA(1, 1), 1)
+end
+
+@testset "config options" begin
+    TestConfigOption.command_main(["--config.c=1", "--config.option.a=1", "--config.option.b=1"])
+
+    opt = TestConfigOption.OptionB(TestConfigOption.OptionA(1, 1), 1)
+    to_toml("config.toml", opt)
+    TestConfigOption.command_main(["--config", "config.toml"])
+    TestConfigOption.command_main(["-c", "config.toml"])
+end
+
+end
diff --git a/test/codegen/julia/dash.jl b/test/codegen/julia/dash.jl
index 237f91a..9ef7a6b 100644
--- a/test/codegen/julia/dash.jl
+++ b/test/codegen/julia/dash.jl
@@ -6,11 +6,11 @@ using Comonicon.JuliaExpr: emit, emit_body, emit_norm_body, emit_dash_body
 using Test
 
 const test_args = Ref{Vector{Any}}()
-const test_kwargs = Ref{Vector{Any}}()
+const test_kwargs = Ref{Dict{Symbol, Any}}()
 
 function foo(a, b = 2, c...; kwargs...)
     test_args[] = [a, b, c...]
-    test_kwargs[] = [kwargs...]
+    test_kwargs[] = Dict{Symbol, Any}(kwargs)
 end
 
 cmd = Entry(;
@@ -39,11 +39,11 @@ eval(emit(cmd))
 @testset "test leaf optional argument" begin
     @test command_main(["3", "2", "5", "6", "7", "--option-a=2", "--option-b", "2.3"]) == 0
     @test test_args[] == Any[3, 2, 5, 6, 7]
-    @test test_kwargs[] == [:option_a => 2, :option_b => 2.3]
+    @test test_kwargs[] == Dict{Symbol, Any}(:option_a => 2, :option_b => 2.3)
 
     @test command_main(["--option-a=2", "--option-b=2.3", "--", "3", "2"]) == 0
     @test test_args[] == Any[3, 2]
-    @test test_kwargs[] == [:option_a => 2, :option_b => 2.3]
+    @test test_kwargs[] == Dict{Symbol, Any}(:option_a => 2, :option_b => 2.3)
 
     @test command_main(["3", "2"]) == 0
     @test test_args[] == Any[3, 2]
diff --git a/test/codegen/julia/options.jl b/test/codegen/julia/options.jl
index b58c434..55c9efb 100644
--- a/test/codegen/julia/options.jl
+++ b/test/codegen/julia/options.jl
@@ -6,10 +6,10 @@ using Comonicon.JuliaExpr: emit, emit_body, emit_norm_body, emit_dash_body
 using Test
 
 const test_args = Ref{Vector{Any}}()
-const test_kwargs = Ref{Vector{Any}}()
+const test_kwargs = Ref{Dict{Symbol, Any}}()
 
 function foo(; kwargs...)
-    test_kwargs[] = [kwargs...]
+    test_kwargs[] = Dict{Symbol, Any}(kwargs)
 end
 
 cmd = Entry(;
@@ -32,11 +32,11 @@ eval(emit(cmd))
 
 @testset "test leaf options" begin
     @test command_main(["--option-a=3", "--option-b", "1.2", "-f", "--flag-b"]) == 0
-    @test test_kwargs[] == [:option_a => 3, :option_b => 1.2, :flag_a => true, :flag_b => true]
+    @test test_kwargs[] == Dict{Symbol, Any}(:option_a => 3, :option_b => 1.2, :flag_a => true, :flag_b => true)
     @test command_main(["-o=3", "--option-b", "1.2", "-f", "--flag-b"]) == 0
-    @test test_kwargs[] == [:option_a => 3, :option_b => 1.2, :flag_a => true, :flag_b => true]
+    @test test_kwargs[] == Dict{Symbol, Any}(:option_a => 3, :option_b => 1.2, :flag_a => true, :flag_b => true)
     @test command_main(["-o3", "--option-b", "1.2", "-f", "--flag-b"]) == 0
-    @test test_kwargs[] == [:option_a => 3, :option_b => 1.2, :flag_a => true, :flag_b => true]
+    @test test_kwargs[] == Dict{Symbol, Any}(:option_a => 3, :option_b => 1.2, :flag_a => true, :flag_b => true)
     @test command_main(["--option-a", "--option-b", "1.2", "-f", "--flag-b"]) == 1
     @test command_main(["-o", "--option-b", "1.2", "-f", "--flag-b"]) == 1
 end