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
3 changes: 3 additions & 0 deletions src/PkgTemplatesCommandLineInterface.jl
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ include("config_manager.jl")
# Include PluginDiscovery module
include("plugin_discovery.jl")

# Include PluginOptionParser module (shared by CreateCommand and ConfigCommand)
include("plugin_option_parser.jl")

# Include TemplateManager module
include("template_manager.jl")

Expand Down
97 changes: 2 additions & 95 deletions src/config_command.jl
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ using TOML
using ..PkgTemplatesCommandLineInterface: CommandResult, JTCError, ConfigurationError
import ..ConfigManager
import ..PluginDiscovery
import ..PluginOptionParser

"""
format_config(config::Dict{String, Any})::String
Expand Down Expand Up @@ -90,100 +91,6 @@ function _resolve_section_target(defaults::Dict, section::AbstractString;
return get(canonical, lowercase(s), s)
end

"""
parse_plugin_option_value(value_str::AbstractString)

Convert a string token into the appropriate Julia type for storage in TOML.
Mirrors `parse_plugin_option_value` in the Python port.
"""
function parse_plugin_option_value(value_str::AbstractString)
s = String(value_str)
lower = lowercase(s)
if lower in ("true", "yes")
return true
elseif lower in ("false", "no")
return false
elseif startswith(s, "[") && endswith(s, "]")
inner = strip(s[2:end-1])
if isempty(inner)
return String[]
end
return [String(strip(strip(item), ['"', '\''])) for item in split(inner, ',')]
elseif occursin(r"^\d+$", s)
return parse(Int, s)
else
# Decimal-looking values (e.g., "1.2", "1.10") stay strings: downstream
# plugins such as ProjectFile expect a string they can parse into a
# VersionNumber, and Float coercion would also drop trailing zeros.
was_quoted = (startswith(s, '"') && endswith(s, '"')) ||
(startswith(s, '\'') && endswith(s, '\''))
if was_quoted
s = s[2:end-1]
end
# Auto-promote comma-separated strings to arrays for PkgTemplates.jl
# compatibility — but only when the user did NOT explicitly quote the
# value. Quotes signal "this is a literal string", so a value like
# `name="Doe, Jane"` must stay a single string instead of becoming
# ["Doe", "Jane"] (which would break String-typed fields like Git.name).
if !was_quoted && occursin(',', s)
return [String(strip(item)) for item in split(s, ',') if !isempty(strip(item))]
end
return s
end
end

# Split an option string respecting quotes and `[...]` arrays so that
# `--plugin 'a=1 ignore=".vscode,.DS_Store"'` parses cleanly.
function _split_plugin_option_string(s::AbstractString)::Vector{String}
parts = String[]
current = IOBuffer()
in_quotes = false
quote_char = '\0'
bracket_depth = 0

for c in s
if c in ('"', '\'') && !in_quotes && bracket_depth == 0
in_quotes = true
quote_char = c
print(current, c)
elseif in_quotes && c == quote_char
in_quotes = false
quote_char = '\0'
print(current, c)
elseif c == '[' && !in_quotes
bracket_depth += 1
print(current, c)
elseif c == ']' && !in_quotes
bracket_depth = max(bracket_depth - 1, 0)
print(current, c)
elseif c == ' ' && !in_quotes && bracket_depth == 0
piece = String(strip(String(take!(current))))
if !isempty(piece)
push!(parts, piece)
end
else
print(current, c)
end
end
piece = String(strip(String(take!(current))))
if !isempty(piece)
push!(parts, piece)
end
return parts
end

# Parse a `key=value key2=value2` string into a Dict, with type coercion.
function _parse_plugin_kv_string(s::AbstractString)::Dict{String,Any}
options = Dict{String,Any}()
for part in _split_plugin_option_string(s)
if contains(part, '=')
k, v = split(part, '=', limit=2)
options[String(strip(k))] = parse_plugin_option_value(strip(v))
end
end
return options
end

# Apply parsed `config set` arguments to the in-memory config dict.
# Returns (updated_config, messages::Vector{String}).
function _apply_set_args(config::Dict{String,Any}, sub_args::Dict{String,Any})
Expand Down Expand Up @@ -298,7 +205,7 @@ function _apply_set_args(config::Dict{String,Any}, sub_args::Dict{String,Any})
section = Dict{String,Any}()
end
if !isempty(value)
for (opt_key, opt_val) in _parse_plugin_kv_string(value)
for (opt_key, opt_val) in PluginOptionParser.parse_kv_string(value)
section[opt_key] = opt_val
push!(messages, "Set default $plugin_name.$opt_key: $(repr(opt_val))")
end
Expand Down
61 changes: 8 additions & 53 deletions src/create_command.jl
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ using ..PkgTemplatesCommandLineInterface: CommandResult, PackageGenerationError
import ..ConfigManager
import ..PackageGenerator
import ..PluginDiscovery
import ..PluginOptionParser
import ..TemplateManager

export execute, merge_config, parse_plugin_options, parse_plugin_option_value
export execute, merge_config, parse_plugin_options

"""
merge_config(config_defaults::Dict, cli_args::Dict)::Dict
Expand All @@ -41,48 +42,6 @@ function merge_config(config_defaults::Dict, cli_args::Dict)::Dict
return merged
end

"""
parse_plugin_option_value(opt::String)::Tuple{String, Any}

Parse plugin option from "key=value" string format.
Performs type inference:
- "true" / "false" → Bool
- "123" → Int
- "1.5" → Float64
- "[a,b,c]" → Vector{String}
- Otherwise → String
"""
function parse_plugin_option_value(opt::String)::Tuple{String, Any}
parts = split(opt, '=', limit=2)
if length(parts) != 2
error("Invalid plugin option format: '$opt'. Expected 'key=value'")
end

key, value_str = parts[1], parts[2]

value = if value_str == "true"
true
elseif value_str == "false"
false
elseif startswith(value_str, "[") && endswith(value_str, "]")
# Array parsing: "[item1,item2]" → ["item1", "item2"]
inner = value_str[2:end-1] # Remove brackets
if isempty(inner)
String[]
else
split(inner, ',')
end
elseif occursin(r"^\d+$", value_str)
parse(Int, value_str)
elseif occursin(r"^\d+\.\d+$", value_str)
parse(Float64, value_str)
else
value_str # String as-is
end

return (String(key), value)
end

"""
parse_plugin_options(args::Dict)::Dict{String, Dict{String, Any}}

Expand All @@ -96,8 +55,9 @@ registration, the value is one of:
- `nothing` → `--<plugin>` not supplied; skip.
- `""` → `--<plugin>` supplied without a value; enable
the plugin with default options (empty section).
- `"k1=v1 k2=v2 ..."` → `--<plugin> "..."`; split on whitespace and
parse each token as a typed `KEY=VALUE` pair.
- `"k1=v1 k2=v2 ..."` → `--<plugin> "..."`; route through
`PluginOptionParser.parse_kv_string` so quoted strings, bracket arrays,
and version-like values parse identically to the `config set` path.

Output keys are canonicalised against `PluginDiscovery.canonical_names()`
so PkgTemplates plugin types resolve via `getfield(PkgTemplates, Symbol(...))`.
Expand All @@ -113,14 +73,9 @@ function parse_plugin_options(args::Dict)::Dict{String, Dict{String, Any}}
if value === nothing
continue
elseif value isa AbstractString
section = Dict{String, Any}()
if !isempty(value)
for tok in split(value)
isempty(tok) && continue
opt_key, opt_value = parse_plugin_option_value(String(tok))
section[opt_key] = opt_value
end
end
section = isempty(value) ?
Dict{String, Any}() :
PluginOptionParser.parse_kv_string(value)
plugin_options[canonical_name] = section
end
end
Expand Down
160 changes: 160 additions & 0 deletions src/plugin_option_parser.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""
PluginOptionParser module — single source of truth for converting plugin
option strings (`KEY=VALUE` bundles) into typed Julia values.

Both `CreateCommand` (CLI plugin options) and `ConfigCommand` (`config set`
plugin options) consume the same shape — a single optional space-separated
string per `--plugin` flag — so they must parse it identically. Routing
both modules through this parser prevents the divergence that issue #9
documented (Float coercion, missing quote handling, etc.).
"""
module PluginOptionParser

export parse_value, split_string, parse_kv_string

"""
parse_value(value_str::AbstractString)

Convert a single value token into the appropriate Julia type:
- `"true"` / `"yes"` (case-insensitive) → `true`
- `"false"` / `"no"` (case-insensitive) → `false`
- `"[a, b, c]"` (bracket form) → `Vector{String}`, with surrounding quotes
on each item stripped
- `"123"` (digits only) → `Int`
- Decimal-looking values (e.g. `"1.2"`, `"1.10"`) **stay strings**, since
downstream plugins such as `ProjectFile` expect a string parsable as
`VersionNumber` and Float coercion would drop trailing zeros.
- An explicitly quoted string (`"..."` or `'...'`) has its quotes stripped
and is returned as a single string, even if it contains commas.
- An unquoted comma-separated value is auto-promoted to `Vector{String}`
for PkgTemplates.jl compatibility.
- Any other input is returned as a String unchanged.
"""
function parse_value(value_str::AbstractString)
s = String(value_str)
lower = lowercase(s)
if lower in ("true", "yes")
return true
elseif lower in ("false", "no")
return false
elseif startswith(s, "[") && endswith(s, "]")
inner = strip(s[2:end-1])
if isempty(inner)
return String[]
end
return [String(strip(strip(item), ['"', '\''])) for item in split(inner, ',')]
elseif occursin(r"^\d+$", s)
return parse(Int, s)
else
was_quoted = (startswith(s, '"') && endswith(s, '"')) ||
(startswith(s, '\'') && endswith(s, '\''))
if was_quoted
s = s[2:end-1]
end
# Auto-promote comma-separated values to arrays — but only when the
# user did NOT explicitly quote the value. Quotes signal "this is
# a literal string", so a value like `name="Doe, Jane"` must stay
# a single string instead of becoming ["Doe", "Jane"] (which would
# break String-typed fields like Git.name).
if !was_quoted && occursin(',', s)
return [String(strip(item)) for item in split(s, ',') if !isempty(strip(item))]
end
return s
end
end

"""
split_string(s::AbstractString)::Vector{String}

Split a plugin option bundle on whitespace while respecting `"..."`,
`'...'`, and `[...]` so that values like `name="Doe, Jane"` or
`ignore=[.DS_Store, .vscode]` survive intact.

A `'` or `"` only opens a quoted block when it appears at the start of
a token — that is, immediately after whitespace, `=`, or the start of
the input. Inside a value (e.g. `name=O'Connor`) an apostrophe is a
literal character and does not affect splitting. Empty pieces from
consecutive whitespace are skipped. Unmatched closing brackets clamp
`bracket_depth` at zero rather than going negative; the opening-quote
character is recorded so a `'` inside a `"..."` block does not close
the quoting context.
"""
function split_string(s::AbstractString)::Vector{String}
parts = String[]
current = IOBuffer()
in_quotes = false
quote_char = '\0'
bracket_depth = 0
prev_char = '\0' # '\0' marks start-of-input

for c in s
# A quote char only opens a quoted block at the start of a token,
# i.e. right after whitespace, `=`, or the start of input. Without
# this guard, `name=O'Connor email=x` would enter quote mode at
# the apostrophe and silently swallow the rest of the bundle.
at_token_start = prev_char == '\0' || isspace(prev_char) || prev_char == '='

if c in ('"', '\'') && !in_quotes && bracket_depth == 0 && at_token_start
in_quotes = true
quote_char = c
print(current, c)
elseif in_quotes && c == quote_char
in_quotes = false
quote_char = '\0'
print(current, c)
elseif c == '[' && !in_quotes
bracket_depth += 1
print(current, c)
elseif c == ']' && !in_quotes
bracket_depth = max(bracket_depth - 1, 0)
print(current, c)
elseif isspace(c) && !in_quotes && bracket_depth == 0
# Treat any whitespace (spaces, tabs, newlines) as a separator
# so pasted multiline bundles parse the same as single-line ones.
piece = String(strip(String(take!(current))))
if !isempty(piece)
push!(parts, piece)
end
else
print(current, c)
end
prev_char = c
end
piece = String(strip(String(take!(current))))
if !isempty(piece)
push!(parts, piece)
end
return parts
end

"""
parse_kv_string(s::AbstractString)::Dict{String,Any}

Parse a whitespace-separated `key=value key2=value2` bundle into a typed
Dict. Only tokens that already contain `=` are considered; other tokens
are silently dropped. Within each such token, whitespace around the key
and value is stripped before the value is passed to `parse_value` for
type coercion. This means `key=value` must remain a single token —
whitespace around `=` (for example, `k= v` or `k =v`) will split the
pair across tokens and will not be parsed as a single option.

Returns an empty `Dict` for an empty input or a string with no `=` tokens.
"""
function parse_kv_string(s::AbstractString)::Dict{String,Any}
options = Dict{String,Any}()
for part in split_string(s)
if contains(part, '=')
k, v = split(part, '=', limit=2)
key = String(strip(k))
# Drop empty-key tokens (e.g. `=value`, ` =x`) so downstream
# plugin construction and TOML writes never see an empty
# option name.
if !isempty(key)
options[key] = parse_value(strip(v))
end
end
end
return options
end

end # module PluginOptionParser
1 change: 1 addition & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ using Aqua
include("test_types.jl")
include("test_config_manager.jl")
include("test_plugin_discovery.jl")
include("test_plugin_option_parser.jl")
include("test_template_manager.jl")
include("test_package_generator.jl")
include("test_create_command.jl")
Expand Down
Loading