slipway 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.yardopts +7 -0
- data/CHANGELOG.md +45 -0
- data/LICENSE.txt +21 -0
- data/README.md +1013 -0
- data/exe/slipway +10 -0
- data/lib/slipway/cli/builtins.rb +241 -0
- data/lib/slipway/cli/completer.rb +158 -0
- data/lib/slipway/cli/completion_scripts.rb +163 -0
- data/lib/slipway/cli/context.rb +67 -0
- data/lib/slipway/cli/errors.rb +19 -0
- data/lib/slipway/cli/globals.rb +27 -0
- data/lib/slipway/cli/help_renderer.rb +135 -0
- data/lib/slipway/cli/manpage.rb +226 -0
- data/lib/slipway/cli/parser.rb +45 -0
- data/lib/slipway/cli/registry.rb +191 -0
- data/lib/slipway/cli/runner.rb +186 -0
- data/lib/slipway/cli/style.rb +82 -0
- data/lib/slipway/cli/theme.rb +85 -0
- data/lib/slipway/cli/validator.rb +61 -0
- data/lib/slipway/cli.rb +22 -0
- data/lib/slipway/command_line.rb +22 -0
- data/lib/slipway/commands/api_resources.rb +82 -0
- data/lib/slipway/commands/apply.rb +172 -0
- data/lib/slipway/commands/base.rb +50 -0
- data/lib/slipway/commands/config.rb +73 -0
- data/lib/slipway/commands/create.rb +218 -0
- data/lib/slipway/commands/delete.rb +82 -0
- data/lib/slipway/commands/describe.rb +74 -0
- data/lib/slipway/commands/diff.rb +122 -0
- data/lib/slipway/commands/edit.rb +130 -0
- data/lib/slipway/commands/explain.rb +97 -0
- data/lib/slipway/commands/fetch.rb +112 -0
- data/lib/slipway/commands/from_dir.rb +141 -0
- data/lib/slipway/commands/get.rb +167 -0
- data/lib/slipway/commands/label.rb +114 -0
- data/lib/slipway/commands/manual.rb +67 -0
- data/lib/slipway/commands/options.rb +73 -0
- data/lib/slipway/commands/results.rb +57 -0
- data/lib/slipway/commands/rollout.rb +114 -0
- data/lib/slipway/commands/rollout_spec.rb +99 -0
- data/lib/slipway/commands/rollout_undo.rb +126 -0
- data/lib/slipway/commands/scope.rb +156 -0
- data/lib/slipway/commands/sync.rb +140 -0
- data/lib/slipway/commands.rb +54 -0
- data/lib/slipway/drift.rb +87 -0
- data/lib/slipway/editor.rb +71 -0
- data/lib/slipway/error.rb +27 -0
- data/lib/slipway/fetcher.rb +99 -0
- data/lib/slipway/field_selector.rb +86 -0
- data/lib/slipway/git/branch_name.rb +32 -0
- data/lib/slipway/git/commit.rb +13 -0
- data/lib/slipway/git/distance.rb +13 -0
- data/lib/slipway/git/errors.rb +125 -0
- data/lib/slipway/git/fake.rb +147 -0
- data/lib/slipway/git/fast_forward.rb +12 -0
- data/lib/slipway/git/fast_forwarding.rb +148 -0
- data/lib/slipway/git/fetch_result.rb +22 -0
- data/lib/slipway/git/move_back.rb +12 -0
- data/lib/slipway/git/reflog.rb +25 -0
- data/lib/slipway/git/repository.rb +288 -0
- data/lib/slipway/git/rolling_back.rb +98 -0
- data/lib/slipway/git/runner.rb +175 -0
- data/lib/slipway/git/status.rb +110 -0
- data/lib/slipway/git/url.rb +95 -0
- data/lib/slipway/git.rb +20 -0
- data/lib/slipway/inspector.rb +103 -0
- data/lib/slipway/labels.rb +126 -0
- data/lib/slipway/manifest.rb +265 -0
- data/lib/slipway/names.rb +22 -0
- data/lib/slipway/outcome.rb +45 -0
- data/lib/slipway/output/age.rb +70 -0
- data/lib/slipway/output/describe.rb +71 -0
- data/lib/slipway/output/explain.rb +75 -0
- data/lib/slipway/output/serializer.rb +35 -0
- data/lib/slipway/output/table.rb +67 -0
- data/lib/slipway/output.rb +28 -0
- data/lib/slipway/paths.rb +65 -0
- data/lib/slipway/plan.rb +227 -0
- data/lib/slipway/pool.rb +94 -0
- data/lib/slipway/resources.rb +91 -0
- data/lib/slipway/rollback.rb +236 -0
- data/lib/slipway/rollout_history.rb +69 -0
- data/lib/slipway/runtime.rb +65 -0
- data/lib/slipway/scanner.rb +54 -0
- data/lib/slipway/schema.rb +128 -0
- data/lib/slipway/selector.rb +146 -0
- data/lib/slipway/settings.rb +174 -0
- data/lib/slipway/state.rb +82 -0
- data/lib/slipway/store.rb +170 -0
- data/lib/slipway/syncer.rb +139 -0
- data/lib/slipway/version.rb +5 -0
- data/lib/slipway/views/group.rb +35 -0
- data/lib/slipway/views/project.rb +148 -0
- data/lib/slipway/views.rb +10 -0
- data/lib/slipway/yaml.rb +14 -0
- data/lib/slipway.rb +32 -0
- data/man/man1/slipway-api-resources.1 +53 -0
- data/man/man1/slipway-apply.1 +45 -0
- data/man/man1/slipway-completion.1 +29 -0
- data/man/man1/slipway-config-path.1 +20 -0
- data/man/man1/slipway-config-view.1 +25 -0
- data/man/man1/slipway-config.1 +22 -0
- data/man/man1/slipway-create.1 +89 -0
- data/man/man1/slipway-delete.1 +46 -0
- data/man/man1/slipway-describe.1 +95 -0
- data/man/man1/slipway-diff.1 +120 -0
- data/man/man1/slipway-edit.1 +34 -0
- data/man/man1/slipway-explain.1 +36 -0
- data/man/man1/slipway-fetch.1 +77 -0
- data/man/man1/slipway-get.1 +155 -0
- data/man/man1/slipway-help.1 +19 -0
- data/man/man1/slipway-label.1 +53 -0
- data/man/man1/slipway-man.1 +36 -0
- data/man/man1/slipway-rollout-history.1 +28 -0
- data/man/man1/slipway-rollout-pause.1 +21 -0
- data/man/man1/slipway-rollout-resume.1 +21 -0
- data/man/man1/slipway-rollout-undo.1 +73 -0
- data/man/man1/slipway-rollout-unpin.1 +21 -0
- data/man/man1/slipway-rollout.1 +36 -0
- data/man/man1/slipway-sync.1 +90 -0
- data/man/man1/slipway-version.1 +17 -0
- data/man/man1/slipway.1 +243 -0
- metadata +173 -0
data/exe/slipway
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# A checkout needs lib on the load path; an installed gem already has it there.
|
|
5
|
+
lib = File.expand_path('../lib', __dir__)
|
|
6
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
7
|
+
|
|
8
|
+
require 'slipway'
|
|
9
|
+
|
|
10
|
+
exit Slipway.run(ARGV)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module Slipway
|
|
6
|
+
module CLI
|
|
7
|
+
module Builtins
|
|
8
|
+
MAN_DIR = File.expand_path('../../../man/man1', __dir__)
|
|
9
|
+
|
|
10
|
+
def self.all(program:, version:, resolve:, **man)
|
|
11
|
+
[help(program:, resolve:), version(program:, version:), completion(program:), man(program:, resolve:, **man),
|
|
12
|
+
complete(resolve:)]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.version_line(program, version)
|
|
16
|
+
"#{program} #{version} (ruby #{RUBY_VERSION}) [#{Gem::Platform.local}]"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.help(program:, resolve:)
|
|
20
|
+
Command.new(
|
|
21
|
+
name: 'help', summary: 'Help about any command', section: 'Other Commands',
|
|
22
|
+
description: "Help provides help for any command in the application.\n" \
|
|
23
|
+
"Type #{program} help [path to command] for full details.",
|
|
24
|
+
examples: [Example.new(comment: 'Show the help of a nested command', command: 'help config view')],
|
|
25
|
+
positionals: [Positional.new(name: 'COMMAND', required: false, variadic: true,
|
|
26
|
+
completer: ->(given, _current) { subcommand_names(resolve.call, given) })],
|
|
27
|
+
handler: HelpCommand.new(resolve)
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.version(program:, version:)
|
|
32
|
+
Command.new(
|
|
33
|
+
name: 'version', summary: "Print the version of #{program}", section: 'Other Commands',
|
|
34
|
+
description: "Print the version of #{program}, the Ruby it runs on and the platform.",
|
|
35
|
+
examples: [Example.new(comment: 'Print the version', command: 'version')],
|
|
36
|
+
handler: ->(context, _args, _opts) { context.puts(version_line(program, version)) }
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.completion(program:)
|
|
41
|
+
shells = CompletionScripts::SHELLS.join(', ')
|
|
42
|
+
Command.new(
|
|
43
|
+
name: 'completion', section: 'Settings Commands',
|
|
44
|
+
summary: "Output shell completion code for the specified shell (#{shells})",
|
|
45
|
+
description: "Output shell completion code for the specified shell (#{shells}).\n" \
|
|
46
|
+
'The shell code must be evaluated to provide interactive completion of commands, ' \
|
|
47
|
+
'resource types and names.',
|
|
48
|
+
examples: completion_examples(program),
|
|
49
|
+
positionals: [Positional.new(name: 'SHELL', enum: CompletionScripts::SHELLS)],
|
|
50
|
+
handler: ->(context, args, _opts) { context.print(CompletionScripts.render(args.first, program)) }
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Keep these paths in step with the install lines in the CompletionScripts headers.
|
|
55
|
+
def self.completion_examples(program)
|
|
56
|
+
[
|
|
57
|
+
Example.new(comment: 'Install bash completions where bash-completion loads them',
|
|
58
|
+
command: 'completion bash > "${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion/completions/' \
|
|
59
|
+
"#{program}\""),
|
|
60
|
+
Example.new(comment: 'Install zsh completions in a directory on your fpath',
|
|
61
|
+
command: "completion zsh > ~/.zfunc/_#{program}"),
|
|
62
|
+
Example.new(comment: 'Install fish completions',
|
|
63
|
+
command: "completion fish > ~/.config/fish/completions/#{program}.fish")
|
|
64
|
+
]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# `paths` is called with the environment and answers man_install_dir and man_db_dir for
|
|
68
|
+
# a bare --install. `exec` and `paths` are injectable so tests can observe the calls.
|
|
69
|
+
def self.man(program:, resolve:, man_dir: MAN_DIR, exec: Kernel.method(:exec), paths: nil)
|
|
70
|
+
Command.new(
|
|
71
|
+
name: 'man', section: 'Settings Commands', summary: 'Show the manual page of a command',
|
|
72
|
+
description: "Show the manual page of #{program} or of one of its commands with man(1).\n" \
|
|
73
|
+
'The pages ship with the gem; --install copies them into a man1 directory and ' \
|
|
74
|
+
'says how man(1) finds them.',
|
|
75
|
+
examples: man_examples,
|
|
76
|
+
positionals: [Positional.new(name: 'COMMAND', required: false, variadic: true,
|
|
77
|
+
completer: ->(given, _current) { subcommand_names(resolve.call, given) })],
|
|
78
|
+
options: man_options,
|
|
79
|
+
handler: ManCommand.new(resolve, man_dir:, exec:, paths:)
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.man_examples
|
|
84
|
+
[
|
|
85
|
+
Example.new(comment: 'Read the page of a command', command: 'man get'),
|
|
86
|
+
Example.new(comment: 'Install every page for man(1)', command: 'man --install'),
|
|
87
|
+
Example.new(comment: 'Print the bundled page directory', command: 'man --path')
|
|
88
|
+
]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.man_options
|
|
92
|
+
[
|
|
93
|
+
Option.new(long: 'path', description: 'Print the directory of the bundled pages and exit.'),
|
|
94
|
+
Option.new(long: 'install', argument: 'DIR', optional: true, implicit: true,
|
|
95
|
+
description: 'Copy every page into DIR, a man1 directory, or into ' \
|
|
96
|
+
'${XDG_DATA_HOME:-~/.local/share}/man/man1.')
|
|
97
|
+
]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.complete(resolve:)
|
|
101
|
+
Command.new(
|
|
102
|
+
name: '__complete', summary: 'Print completion candidates for the given words', hidden: true, raw: true,
|
|
103
|
+
positionals: [Positional.new(name: 'WORDS', required: false, variadic: true)],
|
|
104
|
+
handler: ->(context, words, opts) { Completer.new(resolve.call).call(context, words, opts) }
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.subcommand_names(registry, given)
|
|
109
|
+
registry.resolve(given).first.visible_subcommands.map(&:name)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# `resolve` returns the registry when called, since the builtin is built before the
|
|
113
|
+
# registry that holds it exists.
|
|
114
|
+
class HelpCommand
|
|
115
|
+
def initialize(resolve)
|
|
116
|
+
@resolve = resolve
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def call(context, words, _opts)
|
|
120
|
+
registry = @resolve.call
|
|
121
|
+
renderer = HelpRenderer.new(registry, context.style)
|
|
122
|
+
command, path = registry.resolve(words)
|
|
123
|
+
context.print(words.empty? ? renderer.root : renderer.command(command, path))
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
class ManCommand
|
|
128
|
+
# Any of these means the user configured the pager's look, so it is left alone.
|
|
129
|
+
USER_PAGER_VARIABLES = %w[LESS_TERMCAP_md MANPAGER MANROFFOPT GROFF_NO_SGR].freeze
|
|
130
|
+
RESET = "\e[0m"
|
|
131
|
+
NO_DEFAULT_DIR = 'no default install directory is configured; pass --install=DIR'
|
|
132
|
+
# man(1) looks for section 1 pages in the man1 directory of each MANPATH entry, so the
|
|
133
|
+
# MANPATH line printed for any other directory would find nothing.
|
|
134
|
+
SECTION_DIR = 'man1'
|
|
135
|
+
NOT_A_SECTION_DIR = "invalid argument %s for --install: must be a #{SECTION_DIR} directory, " \
|
|
136
|
+
"such as ~/.local/share/man/#{SECTION_DIR}".freeze
|
|
137
|
+
# An empty DIR would expand to the working directory.
|
|
138
|
+
INSTALL_EMPTY = 'flag --install must not be empty'
|
|
139
|
+
|
|
140
|
+
def initialize(resolve, man_dir:, exec:, paths:)
|
|
141
|
+
@resolve = resolve
|
|
142
|
+
@man_dir = man_dir
|
|
143
|
+
@exec = exec
|
|
144
|
+
@paths = paths
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def call(context, args, opts)
|
|
148
|
+
refuse_arguments(args, opts) if opts[:path] || opts[:install]
|
|
149
|
+
return context.puts(@man_dir) if opts[:path]
|
|
150
|
+
return install(context, opts[:install]) if opts[:install]
|
|
151
|
+
|
|
152
|
+
show(context, args)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def registry = @resolve.call
|
|
158
|
+
|
|
159
|
+
# --path and --install ignore COMMAND, and since DIR is optional, a DIR given after a
|
|
160
|
+
# space arrives as one.
|
|
161
|
+
def refuse_arguments(args, opts)
|
|
162
|
+
return if args.empty?
|
|
163
|
+
|
|
164
|
+
hint = '; pass the directory as --install=DIR' if opts[:install] == true
|
|
165
|
+
raise UsageError, "unexpected argument #{args.first.inspect}#{hint}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def show(context, words)
|
|
169
|
+
_, path = registry.resolve(words)
|
|
170
|
+
file = File.join(@man_dir, "#{[registry.program, *path].join('-')}.1")
|
|
171
|
+
raise Slipway::Error, "manual page #{File.basename(file)} not found in #{@man_dir}" unless File.file?(file)
|
|
172
|
+
raise Slipway::Error, missing_man_message(path) unless man?(context.env)
|
|
173
|
+
|
|
174
|
+
@exec.call(pager_env(context), 'man', file)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def missing_man_message(path)
|
|
178
|
+
"man(1) not found; run '#{[registry.program, 'help', *path].join(' ')}' instead"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def man?(env)
|
|
182
|
+
env.fetch('PATH', '').split(File::PATH_SEPARATOR).any? { File.executable?(File.join(it, 'man')) }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# LESS_TERMCAP colors only take effect when groff stops emitting SGR itself, hence
|
|
186
|
+
# GROFF_NO_SGR.
|
|
187
|
+
def pager_env(context)
|
|
188
|
+
return {} unless context.color? && USER_PAGER_VARIABLES.none? { set?(context.env[it]) }
|
|
189
|
+
|
|
190
|
+
theme = context.style.theme
|
|
191
|
+
{ 'GROFF_NO_SGR' => '1',
|
|
192
|
+
'LESS_TERMCAP_md' => sgr(theme.sgr(:help_header)), 'LESS_TERMCAP_me' => RESET,
|
|
193
|
+
'LESS_TERMCAP_us' => sgr("4;#{theme.sgr(:help_flag)}"), 'LESS_TERMCAP_ue' => RESET,
|
|
194
|
+
'LESS_TERMCAP_so' => sgr("7;#{theme.sgr(:status_warning)}"), 'LESS_TERMCAP_se' => RESET }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def sgr(parameters) = "\e[#{parameters}m"
|
|
198
|
+
|
|
199
|
+
def set?(value) = !value.to_s.empty?
|
|
200
|
+
|
|
201
|
+
def install(context, target)
|
|
202
|
+
paths = @paths&.call(context.env)
|
|
203
|
+
dir = target == true ? default_install_dir(paths) : named_install_dir(target)
|
|
204
|
+
pages = Dir[File.join(@man_dir, '*.1')]
|
|
205
|
+
raise Slipway::Error, "no manual pages found in #{@man_dir}" if pages.empty?
|
|
206
|
+
|
|
207
|
+
FileUtils.mkdir_p(dir)
|
|
208
|
+
pages.each do |page|
|
|
209
|
+
FileUtils.cp(page, dir)
|
|
210
|
+
context.puts("installed #{File.join(dir, File.basename(page))}")
|
|
211
|
+
end
|
|
212
|
+
context.puts(install_note(paths, dir))
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def default_install_dir(paths)
|
|
216
|
+
raise Slipway::Error, NO_DEFAULT_DIR if paths.nil?
|
|
217
|
+
|
|
218
|
+
paths.man_install_dir
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def named_install_dir(target)
|
|
222
|
+
raise UsageError, INSTALL_EMPTY if target.strip.empty?
|
|
223
|
+
|
|
224
|
+
dir = File.expand_path(target)
|
|
225
|
+
raise UsageError, format(NOT_A_SECTION_DIR, target.inspect) unless File.basename(dir) == SECTION_DIR
|
|
226
|
+
|
|
227
|
+
dir
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# man-db adds ~/.local/share/man on its own only when ~/.local/bin is on PATH.
|
|
231
|
+
def install_note(paths, dir)
|
|
232
|
+
if paths && dir == paths.man_db_dir
|
|
233
|
+
'man-db searches ~/.local/share/man when ~/.local/bin is on PATH; otherwise add it to MANPATH.'
|
|
234
|
+
else
|
|
235
|
+
%(export MANPATH="#{File.dirname(dir)}:$MANPATH")
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slipway
|
|
4
|
+
module CLI
|
|
5
|
+
# Speaks cobra's __complete protocol: one candidate per line, as `value` or
|
|
6
|
+
# `value<TAB>description`, then a final `:N` line. N is a sum of cobra's directives: 4 when
|
|
7
|
+
# the shell must not complete file names in place of the candidates, 2 when it must not add a
|
|
8
|
+
# space after the one it inserts, and 0 lets the shell complete file names.
|
|
9
|
+
#
|
|
10
|
+
# A completer proc on an Option or Positional receives the positional words typed so far and
|
|
11
|
+
# the word being completed, without a `--flag=` in front of it, and may return an Array of
|
|
12
|
+
# values, a Hash of value to description, either of them wrapped in NoSpace, or FILES to
|
|
13
|
+
# request file completion. The candidates are filtered by that word afterwards. Never raises:
|
|
14
|
+
# on any error only `:4` is printed.
|
|
15
|
+
class Completer
|
|
16
|
+
FILES = :files
|
|
17
|
+
FILES_DIRECTIVE = 0
|
|
18
|
+
NO_SPACE_DIRECTIVE = 2
|
|
19
|
+
NO_FILES_DIRECTIVE = 4
|
|
20
|
+
# Candidates the user goes on typing after, such as a field path that continues after a
|
|
21
|
+
# dot: the shell adds no space after the one it inserts. The directive covers the whole
|
|
22
|
+
# answer, so a completer returns NoSpace only when a candidate that matches the word goes
|
|
23
|
+
# on, and a finished value still gets its space.
|
|
24
|
+
NoSpace = Data.define(:candidates)
|
|
25
|
+
# bash splits `--flag=value` at the `=` (COMP_WORDBREAKS), so the separator may arrive
|
|
26
|
+
# as a word of its own between the flag and its value.
|
|
27
|
+
EQUALS = '='
|
|
28
|
+
INLINE_VALUE = /\A--[^=]+=/
|
|
29
|
+
|
|
30
|
+
def initialize(registry)
|
|
31
|
+
@registry = registry
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(context, words, _opts)
|
|
35
|
+
candidates, directive = safely { complete(words) }
|
|
36
|
+
candidates.each { |value, description| context.puts(description ? "#{value}\t#{description}" : value) }
|
|
37
|
+
context.puts(":#{directive}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def complete(words)
|
|
41
|
+
words = words.dup
|
|
42
|
+
current = words.pop || ''
|
|
43
|
+
state = replay(words)
|
|
44
|
+
return [[], NO_FILES_DIRECTIVE] unless state
|
|
45
|
+
|
|
46
|
+
case candidates_for(state, current)
|
|
47
|
+
in FILES then [[], FILES_DIRECTIVE]
|
|
48
|
+
in NoSpace(candidates:) then [select(candidates, state, current), NO_FILES_DIRECTIVE | NO_SPACE_DIRECTIVE]
|
|
49
|
+
in candidates then [select(candidates, state, current), NO_FILES_DIRECTIVE]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# `pending` is an option still waiting for its value; `literal` is set once `--` is seen.
|
|
56
|
+
State = Struct.new(:command, :args, :pending, :literal)
|
|
57
|
+
private_constant :State
|
|
58
|
+
|
|
59
|
+
def safely
|
|
60
|
+
yield
|
|
61
|
+
rescue StandardError
|
|
62
|
+
[[], NO_FILES_DIRECTIVE]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def replay(words)
|
|
66
|
+
words.reduce(State.new(@registry.root, [], nil, false)) { |state, word| consume(state, word) or return nil }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def consume(state, word)
|
|
70
|
+
if state.pending then consume_value(state, word)
|
|
71
|
+
elsif word == '--' && !state.literal then state.literal = true
|
|
72
|
+
elsif option_word?(word, state) then state.pending = pending_option(word, state)
|
|
73
|
+
elsif state.command.group? then state.command = state.command.find(word) or return nil
|
|
74
|
+
else state.args << word
|
|
75
|
+
end
|
|
76
|
+
state
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A lone `=` keeps the option open so the next word is still its value.
|
|
80
|
+
def consume_value(state, word)
|
|
81
|
+
state.pending = nil unless word == EQUALS
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def option_word?(word, state) = word.start_with?('-') && !state.literal
|
|
85
|
+
|
|
86
|
+
# An optional-argument option only takes its value attached, so it never waits.
|
|
87
|
+
def pending_option(word, state)
|
|
88
|
+
name = option_name(word)
|
|
89
|
+
return nil unless name
|
|
90
|
+
|
|
91
|
+
option = option_for(state.command, name)
|
|
92
|
+
option unless option.nil? || option.flag? || option.optional
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# nil when the value is attached: `--x=v`, `-ov`.
|
|
96
|
+
def option_name(word)
|
|
97
|
+
case word
|
|
98
|
+
when INLINE_VALUE then nil
|
|
99
|
+
when /\A--(.+)\z/, /\A-(.)\z/ then Regexp.last_match(1)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def option_for(command, name)
|
|
104
|
+
options_of(command).find { it.long == name || it.short == name }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def options_of(command) = @registry.globals + command.options
|
|
108
|
+
|
|
109
|
+
def candidates_for(state, current)
|
|
110
|
+
return values_of(state.pending, state.args, prefix(state, current)) if state.pending
|
|
111
|
+
return inline_value_candidates(state, current) if current.match?(INLINE_VALUE)
|
|
112
|
+
return switch_candidates(state.command) if option_word?(current, state)
|
|
113
|
+
return subcommand_candidates(state.command) if state.command.group?
|
|
114
|
+
|
|
115
|
+
values_of(state.command.positional_at(state.args.size), state.args, current)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def values_of(target, args, current)
|
|
119
|
+
within(target&.candidates(args, current) || []) do |values|
|
|
120
|
+
values.is_a?(Hash) ? values.to_a : values.map { [it, nil] }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Values for `--flag=partial` carry the `--flag=` prefix so they replace the whole word.
|
|
125
|
+
def inline_value_candidates(state, current)
|
|
126
|
+
flag, typed = current.split(EQUALS, 2)
|
|
127
|
+
values = values_of(option_for(state.command, flag.delete_prefix('--')), state.args, typed)
|
|
128
|
+
within(values) { |pairs| pairs.map { |value, description| ["#{flag}=#{value}", description] } }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Yields the candidates, unwrapped from a NoSpace and wrapped back; FILES passes through.
|
|
132
|
+
def within(values)
|
|
133
|
+
case values
|
|
134
|
+
in FILES then FILES
|
|
135
|
+
in NoSpace(candidates:) then NoSpace.new(yield(candidates))
|
|
136
|
+
else yield(values)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def switch_candidates(command)
|
|
141
|
+
options_of(command).flat_map { |option| option.switches.map { [it, option.description] } }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def subcommand_candidates(command)
|
|
145
|
+
command.visible_subcommands.map { [it.name, it.summary] }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def prefix(state, current)
|
|
149
|
+
state.pending && current == EQUALS ? '' : current
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def select(candidates, state, current)
|
|
153
|
+
typed = prefix(state, current)
|
|
154
|
+
candidates.select { |value, _| value.start_with?(typed) && !state.args.include?(value) }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slipway
|
|
4
|
+
module CLI
|
|
5
|
+
# Every request goes to `PROGRAM __complete WORDS...`, so the scripts never change when
|
|
6
|
+
# commands do.
|
|
7
|
+
module CompletionScripts
|
|
8
|
+
SHELLS = %w[bash zsh fish].freeze
|
|
9
|
+
|
|
10
|
+
def self.render(shell, program)
|
|
11
|
+
RENDERERS.fetch(shell).render(program)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# _comp_initialize is bash-completion 2.12+ and _init_completion the older name; without
|
|
15
|
+
# either, COMP_WORDS is read directly.
|
|
16
|
+
module Bash
|
|
17
|
+
def self.render(program)
|
|
18
|
+
<<~BASH
|
|
19
|
+
# bash completion for #{program} -*- shell-script -*-
|
|
20
|
+
# Install to ${XDG_DATA_HOME:-~/.local/share}/bash-completion/completions/#{program}
|
|
21
|
+
# or load it with: eval "$(#{program} completion bash)"
|
|
22
|
+
_#{program}() {
|
|
23
|
+
# prev is filled by the bash-completion initializers; it stays local, not global.
|
|
24
|
+
# shellcheck disable=SC2034
|
|
25
|
+
local cur prev words cword
|
|
26
|
+
if declare -F _comp_initialize >/dev/null 2>&1; then
|
|
27
|
+
_comp_initialize -n = -- "$@" || return
|
|
28
|
+
elif declare -F _init_completion >/dev/null 2>&1; then
|
|
29
|
+
_init_completion -n = || return
|
|
30
|
+
else
|
|
31
|
+
words=("${COMP_WORDS[@]}") cword=$COMP_CWORD cur=${COMP_WORDS[COMP_CWORD]}
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
local out directive
|
|
35
|
+
out=$("${words[0]}" __complete "${words[@]:1:cword-1}" "$cur" 2>/dev/null) || return
|
|
36
|
+
directive=${out##*$'\\n':}
|
|
37
|
+
[[ $out == :* ]] && directive=${out#:}
|
|
38
|
+
out=${out%$'\\n'*}
|
|
39
|
+
[[ $out == :* ]] && out=""
|
|
40
|
+
|
|
41
|
+
COMPREPLY=()
|
|
42
|
+
local line
|
|
43
|
+
if [[ -n $out ]]; then
|
|
44
|
+
while IFS= read -r line; do COMPREPLY+=("${line%%$'\\t'*}"); done <<<"$out"
|
|
45
|
+
fi
|
|
46
|
+
# readline still breaks the word at "=", so "--flag=" must leave the replies.
|
|
47
|
+
if [[ $cur == -*=* && $COMP_WORDBREAKS == *=* ]]; then
|
|
48
|
+
local i prefix=${cur%%=*}=
|
|
49
|
+
for i in "${!COMPREPLY[@]}"; do COMPREPLY[i]=${COMPREPLY[i]#"$prefix"}; done
|
|
50
|
+
fi
|
|
51
|
+
(( directive & 2 )) && compopt -o nospace 2>/dev/null
|
|
52
|
+
if (( ${#COMPREPLY[@]} == 0 )) && ! (( directive & 4 )); then
|
|
53
|
+
if declare -F _comp_compgen_filedir >/dev/null 2>&1; then
|
|
54
|
+
_comp_compgen_filedir
|
|
55
|
+
elif declare -F _filedir >/dev/null 2>&1; then
|
|
56
|
+
_filedir
|
|
57
|
+
elif ! compopt -o default 2>/dev/null; then
|
|
58
|
+
mapfile -t COMPREPLY < <(compgen -f -- "$cur")
|
|
59
|
+
fi
|
|
60
|
+
fi
|
|
61
|
+
}
|
|
62
|
+
complete -F _#{program} #{program}
|
|
63
|
+
BASH
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Builds `value:description` pairs for _describe; a `--flag=` prefix is moved into
|
|
68
|
+
# IPREFIX with compset so the values alone are listed.
|
|
69
|
+
module Zsh
|
|
70
|
+
def self.render(program)
|
|
71
|
+
<<~ZSH
|
|
72
|
+
#compdef #{program}
|
|
73
|
+
# zsh completion for #{program}. Install it as _#{program} in a directory on your fpath,
|
|
74
|
+
# such as ~/.zfunc/_#{program} with `fpath+=~/.zfunc` before compinit, or load it
|
|
75
|
+
# with: source <(#{program} completion zsh)
|
|
76
|
+
compdef _#{program} #{program}
|
|
77
|
+
|
|
78
|
+
_#{program}() {
|
|
79
|
+
local -a lines candidates describe_opts
|
|
80
|
+
local line directive prefix="" ret=1
|
|
81
|
+
lines=("${(@f)$(${words[1]} __complete "${(@)words[2,CURRENT-1]}" "${words[CURRENT]}" 2>/dev/null)}")
|
|
82
|
+
(( ${#lines} )) || return 1
|
|
83
|
+
directive=${lines[-1]#:}
|
|
84
|
+
lines=("${(@)lines[1,-2]}")
|
|
85
|
+
compset -P '--[^=]#=' && prefix=$IPREFIX
|
|
86
|
+
for line in "${lines[@]}"; do
|
|
87
|
+
[[ -z $line ]] && continue
|
|
88
|
+
line=${line#"$prefix"}
|
|
89
|
+
if [[ $line == *$'\\t'* ]]; then
|
|
90
|
+
candidates+=("${${line%%$'\\t'*}//:/\\\\:}:${line#*$'\\t'}")
|
|
91
|
+
else
|
|
92
|
+
candidates+=("${line//:/\\\\:}")
|
|
93
|
+
fi
|
|
94
|
+
done
|
|
95
|
+
(( directive & 2 )) && describe_opts+=(-S '')
|
|
96
|
+
if (( ${#candidates} )); then
|
|
97
|
+
_describe -t values '#{program}' candidates "${describe_opts[@]}" && ret=0
|
|
98
|
+
fi
|
|
99
|
+
if (( ret )) && ! (( directive & 4 )); then
|
|
100
|
+
_files && ret=0
|
|
101
|
+
fi
|
|
102
|
+
return ret
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if [[ "$funcstack[1]" == "_#{program}" ]]; then
|
|
106
|
+
_#{program} "$@"
|
|
107
|
+
fi
|
|
108
|
+
ZSH
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Both completion conditions call __PROGRAM_complete, so the answer is cached per command
|
|
113
|
+
# line. fish reads `value<TAB>description` lines natively. It has no switch to leave out the
|
|
114
|
+
# space after a candidate: it adds none only after one that ends in one of @=/:., or while
|
|
115
|
+
# several remain. So, as cobra does, a lone candidate under the no-space directive gets a
|
|
116
|
+
# copy with a dot after it, and fish inserts what the two share.
|
|
117
|
+
module Fish
|
|
118
|
+
def self.render(program)
|
|
119
|
+
<<~FISH
|
|
120
|
+
# fish completion for #{program}. Install it to ~/.config/fish/completions/#{program}.fish
|
|
121
|
+
function __#{program}_complete
|
|
122
|
+
set -l line (commandline -cp)
|
|
123
|
+
if set -q __#{program}_line; and test "$__#{program}_line" = "$line"
|
|
124
|
+
return 0
|
|
125
|
+
end
|
|
126
|
+
set -g __#{program}_line $line
|
|
127
|
+
set -g __#{program}_directive 4
|
|
128
|
+
set -g __#{program}_results
|
|
129
|
+
set -l tokens (commandline -opc)
|
|
130
|
+
set -l program $tokens[1]
|
|
131
|
+
set -e tokens[1]
|
|
132
|
+
set -l lines ($program __complete $tokens (commandline -ct) 2>/dev/null)
|
|
133
|
+
or return 0
|
|
134
|
+
if test (count $lines) -gt 0
|
|
135
|
+
set -g __#{program}_directive (string replace -r '^:' '' -- $lines[-1])
|
|
136
|
+
set -e lines[-1]
|
|
137
|
+
set -g __#{program}_results $lines
|
|
138
|
+
end
|
|
139
|
+
if test (count $__#{program}_results) -eq 1; and test (math "bitand($__#{program}_directive, 2)") -ne 0
|
|
140
|
+
set -l value (string split -m 1 \\t -- $__#{program}_results[1])[1]
|
|
141
|
+
if not string match -qr '[@=/:.,]$' -- $value
|
|
142
|
+
set -g __#{program}_results $value $value.
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
function __#{program}_wants_files
|
|
148
|
+
__#{program}_complete
|
|
149
|
+
test (count $__#{program}_results) -eq 0
|
|
150
|
+
and test (math "bitand($__#{program}_directive, 4)") -eq 0
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
complete -c #{program} -e
|
|
154
|
+
complete -c #{program} -n '__#{program}_complete' -f -a '$__#{program}_results'
|
|
155
|
+
complete -c #{program} -n '__#{program}_wants_files' -a '(__fish_complete_path (commandline -ct))'
|
|
156
|
+
FISH
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
RENDERERS = { 'bash' => Bash, 'zsh' => Zsh, 'fish' => Fish }.freeze
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'style'
|
|
4
|
+
require_relative 'theme'
|
|
5
|
+
|
|
6
|
+
module Slipway
|
|
7
|
+
module CLI
|
|
8
|
+
# Color is decided for stdout and stderr apart. A stream whose reader went away is
|
|
9
|
+
# remembered and its later output dropped, so a command finishes its work when `head`
|
|
10
|
+
# exits or a wrapper closed stderr.
|
|
11
|
+
class Context
|
|
12
|
+
attr_reader :out, :err, :env, :tty, :err_tty, :style, :err_style
|
|
13
|
+
|
|
14
|
+
def self.system
|
|
15
|
+
new(out: $stdout, err: $stderr, env: ENV, tty: $stdout.tty?, err_tty: $stderr.tty?)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(out:, err:, env: {}, tty: false, err_tty: false,
|
|
19
|
+
style: Style.disabled, err_style: Style.disabled)
|
|
20
|
+
@out = out
|
|
21
|
+
@err = err
|
|
22
|
+
@env = env
|
|
23
|
+
@tty = tty
|
|
24
|
+
@err_tty = err_tty
|
|
25
|
+
@style = style
|
|
26
|
+
@err_style = err_style
|
|
27
|
+
@closed = {}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The copy shares this context's memory of closed streams.
|
|
31
|
+
def with_color(mode, theme: Theme.default)
|
|
32
|
+
dup.tap { it.restyle(Style.for(mode, tty:, env:, theme:), Style.for(mode, tty: err_tty, env:, theme:)) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def puts(*lines) = write(:out) { out.puts(*lines) }
|
|
36
|
+
|
|
37
|
+
def print(*parts) = write(:out) { out.print(*parts) }
|
|
38
|
+
|
|
39
|
+
def warn(*lines) = write(:err) { err.puts(*lines) }
|
|
40
|
+
|
|
41
|
+
def unbuffer = out.sync = true
|
|
42
|
+
|
|
43
|
+
def paint(role, text) = style.paint(role, text)
|
|
44
|
+
|
|
45
|
+
def paint_err(role, text) = err_style.paint(role, text)
|
|
46
|
+
|
|
47
|
+
def color? = style.enabled?
|
|
48
|
+
|
|
49
|
+
protected
|
|
50
|
+
|
|
51
|
+
def restyle(style, err_style)
|
|
52
|
+
@style = style
|
|
53
|
+
@err_style = err_style
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def write(stream)
|
|
59
|
+
return if @closed[stream]
|
|
60
|
+
|
|
61
|
+
yield
|
|
62
|
+
rescue Errno::EPIPE
|
|
63
|
+
@closed[stream] = true
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../error'
|
|
4
|
+
|
|
5
|
+
module Slipway
|
|
6
|
+
module CLI
|
|
7
|
+
class UsageError < Slipway::Error
|
|
8
|
+
# A nil hint is filled in by the Runner from the resolved command path.
|
|
9
|
+
attr_reader :hint
|
|
10
|
+
|
|
11
|
+
def initialize(message, hint: nil)
|
|
12
|
+
super(message)
|
|
13
|
+
@hint = hint
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def exit_status = 2
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|