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.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +7 -0
  3. data/CHANGELOG.md +45 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +1013 -0
  6. data/exe/slipway +10 -0
  7. data/lib/slipway/cli/builtins.rb +241 -0
  8. data/lib/slipway/cli/completer.rb +158 -0
  9. data/lib/slipway/cli/completion_scripts.rb +163 -0
  10. data/lib/slipway/cli/context.rb +67 -0
  11. data/lib/slipway/cli/errors.rb +19 -0
  12. data/lib/slipway/cli/globals.rb +27 -0
  13. data/lib/slipway/cli/help_renderer.rb +135 -0
  14. data/lib/slipway/cli/manpage.rb +226 -0
  15. data/lib/slipway/cli/parser.rb +45 -0
  16. data/lib/slipway/cli/registry.rb +191 -0
  17. data/lib/slipway/cli/runner.rb +186 -0
  18. data/lib/slipway/cli/style.rb +82 -0
  19. data/lib/slipway/cli/theme.rb +85 -0
  20. data/lib/slipway/cli/validator.rb +61 -0
  21. data/lib/slipway/cli.rb +22 -0
  22. data/lib/slipway/command_line.rb +22 -0
  23. data/lib/slipway/commands/api_resources.rb +82 -0
  24. data/lib/slipway/commands/apply.rb +172 -0
  25. data/lib/slipway/commands/base.rb +50 -0
  26. data/lib/slipway/commands/config.rb +73 -0
  27. data/lib/slipway/commands/create.rb +218 -0
  28. data/lib/slipway/commands/delete.rb +82 -0
  29. data/lib/slipway/commands/describe.rb +74 -0
  30. data/lib/slipway/commands/diff.rb +122 -0
  31. data/lib/slipway/commands/edit.rb +130 -0
  32. data/lib/slipway/commands/explain.rb +97 -0
  33. data/lib/slipway/commands/fetch.rb +112 -0
  34. data/lib/slipway/commands/from_dir.rb +141 -0
  35. data/lib/slipway/commands/get.rb +167 -0
  36. data/lib/slipway/commands/label.rb +114 -0
  37. data/lib/slipway/commands/manual.rb +67 -0
  38. data/lib/slipway/commands/options.rb +73 -0
  39. data/lib/slipway/commands/results.rb +57 -0
  40. data/lib/slipway/commands/rollout.rb +114 -0
  41. data/lib/slipway/commands/rollout_spec.rb +99 -0
  42. data/lib/slipway/commands/rollout_undo.rb +126 -0
  43. data/lib/slipway/commands/scope.rb +156 -0
  44. data/lib/slipway/commands/sync.rb +140 -0
  45. data/lib/slipway/commands.rb +54 -0
  46. data/lib/slipway/drift.rb +87 -0
  47. data/lib/slipway/editor.rb +71 -0
  48. data/lib/slipway/error.rb +27 -0
  49. data/lib/slipway/fetcher.rb +99 -0
  50. data/lib/slipway/field_selector.rb +86 -0
  51. data/lib/slipway/git/branch_name.rb +32 -0
  52. data/lib/slipway/git/commit.rb +13 -0
  53. data/lib/slipway/git/distance.rb +13 -0
  54. data/lib/slipway/git/errors.rb +125 -0
  55. data/lib/slipway/git/fake.rb +147 -0
  56. data/lib/slipway/git/fast_forward.rb +12 -0
  57. data/lib/slipway/git/fast_forwarding.rb +148 -0
  58. data/lib/slipway/git/fetch_result.rb +22 -0
  59. data/lib/slipway/git/move_back.rb +12 -0
  60. data/lib/slipway/git/reflog.rb +25 -0
  61. data/lib/slipway/git/repository.rb +288 -0
  62. data/lib/slipway/git/rolling_back.rb +98 -0
  63. data/lib/slipway/git/runner.rb +175 -0
  64. data/lib/slipway/git/status.rb +110 -0
  65. data/lib/slipway/git/url.rb +95 -0
  66. data/lib/slipway/git.rb +20 -0
  67. data/lib/slipway/inspector.rb +103 -0
  68. data/lib/slipway/labels.rb +126 -0
  69. data/lib/slipway/manifest.rb +265 -0
  70. data/lib/slipway/names.rb +22 -0
  71. data/lib/slipway/outcome.rb +45 -0
  72. data/lib/slipway/output/age.rb +70 -0
  73. data/lib/slipway/output/describe.rb +71 -0
  74. data/lib/slipway/output/explain.rb +75 -0
  75. data/lib/slipway/output/serializer.rb +35 -0
  76. data/lib/slipway/output/table.rb +67 -0
  77. data/lib/slipway/output.rb +28 -0
  78. data/lib/slipway/paths.rb +65 -0
  79. data/lib/slipway/plan.rb +227 -0
  80. data/lib/slipway/pool.rb +94 -0
  81. data/lib/slipway/resources.rb +91 -0
  82. data/lib/slipway/rollback.rb +236 -0
  83. data/lib/slipway/rollout_history.rb +69 -0
  84. data/lib/slipway/runtime.rb +65 -0
  85. data/lib/slipway/scanner.rb +54 -0
  86. data/lib/slipway/schema.rb +128 -0
  87. data/lib/slipway/selector.rb +146 -0
  88. data/lib/slipway/settings.rb +174 -0
  89. data/lib/slipway/state.rb +82 -0
  90. data/lib/slipway/store.rb +170 -0
  91. data/lib/slipway/syncer.rb +139 -0
  92. data/lib/slipway/version.rb +5 -0
  93. data/lib/slipway/views/group.rb +35 -0
  94. data/lib/slipway/views/project.rb +148 -0
  95. data/lib/slipway/views.rb +10 -0
  96. data/lib/slipway/yaml.rb +14 -0
  97. data/lib/slipway.rb +32 -0
  98. data/man/man1/slipway-api-resources.1 +53 -0
  99. data/man/man1/slipway-apply.1 +45 -0
  100. data/man/man1/slipway-completion.1 +29 -0
  101. data/man/man1/slipway-config-path.1 +20 -0
  102. data/man/man1/slipway-config-view.1 +25 -0
  103. data/man/man1/slipway-config.1 +22 -0
  104. data/man/man1/slipway-create.1 +89 -0
  105. data/man/man1/slipway-delete.1 +46 -0
  106. data/man/man1/slipway-describe.1 +95 -0
  107. data/man/man1/slipway-diff.1 +120 -0
  108. data/man/man1/slipway-edit.1 +34 -0
  109. data/man/man1/slipway-explain.1 +36 -0
  110. data/man/man1/slipway-fetch.1 +77 -0
  111. data/man/man1/slipway-get.1 +155 -0
  112. data/man/man1/slipway-help.1 +19 -0
  113. data/man/man1/slipway-label.1 +53 -0
  114. data/man/man1/slipway-man.1 +36 -0
  115. data/man/man1/slipway-rollout-history.1 +28 -0
  116. data/man/man1/slipway-rollout-pause.1 +21 -0
  117. data/man/man1/slipway-rollout-resume.1 +21 -0
  118. data/man/man1/slipway-rollout-undo.1 +73 -0
  119. data/man/man1/slipway-rollout-unpin.1 +21 -0
  120. data/man/man1/slipway-rollout.1 +36 -0
  121. data/man/man1/slipway-sync.1 +90 -0
  122. data/man/man1/slipway-version.1 +17 -0
  123. data/man/man1/slipway.1 +243 -0
  124. 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