diff --git a/.gitignore b/.gitignore index 9609270..e4cd38a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ *.jl.*.cov *.jl.cov *.jl.mem +/Manifest.toml diff --git a/.travis.yml b/.travis.yml index f1dce69..98ffe43 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,7 +12,6 @@ julia: before_script: - git config --global user.name Tester - git config --global user.email te@st.er -script: travis_wait julia --project -e 'using Pkg; Pkg.test(coverage=true)' matrix: fast_finish: true allow_failures: diff --git a/Manifest.toml b/Manifest.toml deleted file mode 100644 index 3ab4b0a..0000000 --- a/Manifest.toml +++ /dev/null @@ -1,115 +0,0 @@ -# This file is machine-generated - editing it directly is not advised - -[[Base64]] -uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" - -[[DataAPI]] -git-tree-sha1 = "674b67f344687a88310213ddfa8a2b3c76cc4252" -uuid = "9a962f9c-6df0-11e9-0e5d-c546b8b5ee8a" -version = "1.1.0" - -[[DataValueInterfaces]] -git-tree-sha1 = "bfc1187b79289637fa0ef6d4436ebdfe6905cbd6" -uuid = "e2d170a0-9d28-54be-80f0-106bbe20a464" -version = "1.0.0" - -[[Dates]] -deps = ["Printf"] -uuid = "ade2ca70-3891-5945-98fb-dc099432e06a" - -[[Distributed]] -deps = ["Random", "Serialization", "Sockets"] -uuid = "8ba89e20-285c-5b6f-9357-94700520ee1b" - -[[InteractiveUtils]] -deps = ["Markdown"] -uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240" - -[[IteratorInterfaceExtensions]] -git-tree-sha1 = "a3f24677c21f5bbe9d2a714f95dcd58337fb2856" -uuid = "82899510-4779-5014-852e-03e436cf321d" -version = "1.0.0" - -[[LibGit2]] -deps = ["Printf"] -uuid = "76f85450-5226-5b5a-8eaa-529ad045b433" - -[[Libdl]] -uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" - -[[LinearAlgebra]] -deps = ["Libdl"] -uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" - -[[Logging]] -uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" - -[[Markdown]] -deps = ["Base64"] -uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" - -[[Mustache]] -deps = ["Printf", "Tables"] -git-tree-sha1 = "e06eef2abee113c49695f5347668e15d4c02978a" -uuid = "ffc61752-8dc7-55ee-8c37-f3e9cdd09e70" -version = "1.0.0" - -[[OrderedCollections]] -deps = ["Random", "Serialization", "Test"] -git-tree-sha1 = "c4c13474d23c60d20a67b217f1d7f22a40edf8f1" -uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" -version = "1.1.0" - -[[Parameters]] -deps = ["OrderedCollections"] -git-tree-sha1 = "b62b2558efb1eef1fa44e4be5ff58a515c287e38" -uuid = "d96e819e-fc66-5662-9728-84c9c7592b0a" -version = "0.12.0" - -[[Pkg]] -deps = ["Dates", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "REPL", "Random", "SHA", "UUIDs"] -uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" - -[[Printf]] -deps = ["Unicode"] -uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7" - -[[REPL]] -deps = ["InteractiveUtils", "Markdown", "Sockets"] -uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" - -[[Random]] -deps = ["Serialization"] -uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" - -[[SHA]] -uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" - -[[Serialization]] -uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" - -[[Sockets]] -uuid = "6462fe0b-24de-5631-8697-dd941f90decc" - -[[TableTraits]] -deps = ["IteratorInterfaceExtensions"] -git-tree-sha1 = "b1ad568ba658d8cbb3b892ed5380a6f3e781a81e" -uuid = "3783bdb8-4a98-5b6b-af9a-565f29a5fe9c" -version = "1.0.0" - -[[Tables]] -deps = ["DataAPI", "DataValueInterfaces", "IteratorInterfaceExtensions", "LinearAlgebra", "TableTraits", "Test"] -git-tree-sha1 = "aaed7b3b00248ff6a794375ad6adf30f30ca5591" -uuid = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" -version = "0.2.11" - -[[Test]] -deps = ["Distributed", "InteractiveUtils", "Logging", "Random"] -uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40" - -[[UUIDs]] -deps = ["Random", "SHA"] -uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" - -[[Unicode]] -uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" diff --git a/Project.toml b/Project.toml index 5542621..9ab26fd 100644 --- a/Project.toml +++ b/Project.toml @@ -5,10 +5,12 @@ version = "0.7.0-DEV" [deps] Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" +InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" LibGit2 = "76f85450-5226-5b5a-8eaa-529ad045b433" Mustache = "ffc61752-8dc7-55ee-8c37-f3e9cdd09e70" Parameters = "d96e819e-fc66-5662-9728-84c9c7592b0a" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" +REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" [compat] diff --git a/docs/Manifest.toml b/docs/Manifest.toml index 9e44ced..ed34133 100644 --- a/docs/Manifest.toml +++ b/docs/Manifest.toml @@ -4,9 +4,9 @@ uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" [[DataAPI]] -git-tree-sha1 = "674b67f344687a88310213ddfa8a2b3c76cc4252" +git-tree-sha1 = "176e23402d80e7743fc26c19c681bfb11246af32" uuid = "9a962f9c-6df0-11e9-0e5d-c546b8b5ee8a" -version = "1.1.0" +version = "1.3.0" [[DataValueInterfaces]] git-tree-sha1 = "bfc1187b79289637fa0ef6d4436ebdfe6905cbd6" @@ -29,9 +29,9 @@ version = "0.8.1" [[Documenter]] deps = ["Base64", "Dates", "DocStringExtensions", "InteractiveUtils", "JSON", "LibGit2", "Logging", "Markdown", "REPL", "Test", "Unicode"] -git-tree-sha1 = "646ebc3db49889ffeb4c36f89e5d82c6a26295ff" +git-tree-sha1 = "395fa1554c69735802bba37d9e7d9586fd44326c" uuid = "e30172f5-a6a5-5a46-863b-614d45cd2de4" -version = "0.24.7" +version = "0.24.11" [[InteractiveUtils]] deps = ["Markdown"] @@ -71,15 +71,14 @@ uuid = "a63ad114-7e13-5084-954f-fe012c677804" [[Mustache]] deps = ["Printf", "Tables"] -git-tree-sha1 = "e06eef2abee113c49695f5347668e15d4c02978a" +git-tree-sha1 = "2e11fc5de3a01d23482a257e22009ddaab058d9a" uuid = "ffc61752-8dc7-55ee-8c37-f3e9cdd09e70" -version = "1.0.0" +version = "1.0.2" [[OrderedCollections]] -deps = ["Random", "Serialization", "Test"] -git-tree-sha1 = "c4c13474d23c60d20a67b217f1d7f22a40edf8f1" +git-tree-sha1 = "12ce190210d278e12644bcadf5b21cbdcf225cd3" uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" -version = "1.1.0" +version = "1.2.0" [[Parameters]] deps = ["OrderedCollections"] @@ -89,16 +88,16 @@ version = "0.12.0" [[Parsers]] deps = ["Dates", "Test"] -git-tree-sha1 = "0c16b3179190d3046c073440d94172cfc3bb0553" +git-tree-sha1 = "f8f5d2d4b4b07342e5811d2b6428e45524e241df" uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" -version = "0.3.12" +version = "1.0.2" [[Pkg]] deps = ["Dates", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "REPL", "Random", "SHA", "UUIDs"] uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" [[PkgTemplates]] -deps = ["Dates", "LibGit2", "Mustache", "Parameters", "Pkg", "UUIDs"] +deps = ["Dates", "InteractiveUtils", "LibGit2", "Mustache", "Parameters", "Pkg", "REPL", "UUIDs"] path = ".." uuid = "14b8a8f1-9102-5b29-a752-f990bacb7fe1" version = "0.7.0-DEV" @@ -132,9 +131,9 @@ version = "1.0.0" [[Tables]] deps = ["DataAPI", "DataValueInterfaces", "IteratorInterfaceExtensions", "LinearAlgebra", "TableTraits", "Test"] -git-tree-sha1 = "aaed7b3b00248ff6a794375ad6adf30f30ca5591" +git-tree-sha1 = "c45dcc27331febabc20d86cb3974ef095257dcf3" uuid = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" -version = "0.2.11" +version = "1.0.4" [[Test]] deps = ["Distributed", "InteractiveUtils", "Logging", "Random"] diff --git a/docs/src/developer.md b/docs/src/developer.md index d07e38f..1326804 100644 --- a/docs/src/developer.md +++ b/docs/src/developer.md @@ -75,7 +75,7 @@ To understand how they're implemented, let's look at simplified versions of two ### Example: `Documenter` ```julia -@with_kw_noshow struct Documenter <: Plugin +@plugin struct Documenter <: Plugin make_jl::String = default_file("docs", "make.jl") index_md::String = default_file("docs", "src", "index.md") end @@ -117,10 +117,11 @@ function hook(p::Documenter, t::Template, pkg_dir::AbstractString) end ``` -The `@with_kw_noshow` macro defines keyword constructors for us. +The `@plugin` macro defines some helpful methods for us. Inside of our struct definition, we're using [`default_file`](@ref) to refer to files in this repository. ```@docs +@plugin default_file ``` @@ -138,7 +139,11 @@ Badge ``` These two functions, [`gitignore`](@ref) and [`badges`](@ref), are currently the only "special" functions for cross-plugin interactions. -In other cases, you can still access the [`Template`](@ref)'s plugins to depend on the presence/properties of other plugins, although that's less powerful. +In other cases, you can still access the [`Template`](@ref)'s plugins to depend on the presence/properties of other plugins via [`getplugin`](@ref), although that's less powerful. + +```@docs +getplugin +``` Third, we implement [`view`](@ref), which is used to fill placeholders in badges and rendered files. @@ -197,6 +202,7 @@ function posthook(::Git, ::Template, pkg_dir::AbstractString) end ``` +We didn't use `@plugin` for this one, because there are no fields. Validation and all three hooks are implemented: - [`validate`](@ref) makes sure that all required Git configuration is present. @@ -217,7 +223,7 @@ In general, they just generate one templated file. To illustrate, let's look at the [`Citation`](@ref) plugin, which creates a `CITATION.bib` file. ```julia -@with_kw_noshow struct Citation <: FilePlugin +@plugin struct Citation <: FilePlugin file::String = default_file("CITATION.bib") end @@ -294,7 +300,7 @@ Of course, we could use a normal [`Plugin`](@ref), but it turns out there's a wa The plugin implements its own `hook`, but uses `invoke` to avoid duplicating the file creation code: ```julia -@with_kw_noshow struct Tests <: FilePlugin +@plugin struct Tests <: FilePlugin file::String = default_file("runtests.jl") end @@ -315,6 +321,20 @@ If you want to extend the validation but keep the file existence check, use the For more examples, see the plugins in the [Continuous Integration (CI)](@ref) and [Code Coverage](@ref) sections. +## Supporting Interactive Mode + +When it comes to supporting interactive mode for your custom plugins, you have two options: write your own [`interactive`](@ref) method, or use the default one. +If you choose the first option, then you are free to implement the method however you want. +If you want to use the default implementation, then there are a few functions that you should be aware of, although in many cases you will not need to add any new methods. + +```@docs +interactive +prompt +customizable +input_tips +convert_input +``` + ## Miscellaneous Tips ### Writing Template Files diff --git a/docs/src/migrating.md b/docs/src/migrating.md index ac7687b..7769ea6 100644 --- a/docs/src/migrating.md +++ b/docs/src/migrating.md @@ -41,9 +41,12 @@ One less name to remember! | :-----------------------------------------: | :---------------------------------: | | `generate(::Template, pkg::AbstractString)` | `(::Template)(pkg::AbstractString)` | -## Interactive Templates +## Interactive Mode -Currently not implemented, but will be in the future. +| Old | New | +| :-----------------------------------------: | :---------------------------------: | +| `interactive_template()` | `Template(; interactive=true)` | +| `generate_interactive(pkg::AbstractString)` | `Template(; interactive=true)(pkg)` | ## Other Functions diff --git a/docs/src/user.md b/docs/src/user.md index 4c529f7..fca50af 100644 --- a/docs/src/user.md +++ b/docs/src/user.md @@ -21,6 +21,7 @@ t("MyPkg") ```@docs Template +generate ``` ## Plugins @@ -40,6 +41,7 @@ Tests Readme License Git +CompatHelper TagBot Secret ``` @@ -76,7 +78,6 @@ Documenter ```@docs Develop -CompatHelper Citation ``` diff --git a/src/PkgTemplates.jl b/src/PkgTemplates.jl index ba83caf..1121287 100644 --- a/src/PkgTemplates.jl +++ b/src/PkgTemplates.jl @@ -3,8 +3,10 @@ module PkgTemplates using Base: active_project, contractuser using Dates: month, today, year +using InteractiveUtils: subtypes using LibGit2: LibGit2, GitConfig, GitRemote, GitRepo using Pkg: Pkg, TOML, PackageSpec +using REPL.TerminalMenus: MultiSelectMenu, RadioMenu, request using UUIDs: uuid4 using Mustache: render @@ -25,6 +27,7 @@ export GitHubActions, GitLabCI, License, + NoDeploy, ProjectFile, Readme, Secret, @@ -44,6 +47,7 @@ abstract type Plugin end include("template.jl") include("plugin.jl") include("show.jl") +include("interactive.jl") include("deprecated.jl") # Run some function with a project activated at the given path. diff --git a/src/deprecated.jl b/src/deprecated.jl index b47c4d0..9c64025 100644 --- a/src/deprecated.jl +++ b/src/deprecated.jl @@ -3,3 +3,4 @@ @deprecate interactive_template() Template(; interactive=true) @deprecate generate_interactive(pkg::AbstractString) Template(; interactive=true)(pkg) @deprecate GitHubPages(; kwargs...) Documenter{TravisCI}(; kwargs...) +@deprecate GitLabPages(; kwargs...) Documenter{GitLabCI}(; kwargs...) diff --git a/src/interactive.jl b/src/interactive.jl new file mode 100644 index 0000000..485c810 --- /dev/null +++ b/src/interactive.jl @@ -0,0 +1,179 @@ +""" + generate([pkg::AbstractString]) -> Template + +Shortcut for `Template(; interactive=true)(pkg)`. +If no package name is supplied, you will be prompted for one. +""" +function generate(pkg::AbstractString=prompt(Template, String, :pkg)) + t = Template(; interactive=true) + t(pkg) + return t +end + +""" + interactive(T::Type{<:Plugin}) -> T + +Interactively create a plugin of type `T`. Implement this method and ignore other +related functions only if you want completely custom behaviour. +""" +function interactive(T::Type) + pairs = interactive_pairs(T) + + # There must be at least 2 MultiSelectMenu options. + # If there are none, return immediately. + # If there's just one, add a "dummy" option. + isempty(pairs) && return T() + just_one = length(pairs) == 1 + just_one && push!(pairs, :None => Nothing) + + menu = MultiSelectMenu( + collect(map(pair -> string(first(pair)), pairs)); + pagesize=length(pairs), + ) + println("$(nameof(T)) keywords to customize:") + customize = sort!(collect(request(menu))) + + # If the "None" option was selected, don't customize anything. + just_one && lastindex(pairs) in customize && return T() + + kwargs = Dict{Symbol, Any}() + foreach(pairs[customize]) do (name, F) + kwargs[name] = prompt(T, F, name) + end + return T(; kwargs...) +end + +struct NotCustomizable end + +""" + customizable(::Type{<:Plugin}) -> Vector{Pair{Symbol, DataType}} + +Return a list of keyword arguments that the given plugin type accepts, +which are not fields of the type, and should be customizable in interactive mode. +For example, for a constructor `Foo(; x::Bool)`, provide `[x => Bool]`. +If `T` has fields which should not be customizable, use `NotCustomizable` as the type. +""" +customizable(::Type) = () + +function pretty_message(s::AbstractString) + replacements = [ + r"Array{(.*?),1}" => s"Vector{\1}", + r"Union{Nothing, (.*?)}" => s"Union{\1, Nothing}", + ] + return reduce((s, p) -> replace(s, p), replacements; init=s) +end + +""" + input_tips(::Type{T}) -> Vector{String} + +Provide some extra tips to users on how to structure their input for the type `T`, +for example if multiple delimited values are expected. +""" +input_tips(::Type{Vector{T}}) where T = ["comma-delimited", input_tips(T)...] +input_tips(::Type{Nothing}) = String[] +input_tips(::Type{Union{T, Nothing}}) where T = ["'nothing' for nothing", input_tips(T)...] +input_tips(::Type{Secret}) = ["name only"] +input_tips(::Type) = String[] + +""" + convert_input(::Type{P}, ::Type{T}, s::AbstractString) -> T + +Convert the user input `s` into an instance of `T` for plugin of type `P`. +A default implementation of `T(s)` exists. +""" +convert_input(::Type, T::Type{<:Real}, s::AbstractString) = parse(T, s) +convert_input(::Type, T::Type, s::AbstractString) = T(s) + +function convert_input(P::Type, ::Type{Union{T, Nothing}}, s::AbstractString) where T + # This is kind of sketchy because technically, there might be some other input + # whose value we want to instantiate with the string "nothing", + # but I think that would be a pretty rare occurrence. + # If that really happens, they can just override this method. + return s == "nothing" ? nothing : convert_input(P, T, s) +end + +function convert_input(::Type, ::Type{Bool}, s::AbstractString) + s = lowercase(s) + return if startswith(s, 't') || startswith(s, 'y') + true + elseif startswith(s, 'f') || startswith(s, 'n') + false + else + throw(ArgumentError("Unrecognized boolean response")) + end +end + +function convert_input(P::Type, T::Type{<:Vector}, s::AbstractString) + startswith(s, '[') && endswith(s, ']') && (s = s[2:end-1]) + xs = map(strip, split(s, ",")) + return map(x -> convert_input(P, eltype(T), x), xs) +end + +""" + prompt(::Type{P}, ::Type{T}, ::Val{name::Symbol}) -> Any + +Prompts for an input of type `T` for field `name` of plugin type `P`. +Implement this method to customize particular fields of particular types. +""" +prompt(P::Type, T::Type, name::Symbol) = prompt(P, T, Val(name)) + +# The trailing `nothing` is a hack for `fallback_prompt` to use, ignore it. +function prompt(P::Type, ::Type{T}, ::Val{name}, ::Nothing=nothing) where {T, name} + tips = join([T; input_tips(T); "default=$(repr(defaultkw(P, name)))"], ", ") + default = defaultkw(P, name) + input = Base.prompt(pretty_message("Enter value for '$name' ($tips)")) + input === nothing && throw(InterruptException()) + return if isempty(input) + default + else + try + # Working around what appears to be a bug in Julia 1.0: + # #145#issuecomment-623049535 + if VERSION < v"1.1" && T isa Union && Nothing <: T + if input == "nothing" + nothing + else + convert_input(P, T.a === Nothing ? T.b : T.a, input) + end + else + convert_input(P, T, input) + end + catch ex + ex isa InterruptException && rethrow() + @warn "Invalid input" ex + prompt(P, T, name) + end + end +end + +# Compute all the concrete subtypes of T. +concretes_rec(T::Type) = isabstracttype(T) ? vcat(map(concretes_rec, subtypes(T))...) : Any[T] +concretes(T::Type) = sort!(concretes_rec(T); by=nameof) + +# Compute name => type pairs for T's interactive options. +function interactive_pairs(T::Type) + pairs = collect(map(name -> name => fieldtype(T, name), fieldnames(T))) + + # Use prepend! here so that users can override field types if they wish. + prepend!(pairs, reverse(customizable(T))) + uniqueby!(first, pairs) + filter!(p -> last(p) !== NotCustomizable, pairs) + sort!(pairs; by=first) + + return pairs +end + +# unique!(f, xs) added here: https://github.com/JuliaLang/julia/pull/30141 +if VERSION >= v"1.1" + const uniqueby! = unique! +else + function uniqueby!(f, xs) + seen = Set() + todelete = Int[] + foreach(enumerate(map(f, xs))) do (i, out) + out in seen && push!(todelete, i) + push!(seen, out) + end + return deleteat!(xs, todelete) + end +end diff --git a/src/plugin.jl b/src/plugin.jl index d477c16..aaaf72a 100644 --- a/src/plugin.jl +++ b/src/plugin.jl @@ -1,6 +1,82 @@ const TEMPLATES_DIR = normpath(joinpath(@__DIR__, "..", "templates")) const DEFAULT_PRIORITY = 1000 +""" + @plugin struct ... end + +Define a plugin subtype with keyword constructors and default values. + +For details on the general syntax, see +[Parameters.jl](https://mauro3.github.io/Parameters.jl/stable/manual/#Types-with-default-values-and-keyword-constructors-1). + +There are a few extra restrictions: + +- Before using this macro, you must have imported `@with_kw_noshow` + via `using PkgTemplates: @with_kw_noshow` +- The type must be a subtype of [`Plugin`](@ref) (or one of its abstract subtypes) +- The type cannot be parametric +- All fields must have default values + +## Example + +```julia +using PkgTemplates: @plugin, @with_kw_noshow, Plugin +@plugin struct MyPlugin <: Plugin + x::String = "hello!" + y::Union{Int, Nothing} = nothing +end +``` + +## Implementing `@plugin` Manually + +If for whatever reason, you are unable to meet the criteria outlined above, +you can manually implement the methods that `@plugin` would have created for you. +This is only mandatory if you want to use your plugin in interactive mode. + +### Keyword Constructors + +If possible, use `@with_kw_noshow` to create a keyword constructor for your type. +Your type must be capable of being instantiated with no arguments. + +### Default Values + +If your type's fields have sensible default values, implement `defaultkw` like so: + +```julia +using PkgTemplates: PkgTemplates, Plugin +struct MyPlugin <: Plugin + x::String +end +PkgTemplates.defaultkw(::Type{MyPlugin}, ::Val{:x}) = "my default" +``` + +Remember to add a method to the function belonging to PkgTemplates, +rather than creating your own function that PkgTemplates won't see. + +If your plugin's fields have no sane defaults, then you'll need to implement +[`prompt`](@ref) appropriately instead. +""" +macro plugin(ex::Expr) + @assert ex.head === :struct "Expression must be a struct definition" + @assert ex.args[2] isa Expr && ex.args[2].head === :<: "Type must have a supertype" + T = ex.args[2].args[1] + @assert T isa Symbol "@plugin does not work for parametric types" + + msg = "Run `using PkgTemplates: @with_kw_noshow` before using this macro" + @assert isdefined(__module__, Symbol("@with_kw_noshow")) msg + block = :(begin @with_kw_noshow $ex end) + + foreach(filter(arg -> arg isa Expr, ex.args[3].args)) do field + @assert field.head === :(=) "Field must have a default value" + name = QuoteNode(field.args[1].args[1]) + default = field.args[2] + def = :(PkgTemplates.defaultkw(::Type{$T}, ::Val{$name}) = $default) + push!(block.args, def) + end + + return esc(block) +end + function Base.:(==)(a::T, b::T) where T <: Plugin return all(n -> getfield(a, n) == getfield(b, n), fieldnames(T)) end @@ -240,13 +316,13 @@ function gen_file(file::AbstractString, text::AbstractString) end """ - render_file(file::AbstractString view::Dict{<:AbstractString}, tags) -> String + render_file(file::AbstractString view::Dict{<:AbstractString}, tags=nothing) -> String Render a template file with the data in `view`. `tags` should be a tuple of two strings, which are the opening and closing delimiters, or `nothing` to use the default delimiters. """ -function render_file(file::AbstractString, view::Dict{<:AbstractString}, tags) +function render_file(file::AbstractString, view::Dict{<:AbstractString}, tags=nothing) return render_text(read(file, String), view, tags) end diff --git a/src/plugins/ci.jl b/src/plugins/ci.jl index 71cc715..e15ab67 100644 --- a/src/plugins/ci.jl +++ b/src/plugins/ci.jl @@ -44,7 +44,7 @@ $EXTRA_VERSIONS_DOC If using coverage plugins, don't forget to manually add your API tokens as secrets, as described [here](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets#creating-encrypted-secrets). """ -@with_kw_noshow struct GitHubActions <: FilePlugin +@plugin struct GitHubActions <: FilePlugin file::String = default_file("github", "workflows", "ci.yml") destination::String = "ci.yml" linux::Bool = true @@ -116,7 +116,7 @@ Integrates your packages with [Travis CI](https://travis-ci.com). Another code coverage plugin such as [`Codecov`](@ref) must also be included. $EXTRA_VERSIONS_DOC """ -@with_kw_noshow struct TravisCI <: FilePlugin +@plugin struct TravisCI <: FilePlugin file::String = default_file("travis.yml") linux::Bool = true osx::Bool = true @@ -188,7 +188,7 @@ via [AppVeyor.jl](https://github.com/JuliaCI/Appveyor.jl). [`Codecov`](@ref) must also be included. $EXTRA_VERSIONS_DOC """ -@with_kw_noshow struct AppVeyor <: FilePlugin +@plugin struct AppVeyor <: FilePlugin file::String = default_file("appveyor.yml") x86::Bool = false coverage::Bool = true @@ -244,7 +244,7 @@ $EXTRA_VERSIONS_DOC Code coverage submission from Cirrus CI is not yet supported by [Coverage.jl](https://github.com/JuliaCI/Coverage.jl). """ -@with_kw_noshow struct CirrusCI <: FilePlugin +@plugin struct CirrusCI <: FilePlugin file::String = default_file("cirrus.yml") image::String = "freebsd-12-0-release-amd64" coverage::Bool = true @@ -293,7 +293,7 @@ See [`Documenter`](@ref) for more information. !!! note Nightly Julia is not supported. """ -@with_kw_noshow struct GitLabCI <: FilePlugin +@plugin struct GitLabCI <: FilePlugin file::String = default_file("gitlab-ci.yml") coverage::Bool = true # Nightly has no Docker image. @@ -353,7 +353,7 @@ $EXTRA_VERSIONS_DOC !!! note Nightly Julia is not supported. """ -@with_kw_noshow struct DroneCI <: FilePlugin +@plugin struct DroneCI <: FilePlugin file::String = default_file("drone.star") destination::String = ".drone.star" amd64::Bool = true @@ -396,6 +396,8 @@ function collect_versions(t::Template, versions::Vector) return sort(unique(vs)) end +const AllCI = Union{AppVeyor, GitHubActions, TravisCI, CirrusCI, GitLabCI, DroneCI} + """ is_ci(::Plugin) -> Bool @@ -403,6 +405,7 @@ Determine whether or not a plugin is a CI plugin. If you are adding a CI plugin, you should implement this function and return `true`. """ is_ci(::Plugin) = false -is_ci(::Union{AppVeyor, GitHubActions, TravisCI, CirrusCI, GitLabCI, DroneCI}) = true +is_ci(::AllCI) = true -needs_username(::Union{AppVeyor, GitHubActions, TravisCI, CirrusCI, GitLabCI, DroneCI}) = true +needs_username(::AllCI) = true +customizable(::Type{<:AllCI}) = (:extra_versions => Vector{VersionNumber},) diff --git a/src/plugins/citation.jl b/src/plugins/citation.jl index d4e5f84..350d2e5 100644 --- a/src/plugins/citation.jl +++ b/src/plugins/citation.jl @@ -7,7 +7,7 @@ Creates a `CITATION.bib` file for citing package repositories. - `file::AbstractString`: Template file for `CITATION.bib`. - `readme::Bool`: Whether or not to include a section about citing in the README. """ -@with_kw_noshow struct Citation <: FilePlugin +@plugin struct Citation <: FilePlugin file::String = default_file("CITATION.bib") readme::Bool = false end diff --git a/src/plugins/compat_helper.jl b/src/plugins/compat_helper.jl index 14c709b..2024c74 100644 --- a/src/plugins/compat_helper.jl +++ b/src/plugins/compat_helper.jl @@ -13,7 +13,7 @@ Integrates your packages with [CompatHelper](https://github.com/bcbi/CompatHelpe relative to `.github/workflows`. - `cron::AbstractString`: Cron expression for the schedule interval. """ -@with_kw_noshow struct CompatHelper <: FilePlugin +@plugin struct CompatHelper <: FilePlugin file::String = default_file("github", "workflows", "CompatHelper.yml") destination::String = "CompatHelper.yml" cron::String = "0 0 * * *" diff --git a/src/plugins/coverage.jl b/src/plugins/coverage.jl index ff4be5c..22f5f3b 100644 --- a/src/plugins/coverage.jl +++ b/src/plugins/coverage.jl @@ -9,7 +9,7 @@ Sets up code coverage submission from CI to [Codecov](https://codecov.io). - `file::Union{AbstractString, Nothing}`: Template file for `.codecov.yml`, or `nothing` to create no file. """ -@with_kw_noshow struct Codecov <: FilePlugin +@plugin struct Codecov <: FilePlugin file::Union{String, Nothing} = nothing end @@ -31,7 +31,7 @@ Sets up code coverage submission from CI to [Coveralls](https://coveralls.io). - `file::Union{AbstractString, Nothing}`: Template file for `.coveralls.yml`, or `nothing` to create no file. """ -@with_kw_noshow struct Coveralls <: FilePlugin +@plugin struct Coveralls <: FilePlugin file::Union{String, Nothing} = nothing end diff --git a/src/plugins/documenter.jl b/src/plugins/documenter.jl index a287d7f..3175ed7 100644 --- a/src/plugins/documenter.jl +++ b/src/plugins/documenter.jl @@ -3,11 +3,12 @@ const DOCUMENTER_DEP = PackageSpec(; uuid="e30172f5-a6a5-5a46-863b-614d45cd2de4", ) -const DeployStyle = Union{TravisCI, GitHubActions, GitLabCI, Nothing} +struct NoDeploy end +const DeployStyle = Union{TravisCI, GitHubActions, GitLabCI, NoDeploy} const GitHubPagesStyle = Union{TravisCI, GitHubActions} """ - Documenter{T<:Union{TravisCI, GitLabCI, Nothing}}(; + Documenter{T<:Union{TravisCI, GitLabCI, NoDeploy}}(; make_jl="$(contractuser(default_file("docs", "make.jl")))", index_md="$(contractuser(default_file("docs", "src", "index.md")))", assets=String[], @@ -26,7 +27,7 @@ or `Nothing` to only support local documentation builds. with the help of [`TravisCI`](@ref). - `GitLabCI`: Deploys documentation to [GitLab Pages](https://pages.gitlab.com) with the help of [`GitLabCI`](@ref). -- `Nothing` (default): Does not set up documentation deployment. +- `NoDeploy` (default): Does not set up documentation deployment. ## Keyword Arguments - `make_jl::AbstractString`: Template file for `make.jl`. @@ -49,7 +50,7 @@ struct Documenter{T<:DeployStyle} <: Plugin make_jl::String index_md::String - # Can't use @with_kw_noshow due to some weird precompilation issues. + # Can't use @plugin because we're implementing our own no-arguments constructor. function Documenter{T}(; assets::Vector{<:AbstractString}=String[], makedocs_kwargs::Dict{Symbol}=Dict{Symbol, Any}(), @@ -61,7 +62,12 @@ struct Documenter{T<:DeployStyle} <: Plugin end end -Documenter(; kwargs...) = Documenter{Nothing}(; kwargs...) +Documenter(; kwargs...) = Documenter{NoDeploy}(; kwargs...) + +# We have to define these manually because we didn't use @plugin. +defaultkw(::Type{<:Documenter}, ::Val{:assets}) = String[] +defaultkw(::Type{<:Documenter}, ::Val{:make_jl}) = default_file("docs", "make.jl") +defaultkw(::Type{<:Documenter}, ::Val{:index_md}) = default_file("docs", "src", "index.md") gitignore(::Documenter) = ["/docs/build/"] priority(::Documenter, ::Function) = DEFAULT_PRIORITY - 1 # We need SrcDir to go first. @@ -102,7 +108,7 @@ function view(p::Documenter{<:GitHubPagesStyle}, t::Template, pkg::AbstractStrin return merge(base, Dict("HAS_DEPLOY" => true)) end -validate(::Documenter{Nothing}, ::Template) = nothing +validate(::Documenter{NoDeploy}, ::Template) = nothing function validate(::Documenter{T}, t::Template) where T <: DeployStyle if !hasplugin(t, T) name = nameof(T) @@ -138,6 +144,18 @@ gitlab_pages_url(t::Template, pkg::AbstractString) = "https://$(t.user).gitlab.i make_canonical(::Type{<:GitHubPagesStyle}) = github_pages_url make_canonical(::Type{GitLabCI}) = gitlab_pages_url -make_canonical(::Type{Nothing}) = nothing +make_canonical(::Type{NoDeploy}) = nothing needs_username(::Documenter) = true + +function customizable(::Type{<:Documenter}) + return (:canonical_url => NotCustomizable, :makedocs_kwargs => NotCustomizable) +end + +function interactive(::Type{Documenter}) + styles = [Nothing, TravisCI, GitLabCI] + menu = RadioMenu(map(string, styles); pagesize=length(styles)) + println("Documenter deploy style:") + idx = request(menu) + return interactive(Documenter{styles[idx]}) +end diff --git a/src/plugins/git.jl b/src/plugins/git.jl index 77c0763..8e4aee3 100644 --- a/src/plugins/git.jl +++ b/src/plugins/git.jl @@ -22,7 +22,7 @@ Creates a Git repository and a `.gitignore` file. This option requires that the Git CLI is installed, and for you to have a GPG key associated with your committer identity. """ -@with_kw_noshow struct Git <: Plugin +@plugin struct Git <: Plugin ignore::Vector{String} = String[] name::Union{String, Nothing} = nothing email::Union{String, Nothing} = nothing @@ -34,8 +34,6 @@ end # Try to make sure that no files are created after we commit. priority(::Git, ::typeof(posthook)) = 5 -Base.:(==)(a::Git, b::Git) = all(map(n -> getfield(a, n) == getfield(b, n), fieldnames(Git))) - function gitignore(p::Git) ignore = copy(p.ignore) p.manifest || push!(ignore, "Manifest.toml") @@ -101,6 +99,7 @@ function posthook(p::Git, ::Template, pkg_dir::AbstractString) msg = "Files generated by PkgTemplates" v = version_of("PkgTemplates") v === nothing || (msg *= "\n\nPkgTemplates version: $v") + # TODO: Put the template config in the message too? commit(p, repo, pkg_dir, msg) end end diff --git a/src/plugins/license.jl b/src/plugins/license.jl index 8464f5e..656fb16 100644 --- a/src/plugins/license.jl +++ b/src/plugins/license.jl @@ -29,9 +29,26 @@ function License(; return License(path, destination) end +defaultkw(::Type{License}, ::Val{:path}) = nothing +defaultkw(::Type{License}, ::Val{:name}) = "MIT" +defaultkw(::Type{License}, ::Val{:destination}) = "LICENSE" + source(p::License) = p.path destination(p::License) = p.destination view(::License, t::Template, ::AbstractString) = Dict( "AUTHORS" => join(t.authors, ", "), "YEAR" => year(today()), ) + +function prompt(::Type{License}, ::Type, ::Val{:name}) + options = readdir(default_file("licenses")) + # Move MIT to the top. + deleteat!(options, findfirst(==("MIT"), options)) + pushfirst!(options, "MIT") + menu = RadioMenu(options; pagesize=length(options)) + println("Select a license:") + idx = request(menu) + return options[idx] +end + +customizable(::Type{License}) = (:name => String,) diff --git a/src/plugins/project_file.jl b/src/plugins/project_file.jl index 0afbd04..28efd58 100644 --- a/src/plugins/project_file.jl +++ b/src/plugins/project_file.jl @@ -6,7 +6,7 @@ Creates a `Project.toml`. ## Keyword Arguments - `version::VersionNumber`: The initial version of created packages. """ -@with_kw_noshow struct ProjectFile <: Plugin +@plugin struct ProjectFile <: Plugin version::VersionNumber = v"0.1.0" end diff --git a/src/plugins/readme.jl b/src/plugins/readme.jl index ff91233..b5aa06b 100644 --- a/src/plugins/readme.jl +++ b/src/plugins/readme.jl @@ -13,7 +13,7 @@ Creates a `README` file that contains badges for other included plugins. For example, values of `"README"` or `"README.rst"` might be desired. - `inline_badges::Bool`: Whether or not to put the badges on the same line as the package name. """ -@with_kw_noshow struct Readme <: FilePlugin +@plugin struct Readme <: FilePlugin file::String = default_file("README.md") destination::String = "README.md" inline_badges::Bool = false diff --git a/src/plugins/src_dir.jl b/src/plugins/src_dir.jl index 615c10e..e6004e0 100644 --- a/src/plugins/src_dir.jl +++ b/src/plugins/src_dir.jl @@ -6,7 +6,7 @@ Creates a module entrypoint. ## Keyword Arguments - `file::AbstractString`: Template file for `src/.jl`. """ -@with_kw_noshow mutable struct SrcDir <: FilePlugin +@plugin mutable struct SrcDir <: FilePlugin file::String = default_file("src", "module.jl") destination::String = "" end diff --git a/src/plugins/tagbot.jl b/src/plugins/tagbot.jl index a447498..b4de0ba 100644 --- a/src/plugins/tagbot.jl +++ b/src/plugins/tagbot.jl @@ -34,7 +34,7 @@ Adds GitHub release support via [TagBot](https://github.com/JuliaRegistries/TagB - `dispatch::Bool`: Whether or not to enable the `dispatch` option. - `dispatch_delay::Int`: Number of minutes to delay for dispatch events. """ -@with_kw_noshow struct TagBot <: FilePlugin +@plugin struct TagBot <: FilePlugin file::String = default_file("github", "workflows", "TagBot.yml") destination::String = "TagBot.yml" cron::String = "0 0 * * *" diff --git a/src/plugins/tests.jl b/src/plugins/tests.jl index eacfe49..d100cd3 100644 --- a/src/plugins/tests.jl +++ b/src/plugins/tests.jl @@ -16,7 +16,7 @@ Sets up testing for packages. Managing test dependencies with `test/Project.toml` is only supported in Julia 1.2 and later. """ -@with_kw_noshow struct Tests <: FilePlugin +@plugin struct Tests <: FilePlugin file::String = default_file("test", "runtests.jl") project::Bool = false end diff --git a/src/show.jl b/src/show.jl index fddad04..85f2b83 100644 --- a/src/show.jl +++ b/src/show.jl @@ -16,7 +16,7 @@ end function Base.show(io::IO, ::MIME"text/plain", p::T) where T <: Plugin indent = get(io, :indent, 0) - print(io, repeat(' ', indent), T) + print(io, repeat(' ', indent), nameof(T)) ns = fieldnames(T) isempty(ns) || print(io, ":") foreach(ns) do n diff --git a/src/template.jl b/src/template.jl index 1da1606..2d7324a 100644 --- a/src/template.jl +++ b/src/template.jl @@ -42,10 +42,17 @@ A configuration used to generate packages. ### Template Plugins - `plugins::Vector{<:Plugin}=Plugin[]`: A list of [`Plugin`](@ref)s used by the template. The default plugins are [`ProjectFile`](@ref), [`SrcDir`](@ref), [`Tests`](@ref), - [`Readme`](@ref), [`License`](@ref), and [`Git`](@ref). + [`Readme`](@ref), [`License`](@ref), [`Git`](@ref), [`CompatHelper`](@ref), and + [`TagBot`](@ref). To disable a default plugin, pass in the negated type: `!PluginType`. To override a default plugin instead of disabling it, pass in your own instance. +### Interactive Mode +- `interactive::Bool=false`: In addition to specifying the template options with keywords, + you can also build up a template by following a set of prompts. + To create a template interactively, set this keyword to `true`. + See also the similar [`generate`](@ref) function. + --- To create a package from a `Template`, use the following syntax: @@ -65,9 +72,9 @@ struct Template user::String end -Template(; kwargs...) = Template(Val(false); kwargs...) +Template(; interactive::Bool=false, kwargs...) = Template(Val(interactive); kwargs...) +Template(::Val{true}; kwargs...) = interactive(Template; kwargs...) -# Non-interactive constructor. function Template(::Val{false}; kwargs...) kwargs = Dict(kwargs) @@ -144,7 +151,11 @@ end hasplugin(t::Template, f::Function) = any(f, t.plugins) hasplugin(t::Template, ::Type{T}) where T <: Plugin = hasplugin(t, p -> p isa T) -# Get a plugin by type. +""" + getplugin(t::Template, ::Type{T<:Plugin}) -> Union{T, Nothing} + +Get the plugin of type `T` from the template `t`, if it's present. +""" function getplugin(t::Template, ::Type{T}) where T <: Plugin i = findfirst(p -> p isa T, t.plugins) return i === nothing ? nothing : t.plugins[i] @@ -156,8 +167,87 @@ getkw!(kwargs, k) = pop!(kwargs, k, defaultkw(Template, k)) # Default Template keyword values. defaultkw(::Type{T}, s::Symbol) where T = defaultkw(T, Val(s)) defaultkw(::Type{Template}, ::Val{:authors}) = default_authors() -defaultkw(::Type{Template}, ::Val{:dir}) = Pkg.devdir() +defaultkw(::Type{Template}, ::Val{:dir}) = contractuser(Pkg.devdir()) defaultkw(::Type{Template}, ::Val{:host}) = "github.com" defaultkw(::Type{Template}, ::Val{:julia}) = default_version() defaultkw(::Type{Template}, ::Val{:plugins}) = Plugin[] defaultkw(::Type{Template}, ::Val{:user}) = default_user() + +function interactive(::Type{Template}; kwargs...) + # If the user supplied any keywords themselves, don't prompt for them. + kwargs = Dict{Symbol, Any}(kwargs) + options = [:user, :authors, :dir, :host, :julia, :plugins] + customizable = setdiff(options, keys(kwargs)) + + # Make sure we don't try to show a menu with < 2 options. + isempty(customizable) && return Template(; kwargs...) + just_one = length(customizable) == 1 + just_one && push(customizable, "None") + + return try + println("Template keywords to customize:") + menu = MultiSelectMenu(map(string, customizable); pagesize=length(customizable)) + customize = customizable[sort!(collect(request(menu)))] + just_one && lastindex(customizable) in customize && return Template(; kwargs...) + + # Prompt for each keyword. + foreach(customize) do k + kwargs[k] = prompt(Template, fieldtype(Template, k), k) + end + + Template(; kwargs...) + catch e + e isa InterruptException || rethrow() + println() + @info "Cancelled" + nothing + end +end + +function prompt(::Type{Template}, ::Type, ::Val{:host}) + hosts = ["github.com", "gitlab.com", "bitbucket.org", "Other"] + menu = RadioMenu(hosts; pagesize=length(hosts)) + println("Select Git repository hosting service:") + idx = request(menu) + return if idx == lastindex(hosts) + fallback_prompt(String, :host) + else + hosts[idx] + end +end + +function prompt(::Type{Template}, ::Type, ::Val{:julia}) + versions = map(format_version, VersionNumber.(1, 0:VERSION.minor)) + push!(versions, "Other") + menu = RadioMenu(map(string, versions); pagesize=length(versions)) + println("Select minimum Julia version:") + idx = request(menu) + return if idx == lastindex(versions) + fallback_prompt(VersionNumber, :julia) + else + VersionNumber(versions[idx]) + end +end + +const CR = "\r" +const DOWN = "\eOB" + +function prompt(::Type{Template}, ::Type, ::Val{:plugins}) + defaults = map(typeof, default_plugins()) + ndefaults = length(defaults) + # Put the defaults first. + options = unique!([defaults; concretes(Plugin)]) + menu = MultiSelectMenu(map(T -> string(nameof(T)), options); pagesize=length(options)) + println("Select plugins:") + # Pre-select the default plugins and move the cursor to the first non-default. + # To make this better, we need julia#30043. + print(stdin.buffer, (CR * DOWN)^ndefaults) + types = sort!(collect(request(menu))) + plugins = Vector{Any}(map(interactive, options[types])) + # Find any defaults that were disabled. + foreach(i -> i in types || push!(plugins, !defaults[i]), 1:ndefaults) + return plugins +end + +# Call the default prompt method even if a specialized one exists. +fallback_prompt(T::Type, name::Symbol) = prompt(Template, T, Val(name), nothing) diff --git a/test/git.jl b/test/git.jl index 72c1615..450fbf4 100644 --- a/test/git.jl +++ b/test/git.jl @@ -1,3 +1,5 @@ +@info "Running Git tests" + @testset "Git repositories" begin @testset "Does not create Git repo" begin t = tpl(; plugins=[!Git]) diff --git a/test/interactive.jl b/test/interactive.jl new file mode 100644 index 0000000..86e176d --- /dev/null +++ b/test/interactive.jl @@ -0,0 +1,203 @@ +@info "Running interactive tests" + +using PkgTemplates: @with_kw_noshow + +const CR = "\r" +const LF = "\n" +const UP = "\eOA" +const DOWN = "\eOB" +const ALL = "a" +const NONE = "n" +const DONE = "d" + +# Because the plugin selection dialog prints directly to stdin in the same way +# as we do here, and our input prints happen first, we're going to need to insert +# the plugin selection prints ourselves, and then "undo" the extra ones at the end +# by consuming whatever is left in stdin. +const NDEFAULTS = length(PT.default_plugins()) +const SELECT_DEFAULTS = (CR * DOWN)^NDEFAULTS + +struct FromString + s::String +end + +@testset "Interactive mode" begin + @testset "Input conversion" begin + generic(T, x) = PT.convert_input(PT.Plugin, T, x) + @test generic(String, "foo") == "foo" + @test generic(Float64, "1.23") == 1.23 + @test generic(Int, "01") == 1 + @test generic(Bool, "yes") === true + @test generic(Bool, "True") === true + @test generic(Bool, "No") === false + @test generic(Bool, "false") === false + @test generic(Vector{Int}, "1, 2, 3") == [1, 2, 3] + @test generic(Vector{String}, "a, b,c") == ["a", "b", "c"] + @test generic(FromString, "hello") == FromString("hello") + if VERSION < v"1.1" + @test_broken generic(Union{String, Nothing}, "nothing") === nothing + else + @test generic(Union{String, Nothing}, "nothing") === nothing + end + + @test_throws ArgumentError generic(Int, "hello") + @test_throws ArgumentError generic(Float64, "hello") + @test_throws ArgumentError generic(Bool, "hello") + end + + @testset "input_tips" begin + @test isempty(PT.input_tips(Int)) + @test PT.input_tips(Vector{String}) == ["comma-delimited"] + @test PT.input_tips(Union{Vector{String}, Nothing}) == + ["'nothing' for nothing", "comma-delimited"] + @test PT.input_tips(Union{String, Nothing}) == ["'nothing' for nothing"] + @test PT.input_tips(Union{Vector{Secret}, Nothing}) == + ["'nothing' for nothing", "comma-delimited", "name only"] + end + + @testset "Interactive name/type pair collection" begin + name = gensym() + @eval begin + PT.@plugin struct $name <: PT.Plugin + x::Int = 0 + y::String = "" + end + + @test PT.interactive_pairs($name) == [:x => Int, :y => String] + PT.customizable(::Type{$name}) = (:x => PT.NotCustomizable, :y => Float64, :z => Int) + @test PT.interactive_pairs($name) == [:y => Float64, :z => Int] + end + end + + @testset "Simulated inputs" begin + @testset "Default template" begin + print( + stdin.buffer, + CR, # Select user + DONE, # Finish menu + USER, LF, # Enter user + ) + @test Template(; interactive=true) == Template(; user=USER) + end + + @testset "Custom options, accept defaults" begin + print( + stdin.buffer, + ALL, DONE, # Customize all fields + "user", LF, # Enter user (don't assume we have default for this one). + LF, # Enter authors + LF, # Enter dir + CR, # Enter host + CR, # Enter julia + SELECT_DEFAULTS, # Pre-select default plugins + DONE, # Select no additional plugins + DONE^NDEFAULTS, # Don't customize plugins + ) + @test Template(; interactive=true) == Template(; user="user") + readavailable(stdin.buffer) + end + + @testset "Custom options, custom values" begin + nversions = VERSION.minor + 1 + print( + stdin.buffer, + ALL, DONE, # Customize all fields + "user", LF, # Enter user + "a, b", LF, # Enter authors + "~", LF, # Enter dir + DOWN^3, CR, # Choose "Other" for host + "x.com", LF, # Enter host + DOWN^nversions, CR, # Choose "Other" for julia + "0.7", LF, # Enter Julia version + SELECT_DEFAULTS, # Pre-select default plugins + DONE, # Select no additional plugins + DONE^NDEFAULTS, # Don't customize plugins + ) + @test Template(; interactive=true) == Template(; + user="user", + authors=["a", "b"], + dir="~", + host="x.com", + julia=v"0.7", + ) + readavailable(stdin.buffer) + end + + @testset "Disabling default plugins" begin + print( + stdin.buffer, + CR, DOWN^5, CR, DONE, # Customize user and plugins + USER, LF, # Enter user + SELECT_DEFAULTS, # Pre-select default plugins + UP, CR, UP^2, CR, DONE, # Disable TagBot and Readme + DONE^(NDEFAULTS - 2), # Don't customize plugins + ) + @test Template(; interactive=true) == Template(; + user=USER, + plugins=[!Readme, !TagBot], + ) + readavailable(stdin.buffer) + end + + @testset "Plugins" begin + print( + stdin.buffer, + ALL, DONE, # Customize all fields + "true", LF, # Enable ARM64 + "no", LF, # Disable coverage + "1.1,v1.2", LF, # Enter extra versions + "x.txt", LF, # Enter file + "Yes", LF, # Enable Linux + "false", LF, # Disable OSX + "TRUE", LF, # Enable Windows + "YES", LF, # Enable x64 + "NO", LF, # Disable x86 + ) + @test PT.interactive(TravisCI) == TravisCI(; + arm64=true, + coverage=false, + extra_versions=[v"1.1", v"1.2"], + file="x.txt", + linux=true, + osx=false, + windows=true, + x64=true, + x86=false, + ) + + print( + stdin.buffer, + DOWN^2, CR, # Select GitLabCI + DOWN, CR, DONE, # Customize index_md + "x.txt", LF, # Enter index file + ) + @test PT.interactive(Documenter) == Documenter{GitLabCI}(; index_md="x.txt") + + print( + stdin.buffer, + CR, DOWN, CR, DONE, # Customize name and destination + "COPYING", LF, # Enter destination + CR, # Choose MIT for name (it's at the top) + ) + @test PT.interactive(License) == License(; destination="COPYING", name="MIT") + end + + @testset "Union{T, Nothing} weirdness" begin + print( + stdin.buffer, + DOWN, CR, DONE, # Customize changelog + "hello", LF, # Enter changelog + ) + @test PT.interactive(TagBot) == TagBot(; changelog="hello") + + print( + stdin.buffer, + DOWN, CR, DONE, # Customize changelog + "nothing", LF, # Set to null + ) + @test PT.interactive(TagBot) == TagBot(; changelog=nothing) + end + + println() + end +end diff --git a/test/plugin.jl b/test/plugin.jl index 5ff14cd..0918172 100644 --- a/test/plugin.jl +++ b/test/plugin.jl @@ -1,5 +1,7 @@ # Don't move this line from the top, please. {{X}} {{Y}} {{Z}} +@info "Running plugin tests" + struct FileTest <: PT.FilePlugin a::String b::Bool diff --git a/test/reference.jl b/test/reference.jl index ced8cb6..948aae7 100644 --- a/test/reference.jl +++ b/test/reference.jl @@ -1,3 +1,5 @@ +@info "Running reference tests" + const PROMPT = get(ENV, "PT_INTERACTIVE", "false") == "true" || !haskey(ENV, "CI") const STATIC_FILE = joinpath(@__DIR__, "fixtures", "static.txt") const STATIC_DOCUMENTER = [ diff --git a/test/runtests.jl b/test/runtests.jl index 66882ff..a2ae4d7 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -4,7 +4,7 @@ using Base.Filesystem: path_separator using LibGit2: LibGit2, GitCommit, GitRemote, GitRepo using Pkg: Pkg, PackageSpec, TOML using Random: Random, randstring -using Test: @test, @testset, @test_logs, @test_throws +using Test: @test, @testset, @test_broken, @test_logs, @test_throws using DeepDiffs: deepdiff using SimpleMock: mock @@ -54,6 +54,7 @@ mktempdir() do dir include("template.jl") include("plugin.jl") include("show.jl") + include("interactive.jl") if PT.git_is_installed() include("git.jl") @@ -64,7 +65,7 @@ mktempdir() do dir if VERSION.major == 1 && VERSION.minor == 4 include("reference.jl") else - @info "Skipping reference tests" julia=VERSION + @info "Skipping reference tests" VERSION end else @info "Git is not installed, skipping Git and reference tests" diff --git a/test/show.jl b/test/show.jl index 3cbe24a..459b19e 100644 --- a/test/show.jl +++ b/test/show.jl @@ -1,3 +1,5 @@ +@info "Running show tests" + const TEMPLATES_DIR = contractuser(PT.TEMPLATES_DIR) const LICENSES_DIR = joinpath(TEMPLATES_DIR, "licenses") diff --git a/test/template.jl b/test/template.jl index 443aa21..c1bf96a 100644 --- a/test/template.jl +++ b/test/template.jl @@ -1,3 +1,5 @@ +@info "Running template tests" + @testset "Template" begin @testset "Template constructor" begin @testset "user" begin